09密度フィールドの描画
シミュレーションが持っているのは32×32の数値配列だけで、それを画面解像度にどう拡大して描くかで、同じデータがブロック状のピクセルにも滑らかなグラデーションにも見えます。最近傍補間とバイリニア補間を並べて比較します。
使いどころ低解像度計算を高解像度で見せる比較デモ
createImageDataimageSmoothingEnabled

<main class="stage stage--orange">
<div class="df" id="df">
<p class="df__hint">
<svg class="df__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200,32H56A24,24,0,0,0,32,56V200a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V56A24,24,0,0,0,200,32Zm8,168a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H200a8,8,0,0,1,8,8Z" opacity=".2"/><path d="M224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48Z" opacity="0"/><path d="M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40ZM96,200H56V168H96Zm0-56H56V112H96Zm0-56H56V56H96Zm104,112H112V168h88Zm0-56H112V112h88Zm0-56H112V56h88Z"/></g></svg>
<span>同じ32×32の密度配列を二回描きます — 左はマス目そのまま、右は補間で滑らかにします</span>
</p>
<div class="df__duo" id="duo" role="application" aria-label="密度場拡大比較実験盤" tabindex="0">
<div class="df__pane">
<canvas class="df__cv" id="cvA"></canvas>
<b class="df__tag">最近傍</b>
</div>
<div class="df__pane">
<canvas class="df__cv" id="cvB"></canvas>
<b class="df__tag">補間</b>
</div>
<b class="df__badge" aria-hidden="true">32<span class="df__badgeUnit">×32</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유체 밀도장 확대 비교 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·두 판·라벨.
// @keyframes 를 두지 않는다 — 렌더러가 CSS 시계 대신 실시간으로 24장을 찍는다.
// 무대는 $stage-orange(토큰 참조라 BD26 대상 아님). 밀도 리터럴은 따뜻한 파스텔로 직접 잡는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.df {
// 밀도 농도 세 단계 — 모래(채도 .53)·밀짚(.52)·황동(.50). 종이색 base 는 주황 무대 위 판색
--den-1: #e6d0ad;
--den-2: #dab88a;
--den-3: #c9985e;
--den-paper: #fff7ea;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.df__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.df__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.df__duo {
position: relative; height: 208px;
display: grid; grid-template-columns: 1fr 1fr; gap: $sp-1;
cursor: crosshair; touch-action: none;
}
.df__duo:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.df__pane {
position: relative;
border-radius: $r-card; overflow: hidden;
background: #fff7ea;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .22);
}
.df__cv { display: block; width: 100%; height: 100%; }
.df__tag {
position: absolute; left: $sp-1; bottom: $sp-1;
padding: 0 $sp-1; border-radius: $r-pill;
background: rgba($subject-ink, .82);
color: $subject-cream; font-weight: 700; font-size: 10px; line-height: 18px;
}
.df__badge {
position: absolute; right: $sp-1; top: $sp-1;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .82);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.df__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.df { gap: $sp-1; }
.df__duo { height: 124px; }
.df__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — 루프가 멈춘 한 장이 남으므로 커서 신호만 바꾼다.
@media (prefers-reduced-motion: reduce) {
.df.is-still .df__duo { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.df {
--den-1: #e6d0ad;
--den-2: #dab88a;
--den-3: #c9985e;
--den-paper: #fff7ea;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.df__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.df__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.df__duo {
position: relative;
height: 208px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
cursor: crosshair;
touch-action: none;
}
.df__duo:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.df__pane {
position: relative;
border-radius: 16px;
overflow: hidden;
background: #fff7ea;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.22);
}
.df__cv {
display: block;
width: 100%;
height: 100%;
}
.df__tag {
position: absolute;
left: 4px;
bottom: 4px;
padding: 0 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
font-weight: 700;
font-size: 10px;
line-height: 18px;
}
.df__badge {
position: absolute;
right: 4px;
top: 4px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.df__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.df {
gap: 4px;
}
.df__duo {
height: 124px;
}
.df__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.df.is-still .df__duo {
cursor: default !important;
}
}// 유체 밀도장 확대 비교 — 시뮬레이션 데이터는 32×32 한 장뿐이다(그게 이 항목의 핵심 소재).
// 같은 배열을 두 캔버스에 그리되 왼쪽은 imageSmoothingEnabled=false(最近傍 이웃, 칸이 그대로 보임),
// 오른쪽은 true + quality high(쌍선형 補間, 부드럽게 펴짐)로 확대한다. 렌더 경로만 다르다.
var root = document.getElementById('df');
var duo = document.getElementById('duo');
var cvA = document.getElementById('cvA');
var cvB = document.getElementById('cvB');
var ctxA = cvA.getContext('2d', { willReadFrequently: true });
var ctxB = cvB.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var W1 = css.getPropertyValue('--den-1').trim();
var W2 = css.getPropertyValue('--den-2').trim();
var W3 = css.getPropertyValue('--den-3').trim();
var PAPER = css.getPropertyValue('--den-paper').trim();
function hex(c) { return [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)]; }
var CR = hex(PAPER), C1 = hex(W1), C2 = hex(W2), C3 = hex(W3);
// N=32 고정 — 이 값 자체가 보여주고 싶은 내용이다
var N = 32;
var SIZE = (N + 2) * (N + 2);
var IX = function (x, y) { return x + (N + 2) * y; };
var u = new Float32Array(SIZE), v = new Float32Array(SIZE);
var u0 = new Float32Array(SIZE), v0 = new Float32Array(SIZE);
var d = new Float32Array(SIZE), d0 = new Float32Array(SIZE);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, seedT = 0;
// 오프스크린 32×32 원본 — 이 한 장을 양쪽이 같이 쓴다
var src = document.createElement('canvas');
src.width = N; src.height = N;
var sctx = src.getContext('2d');
var simg = sctx.createImageData(N, N);
function fit() {
var rect = duo.getBoundingClientRect();
var w = Math.round(rect.width), h = Math.round(rect.height);
cvA.width = w; cvA.height = h;
cvB.width = w; cvB.height = h;
}
function advect(b, dt) {
var s = b === 0 ? u0 : b === 1 ? v0 : d0;
var dst = b === 0 ? u : b === 1 ? v : d;
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var x = i - dt * N * u0[IX(i, j)];
var y = j - dt * N * v0[IX(i, j)];
if (x < 0.5) x = 0.5; else if (x > N + 0.5) x = N + 0.5;
var i0 = x | 0, i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
var j0 = y | 0, j1 = j0 + 1;
var s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
dst[IX(i, j)] = s0 * (t0 * s[IX(i0, j0)] + t1 * s[IX(i0, j1)]) +
s1 * (t0 * s[IX(i1, j0)] + t1 * s[IX(i1, j1)]);
}
}
}
function bounds() {
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
u[IX(i, 0)] = u[IX(i, 1)] * 0.5; u[IX(i, N + 1)] = u[IX(i, N)] * 0.5;
v[IX(i, 0)] = v[IX(i, 1)] * 0.5; v[IX(i, N + 1)] = v[IX(i, N)] * 0.5;
}
for (var j = 1; j <= N; j++) {
d[IX(0, j)] = d[IX(1, j)] * 0.5; d[IX(N + 1, j)] = d[IX(N, j)] * 0.5;
u[IX(0, j)] = u[IX(1, j)] * 0.5; u[IX(N + 1, j)] = u[IX(N, j)] * 0.5;
v[IX(0, j)] = v[IX(1, j)] * 0.5; v[IX(N + 1, j)] = v[IX(N, j)] * 0.5;
}
}
// 결정적 시드 — 두 시드가 대각선으로 지나가며 밀도를 심는다. seedT 고정이라 매번 같은 흐름
function inject(t) {
var ax = N * 0.34 + Math.sin(t * 0.8) * N * 0.16;
var ay = N * 0.3 + t * N * 0.055;
var bx = N * 0.66 + Math.sin(t * 1.1 + 2.0) * N * 0.14;
var by = N * 0.75 - Math.sin(t * 0.7) * N * 0.1;
splatDye(Math.round(ax), Math.round(ay), 1);
splatDye(Math.round(bx), Math.round(by), 0.85);
}
function splatDye(cx, cy, amp) {
var rad = 2.4;
for (var j = -3; j <= 3; j++) {
for (var i = -3; i <= 3; i++) {
var q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
var gi = cx + i, gj = cy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
d[IX(gi, gj)] += Math.exp(-q * 2.2) * amp;
}
}
}
// 느린 확산 바람 — 밀도가 칸 사이를 스며들게 해 두 렌더의 차이를 계속 보여준다
function drift() {
var cx = N * 0.5, cy = N * 0.5;
for (var j = 2; j <= N - 1; j++) {
for (var i = 2; i <= N - 1; i++) {
var dx = i - cx, dy = j - cy;
var q = Math.sqrt(dx * dx + dy * dy) / (N * 0.5);
var w = Math.exp(-q * q * 1.6) * 0.4;
u[IX(i, j)] += (-dy * 0.045 + 0.07) * w;
v[IX(i, j)] += (dx * 0.045) * w;
}
}
}
function step(dt) {
drift();
inject(seedT);
u0.set(u); v0.set(v); d0.set(d);
advect(0, dt); u0.set(u);
advect(1, dt); v0.set(v);
advect(2, dt);
bounds();
for (var k = 0; k < SIZE; k++) { d[k] *= 0.988; u[k] *= 0.99; v[k] *= 0.99; }
seedT += dt;
}
// 오프스크린 32×32 한 장을 그린다 — 여기까지가 데이터. 확대는 아래 두 호출이 다르게 한다
function paintSource() {
var px = simg.data;
for (var j = 0; j < N; j++) {
for (var i = 0; i < N; i++) {
var q = d[IX(i + 1, j + 1)];
var band, a;
if (q <= 0.02) { band = CR; a = 0; }
else if (q > 1.15) { band = C3; a = Math.min(1, q - 0.15); }
else if (q > 0.5) { band = C2; a = Math.min(1, q + 0.1); }
else { band = C1; a = Math.min(1, q * 1.6); }
var o = (j * N + i) * 4;
px[o] = CR[0] + (band[0] - CR[0]) * a;
px[o + 1] = CR[1] + (band[1] - CR[1]) * a;
px[o + 2] = CR[2] + (band[2] - CR[2]) * a;
px[o + 3] = 255;
}
}
sctx.putImageData(simg, 0, 0);
}
function render() {
paintSource();
var w = cvA.width, h = cvA.height;
// 왼쪽 — 最近傍 이웃: 칸이 네모 그대로 커진다
ctxA.imageSmoothingEnabled = false;
ctxA.clearRect(0, 0, w, h);
ctxA.drawImage(src, 0, 0, w, h);
// 오른쪽 — 쌍선형 補間: 같은 소스를 부드럽게 펴낸다
ctxB.imageSmoothingEnabled = true;
ctxB.imageSmoothingQuality = 'high';
ctxB.clearRect(0, 0, w, h);
ctxB.drawImage(src, 0, 0, w, h);
}
function frame() {
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
// 사람 손 — 간단한 스플랏. 두 판이 같은 배열을 쓰므로 한 번의 스플랏이 양쪽에 동시에 나타난다
function splat(gx, gy, fx, fy, amt) {
var rad = 3;
for (var j = -rad; j <= rad; j++) {
for (var i = -rad; i <= rad; i++) {
var q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
var gi = gx + i, gj = gy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
var f = Math.exp(-q * 2.6) * amt;
var idx = IX(gi, gj);
u[idx] += fx * f; v[idx] += fy * f;
d[idx] = Math.min(2, d[idx] + f * 0.8);
}
}
}
var last = null;
var target = null;
duo.addEventListener('pointerdown', function (e) {
var rect = duo.getBoundingClientRect();
var half = rect.width / 2;
target = (e.clientX - rect.left) < half ? cvA : cvB;
target.dispatchEvent(new PointerEvent('pointermove', { clientX: e.clientX, clientY: e.clientY }));
});
duo.addEventListener('pointermove', function (e) {
if (!target && e.pointerType === 'mouse' && e.buttons === 0) {
// 마우스는 누르지 않아도 흐르는 배경 시드가 있으니 스플랏은 억제한다(08 과 체감을 구분)
}
var host = target || duo;
var rect = host.getBoundingClientRect();
var gx = 1 + Math.round((e.clientX - rect.left) / rect.width * (N - 1));
var gy = 1 + Math.round((e.clientY - rect.top) / rect.height * (N - 1));
if (last) {
var fx = (e.clientX - last.x) * 0.15, fy = (e.clientY - last.y) * 0.15;
splat(gx, gy, fx, fy, Math.min(1.8, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
last = { x: e.clientX, y: e.clientY };
if (reduce) render();
});
function endPtr() { last = null; target = null; }
duo.addEventListener('pointerup', endPtr);
duo.addEventListener('pointerleave', endPtr);
duo.addEventListener('keydown', function (e) {
var dir = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] }[e.key];
if (!dir) return;
e.preventDefault();
splat(N >> 1, N >> 1, dir[0], dir[1], 1.4);
if (reduce) render();
});
window.addEventListener('resize', function () { fit(); if (reduce) render(); });
fit();
if (reduce) {
// reduce — 40 걸음 미리 감아 두 판의 차이가 분명한 한 장을 그린다
for (var k = 0; k < 40; k++) step(1 / 30);
render();
} else {
raf = requestAnimationFrame(frame);
}// DensityField — vanilla 판(run/308/demo/09-fluid-density-field/index.html) 의 React 포트.
// 시뮬레이션 데이터는 32×32 배열 한 장뿐(이 항목의 핵심 소재). 같은 오프스크린 소스를
// imageSmoothingEnabled=false(최근접)와 true+quality high(쌍선형)로 각각 확대해 나란히 비교한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DensityField.module.scss";
export type DensityFieldProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number; buttons: number; pointerType: string };
const N = 32;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
export default function DensityField({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: DensityFieldProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const duoRef = useRef<HTMLDivElement | null>(null);
const cvARef = useRef<HTMLCanvasElement | null>(null);
const cvBRef = useRef<HTMLCanvasElement | null>(null);
const ctxARef = useRef<CanvasRenderingContext2D | null>(null);
const ctxBRef = useRef<CanvasRenderingContext2D | null>(null);
const srcRef = useRef<HTMLCanvasElement | null>(null);
const sctxRef = useRef<CanvasRenderingContext2D | null>(null);
const simgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const palRef = useRef({ cr: [255, 247, 234], c1: [230, 208, 173], c2: [218, 184, 138], c3: [201, 152, 94] }) as Box<{ cr: number[]; c1: number[]; c2: number[]; c3: number[] }>;
const u = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const v = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const u0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const v0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const d = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const d0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const seedTRef = useRef(0) as Box<number>;
const lastRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
const targetRef = useRef<HTMLCanvasElement | null>(null) as Box<HTMLCanvasElement | null>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const duo = duoRef.current, cvA = cvARef.current, cvB = cvBRef.current;
if (!duo || !cvA || !cvB) return;
const rect = duo.getBoundingClientRect();
const w = Math.round(rect.width), h = Math.round(rect.height);
cvA.width = w; cvA.height = h;
cvB.width = w; cvB.height = h;
}
function advect(b: 0 | 1 | 2, dt: number): void {
const s = b === 0 ? u0.current : b === 1 ? v0.current : d0.current;
const dst = b === 0 ? u.current : b === 1 ? v.current : d.current;
const uu = u0.current, vv = v0.current;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
let x = i - dt * N * uu[IX(i, j)];
let y = j - dt * N * vv[IX(i, j)];
if (x < 0.5) x = 0.5; else if (x > N + 0.5) x = N + 0.5;
const i0 = x | 0, i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
const j0 = y | 0, j1 = j0 + 1;
const s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
dst[IX(i, j)] = s0 * (t0 * s[IX(i0, j0)] + t1 * s[IX(i0, j1)]) +
s1 * (t0 * s[IX(i1, j0)] + t1 * s[IX(i1, j1)]);
}
}
}
function bounds(): void {
const dd = d.current, uu = u.current, vv = v.current;
for (let i = 1; i <= N; i++) {
dd[IX(i, 0)] = dd[IX(i, 1)] * 0.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
uu[IX(i, 0)] = uu[IX(i, 1)] * 0.5; uu[IX(i, N + 1)] = uu[IX(i, N)] * 0.5;
vv[IX(i, 0)] = vv[IX(i, 1)] * 0.5; vv[IX(i, N + 1)] = vv[IX(i, N)] * 0.5;
}
for (let j = 1; j <= N; j++) {
dd[IX(0, j)] = dd[IX(1, j)] * 0.5; dd[IX(N + 1, j)] = dd[IX(N, j)] * 0.5;
uu[IX(0, j)] = uu[IX(1, j)] * 0.5; uu[IX(N + 1, j)] = uu[IX(N, j)] * 0.5;
vv[IX(0, j)] = vv[IX(1, j)] * 0.5; vv[IX(N + 1, j)] = vv[IX(N, j)] * 0.5;
}
}
function splatDye(cx: number, cy: number, amp: number): void {
const rad = 2.4;
const dd = d.current;
for (let j = -3; j <= 3; j++) {
for (let i = -3; i <= 3; i++) {
const q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
const gi = cx + i, gj = cy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
dd[IX(gi, gj)] += Math.exp(-q * 2.2) * amp;
}
}
}
function inject(t: number): void {
const ax = N * 0.34 + Math.sin(t * 0.8) * N * 0.16;
const ay = N * 0.3 + t * N * 0.055;
const bx = N * 0.66 + Math.sin(t * 1.1 + 2.0) * N * 0.14;
const by = N * 0.75 - Math.sin(t * 0.7) * N * 0.1;
splatDye(Math.round(ax), Math.round(ay), 1);
splatDye(Math.round(bx), Math.round(by), 0.85);
}
function drift(): void {
const cx = N * 0.5, cy = N * 0.5;
const uu = u.current, vv = v.current;
for (let j = 2; j <= N - 1; j++) {
for (let i = 2; i <= N - 1; i++) {
const dx = i - cx, dy = j - cy;
const q = Math.sqrt(dx * dx + dy * dy) / (N * 0.5);
const w = Math.exp(-q * q * 1.6) * 0.4;
uu[IX(i, j)] += (-dy * 0.045 + 0.07) * w;
vv[IX(i, j)] += (dx * 0.045) * w;
}
}
}
function step(dt: number): void {
drift();
inject(seedTRef.current);
u0.current.set(u.current); v0.current.set(v.current); d0.current.set(d.current);
advect(0, dt); u0.current.set(u.current);
advect(1, dt); v0.current.set(v.current);
advect(2, dt);
bounds();
const dd = d.current, uu = u.current, vv = v.current;
for (let k = 0; k < SIZE; k++) { dd[k] *= 0.988; uu[k] *= 0.99; vv[k] *= 0.99; }
seedTRef.current += dt;
}
function paintSource(): void {
const sctx = sctxRef.current, simg = simgRef.current;
if (!sctx || !simg) return;
const px = simg.data;
const pal = palRef.current;
const dd = d.current;
for (let j = 0; j < N; j++) {
for (let i = 0; i < N; i++) {
const q = dd[IX(i + 1, j + 1)];
let band: number[], a: number;
if (q <= 0.02) { band = pal.cr; a = 0; }
else if (q > 1.15) { band = pal.c3; a = Math.min(1, q - 0.15); }
else if (q > 0.5) { band = pal.c2; a = Math.min(1, q + 0.1); }
else { band = pal.c1; a = Math.min(1, q * 1.6); }
const o = (j * N + i) * 4;
px[o] = pal.cr[0] + (band[0] - pal.cr[0]) * a;
px[o + 1] = pal.cr[1] + (band[1] - pal.cr[1]) * a;
px[o + 2] = pal.cr[2] + (band[2] - pal.cr[2]) * a;
px[o + 3] = 255;
}
}
sctx.putImageData(simg, 0, 0);
}
function render(): void {
paintSource();
const src = srcRef.current;
const ctxA = ctxARef.current, ctxB = ctxBRef.current;
const cvA = cvARef.current, cvB = cvBRef.current;
if (!src || !ctxA || !ctxB || !cvA || !cvB) return;
const w = cvA.width, h = cvA.height;
ctxA.imageSmoothingEnabled = false;
ctxA.clearRect(0, 0, w, h);
ctxA.drawImage(src, 0, 0, w, h);
ctxB.imageSmoothingEnabled = true;
ctxB.imageSmoothingQuality = "high";
ctxB.clearRect(0, 0, w, h);
ctxB.drawImage(src, 0, 0, w, h);
}
function frame(): void {
step(1 / 30);
render();
rafRef.current = requestAnimationFrame(frame);
}
function splat(gx: number, gy: number, fx: number, fy: number, amt: number): void {
const rad = 3;
const uu = u.current, vv = v.current, dd = d.current;
for (let j = -rad; j <= rad; j++) {
for (let i = -rad; i <= rad; i++) {
const q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
const gi = gx + i, gj = gy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
const f = Math.exp(-q * 2.6) * amt;
const idx = IX(gi, gj);
uu[idx] += fx * f; vv[idx] += fy * f;
dd[idx] = Math.min(2, dd[idx] + f * 0.8);
}
}
}
function onDown(e: PtrEvt): void {
const duo = duoRef.current;
if (!duo) return;
const rect = duo.getBoundingClientRect();
const half = rect.width / 2;
targetRef.current = (e.clientX - rect.left) < half ? cvARef.current : cvBRef.current;
}
function onMove(e: PtrEvt): void {
const host = targetRef.current || duoRef.current;
if (!host) return;
const rect = host.getBoundingClientRect();
const gx = 1 + Math.round((e.clientX - rect.left) / rect.width * (N - 1));
const gy = 1 + Math.round((e.clientY - rect.top) / rect.height * (N - 1));
const last = lastRef.current;
if (last) {
const fx = (e.clientX - last.x) * 0.15, fy = (e.clientY - last.y) * 0.15;
splat(gx, gy, fx, fy, Math.min(1.8, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
lastRef.current = { x: e.clientX, y: e.clientY };
if (reduced()) render();
}
function endPtr(): void { lastRef.current = null; targetRef.current = null; }
function onKey(e: KeyEvt): void {
const dir: Record<string, number[]> = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] };
const dv = dir[e.key];
if (!dv) return;
e.preventDefault();
splat(N >> 1, N >> 1, dv[0], dv[1], 1.4);
if (reduced()) render();
}
function onResize(): void { fit(); if (reduced()) render(); }
function hexToRgb(c: string): number[] {
return [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
}
useEffect(() => {
const cvA = cvARef.current, cvB = cvBRef.current;
if (!cvA || !cvB) return;
ctxARef.current = cvA.getContext("2d", { willReadFrequently: true });
ctxBRef.current = cvB.getContext("2d", { willReadFrequently: true });
const src = document.createElement("canvas");
src.width = N; src.height = N;
srcRef.current = src;
sctxRef.current = src.getContext("2d");
if (sctxRef.current) simgRef.current = sctxRef.current.createImageData(N, N);
const root = rootRef.current;
if (root) {
const css = getComputedStyle(root);
const p1 = css.getPropertyValue("--den-1").trim();
const p2 = css.getPropertyValue("--den-2").trim();
const p3 = css.getPropertyValue("--den-3").trim();
const pp = css.getPropertyValue("--den-paper").trim();
if (p1 && p2 && p3 && pp) {
palRef.current = { cr: hexToRgb(pp), c1: hexToRgb(p1), c2: hexToRgb(p2), c3: hexToRgb(p3) };
}
}
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
if (root) root.classList.add("is-still");
for (let k = 0; k < 40; k++) step(1 / 30);
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Z" opacity=".2"/><path d="M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40ZM96,200H56V168H96Zm0-56H56V112H96Zm0-56H56V56H96Zm104,112H112V168h88Zm0-56H112V112h88Zm0-56H112V56h88Z"/></g></svg>
<span>같은 32×32 밀도 배열을 두 번 그립니다 — 왼쪽은 칸 그대로, 오른쪽은 보간으로 펴냅니다</span>
</p>
<div
className={styles.duo}
ref={duoRef}
role="application"
aria-label="밀도장 확대 비교 실험판"
tabIndex={0}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={endPtr}
onPointerLeave={endPtr}
onKeyDown={onKey}
>
<div className={styles.pane}>
<canvas className={styles.cv} ref={cvARef}></canvas>
<b className={styles.tag}>최근접</b>
</div>
<div className={styles.pane}>
<canvas className={styles.cv} ref={cvBRef}></canvas>
<b className={styles.tag}>보간</b>
</div>
<b className={styles.badge} aria-hidden="true">32<span className={styles.badgeUnit}>×32</span></b>
</div>
</div>
</main>
);
}09 密度フィールドの描画 — 計測 累積面積 54.627% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 流体シミュレーション9種 — canvas の煙・インク格子計算 (WebGLなし)
このデモが属する記事
流体シミュレーション9種 — canvas の煙・インク格子計算 (WebGLなし)流体シミュレーションとは、ナビエ–ストークス方程式を厳密に解くことではなく、格子状に並べた密度と速度の配列を毎フレーム「移流・拡散・減衰」させて流れを模す計算のことだ。この記事では canvas 2D コンテキストだけで、32×32 の F この記事のデモをすべて見る


