03Storm sky — several strikes overlapping at random intervals
Sampling each bolt's wait time from an exponential distribution reproduces the real, uneven rhythm of clustered strikes followed by long lulls across several points on the canvas.
Best forA background scene for a dark-mode hero, a page-transition loading flourish
lineToglobalCompositeOperationrequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>Drawing the wait for the next strike from an exponential distribution makes the storm surge and pause like the real thing. Distant lightning only changes the sky's light; nearby lightning shows its stem in full</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Storm-Sky Multi-Strike test panel" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">avg <b class="cd__val" id="cdN">0.5</b>sec interval · storm burst</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; // 지수분포 avg 간격(초) — 작을수록 몰아침
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 Storm sky — several strikes overlapping at random intervals — Measured area 52.833% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · 9 Lightning Effect Background Scenes, No WebGL
The post this demo lives in
9 Lightning Effect Background Scenes, No WebGLNo library, no WebGL — a single recursive midpoint-displacement split makes lightning branches fork on their own, and th See all demos in this post


