煙 エフェクト 9パターン — ノイズ計算で作るキャンバス演出
この煙 エフェクトは9パターンあります。この煙にはパーティクル物理エンジンがない。それでも06番はポインターが近づくと渦を巻いて避け、07番はボタンを押すと塊が弾けるように広がり、09番はスクロール値だけで場面全体の密度が変わる。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 上へ立ち上る基本の煙の柱
- 02 風の流れ場に沿って横に曲がる煙
- 03 発生量の値で濃さが変わる煙
- 04 発生源から離れるほど薄れて消える煙
- 05 時間とともに色が変わる煙
- 06 ポインターを避けて渦を巻く煙
- 07 ボタンを押すと弾けるように広がる煙
- 08 レイヤーごとに速度が違う何層もの煙
- 09 スクロール値に反応するヒーロー煙シーン
煙 エフェクト 9パターン — ひとつのキャンバス計算が煙の動き方をどう変えるか
以下の9パターンはすべて同じキャンバスループを共有しています。パーティクルを発生させ、寿命の割合で年齢を進め、放射状グラデーションで描く——CSSの @keyframes で要素をトゥイーンさせているわけではありません。01番は最も素直な読み方で、ただ立ち上って滲むだけの煙柱です。06番になると同じループがポインター駆動のベクトル場に沿って曲がり、09番はまったく同じ計算をスクロール連動のシーン全体に使っていて、フレームごとにグラデーションを塗り直す発想はレインボーグラデーション 9パターン — キャンバスで作るプラズマ背景とも共通しています。以下の並びは、静的な煙柱から風・密度・距離・色・ポインター操作・押下トリガー・奥行き層・スクロール駆動シーンへと、ループに読ませる入力をひとつずつ増やしていく順になっています。
01上に立ち上る基本の煙柱
煙のパーティクルが下から立ち上るにつれ、徐々に横に広がりぼやけが強くなります——PLUME_DRIFT が横揺れの幅を決め、PLUME_LIFE_MS が寿命の上限を決め、その寿命が尽きる前に SMOKE_RISE_SPEED_PX が粒子を上端の外まで運びます。暗い背景さえあれば発光がそのまま読めるので、控えめな背景演出としてそのまま使えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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';
}
01番は面積12.5%・強度25.8・動いたフレーム23/23でした。
02風の流れ場に沿って横に巻く煙
流れ場の強さは、時間とともに強まったり弱まったりするゲイン値と掛け合わされていて、その値が大きいほど煙柱が上りながら大きく横に曲がります——WIND_FLOW_STRENGTH が曲がりの上限を決め、gustEnvelope(nowMs) は訪問者が動かすスライダーではなく時間ベースのゲインです。flowAngle() は各粒子自身の位置を読むので、発生源に近い粒子ほど曲がりが小さく、すでに高く上った粒子ほど曲がりが大きくなります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
02番は面積11.1%・強度16.7・動いたフレーム23/23でした。
03発生量の値で濃さが変わる煙
発生量の値を上げると重なり合う粒子が増えて、煙がより濃く不透明に見えます——SMOKE_EMISSION_RATE が smkRate の1秒あたりの発生数を決め、DEMO_SWEEP_MS がその値を自動で上下に揺らすので、何も操作しなくても違いがそのまま見えます。警告や注意状態の背景として使うと、濃い煙ほど緊迫感が伝わります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
03番は面積17.7%・強度16.2・動いたフレーム23/23でした。
04発生源から離れるほど溶けて消えていく煙
発生源からの距離に応じて不透明度が下がっていき、ある距離を超えると粒子は完全に消えます——DISSOLVE_DISTANCE がその境界で、SMOKE_DISSOLVE_MS と DRIFT_SPEED_PX_S から一度だけ計算され、時間の長さを移動距離に変換しています。読み込み完了後の後片付け演出として使えば、手動でフェードアウトさせなくても自然に消えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
04番は面積15.5%・強度18.5・動いたフレーム23/23でした。
05時間とともに色が移り変わる煙
立ち上ってからの時間が長いほど、粒子の色が2色の間を少しずつ移り変わります——SMOKE_TINT_CYCLE_MS が SMOKE_TINT_FROM から SMOKE_TINT_TO まで一周する時間を決めます。ブランドカラーのヒーロー背景として使う場合、移り変わりが十分ゆっくりなので警告のようには見えず、雰囲気演出として読めます。

