07染料の混合
密度チャンネル三つ(RGB)をそれぞれ独立に移流・拡散させると、二色が水のように混ざり中間色が滑かに生まれます。色を先に混ぜて描くのではなく、場の計算の結果として混ざります。
使いどころ二つのブランドカラーを混ぜるヒーロー
putImageDataFloat32Array

<main class="stage stage--yellow">
<div class="dm" id="dm">
<p class="dm__hint">
<svg class="dm__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,184a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,208ZM112,72v56a16,16,0,0,0,32,0V72a16,16,0,0,0-32,0Z" opacity=".2"/><path d="M128,16A112,112,0,1,0,240,128,112.12,112.12,0,0,0,128,16Zm0,192a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,208ZM112,72v56a16,16,0,0,0,32,0V72a16,16,0,0,0-32,0Z"/></g></svg>
<span>三つのチャンネルがそれぞれ流れて混ざり合います — カーソルで二色の間をかき混ぜてみましょう</span>
</p>
<div class="dm__field" id="field" role="application" aria-label="染料混合実験盤" tabindex="0">
<canvas class="dm__cv" id="cv"></canvas>
<b class="dm__badge" aria-hidden="true">3<span class="dm__badgeUnit">チャンネル</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유체 염료 혼합 — 움직임은 전부 캔버스 rAF 루프 안에 있다(01 과 같은 구조). CSS 는 무대·판·배지.
// @keyframes 를 두지 않는다 — 렌더러가 CSS 시계 대신 실시간으로 24장을 찍는다.
// 무대는 $stage-yellow(토큰 참조라 BD26 대상 아님). 염료 리터럴은 전부 파스텔로 직접 잡는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-yellow);
.dm {
// 염료 채널 세 색 — 파스텔 로즈(채도 .42)·세이지(.23)·코발트(.40). 종이색 base 는 노랑 무대 위 판색
--dye-r: #dba7ab;
--dye-g: #a8c9ab;
--dye-b: #a7b9d9;
--dye-paper: #fff3cf;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.dm__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;
}
.dm__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.dm__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: #fff3cf;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .22);
cursor: crosshair; touch-action: none;
}
.dm__field:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.dm__cv { display: block; width: 100%; height: 100%; }
.dm__badge {
position: absolute; right: $sp-2; top: $sp-2;
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;
}
.dm__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.dm { gap: $sp-1; }
.dm__field { height: 124px; }
.dm__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — 루프가 멈춘 한 장이 남으므로 커서 신호만 바꾼다.
@media (prefers-reduced-motion: reduce) {
.dm.is-still .dm__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dm {
--dye-r: #dba7ab;
--dye-g: #a8c9ab;
--dye-b: #a7b9d9;
--dye-paper: #fff3cf;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.dm__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;
}
.dm__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.dm__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff3cf;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.22);
cursor: crosshair;
touch-action: none;
}
.dm__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.dm__cv {
display: block;
width: 100%;
height: 100%;
}
.dm__badge {
position: absolute;
right: 8px;
top: 8px;
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;
}
.dm__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.dm {
gap: 4px;
}
.dm__field {
height: 124px;
}
.dm__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dm.is-still .dm__field {
cursor: default !important;
}
}// 유체 염료 혼합 — 밀도 채널을 하나가 아니라 셋(R,G,B 역할)으로 두고 각각 독립 이류한다.
// 속도장은 하나를 공유하므로 세 염료는 같은 물 위에서 움직이고, 겹치는 곳의 중간색은
// 색을 미리 섞어 그린 결과가 아니라 チャンネル 값의 합이 렌더식을 통과한 결과다.
var root = document.getElementById('dm');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var ROSE = css.getPropertyValue('--dye-r').trim();
var SAGE = css.getPropertyValue('--dye-g').trim();
var COBALT = css.getPropertyValue('--dye-b').trim();
var CREAM = css.getPropertyValue('--dye-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(CREAM), K1 = hex(ROSE), K2 = hex(SAGE), K3 = hex(COBALT);
// N=48 — 채널이 셋이라 32보다 살짝 올려도 한 프레임 계산이 가볍다(칸 2,500 × 3チャンネル)
var N = 48;
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 r = new Float32Array(SIZE), r0 = new Float32Array(SIZE);
var g = new Float32Array(SIZE), g0 = new Float32Array(SIZE);
var b = new Float32Array(SIZE), b0 = new Float32Array(SIZE);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, seedT = 0, spin = 0;
function fit() {
var rect = field.getBoundingClientRect();
W = rect.width; H = rect.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
// 후방 이류 — 한 칸의 새 값을 속도를 dt 만큼 거슬러 간 좌표의 옛 값으로 채운다(チャンネル 공통)
function advect(dst, src, dt) {
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 * src[IX(i0, j0)] + t1 * src[IX(i0, j1)]) +
s1 * (t0 * src[IX(i1, j0)] + t1 * src[IX(i1, j1)]);
}
}
}
// 확산 — 옆칸과의 평균으로 번지게 한다. 이 항이 있어야 색이 스르 녹듯 섞인다
function diffuse(dst, src, k) {
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var idx = IX(i, j);
dst[idx] = src[idx] + k * (src[IX(i - 1, j)] + src[IX(i + 1, j)] + src[IX(i, j - 1)] + src[IX(i, j + 1)] - 4 * src[idx]);
}
}
}
// チャンネル 경계 — 벽으로 새어나간 염료는 절반으로 감쇠
function dyeBounds(d) {
var n2 = N + 2;
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;
}
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;
}
}
function velBounds() {
var n2 = N + 2;
for (var i = 1; i <= N; i++) {
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++) {
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;
}
}
// 공통 속도장 — 중심 회전(와지) + 왼쪽에서 오른쪽 흐름. 두 염료를 계속 서로 쪽으로 밀어낸다
function velSource() {
var cx = N * 0.5, cy = N * 0.52;
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 * 2.2) * 0.55;
u[IX(i, j)] += (-dy * 0.05 + 0.16) * w;
v[IX(i, j)] += (dx * 0.05) * w;
}
}
spin += 1;
}
// 시드 주입 — 좌상단에서 도는 로즈(R), 우하단에서 도는 코발트(B), 가운데를 지나는 세이지(G).
// seedT 로 결정적: 같은 편의 gif·poster 는 언제나 같은 흐름이 담긴다.
function inject(t) {
var ax = N * 0.27 + Math.sin(t * 1.1) * N * 0.09;
var ay = N * 0.3 + Math.cos(t * 0.9) * N * 0.08;
var bx = N * 0.7 + Math.sin(t * 0.8 + 2.1) * N * 0.08;
var by = N * 0.7 + Math.cos(t * 1.3 + 1.4) * N * 0.07;
var mx = N * 0.5 + Math.sin(t * 0.55 + 4.0) * N * 0.13;
var my = N * 0.5 + Math.cos(t * 0.62 + 1.0) * N * 0.12;
splatDye(r0, ax, ay, 1.3);
splatDye(b0, bx, by, 1);
splatDye(g0, mx, my, 0.8);
}
function splatDye(d, cx, cy, amp) {
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 = Math.round(cx) + i, gj = Math.round(cy) + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
d[IX(gi, gj)] += Math.exp(-q * 2.4) * amp;
}
}
}
function step(dt) {
velSource();
u0.set(u); v0.set(v);
advect(u, u0, dt); u0.set(u);
advect(v, v0, dt); v0.set(v);
velBounds();
r0.set(r); g0.set(g); b0.set(b);
inject(seedT);
diffuse(r, r0, 0.09); diffuse(g, g0, 0.09); diffuse(b, b0, 0.09);
r0.set(r); g0.set(g); b0.set(b);
advect(r, r0, dt); advect(g, g0, dt); advect(b, b0, dt);
dyeBounds(r); dyeBounds(g); dyeBounds(b);
for (var k = 0; k < SIZE; k++) { r[k] *= 0.996; g[k] *= 0.996; b[k] *= 0.996; }
seedT += dt;
}
// 렌더 — finalRGB = base + R*colorR + G*colorG + B*colorB. 중간색은 이 식을 통과하며 자연히 파생한다
function render() {
var img = ctx.createImageData(cv.width, cv.height);
var px = img.data;
var cellW = cv.width / N, cellH = cv.height / N;
for (var py = 0; py < cv.height; py++) {
var gy = Math.min(N - 1, (py / cellH) | 0);
for (var pxx = 0; pxx < cv.width; pxx++) {
var gx = Math.min(N - 1, (pxx / cellW) | 0);
var idx = IX(gx + 1, gy + 1);
var qr = Math.min(1.6, r[idx]) / 1.6;
var qg = Math.min(1.6, g[idx]) / 1.6;
var qb = Math.min(1.6, b[idx]) / 1.6;
var o = (py * cv.width + pxx) * 4;
px[o] = Math.min(255, CR[0] + qr * (K1[0] - CR[0]) + qg * (K2[0] - CR[0]) + qb * (K3[0] - CR[0]));
px[o + 1] = Math.min(255, CR[1] + qr * (K1[1] - CR[1]) + qg * (K2[1] - CR[1]) + qb * (K3[1] - CR[1]));
px[o + 2] = Math.min(255, CR[2] + qr * (K1[2] - CR[2]) + qg * (K2[2] - CR[2]) + qb * (K3[2] - CR[2]));
px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame() {
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
// 사람이 휘젓기 — 프레임 간 위치 차이를 지수 반경으로 공통 속도장에 얹는다(01 splat 확대판)
function splat(gx, gy, fx, fy, amt) {
var rad = 4;
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;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
var f = Math.exp(-q * 3) * amt;
var idx = IX(gx + i, gy + j);
u[idx] += fx * f; v[idx] += fy * f;
}
}
}
var last = null;
field.addEventListener('pointermove', function (e) {
var rect = field.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.18, fy = (e.clientY - last.y) * 0.18;
splat(gx, gy, fx, fy, Math.min(2, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.9);
}
last = { x: e.clientX, y: e.clientY };
if (reduce) render();
});
field.addEventListener('pointerleave', function () { last = null; });
field.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.2);
if (reduce) render();
});
window.addEventListener('resize', function () { fit(); if (reduce) render(); });
fit();
if (reduce) {
// reduce — 시뮬레이션을 44 걸음만 앞으로 감아 두 색이 만난 한 장을 그린다(빈 화면이 아니다)
for (var k = 0; k < 44; k++) step(1 / 30);
render();
} else {
raf = requestAnimationFrame(frame);
}// DyeMix — vanilla 판(run/308/demo/07-fluid-dye-mix/index.html) 의 React 포트.
// 밀도 채널을 하나가 아니라 셋(R,G,B 역할)으로 두고 공유 속도장 위에서 각각 독립 이류·확산한다.
// 겹치는 곳의 중간색은 미리 섞은 색이 아니라 채널 값이 렌더식을 통과한 결과다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DyeMix.module.scss";
export type DyeMixProps = {
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 };
const N = 48;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
export default function DyeMix({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#a8c9ab",
}: DyeMixProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const palRef = useRef({ cr: [255, 243, 207], k1: [219, 167, 171], k2: [168, 201, 171], k3: [167, 185, 217] }) as Box<{ cr: number[]; k1: number[]; k2: number[]; k3: 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 r = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const r0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const g = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const g0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const bC = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const b0 = 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 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 field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const rect = field.getBoundingClientRect();
cv.width = Math.round(rect.width);
cv.height = Math.round(rect.height);
}
function advect(dst: Float32Array, src: Float32Array, dt: number): void {
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 * src[IX(i0, j0)] + t1 * src[IX(i0, j1)]) +
s1 * (t0 * src[IX(i1, j0)] + t1 * src[IX(i1, j1)]);
}
}
}
function diffuse(dst: Float32Array, src: Float32Array, k: number): void {
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const idx = IX(i, j);
dst[idx] = src[idx] + k * (src[IX(i - 1, j)] + src[IX(i + 1, j)] + src[IX(i, j - 1)] + src[IX(i, j + 1)] - 4 * src[idx]);
}
}
}
function dyeBounds(d: Float32Array): void {
for (let 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;
}
for (let 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;
}
}
function velBounds(): void {
const uu = u.current, vv = v.current;
for (let i = 1; i <= N; i++) {
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++) {
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 velSource(): void {
const cx = N * 0.5, cy = N * 0.52;
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 * 2.2) * 0.55;
uu[IX(i, j)] += (-dy * 0.05 + 0.16) * w;
vv[IX(i, j)] += (dx * 0.05) * w;
}
}
}
function splatDye(d: Float32Array, cx: number, cy: number, amp: number): void {
const rad = 3;
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 = Math.round(cx) + i, gj = Math.round(cy) + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
d[IX(gi, gj)] += Math.exp(-q * 2.4) * amp;
}
}
}
function inject(t: number): void {
const ax = N * 0.27 + Math.sin(t * 1.1) * N * 0.09;
const ay = N * 0.3 + Math.cos(t * 0.9) * N * 0.08;
const bx = N * 0.7 + Math.sin(t * 0.8 + 2.1) * N * 0.08;
const by = N * 0.7 + Math.cos(t * 1.3 + 1.4) * N * 0.07;
const mx = N * 0.5 + Math.sin(t * 0.55 + 4.0) * N * 0.13;
const my = N * 0.5 + Math.cos(t * 0.62 + 1.0) * N * 0.12;
splatDye(r0.current, ax, ay, 1.3);
splatDye(b0.current, bx, by, 1);
splatDye(g0.current, mx, my, 0.8);
}
function step(dt: number): void {
velSource();
u0.current.set(u.current); v0.current.set(v.current);
advect(u.current, u0.current, dt); u0.current.set(u.current);
advect(v.current, v0.current, dt); v0.current.set(v.current);
velBounds();
r0.current.set(r.current); g0.current.set(g.current); b0.current.set(bC.current);
inject(seedTRef.current);
diffuse(r.current, r0.current, 0.09); diffuse(g.current, g0.current, 0.09); diffuse(bC.current, b0.current, 0.09);
r0.current.set(r.current); g0.current.set(g.current); b0.current.set(bC.current);
advect(r.current, r0.current, dt); advect(g.current, g0.current, dt); advect(bC.current, b0.current, dt);
dyeBounds(r.current); dyeBounds(g.current); dyeBounds(bC.current);
const rr = r.current, gg = g.current, bb = bC.current;
for (let k = 0; k < SIZE; k++) { rr[k] *= 0.996; gg[k] *= 0.996; bb[k] *= 0.996; }
seedTRef.current += dt;
}
function render(): void {
const ctx = ctxRef.current, cv = cvRef.current;
if (!ctx || !cv) return;
const img = ctx.createImageData(cv.width, cv.height);
const px = img.data;
const cellW = cv.width / N, cellH = cv.height / N;
const pal = palRef.current;
const rr = r.current, gg = g.current, bb = bC.current;
for (let py = 0; py < cv.height; py++) {
const gy = Math.min(N - 1, (py / cellH) | 0);
for (let pxx = 0; pxx < cv.width; pxx++) {
const gx = Math.min(N - 1, (pxx / cellW) | 0);
const idx = IX(gx + 1, gy + 1);
const qr = Math.min(1.6, rr[idx]) / 1.6;
const qg = Math.min(1.6, gg[idx]) / 1.6;
const qb = Math.min(1.6, bb[idx]) / 1.6;
const o = (py * cv.width + pxx) * 4;
px[o] = Math.min(255, pal.cr[0] + qr * (pal.k1[0] - pal.cr[0]) + qg * (pal.k2[0] - pal.cr[0]) + qb * (pal.k3[0] - pal.cr[0]));
px[o + 1] = Math.min(255, pal.cr[1] + qr * (pal.k1[1] - pal.cr[1]) + qg * (pal.k2[1] - pal.cr[1]) + qb * (pal.k3[1] - pal.cr[1]));
px[o + 2] = Math.min(255, pal.cr[2] + qr * (pal.k1[2] - pal.cr[2]) + qg * (pal.k2[2] - pal.cr[2]) + qb * (pal.k3[2] - pal.cr[2]));
px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
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 = 4;
const uu = u.current, vv = v.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;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
const f = Math.exp(-q * 3) * amt;
const idx = IX(gx + i, gy + j);
uu[idx] += fx * f; vv[idx] += fy * f;
}
}
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const rect = field.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.18, fy = (e.clientY - last.y) * 0.18;
splat(gx, gy, fx, fy, Math.min(2, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.9);
}
lastRef.current = { x: e.clientX, y: e.clientY };
if (reduced()) render();
}
function onLeave(): void { lastRef.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.2);
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 cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d", { willReadFrequently: true });
if (!ctxRef.current) return;
const root = rootRef.current;
if (root) {
const css = getComputedStyle(root);
const pr = css.getPropertyValue("--dye-r").trim();
const pg = css.getPropertyValue("--dye-g").trim();
const pb = css.getPropertyValue("--dye-b").trim();
const pp = css.getPropertyValue("--dye-paper").trim();
if (pr && pg && pb && pp) {
palRef.current = { cr: hexToRgb(pp), k1: hexToRgb(pr), k2: hexToRgb(pg), k3: hexToRgb(pb) };
}
}
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
if (root) root.classList.add("is-still");
for (let k = 0; k < 44; 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="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,184a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,208ZM112,72v56a16,16,0,0,0,32,0V72a16,16,0,0,0-32,0Z" opacity=".2"/><path d="M128,16A112,112,0,1,0,240,128,112.12,112.12,0,0,0,128,16Zm0,192a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,208ZM112,72v56a16,16,0,0,0,32,0V72a16,16,0,0,0-32,0Z"/></g></svg>
<span>채널 세 개가 각자 흘러 섞입니다 — 커서로 두 색 사이를 저어 보세요</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="염료 혼합 실험판"
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">3<span className={styles.badgeUnit}>채널</span></b>
</div>
</div>
</main>
);
}07 染料の混合 — 計測 累積面積 43.042% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 流体シミュレーション9種 — canvas の煙・インク格子計算 (WebGLなし)
このデモが属する記事
流体シミュレーション9種 — canvas の煙・インク格子計算 (WebGLなし)流体シミュレーションとは、ナビエ–ストークス方程式を厳密に解くことではなく、格子状に並べた密度と速度の配列を毎フレーム「移流・拡散・減衰」させて流れを模す計算のことだ。この記事では canvas 2D コンテキストだけで、32×32 の F この記事のデモをすべて見る


