연기 효과 9가지 — 캔버스 노이즈로 만드는 연기 효과 소스
이 연기에는 파티클 물리 엔진이 없다. 없어도 06번은 포인터가 다가오면 소용돌이치며 피하고, 07번은 버튼을 누르면 한 뭉치가 터지듯 퍼지며, 09번은 스크롤 값만으로 장면 전체 밀도가 달라진다. 연기 효과 아홉 가지를 캔버스 2D 값노이즈 계산 하나로 만든 결과다.
01 smoke-effect-ambient-plume-rise
02 smoke-effect-wind-drift-flowfield
03 smoke-effect-density-emission-rate
04 smoke-effect-distance-fade-dissolve
05 smoke-effect-color-tint-shift
06 smoke-effect-pointer-swirl-avoid
07 smoke-effect-press-puff-burst
08 smoke-effect-layered-depth-parallax
09 smoke-effect-hero-scroll-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 위로 피어오르는 기본 연기 기둥
- 02 바람 흐름장에 따라 옆으로 휘는 연기
- 03 발생량 값으로 짙기가 바뀌는 연기
- 04 발원지에서 멀어질수록 옅어지다 사라지는 연기
- 05 시간에 따라 색이 바뀌는 연기
- 06 포인터를 피해 소용돌이치는 연기
- 07 버튼을 누르면 뭉쳐 터지듯 퍼지는 연기
- 08 레이어마다 속도가 다른 겹겹의 연기
- 09 스크롤 값에 반응하는 히어로 연기 장면
연기 효과 9가지 — 캔버스 계산 하나가 연기의 움직임을 어떻게 바꾸는가
아홉 개 모두 파티클을 그리지만, 화면에서 다르게 보이는 이유는 매 프레임 실행되는 계산 하나가 다르기 때문이다. 상승 속도만 바꾸는 항목이 있고, 흐름장 각도로 좌우로 미는 항목이 있고, 포인터·버튼·스크롤 같은 입력값을 그 계산에 얹는 항목도 있다. 모두 seedRand로 고정한 난수와 valueNoise2D 값-노이즈로만 움직이며, 연기 효과 gif로 저장해 그대로 배경에 얹어도 매번 같은 궤적을 그린다. 비슷한 값-노이즈 계산은 유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)에서 잉크 격자로도 확인할 수 있다.
패턴별 코드
아홉 항목의 핵심 계산 구간만 그대로 옮겼다 — 전체 코드는 압축 파일에 있다.
01위로 피어오르는 기본 연기 기둥
위로 피어오르는 기본 연기 기둥이다. 입자가 아래에서 위로 올라갈수록 값-노이즈로 옆으로 조금씩 퍼지며 흐릿해져, 배경 앰비언트 연출에 쓴다.