<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">煙が立ち上ってからの時間が経つほど、粒子の色が設定した2色の間で徐々に変わっていく。</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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';
05番は面積16.1%・強度30.6・動いたフレーム23/23でした。
06ポインターを避けて渦を巻く煙
ポインターが煙柱に近づくほど、近くの粒子が渦を巻いて押しのけられます——SMOKE_SWIRL_RADIUS がその押しの届く範囲を決め、SMOKE_SWIRL_STRENGTH が押しの強さを決め、誰もホバーしていないときは virtualPointerOrbit がデモ用の仮想ポインターを自分で動かします。矢印キーはその仮想ポインターを上下左右4方向に動かせるので、マウスがなくても渦が見られます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
06番は面積21.0%・強度29.6・動いたフレーム23/23でした。
07ボタンを押すと弾けるように広がる煙
ボタンを押した瞬間、ひとつの粒子塊が発生してあらゆる方向に弾け飛び、その後すぐに消えていきます——SMOKE_BURST_COUNT が塊の大きさを決め、SMOKE_BURST_LIFE_MS が消える速さを決め、smkBtn はクリックでもEnterでもSpaceでも同じように反応します。送信完了ボタンのフィードバックとして使うと、一度弾けたらすぐに消えるので邪魔になりません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
07番は面積25.8%・強度72.7・動いたフレーム18/23でした——他より低いのは意図どおりで、弾けた塊は固定23フレームの計測時間より先に消え切るため、後半の数フレームは動かす粒子自体がもう残っていないだけです。
08層ごとに速さが違う重なった煙
奥の層ほど粒子の動きが遅くぼやけて見え、手前の層との速度差がそのまま奥行きに読めます——LAYERS が層ごとの粒子プールを別々に持ち、radiusMul・alphaMul が共通の描画ループに触れずに層ごとの大きさと不透明度だけを調整します。ポートフォリオのヒーロー演出として使うと、静止画の状態でも奥行きが伝わります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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();
}
}
}
08番は面積23.2%・強度18.8・動いたフレーム23/23でした。
09スクロール値に反応するヒーロー煙シーン
スクロール値が大きくなるにつれ、煙の発生量と広がりの半径が同時に増えていき、シーン全体の密度が濃くなっていきます——scrollProgress が両方の数値を同時に動かし、実際のスクロール入力がないときは triangleWave() が SCROLL_AUTO_PERIOD_MS ごとに自動で値を行き来させます。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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
09番は面積11.4%・強度18.0・動いたフレーム23/23でした。
煙 エフェクト 9パターンを面積と強度で測定した結果
累積面積は固定カメラのフレームのうち実際に塗り直された割合を平均したもので、強度は塗り直された部分の平均ピクセル差です。07番の弾けは触れたピクセルあたりの強度が最も高い一方で最も寿命が短く、06番のポインター渦は雰囲気演出の中で最も広い面積を占め、09番のヒーローシーンはスクロール値でしか密度が増えないぶん面積は控えめです。煙 エフェクト 動画のような落ち着いた見た目にするなら、面積20%未満・強度30未満に収まるあたりが目安で、この9パターンの大半がそこに収まっています。
| パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 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 |
値が煙を変える場所 — 発生量・上昇速度・溶解時間
以下の3つの値は見た目以上に働いています——SMOKE_EMISSION_RATE は03番が1画面に詰め込む粒子数を決め、SMOKE_RISE_SPEED_PX は01番の煙柱がどれだけ速く上って画面から消えるかを決め、SMOKE_DISSOLVE_MS は04番の粒子がどこまで漂ってから消えるかを決めます。どれも描かれる形そのものは変えず、同じ放射状グラデーションのループがどれだけ濃く・速く・遠くまで読めるかを変えるだけですが、そこがまさに煙 エフェクト 素材として作り込まれたものと、同じ噴煙をただループさせただけのものを分ける部分です。html5 canvas アニメーション9種 — 水滴とブラックホールも似た種類のピクセル単位の計算を、漂う粒子ではなく歪んだ画像の上で行っています。
どこで壊れるか — reduced motion 設定・低スペック端末でのフレーム落ち・背景なしでの使用
prefers-reduced-motion はキャンバス自体を止めるのではなく、読み込み時に全項目から自動再生の .is-demo クラスを外すだけなので、06番は実際にポインターが動けば渦を巻き続け、07番は実際にクリックすれば弾け続けます——止まるのはスクリプトの自動再生ループだけです。すべての項目が粒子ひとつひとつに createRadialGradient() を呼んで描き直しており、そこに globalCompositeOperation: 'lighter' が重なるため、何百もの粒子が重なり合う状況で低スペック端末のフレーム落ちが真っ先に表れます。これらの粒子は暗さを前提に読ませる設計で、9つのどれかを白一色のページに置くと加算発光がほとんど見えなくなります——暗いピクセルを明るくする仕組みであって、明るいページの上に不透明な形として乗る仕組みではないからです。この9つを支える index.html・style.scss の組は59z8ez3nで、この9パターンすべてを支えるキャンバスループがどれだけ小さいかを確かめる一番早い方法です。
変形3つとアクセシビリティ
| 変形 | 値の変化 | 印象 |
|---|---|---|
| 煙の発生量の値を調整する | 03 SMOKE_EMISSION_RATE 4 → 18 | 値を下げると粒子数が少なく保たれて煙は薄くまばらに見え、上げると粒子が密に重なって濃く不透明に見えます。 |
| 煙の上昇速度の値を調整する | 01 SMOKE_RISE_SPEED_PX 12 → 40 | 値を下げるとゆっくり立ち上って長く画面に留まり、上げると勢いよく上って早く画面から消えます。 |
| 煙の溶解時間の値を調整する | 04 SMOKE_DISSOLVE_MS 900 → 2600 | 値を下げると発生源のすぐ近くで消え、上げると遠くまで漂ってからようやく消えます。 |
9つすべてが自分のスクリプトの冒頭で一度だけ prefers-reduced-motion を確認し、個々のプロパティを止めるのではなく自動再生クラスを外すことで応えるので、モーションを減らしたい訪問者にも空白のキャンバスではなく静止した形がきちんと見えます。矢印キーは06番の仮想ポインターを上下左右4方向に動かし、EnterまたはSpaceはクリックと同じように07番の弾けを起こし、09番はArrowUp・ArrowDownに加えてPageUp・PageDownにも反応してスクロールバーなしでスクロール値を動かせます。CanvasRenderingContext2D、requestAnimationFrame()、prefers-reduced-motion はMDNに文書化されています。
煙エフェクトはパーティクルライブラリなしで作れるか?
この9つはどれもライブラリを読み込んでいません。それぞれが単純なオブジェクトの配列を自前で持ち、Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS)) で年齢を進め、寿命が尽きたものを取り除いているだけです——パーティクルライブラリが自前のAPIで包んでいるのと同じ仕組みを、そのまま外に出しているにすぎません。流体シミュレーション9種 — canvas の煙・インク格子計算 (WebGLなし)も同じくライブラリなしのアプローチを、個々の粒子ではなくピクセルグリッド全体で行っています。個々の粒子9つでは足りなくなったときの次の一歩がこちらです。
この9つを試す前によくある質問に答えます
ビルド手順なしで、この9つのどれかをhtml css smoke effectとしてページにそのまま置けますか?
置けます。どの項目も <canvas>・スクリプト・スタイルシートを内包した単体の index.html で、ブラウザで開けばそのまま動きます。バンドラーもフレームワークも要りません。
なぜ07番だけ動いたフレームが他より少ないのですか?
弾けた塊は固定23フレームの計測時間の途中で完全に消え切るため、後半の数フレームには本当に動かす粒子が残っていません。これは計測漏れではなく、設計どおりバーストが働いた結果です。
これらのような煙 エフェクト 動画の背景にWebGLは必要ですか?
必要ありません。9つとも発光には globalCompositeOperation: 'lighter' を使った普通の2D CanvasRenderingContext2D API だけで動いていて、WebGLコンテキストもシェーダーもどこにもありません。









