04Afterglow diffusion — the bolt's halo bleeding through a pixel buffer
Blending each pixel toward its neighbors' average brightness every frame, then writing it back, leaves a soft halo that lingers and cools even after the stroke itself is gone.
Best forAn emphasis background on a product page, a faint glowing card edge on dark UI
ImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M150 14 66 146h49l-24 96 99-138h-52l28-90z"/></svg>
<span>The brightness buffer left behind by the lightning is rewritten every frame by averaging it with neighboring pixels — even after the line vanishes, a halo keeps spreading and cooling</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Lightning Afterglow Diffusion test panel" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">diffusion <b class="cd__val" id="cdSpread"></b> · cooling <b class="cd__val" id="cdCool"></b></span>
<span class="cd__meta cd__meta--dim">canvas · ImageData · putImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 잔광 확산(04). @keyframes 를 아예 두지 않는다 — 움직임은 전부 캔버스 rAF 루프
// (밝기 버퍼 확산 + 냉각 + LUT putImageData)가 만든다.
// 무대는 어스름 잉크. 파스텔 팔레트를 --bolt-* 로 편다(편 316 공통 규격).
$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-halo: #b9a0ff; // 헤일로 라일락
$bolt-mint: #b9ddd1; // 민트 보조
$bolt-pink: #ffb9c8; // 핑크 보조
$bolt-mist: #d8dce6; // 안개 회색빛
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --bolt-* 를 받아 쓴다
--bolt-ink: #{$bolt-ink};
--bolt-dusk: #{$bolt-dusk};
--bolt-core: #{$bolt-core};
--bolt-halo: #{$bolt-halo};
--bolt-mint: #{$bolt-mint};
--bolt-pink: #{$bolt-pink};
--bolt-mist: #{$bolt-mist};
// 항목 반경 토큰(01~09 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $bolt-halo; }
.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-halo; 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-halo; 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-halo: #b9a0ff;
--bolt-mint: #b9ddd1;
--bolt-pink: #ffb9c8;
--bolt-mist: #d8dce6;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b9a0ff;
}
.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;
}
}// 잔광 diffusion(04). 밝기 버퍼 lum(픽셀당 실수 1채널)을 매 프레임 이웃 4픽셀 avg 쪽으로 섞고(diffuse)
// 냉각 계수를 곱해 식힌다. 낙뢰는 살아 있는 동안 매 프레임 새 중점변위 경로로 버퍼에 도장찍는다(max 갱신).
// 화면 재구성은 LUT(잉크-진보라-라일락-코어)를 통한 ImageData 한 장 putImageData 로 끝낸다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var SPREAD = 0.44; // diffusion 계수 — 변형 "번짐" 축(0.38 → 0.62)
var COOL = 0.986; // 냉각 계수 — 변형 "잔광 시간" 축(0.975 → 0.988)
var GAP_MS = 430; // avg 낙뢰 간격(ms) — 변형 "하늘 리듬" 축(640 → 1500)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
document.getElementById('cdSpread').textContent = String(SPREAD);
document.getElementById('cdCool').textContent = String(COOL);
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 흐름으로 번친다
var SEED = 0x3160404;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 CSS 커스텀 프로퍼티(--bolt-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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_HALO = tokRGB('--bolt-halo');
var C_CORE = tokRGB('--bolt-core');
// LUT — 밝기 t 0(잉크) 부터 1(코어 위 흰끼) 까지 4구간 색 ramp. compose 에서 픽셀당 조회만 한다
var lutR = new Uint8ClampedArray(256), lutG = new Uint8ClampedArray(256), lutB = new Uint8ClampedArray(256);
function buildLUT() {
var m = [0, 0.32, 0.62, 0.88, 1];
var cs = [
[C_INK[0], C_INK[1], C_INK[2]],
[C_INK[0] + (C_HALO[0] - C_INK[0]) * 0.42, C_INK[1] + (C_HALO[1] - C_INK[1]) * 0.42, C_INK[2] + (C_HALO[2] - C_INK[2]) * 0.42],
[C_HALO[0], C_HALO[1], C_HALO[2]],
[C_CORE[0], C_CORE[1], C_CORE[2]],
[255, 252, 255]
];
for (var i = 0; i < 256; i++) {
var t = i / 255, k = 0;
while (k < 3 && t > m[k + 1]) k++;
var f = (t - m[k]) / (m[k + 1] - m[k] || 1);
lutR[i] = cs[k][0] + (cs[k + 1][0] - cs[k][0]) * f;
lutG[i] = cs[k][1] + (cs[k + 1][1] - cs[k][1]) * f;
lutB[i] = cs[k][2] + (cs[k + 1][2] - cs[k][2]) * f;
}
}
var W = 0, H = 0;
var lum = null, lum2 = null, img = null; // 밝기 이중 버퍼 + 출력 ImageData
var strikes = [], timer = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, prev = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
lum = new Float32Array(W * H);
lum2 = new Float32Array(W * H);
img = ctx.createImageData(W, H);
var d = img.data;
for (var i = 3; i < d.length; i += 4) d[i] = 255; // 알파는 한 번 채운 뒤 건드리지 않는다
prngState = SEED >>> 0;
strikes = [];
timer = 0; // 첫 프레임에 곧장 낙뢰 — 로드 직후 화면이 이미 산다
}
// diffusion — 각 픽셀을 이웃 4개 avg 쪽으로 SPREAD 만큼 끌고 COOL 로 식힌다. 두 버퍼를 교대로 써 편향을 없앤다
function diffuse() {
for (var y = 0; y < H; y++) {
var row = y * W;
var up = (y > 0 ? y - 1 : y) * W;
var dn = (y < H - 1 ? y + 1 : y) * W;
for (var x = 0; x < W; x++) {
var i = row + x;
var l = lum[i];
var av = (lum[row + (x > 0 ? x - 1 : x)] + lum[row + (x < W - 1 ? x + 1 : x)] +
lum[up + x] + lum[dn + x]) * 0.25;
lum2[i] = (l + SPREAD * (av - l)) * COOL;
}
}
var tmp = lum; lum = lum2; lum2 = tmp;
}
// 중점변위 — 선분을 depth 번 반분하며 중점을 법선 방향으로 무작위 오프셋. 호출마다 새 경로가 나온다
function genBolt(ax, ay, bx, by, mag, depth) {
var pts = [{ x: ax, y: ay }, { x: bx, y: by }];
var dx = bx - ax, dy = by - ay;
var len = Math.hypot(dx, dy) || 1;
var nx = -dy / len, ny = dx / len; // 법선 — 변위가 놓이는 방향
var off = mag;
for (var d = 0; d < depth; d++) {
var next = [pts[0]];
for (var i = 0; i < pts.length - 1; i++) {
var p = pts[i], q = pts[i + 1];
var r = (rand() * 2 - 1) * off;
next.push({ x: (p.x + q.x) * 0.5 + nx * r, y: (p.y + q.y) * 0.5 + ny * r }, q);
}
pts = next;
off *= 0.55;
}
return pts;
}
// 점 하나를 밝기 버퍼에 도장 — 코어는 과노출(1.25), 둘레는 거리 비례 감쇠. 기존 값과 비교해 max 만 취한다
function stampPoint(px, py, radius) {
var cx = Math.round(px), cy = Math.round(py);
for (var yy = cy - radius; yy <= cy + radius; yy++) {
if (yy < 0 || yy >= H) continue;
for (var xx = cx - radius; xx <= cx + radius; xx++) {
if (xx < 0 || xx >= W) continue;
var dxx = xx - cx, dyy = yy - cy;
var d = Math.sqrt(dxx * dxx + dyy * dyy);
if (d > radius) continue;
var v = d <= 1 ? 1.25 : (1 - d / radius) * 0.62;
var idx = yy * W + xx;
if (v > lum[idx]) lum[idx] = v;
}
}
}
// 경로 도장 — 선분을 2px 간격으로 보간해 찍어 끊김 없는 줄기를 만든다
function stampPath(pts, radius) {
for (var i = 0; i < pts.length - 1; i++) {
var p = pts[i], q = pts[i + 1];
var seg = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.y - p.y) / 2));
for (var s = 0; s < seg; s++) {
var f = s / seg;
stampPoint(p.x + (q.x - p.x) * f, p.y + (q.y - p.y) * f, radius);
}
}
stampPoint(pts[pts.length - 1].x, pts[pts.length - 1].y, radius);
}
// 낙뢰 정의 — 메인 줄기(상단→하단)와 본 줄기 중간에서 갈라지는 곁가지 2~3개
function newStrike() {
var ax = W * (0.12 + rand() * 0.76);
var bx = W * (0.12 + rand() * 0.76);
var s = { ax: ax, bx: bx, life: 4 + (rand() * 3 | 0), branches: [] };
var nb = 2 + (rand() * 2 | 0);
for (var k = 0; k < nb; k++) {
var t = 0.3 + rand() * 0.45;
var dir = rand() < 0.5 ? -1 : 1;
s.branches.push({
sx: ax + (bx - ax) * t,
sy: H * t,
ex: ax + (bx - ax) * t + dir * (W * 0.1 + rand() * W * 0.2),
ey: H * (0.7 + rand() * 0.35),
mag: H * 0.1
});
}
return s;
}
// 살아 있는 낙뢰는 매 프레임 새 경로로 다시 도장 — 줄기가 몇 프레임 떨리다 지워진다
function stepStrikes(dtMs) {
timer -= dtMs;
if (timer <= 0) {
strikes.push(newStrike());
timer = GAP_MS * (0.5 + rand()); // 몰아치다 쉬는 불규칙 간격
}
for (var i = strikes.length - 1; i >= 0; i--) {
var s = strikes[i];
stampPath(genBolt(s.ax, -8, s.bx, H + 8, H * 0.14, 5), 6);
for (var k = 0; k < s.branches.length; k++) {
var b = s.branches[k];
stampPath(genBolt(b.sx, b.sy, b.ex, b.ey, b.mag, 4), 5);
}
s.life--;
if (s.life <= 0) strikes.splice(i, 1);
}
}
// LUT 로 픽셀 색을 매기고 한 장에 뿌린다
function compose() {
var d = img.data, n = W * H;
for (var i = 0; i < n; i++) {
var v = lum[i];
var li = v >= 1 ? 255 : (v <= 0 ? 0 : (v * 255) | 0);
var j = i << 2;
d[j] = lutR[li]; d[j + 1] = lutG[li]; d[j + 2] = lutB[li];
}
ctx.putImageData(img, 0, 0);
}
// reduce 용 한 장 — rAF 없이 낙뢰 직후 잔광이 번진 상태를 시뮬레이션으로 만들어 찍는다
function stillDraw() {
fit();
diffuse();
stepStrikes(1000);
for (var w = 0; w < 6; w++) { diffuse(); stepStrikes(16); }
compose();
}
function frame(now) {
var dt = Math.min(50, now - (prev || now));
prev = now;
diffuse();
stepStrikes(dt);
compose();
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { if (reduce) stillDraw(); });
fit();
buildLUT();
if (reduce) {
stillDraw();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// HaloDiffusion — vanilla 판(run/316/demo/04-lightning-glow-halo-diffusion/index.html) 의 React 포트.
// 밝기 버퍼(Float32Array) 를 매 프레임 이웃 4픽셀 평균 쪽으로 확산 + 냉각하고, 살아 있는 낙뢰를
// 중점변위 경로로 도장찍는다. 화면 재구성은 LUT 조회 + putImageData 한 번으로 끝낸다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HaloDiffusion.module.scss";
export type HaloDiffusionProps = {
duration?: string;
easing?: string;
color?: string;
spread?: number; // 확산 계수 — 변형 "번짐" 축(0.44 → 0.62)
cool?: number; // 냉각 계수 — 변형 "잔광 시간" 축(0.986 → 0.988)
gapMs?: number; // 평균 낙뢰 간격(ms) — 변형 "하늘 리듬" 축(430 → 1500)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Branch = { sx: number; sy: number; ex: number; ey: number; mag: number };
type Strike = { ax: number; bx: number; life: number; branches: Branch[] };
type Colors = { ink: RGB; halo: RGB; core: RGB };
const FIELD_LABEL = "번개 잔광 확산 실험판";
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 HaloDiffusion({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
spread = 0.44,
cool = 0.986,
gapMs = 430,
}: HaloDiffusionProps) {
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 spreadRef = useRef(spread) as Box<number>;
const coolRef = useRef(cool) as Box<number>;
const gapRef = useRef(gapMs) as Box<number>;
const lumRef = useRef<Float32Array | null>(null) as Box<Float32Array | null>;
const lum2Ref = useRef<Float32Array | null>(null) as Box<Float32Array | null>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const lutRef = useRef<{ r: Uint8ClampedArray; g: Uint8ClampedArray; b: Uint8ClampedArray } | null>(null);
const strikesRef = useRef<Strike[]>([]) as Box<Strike[]>;
const timerRef = useRef(0) as Box<number>;
const randRef = useRef<() => number>(makePRNG(0x3160404)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [spreadShown] = useState(spread);
const [coolShown] = useState(cool);
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 buildLUT(col: Colors): void {
const r = new Uint8ClampedArray(256), g = new Uint8ClampedArray(256), b = new Uint8ClampedArray(256);
const m = [0, 0.32, 0.62, 0.88, 1];
const cs: RGB[] = [
col.ink,
[col.ink[0] + (col.halo[0] - col.ink[0]) * 0.42, col.ink[1] + (col.halo[1] - col.ink[1]) * 0.42, col.ink[2] + (col.halo[2] - col.ink[2]) * 0.42],
col.halo,
col.core,
[255, 252, 255],
];
for (let i = 0; i < 256; i++) {
const t = i / 255;
let k = 0;
while (k < 3 && t > m[k + 1]) k++;
const f = (t - m[k]) / (m[k + 1] - m[k] || 1);
r[i] = cs[k][0] + (cs[k + 1][0] - cs[k][0]) * f;
g[i] = cs[k][1] + (cs[k + 1][1] - cs[k][1]) * f;
b[i] = cs[k][2] + (cs[k + 1][2] - cs[k][2]) * f;
}
lutRef.current = { r, g, b };
}
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();
const W = Math.max(2, Math.round(r.width));
const H = Math.max(2, Math.round(r.height));
sizeRef.current.w = W;
sizeRef.current.h = H;
cv.width = W; cv.height = H;
ctxRef.current = ctx;
lumRef.current = new Float32Array(W * H);
lum2Ref.current = new Float32Array(W * H);
const img = ctx.createImageData(W, H);
const d = img.data;
for (let i = 3; i < d.length; i += 4) d[i] = 255;
imgRef.current = img;
randRef.current = makePRNG(0x3160404);
strikesRef.current = [];
timerRef.current = 0;
}
function diffuse(): void {
const { w: W, h: H } = sizeRef.current;
let lum = lumRef.current, lum2 = lum2Ref.current;
if (!lum || !lum2) return;
const SP = spreadRef.current, CL = coolRef.current;
for (let y = 0; y < H; y++) {
const row = y * W;
const up = (y > 0 ? y - 1 : y) * W;
const dn = (y < H - 1 ? y + 1 : y) * W;
for (let x = 0; x < W; x++) {
const i = row + x;
const l = lum[i];
const av = (lum[row + (x > 0 ? x - 1 : x)] + lum[row + (x < W - 1 ? x + 1 : x)] + lum[up + x] + lum[dn + x]) * 0.25;
lum2[i] = (l + SP * (av - l)) * CL;
}
}
lumRef.current = lum2;
lum2Ref.current = lum;
}
function genBolt(ax: number, ay: number, bx: number, by: number, mag: number, depth: number): Pt[] {
const rand = randRef.current;
let pts: Pt[] = [{ x: ax, y: ay }, { x: bx, y: by }];
const dx = bx - ax, dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len;
let off = mag;
for (let d = 0; d < depth; d++) {
const next: Pt[] = [pts[0]];
for (let i = 0; i < pts.length - 1; i++) {
const p = pts[i], q = pts[i + 1];
const r = (rand() * 2 - 1) * off;
next.push({ x: (p.x + q.x) * 0.5 + nx * r, y: (p.y + q.y) * 0.5 + ny * r }, q);
}
pts = next;
off *= 0.55;
}
return pts;
}
function stampPoint(px: number, py: number, radius: number): void {
const lum = lumRef.current;
const { w: W, h: H } = sizeRef.current;
if (!lum) return;
const cx = Math.round(px), cy = Math.round(py);
for (let yy = cy - radius; yy <= cy + radius; yy++) {
if (yy < 0 || yy >= H) continue;
for (let xx = cx - radius; xx <= cx + radius; xx++) {
if (xx < 0 || xx >= W) continue;
const dxx = xx - cx, dyy = yy - cy;
const d = Math.sqrt(dxx * dxx + dyy * dyy);
if (d > radius) continue;
const v = d <= 1 ? 1.25 : (1 - d / radius) * 0.62;
const idx = yy * W + xx;
if (v > lum[idx]) lum[idx] = v;
}
}
}
function stampPath(pts: Pt[], radius: number): void {
for (let i = 0; i < pts.length - 1; i++) {
const p = pts[i], q = pts[i + 1];
const seg = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.y - p.y) / 2));
for (let s = 0; s < seg; s++) {
const f = s / seg;
stampPoint(p.x + (q.x - p.x) * f, p.y + (q.y - p.y) * f, radius);
}
}
stampPoint(pts[pts.length - 1].x, pts[pts.length - 1].y, radius);
}
function newStrike(): Strike {
const rand = randRef.current;
const { w: W, h: H } = sizeRef.current;
const ax = W * (0.12 + rand() * 0.76);
const bx = W * (0.12 + rand() * 0.76);
const s: Strike = { ax, bx, life: 4 + ((rand() * 3) | 0), branches: [] };
const nb = 2 + ((rand() * 2) | 0);
for (let k = 0; k < nb; k++) {
const t = 0.3 + rand() * 0.45;
const dir = rand() < 0.5 ? -1 : 1;
s.branches.push({
sx: ax + (bx - ax) * t,
sy: H * t,
ex: ax + (bx - ax) * t + dir * (W * 0.1 + rand() * W * 0.2),
ey: H * (0.7 + rand() * 0.35),
mag: H * 0.1,
});
}
return s;
}
function stepStrikes(dtMs: number): void {
const rand = randRef.current;
const { h: H } = sizeRef.current;
timerRef.current -= dtMs;
if (timerRef.current <= 0) {
strikesRef.current.push(newStrike());
timerRef.current = gapRef.current * (0.5 + rand());
}
const strikes = strikesRef.current;
for (let i = strikes.length - 1; i >= 0; i--) {
const s = strikes[i];
stampPath(genBolt(s.ax, -8, s.bx, H + 8, H * 0.14, 5), 6);
for (let k = 0; k < s.branches.length; k++) {
const b = s.branches[k];
stampPath(genBolt(b.sx, b.sy, b.ex, b.ey, b.mag, 4), 5);
}
s.life--;
if (s.life <= 0) strikes.splice(i, 1);
}
}
function compose(): void {
const ctx = ctxRef.current;
const img = imgRef.current;
const lum = lumRef.current;
const lut = lutRef.current;
const { w: W, h: H } = sizeRef.current;
if (!ctx || !img || !lum || !lut) return;
const d = img.data, n = W * H;
for (let i = 0; i < n; i++) {
const v = lum[i];
const li = v >= 1 ? 255 : v <= 0 ? 0 : (v * 255) | 0;
const j = i << 2;
d[j] = lut.r[li]; d[j + 1] = lut.g[li]; d[j + 2] = lut.b[li];
}
ctx.putImageData(img, 0, 0);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const dt = Math.min(50, now - (prev || now));
prev = now;
diffuse();
stepStrikes(dt);
compose();
rafRef.current = requestAnimationFrame(frame);
}
function stillDraw(): void {
fit();
diffuse();
stepStrikes(1000);
for (let w = 0; w < 6; w++) { diffuse(); stepStrikes(16); }
compose();
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
spreadRef.current = spread;
coolRef.current = cool;
gapRef.current = gapMs;
colRef.current = {
ink: tokRGB(root, "--bolt-ink"),
halo: tokRGB(root, "--bolt-halo"),
core: tokRGB(root, "--bolt-core"),
};
fit();
buildLUT(colRef.current);
function onResize(): void {
fit();
if (colRef.current) buildLUT(colRef.current);
if (reduced()) stillDraw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillDraw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spread, cool, gapMs]);
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}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M150 14 66 146h49l-24 96 99-138h-52l28-90z"/></svg>
<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}>{spreadShown}</b> · 냉각 <b className={styles.val}>{coolShown}</b></span>
<span className={styles.metaDim}>캔버스 · ImageData · putImageData</span>
</div>
</div>
</main>
);
}04 Afterglow diffusion — the bolt's halo bleeding through a pixel buffer — Measured area 33.303% · moving frames 23/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
If you like this effect - similar demos
Creative UI03 Torus ring — the donut distance and rim light9 Ray Marching Scenes in Canvas (No WebGL)
Creative UI06 Trail-age coloring — the freshest paths glow neon-brightSlime Mold Simulation, 9 Ways (No Library)
Creative UI03 Maze — stripes kink into corridors in the middle ground9 Reaction Diffusion Simulations, No Library