<main class="stage">
<div class="smk is-demo" id="smkRoot">
<canvas class="smk__canvas" id="smkCanvas" width="480" height="300"></canvas>
<p class="smk__caption">연기 입자가 아래에서 위로 갈수록 옆으로 조금씩 퍼지며 흐릿해진다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.smk {
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: $stage-ink;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.smk { 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);
}
.smk {
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;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.smk {
cursor: default;
}
}(function () {
// 기법: 값 노이즈 필드로 좌우 흔들림(난류)을 만들어 입자마다 다른 파형으로 옆으로 밀어내고,
// 입자 나이(0~1)를 반경·투명도에 그대로 전파해 위로 갈수록 더 크고 흐리게 번지다 사라지게
// 한다. 파티클 물리 엔진 없이 배열 루프 하나로 발생·상승·소멸을 계산한다(계산 공간은
// CALC_W x CALC_H, 화면 표시 배율로 확대해 그린다).
var root = document.getElementById('smkRoot');
var canvas = document.getElementById('smkCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var SMOKE_RISE_SPEED_PX = 12;
var PLUME_SPAWN_PER_S = 50;
var PLUME_LIFE_MS = 2400;
var PLUME_DRIFT = 30;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var NOISE_DIM = 40;
var noiseRand = seedRand(423011);
var noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (var ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi, yi) {
var wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
var wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
var v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx;
var b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
var pRand = seedRand(423012);
var particles = [];
var spawnAcc = 0;
function spawn(bornMs) {
particles.push({
bx: CALC_W * 0.5 + (pRand() - 0.5) * 10,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000,
sign: pRand() < 0.5 ? -1 : 1
});
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
function drawSmoke(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0018) - 0.5) * 2;
var x = p.bx + turb * PLUME_DRIFT * f * p.sign;
var r = 7 + f * 32;
var alpha = Math.sin(f * Math.PI) * 0.55;
if (alpha <= 0.01) continue;
var dx = x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(255,247,230,' + alpha + ')');
g.addColorStop(1, 'rgba(255,247,230,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawSmoke(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 160 : 120;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawSmoke(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./SmokeEffectAmbientPlumeRise.module.scss";
type Particle = { x: number; y: number; born: number; wSeed: number };
export default function SmokeEffectAmbientPlumeRise() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const SMOKE_RISE_SPEED_PX = 12;
const PLUME_SPAWN_PER_S = 50;
const PLUME_LIFE_MS = 2400;
const PLUME_DRIFT = 30;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const NOISE_DIM = 40;
const noiseRand = seedRand(423010);
const noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (let ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi: number, yi: number) {
const wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
const wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t: number) { return t * t * (3 - 2 * t); }
function valueNoise2D(x: number, y: number) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const tx = smoothstep(x - x0), ty = smoothstep(y - y0);
const v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
const v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * tx;
const b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
const pRand = seedRand(423011);
let particles: Particle[] = [];
let spawnAcc = 0;
function spawn(bornMs: number) {
particles.push({
x: CALC_W * 0.5 + (pRand() - 0.5) * 12,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000
});
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
const alive: Particle[] = [];
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0016) - 0.5) * 2;
p.x += turb * PLUME_DRIFT * dtS * 0.1;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
let cw = 480, ch = 300;
function drawSmoke(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
ctx!.globalCompositeOperation = "lighter";
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
const r = 7 + f * 32;
const alpha = Math.sin(f * Math.PI) * 0.5;
if (alpha <= 0.01) continue;
const dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, "rgba(245,238,224," + alpha + ")");
g.addColorStop(1, "rgba(245,238,224,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.globalCompositeOperation = "source-over";
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawSmoke(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 160 : 130;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawSmoke(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.smk} is-demo`} ref={rootRef}>
<canvas className={styles.smk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.smk__caption}>연기 입자가 아래에서 위로 갈수록 옆으로 조금씩 퍼지며 흐릿해진다</p>
</div>
</main>
);
}01 · 위로 피어오르는 기본 연기 기둥 — 측정 누적면적 12.548% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0018) - 0.5) * 2;
var x = p.bx + turb * PLUME_DRIFT * f * p.sign;
var r = 7 + f * 32;
var alpha = Math.sin(f * Math.PI) * 0.55;
if (alpha <= 0.01) continue;
var dx = x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(255,247,230,' + alpha + ')');
g.addColorStop(1, 'rgba(255,247,230,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
02바람 흐름장에 따라 옆으로 휘는 연기
바람 흐름장에 따라 옆으로 휘는 연기다. 흐름장 세기(WIND_FLOW_STRENGTH)에 시간에 따라 오르내리는 돌풍 게인 gustEnvelope(t)를 곱해, 게인이 커지는 순간일수록 연기 기둥이 위로 갈수록 더 크게 옆으로 휘어진다. 슬라이더 없이 오직 재생 시간만으로 세기가 바뀌어 야외 배경 무드 연출에 쓴다.

<main class="stage">
<div class="smk is-demo" id="smkRoot">
<canvas class="smk__canvas" id="smkCanvas" width="480" height="300"></canvas>
<p class="smk__caption">흐름장 값이 커질수록 연기 기둥이 위로 갈수록 더 크게 옆으로 휘어진다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.smk {
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: $stage-yellow;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.smk { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.smk {
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: #ffd23f;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.smk {
cursor: default;
}
}(function () {
// 기법: 값 노이즈로 만든 방향장(flow field)의 각도를 입자 위치·시간에서 샘플링해 수평
// 속도로 쓴다. 흐름장 세기(WIND_FLOW_STRENGTH)에 바람이 부는 세기가 시간에 따라 오르내리는
// 게인(gustEnvelope)을 곱해, 같은 상수라도 순간마다 휘는 정도가 달라지는 걸 보여준다.
// 높이 비율(f)을 곱해 위로 갈수록 휘어짐이 커지도록 전파한다.
var root = document.getElementById('smkRoot');
var canvas = document.getElementById('smkCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var SMOKE_RISE_SPEED_PX = 26;
var WIND_FLOW_STRENGTH = 60;
var PLUME_SPAWN_PER_S = 40;
var PLUME_LIFE_MS = 2400;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var NOISE_DIM = 40;
var noiseRand = seedRand(423021);
var noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (var ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi, yi) {
var wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
var wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
var v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx;
var b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
function flowAngle(x, y, t) {
return (valueNoise2D(x * 0.06, y * 0.06 + t * 0.0004) - 0.5) * Math.PI * 2.2;
}
function gustEnvelope(t) {
return 0.5 + 0.5 * Math.sin(t * 0.0009);
}
var pRand = seedRand(423022);
var particles = [];
var spawnAcc = 0;
function spawn(bornMs) {
particles.push({
x: CALC_W * 0.5 + (pRand() - 0.5) * 8,
y: CALC_H - 6,
born: bornMs
});
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
var gust = gustEnvelope(nowMs);
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var ang = flowAngle(p.x, p.y, nowMs);
p.x += Math.sin(ang) * WIND_FLOW_STRENGTH * gust * f * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
function drawSmoke(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var r = 6 + f * 26;
var alpha = Math.sin(f * Math.PI) * 0.6;
if (alpha <= 0.01) continue;
var dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(40,34,26,' + alpha + ')');
g.addColorStop(1, 'rgba(40,34,26,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawSmoke(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 160 : 120;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawSmoke(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./SmokeEffectWindDriftFlowfield.module.scss";
type Particle = { x: number; y: number; born: number; wSeed: number };
export default function SmokeEffectWindDriftFlowfield() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const WIND_FLOW_STRENGTH = 60;
const SMOKE_RISE_SPEED_PX = 26;
const PLUME_SPAWN_PER_S = 40;
const PLUME_LIFE_MS = 2400;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const NOISE_DIM = 40;
const noiseRand = seedRand(423020);
const noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (let ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi: number, yi: number) {
const wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
const wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t: number) { return t * t * (3 - 2 * t); }
function valueNoise2D(x: number, y: number) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const tx = smoothstep(x - x0), ty = smoothstep(y - y0);
const v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
const v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * tx;
const b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
function flowAngle(x: number, y: number, t: number) {
return (valueNoise2D(x * 0.05 + t, y * 0.05) - 0.5) * Math.PI * 1.6;
}
function gustEnvelope(t: number) { return 0.5 + 0.5 * Math.sin(t * 0.0009); }
const pRand = seedRand(423021);
let particles: Particle[] = [];
let spawnAcc = 0;
function spawn(bornMs: number) {
particles.push({
x: CALC_W * 0.5 + (pRand() - 0.5) * 10,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000
});
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
const gust = gustEnvelope(nowMs);
const alive: Particle[] = [];
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const ang = flowAngle(p.wSeed, p.y, nowMs * 0.0007);
p.x += Math.sin(ang) * WIND_FLOW_STRENGTH * gust * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
let cw = 480, ch = 300;
function drawSmoke(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
const r = 6 + f * 26;
const alpha = Math.sin(f * Math.PI) * 0.6;
if (alpha <= 0.01) continue;
const dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, "rgba(40,34,26," + alpha + ")");
g.addColorStop(1, "rgba(40,34,26,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawSmoke(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 160 : 130;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawSmoke(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.smk} is-demo`} ref={rootRef}>
<canvas className={styles.smk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.smk__caption}>흐름장 값이 커질수록 연기 기둥이 위로 갈수록 더 크게 옆으로 휘어진다</p>
</div>
</main>
);
}02 · 바람 흐름장에 따라 옆으로 휘는 연기 — 측정 누적면적 11.102% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
var gust = gustEnvelope(nowMs);
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var ang = flowAngle(p.x, p.y, nowMs);
p.x += Math.sin(ang) * WIND_FLOW_STRENGTH * gust * f * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
03발생량 값으로 짙기가 바뀌는 연기
발생량 값으로 짙기가 바뀌는 연기다. SMOKE_EMISSION_RATE 값이 커질수록 한 화면에 겹치는 입자 수가 늘어 연기가 더 짙고 불투명하게 보인다. 화면 위쪽 슬라이더를 움직이면 is-demo 가 꺼져 자동 삼각파 스윕이 멈추고 손으로 정한 발생량이 그대로 유지되며, 경고·주의 상태 배경에 쓴다.

<main class="stage">
<div class="smk is-demo" id="smkRoot">
<canvas class="smk__canvas" id="smkCanvas" width="480" height="300"></canvas>
<p class="smk__caption">발생량 값을 올리면 한 화면에 겹치는 입자 수가 늘어 연기가 더 짙고 불투명하게 보인다</p>
<div class="smk__control">
<label class="smk__label" for="smkRate">발생량</label>
<input class="smk__range" id="smkRate" type="range" min="2" max="22" value="4" step="1" aria-label="발생량">
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.smk {
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: $stage-orange;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
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;
}
.smk__control {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: $sp-2;
box-sizing: border-box;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .55);
}
.smk__label {
flex: 0 0 auto;
font-size: 11px;
font-weight: 600;
color: $stage-paper;
white-space: nowrap;
}
.smk__range {
flex: 1 1 auto;
min-width: 0;
width: 0;
accent-color: $subject-orange-deep;
}
@media (max-width: 340px) {
.smk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.smk__label { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.smk { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.smk {
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: #ff4d1f;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
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;
}
.smk__control {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.55);
}
.smk__label {
flex: 0 0 auto;
font-size: 11px;
font-weight: 600;
color: #ffffff;
white-space: nowrap;
}
.smk__range {
flex: 1 1 auto;
min-width: 0;
width: 0;
accent-color: #c93000;
}
@media (max-width: 340px) {
.smk__caption {
font-size: 10px;
padding: 4px 8px;
}
.smk__label {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.smk {
cursor: default;
}
}(function () {
// 기법: 초당 발생 수(emissionRate)만큼 입자를 계속 만들어내고, 살아있는 입자마다 옅은
// 알파로 겹쳐 그린다(source-over). 겹치는 입자 수가 많아질수록 알파가 누적되어 짙고
// 불투명하게 보이는 게 실제 계산 결과다 — 색을 진하게 바꾸는 게 아니라 개수가 밀도를 만든다.
var root = document.getElementById('smkRoot');
var canvas = document.getElementById('smkCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var rateInput = document.getElementById('smkRate');
var CALC_W = 160, CALC_H = 100;
var SMOKE_EMISSION_RATE = 4;
var SMOKE_RISE_SPEED_PX = 14;
var PLUME_LIFE_MS = 2200;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var NOISE_DIM = 40;
var noiseRand = seedRand(423031);
var noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (var ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi, yi) {
var wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
var wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
var v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx;
var b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
var pRand = seedRand(423032);
var particles = [];
var spawnAcc = 0;
var emissionRate = SMOKE_EMISSION_RATE;
rateInput.addEventListener('input', function () {
root.classList.remove('is-demo'); // 사람이 만졌으니 자동 스윕을 끈다
emissionRate = parseFloat(rateInput.value);
});
function spawn(bornMs) {
particles.push({
bx: CALC_W * (0.28 + pRand() * 0.44),
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000
});
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * emissionRate / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
if (particles.length > 320) particles = particles.slice(particles.length - 320);
}
function drawSmoke(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0015) - 0.5) * 2;
var x = p.bx + turb * 14 * f;
var r = 8 + f * 20;
var alpha = Math.sin(f * Math.PI) * 0.22;
if (alpha <= 0.01) continue;
var dx = x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(30,26,20,' + alpha + ')');
g.addColorStop(1, 'rgba(30,26,20,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawSmoke(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
rateInput.addEventListener('input', function () {
if (reduce) drawSmoke(elapsed);
});
var warmSteps = 140;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
var lastTs = null;
var demoPhase = 0;
var DEMO_SWEEP_MS = 2000;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (root.classList.contains('is-demo')) {
demoPhase = (demoPhase + dt) % DEMO_SWEEP_MS;
var t = demoPhase / DEMO_SWEEP_MS;
var sweep = t < 0.5 ? t * 2 : (1 - t) * 2;
emissionRate = 3 + sweep * 17;
}
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawSmoke(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./SmokeEffectDensityEmissionRate.module.scss";
type Particle = { x: number; y: number; born: number; wSeed: number };
export default function SmokeEffectDensityEmissionRate() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rangeRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const range = rangeRef.current;
if (!root || !canvas || !range) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const SMOKE_EMISSION_RATE = 4;
const SMOKE_RISE_SPEED_PX = 14;
const PLUME_LIFE_MS = 2200;
const PARTICLE_CAP = 320;
const DEMO_SWEEP_MS = 2000;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const NOISE_DIM = 40;
const noiseRand = seedRand(423030);
const noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (let ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi: number, yi: number) {
const wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
const wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t: number) { return t * t * (3 - 2 * t); }
function valueNoise2D(x: number, y: number) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const tx = smoothstep(x - x0), ty = smoothstep(y - y0);
const v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
const v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * tx;
const b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
const pRand = seedRand(423031);
let particles: Particle[] = [];
let spawnAcc = 0;
let emissionRate = SMOKE_EMISSION_RATE;
function spawn(bornMs: number) {
particles.push({
x: CALC_W * 0.5 + (pRand() - 0.5) * 14,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000
});
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
spawnAcc += dtMs * emissionRate / 1000;
while (spawnAcc >= 1) {
if (particles.length < PARTICLE_CAP) spawn(nowMs);
spawnAcc -= 1;
}
const alive: Particle[] = [];
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0016) - 0.5) * 2;
p.x += turb * 6 * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
let cw = 480, ch = 300;
function drawSmoke(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
const r = 6 + f * 14;
const alpha = Math.sin(f * Math.PI) * 0.22;
if (alpha <= 0.01) continue;
const dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, "rgba(50,32,20," + alpha + ")");
g.addColorStop(1, "rgba(50,32,20,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawSmoke(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
let userDriven = false;
function onRangeInput() {
userDriven = true;
emissionRate = Number(range!.value);
if (reduce) { stepSim(16, elapsed); drawSmoke(elapsed); }
}
range.addEventListener("input", onRangeInput);
const warmSteps = reduce ? 160 : 130;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (!userDriven && root!.classList.contains("is-demo")) {
const phase = (elapsed % DEMO_SWEEP_MS) / DEMO_SWEEP_MS;
const tri = phase < 0.5 ? phase * 2 : 2 - phase * 2;
emissionRate = 3 + tri * 17;
range!.value = String(Math.round(emissionRate));
}
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawSmoke(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
range.removeEventListener("input", onRangeInput);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.smk} is-demo`} ref={rootRef}>
<canvas className={styles.smk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.smk__caption}>발생량 값을 올리면 한 화면에 겹치는 입자 수가 늘어 연기가 더 짙고 불투명하게 보인다</p>
<div className={styles.smk__control}>
<label className={styles.smk__label} htmlFor="smkRate423react">발생량</label>
<input
className={styles.smk__range}
id="smkRate423react"
ref={rangeRef}
type="range"
min={2}
max={22}
defaultValue={4}
aria-label="발생량"
/>
</div>
</div>
</main>
);
}03 · 발생량 값으로 짙기가 바뀌는 연기 — 측정 누적면적 17.719% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * emissionRate / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
if (particles.length > 320) particles = particles.slice(particles.length - 320);
}
04발원지에서 멀어질수록 옅어지다 사라지는 연기
발원지에서 멀어질수록 옅어지다 사라지는 연기다. 발원지에서 멀어진 거리만큼 입자의 불투명도가 줄어들다가 DISSOLVE_DISTANCE를 넘으면 완전히 사라져, 로딩 완료 후 잔상 정리에 쓴다.

<main class="stage">
<div class="smk is-demo" id="smkRoot">
<canvas class="smk__canvas" id="smkCanvas" width="480" height="300"></canvas>
<p class="smk__caption">발원지에서 멀어진 거리만큼 입자의 불투명도가 줄어들다가 일정 거리를 넘으면 완전히 사라진다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.smk {
box-sizing: border-box;
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: $stage-paper;
border: 1px solid rgba(23, 20, 26, .08);
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: $stage-paper;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.smk { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.smk {
box-sizing: border-box;
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: #ffffff;
border: 1px solid rgba(23, 20, 26, 0.08);
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #ffffff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.smk {
cursor: default;
}
}(function () {
// 기법: 발원지(원점) 좌표를 저장해두고, 매 프레임 입자와 원점 사이의 유클리드 거리를 계산한다.
// 거리를 SMOKE_DISSOLVE_MS(=이동 속도 x 시간으로 환산한 소멸 거리)로 나눈 비율만큼 알파를
// 선형으로 깎는다 — 나이가 아니라 "거리"가 직접 불투명도를 정한다. 거리가 소멸 거리를
// 넘어서면 배열에서 제거한다.
var root = document.getElementById('smkRoot');
var canvas = document.getElementById('smkCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var SMOKE_DISSOLVE_MS = 900;
var DRIFT_SPEED_PX_S = 30;
var DISSOLVE_DISTANCE = (SMOKE_DISSOLVE_MS / 1000) * DRIFT_SPEED_PX_S;
var SPAWN_PER_S = 30;
var SOURCE_X = CALC_W * 0.5, SOURCE_Y = CALC_H * 0.62;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var NOISE_DIM = 40;
var noiseRand = seedRand(423041);
var noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (var ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi, yi) {
var wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
var wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
var v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx;
var b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
var pRand = seedRand(423042);
var particles = [];
var spawnAcc = 0;
function spawn() {
var ang = -Math.PI / 2 + (pRand() - 0.5) * Math.PI * 0.9;
particles.push({
x: SOURCE_X, y: SOURCE_Y,
vx: Math.cos(ang) * DRIFT_SPEED_PX_S,
vy: Math.sin(ang) * DRIFT_SPEED_PX_S,
wSeed: pRand() * 1000
});
}
function stepSim(dtMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(); spawnAcc -= 1; }
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
p.x += p.vx * dtS;
p.y += p.vy * dtS;
var dist = Math.hypot(p.x - SOURCE_X, p.y - SOURCE_Y);
if (dist < DISSOLVE_DISTANCE) alive.push(p);
}
particles = alive;
}
function drawSmoke(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var dist = Math.hypot(p.x - SOURCE_X, p.y - SOURCE_Y);
var f = Math.max(0, 1 - dist / DISSOLVE_DISTANCE);
var turb = (valueNoise2D(p.wSeed * 0.04, nowMs * 0.0016) - 0.5) * 6;
var r = 6 + (1 - f) * 24;
var alpha = f * 0.6;
if (alpha <= 0.01) continue;
var dx = (p.x + turb) * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(35,32,40,' + alpha + ')');
g.addColorStop(1, 'rgba(35,32,40,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawSmoke(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = 60;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt);
drawSmoke(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawSmoke(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./SmokeEffectDistanceFadeDissolve.module.scss";
type Particle = { x: number; y: number; ang: number; born: number };
export default function SmokeEffectDistanceFadeDissolve() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const SMOKE_DISSOLVE_MS = 900;
const DRIFT_SPEED_PX_S = 30;
const DISSOLVE_DISTANCE = (SMOKE_DISSOLVE_MS / 1000) * DRIFT_SPEED_PX_S;
const SOURCE_X = CALC_W * 0.5, SOURCE_Y = CALC_H * 0.55;
const SPAWN_PER_S = 30;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pRand = seedRand(423041);
let particles: Particle[] = [];
let spawnAcc = 0;
function spawn(bornMs: number) {
particles.push({ x: SOURCE_X, y: SOURCE_Y, ang: pRand() * Math.PI * 2, born: bornMs });
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
spawnAcc += dtMs * SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
const alive: Particle[] = [];
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
p.x += Math.cos(p.ang) * DRIFT_SPEED_PX_S * dtS;
p.y += Math.sin(p.ang) * DRIFT_SPEED_PX_S * dtS - DRIFT_SPEED_PX_S * 0.2 * dtS;
const dist = Math.hypot(p.x - SOURCE_X, p.y - SOURCE_Y);
if (dist < DISSOLVE_DISTANCE && nowMs - p.born < 4000) alive.push(p);
}
particles = alive;
}
let cw = 480, ch = 300;
function drawSmoke(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const dist = Math.hypot(p.x - SOURCE_X, p.y - SOURCE_Y);
const f = Math.max(0, 1 - dist / DISSOLVE_DISTANCE);
const r = 6 + (1 - f) * 24;
const alpha = f * 0.6;
if (alpha <= 0.01) continue;
const dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, "rgba(35,32,40," + alpha + ")");
g.addColorStop(1, "rgba(35,32,40,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
void nowMs;
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawSmoke(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 140 : 100;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawSmoke(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.smk} is-demo`} ref={rootRef}>
<canvas className={styles.smk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.smk__caption}>발원지에서 멀어진 거리만큼 입자의 불투명도가 줄어들다가 일정 거리를 넘으면 완전히 사라진다</p>
</div>
</main>
);
}04 · 발원지에서 멀어질수록 옅어지다 사라지는 연기 — 측정 누적면적 15.542% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(); spawnAcc -= 1; }
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
p.x += p.vx * dtS;
p.y += p.vy * dtS;
var dist = Math.hypot(p.x - SOURCE_X, p.y - SOURCE_Y);
if (dist < DISSOLVE_DISTANCE) alive.push(p);
}
particles = alive;
}
05시간에 따라 색이 바뀌는 연기
시간에 따라 색이 바뀌는 연기다. 피어오르는 시간이 지날수록 입자 색상이 정해둔 두 색 사이를 colorT 값으로 오가며 서서히 바뀌어, 브랜드 컬러 히어로 배경에 쓴다.

<main class="stage">
<div class="smk is-demo" id="smkRoot">
<canvas class="smk__canvas" id="smkCanvas" width="480" height="300"></canvas>
<p class="smk__caption">연기가 피어오르는 시간이 지날수록 입자 색상이 정해둔 두 색 사이를 오가며 서서히 바뀐다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.smk {
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: $stage-ink;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.smk { 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);
}
.smk {
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;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.smk {
cursor: default;
}
}(function () {
// 기법: 씬이 시작된 뒤 흐른 시간(nowMs)에 사인파를 걸어 0~1 사이를 오가는 색 진행도
// colorT 를 만들고, 이 값으로 두 색(SMOKE_TINT_FROM/TO)의 R·G·B를 각각 선형보간(lerp)한다.
// 입자 개별 나이가 아니라 장면 전체의 경과 시간이 색을 바꾼다는 점이 이 항목의 계산 축이다.
var root = document.getElementById('smkRoot');
var canvas = document.getElementById('smkCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var SMOKE_RISE_SPEED_PX = 13;
var SMOKE_TINT_CYCLE_MS = 4200;
var SMOKE_TINT_FROM = [255, 158, 187];
var SMOKE_TINT_TO = [185, 160, 255];
var PLUME_SPAWN_PER_S = 50;
var PLUME_LIFE_MS = 2400;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var NOISE_DIM = 40;
var noiseRand = seedRand(423051);
var noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (var ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi, yi) {
var wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
var wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
var v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx;
var b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
function lerp(a, b, t) { return a + (b - a) * t; }
var pRand = seedRand(423052);
var particles = [];
var spawnAcc = 0;
function spawn(bornMs) {
particles.push({
bx: CALC_W * 0.5 + (pRand() - 0.5) * 10,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000,
sign: pRand() < 0.5 ? -1 : 1
});
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
function drawSmoke(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var colorT = 0.5 + 0.5 * Math.sin(nowMs / SMOKE_TINT_CYCLE_MS * Math.PI * 2);
var cr = Math.round(lerp(SMOKE_TINT_FROM[0], SMOKE_TINT_TO[0], colorT));
var cg = Math.round(lerp(SMOKE_TINT_FROM[1], SMOKE_TINT_TO[1], colorT));
var cb = Math.round(lerp(SMOKE_TINT_FROM[2], SMOKE_TINT_TO[2], colorT));
ctx.clearRect(0, 0, cw, ch);
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0018) - 0.5) * 2;
var x = p.bx + turb * 30 * f * p.sign;
var r = 7 + f * 32;
var alpha = Math.sin(f * Math.PI) * 0.6;
if (alpha <= 0.01) continue;
var dx = x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(' + cr + ',' + cg + ',' + cb + ',' + alpha + ')');
g.addColorStop(1, 'rgba(' + cr + ',' + cg + ',' + cb + ',0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawSmoke(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 160 : 120;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawSmoke(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./SmokeEffectColorTintShift.module.scss";
type Particle = { x: number; y: number; born: number; wSeed: number };
export default function SmokeEffectColorTintShift() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const SMOKE_RISE_SPEED_PX = 12;
const PLUME_SPAWN_PER_S = 50;
const PLUME_LIFE_MS = 2400;
const SMOKE_TINT_CYCLE_MS = 4200;
const SMOKE_TINT_FROM = [255, 158, 187];
const SMOKE_TINT_TO = [185, 160, 255];
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const NOISE_DIM = 40;
const noiseRand = seedRand(423050);
const noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (let ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi: number, yi: number) {
const wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
const wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t: number) { return t * t * (3 - 2 * t); }
function valueNoise2D(x: number, y: number) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const tx = smoothstep(x - x0), ty = smoothstep(y - y0);
const v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
const v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * tx;
const b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
const pRand = seedRand(423051);
let particles: Particle[] = [];
let spawnAcc = 0;
function spawn(bornMs: number) {
particles.push({
x: CALC_W * 0.5 + (pRand() - 0.5) * 12,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000
});
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
const alive: Particle[] = [];
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0016) - 0.5) * 2;
p.x += turb * 4 * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
let cw = 480, ch = 300;
function drawSmoke(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
ctx!.globalCompositeOperation = "lighter";
const colorT = 0.5 + 0.5 * Math.sin((nowMs / SMOKE_TINT_CYCLE_MS) * Math.PI * 2);
const rr = Math.round(lerp(SMOKE_TINT_FROM[0], SMOKE_TINT_TO[0], colorT));
const gg = Math.round(lerp(SMOKE_TINT_FROM[1], SMOKE_TINT_TO[1], colorT));
const bb = Math.round(lerp(SMOKE_TINT_FROM[2], SMOKE_TINT_TO[2], colorT));
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
const r = 7 + f * 32;
const alpha = Math.sin(f * Math.PI) * 0.6;
if (alpha <= 0.01) continue;
const dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, "rgba(" + rr + "," + gg + "," + bb + "," + alpha + ")");
g.addColorStop(1, "rgba(" + rr + "," + gg + "," + bb + ",0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.globalCompositeOperation = "source-over";
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawSmoke(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 160 : 130;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawSmoke(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.smk} is-demo`} ref={rootRef}>
<canvas className={styles.smk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.smk__caption}>연기가 피어오르는 시간이 지날수록 입자 색상이 정해둔 두 색 사이를 오가며 서서히 바뀐다</p>
</div>
</main>
);
}05 · 시간에 따라 색이 바뀌는 연기 — 측정 누적면적 16.073% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
p.y -= SMOKE_RISE_SPEED_PX * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
06포인터를 피해 소용돌이치는 연기
포인터를 피해 소용돌이치는 연기다. 포인터가 연기 기둥에 가까워지는 거리만큼 근처 입자가 접선 방향으로 밀려나며 소용돌이치고, 마우스가 없는 화면에서는 화살표 4방향(ArrowLeft·ArrowRight·ArrowUp·ArrowDown) 키로 포인터 좌표를 옮겨 같은 반응을 볼 수 있어 인터랙티브 히어로 일러스트에 쓴다.

<main class="stage">
<div class="smk is-demo" id="smkRoot" tabindex="0">
<canvas class="smk__canvas" id="smkCanvas" width="480" height="300"></canvas>
<p class="smk__caption">포인터가 연기 기둥에 가까워지는 거리만큼 근처 입자가 소용돌이치며 옆으로 밀려난다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.smk {
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: $stage-yellow;
cursor: crosshair;
outline: none;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.smk { cursor: pointer; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.smk {
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: #ffd23f;
cursor: crosshair;
outline: none;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.smk__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.smk {
cursor: pointer;
}
}(function () {
// 기법: 포인터-입자 거리가 SMOKE_SWIRL_RADIUS 안이면 반경 벡터를 90도 회전한 접선
// 방향으로 밀어내는 속도를 더한다 — 거리가 가까울수록(1 - dist/radius) 세게 밀린다.
// 실제 손이 없을 때는 가상 포인터가 원을 그리며 같은 계산 경로를 그대로 탄다.
var root = document.getElementById('smkRoot');
var canvas = document.getElementById('smkCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var SMOKE_RISE_SPEED_PX = 13;
var SMOKE_SWIRL_RADIUS = 34;
var SMOKE_SWIRL_STRENGTH = 70;
var PLUME_SPAWN_PER_S = 22;
var PLUME_LIFE_MS = 2400;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var NOISE_DIM = 40;
var noiseRand = seedRand(423061);
var noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (var ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi, yi) {
var wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
var wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
var v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx;
var b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
var pRand = seedRand(423062);
var particles = [];
var spawnAcc = 0;
function spawn(bornMs) {
particles.push({
x: CALC_W * 0.5 + (pRand() - 0.5) * 12,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000
});
}
var pointerX = CALC_W * 0.5, pointerY = CALC_H * 0.4;
var pointerActive = false;
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0018) - 0.5) * 2;
p.x += turb * 8 * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
var rx = p.x - pointerX, ry = p.y - pointerY;
var dist = Math.hypot(rx, ry);
if (dist < SMOKE_SWIRL_RADIUS && dist > 0.5) {
var push = (1 - dist / SMOKE_SWIRL_RADIUS) * SMOKE_SWIRL_STRENGTH;
var tx = -ry / dist, ty = rx / dist;
p.x += tx * push * dtS;
p.y += ty * push * dtS;
}
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
function drawSmoke(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var r = 6 + f * 15;
var alpha = Math.sin(f * Math.PI) * 0.5;
if (alpha <= 0.01) continue;
var dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(38,32,24,' + alpha + ')');
g.addColorStop(1, 'rgba(38,32,24,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawSmoke(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
function toCalc(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
return {
x: (t0.clientX - r.left) / r.width * CALC_W,
y: (t0.clientY - r.top) / r.height * CALC_H
};
}
wrap.addEventListener('pointermove', function (e) {
pointerActive = true;
var g = toCalc(e);
pointerX = g.x; pointerY = g.y;
if (reduce) { stepSim(16, elapsed); drawSmoke(elapsed); }
});
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
wrap.addEventListener('keydown', function (e) {
var moved = true;
if (e.key === 'ArrowLeft') pointerX = Math.max(0, pointerX - 8);
else if (e.key === 'ArrowRight') pointerX = Math.min(CALC_W, pointerX + 8);
else if (e.key === 'ArrowUp') pointerY = Math.max(0, pointerY - 8);
else if (e.key === 'ArrowDown') pointerY = Math.min(CALC_H, pointerY + 8);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) { stepSim(16, elapsed); drawSmoke(elapsed); }
}
});
function virtualPointerOrbit(t) {
var cx = CALC_W * (0.3 + 0.4 * ((Math.sin(t * 0.0011) + 1) / 2));
var cy = CALC_H * (0.28 + 0.5 * ((Math.cos(t * 0.0017) + 1) / 2));
return { x: cx, y: cy };
}
var warmSteps = reduce ? 160 : 120;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (!pointerActive) {
var v = virtualPointerOrbit(elapsed);
pointerX = v.x; pointerY = v.y;
}
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawSmoke(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./SmokeEffectPointerSwirlAvoid.module.scss";
type Particle = { x: number; y: number; born: number; wSeed: number };
export default function SmokeEffectPointerSwirlAvoid() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const SMOKE_RISE_SPEED_PX = 13;
const SMOKE_SWIRL_RADIUS = 34;
const SMOKE_SWIRL_STRENGTH = 70;
const PLUME_SPAWN_PER_S = 22;
const PLUME_LIFE_MS = 2400;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const NOISE_DIM = 40;
const noiseRand = seedRand(423061);
const noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (let ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi: number, yi: number) {
const wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
const wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t: number) { return t * t * (3 - 2 * t); }
function valueNoise2D(x: number, y: number) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const tx = smoothstep(x - x0), ty = smoothstep(y - y0);
const v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
const v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * tx;
const b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
const pRand = seedRand(423062);
let particles: Particle[] = [];
let spawnAcc = 0;
function spawn(bornMs: number) {
particles.push({
x: CALC_W * 0.5 + (pRand() - 0.5) * 12,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000
});
}
let pointerX = CALC_W * 0.5, pointerY = CALC_H * 0.4;
let pointerActive = false;
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
const alive: Particle[] = [];
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0018) - 0.5) * 2;
p.x += turb * 8 * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
const rx = p.x - pointerX, ry = p.y - pointerY;
const dist = Math.hypot(rx, ry);
if (dist < SMOKE_SWIRL_RADIUS && dist > 0.5) {
const push = (1 - dist / SMOKE_SWIRL_RADIUS) * SMOKE_SWIRL_STRENGTH;
const tx = -ry / dist, ty = rx / dist;
p.x += tx * push * dtS;
p.y += ty * push * dtS;
}
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
let cw = 480, ch = 300;
function drawSmoke(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
const r = 6 + f * 15;
const alpha = Math.sin(f * Math.PI) * 0.5;
if (alpha <= 0.01) continue;
const dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, "rgba(38,32,24," + alpha + ")");
g.addColorStop(1, "rgba(38,32,24,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawSmoke(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function toCalc(e: PointerEvent) {
const r = wrap.getBoundingClientRect();
return {
x: (e.clientX - r.left) / r.width * CALC_W,
y: (e.clientY - r.top) / r.height * CALC_H
};
}
function onPointerMove(e: PointerEvent) {
pointerActive = true;
const g = toCalc(e);
pointerX = g.x; pointerY = g.y;
if (reduce) { stepSim(16, elapsed); drawSmoke(elapsed); }
}
function onPointerLeave() { pointerActive = false; }
function onKeydown(e: KeyboardEvent) {
let moved = true;
if (e.key === "ArrowLeft") pointerX = Math.max(0, pointerX - 8);
else if (e.key === "ArrowRight") pointerX = Math.min(CALC_W, pointerX + 8);
else if (e.key === "ArrowUp") pointerY = Math.max(0, pointerY - 8);
else if (e.key === "ArrowDown") pointerY = Math.min(CALC_H, pointerY + 8);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) { stepSim(16, elapsed); drawSmoke(elapsed); }
}
}
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.addEventListener("keydown", onKeydown);
function virtualPointerOrbit(t: number) {
const cx = CALC_W * (0.3 + 0.4 * ((Math.sin(t * 0.0011) + 1) / 2));
const cy = CALC_H * (0.28 + 0.5 * ((Math.cos(t * 0.0017) + 1) / 2));
return { x: cx, y: cy };
}
const warmSteps = reduce ? 160 : 120;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (!pointerActive) {
const v = virtualPointerOrbit(elapsed);
pointerX = v.x; pointerY = v.y;
}
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawSmoke(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.smk} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.smk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.smk__caption}>포인터가 연기 기둥에 가까워지는 거리만큼 근처 입자가 소용돌이치며 옆으로 밀려난다</p>
</div>
</main>
);
}06 · 포인터를 피해 소용돌이치는 연기 — 측정 누적면적 21.019% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0018) - 0.5) * 2;
p.x += turb * 8 * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
var rx = p.x - pointerX, ry = p.y - pointerY;
var dist = Math.hypot(rx, ry);
if (dist < SMOKE_SWIRL_RADIUS && dist > 0.5) {
var push = (1 - dist / SMOKE_SWIRL_RADIUS) * SMOKE_SWIRL_STRENGTH;
var tx = -ry / dist, ty = rx / dist;
p.x += tx * push * dtS;
p.y += ty * push * dtS;
}
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
07버튼을 누르면 뭉쳐 터지듯 퍼지는 연기
버튼을 누르면 뭉쳐 터지듯 퍼지는 연기다. 버튼을 누르거나 포커스 상태에서 Enter·Space 키를 누르면 입자 34개가 한 뭉치로 생겨 사방으로 퍼지다가 빠르게 옅어지고, 순간적으로 생겼다 사라지는 특성상 23프레임 중 18프레임에서만 움직임이 감지된다. 제출 완료 버튼 피드백에 쓴다.

<main class="stage">
<div class="smk is-demo" id="smkRoot">
<canvas class="smk__canvas" id="smkCanvas" width="480" height="300"></canvas>
<p class="smk__caption">버튼이 눌린 순간 입자 뭉치 하나가 생겨 짧은 시간 안에 사방으로 퍼지다가 빠르게 옅어진다</p>
<button class="smk__btn" id="smkBtn" type="button">연기 뿜기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.smk {
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: $stage-orange;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 66%;
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;
}
.smk__btn {
position: absolute;
left: 50%;
bottom: $sp-4;
transform: translateX(-50%);
border: none;
border-radius: $r-control;
padding: $sp-2 $sp-6;
font-weight: 700;
font-size: 13px;
color: $stage-paper;
background: $subject-ink;
box-shadow: $shadow-press;
cursor: pointer;
}
.smk__btn:focus-visible {
outline: 2px solid $stage-paper;
outline-offset: 2px;
}
@media (max-width: 340px) {
.smk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.smk__btn { font-size: 12px; padding: $sp-1 $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.smk { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.smk {
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: #ff4d1f;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: 12px;
top: 12px;
max-width: 66%;
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;
}
.smk__btn {
position: absolute;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
border: none;
border-radius: 12px;
padding: 8px 24px;
font-weight: 700;
font-size: 13px;
color: #ffffff;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: pointer;
}
.smk__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.smk__caption {
font-size: 10px;
padding: 4px 8px;
}
.smk__btn {
font-size: 12px;
padding: 4px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.smk {
cursor: default;
}
}(function () {
// 기법: 누르는 순간 원점 한 곳에서 SMOKE_BURST_COUNT 개 입자를 방사형으로 한 번에
// 만들어 서로 다른 각도·속도로 사방에 흩뿌리고, 수명을 짧게 잡아(SMOKE_BURST_LIFE_MS)
// 빠르게 옅어지게 한다. 자동 시연은 루프마다 두 번 같은 burst() 함수를 그대로 호출한다.
var root = document.getElementById('smkRoot');
var canvas = document.getElementById('smkCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var btn = document.getElementById('smkBtn');
var CALC_W = 160, CALC_H = 100;
var SMOKE_BURST_COUNT = 34;
var SMOKE_BURST_SPEED_PX_S = 60;
var SMOKE_BURST_LIFE_MS = 620;
var ORIGIN_X = CALC_W * 0.5, ORIGIN_Y = CALC_H * 0.56;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var pRand = seedRand(423071);
var particles = [];
function burst(bornMs) {
for (var i = 0; i < SMOKE_BURST_COUNT; i += 1) {
var ang = pRand() * Math.PI * 2;
var speed = SMOKE_BURST_SPEED_PX_S * (0.4 + pRand() * 0.9);
particles.push({
x: ORIGIN_X, y: ORIGIN_Y,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed,
born: bornMs
});
}
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
p.x += p.vx * dtS;
p.y += p.vy * dtS;
p.vy -= 8 * dtS;
if (nowMs - p.born < SMOKE_BURST_LIFE_MS) alive.push(p);
}
particles = alive;
}
function drawSmoke(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / SMOKE_BURST_LIFE_MS));
var r = 4 + f * 15;
var alpha = (1 - f) * 0.55;
if (alpha <= 0.01) continue;
var dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(28,24,20,' + alpha + ')');
g.addColorStop(1, 'rgba(28,24,20,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawSmoke(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
btn.addEventListener('pointerdown', function () {
burst(elapsed);
if (reduce) drawSmoke(elapsed);
});
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
burst(elapsed);
if (reduce) drawSmoke(elapsed);
}
});
var PUFF_LOOP_MS = 2000;
var PUFF_TIME_1 = 260;
var PUFF_TIME_2 = 1300;
var lastCycle = -1;
var triggered1 = false, triggered2 = false;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (root.classList.contains('is-demo')) {
var cycle = Math.floor(elapsed / PUFF_LOOP_MS);
var phase = elapsed % PUFF_LOOP_MS;
if (cycle !== lastCycle) { lastCycle = cycle; triggered1 = false; triggered2 = false; }
if (!triggered1 && phase >= PUFF_TIME_1) { triggered1 = true; burst(elapsed); }
if (!triggered2 && phase >= PUFF_TIME_2) { triggered2 = true; burst(elapsed); }
}
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawSmoke(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./SmokeEffectPressPuffBurst.module.scss";
type Particle = { x: number; y: number; vx: number; vy: number; born: number };
export default function SmokeEffectPressPuffBurst() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const btn = btnRef.current;
if (!root || !canvas || !btn) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const SMOKE_BURST_COUNT = 34;
const SMOKE_BURST_SPEED_PX_S = 60;
const SMOKE_BURST_LIFE_MS = 620;
const ORIGIN_X = CALC_W * 0.5, ORIGIN_Y = CALC_H * 0.56;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pRand = seedRand(423071);
let particles: Particle[] = [];
function burst(bornMs: number) {
for (let i = 0; i < SMOKE_BURST_COUNT; i += 1) {
const ang = pRand() * Math.PI * 2;
const speed = SMOKE_BURST_SPEED_PX_S * (0.4 + pRand() * 0.9);
particles.push({
x: ORIGIN_X, y: ORIGIN_Y,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed,
born: bornMs
});
}
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
const alive: Particle[] = [];
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
p.x += p.vx * dtS;
p.y += p.vy * dtS;
p.vy -= 8 * dtS;
if (nowMs - p.born < SMOKE_BURST_LIFE_MS) alive.push(p);
}
particles = alive;
}
let cw = 480, ch = 300;
function drawSmoke(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const f = Math.max(0, Math.min(1, (nowMs - p.born) / SMOKE_BURST_LIFE_MS));
const r = 4 + f * 15;
const alpha = (1 - f) * 0.55;
if (alpha <= 0.01) continue;
const dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, "rgba(28,24,20," + alpha + ")");
g.addColorStop(1, "rgba(28,24,20,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawSmoke(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function onPointerDown() {
burst(elapsed);
if (reduce) drawSmoke(elapsed);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
burst(elapsed);
if (reduce) drawSmoke(elapsed);
}
}
btn.addEventListener("pointerdown", onPointerDown);
btn.addEventListener("keydown", onKeydown);
const PUFF_LOOP_MS = 2000;
const PUFF_TIME_1 = 260;
const PUFF_TIME_2 = 1300;
let lastCycle = -1;
let triggered1 = false, triggered2 = false;
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (root!.classList.contains("is-demo")) {
const cycle = Math.floor(elapsed / PUFF_LOOP_MS);
const phase = elapsed % PUFF_LOOP_MS;
if (cycle !== lastCycle) { lastCycle = cycle; triggered1 = false; triggered2 = false; }
if (!triggered1 && phase >= PUFF_TIME_1) { triggered1 = true; burst(elapsed); }
if (!triggered2 && phase >= PUFF_TIME_2) { triggered2 = true; burst(elapsed); }
}
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawSmoke(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
btn.removeEventListener("pointerdown", onPointerDown);
btn.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.smk} is-demo`} ref={rootRef}>
<canvas className={styles.smk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.smk__caption}>버튼이 눌린 순간 입자 뭉치 하나가 생겨 짧은 시간 안에 사방으로 퍼지다가 빠르게 옅어진다</p>
<button className={styles.smk__btn} ref={btnRef} type="button">연기 뿜기</button>
</div>
</main>
);
}07 · 버튼을 누르면 뭉쳐 터지듯 퍼지는 연기 — 측정 누적면적 25.765% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function burst(bornMs) {
for (var i = 0; i < SMOKE_BURST_COUNT; i += 1) {
var ang = pRand() * Math.PI * 2;
var speed = SMOKE_BURST_SPEED_PX_S * (0.4 + pRand() * 0.9);
particles.push({
x: ORIGIN_X, y: ORIGIN_Y,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed,
born: bornMs
});
}
}
08레이어마다 속도가 다른 겹겹의 연기
레이어마다 속도가 다른 겹겹의 연기다. 뒤쪽 레이어일수록 입자가 더 느리고 흐릿하게 움직여 앞쪽 레이어와 속도 차이가 깊이감을 만들어, 포트폴리오 히어로 씬에 쓴다.

<main class="stage">
<div class="smk is-demo" id="smkRoot">
<canvas class="smk__canvas" id="smkCanvas" width="480" height="300"></canvas>
<p class="smk__caption">뒤쪽 레이어일수록 입자가 더 느리고 흐릿하게 움직여 앞쪽 레이어와 속도 차이가 깊이감을 만든다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.smk {
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: $stage-paper;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
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) {
.smk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.smk { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.smk {
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: #ffffff;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
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) {
.smk__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.smk {
cursor: default;
}
}(function () {
// 기법: 같은 상승·난류 계산을 레이어 배열(LAYER)에 담긴 서로 다른 속도·반경 배율·
// 알파로 3번 반복한다. 뒤 레이어일수록 속도를 낮추고 반경을 키우고 알파를 낮춰 흐릿하게
// 그린다 — 원근에 따른 대기 산란(aerial perspective) 근사를 계산으로 만든다. 뒤에서
// 앞 순서로 그려 앞 레이어가 항상 위에 겹치게 한다.
var root = document.getElementById('smkRoot');
var canvas = document.getElementById('smkCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LAYERS = [
{ speed: 6, radiusMul: 1.7, alphaMul: 0.20, bx: CALC_W * 0.30, spawnPerS: 11, seed: 423081 },
{ speed: 13, radiusMul: 1.15, alphaMul: 0.32, bx: CALC_W * 0.52, spawnPerS: 15, seed: 423082 },
{ speed: 22, radiusMul: 0.8, alphaMul: 0.46, bx: CALC_W * 0.72, spawnPerS: 19, seed: 423083 }
];
var PLUME_LIFE_MS = 2400;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var NOISE_DIM = 40;
var noiseRand = seedRand(423080);
var noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (var ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi, yi) {
var wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
var wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
var v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx;
var b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
for (var li = 0; li < LAYERS.length; li += 1) {
LAYERS[li].rand = seedRand(LAYERS[li].seed);
LAYERS[li].particles = [];
LAYERS[li].spawnAcc = 0;
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
for (var li = 0; li < LAYERS.length; li += 1) {
var L = LAYERS[li];
L.spawnAcc += dtMs * L.spawnPerS / 1000;
while (L.spawnAcc >= 1) {
L.particles.push({
bx: L.bx + (L.rand() - 0.5) * 10,
y: CALC_H - 6,
born: nowMs,
wSeed: L.rand() * 1000
});
L.spawnAcc -= 1;
}
var alive = [];
for (var i = 0; i < L.particles.length; i += 1) {
var p = L.particles[i];
p.y -= L.speed * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
L.particles = alive;
}
}
function drawSmoke(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var li = 0; li < LAYERS.length; li += 1) {
var L = LAYERS[li];
for (var i = 0; i < L.particles.length; i += 1) {
var p = L.particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0016) - 0.5) * 2;
var x = p.bx + turb * 14 * f;
var r = (6 + f * 16) * L.radiusMul;
var alpha = Math.sin(f * Math.PI) * L.alphaMul;
if (alpha <= 0.01) continue;
var dx = x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(30,28,34,' + alpha + ')');
g.addColorStop(1, 'rgba(30,28,34,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawSmoke(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 160 : 130;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawSmoke(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./SmokeEffectLayeredDepthParallax.module.scss";
type Particle = { bx: number; y: number; born: number; wSeed: number };
type Layer = {
speed: number; radiusMul: number; alphaMul: number; bx: number; spawnPerS: number; seed: number;
rand: () => number; particles: Particle[]; spawnAcc: number;
};
export default function SmokeEffectLayeredDepthParallax() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const PLUME_LIFE_MS = 2400;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const NOISE_DIM = 40;
const noiseRand = seedRand(423080);
const noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (let ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi: number, yi: number) {
const wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
const wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t: number) { return t * t * (3 - 2 * t); }
function valueNoise2D(x: number, y: number) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const tx = smoothstep(x - x0), ty = smoothstep(y - y0);
const v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
const v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * tx;
const b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
const LAYERS: Layer[] = [
{ speed: 6, radiusMul: 1.7, alphaMul: 0.20, bx: CALC_W * 0.30, spawnPerS: 11, seed: 423081, rand: seedRand(423081), particles: [], spawnAcc: 0 },
{ speed: 13, radiusMul: 1.15, alphaMul: 0.32, bx: CALC_W * 0.52, spawnPerS: 15, seed: 423082, rand: seedRand(423082), particles: [], spawnAcc: 0 },
{ speed: 22, radiusMul: 0.8, alphaMul: 0.46, bx: CALC_W * 0.72, spawnPerS: 19, seed: 423083, rand: seedRand(423083), particles: [], spawnAcc: 0 }
];
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
for (let li = 0; li < LAYERS.length; li += 1) {
const L = LAYERS[li];
L.spawnAcc += dtMs * L.spawnPerS / 1000;
while (L.spawnAcc >= 1) {
L.particles.push({ bx: L.bx + (L.rand() - 0.5) * 10, y: CALC_H - 6, born: nowMs, wSeed: L.rand() * 1000 });
L.spawnAcc -= 1;
}
const alive: Particle[] = [];
for (let i = 0; i < L.particles.length; i += 1) {
const p = L.particles[i];
p.y -= L.speed * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
L.particles = alive;
}
}
let cw = 480, ch = 300;
function drawSmoke(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let li = 0; li < LAYERS.length; li += 1) {
const L = LAYERS[li];
for (let i = 0; i < L.particles.length; i += 1) {
const p = L.particles[i];
const f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
const turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0016) - 0.5) * 2;
const x = p.bx + turb * 14 * f;
const r = (6 + f * 16) * L.radiusMul;
const alpha = Math.sin(f * Math.PI) * L.alphaMul;
if (alpha <= 0.01) continue;
const dx = x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, "rgba(30,28,34," + alpha + ")");
g.addColorStop(1, "rgba(30,28,34,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawSmoke(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 160 : 130;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawSmoke(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.smk} is-demo`} ref={rootRef}>
<canvas className={styles.smk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.smk__caption}>뒤쪽 레이어일수록 입자가 더 느리고 흐릿하게 움직여 앞쪽 레이어와 속도 차이가 깊이감을 만든다</p>
</div>
</main>
);
}08 · 레이어마다 속도가 다른 겹겹의 연기 — 측정 누적면적 23.225% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
for (var li = 0; li < LAYERS.length; li += 1) {
var L = LAYERS[li];
L.spawnAcc += dtMs * L.spawnPerS / 1000;
while (L.spawnAcc >= 1) {
L.particles.push({
bx: L.bx + (L.rand() - 0.5) * 10,
y: CALC_H - 6,
born: nowMs,
wSeed: L.rand() * 1000
});
L.spawnAcc -= 1;
}
var alive = [];
for (var i = 0; i < L.particles.length; i += 1) {
var p = L.particles[i];
p.y -= L.speed * dtS;
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
L.particles = alive;
}
}
09스크롤 값에 반응하는 히어로 연기 장면
스크롤 값에 반응하는 히어로 연기 장면이다. 스크롤 값이 커질수록 연기 발생량과 소용돌이 반경이 함께 늘어 화면 전체 밀도가 짙어지고, 스크롤이 없는 화면에서는 ArrowUp·ArrowDown과 PageUp·PageDown 키로 같은 반응을 재현할 수 있어 사이트 최상단 히어로 섹션에 쓴다.

<main class="stage">
<div class="smk is-demo" id="smkRoot" tabindex="0">
<canvas class="smk__canvas" id="smkCanvas" width="480" height="300"></canvas>
<p class="smk__caption">스크롤 값이 커질수록 연기 발생량과 확산 범위가 함께 늘어나 화면 전체 밀도가 짙어진다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.smk {
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: $stage-ink;
outline: none;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
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) {
.smk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.smk { 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);
}
.smk {
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;
outline: none;
}
.smk__canvas {
display: block;
width: 100%;
height: 100%;
}
.smk__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
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) {
.smk__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.smk {
cursor: default;
}
}(function () {
// 훅 06·09 합성: 스크롤 진행값(scrollProgress)이 항목 03 의 발생량(emissionRate) 계산과
// 항목 06 의 포인터-소용돌이 계산을 동시에 키운다 — 스크롤이 늘수록 입자가 더 많이
// 태어나고(발생량) 포인터가 지나는 자리의 소용돌이 반경도 커져(확산 범위) 화면 전체
// 밀도가 짙어지는 것을 한 값(scrollProgress)으로 함께 구동한다. 실제 스크롤 입력이
// 없을 때는 triangleWave() 로 진행값을 왕복시켜 같은 계산 경로를 그대로 탄다.
var root = document.getElementById('smkRoot');
var canvas = document.getElementById('smkCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var SMOKE_RISE_SPEED_PX = 14;
var BASE_EMISSION_PER_S = 6;
var EMISSION_RANGE_PER_S = 24;
var SMOKE_SWIRL_RADIUS_BASE = 16;
var SMOKE_SWIRL_RADIUS_RANGE = 30;
var SMOKE_SWIRL_STRENGTH = 60;
var PLUME_LIFE_MS = 2200;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var NOISE_DIM = 40;
var noiseRand = seedRand(423090);
var noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (var ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi, yi) {
var wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
var wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
var v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx;
var b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
var pRand = seedRand(423091);
var particles = [];
var spawnAcc = 0;
var scrollProgress = 0.15;
function spawn(bornMs) {
particles.push({
x: CALC_W * 0.5 + (pRand() - 0.5) * 16,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000
});
}
var pointerX = CALC_W * 0.5, pointerY = CALC_H * 0.4;
var pointerActive = false;
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
var emissionRate = BASE_EMISSION_PER_S + scrollProgress * EMISSION_RANGE_PER_S;
var swirlRadius = SMOKE_SWIRL_RADIUS_BASE + scrollProgress * SMOKE_SWIRL_RADIUS_RANGE;
spawnAcc += dtMs * emissionRate / 1000;
var cap = 260;
while (spawnAcc >= 1) {
if (particles.length < cap) spawn(nowMs);
spawnAcc -= 1;
}
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0017) - 0.5) * 2;
p.x += turb * 10 * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
var rx = p.x - pointerX, ry = p.y - pointerY;
var dist = Math.hypot(rx, ry);
if (dist < swirlRadius && dist > 0.5) {
var push = (1 - dist / swirlRadius) * SMOKE_SWIRL_STRENGTH;
var tx = -ry / dist, ty = rx / dist;
p.x += tx * push * dtS;
p.y += ty * push * dtS;
}
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
function drawSmoke(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
var densityAlpha = 0.34 + scrollProgress * 0.18;
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
var r = 6 + f * 15;
var alpha = Math.sin(f * Math.PI) * densityAlpha;
if (alpha <= 0.01) continue;
var dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, 'rgba(232,226,240,' + alpha + ')');
g.addColorStop(1, 'rgba(232,226,240,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawSmoke(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var userDriven = false;
function applyScroll(delta) {
userDriven = true;
scrollProgress = Math.max(0, Math.min(1, scrollProgress + delta));
if (reduce) { stepSim(16, elapsed); drawSmoke(elapsed); }
}
wrap.addEventListener('wheel', function (e) {
e.preventDefault();
applyScroll(e.deltaY * 0.0025);
}, { passive: false });
wrap.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'PageDown') { e.preventDefault(); applyScroll(0.08); }
else if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); applyScroll(-0.08); }
});
wrap.addEventListener('pointermove', function (e) {
pointerActive = true;
var r = wrap.getBoundingClientRect();
pointerX = (e.clientX - r.left) / r.width * CALC_W;
pointerY = (e.clientY - r.top) / r.height * CALC_H;
if (reduce) { stepSim(16, elapsed); drawSmoke(elapsed); }
});
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
function triangleWave(t, period) {
var phase = (t % period) / period;
return phase < 0.5 ? phase * 2 : 2 - phase * 2;
}
function virtualPointerOrbit(t) {
return {
x: CALC_W * (0.35 + 0.3 * ((Math.sin(t * 0.0012) + 1) / 2)),
y: CALC_H * (0.3 + 0.4 * ((Math.cos(t * 0.0015) + 1) / 2))
};
}
var SCROLL_AUTO_PERIOD_MS = 3400;
var warmSteps = reduce ? 160 : 130;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (!userDriven && root.classList.contains('is-demo')) {
scrollProgress = triangleWave(elapsed, SCROLL_AUTO_PERIOD_MS);
}
if (!pointerActive) {
var v = virtualPointerOrbit(elapsed);
pointerX = v.x; pointerY = v.y;
}
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawSmoke(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./SmokeEffectHeroScrollScene.module.scss";
type Particle = { x: number; y: number; born: number; wSeed: number };
export default function SmokeEffectHeroScrollScene() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
// 훅 06·09 합성: scrollProgress 하나가 항목 03 의 발생량 계산과 항목 06 의
// 포인터-소용돌이 계산을 동시에 구동한다(자세한 설명은 vanilla index.html 참조).
const CALC_W = 160, CALC_H = 100;
const SMOKE_RISE_SPEED_PX = 14;
const BASE_EMISSION_PER_S = 6;
const EMISSION_RANGE_PER_S = 24;
const SMOKE_SWIRL_RADIUS_BASE = 16;
const SMOKE_SWIRL_RADIUS_RANGE = 30;
const SMOKE_SWIRL_STRENGTH = 60;
const PLUME_LIFE_MS = 2200;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const NOISE_DIM = 40;
const noiseRand = seedRand(423090);
const noiseTable = new Float32Array(NOISE_DIM * NOISE_DIM);
for (let ni = 0; ni < noiseTable.length; ni += 1) noiseTable[ni] = noiseRand();
function noiseAt(xi: number, yi: number) {
const wx = ((xi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
const wy = ((yi % NOISE_DIM) + NOISE_DIM) % NOISE_DIM;
return noiseTable[wy * NOISE_DIM + wx];
}
function smoothstep(t: number) { return t * t * (3 - 2 * t); }
function valueNoise2D(x: number, y: number) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const tx = smoothstep(x - x0), ty = smoothstep(y - y0);
const v00 = noiseAt(x0, y0), v10 = noiseAt(x0 + 1, y0);
const v01 = noiseAt(x0, y0 + 1), v11 = noiseAt(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * tx;
const b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
const pRand = seedRand(423091);
let particles: Particle[] = [];
let spawnAcc = 0;
let scrollProgress = 0.15;
function spawn(bornMs: number) {
particles.push({
x: CALC_W * 0.5 + (pRand() - 0.5) * 16,
y: CALC_H - 6,
born: bornMs,
wSeed: pRand() * 1000
});
}
let pointerX = CALC_W * 0.5, pointerY = CALC_H * 0.4;
let pointerActive = false;
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
const emissionRate = BASE_EMISSION_PER_S + scrollProgress * EMISSION_RANGE_PER_S;
const swirlRadius = SMOKE_SWIRL_RADIUS_BASE + scrollProgress * SMOKE_SWIRL_RADIUS_RANGE;
spawnAcc += dtMs * emissionRate / 1000;
const cap = 260;
while (spawnAcc >= 1) {
if (particles.length < cap) spawn(nowMs);
spawnAcc -= 1;
}
const alive: Particle[] = [];
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0017) - 0.5) * 2;
p.x += turb * 10 * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
const rx = p.x - pointerX, ry = p.y - pointerY;
const dist = Math.hypot(rx, ry);
if (dist < swirlRadius && dist > 0.5) {
const push = (1 - dist / swirlRadius) * SMOKE_SWIRL_STRENGTH;
const tx = -ry / dist, ty = rx / dist;
p.x += tx * push * dtS;
p.y += ty * push * dtS;
}
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
}
let cw = 480, ch = 300;
function drawSmoke(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
const densityAlpha = 0.34 + scrollProgress * 0.18;
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
const r = 6 + f * 15;
const alpha = Math.sin(f * Math.PI) * densityAlpha;
if (alpha <= 0.01) continue;
const dx = p.x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, "rgba(232,226,240," + alpha + ")");
g.addColorStop(1, "rgba(232,226,240,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawSmoke(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
let userDriven = false;
function applyScroll(delta: number) {
userDriven = true;
scrollProgress = Math.max(0, Math.min(1, scrollProgress + delta));
if (reduce) { stepSim(16, elapsed); drawSmoke(elapsed); }
}
function onWheel(e: WheelEvent) {
e.preventDefault();
applyScroll(e.deltaY * 0.0025);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowDown" || e.key === "PageDown") { e.preventDefault(); applyScroll(0.08); }
else if (e.key === "ArrowUp" || e.key === "PageUp") { e.preventDefault(); applyScroll(-0.08); }
}
function onPointerMove(e: PointerEvent) {
pointerActive = true;
const r = wrap.getBoundingClientRect();
pointerX = (e.clientX - r.left) / r.width * CALC_W;
pointerY = (e.clientY - r.top) / r.height * CALC_H;
if (reduce) { stepSim(16, elapsed); drawSmoke(elapsed); }
}
function onPointerLeave() { pointerActive = false; }
wrap.addEventListener("wheel", onWheel, { passive: false });
wrap.addEventListener("keydown", onKeydown);
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
function triangleWave(t: number, period: number) {
const phase = (t % period) / period;
return phase < 0.5 ? phase * 2 : 2 - phase * 2;
}
function virtualPointerOrbit(t: number) {
return {
x: CALC_W * (0.35 + 0.3 * ((Math.sin(t * 0.0012) + 1) / 2)),
y: CALC_H * (0.3 + 0.4 * ((Math.cos(t * 0.0015) + 1) / 2))
};
}
const SCROLL_AUTO_PERIOD_MS = 3400;
const warmSteps = reduce ? 160 : 130;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (!userDriven && root!.classList.contains("is-demo")) {
scrollProgress = triangleWave(elapsed, SCROLL_AUTO_PERIOD_MS);
}
if (!pointerActive) {
const v = virtualPointerOrbit(elapsed);
pointerX = v.x; pointerY = v.y;
}
stepSim(dt, elapsed);
drawSmoke(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawSmoke(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("wheel", onWheel);
wrap.removeEventListener("keydown", onKeydown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.smk} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.smk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.smk__caption}>스크롤 값이 커질수록 연기 발생량과 확산 범위가 함께 늘어나 화면 전체 밀도가 짙어진다</p>
</div>
</main>
);
}09 · 스크롤 값에 반응하는 히어로 연기 장면 — 측정 누적면적 11.384% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0017) - 0.5) * 2;
p.x += turb * 10 * dtS;
p.y -= SMOKE_RISE_SPEED_PX * dtS;
var rx = p.x - pointerX, ry = p.y - pointerY;
var dist = Math.hypot(rx, ry);
if (dist < swirlRadius && dist > 0.5) {
var push = (1 - dist / swirlRadius) * SMOKE_SWIRL_STRENGTH;
var tx = -ry / dist, ty = rx / dist;
p.x += tx * push * dtS;
p.y += ty * push * dtS;
}
if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
}
particles = alive;
잰 연기 아홉 항목의 실제 변동 — 24장 캡처 기준
캡처 24장을 델타 23개로 비교해 잰 수치는 다음과 같다.
| 항목 | 누적면적 | 평균 강도 | 움직인 프레임 |
|---|---|---|---|
| 01 위로 피어오르는 기본 연기 기둥 | 12.5% | 25.8 | 23/23 |
| 02 바람 흐름장에 따라 옆으로 휘는 연기 | 11.1% | 16.7 | 23/23 |
| 03 발생량 값으로 짙기가 바뀌는 연기 | 17.7% | 16.2 | 23/23 |
| 04 발원지에서 멀어질수록 옅어지다 사라지는 연기 | 15.5% | 18.5 | 23/23 |
| 05 시간에 따라 색이 바뀌는 연기 | 16.1% | 30.6 | 23/23 |
| 06 포인터를 피해 소용돌이치는 연기 | 21.0% | 29.6 | 23/23 |
| 07 버튼을 누르면 뭉쳐 터지듯 퍼지는 연기 | 25.8% | 72.7 | 18/23 |
| 08 레이어마다 속도가 다른 겹겹의 연기 | 23.2% | 18.8 | 23/23 |
| 09 스크롤 값에 반응하는 히어로 연기 장면 | 11.4% | 18.0 | 23/23 |
값이 연기를 바꾸는 곳 — 발생량·상승 속도·소멸 시간
세 상수가 연기의 성격을 가른다. SMOKE_EMISSION_RATE는 03 항목에서 한 화면에 겹치는 입자 수를, SMOKE_RISE_SPEED_PX는 01·02·03·05·06·09 항목에서 입자가 위로 올라가는 속도를, SMOKE_DISSOLVE_MS는 04 항목에서 발원지로부터 얼마나 멀어져야 사라지는지를 정한다. 02 항목은 SMOKE_RISE_SPEED_PX 외에 WIND_FLOW_STRENGTH × gustEnvelope(t)라는 시간 게인을 따로 곱하는데, 이 게인에는 조절 슬라이더가 없고 오직 재생 시간에 따라서만 오르내린다.
어디서 깨지는가 — 동작 줄임 설정·저사양 기기의 프레임 저하·배경 없이 쓸 때
아홉 항목 모두 matchMedia('(prefers-reduced-motion: reduce)')로 동작 줄임 설정을 읽어 분기한다. 켜져 있으면 대부분 requestAnimationFrame 루프를 돌리는 대신 몇 프레임만 미리 계산해 둔 정지 장면 하나로 멈추고, 06·07·09처럼 입력을 받는 항목은 정지 상태에서도 화살표나 버튼을 누르면 그 자리에서 동기적으로 다시 그린다.
저사양 기기에서는 입자 수가 그대로 프레임 저하로 이어진다. 03 항목은 자동 재생의 삼각파 스윕이 발생량을 최대 20까지 끌어올려 320개 한도 근처까지 입자가 늘어나고, 07 항목은 버튼 한 번에 34개가 동시에 생겨나며, 09 항목은 스크롤이 끝까지 내려가면 260개 한도까지 입자가 채워져 매 프레임 그리는 원(그라디언트) 수가 함께 늘어난다.
진짜 함정은 배경이다. 아홉 데모 모두 어두운 무대 배경 위에서 입자의 rgba 알파값을 골랐는데 — 01·05는 밝은 색을 lighter 합성 모드로 얹고 나머지는 어두운 색을 기본 합성 모드로 얹는 식이라 — 이 배경 없이 흰 페이지에 캔버스만 옮기면 연기가 거의 보이지 않는다. 이 어두운 배경 값까지 포함한 아홉 개 원본은 압축 파일 안에 있고, 여는 비밀번호는 59z8ez3n 이다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 연기 발생량 값 조정 | 03 SMOKE_EMISSION_RATE 4 → 18 | 값을 줄이면 입자 수가 적어 연기가 옅고 성기게 보이고, 값을 늘리면 입자가 촘촘히 겹쳐 짙고 불투명하게 보인다. |
| 연기 상승 속도 값 조정 | 01 SMOKE_RISE_SPEED_PX 12 → 40 | 값을 줄이면 연기가 천천히 올라가 오래 머무르고, 값을 늘리면 빠르게 치솟아 화면 위로 금방 빠져나간다. |
| 연기 소멸 시간 값 조정 | 04 SMOKE_DISSOLVE_MS 900 → 2600 | 값을 줄이면 발원지에서 조금만 멀어져도 금방 사라지고, 값을 늘리면 한참 멀리 퍼진 뒤에야 옅어져 사라진다. |
접근성은 반응 방식이 항목마다 다르다. matchMedia로 동작 줄임 설정을 읽어 대부분 정지 장면 하나로 멈추지만, 06 항목은 ArrowLeft·ArrowRight·ArrowUp·ArrowDown 네 방향 키로 포인터 좌표를 옮길 수 있고, 07 항목은 Enter·Space 키로 버튼과 같은 뭉치 터짐을 재현하며, 09 항목은 ArrowUp·ArrowDown과 PageUp·PageDown 키로 스크롤 값을 밀고 당길 수 있다. 셋 다 prefers-reduced-motion 값이 켜져 있어도 입력이 들어오면 그 값을 다시 확인해 동기적으로 화면을 갱신한다.
연기 효과는 파티클 라이브러리 없이도 되나?
된다. 아홉 항목 모두 외부 파티클 라이브러리 없이 seedRand 고정 난수와 valueNoise2D 값-노이즈, CanvasRenderingContext2D의 방사형 그라디언트만으로 연기를 그린다. 매 프레임은 requestAnimationFrame 콜백 안에서 stepSim으로 입자 위치를 갱신하고 drawSmoke로 그리는 두 함수만 반복한다. 같은 계산 축의 다른 예제는 html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과와 무지개 그라데이션 9가지 — 캔버스 플라즈마 배경 만들기에서 확인할 수 있다.
연기 효과 아홉 가지에 대해 자주 나오는 질문 셋
연기 효과 코드에 실제 파티클 물리 엔진이 들어 있나요?
아니다. 중력·충돌 같은 물리 엔진 없이 seedRand 고정 난수와 값-노이즈로 위치만 계산한다. 07 항목만 예외로 p.vy -= 8 * dtS 한 줄로 간단한 낙하 감쇠를 흉내 낸다.
연기 효과를 다른 배경 위에 그대로 붙여도 되나요?
붙는 것은 되지만 원래 색감이 그대로 보이진 않는다. 아홉 데모 모두 어두운 무대 배경에 맞춰 알파값을 골랐기 때문에, 밝은 배경에서 쓰려면 입자 색과 합성 모드를 먼저 바꿔야 한다.
연기 효과를 영상으로 만들어 써도 되나요?
된다. 화면을 그대로 연기 효과 영상으로 녹화해 배경 영상처럼 반복 재생할 수 있는데, 이 경우는 동작 줄임 설정을 읽지 못하는 사전 녹화본이라 reduced-motion 사용자를 위한 정지 이미지를 따로 준비해야 한다.









