07Dye mixing
Advect and diffuse three dye channels (RGB) independently and two colours blend like liquids, deriving smooth intermediates. The colours mix as an outcome of the field math, not by pre-blending.
Best forHero blending two brand colours
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>Three channels flow on their own and blend together — stir between the two colors with the cursor</span>
</p>
<div class="dm__field" id="field" role="application" aria-label="Dye mix board" tabindex="0">
<canvas class="dm__cv" id="cv"></canvas>
<b class="dm__badge" aria-hidden="true">3<span class="dm__badgeUnit">channel</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 역할)으로 두고 각각 독립 이류한다.
// 속도장은 하나를 공유하므로 세 염료는 같은 물 위에서 움직이고, 겹치는 곳의 중간색은
// 색을 미리 섞어 그린 결과가 아니라 channel 값의 합이 렌더식을 통과한 결과다.
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 × 3channel)
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 만큼 거슬러 간 좌표의 옛 값으로 채운다(channel 공통)
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]);
}
}
}
// channel 경계 — 벽으로 새어나간 염료는 절반으로 감쇠
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 Dye mixing — Measured area 43.042% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Canvas Fluid Simulation: 9 Grid Effects
The post this demo lives in
Canvas Fluid Simulation: 9 Grid EffectsA canvas fluid simulation doesn't solve the Navier-Stokes equations — it advects, diffuses, and decays density and veloc See all demos in this post


