08세포막 — 경계가 두꺼워지고 떨림이 남는다
제거율 k 를 올리면 무늬가 쪼그라들어 세포처럼 동그란 방이 남는데, 경계가 계속 미세하게 떨리는 것이 반응이 살아 있다는 증거라 정지 그림과 구별됩니다.
쓰임과학 콘텐츠 일러스트, 세포 컨셉 UI
ImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="tcm" id="tcm">
<p class="tcm__hint">
<svg class="tcm__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88" opacity=".25"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m48-88a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8"/></g></svg>
<span>제거율이 높은 구역에서는 무늬가 쪼그라들어 방이 되고 경계가 계속 떨립니다</span>
</p>
<div class="tcm__field" id="field">
<canvas class="tcm__cv" id="cv"></canvas>
</div>
<div class="tcm__bar">
<label class="tcm__dial">시드
<input type="number" id="seed" min="1" max="9999" step="1" value="7" aria-label="패턴 시드">
</label>
<label class="tcm__dial">사료율 f
<input type="range" id="fk" min="0.010" max="0.070" step="0.001" value="0.060" aria-label="사료율 f">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 세포 방 — Gray-Scott 를 제거율 높은 구간에서 굴려 세포막 같은 방 무늬를 만드는 항목.
// 파스텔 — mix() 로 옅게 섞은 로컬 변수만 쓴다. 리터럴 hex 0개(BD26).
$cell-paper: mix($stage-paper, $subject-lilac, 8%); // 밑그림 종이 — 라일락이 살짝 섞인 페이퍼
$cell-mist: mix($subject-lilac, $stage-paper, 24%); // 아주 옅은 안개 단계
$cell-lilac: mix($subject-lilac, $stage-paper, 42%); // 방의 기본 라일락 파스텔
$cell-plum: mix($subject-lilac, $subject-ink, 30%); // 중간 농도 — 막의 몸통
$cell-ink: mix($subject-lilac, $subject-ink, 56%); // 가장 진한 잉크끼 — 방의 중심
$duration: $dur-loop;
$easing: $ease-out;
$color: $cell-lilac;
@include stage($stage-paper);
.tcm {
// V 농도 → 색 5단계 LUT (밑그림 종이 → 잉크끼). JS 가 getComputedStyle 로 읽어 쓴다
--p0: #{$cell-paper};
--p1: #{$cell-mist};
--p2: #{$cell-lilac};
--p3: #{$cell-plum};
--p4: #{$cell-ink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tcm__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;
}
.tcm__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.tcm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $cell-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.tcm__cv { display: block; width: 100%; height: 100%; }
.tcm__bar { display: flex; align-items: center; gap: $sp-3; flex-wrap: wrap; }
.tcm__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.tcm__dial input {
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;
}
.tcm__dial input[type="number"] { width: 72px; }
.tcm__dial input[type="range"] { width: 132px; height: 4px; padding: 0; border-radius: $r-pill; }
.tcm__dial input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
@media (max-width: 340px) {
.tcm { gap: $sp-1; }
.tcm__field { height: 100px; }
.tcm__dial input[type="number"] { width: 56px; }
.tcm__dial input[type="range"] { width: 104px; }
.tcm__hint { font-size: 10px; }
}
// reduce — 루프 없이 시드의 정적 한 장만 남는다(떨림 주입·시뮬레이션은 멈춘 상태)
@media (prefers-reduced-motion: reduce) {
.tcm.is-still .tcm__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);
}
.tcm {
--p0: rgb(74.7450980392%, 65.7254901961%, 100%);
--p1: rgb(93.4117647059%, 91.0588235294%, 100%);
--p2: rgb(88.4705882353%, 84.3529411765%, 100%);
--p3: rgb(28.0784313725%, 24.3137254902%, 37.137254902%);
--p4: rgb(44.5960784314%, 38.5882352941%, 60.4862745098%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.tcm__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;
}
.tcm__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.tcm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: rgb(74.7450980392%, 65.7254901961%, 100%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.tcm__cv {
display: block;
width: 100%;
height: 100%;
}
.tcm__bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.tcm__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.tcm__dial input {
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;
}
.tcm__dial input[type=number] {
width: 72px;
}
.tcm__dial input[type=range] {
width: 132px;
height: 4px;
padding: 0;
border-radius: 999px;
}
.tcm__dial input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tcm {
gap: 4px;
}
.tcm__field {
height: 100px;
}
.tcm__dial input[type=number] {
width: 56px;
}
.tcm__dial input[type=range] {
width: 104px;
}
.tcm__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tcm.is-still .tcm__field {
cursor: default;
}
}// 세포 방 — Gray-Scott 반응-확산 격자를 제거율 k 높은 구간(f·k 둘 다 높음)에서 돌려
// 무늬가 쪼그라든 동그란 방 상태로 만든 뒤, 경계가 미세하게 떨리는 주입을 계속 준다.
// 라이브러리 0 — 두 물질 U(사료)·V(촉매)의 라플라스 확산 + 반응항만으로 계산한다.
var root = document.getElementById('tcm');
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 = 96, GH = 60; // 반응 격자 해상도 — 화면이 커져도 이 값은 그대로다
var DU = 1.0, DV = 0.5; // 확산 계수(고정)
var F = 0.060, K = 0.066; // 사료율·제거율 — fk 슬라이더가 F 를 움직인다
var STEPS = 12; // 한 프레임에 도는 시뮬레이션 스텝 수
var RIP_STEPS = 3000; // 미리성숙 스텝 — 방 무늬가 자리 잡은 상태로 시작
var SRC_N = 14; // 떨림 주입원 개수 — 방 경계를 자극해 미세 진동을 유지한다
var SRC_RAD = 8; // 주입 반경(셀)
var SRC_BOOST = 0.55; // 주입량 — 방 형태를 유지하면서 떨림이 커지게 맞춘 값
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 t = 0; // 떨림 위상 — 주입원이 이 시간을 따라 천천히 돈다
// 밑그림 종이 → 라일락 잉크끼 파스텔 5단계 LUT — V 농도를 색 단계로 편다
var css = getComputedStyle(root);
function tok(name) { return css.getPropertyValue(name).trim().replace('#', ''); }
function rgbOf(hex) {
var h = hex.length === 3 ? hex.charAt(0) + hex.charAt(0) + hex.charAt(1) + hex.charAt(1) + hex.charAt(2) + hex.charAt(2) : hex;
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
var STOPS = [rgbOf(tok('--p0')), rgbOf(tok('--p1')), rgbOf(tok('--p2')), rgbOf(tok('--p3')), rgbOf(tok('--p4'))];
var LUT = new Uint8Array(256 * 3);
(function () {
for (var q = 0; q < 256; q++) {
var s = q / 255 * (STOPS.length - 1);
var k = Math.min(STOPS.length - 2, Math.floor(s));
var f = s - k;
for (var c = 0; c < 3; c++) LUT[q * 3 + c] = Math.round(STOPS[k][c] + (STOPS[k + 1][c] - STOPS[k][c]) * f);
}
})();
var small = document.createElement('canvas');
small.width = GW; small.height = GH;
var sctx = small.getContext('2d');
var img = sctx.createImageData(GW, GH);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var raf = 0;
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);
}
// ── Gray-Scott 한 스텝 — 5점 라플라스 + 반응항. 경계는 토러스(wrap)다 ──────────────
function step() {
for (var y = 0; y < GH; y++) {
var ym = ((y - 1 + GH) % GH) * GW, y0 = y * GW, yp = ((y + 1) % GH) * GW;
for (var x = 0; x < GW; x++) {
var xl = (x - 1 + GW) % GW, xr = (x + 1) % GW;
var i = y0 + x;
var u = U[i], v = V[i];
var lapU = U[y0 + xl] + U[y0 + xr] + U[ym + x] + U[yp + x] - 4 * u;
var lapV = V[y0 + xl] + V[y0 + xr] + V[ym + x] + V[yp + x] - 4 * v;
var uvv = u * v * v;
var nu = u + DU * lapU - uvv + F * (1 - u);
var nv = v + DV * lapV + uvv - (F + K) * v;
U2[i] = nu < 0 ? 0 : (nu > 1 ? 1 : nu);
V2[i] = nv < 0 ? 0 : (nv > 1 ? 1 : nv);
}
}
var tu = U; U = U2; U2 = tu;
var tv = V; V = V2; V2 = tv;
}
// 떨림 주입 — 위상이 제각각인 주입원이 방 경계 부근을 살짝 자극한다.
// 세포 방은 정착형이라 주입량을 낮게 잡아 형태는 유지하고 경계만 떨리게 한다
function quiver() {
for (var j = 0; j < SRC_N; j++) {
var ph = 2.3 + j * 1.9;
var cx = (GW / 2 + Math.cos(t * ph) * GW * 0.34) | 0;
var cy = (GH / 2 + Math.sin(t * (1.3 + j * 0.9)) * GH * 0.32) | 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 build(seed) {
var rng = mulberry32(seed >>> 0);
for (var i = 0; i < GW * GH; i++) { U[i] = 1; V[i] = 0.5 + (rng() - 0.5) * 0.04; }
var cx = (GW / 2) | 0, cy = (GH / 2) | 0;
for (var dy = -5; dy <= 5; dy++) for (var dx = -5; dx <= 5; dx++) {
if (dx * dx + dy * dy > 25) continue;
V[((cy + dy + GH) % GH) * GW + ((cx + dx + GW) % GW)] = 1;
}
var n = 5 + ((rng() * 4) | 0);
for (var s = 0; s < n; s++) {
var px = (3 + rng() * (GW - 6)) | 0, py = (3 + rng() * (GH - 6)) | 0;
for (var qy = -2; qy <= 2; qy++) for (var qx = -2; qx <= 2; qx++) {
if (qx * qx + qy * qy > 4) continue;
V[((py + qy + GH) % GH) * GW + ((px + qx + GW) % GW)] = 1;
}
}
for (var m = 0; m < RIP_STEPS; m++) step();
t = 0;
}
// 렌더 — 격자 V 를 ImageData 화소에 LUT 로 매핑해 putImageData 로 버퍼에 그리고 drawImage 로 확대한다
function paint() {
var px = img.data, i = 0;
for (var c = 0; c < GW * GH; c++) {
var q = (V[c] * 255) | 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(small, 0, 0, GW, GH, 0, 0, W, H);
}
function frame() {
for (var s = 0; s < STEPS; s++) { quiver(); step(); }
t += 0.006;
paint();
raf = requestAnimationFrame(frame);
}
function onSeed() {
var v = Math.max(1, Math.min(9999, Math.floor(Number(seedIn.value)) || 1));
build(v);
if (reduce) paint();
}
function onFk() {
F = Number(fkIn.value);
if (reduce) { build(Number(seedIn.value) || 7); paint(); }
}
seedIn.addEventListener('input', onSeed);
seedIn.addEventListener('change', onSeed);
fkIn.addEventListener('input', onFk);
fkIn.addEventListener('change', onFk);
window.addEventListener('resize', function () { fit(); if (reduce) paint(); });
build(Number(seedIn.value) || 7);
fit();
if (reduce) {
// reduce — 루프를 시작하지 않고 성숙 상태 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
paint();
} else {
raf = requestAnimationFrame(frame);
}// CellMembrane — vanilla 판(run/311/demo/08-turing-cell-membrane/index.html) 의 React 포트.
// Gray-Scott 를 제거율 높은 구간에서 방 무늬로 성숙시킨 뒤, 주입원이 경계를 자극해
// 세포막처럼 미세하게 떨리는 상태를 유지한다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CellMembrane.module.scss";
export type CellMembraneProps = {
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 = 96, GH = 60; // 반응 격자 해상도 — 화면이 커져도 이 값은 그대로다
const DU = 1.0, DV = 0.5; // 확산 계수(고정)
const STEPS = 12; // 한 프레임에 도는 시뮬레이션 스텝 수
const RIP_STEPS = 3000; // 미리성숙 스텝 — 방 무늬가 자리 잡은 상태로 시작
const SRC_N = 14; // 떨림 주입원 개수 — 방 경계를 자극해 미세 진동을 유지한다
const SRC_RAD = 8; // 주입 반경(셀)
const SRC_BOOST = 0.55; // 주입량 — 방 형태를 유지하면서 떨림이 커지게 맞춘 값
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;
}
export default function CellMembrane({
duration = "2s", // vanilla $duration: $dur-loop
easing = "ease-out", // vanilla $easing: $ease-out
color = "#d6c8fe", // vanilla $color: $cell-lilac
}: CellMembraneProps) {
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(0.060) 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(0.060);
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 hex3to6(v: string): string {
if (v.length === 3) return v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return v;
}
function stops(): number[][] {
const root = rootRef.current;
if (!root) return [[0, 0, 0], [255, 255, 255]];
const one = (n: string) => {
const v = hex3to6(getComputedStyle(root).getPropertyValue(n).trim().replace("#", ""));
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
};
return [one("--p0"), one("--p1"), one("--p2"), one("--p3"), one("--p4")];
}
// 고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);
}
// ── Gray-Scott 한 스텝 — 5점 라플라스 + 반응항. 경계는 토러스(wrap)다 ──────────────
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, K = 0.066;
for (let y = 0; y < GH; y++) {
const ym = ((y - 1 + GH) % GH) * GW, y0 = y * GW, yp = ((y + 1) % GH) * GW;
for (let x = 0; x < GW; x++) {
const xl = (x - 1 + GW) % GW, xr = (x + 1) % GW;
const i = y0 + x;
const u = U[i], v = V[i];
const lapU = U[y0 + xl] + U[y0 + xr] + U[ym + x] + U[yp + x] - 4 * u;
const lapV = V[y0 + xl] + V[y0 + xr] + V[ym + x] + V[yp + x] - 4 * v;
const uvv = u * v * v;
let nu = u + DU * lapU - uvv + F * (1 - u);
let nv = v + DV * lapV + uvv - (F + K) * v;
U2[i] = nu < 0 ? 0 : (nu > 1 ? 1 : nu);
V2[i] = nv < 0 ? 0 : (nv > 1 ? 1 : nv);
}
}
uRef.current = U2; u2Ref.current = U;
vRef.current = V2; v2Ref.current = V;
}
// 떨림 주입 — 위상이 제각각인 주입원이 방 경계 부근을 살짝 자극한다
function quiver(): 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 = 2.3 + j * 1.9;
const cx = (GW / 2 + Math.cos(t * ph) * GW * 0.34) | 0;
const cy = (GH / 2 + Math.sin(t * (1.3 + j * 0.9)) * GH * 0.32) | 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;
}
}
}
}
// 시드 재생성 — 중앙 덩어리 하나 + 주변 점 몇 개. 미리성숙 스텝으로 방 무늬를 만들어 둔다
function build(seed: number): void {
const U = uRef.current, V = vRef.current;
if (!U || !V) return;
const rng = mulberry32(seed >>> 0);
for (let i = 0; i < GW * GH; i++) { U[i] = 1; V[i] = 0.5 + (rng() - 0.5) * 0.04; }
const cx = (GW / 2) | 0, cy = (GH / 2) | 0;
for (let dy = -5; dy <= 5; dy++) for (let dx = -5; dx <= 5; dx++) {
if (dx * dx + dy * dy > 25) continue;
V[((cy + dy + GH) % GH) * GW + ((cx + dx + GW) % GW)] = 1;
}
const n = 5 + ((rng() * 4) | 0);
for (let s = 0; s < n; s++) {
const px = (3 + rng() * (GW - 6)) | 0, py = (3 + rng() * (GH - 6)) | 0;
for (let qy = -2; qy <= 2; qy++) for (let qx = -2; qx <= 2; qx++) {
if (qx * qx + qy * qy > 4) continue;
V[((py + qy + GH) % GH) * GW + ((px + qx + GW) % GW)] = 1;
}
}
for (let m = 0; m < RIP_STEPS; m++) step();
tRef.current = 0;
}
// 렌더 — 격자 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 = (V[c] * 255) | 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++) { quiver(); step(); }
tRef.current += 0.006;
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);
if (reduced()) paint();
}
function onFk(e: InputEvt): void {
const v = Number(e.target.value);
fRef.current = v;
setFk(v);
if (reduced()) { build(seed); paint(); }
}
function onResize(): void {
fit();
if (reduced()) 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="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88" opacity=".25"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m48-88a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8"/></g></svg>
<span>제거율이 높은 구역에서는 무늬가 쪼그라들어 방이 되고 경계가 계속 떨립니다</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}>사료율 f
<input type="range" min={0.010} max={0.070} step={0.001} value={fk} aria-label="사료율 f" onChange={onFk} />
</label>
</div>
</div>
</main>
);
}08 세포막 — 경계가 두꺼워지고 떨림이 남는다 — 측정 누적면적 31.474% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 튜링 패턴 시뮬레이션 9종 — 라이브러리 없이 두 물질의 반응 격자만으로
이 데모가 속한 글
튜링 패턴 시뮬레이션 9종 — 라이브러리 없이 두 물질의 반응 격자만으로튜링 패턴 시뮬레이션은 두 물질이 반응하며 퍼지는 격자를 프레임마다 계산해 점·줄무늬·미로·산호 같은 무늬가 저절로 생기는 과정을 보여 줍니다. 아홉 종을 Gray-Scott 근사로 캔버스 2D 에 직접 구현했고, 이 글의 데모 전부 보기


