05양 털 — 씨앗 여러 개가 부딪히며 섞인다
씨앗을 화면 곳곳에 여러 개 뿌리면 각자 자란 무늬가 서로 부딪혀 경계를 이루는데, 양 털·구름 뭉치처럼 불규칙하면서도 덩어리진 질감이 됩니다.
쓰임텍스처 배경, 일러스트 채움 무늬
ImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="tsf" id="tsf">
<p class="tsf__hint">
<svg class="tsf__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M124.9 32a52 52 0 0 0-49.55 36.11A40 40 0 0 0 24 107.33c0 25 21.75 44.67 46.75 44.67h119c23.5 0 42.25-17.75 42.25-40a40 40 0 0 0-36.71-39.81A52 52 0 0 0 124.9 32" opacity=".2"/><path d="M168 16a56 56 0 0 0-53.16 38.47A44 44 0 0 0 64 97.34c0 .83 0 1.66.08 2.49A36 36 0 0 0 68 172h100a52 52 0 0 0 0-104Zm0 88a36 36 0 0 1 0 72H68a20 20 0 0 1-6.7-38.92l10.83-3.73-2.51-11.22A28 28 0 0 1 97.34 72h4.22l3.76-2.6A36 36 0 0 1 168 72a36 36 0 0 1 0 72Z"/></g></svg>
<span>씨앗 14개가 부딪혀 만든 뭉치 위를 주입원이 천천히 지나 무늬가 계속 섞입니다</span>
</p>
<div class="tsf__field" id="field">
<canvas class="tsf__cv" id="cv"></canvas>
</div>
<div class="tsf__bar">
<label class="tsf__dial">시드
<input type="number" id="seed" min="1" max="9999" step="1" value="7" aria-label="패턴 시드">
</label>
<label class="tsf__dial tsf__grow">사료율 F
<input type="range" id="fk" min="0.010" max="0.070" step="0.001" value="0.046" aria-label="사료율 F">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 양 털 — Gray-Scott 반응-확산. 화면 곳곳의 시드 점이 각자 자라 서로 부딪혀
// 경계를 이루는 덩어리 질감 축 (05).
// 파스텔 — mix() 로 옅게 섞은 로컬 변수만 쓴다. 리터럴 hex 0개(BD26).
$tsf-fleece: mix($subject-cream, $subject-ink, 82%); // 뭉치 본체 — 크림끼 잉크
$tsf-edge: mix($subject-lilac, $stage-paper, 46%); // 뭉치 경계 — 라일락끼
$tsf-field: mix($subject-sky, $stage-paper, 26%); // 밭 바탕 — 옅은 하늘색
$tsf-inkend: mix($subject-blue, $subject-ink, 30%); // 가장 진한 끝단
$duration: $dur-loop;
$easing: $ease-out;
$color: $tsf-edge;
@include stage($stage-paper);
.tsf {
// V 농도 → 색 5단계 LUT (JS 가 getComputedStyle 로 읽는다)
--tsf-c0: #{$stage-paper};
--tsf-c1: #{$tsf-field};
--tsf-c2: #{$tsf-edge};
--tsf-c3: #{$tsf-fleece};
--tsf-c4: #{$tsf-inkend};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tsf__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;
}
.tsf__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.tsf__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.tsf__cv { display: block; width: 100%; height: 100%; }
.tsf__bar { display: flex; align-items: center; gap: $sp-3; flex-wrap: wrap; }
.tsf__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.tsf__grow { flex: 1 1 auto; min-width: 120px; }
.tsf__grow input { flex: 1 1 auto; }
.tsf__dial input {
width: 72px; margin: 0;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-sm;
background: rgba($subject-ink, .08); color: $subject-ink;
font-weight: 700; font-size: 12px; font-family: inherit;
}
.tsf__dial input[type="range"] {
width: auto; padding: 0; accent-color: $tsf-edge;
}
.tsf__dial input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
@media (max-width: 340px) {
.tsf { gap: $sp-1; }
.tsf__field { height: 100px; }
.tsf__dial input { width: 56px; }
.tsf__hint { font-size: 10px; }
}
// reduce — 루프 없이 성숙 상태 한 장만 남긴다
@media (prefers-reduced-motion: reduce) {
.tsf.is-still .tsf__field { 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);
}
.tsf {
--tsf-c0: #ffffff;
--tsf-c1: rgb(85.2156862745%, 94.3921568627%, 100%);
--tsf-c2: rgb(87.3725490196%, 82.862745098%, 100%);
--tsf-c3: rgb(83.6235294118%, 80.8392156863%, 75.7960784314%);
--tsf-c4: rgb(11.8431372549%, 18.3137254902%, 36.0784313725%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.tsf__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;
}
.tsf__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.tsf__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.tsf__cv {
display: block;
width: 100%;
height: 100%;
}
.tsf__bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.tsf__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.tsf__grow {
flex: 1 1 auto;
min-width: 120px;
}
.tsf__grow input {
flex: 1 1 auto;
}
.tsf__dial input {
width: 72px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 12px;
font-family: inherit;
}
.tsf__dial input[type=range] {
width: auto;
padding: 0;
accent-color: rgb(87.3725490196%, 82.862745098%, 100%);
}
.tsf__dial input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tsf {
gap: 4px;
}
.tsf__field {
height: 100px;
}
.tsf__dial input {
width: 56px;
}
.tsf__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tsf.is-still .tsf__field {
cursor: default;
}
}// 양 털 — Gray-Scott 반응-확산. U(사료)·V(촉매) 두 격자를 64x40 셀에 두고
// 매 프레임 16스텝씩 확산 + 반응항을 계산해 ImageData 로 화소를 박는다.
// 시드는 화면 곳곳의 점 14개 — 각자 자란 덩어리가 서로 부딪혀 경계를 이뤄
// 양 털·구름 뭉치 같은 질감이 된다.
// 다만 완전 성숙한 뭉치는 수학적 정지 상태라 변화가 사라진다 — 그래서 성숙 뒤에도
// 위상이 다른 주입원 6개가 리사주 곤적으로 화면을 천천히 순회하며 U 를 부어
// 뭉치가 계속 부딪히며 섞이게 한다 (07 파생). 주제는 '씨앗 여러 개가 섞인다'.
// 라플라스는 간선 0.2·대각 0.05 가중(9점) — 5점 대비 이방성 노이즈가 적어
// 성장 무늬가 부드럽다. DU=1.0 DV=0.5 dt=1.0 (브리프 규격).
// 렌더: 셀 V 값을 파스텔 LUT 로 색을 정해 저해상 버퍼에 putImageData, 그 버퍼를
// drawImage 로 확대한다(imageSmoothingEnabled — 부드럽게).
var root = document.getElementById('tsf');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var seedIn = document.getElementById('seed');
var fkIn = document.getElementById('fk');
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var GW = 64, GH = 40; // 격자 셀 수 (480x300 비율)
var DU = 1.0, DV = 0.5; // 확산 계수 (고정)
var F = 0.046, K = 0.062; // 사료율·제거율 (fk 슬라이더가 F 를 움직인다)
var STEPS = 16; // 프레임당 시뮬레이션 스텝
var PRE = 240; // 시작 시 미리 성숙시키는 스텝 — 뭉치가 막 자라기 시작한 시점
var t = 0; // 주입 위상 — 주입원이 이 시간을 따라 화면을 천천히 순회한다
var SRC_N = 6; // 주입원 개수 — 각자 위상이 달라 뭉치를 여러 방향에서 흔든다
var SRC_RAD = 4; // 주입 반경(셀)
var SRC_BOOST = 0.45; // 주입량 — 성숙한 뭉치를 밀어 섞임이 계속 되게 하는 힘
var SRC_DT = 0.003; // 주입원 순회 속도 — 양털은 천천히 (07 물결보다 느리게)
var u = new Float32Array(GW * GH);
var v = new Float32Array(GW * GH);
var u2 = new Float32Array(GW * GH);
var v2 = new Float32Array(GW * GH);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var raf = 0;
// ── 색 — scss 가 내려준 파스텔 사용자속성을 읽어 5단계 LUT 를 미리 편다 ──
var css = getComputedStyle(root);
var LUT = new Uint8Array(256 * 3);
function tok(name) { return css.getPropertyValue(name); }
function col2rgb(s) {
// sass 가 rgb(...) 함수꼴로 내려보내는 값과 hex 를 둘 다 받는다
s = String(s).trim();
var m = s.match(/^rgba?\(([^)]+)\)$/);
if (m) {
var parts = m[1].split(',');
var out = [];
for (var i = 0; i < 3; i++) {
var p = parseFloat(parts[i]);
out.push(parts[i].indexOf('%') >= 0 ? Math.round(p * 2.55) : Math.round(p));
}
return out;
}
s = s.replace('#', '');
if (s.length === 3) s = s.charAt(0) + s.charAt(0) + s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2);
return [parseInt(s.slice(0, 2), 16) || 0, parseInt(s.slice(2, 4), 16) || 0, parseInt(s.slice(4, 6), 16) || 0];
}
function buildLut() {
var stops = [col2rgb(tok('--tsf-c0')), col2rgb(tok('--tsf-c1')), col2rgb(tok('--tsf-c2')), col2rgb(tok('--tsf-c3')), col2rgb(tok('--tsf-c4'))];
for (var q = 0; q < 256; q++) {
var t = q / 255 * (stops.length - 1);
var k = Math.min(stops.length - 2, Math.floor(t));
var f = t - k;
for (var c = 0; c < 3; c++) LUT[q * 3 + c] = stops[k][c] + (stops[k + 1][c] - stops[k][c]) * f;
}
}
// ── 시드 — 화면 곳곳에 점 14개를 넓게. 시드가 좌표를 결정 ──
function seedField(sd) {
var rng = mulberry32(sd >>> 0);
u.fill(1); v.fill(0);
for (var n = 0; n < 14; n++) {
var cx = 5 + rng() * (GW - 10), cy = 4 + rng() * (GH - 8);
var rr = 2 + rng() * 1.6;
var y0 = Math.max(0, Math.floor(cy - rr)), y1 = Math.min(GH - 1, Math.ceil(cy + rr));
var x0 = Math.max(0, Math.floor(cx - rr)), x1 = Math.min(GW - 1, Math.ceil(cx + rr));
for (var y = y0; y <= y1; y++) {
for (var x = x0; x <= x1; x++) {
var dx = x - cx, dy = y - cy;
if (dx * dx + dy * dy < rr * rr) { v[y * GW + x] = 0.5; u[y * GW + x] = 0.25; }
}
}
}
}
// ── 한 스텝 — 9점 가중 라플라스(토러스 경계) + Gray-Scott 반응항 ──
function step() {
for (var y = 0; y < GH; y++) {
var yT = ((y - 1 + GH) % GH) * GW, yB = ((y + 1) % GH) * GW, yC = y * GW;
for (var x = 0; x < GW; x++) {
var xL = (x - 1 + GW) % GW, xR = (x + 1) % GW;
var i = yC + x;
var uc = u[i], vc = v[i];
var lapU = 0.2 * (u[yC + xL] + u[yC + xR] + u[yT + x] + u[yB + x])
+ 0.05 * (u[yT + xL] + u[yT + xR] + u[yB + xL] + u[yB + xR]) - uc;
var lapV = 0.2 * (v[yC + xL] + v[yC + xR] + v[yT + x] + v[yB + x])
+ 0.05 * (v[yT + xL] + v[yT + xR] + v[yB + xL] + v[yB + xR]) - vc;
var uvv = uc * vc * vc;
u2[i] = uc + DU * lapU - uvv + F * (1 - uc);
v2[i] = vc + DV * lapV + uvv - (F + K) * vc;
// 포화 클램프 — 주입(v=1) 이웃에서 9점 라플라스가 오버슈트해 음수 U 를 만들면
// uvv 가 음수가 되며 발산한다(실측: 주입 약 100스텝 만에 NaN). 07 과 같이 [0,1] 로 자른다
var cu = u2[i], cv2 = v2[i];
u2[i] = cu < 0 ? 0 : (cu > 1 ? 1 : cu);
v2[i] = cv2 < 0 ? 0 : (cv2 > 1 ? 1 : cv2);
}
}
var tu = u; u = u2; u2 = tu;
var tv = v; v = v2; v2 = tv;
}
var buf = null, small = null, sctx = null;
function setupBuffers() {
small = document.createElement('canvas');
small.width = GW; small.height = GH;
sctx = small.getContext('2d');
buf = sctx.createImageData(GW, GH);
}
// 미세 U 주입 — 위상이 제각각인 주입원 6개가 리사주 궤적으로 화면을 천천히 순회하며
// 성숙한 뭉치를 계속 밀어 섞인다. 주입원끼리 위상이 다르면 밀림 방향도 제각각 —
// 뭉치 전체가 계속 부딪히며 움직이게 보인다 (07 다중 주입원 방식)
function graze() {
for (var j = 0; j < SRC_N; j++) {
var ph = 1.7 + j * 2.4;
var cx = (GW / 2 + Math.cos(t * ph) * GW * 0.38) | 0;
var cy = (GH / 2 + Math.sin(t * (0.9 + j * 0.8)) * GH * 0.38) | 0;
for (var dy = -SRC_RAD; dy <= SRC_RAD; dy++) {
for (var dx = -SRC_RAD; dx <= SRC_RAD; dx++) {
if (dx * dx + dy * dy > SRC_RAD * SRC_RAD) continue;
var i = ((cy + dy + GH) % GH) * GW + ((cx + dx + GW) % GW);
u[i] = 1;
var nv = v[i] + SRC_BOOST;
v[i] = nv > 1 ? 1 : nv;
}
}
}
}
function paint() {
var px = buf.data, i4 = 0;
for (var p = 0; p < GW * GH; p++) {
// V 농도 0(종이) → 1(잉크끼 파스텔) — LUT 로 색 단계
var q = Math.max(0, Math.min(255, (v[p] * 460) | 0));
px[i4] = LUT[q * 3];
px[i4 + 1] = LUT[q * 3 + 1];
px[i4 + 2] = LUT[q * 3 + 2];
px[i4 + 3] = 255;
i4 += 4;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, GW, GH, 0, 0, W, H);
}
function frame() {
for (var s = 0; s < STEPS; s++) { graze(); step(); }
t += SRC_DT;
paint();
raf = requestAnimationFrame(frame);
}
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 시드 재생성 → 미리성숙(PRE 스텝 동기) → 즉시 다시 그리기 (reduce 경로에서도)
function regrow(sd) {
seedField(sd);
for (var s = 0; s < PRE; s++) step();
t = 0;
paint();
}
function onSeed() {
var val = Math.max(1, Math.min(9999, Math.floor(Number(seedIn.value)) || 1));
regrow(val);
}
function onFk() {
F = Number(fkIn.value);
// reduce 경로에서도 프레임이 변해야 한다 — 시드 유지 상태로 새 F 로 다시 성숙시켜 그린다
if (reduce) regrow(Math.max(1, Math.min(9999, Math.floor(Number(seedIn.value)) || 1)));
}
seedIn.addEventListener('input', onSeed);
seedIn.addEventListener('change', onSeed);
fkIn.addEventListener('input', onFk);
fkIn.addEventListener('change', onFk);
window.addEventListener('resize', function () { fit(); paint(); });
buildLut();
setupBuffers();
fit();
regrow(Number(seedIn.value) || 7);
if (reduce) {
// reduce — 루프를 돌지 않고 성숙 상태 한 장만 그린다
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}// SheepFleece — vanilla 판(run/311/demo/05-turing-sheep-fleece/index.html) 의 React 포트.
// Gray-Scott 반응-확산을 64x40 격자에서 계산해 ImageData 로 화소를 박고
// imageSmoothingEnabled 를 켠 채 drawImage 로 늘려 화면을 채운다.
// 시드는 화면 곳곳의 점 14개 — 각자 자라 서로 부딪혀 경계를 이루는 뭉치 질감.
// 성숙 뒤에도 위상이 다른 주입원 6개가 리사주 궤적으로 천천히 순회하며 U 를 부어
// 뭉치가 계속 부딪히며 섞이게 한다 (07 다중 주입원 방식).
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SheepFleece.module.scss";
export type SheepFleeceProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type InputEvt = { target: { value: string } };
const GW = 64, GH = 40; // 격자 셀 수 (480x300 비율)
const DU = 1.0, DV = 0.5; // 확산 계수 (고정)
const K = 0.062; // 제거율 (고정) — fk 슬라이더는 F 만 움직인다
const F0 = 0.046; // 사료율 초기값
const STEPS = 16; // 프레임당 시뮬레이션 스텝
const PRE = 240; // 시작 시 미리 성숙시키는 스텝 — 뭉치가 막 자라기 시작한 시점
const LUT_SCALE = 460; // V 농도 → LUT 인덱스 배율 (파스텔 대비)
const DOTS = 14; // 시드 점 개수 — 화면에 넓게 뿌린다
const SRC_N = 6; // 주입원 개수 — 각자 위상이 달라 뭉치를 여러 방향에서 흔든다
const SRC_RAD = 4; // 주입 반경(셀)
const SRC_BOOST = 0.45; // 주입량 — 성숙한 뭉치를 밀어 섞임이 계속 되게 하는 힘
const SRC_DT = 0.003; // 주입원 순회 속도 — 양털은 천천히 (07 물결보다 느리게)
const FIELD_LABEL = "양 털 뭉치 실험판";
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
// 토큰 다섯 단계를 256칸 표로 편다 — 프레임마다 색을 다시 섞지 않게 하기 위해서다
function buildLut(stops: number[][]): Uint8Array {
const lut = new Uint8Array(256 * 3);
for (let q = 0; q < 256; q++) {
const t = q / 255 * (stops.length - 1);
const k = Math.min(stops.length - 2, Math.floor(t));
const f = t - k;
for (let c = 0; c < 3; c++) lut[q * 3 + c] = stops[k][c] + (stops[k + 1][c] - stops[k][c]) * f;
}
return lut;
}
// sass 가 내려보내는 rgb(%,%,%) 꼴과 hex 를 둘 다 받는다
function col2rgb(s: string): number[] {
const t = String(s).trim();
const m = t.match(/^rgba?\(([^)]+)\)$/);
if (m) {
const parts = m[1].split(",");
const out: number[] = [];
for (let i = 0; i < 3; i++) {
const p = parseFloat(parts[i]);
out.push(parts[i].indexOf("%") >= 0 ? Math.round(p * 2.55) : Math.round(p));
}
return out;
}
let h = t.replace("#", "");
if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2);
return [parseInt(h.slice(0, 2), 16) || 0, parseInt(h.slice(2, 4), 16) || 0, parseInt(h.slice(4, 6), 16) || 0];
}
export default function SheepFleece({
duration = "2s", // vanilla $duration: $dur-loop
easing = "ease-out", // vanilla $easing: $ease-out
color = "#dfd3ff", // vanilla $color: $tsf-edge
}: SheepFleeceProps) {
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 smallRef = useRef<HTMLCanvasElement | null>(null);
const sctxRef = useRef<CanvasRenderingContext2D | null>(null);
const imgRef = useRef<ImageData | null>(null);
const uRef = useRef<Float32Array | null>(null);
const vRef = useRef<Float32Array | null>(null);
const u2Ref = useRef<Float32Array | null>(null);
const v2Ref = useRef<Float32Array | null>(null);
const lutRef = useRef<Uint8Array | null>(null);
const fRef = useRef(F0) as Box<number>;
const tRef = useRef(0) as Box<number>;
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const [seed, setSeed] = useState(7);
const [fk, setFk] = useState(F0);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function stops(): number[][] {
const root = rootRef.current;
if (!root) return [[0, 0, 0], [255, 255, 255]];
const css = getComputedStyle(root);
const one = (n: string) => col2rgb(css.getPropertyValue(n));
return [one("--tsf-c0"), one("--tsf-c1"), one("--tsf-c2"), one("--tsf-c3"), one("--tsf-c4")];
}
// 고DPI 대응 — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// ── 한 스텝 — 9점 가중 라플라스(토러스 경계) + Gray-Scott 반응항 ──────────────
function step(): void {
const U = uRef.current, V = vRef.current, U2 = u2Ref.current, V2 = v2Ref.current;
if (!U || !V || !U2 || !V2) return;
const F = fRef.current;
for (let y = 0; y < GH; y++) {
const yT = ((y - 1 + GH) % GH) * GW, yB = ((y + 1) % GH) * GW, yC = y * GW;
for (let x = 0; x < GW; x++) {
const xL = (x - 1 + GW) % GW, xR = (x + 1) % GW;
const i = yC + x;
const uc = U[i], vc = V[i];
const lapU = 0.2 * (U[yC + xL] + U[yC + xR] + U[yT + x] + U[yB + x])
+ 0.05 * (U[yT + xL] + U[yT + xR] + U[yB + xL] + U[yB + xR]) - uc;
const lapV = 0.2 * (V[yC + xL] + V[yC + xR] + V[yT + x] + V[yB + x])
+ 0.05 * (V[yT + xL] + V[yT + xR] + V[yB + xL] + V[yB + xR]) - vc;
const uvv = uc * vc * vc;
U2[i] = uc + DU * lapU - uvv + F * (1 - uc);
V2[i] = vc + DV * lapV + uvv - (F + K) * vc;
// 포화 클램프 — 주입(v=1) 이웃에서 9점 라플라스가 오버슈트해 음수 U 를 만들면
// uvv 가 음수가 되며 발산한다(실측: 주입 약 100스텝 만에 NaN). 07 과 같이 [0,1] 로 자른다
const cu = U2[i], cv2 = V2[i];
U2[i] = cu < 0 ? 0 : cu > 1 ? 1 : cu;
V2[i] = cv2 < 0 ? 0 : cv2 > 1 ? 1 : cv2;
}
}
uRef.current = U2; u2Ref.current = U;
vRef.current = V2; v2Ref.current = V;
}
// ── 시드 — 화면 곳곳에 점 14개를 넓게. 시드가 좌표를 결정 ──
function seedField(sd: number): void {
const U = uRef.current, V = vRef.current;
if (!U || !V) return;
const rng = mulberry32(sd >>> 0);
U.fill(1); V.fill(0);
for (let n = 0; n < DOTS; n++) {
const cx = 5 + rng() * (GW - 10), cy = 4 + rng() * (GH - 8);
const rr = 2 + rng() * 1.6;
const y0 = Math.max(0, Math.floor(cy - rr)), y1 = Math.min(GH - 1, Math.ceil(cy + rr));
const x0 = Math.max(0, Math.floor(cx - rr)), x1 = Math.min(GW - 1, Math.ceil(cx + rr));
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const dx = x - cx, dy = y - cy;
if (dx * dx + dy * dy < rr * rr) { V[y * GW + x] = 0.5; U[y * GW + x] = 0.25; }
}
}
}
}
// 시드 재생성 → 미리성숙(PRE 스텝 동기) → 즉시 다시 그리기 (reduce 경로에서도)
function build(sd: number): void {
seedField(sd);
for (let s = 0; s < PRE; s++) step();
tRef.current = 0;
paint();
}
// 미세 U 주입 — 위상이 제각각인 주입원 6개가 리사주 궤적으로 화면을 천천히 순회하며
// 성숙한 뭉치를 계속 밀어 섞인다 (07 다중 주입원 방식)
function graze(): void {
const U = uRef.current, V = vRef.current;
if (!U || !V) return;
const t = tRef.current;
for (let j = 0; j < SRC_N; j++) {
const ph = 1.7 + j * 2.4;
const cx = (GW / 2 + Math.cos(t * ph) * GW * 0.38) | 0;
const cy = (GH / 2 + Math.sin(t * (0.9 + j * 0.8)) * GH * 0.38) | 0;
for (let dy = -SRC_RAD; dy <= SRC_RAD; dy++) {
for (let dx = -SRC_RAD; dx <= SRC_RAD; dx++) {
if (dx * dx + dy * dy > SRC_RAD * SRC_RAD) continue;
const i = ((cy + dy + GH) % GH) * GW + ((cx + dx + GW) % GW);
U[i] = 1;
const nv = V[i] + SRC_BOOST;
V[i] = nv > 1 ? 1 : nv;
}
}
}
}
// 렌더 — 격자 V 를 ImageData 화소에 LUT 로 매핑해 putImageData 로 버퍼에 그리고 drawImage 로 확대한다
function paint(): void {
const ctx = ctxRef.current, sctx = sctxRef.current, img = imgRef.current;
const V = vRef.current, lut = lutRef.current;
if (!ctx || !sctx || !img || !V || !lut) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const px = img.data;
let i = 0;
for (let c = 0; c < GW * GH; c++) {
const q = Math.max(0, Math.min(255, (V[c] * LUT_SCALE) | 0));
px[i] = lut[q * 3]; px[i + 1] = lut[q * 3 + 1]; px[i + 2] = lut[q * 3 + 2]; px[i + 3] = 255;
i += 4;
}
sctx.putImageData(img, 0, 0);
// 확대 보간을 켜 둔 채로 늘린다 — 낮은 격자를 부드러운 털 면으로 바꾸는 한 줄
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(sctx.canvas, 0, 0, GW, GH, 0, 0, W, H);
}
function frame(): void {
for (let s = 0; s < STEPS; s++) { graze(); step(); }
tRef.current += SRC_DT;
paint();
rafRef.current = requestAnimationFrame(frame);
}
function onSeed(e: InputEvt): void {
const v = Math.max(1, Math.min(9999, Math.floor(Number(e.target.value)) || 1));
setSeed(v);
build(v);
}
function onFk(e: InputEvt): void {
const v = Number(e.target.value);
fRef.current = v;
setFk(v);
// reduce 경로에서도 프레임이 변해야 한다 — 시드 유지 상태로 새 F 로 다시 성숙시켜 그린다
if (reduced()) { seedField(seed); for (let s = 0; s < PRE; s++) step(); paint(); }
}
function onResize(): void {
fit();
paint();
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
const small = document.createElement("canvas");
small.width = GW;
small.height = GH;
smallRef.current = small;
sctxRef.current = small.getContext("2d");
if (!sctxRef.current) return;
imgRef.current = sctxRef.current.createImageData(GW, GH); // 한 번만 만들어 두고 프레임마다 같은 배열을 덮어쓴다
uRef.current = new Float32Array(GW * GH);
vRef.current = new Float32Array(GW * GH);
u2Ref.current = new Float32Array(GW * GH);
v2Ref.current = new Float32Array(GW * GH);
lutRef.current = buildLut(stops());
fit();
window.addEventListener("resize", onResize);
build(7);
if (reduced()) {
// reduce — 루프를 시작하지 않고 성숙 상태 한 장만 그린다
setStill(true);
paint();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, still ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} 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="M124.9 32a52 52 0 0 0-49.55 36.11A40 40 0 0 0 24 107.33c0 25 21.75 44.67 46.75 44.67h119c23.5 0 42.25-17.75 42.25-40a40 40 0 0 0-36.71-39.81A52 52 0 0 0 124.9 32" opacity=".2"/><path d="M168 16a56 56 0 0 0-53.16 38.47A44 44 0 0 0 64 97.34c0 .83 0 1.66.08 2.49A36 36 0 0 0 68 172h100a52 52 0 0 0 0-104Zm0 88a36 36 0 0 1 0 72H68a20 20 0 0 1-6.7-38.92l10.83-3.73-2.51-11.22A28 28 0 0 1 97.34 72h4.22l3.76-2.6A36 36 0 0 1 168 72a36 36 0 0 1 0 72Z"/></g></svg>
<span>씨앗 14개가 부딪혀 만든 뭉치 위를 주입원이 천천히 지나 무늬가 계속 섞입니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
>
<canvas className={styles.cv} ref={cvRef} id="cv"></canvas>
</div>
<div className={styles.bar}>
<label className={styles.dial}>시드
<input type="number" min={1} max={9999} step={1} value={seed} aria-label="패턴 시드" onChange={onSeed} />
</label>
<label className={styles.dial + " " + styles.grow}>사료율 f
<input type="range" min={0.010} max={0.070} step={0.001} value={fk} aria-label="사료율 f" onChange={onFk} />
</label>
</div>
</div>
</main>
);
}05 양 털 — 씨앗 여러 개가 부딪히며 섞인다 — 측정 누적면적 50.932% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 튜링 패턴 시뮬레이션 9종 — 라이브러리 없이 두 물질의 반응 격자만으로
이 데모가 속한 글
튜링 패턴 시뮬레이션 9종 — 라이브러리 없이 두 물질의 반응 격자만으로튜링 패턴 시뮬레이션은 두 물질이 반응하며 퍼지는 격자를 프레임마다 계산해 점·줄무늬·미로·산호 같은 무늬가 저절로 생기는 과정을 보여 줍니다. 아홉 종을 Gray-Scott 근사로 캔버스 2D 에 직접 구현했고, 이 글의 데모 전부 보기


