03폭풍 하늘 — 무작위 간격으로 겹쳐 치는 여러 낙뢰
각 번개마다 다음 발생까지의 대기시간을 지수분포로 뽑아 저장해 두면, 짧게 몰아치다 길게 쉬는 실제 낙뢰의 불규칙한 리듬이 캔버스 여러 지점에서 겹쳐 재현됩니다.
쓰임다크 모드 히어로의 배경 신, 페이지 전환 로딩 연출
lineToglobalCompositeOperationrequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>다음 낙뢰까지의 대기시간을 지수분포로 뽑으면 폭풍이 실제처럼 몰아치다 쉰다 — 평균 간격이 짧을수록 몰아침이 잦아집니다. 먼 번개는 하늘 빛만 바뀌고 가까운 번개는 줄기가 그대로 보입니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="폭풍 하늘 다회 낙뢰 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">평균 <b class="cd__val" id="cdN">0.5</b>초 간격 · 지수분포 폭풍</span>
<span class="cd__meta cd__meta--dim">canvas · lineTo · globalCompositeOperation</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 폭풍 하늘 다회 낙뢰(03). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(지수분포 대기 + 원근 낙뢰 + 구름 실루엣)가 만든다.
// 파스텔 팔레트 --bolt-* 는 편 공통 규격(01~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD9~11 원칙) ──
$bolt-ink: #1b1e26;
$bolt-dusk: #3d4358;
$bolt-core: #f6e7ff;
$bolt-lilac: #b9a0ff;
$bolt-mint: #b9ddd1;
$bolt-pink: #ffb9c8;
$bolt-mist: #d8dce6;
.cd {
--bolt-ink: #{$bolt-ink};
--bolt-dusk: #{$bolt-dusk};
--bolt-core: #{$bolt-core};
--bolt-lilac: #{$bolt-lilac};
--bolt-mint: #{$bolt-mint};
--bolt-pink: #{$bolt-pink};
--bolt-mist: #{$bolt-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $bolt-mist;
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $bolt-ink;
box-shadow: inset 0 0 0 1px rgba($bolt-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $bolt-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($bolt-mist, .72); }
.cd__val { color: $bolt-lilac; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 없이 낙뢰 한 방의 한 장.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--bolt-ink: #1b1e26;
--bolt-dusk: #3d4358;
--bolt-core: #f6e7ff;
--bolt-lilac: #b9a0ff;
--bolt-mint: #b9ddd1;
--bolt-pink: #ffb9c8;
--bolt-mist: #d8dce6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #b9a0ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 폭풍 하늘 다회 낙뢰(03). 다음 낙뢰까지의 대기를 지수분포(-ln(u)*MEAN)로 뽑아 몰아침-고요 리듬을 만든다.
// 폭풍 강도(쿨다운)가 사인파로 천천히 오르내려 파도 치는 밤이 되고, 각 낙뢰는 거리(원근)를 가진다 —
// 먼 번개는 줄기 대신 하늘 면적이 확 밝아지는 섬광, 가까운 번개는 중점변위 줄기 + 구름 윤곽 실루엣.
// lighter 합성으로 섬광과 줄기 광이 겹쳐 쌓인다. 다크 모드 히어로 배경용.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var MEAN = 0.5; // 지수분포 평균 간격(초) — 작을수록 몰아침
var STORM_A = 0.5; // 폭풍 강도 진폭 — 몰아침-고요의 대비
var FARMAX = 1.0; // 원근 최대 거리 — 1 은 먼 번개까지, 0.6 은 대부분 가까운 번개
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 폭풍으로 흐른다
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x316C03);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--bolt-ink');
var C_DUSK = tokRGB('--bolt-dusk');
var C_CORE = tokRGB('--bolt-core');
var C_LILA = tokRGB('--bolt-lilac');
var C_MINT = tokRGB('--bolt-mint');
var C_PINK = tokRGB('--bolt-pink');
var C_MIST = tokRGB('--bolt-mist');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var DEPTH = 6;
var ROUGH = 0.4;
// ── 중점변위 ──
function displace(ax, ay, bx, by, depth, out) {
if (depth === 0) { out.push([bx, by]); return; }
var mx = (ax + bx) / 2, my = (ay + by) / 2;
var dx = bx - ax, dy = by - ay;
var len = Math.hypot(dx, dy) || 1;
var nx = -dy / len, ny = dx / len;
var off = (rand() * 2 - 1) * len * ROUGH * 0.5;
mx += nx * off; my += ny * off;
displace(ax, ay, mx, my, depth - 1, out);
displace(mx, my, bx, by, depth - 1, out);
}
// ── 구름 — 층층이 쌓인 실루엣. 섬광에 따라 명도가 오르내리고, 느린 표류로 계속 움직인다 ──
var clouds = [];
function buildClouds() {
clouds = [];
var layers = 3;
for (var L = 0; L < layers; L++) {
var lobes = [];
var count = 6 + L * 2;
for (var i = 0; i < count; i++) {
lobes.push({
rx: (i + 0.5) / count + (rand() - 0.5) * 0.06,
ry: 0.08 + L * 0.05 + (rand() - 0.5) * 0.03,
r: 0.10 + rand() * 0.08 + (2 - L) * 0.02,
ph: (i / count) * TAU + L * 1.7
});
}
clouds.push({ lobes: lobes, depth: (L + 1) / layers, spd: 0.05 + L * 0.02 });
}
}
var bolts = []; // {pts, born, life, dist, ex, flickAt}
var nextAt = 0.05;
var cycle = 0;
var ANCHORS = [0.2, 0.5, 0.82, 0.35, 0.66, 0.08, 0.92, 0.48];
function spawn(t, storm) {
// 거리 — 폭풍이 몰아칠 때는 가까운 번개의 비중이 커진다
var dist = Math.pow(rand(), 1.6 - storm * 0.8) * FARMAX;
var ax = ANCHORS[cycle % ANCHORS.length] + (rand() - 0.5) * 0.12;
cycle++;
var sx = (ax + (dist * 0.5 - 0.25) * 0.2) * W; // 먼 번개는 중앙 쪽으로 몰림
var ex = sx + (rand() - 0.5) * W * 0.2 * (1 - dist * 0.5);
var pts = null;
if (dist < 0.55) {
pts = [[sx, -6]];
displace(sx, -6, ex, H * (0.82 - dist * 0.1), DEPTH - (dist > 0.3 ? 1 : 0), pts);
}
bolts.push({ pts: pts, born: t, life: 0.45 + rand() * 0.25, dist: dist, ex: ex, flickAt: t, sx: sx });
}
function env(s, t) {
var u = (t - s.born) / s.life;
if (u >= 1 || u < 0) return 0;
var b = Math.pow(1 - u, 1.6);
if (u > 0.28 && u < 0.36) b = Math.max(b, 0.75);
return b;
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function mix(c1, c2, k) {
return [c1[0] + (c2[0] - c1[0]) * k, c1[1] + (c2[1] - c1[1]) * k, c1[2] + (c2[2] - c1[2]) * k];
}
function draw(t, sky) {
// 하늘 — 어스름 잉크에서 지평선 슬레이트. sky(0..1) 로 전체 명도가 오르내린다
var top = mix(C_INK, C_DUSK, 0.08 + 0.5 * sky);
var bot = mix(C_INK, C_DUSK, 0.4 + 0.6 * sky);
var g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// 별먼지 — 폭풍 밤바닥의 미세 명도 요동
for (var i = 0; i < specks.length; i++) {
var sp = specks[i];
var a = 0.04 + 0.26 * (0.5 + 0.5 * Math.sin(t * sp.spd + sp.ph));
ctx.fillStyle = rgba(C_MIST, a);
ctx.fillRect(sp.rx * W, sp.ry * H, 1.5, 1.5);
}
// 구름 — 섬광(sky) 에 반응하는 실루엣. lighter 로 빛이 쌓인다(칩: globalCompositeOperation)
ctx.globalCompositeOperation = 'lighter';
for (var c = 0; c < clouds.length; c++) {
var cl = clouds[c];
var clCol = mix(mix(C_INK, C_DUSK, 0.3), C_LILA, 0.10 + 0.38 * sky * (1 - cl.depth * 0.3));
ctx.fillStyle = rgba(clCol, 0.5);
for (var L = 0; L < cl.lobes.length; L++) {
var lb = cl.lobes[L];
var drift = Math.sin(t * cl.spd + lb.ph) * 0.018; // 느린 표류 — 매 프레임 실루엣이 미세하게 흘러간다
ctx.beginPath();
ctx.ellipse((lb.rx + drift) * W, lb.ry * H, lb.r * W * 0.5, lb.r * H * 0.42, 0, 0, TAU);
ctx.fill();
}
}
// 줄기 — 가까운 번개만. 헤일로·중간광·코어
for (var j = 0; j < bolts.length; j++) {
var s = bolts[j];
var b = env(s, t);
if (b <= 0 || !s.pts) continue;
var near = 1 - s.dist / FARMAX;
var pts = s.pts;
var k, m;
ctx.strokeStyle = rgba(C_LILA, 0.16 * b * near);
ctx.lineWidth = 8;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
ctx.strokeStyle = rgba(C_LILA, 0.5 * b * near);
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
ctx.strokeStyle = rgba(C_CORE, b * near);
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
// 지면 블룸
var gy = H * (0.82 - s.dist * 0.1);
var br = 8 + 26 * b * near;
var g2 = ctx.createRadialGradient(s.ex, gy, 0, s.ex, gy, br);
g2.addColorStop(0, rgba(C_MINT, 0.45 * b * near));
g2.addColorStop(1, rgba(C_LILA, 0));
ctx.fillStyle = g2;
ctx.fillRect(s.ex - br, gy - br, br * 2, br * 2);
// 착지 핑크 고리
ctx.beginPath();
ctx.arc(s.ex, gy, 4 + 18 * b * near, 0, TAU);
ctx.strokeStyle = rgba(C_PINK, 0.28 * b * near);
ctx.lineWidth = 1.3;
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
}
var specks = [];
function buildSpecks() {
specks = [];
for (var i = 0; i < 18; i++) {
specks.push({ rx: rand(), ry: rand() * 0.8, ph: rand() * TAU, spd: 1 + rand() * 1.6 });
}
}
function stormLevel(t) {
return 0.5 + STORM_A * Math.sin(t * 0.5) * Math.sin(t * 0.23 + 1.7);
}
var skyGlow = 0;
function update(t, dt) {
var storm = stormLevel(t);
if (t >= nextAt) {
spawn(t, storm);
// 지수분포 대기 — 다음 낙뢰까지. 몰아칠 때는 평균이 짧아진다
var mean = MEAN * (1.65 - storm * 1.1);
nextAt = t + -Math.log(1 - rand()) * mean;
}
var target = 0;
for (var i = 0; i < bolts.length; i++) {
var s = bolts[i];
var b = env(s, t);
if (b > 0) target = Math.max(target, b * (0.25 + 0.75 * (1 - s.dist / FARMAX)));
if (t - s.born < s.life * 0.8 && t - s.flickAt > 0.06) {
s.flickAt = t;
if (s.pts && s.dist < 0.4) {
var pts = [[s.sx, -6]];
displace(s.sx, -6, s.ex, H * (0.82 - s.dist * 0.1), DEPTH - (s.dist > 0.3 ? 1 : 0), pts);
s.pts = pts;
}
}
}
skyGlow += (target - skyGlow) * Math.min(1, dt * 14);
while (bolts.length && t - bolts[0].born > 1.2) bolts.shift();
return storm;
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
function stillFrame() {
bolts = [];
var pts = [[W * 0.5, -6]];
displace(W * 0.5, -6, W * 0.56, H * 0.8, DEPTH, pts);
bolts.push({ pts: pts, born: 0.04, life: 0.55, dist: 0.12, ex: W * 0.56, flickAt: 0, sx: W * 0.5 });
draw(0.04, 0.8);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
update(t, dt);
draw(t, Math.max(0, skyGlow + 0.04));
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
buildClouds();
buildSpecks();
nOut.textContent = MEAN.toFixed(1);
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// StormSky — vanilla 판(run/316/demo/03-lightning-storm-sky-multi-strike/index.html) 의 React 포트.
// 폭풍 하늘 다회 낙뢰 — 다음 낙뢰까지의 대기를 지수분포(-ln(u)*MEAN)로 뽑아 몰아침-고요 리듬을 만든다.
// 폭풍 강도가 사인파로 오르내리고, 각 낙뢰는 거리(원근)를 가진다 — 먼 번개는 하늘 섬광, 가까운 번개는
// 중점변위 줄기 + 구름 실루엣. lighter 합성으로 섬광과 줄기 광이 겹쳐 쌓인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StormSky.module.scss";
export type StormSkyProps = {
duration?: string;
easing?: string;
color?: string;
mean?: number; // 지수분포 평균 간격(초) — 변형 "평균 간격" 축(작을수록 몰아침)
stormAmp?: number; // 폭풍 강도 진폭 — 몰아침-고요의 대비
farMax?: number; // 원근 최대 거리 — 1 은 먼 번개까지, 0.6 은 대부분 가까운 번개
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = [number, number];
type Bolt = { pts: Pt[] | null; born: number; life: number; dist: number; ex: number; flickAt: number; sx: number };
type Speck = { rx: number; ry: number; ph: number; spd: number };
type Lobe = { rx: number; ry: number; r: number; ph: number };
type Cloud = { lobes: Lobe[]; depth: number; spd: number };
type Colors = { ink: RGB; dusk: RGB; core: RGB; lilac: RGB; mint: RGB; pink: RGB; mist: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "폭풍 하늘 다회 낙뢰 실험판";
const DEPTH = 6;
const ROUGH = 0.4;
const ANCHORS = [0.2, 0.5, 0.82, 0.35, 0.66, 0.08, 0.92, 0.48];
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function StormSky({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
mean = 0.5,
stormAmp = 0.5,
farMax = 1.0,
}: StormSkyProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const meanRef = useRef(mean) as Box<number>;
const ampRef = useRef(stormAmp) as Box<number>;
const farRef = useRef(farMax) as Box<number>;
const boltsRef = useRef<Bolt[]>([]) as Box<Bolt[]>;
const specksRef = useRef<Speck[]>([]) as Box<Speck[]>;
const cloudsRef = useRef<Cloud[]>([]) as Box<Cloud[]>;
const schedRef = useRef({ nextAt: 0.05, cycle: 0 }) as Box<{ nextAt: number; cycle: number }>;
const skyRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const randRef = useRef(makePRNG(0x316c03)) as Box<() => number>;
const [label, setLabel] = useState(mean.toFixed(1));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
// ── 중점변위 ──
function displace(ax: number, ay: number, bx: number, by: number, d: number, out: Pt[]): void {
if (d === 0) { out.push([bx, by]); return; }
let mx = (ax + bx) / 2, my = (ay + by) / 2;
const dx = bx - ax, dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len;
const off = (randRef.current() * 2 - 1) * len * ROUGH * 0.5;
mx += nx * off; my += ny * off;
displace(ax, ay, mx, my, d - 1, out);
displace(mx, my, bx, by, d - 1, out);
}
// ── 구름 — 층층이 쌓인 실루엣. 섬광에 따라 명도가 오르내리고, 느린 표류로 계속 움직인다 ──
function buildClouds(): void {
const rand = randRef.current;
const arr: Cloud[] = [];
for (let L = 0; L < 3; L++) {
const lobes: Lobe[] = [];
const count = 6 + L * 2;
for (let i = 0; i < count; i++) {
lobes.push({
rx: (i + 0.5) / count + (rand() - 0.5) * 0.06,
ry: 0.08 + L * 0.05 + (rand() - 0.5) * 0.03,
r: 0.1 + rand() * 0.08 + (2 - L) * 0.02,
ph: (i / count) * TAU + L * 1.7,
});
}
arr.push({ lobes, depth: (L + 1) / 3, spd: 0.05 + L * 0.02 });
}
cloudsRef.current = arr;
}
function buildSpecks(): void {
const rand = randRef.current;
const arr: Speck[] = [];
for (let i = 0; i < 18; i++) {
arr.push({ rx: rand(), ry: rand() * 0.8, ph: rand() * TAU, spd: 1 + rand() * 1.6 });
}
specksRef.current = arr;
}
function spawn(t: number, storm: number): void {
const rand = randRef.current;
const { w: W, h: H } = sizeRef.current;
const sched = schedRef.current;
// 거리 — 폭풍이 몰아칠 때는 가까운 번개의 비중이 커진다
const dist = Math.pow(rand(), 1.6 - storm * 0.8) * farRef.current;
const ax = ANCHORS[sched.cycle % ANCHORS.length] + (rand() - 0.5) * 0.12;
sched.cycle++;
const sx = (ax + (dist * 0.5 - 0.25) * 0.2) * W; // 먼 번개는 중앙 쪽으로 몰림
const ex = sx + (rand() - 0.5) * W * 0.2 * (1 - dist * 0.5);
let pts: Pt[] | null = null;
if (dist < 0.55) {
pts = [[sx, -6]];
displace(sx, -6, ex, H * (0.82 - dist * 0.1), DEPTH - (dist > 0.3 ? 1 : 0), pts);
}
boltsRef.current.push({ pts, born: t, life: 0.45 + rand() * 0.25, dist, ex, flickAt: t, sx });
}
function env(s: Bolt, t: number): number {
const u = (t - s.born) / s.life;
if (u >= 1 || u < 0) return 0;
let b = Math.pow(1 - u, 1.6);
if (u > 0.28 && u < 0.36) b = Math.max(b, 0.75);
return b;
}
function rgba(c: RGB, a: number): string {
return `rgba(${c[0]},${c[1]},${c[2]},${a})`;
}
function mix(c1: RGB, c2: RGB, k: number): RGB {
return [c1[0] + (c2[0] - c1[0]) * k, c1[1] + (c2[1] - c1[1]) * k, c1[2] + (c2[2] - c1[2]) * k];
}
function draw(t: number, sky: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
if (!ctx || !col) return;
const { w: W, h: H } = sizeRef.current;
// 하늘 — 어스름 잉크에서 지평선 슬레이트. sky(0..1) 로 전체 명도가 오르내린다
const top = mix(col.ink, col.dusk, 0.08 + 0.5 * sky);
const bot = mix(col.ink, col.dusk, 0.4 + 0.6 * sky);
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// 별먼지 — 폭풍 밤바닥의 미세 명도 요동
const specks = specksRef.current;
for (let i = 0; i < specks.length; i++) {
const sp = specks[i];
const a = 0.04 + 0.26 * (0.5 + 0.5 * Math.sin(t * sp.spd + sp.ph));
ctx.fillStyle = rgba(col.mist, a);
ctx.fillRect(sp.rx * W, sp.ry * H, 1.5, 1.5);
}
// 구름 — 섬광(sky) 에 반응하는 실루엣. lighter 로 빛이 쌓인다
ctx.globalCompositeOperation = "lighter";
const clouds = cloudsRef.current;
for (let c = 0; c < clouds.length; c++) {
const cl = clouds[c];
const clCol = mix(mix(col.ink, col.dusk, 0.3), col.lilac, 0.1 + 0.38 * sky * (1 - cl.depth * 0.3));
ctx.fillStyle = rgba(clCol, 0.5);
for (let L = 0; L < cl.lobes.length; L++) {
const lb = cl.lobes[L];
const drift = Math.sin(t * cl.spd + lb.ph) * 0.018; // 느린 표류 — 매 프레임 실루엣이 미세하게 흘러간다
ctx.beginPath();
ctx.ellipse((lb.rx + drift) * W, lb.ry * H, lb.r * W * 0.5, lb.r * H * 0.42, 0, 0, TAU);
ctx.fill();
}
}
// 줄기 — 가까운 번개만. 헤일로·중간광·코어
const bolts = boltsRef.current;
for (let j = 0; j < bolts.length; j++) {
const s = bolts[j];
const b = env(s, t);
if (b <= 0 || !s.pts) continue;
const near = 1 - s.dist / farRef.current;
const pts = s.pts;
ctx.strokeStyle = rgba(col.lilac, 0.16 * b * near);
ctx.lineWidth = 8;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
ctx.strokeStyle = rgba(col.lilac, 0.5 * b * near);
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
ctx.strokeStyle = rgba(col.core, b * near);
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
// 지면 블룸
const gy = H * (0.82 - s.dist * 0.1);
const br = 8 + 26 * b * near;
const g2 = ctx.createRadialGradient(s.ex, gy, 0, s.ex, gy, br);
g2.addColorStop(0, rgba(col.mint, 0.45 * b * near));
g2.addColorStop(1, rgba(col.lilac, 0));
ctx.fillStyle = g2;
ctx.fillRect(s.ex - br, gy - br, br * 2, br * 2);
// 착지 핑크 고리
ctx.beginPath();
ctx.arc(s.ex, gy, 4 + 18 * b * near, 0, TAU);
ctx.strokeStyle = rgba(col.pink, 0.28 * b * near);
ctx.lineWidth = 1.3;
ctx.stroke();
}
ctx.globalCompositeOperation = "source-over";
}
function stormLevel(t: number): number {
return 0.5 + ampRef.current * Math.sin(t * 0.5) * Math.sin(t * 0.23 + 1.7);
}
function update(t: number, dt: number): number {
const sched = schedRef.current;
const storm = stormLevel(t);
if (t >= sched.nextAt) {
spawn(t, storm);
// 지수분포 대기 — 다음 낙뢰까지. 몰아칠 때는 평균이 짧아진다
const m = meanRef.current * (1.65 - storm * 1.1);
sched.nextAt = t + -Math.log(1 - randRef.current()) * m;
}
let target = 0;
const bolts = boltsRef.current;
for (let i = 0; i < bolts.length; i++) {
const s = bolts[i];
const b = env(s, t);
if (b > 0) target = Math.max(target, b * (0.25 + 0.75 * (1 - s.dist / farRef.current)));
if (t - s.born < s.life * 0.8 && t - s.flickAt > 0.06) {
s.flickAt = t;
if (s.pts && s.dist < 0.4) {
const { h: H } = sizeRef.current;
const pts: Pt[] = [[s.sx, -6]];
displace(s.sx, -6, s.ex, H * (0.82 - s.dist * 0.1), DEPTH - (s.dist > 0.3 ? 1 : 0), pts);
s.pts = pts;
}
}
}
skyRef.current += (target - skyRef.current) * Math.min(1, dt * 14);
while (bolts.length && t - bolts[0].born > 1.2) bolts.shift();
return storm;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctxRef.current = ctx;
}
function stillFrame(): void {
const { w: W, h: H } = sizeRef.current;
boltsRef.current = [];
const pts: Pt[] = [[W * 0.5, -6]];
displace(W * 0.5, -6, W * 0.56, H * 0.8, DEPTH, pts);
boltsRef.current.push({ pts, born: 0.04, life: 0.55, dist: 0.12, ex: W * 0.56, flickAt: 0, sx: W * 0.5 });
draw(0.04, 0.8);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
update(t, dt);
draw(t, Math.max(0, skyRef.current + 0.04));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
meanRef.current = mean;
ampRef.current = stormAmp;
farRef.current = farMax;
colRef.current = {
ink: tokRGB(root, "--bolt-ink"),
dusk: tokRGB(root, "--bolt-dusk"),
core: tokRGB(root, "--bolt-core"),
lilac: tokRGB(root, "--bolt-lilac"),
mint: tokRGB(root, "--bolt-mint"),
pink: tokRGB(root, "--bolt-pink"),
mist: tokRGB(root, "--bolt-mist"),
};
randRef.current = makePRNG(0x316c03);
schedRef.current = { nextAt: 0.05, cycle: 0 };
boltsRef.current = [];
skyRef.current = 0;
fit();
buildClouds();
buildSpecks();
setLabel(mean.toFixed(1));
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mean, stormAmp, farMax]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>다음 낙뢰까지의 대기시간을 지수분포로 뽑으면 폭풍이 실제처럼 몰아치다 쉰다 — 평균 간격이 짧을수록 몰아침이 잦아집니다. 먼 번개는 하늘 빛만 바뀌고 가까운 번개는 줄기가 그대로 보입니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>평균 <b className={styles.val}>{label}</b>초 간격 · 지수분포 폭풍</span>
<span className={styles.metaDim}>canvas · lineTo · globalCompositeOperation</span>
</div>
</div>
</main>
);
}03 폭풍 하늘 — 무작위 간격으로 겹쳐 치는 여러 낙뢰 — 측정 누적면적 52.833% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 번개 효과 그리기 9종 — 프랙탈 중점변위로 치는 낙뢰
이 데모가 속한 글
캔버스 번개 효과 그리기 9종 — 프랙탈 중점변위로 치는 낙뢰번개 효과 그리기는 번개 사진을 붙이는 일이 아니라 곧은 선 하나를 반씩 쪼개는 일입니다. 라이브러리·WebGL 없이 선분을 재귀로 쪼개는 중점변위 하나로 번개 가지가 스스로 갈라집니다. 시작점과 끝점을 이은 선분의 이 글의 데모 전부 보기


