05Embers That Cool From White Through Orange to Red Ash, Flickering Before They Die
The life ratio is fed into color stages running from white through yellow, orange and rose to ash to make the cooling color, and 1D noise shakes the brightness so it flickers harder the closer it is to going out.
Best forCandle and campfire mood sections and atmospheric landings
FLICKER_HZCOOL_STOPSLIFE_MSNOISE_AMP

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="Embers That Cool From White Through Orange to Red Ash, Flickering Before They Die"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">Embers cool down — the life ratio picks the color stage</p>
<button class="sc__btn" id="scBtn" type="button">Pause</button>
<p class="sc__caption">Embers That Cool From White Through Orange to Red Ash, Flickering Before They Die</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = 'Pause';
const BTN_PLAY = 'Play';
const BTN_NEXT = 'Next scene';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const FLICKER_HZ = 9;
const COOL_STOPS = [
[0, [255, 252, 244]],
[0.25, [255, 232, 160]],
[0.55, [255, 176, 128]],
[0.8, [220, 128, 140]],
[1, [128, 108, 130]]
];
const LIFE_MS = 1800;
const NOISE_AMP = 0.55;
const SPAWN_PER_STEP = 4;
const MAX_EMBERS = 420;
const EMBER_G = 0.035;
const EMBER_DRAG = 0.985;
const GROUND_Y = H - 24;
const PREWARM_STEPS = 120;
const TXT_AUTO = 'Embers cool down — the life ratio picks the color stage';
const TXT_TAP = 'Embers spit where you press';
// 수명 비율 t(0 은 갓 태어남, 1 은 꺼짐)를 색 단계 사이 선형 보간으로 색으로 바꾼다
function coolColor(t) {
const c = clamp(t, 0, 1);
for (let i = 1; i < COOL_STOPS.length; i += 1) {
if (c <= COOL_STOPS[i][0]) {
const a = COOL_STOPS[i - 1];
const b = COOL_STOPS[i];
return mixRGB(a[1], b[1], (c - a[0]) / (b[0] - a[0]));
}
}
return COOL_STOPS[COOL_STOPS.length - 1][1];
}
// 불씨 하나의 밝기 — 1차원 노이즈로 흔들고, 식을수록 흔들림 폭이 커진다
function flickerOf(id, seconds, frac, amp) {
const n = noise1(id * 3.7 + seconds * FLICKER_HZ, 7);
return 1 - amp * n * (0.3 + 0.7 * frac * frac);
}
function makeScene(inp, reduce) {
const rng = lcg(505);
const LIFE_STEPS = Math.round(LIFE_MS / FIXED_DT);
const amp = reduce ? NOISE_AMP * 0.4 : NOISE_AMP;
let embers = [];
let t = 0;
let ex = W / 2;
let nextId = 1;
function spawn(x, y, ang, spread, sp0, sp1) {
const a = ang + (rng() - 0.5) * spread;
const sp = sp0 + rng() * (sp1 - sp0);
embers.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: LIFE_STEPS * (0.6 + 0.4 * rng()), id: nextId });
nextId += 1;
}
const scene = {
step() {
t += 1;
const target = inp.idle < IDLE_TICKS ? inp.x : W / 2 + 90 * Math.sin(t * 0.02);
ex = lerp(ex, target, 0.1);
for (let i = 0; i < SPAWN_PER_STEP; i += 1) {
spawn(ex, GROUND_Y, -Math.PI / 2, 1.5, 2.0, 4.6);
}
const alive = [];
for (let i = 0; i < embers.length; i += 1) {
const e = embers[i];
e.age += 1;
// 옆으로 미는 약한 바람도 노이즈에서 받는다
e.vx = e.vx * EMBER_DRAG + (noise1(e.y * 0.03 + t * 0.01, 3) - 0.5) * 0.05;
e.vy = e.vy * EMBER_DRAG + EMBER_G;
e.x += e.vx;
e.y += e.vy;
if (e.age < e.life && e.y < GROUND_Y + 4) {
alive.push(e);
}
}
embers = alive.length > MAX_EMBERS ? alive.slice(alive.length - MAX_EMBERS) : alive;
},
draw(ctx) {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
// 불씨가 많을수록 발사점 주변 하늘이 따뜻하게 물든다
const warm = clamp(embers.length / 300, 0, 1) * (reduce ? 0.12 : 0.3);
const g = ctx.createRadialGradient(ex, GROUND_Y, 0, ex, GROUND_Y, 170);
g.addColorStop(0, rgba([255, 176, 128], warm));
g.addColorStop(1, rgba([255, 176, 128], 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const sec = t * FIXED_DT / 1000;
for (let i = 0; i < embers.length; i += 1) {
const e = embers[i];
const frac = e.age / e.life;
const col = coolColor(frac);
const b = flickerOf(e.id, sec, frac, amp);
const a = Math.pow(1 - frac, 0.7) * b;
ctx.fillStyle = rgba(col, a * 0.28);
ctx.beginPath();
ctx.arc(e.x, e.y, 4.2 * (1 - frac * 0.4), 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(col, a);
ctx.beginPath();
ctx.arc(e.x, e.y, 1.6 * (1 - frac * 0.5) + 0.4, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgba([24, 20, 38], 1);
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
},
label() {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap() {
for (let i = 0; i < 60; i += 1) {
spawn(inp.x, inp.y, rng() * PI2, PI2, 0.8, 3.2);
}
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwEmberCoolingFlickerNoise.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const FLICKER_HZ = 9;
const COOL_STOPS: Array<[number, RGB]> = [
[0, [255, 252, 244]],
[0.25, [255, 232, 160]],
[0.55, [255, 176, 128]],
[0.8, [220, 128, 140]],
[1, [128, 108, 130]]
];
const LIFE_MS = 1800;
const NOISE_AMP = 0.55;
const SPAWN_PER_STEP = 4;
const MAX_EMBERS = 420;
const EMBER_G = 0.035;
const EMBER_DRAG = 0.985;
const GROUND_Y = H - 24;
const PREWARM_STEPS = 120;
const TXT_AUTO = '불씨가 식는다 — 수명 비율이 색 단계를 고른다';
const TXT_TAP = '누른 자리에서 불씨가 튄다';
interface Ember { x: number; y: number; vx: number; vy: number; age: number; life: number; id: number; }
// 수명 비율 t(0 은 갓 태어남, 1 은 꺼짐)를 색 단계 사이 선형 보간으로 색으로 바꾼다
function coolColor(t: number): RGB {
const c = clamp(t, 0, 1);
for (let i = 1; i < COOL_STOPS.length; i += 1) {
if (c <= COOL_STOPS[i][0]) {
const a = COOL_STOPS[i - 1];
const b = COOL_STOPS[i];
return mixRGB(a[1], b[1], (c - a[0]) / (b[0] - a[0]));
}
}
return COOL_STOPS[COOL_STOPS.length - 1][1];
}
// 불씨 하나의 밝기 — 1차원 노이즈로 흔들고, 식을수록 흔들림 폭이 커진다
function flickerOf(id: number, seconds: number, frac: number, amp: number): number {
const n = noise1(id * 3.7 + seconds * FLICKER_HZ, 7);
return 1 - amp * n * (0.3 + 0.7 * frac * frac);
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(505);
const LIFE_STEPS = Math.round(LIFE_MS / FIXED_DT);
const amp = reduce ? NOISE_AMP * 0.4 : NOISE_AMP;
let embers: Ember[] = [];
let t = 0;
let ex = W / 2;
let nextId = 1;
function spawn(x: number, y: number, ang: number, spread: number, sp0: number, sp1: number): void {
const a = ang + (rng() - 0.5) * spread;
const sp = sp0 + rng() * (sp1 - sp0);
embers.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: LIFE_STEPS * (0.6 + 0.4 * rng()), id: nextId });
nextId += 1;
}
const scene: Scene = {
step(): void {
t += 1;
const target = inp.idle < IDLE_TICKS ? inp.x : W / 2 + 90 * Math.sin(t * 0.02);
ex = lerp(ex, target, 0.1);
for (let i = 0; i < SPAWN_PER_STEP; i += 1) {
spawn(ex, GROUND_Y, -Math.PI / 2, 1.5, 2.0, 4.6);
}
const alive: Ember[] = [];
for (let i = 0; i < embers.length; i += 1) {
const e = embers[i];
e.age += 1;
// 옆으로 미는 약한 바람도 노이즈에서 받는다
e.vx = e.vx * EMBER_DRAG + (noise1(e.y * 0.03 + t * 0.01, 3) - 0.5) * 0.05;
e.vy = e.vy * EMBER_DRAG + EMBER_G;
e.x += e.vx;
e.y += e.vy;
if (e.age < e.life && e.y < GROUND_Y + 4) {
alive.push(e);
}
}
embers = alive.length > MAX_EMBERS ? alive.slice(alive.length - MAX_EMBERS) : alive;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
// 불씨가 많을수록 발사점 주변 하늘이 따뜻하게 물든다
const warm = clamp(embers.length / 300, 0, 1) * (reduce ? 0.12 : 0.3);
const g = ctx.createRadialGradient(ex, GROUND_Y, 0, ex, GROUND_Y, 170);
g.addColorStop(0, rgba([255, 176, 128], warm));
g.addColorStop(1, rgba([255, 176, 128], 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const sec = t * FIXED_DT / 1000;
for (let i = 0; i < embers.length; i += 1) {
const e = embers[i];
const frac = e.age / e.life;
const col = coolColor(frac);
const b = flickerOf(e.id, sec, frac, amp);
const a = Math.pow(1 - frac, 0.7) * b;
ctx.fillStyle = rgba(col, a * 0.28);
ctx.beginPath();
ctx.arc(e.x, e.y, 4.2 * (1 - frac * 0.4), 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(col, a);
ctx.beginPath();
ctx.arc(e.x, e.y, 1.6 * (1 - frac * 0.5) + 0.4, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgba([24, 20, 38], 1);
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap(): void {
for (let i = 0; i < 60; i += 1) {
spawn(inp.x, inp.y, rng() * PI2, PI2, 0.8, 3.2);
}
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwEmberCoolingFlickerNoise() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">불씨가 식는다 — 수명 비율이 색 단계를 고른다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨</p>
</div>
</main>
);
}05 Embers That Cool From White Through Orange to Red Ash, Flickering Before They Die — Measured area 24.988% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Fireworks: 9 Bursts, Trails and Reflections
The post this demo lives in
CSS Fireworks: 9 Bursts, Trails and ReflectionsBefore a css fireworks burst can pop, hundreds of grain paths have to be decided first — scene 01's sphere spread and sc See all demos in this post
If you like this effect - similar demos
Interactions09 A Point Cloud That Morphs From Cube to Sphere to Ring as It TurnsJavaScript 3D Animation: 9 Pointer-Led Models
Interactions08 A Circular Mask with a Noisy Edge Blooming from the AnchorCSS Popover: 9 Ways to Anchor, Flip and Spring
Interactions07 Debounced search — a search that fires once a wait value finishes counting down from the last inputMicro Interactions: 9 Essential UI Patterns