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 オリジナル · SCSS と React は zip に同梱 · キャンバス 稲妻 エフェクト 作り方9種 — フラクタル中点変位で落ちる雷
このデモが属する記事
キャンバス 稲妻 エフェクト 作り方9種 — フラクタル中点変位で落ちる雷稲妻 エフェクト 作り方とは、雷の画像を貼る仕事ではなく、まっすぐな線分を再帰的に割っていく計算のことです。ライブラリ·WebGL なしで線分を再帰的に割る中点変位ひとつで稲妻の枝が自ら分岐します。始点と終点を結ぶ線の中点を垂直方向へ乱数の この記事のデモをすべて見る


