06가지치며 뻗어나가는 번개와 화면 플래시
번개 줄기의 중간 지점을 반복해서 어긋나게 갈라 가지 모양 경로를 그리고, 동시에 화면 밝기를 빠르게 올렸다가 천천히 내리는 곡선으로 계산해 번쩍이는 순간을 만든다.
쓰임극적인 폭풍 히어로 배경
LIGHTNING_FLASH_DECAY_MSbranchLightningLIGHTNING_BRANCH_DEPTH

<main class="stage">
<div class="rn is-demo" id="rnRoot">
<canvas class="rn__canvas" id="rnCanvas" width="480" height="300"></canvas>
<p class="rn__caption">번개 줄기의 중간 지점을 반복해서 어긋나게 갈라 가지 모양 경로를 그리고, 동시에 화면 밝기를 빠르게 올렸다가 천천히 내리는 곡선으로 계산해 번쩍이는 순간을 만든다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.rn {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: #14110a;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rn { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rn {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #14110a;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rn {
cursor: default;
}
}(function () {
// 기법: branchLightning() 이 매 낙뢰마다 재귀로 가지를 새로 뻗는다(깊이 LIGHTNING_BRANCH_DEPTH).
// 낙뢰 순간의 흰 섬광은 LIGHTNING_FLASH_DECAY_MS 를 시상수로 하는 지수 감쇠(Math.exp)로 사그라들고,
// 번개 선 자체는 그보다 긴 LIGHTNING_BOLT_FADE_MS 동안 잔상으로 더 남는다.
var root = document.getElementById('rnRoot');
var canvas = document.getElementById('rnCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LIGHTNING_STRIKE_PERIOD_MS = 1500;
var LIGHTNING_FLASH_DECAY_MS = 220;
var LIGHTNING_BOLT_FADE_MS = 900;
var LIGHTNING_BRANCH_DEPTH = 4;
var RAIN_STREAK_COUNT = 50;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431061);
var streaks = [];
for (var i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 110 + rRand() * 70, thick: 0.6 + rRand() * 1 });
}
function branchLightning(segs, x, y, ang, len, depth) {
if (depth <= 0 || len < 3) return;
var steps = 3 + Math.floor(rRand() * 2);
var cx = x, cy = y, cang = ang;
for (var i = 0; i < steps; i += 1) {
cang += (rRand() - 0.5) * 0.9;
var stepLen = len / steps;
var nx = cx + Math.sin(cang) * stepLen;
var ny = cy + Math.cos(cang) * stepLen;
segs.push({ x0: cx, y0: cy, x1: nx, y1: ny, depth: depth });
if (depth > 1 && rRand() < 0.3) {
var side = rRand() < 0.5 ? -1 : 1;
branchLightning(segs, nx, ny, cang + side * (0.6 + rRand() * 0.5), len * 0.5, depth - 1);
}
cx = nx; cy = ny;
}
}
function makeBolt() {
var segs = [];
var startX = 24 + rRand() * 112;
branchLightning(segs, startX, 0, 0, CALC_H * 0.94, LIGHTNING_BRANCH_DEPTH);
return segs;
}
var bolt = [];
var strikeAtMs = -99999;
var lastStrikeIndex = -1;
function updateStrike(nowMs) {
var idx = Math.floor(nowMs / LIGHTNING_STRIKE_PERIOD_MS);
if (idx !== lastStrikeIndex) {
lastStrikeIndex = idx;
bolt = makeBolt();
strikeAtMs = nowMs;
}
}
function stepSim(dtS, nowMs) {
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
s.y += s.speed * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; s.x = rRand() * CALC_W; }
}
updateStrike(nowMs);
}
var cw = 480, ch = 300;
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var sinceStrike = nowMs - strikeAtMs;
var flash = Math.exp(-Math.max(0, sinceStrike) / LIGHTNING_FLASH_DECAY_MS);
var boltAlpha = Math.exp(-Math.max(0, sinceStrike) / LIGHTNING_BOLT_FADE_MS);
ctx.fillStyle = 'rgba(20,17,10,1)';
ctx.fillRect(0, 0, cw, ch);
if (flash > 0.02) {
ctx.fillStyle = 'rgba(255,250,230,' + (flash * 0.55).toFixed(3) + ')';
ctx.fillRect(0, 0, cw, ch);
}
ctx.strokeStyle = 'rgba(214,228,255,0.45)';
ctx.lineWidth = Math.max(0.6, 0.8 * ((sx + sy) / 2));
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 4) * sy;
ctx.beginPath();
ctx.moveTo(x, y0);
ctx.lineTo(x, y1);
ctx.stroke();
}
if (boltAlpha > 0.03 && bolt.length) {
ctx.save();
ctx.strokeStyle = 'rgba(255,252,240,' + boltAlpha.toFixed(3) + ')';
ctx.shadowColor = 'rgba(255,246,200,' + (boltAlpha * 0.8).toFixed(3) + ')';
ctx.shadowBlur = 8 * ((sx + sy) / 2);
for (var b = 0; b < bolt.length; b += 1) {
var seg = bolt[b];
ctx.lineWidth = Math.max(0.8, (seg.depth) * ((sx + sy) / 2) * 0.55);
ctx.beginPath();
ctx.moveTo(seg.x0 * sx, seg.y0 * sy);
ctx.lineTo(seg.x1 * sx, seg.y1 * sy);
ctx.stroke();
}
ctx.restore();
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 30 : 40;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(0.02, elapsed); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt / 1000, elapsed);
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RainEffectCanvasLightningBranchFlash.module.scss";
type Streak = { x: number; y: number; speed: number; thick: number };
type Seg = { x0: number; y0: number; x1: number; y1: number; depth: number };
export default function RainEffectCanvasLightningBranchFlash() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const LIGHTNING_STRIKE_PERIOD_MS = 1500;
const LIGHTNING_FLASH_DECAY_MS = 220;
const LIGHTNING_BOLT_FADE_MS = 900;
const LIGHTNING_BRANCH_DEPTH = 4;
const RAIN_STREAK_COUNT = 50;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431062);
const streaks: Streak[] = [];
for (let i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 110 + rRand() * 70, thick: 0.6 + rRand() * 1 });
}
function branchLightning(segs: Seg[], x: number, y: number, ang: number, len: number, depth: number) {
if (depth <= 0 || len < 3) return;
const steps = 3 + Math.floor(rRand() * 2);
let cx = x, cy = y, cang = ang;
for (let i = 0; i < steps; i += 1) {
cang += (rRand() - 0.5) * 0.9;
const stepLen = len / steps;
const nx = cx + Math.sin(cang) * stepLen;
const ny = cy + Math.cos(cang) * stepLen;
segs.push({ x0: cx, y0: cy, x1: nx, y1: ny, depth });
if (depth > 1 && rRand() < 0.3) {
const side = rRand() < 0.5 ? -1 : 1;
branchLightning(segs, nx, ny, cang + side * (0.6 + rRand() * 0.5), len * 0.5, depth - 1);
}
cx = nx; cy = ny;
}
}
function makeBolt() {
const segs: Seg[] = [];
const startX = 24 + rRand() * 112;
branchLightning(segs, startX, 0, 0, CALC_H * 0.94, LIGHTNING_BRANCH_DEPTH);
return segs;
}
let bolt: Seg[] = [];
let strikeAtMs = -99999;
let lastStrikeIndex = -1;
function updateStrike(nowMs: number) {
const idx = Math.floor(nowMs / LIGHTNING_STRIKE_PERIOD_MS);
if (idx !== lastStrikeIndex) {
lastStrikeIndex = idx;
bolt = makeBolt();
strikeAtMs = nowMs;
}
}
function stepSim(dtS: number, nowMs: number) {
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
s.y += s.speed * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; s.x = rRand() * CALC_W; }
}
updateStrike(nowMs);
}
let cw = 480, ch = 300;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const sinceStrike = nowMs - strikeAtMs;
const flash = Math.exp(-Math.max(0, sinceStrike) / LIGHTNING_FLASH_DECAY_MS);
const boltAlpha = Math.exp(-Math.max(0, sinceStrike) / LIGHTNING_BOLT_FADE_MS);
ctx!.fillStyle = "rgba(20,17,10,1)";
ctx!.fillRect(0, 0, cw, ch);
if (flash > 0.02) {
ctx!.fillStyle = "rgba(255,250,230," + (flash * 0.55).toFixed(3) + ")";
ctx!.fillRect(0, 0, cw, ch);
}
ctx!.strokeStyle = "rgba(214,228,255,0.45)";
ctx!.lineWidth = Math.max(0.6, 0.8 * ((sx + sy) / 2));
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 4) * sy;
ctx!.beginPath();
ctx!.moveTo(x, y0);
ctx!.lineTo(x, y1);
ctx!.stroke();
}
if (boltAlpha > 0.03 && bolt.length) {
ctx!.save();
ctx!.strokeStyle = "rgba(255,252,240," + boltAlpha.toFixed(3) + ")";
ctx!.shadowColor = "rgba(255,246,200," + (boltAlpha * 0.8).toFixed(3) + ")";
ctx!.shadowBlur = 8 * ((sx + sy) / 2);
for (let b = 0; b < bolt.length; b += 1) {
const seg = bolt[b];
ctx!.lineWidth = Math.max(0.8, seg.depth * ((sx + sy) / 2) * 0.55);
ctx!.beginPath();
ctx!.moveTo(seg.x0 * sx, seg.y0 * sy);
ctx!.lineTo(seg.x1 * sx, seg.y1 * sy);
ctx!.stroke();
}
ctx!.restore();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 30 : 40;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(0.02, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt / 1000, elapsed);
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.rn} is-demo`} ref={rootRef}>
<canvas className={styles.rn__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rn__caption}>번개 줄기의 중간 지점을 반복해서 어긋나게 갈라 가지 모양 경로를 그리고, 동시에 화면 밝기를 빠르게 올렸다가 천천히 내리는 곡선으로 계산해 번쩍이는 순간을 만든다</p>
</div>
</main>
);
}06 가지치며 뻗어나가는 번개와 화면 플래시 — 측정 누적면적 73.354% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 비 내리는 효과 9가지 — 창밖 폭풍 캔버스 장면
이 데모가 속한 글
css 비 내리는 효과 9가지 — 창밖 폭풍 캔버스 장면이 배경 장면에서는 바람 벡터 값 하나가 아홉 층 빗줄기의 기울기와 속도를 동시에 결정한다. 아래 아홉 개는 css 비 내리는 효과를 캔버스 2d 위에서 픽셀 단위로 계산해 그린 장면이며, 파일을 내려받으면 그대로 이 글의 데모 전부 보기

