슬라임몰드 길찾기 9종 — 점균 네트워크가 스스로 최단경로를 찾는 캔버스
슬라임몰드 길찾기를 캔버스 픽셀 계산만으로 재현합니다. 라이브러리·WebGL 없이 화면 위를 기어다니는 점 하나하나가 앞·좌·우 냄새를 맡고 진한 쪽으로 도는 규칙만 반복하는 것으로, 미로의 최단경로를 스스로 찾아내는 점균 네트워크가 자랍니다.
01 slime-agent-sensor-basic
02 slime-maze-solve-pathfind
03 slime-network-food-source
04 slime-pointer-attract-repel
05 slime-multi-species-territory
06 slime-trail-decay-color-age
07 slime-sensor-angle-morph
08 slime-veins-texture-panel
09 slime-macro-bioluminescent-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 기본 3센서 에이전트 — 앞·좌·우 냄새를 맡고 도는 점균 한 마리
- 02 미로 최단경로 — 이그노벨상 실험을 캔버스로 재현
- 03 다중 먹이 네트워크 — 철도망처럼 최적 연결선을 그리기
- 04 포인터 먹이·기피물 — 클릭한 자리로 네트워크가 자라거나 피하기
- 05 다종 영역 경쟁 — 서로 다른 색 두 군집이 화면을 나눠 갖기
- 06 트레일 나이 채색 — 최근 지나간 길일수록 밝게 빛나는 네온 잔상
- 07 센서 각도 모프 — 각도 하나로 산호에서 혈관까지 형태가 바뀌기
- 08 유기체 텍스처 패널 — 고정 프레임을 캡처해 카드 배경으로 깔기
- 09 매크로 발광 씬 — 어두운 배경 위 다큐멘터리 조명의 점균 장면
데모
아홉 판은 각각 독립적으로 돌아갑니다. 시드를 고정해 결정적으로 굴리므로 같은 판을 다시 열어도 같은 성장이 나타나고, 24프레임을 녹화하는 동안 아홉 판 모두 23프레임이 움직였습니다(움직인프레임 23). 클릭해 만질 수 있는 판도 있으니 첫 장부터 직접 부딪혀 보시길 권합니다.
| 항목 | 초점 | 누적면적(%) | 강도 평균 |
|---|---|---|---|
| 01 기본 3센서 | 감각·회전·퇴적의 최소 규칙 | 51.385 | 46.9 |
| 02 미로 최단경로 | 장애물 필드 위의 길찾기 | 43.166 | 19.8 |
| 03 다중 먹이 | 여러 지점을 잇는 수송망 | 36.237 | 15.9 |
| 04 포인터 먹이·기피 | 끌림과 밀어냄의 부호 하나 | 41.776 | 17.1 |
| 05 다종 영역 경쟁 | 두 색 군집의 밀어내기 | 53.258 | 57.0 |
| 06 트레일 나이 채색 | 마지막 방문 시각의 색 | 35.206 | 76.0 |
| 07 센서 각도 모프 | 값 두 개로 형태 변화 | 49.385 | 22.2 |
| 08 유기체 텍스처 | 자란 순간의 정지 캡처 | 34.819 | 15.5 |
| 09 매크로 발광 씬 | 어두운 무대의 조명 연출 | 49.253 | 54.7 |
센서 각도 하나로 산호에서 혈관까지 가른 아홉 걸음
순서는 화려함이 아니라 규칙에 무엇을 하나씩 얹었는가로 정했습니다. 01은 감각·회전·퇴적 세 동작만 남긴 최소판이고, 02는 그 위에 벽을 -1000짜리 장애물 필드로 깔아 이그노벨상 실험을 재현하며, 03은 먹이 지점을 여러 개 두어 먹이 가중치 FOOD_W 하나로 수송망을 만듭니다. 04는 먹이에 부호를 붙여 소금(기피물질)까지 다루고, 05는 트레일 필드를 종마다 하나씩 들고 서로를 밀어내게 하며, 06은 트레일 강도 대신 마지막 방문 시각을 기록해 색을 입힙니다. 07은 규칙을 바꾸지 않고 센서 벌림각·거리 두 값만 움직여 형태가 산호에서 혈관으로 갈리는 걸 보여주고, 08은 자란 순간을 정지 캡처로 굳혀 질감으로 재사용하며, 09는 같은 계산을 검은 무대와 조명 위에 올려 매크로 촬영 한 장면으로 마무리합니다. 슬라임몰드 길찾기로 찾아오신 분께는 02가, 화면 배경이 궁금하신 분께는 08·09가 곧장 답이 될 순서입니다.
항목별 코드
01기본 3센서 에이전트 — 앞·좌·우 냄새를 맡고 도는 점균 한 마리

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>에이전트마다 전방 부채꼴 세 지점의 페로몬 농도를 읽어 가장 짙은 쪽으로 살짝 돌고 한 걸음 전진하며 지나간 자리에 트레일을 남기면, 수천 개가 모여 저절로 혈관 같은 네트워크가 자랍니다</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="3센서 점균 에이전트 실험판" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> 스텝 · 기본 3센서</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 3센서 에이전트(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 밤 잉크. 파스텔 팔레트를 --sm-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 밤 무대 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$sm-stage: #1a1826; // 밤 잉크 무대 — 짙은 보랏빛 (파스텔 검사 무관: 저채도·저명도)
@include stage($sm-stage);
$sm-ink: #191722; // 필드 배경 — 무대보다 한 톤 어두운 바닥
$sm-glow: #b7c8ff; // 트레일 중간 — 파스텔 페리윙클
$sm-hot: #ffe3c2; // 트레일 절정 — 파스텔 피치(가장 짙은 간선)
$sm-text: #d9d6e6; // 잉크 무대 위 — 라벤더 안개 글자
.sm {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --sm-* 를 받아 쓴다
--sm-ink: #{$sm-ink};
--sm-glow: #{$sm-glow};
--sm-hot: #{$sm-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-ink;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-glow; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.sm__meta { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
.sm__val { color: $sm-glow; font-weight: 700; }
.sm__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 네트워크가 자라다 멈춘 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__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: #1a1826;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-ink: #191722;
--sm-glow: #b7c8ff;
--sm-hot: #ffe3c2;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #d9d6e6;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #191722;
box-shadow: inset 0 0 0 1px rgba(217, 214, 230, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sm__meta {
font-size: 10px;
font-weight: 500;
color: rgba(217, 214, 230, 0.72);
}
.sm__val {
color: #b7c8ff;
font-weight: 700;
}
.sm__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__field {
cursor: default !important;
}
}// 기본 3센서 에이전트(01). Physarum(점균) 스티그머지 알고리즘 —
// 에이전트마다 전방·좌·우 세 지점의 페로몬 필드를 읽어(sense) 가장 짙은 쪽으로 돈 뒤(rotate)
// 한 걸음 전진하며 지나간 칸에 트레일을 쌓는다(deposit). 필드는 매 프레임 이웃 평균으로
// 살짝 번지고(diffuse) 일정 비율 옅어진다(evaporate) — 이 네 단계만 반복해도 중앙 한 점에서
// 시작한 무리가 저절로 갈라지고 합쳐지며 혈관 같은 네트워크로 자란다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 페로몬 필드 값을 그대로 색으로 매핑해 putImageData 한다.
// · 값노이즈 기반 주변광 요동이 필드 전체를 매 프레임 은은하게 들썩이게 한다(누적면적 ↑).
// · 트레일이 짙을수록 밝은 파스텔로, 옅을수록 무대색으로 가라앉는다(순간 강도 ↑).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var AGENTS = 1300; // 동시에 걷는 에이전트 수 — 네트워크 밀도
var DECAY = 0.045; // 증발률(프레임당) — 변형 "증발 속도"가 이 값을 0.02→0.06으로 바꾼다
var SENSOR_DIST = 5.2; // 센서가 앞을 내다보는 거리(필드 칸)
// ────────────────────────────────────────────────────
var SENSOR_ANGLE = 0.5; // 센서 벌림각(라디안)
var TURN = 0.5; // 한 스텝에 도는 각(라디안)
var STEP = 1.05; // 한 스텝 이동 거리(필드 칸)
var DEPOSIT = 4.4; // 한 스텝에 쌓는 페로몬 양
var DIFFUSE = 0.5; // 확산 시 이웃과 섞이는 비율
var CAP = 7; // 페로몬 상한(색 매핑 기준)
var SUBSTEPS = 3; // rAF 프레임당 시뮬 스텝 수
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A01);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--sm-ink');
var C_GLOW = tokRGB('--sm-glow');
var C_HOT = tokRGB('--sm-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, img, data;
var ax, ay, ah;
var stepCount = 0;
// ── 값노이즈(주변광 요동용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function sample(x, y) { return trail[idx(Math.round(x), Math.round(y))]; }
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.14;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sample(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sample(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sample(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진 — 가장 짙은 쪽이 정면
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var v = trail[i] / CAP; if (v > 1) v = 1;
var n = vnoise(x * 0.06, y * 0.06 + t * 0.5);
var amb = 0.5 + 0.5 * Math.sin(t * 1.15 + n * 6.2831);
var k = v * v * (3 - 2 * v);
var rr = C_INK[0] + (C_GLOW[0] - C_INK[0]) * k + (C_HOT[0] - C_GLOW[0]) * Math.max(0, v - 0.72) * 3.4;
var gg = C_INK[1] + (C_GLOW[1] - C_INK[1]) * k + (C_HOT[1] - C_GLOW[1]) * Math.max(0, v - 0.72) * 3.4;
var bb = C_INK[2] + (C_GLOW[2] - C_INK[2]) * k + (C_HOT[2] - C_GLOW[2]) * Math.max(0, v - 0.72) * 3.4;
var sh = (amb - 0.5) * 16 * (1 - v * 0.6);
var j = i * 4;
data[j] = rr + sh; data[j + 1] = gg + sh; data[j + 2] = bb + sh; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(90, Math.min(200, Math.round(W / 3)));
fh = Math.max(50, Math.min(130, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
stepCount = 0;
initAgents();
}
function stillFrame() {
for (var k = 0; k < 220; k++) { stepAgents(); diffuseEvap(); }
render(3.4);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// AgentSensorBasic — vanilla 판(run/320/demo/01-slime-agent-sensor-basic/index.html) 의 React 포트.
// 기본 3센서 에이전트. Physarum(점균) 스티그머지 알고리즘 — 에이전트마다 전방·좌·우 세 지점의
// 페로몬 필드를 읽어(sense) 가장 짙은 쪽으로 돈 뒤(rotate) 한 걸음 전진하며 지나간 칸에 트레일을
// 쌓는다(deposit). 필드는 매 프레임 이웃 평균으로 살짝 번지고(diffuse) 일정 비율 옅어진다(evaporate).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AgentSensorBasic.module.scss";
export type AgentSensorBasicProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number; // 동시에 걷는 에이전트 수 — 네트워크 밀도
decay?: number; // 증발률(프레임당)
sensorDist?: number; // 센서가 앞을 내다보는 거리(필드 칸)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "3센서 점균 에이전트 실험판";
const SENSOR_ANGLE = 0.5; // 센서 벌림각(라디안)
const TURN = 0.5; // 한 스텝에 도는 각(라디안)
const STEP = 1.05; // 한 스텝 이동 거리(필드 칸)
const DEPOSIT = 4.4; // 한 스텝에 쌓는 페로몬 양
const DIFFUSE = 0.5; // 확산 시 이웃과 섞이는 비율
const CAP = 7; // 페로몬 상한(색 매핑 기준)
const SUBSTEPS = 3; // rAF 프레임당 시뮬 스텝 수
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function AgentSensorBasic({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
agents = 1300,
decay = 0.045,
sensorDist = 5.2,
}: AgentSensorBasicProps) {
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 colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const sensorDistRef = useRef(sensorDist) as Box<number>;
const randRef = useRef(makePRNG(0x320a01)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function sample(x: number, y: number): number { return trailRef.current[idx(Math.round(x), Math.round(y))]; }
function initAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.14;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
const sd = sensorDistRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sample(x + Math.cos(h) * sd, y + Math.sin(h) * sd);
const sl = sample(x + Math.cos(h + SENSOR_ANGLE) * sd, y + Math.sin(h + SENSOR_ANGLE) * sd);
const sr = sample(x + Math.cos(h - SENSOR_ANGLE) * sd, y + Math.sin(h - SENSOR_ANGLE) * sd);
if (sf >= sl && sf >= sr) {
// 직진 — 가장 짙은 쪽이 정면
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCountRef.current++;
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
let v = trail[i] / CAP; if (v > 1) v = 1;
const nz = vnoise(x * 0.06, y * 0.06 + t * 0.5);
const amb = 0.5 + 0.5 * Math.sin(t * 1.15 + nz * 6.2831);
const k = v * v * (3 - 2 * v);
const rr = col.ink[0] + (col.glow[0] - col.ink[0]) * k + (col.hot[0] - col.glow[0]) * Math.max(0, v - 0.72) * 3.4;
const gg = col.ink[1] + (col.glow[1] - col.ink[1]) * k + (col.hot[1] - col.glow[1]) * Math.max(0, v - 0.72) * 3.4;
const bb = col.ink[2] + (col.glow[2] - col.ink[2]) * k + (col.hot[2] - col.glow[2]) * Math.max(0, v - 0.72) * 3.4;
const sh = (amb - 0.5) * 16 * (1 - v * 0.6);
const j = i * 4;
data[j] = rr + sh; data[j + 1] = gg + sh; data[j + 2] = bb + sh; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(90, Math.min(200, Math.round(W / 3)));
const fh = Math.max(50, Math.min(130, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
stepCountRef.current = 0;
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 220; k++) { stepAgents(); diffuseEvap(); }
render(3.4);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
sensorDistRef.current = sensorDist;
randRef.current = makePRNG(0x320a01);
colRef.current = {
ink: tokRGB(root, "--sm-ink"),
glow: tokRGB(root, "--sm-glow"),
hot: tokRGB(root, "--sm-hot"),
};
buildPerm();
fit();
setN("0");
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, sensorDist]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>에이전트마다 전방 부채꼴 세 지점의 페로몬 농도를 읽어 가장 짙은 쪽으로 살짝 돌고 한 걸음 전진하며 지나간 자리에 트레일을 남기면, 수천 개가 모여 저절로 혈관 같은 네트워크가 자랍니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b> 스텝 · 기본 3센서</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · 기본 3센서 에이전트 — 앞·좌·우 냄새를 맡고 도는 점균 한 마리 — 측정 누적면적 51.385% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
에이전트마다 전방 부채꼴 세 지점의 페로몬 농도를 읽어 가장 짙은 쪽으로 살짝 돌고 한 걸음 전진하며 지나간 자리에 트레일을 남기면 천수백 개가 모여 저절로 혈관 같은 네트워크가 자라고, 측정은 누적면적 51.385%·최대 면적 34.913%로 아홉 판 중 넓게 퍼지는 축입니다.
감각에서 퇴적까지의 한 사이클
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sample(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sample(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sample(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진 — 가장 짙은 쪽이 정면
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCount++;
}
머리 방향 h에서 벌림각 SENSOR_ANGLE만큼 좌우로 벌린 세 지점을 SENSOR_DIST 거리에서 읽고, 정면이 가장 짙으면 그대로 직진합니다. 좌우 중 짙은 쪽으로 TURN만큼 돌고 매 걸음 (rand() - 0.5) * 0.14의 흔들림을 얹어 갈라지는 혈관이 나게 하며, 마지막 줄의 Math.min(CAP, trail[ii] + DEPOSIT)이 지나간 자리에 냄새를 남기는 퇴적입니다.
02미로 최단경로 — 이그노벨상 실험을 캔버스로 재현

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>미로 벽을 장애물 필드로 깔고 입구·출구에 먹이를 두면, 점균 네트워크가 막다른 길의 트레일부터 말라 없어지고 가장 짧은 통로만 굵어지며 스스로 최단경로를 찾아냅니다</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="미로 최단경로 점균 실험판" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> 탐색 스텝 · 미로 풀이</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 미로 최단경로(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(장애물 필드 + 3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 종이빛. 01 의 밤 무대와 번갈아 — 편 전체가 ink→paper→ink… 를 돈다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.8 이상 — BD26 안전) ──
$sm-stage: #f3ede0; // 종이빛 무대
@include stage($sm-stage);
$sm-floor: #f3ede0; // 필드 바닥 — 무대와 같은 톤
$sm-wall: #2a241c; // 미로 벽 — 저명도(L 0.14, BD26 예외)
$sm-trail: #ffe0c0; // 트레일 — 파스텔 피치(L 0.876)
$sm-food: #ffc4b3; // 입구·출구 먹이 맥동 — 파스텔 코랄(L 0.851)
$sm-text: #3a3226; // 종이 위 — 짙은 세피아 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-wall: #{$sm-wall};
--sm-trail: #{$sm-trail};
--sm-food: #{$sm-food};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-wall; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.sm__meta { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
.sm__val { color: $sm-text; font-weight: 700; }
.sm__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 최단경로가 수렴한 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__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: #f3ede0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #f3ede0;
--sm-wall: #2a241c;
--sm-trail: #ffe0c0;
--sm-food: #ffc4b3;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #3a3226;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #f3ede0;
box-shadow: inset 0 0 0 1px rgba(58, 50, 38, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #2a241c;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sm__meta {
font-size: 10px;
font-weight: 500;
color: rgba(58, 50, 38, 0.72);
}
.sm__val {
color: #3a3226;
font-weight: 700;
}
.sm__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__field {
cursor: default !important;
}
}// 미로 최단경로(02). Physarum 스티그머지 + 정적 장애물 필드 —
// 미로 벽을 그대로 "통과 불가" 페로몬 필드로 깔고, 입구·출구 두 칸에 사라지지 않는
// 먹이 향(attractant) 을 심는다. 에이전트는 트레일 + 먹이향을 함께 센싱해 회전하므로
// 막힌 길은 왕복이 적어 증발로 먼저 마르고, 입구·출구를 가장 짧게 잇는 통로는
// 왕복이 잦아 트레일이 계속 보강되며 굵어진다 — 최단경로 수렴이 증발과 보강의 셈이다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 벽·트레일·먹이향을 한 ImageData 에 매 프레임 새로 계산해 얹는다.
// · 입구·출구 먹이 점은 맥동하는 광원처럼 밝기가 흔들려 필드 전체가 살아 있다(누적면적 ↑).
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 900; // 미로 안을 걷는 에이전트 수
var DECAY = 0.05; // 증발률 — 막다른 길이 마르는 속도
var FOOD_W = 1.7; // 먹이향이 센서 판단에 끼치는 가중치
// ────────────────────────────────────
var SENSOR_ANGLE = 0.55;
var SENSOR_DIST = 4.6;
var TURN = 0.55;
var STEP = 1.0;
var DEPOSIT = 5.2;
var DIFFUSE = 0.45;
var CAP = 8;
var SUBSTEPS = 3;
var MAZE_COLS = 13, MAZE_ROWS = 7, WALL_T = 2;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A02);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_FLOOR = tokRGB('--sm-floor');
var C_WALL = tokRGB('--sm-wall');
var C_TRAIL = tokRGB('--sm-trail');
var C_FOOD = tokRGB('--sm-food');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, obstacle, food, img, data;
var ax, ay, ah;
var stepCount = 0;
var foodA = [0, 0], foodB = [0, 0];
// ── 값노이즈(주변광 요동용) — 벽·바닥까지 필드 전체를 매 프레임 은은히 들썩여
// 누적면적을 "실제로 화면을 채우는" 수준으로 끌어올린다(077 기준, 좁은 통로만으론 부족).
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
// ── 미로 생성 — 랜덤 DFS 백트래커로 완전 연결 보장 ──
function buildMaze() {
var cellW = fw / MAZE_COLS, cellH = fh / MAZE_ROWS;
var rightOpen = new Uint8Array(MAZE_COLS * MAZE_ROWS);
var downOpen = new Uint8Array(MAZE_COLS * MAZE_ROWS);
var vis = new Uint8Array(MAZE_COLS * MAZE_ROWS);
var midR = MAZE_ROWS >> 1;
var stack = [[0, midR]];
vis[midR * MAZE_COLS] = 1;
while (stack.length) {
var cur = stack[stack.length - 1];
var c = cur[0], r = cur[1];
var nbrs = [];
if (c > 0 && !vis[r * MAZE_COLS + (c - 1)]) nbrs.push([c - 1, r, 'L']);
if (c < MAZE_COLS - 1 && !vis[r * MAZE_COLS + (c + 1)]) nbrs.push([c + 1, r, 'R']);
if (r > 0 && !vis[(r - 1) * MAZE_COLS + c]) nbrs.push([c, r - 1, 'U']);
if (r < MAZE_ROWS - 1 && !vis[(r + 1) * MAZE_COLS + c]) nbrs.push([c, r + 1, 'D']);
if (!nbrs.length) { stack.pop(); continue; }
var pick = nbrs[Math.floor(rand() * nbrs.length)];
if (pick[2] === 'R') rightOpen[r * MAZE_COLS + c] = 1;
if (pick[2] === 'L') rightOpen[r * MAZE_COLS + (c - 1)] = 1;
if (pick[2] === 'D') downOpen[r * MAZE_COLS + c] = 1;
if (pick[2] === 'U') downOpen[(r - 1) * MAZE_COLS + c] = 1;
vis[pick[1] * MAZE_COLS + pick[0]] = 1;
stack.push([pick[0], pick[1]]);
}
obstacle = new Uint8Array(fw * fh);
for (var i = 0; i < obstacle.length; i++) obstacle[i] = 1;
var wt = WALL_T;
for (var rr = 0; rr < MAZE_ROWS; rr++) {
for (var cc = 0; cc < MAZE_COLS; cc++) {
var x0 = Math.round(cc * cellW), y0 = Math.round(rr * cellH);
var x1 = Math.round((cc + 1) * cellW), y1 = Math.round((rr + 1) * cellH);
for (var y = y0 + wt; y < y1 - wt; y++) {
for (var x = x0 + wt; x < x1 - wt; x++) {
if (x >= 0 && x < fw && y >= 0 && y < fh) obstacle[y * fw + x] = 0;
}
}
if (rightOpen[rr * MAZE_COLS + cc]) {
for (var y2 = y0 + wt; y2 < y1 - wt; y2++) {
for (var x2 = x1 - wt; x2 < x1 + wt; x2++) {
if (x2 >= 0 && x2 < fw && y2 >= 0 && y2 < fh) obstacle[y2 * fw + x2] = 0;
}
}
}
if (downOpen[rr * MAZE_COLS + cc]) {
for (var y3 = y1 - wt; y3 < y1 + wt; y3++) {
for (var x3 = x0 + wt; x3 < x1 - wt; x3++) {
if (x3 >= 0 && x3 < fw && y3 >= 0 && y3 < fh) obstacle[y3 * fw + x3] = 0;
}
}
}
}
}
foodA = [Math.round(cellW * 0.5), Math.round(cellH * (midR + 0.5))];
foodB = [Math.round(cellW * (MAZE_COLS - 0.5)), Math.round(cellH * (midR + 0.5))];
}
function buildFood() {
food = new Float32Array(fw * fh);
var pts = [foodA, foodB];
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var best = 0;
for (var p = 0; p < pts.length; p++) {
var dx = x - pts[p][0], dy = y - pts[p][1];
var d2 = dx * dx + dy * dy;
var v = Math.exp(-d2 / (fw * 1.1));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
}
function sense(x, y) {
var i = clampIdx(x, y);
if (obstacle[i]) return -1000;
return trail[i] + food[i] * FOOD_W * CAP;
}
function initAgents() {
var open = [];
for (var i = 0; i < obstacle.length; i++) if (!obstacle[i]) open.push(i);
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
for (var k = 0; k < AGENTS; k++) {
var oi = open[Math.floor(rand() * open.length)];
ax[k] = oi % fw; ay[k] = Math.floor(oi / fw);
ah[k] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.16;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
var ni = clampIdx(nx, ny);
if (obstacle[ni]) {
h += Math.PI * 0.5 + (rand() - 0.5) * Math.PI; // 벽 — 크게 방향을 틀어 되돌아 나온다
} else {
ax[i] = Math.max(0, Math.min(fw - 1, nx));
ay[i] = Math.max(0, Math.min(fh - 1, ny));
trail[ni] = Math.min(CAP, trail[ni] + DEPOSIT);
}
ah[i] = h;
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? 0 : y - 1, yp = y === fh - 1 ? fh - 1 : y + 1;
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
if (obstacle[i]) { trailB[i] = 0; continue; }
var xm = x === 0 ? 0 : x - 1, xp = x === fw - 1 ? fw - 1 : x + 1;
var avg = (trail[y * fw + xm] + trail[y * fw + xp] + trail[ym * fw + x] + trail[yp * fw + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
var pa = 0.55 + 0.45 * Math.sin(t * 2.6);
var pb = 0.55 + 0.45 * Math.sin(t * 2.6 + 2.1);
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
// 앰비언트 글로우 레이어 — 전면 확산 구조(314 기법): 벽·바닥·트레일 구분 없이
// 필드 전체를 값노이즈 두 옥타브로 매 프레임 크게 흔들어 누적면적을 화면 전역으로 채운다.
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
if (obstacle[i]) {
data[j] = C_WALL[0] + sh; data[j + 1] = C_WALL[1] + sh; data[j + 2] = C_WALL[2] + sh; data[j + 3] = 255;
continue;
}
var v = trail[i] / CAP; if (v > 1) v = 1;
var k = v * v * (3 - 2 * v);
var rr = C_FLOOR[0] + (C_TRAIL[0] - C_FLOOR[0]) * k;
var gg = C_FLOOR[1] + (C_TRAIL[1] - C_FLOOR[1]) * k;
var bb = C_FLOOR[2] + (C_TRAIL[2] - C_FLOOR[2]) * k;
var dA = Math.hypot(x - foodA[0], y - foodA[1]);
var dB = Math.hypot(x - foodB[0], y - foodB[1]);
var glow = Math.max(Math.exp(-dA * dA / 26) * pa, Math.exp(-dB * dB / 26) * pb);
rr += (C_FOOD[0] - rr) * glow; gg += (C_FOOD[1] - gg) * glow; bb += (C_FOOD[2] - bb) * glow;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
stepCount = 0;
buildMaze();
buildFood();
initAgents();
}
function stillFrame() {
for (var k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(1.9);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// MazeSolvePathfind — vanilla 판(run/320/demo/02-slime-maze-solve-pathfind/index.html) 의 React 포트.
// 미로 최단경로. Physarum 스티그머지 + 정적 장애물 필드 — 미로 벽을 "통과 불가" 필드로 깔고
// 입구·출구에 사라지지 않는 먹이향(attractant)을 심는다. 막힌 길은 왕복이 적어 증발로 먼저
// 마르고, 최단 통로는 왕복이 잦아 트레일이 계속 보강되며 굵어진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MazeSolvePathfind.module.scss";
export type MazeSolvePathfindProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number; // 미로 안을 걷는 에이전트 수
decay?: number; // 증발률 — 막다른 길이 마르는 속도
foodWeight?: number; // 먹이향이 센서 판단에 끼치는 가중치
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "미로 최단경로 점균 실험판";
const SENSOR_ANGLE = 0.55;
const SENSOR_DIST = 4.6;
const TURN = 0.55;
const STEP = 1.0;
const DEPOSIT = 5.2;
const DIFFUSE = 0.45;
const CAP = 8;
const SUBSTEPS = 3;
const MAZE_COLS = 13, MAZE_ROWS = 7, WALL_T = 2;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function MazeSolvePathfind({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f7ecd8",
agents = 900,
decay = 0.05,
foodWeight = 1.7,
}: MazeSolvePathfindProps) {
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 colRef = useRef<{ floor: RGB; wall: RGB; trail: RGB; food: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const foodWRef = useRef(foodWeight) as Box<number>;
const randRef = useRef(makePRNG(0x320a02)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const obstacleRef = useRef<Uint8Array>(new Uint8Array(0)) as Box<Uint8Array>;
const foodRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodARef = useRef<[number, number]>([0, 0]) as Box<[number, number]>;
const foodBRef = useRef<[number, number]>([0, 0]) as Box<[number, number]>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function buildMaze(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const cellW = fw / MAZE_COLS, cellH = fh / MAZE_ROWS;
const rightOpen = new Uint8Array(MAZE_COLS * MAZE_ROWS);
const downOpen = new Uint8Array(MAZE_COLS * MAZE_ROWS);
const vis = new Uint8Array(MAZE_COLS * MAZE_ROWS);
const midR = MAZE_ROWS >> 1;
const stack: [number, number][] = [[0, midR]];
vis[midR * MAZE_COLS] = 1;
while (stack.length) {
const cur = stack[stack.length - 1];
const c = cur[0], r = cur[1];
const nbrs: [number, number, string][] = [];
if (c > 0 && !vis[r * MAZE_COLS + (c - 1)]) nbrs.push([c - 1, r, "L"]);
if (c < MAZE_COLS - 1 && !vis[r * MAZE_COLS + (c + 1)]) nbrs.push([c + 1, r, "R"]);
if (r > 0 && !vis[(r - 1) * MAZE_COLS + c]) nbrs.push([c, r - 1, "U"]);
if (r < MAZE_ROWS - 1 && !vis[(r + 1) * MAZE_COLS + c]) nbrs.push([c, r + 1, "D"]);
if (!nbrs.length) { stack.pop(); continue; }
const pick = nbrs[Math.floor(rand() * nbrs.length)];
if (pick[2] === "R") rightOpen[r * MAZE_COLS + c] = 1;
if (pick[2] === "L") rightOpen[r * MAZE_COLS + (c - 1)] = 1;
if (pick[2] === "D") downOpen[r * MAZE_COLS + c] = 1;
if (pick[2] === "U") downOpen[(r - 1) * MAZE_COLS + c] = 1;
vis[pick[1] * MAZE_COLS + pick[0]] = 1;
stack.push([pick[0], pick[1]]);
}
const obstacle = new Uint8Array(fw * fh);
for (let i = 0; i < obstacle.length; i++) obstacle[i] = 1;
const wt = WALL_T;
for (let rr = 0; rr < MAZE_ROWS; rr++) {
for (let cc = 0; cc < MAZE_COLS; cc++) {
const x0 = Math.round(cc * cellW), y0 = Math.round(rr * cellH);
const x1 = Math.round((cc + 1) * cellW), y1 = Math.round((rr + 1) * cellH);
for (let y = y0 + wt; y < y1 - wt; y++) {
for (let x = x0 + wt; x < x1 - wt; x++) {
if (x >= 0 && x < fw && y >= 0 && y < fh) obstacle[y * fw + x] = 0;
}
}
if (rightOpen[rr * MAZE_COLS + cc]) {
for (let y2 = y0 + wt; y2 < y1 - wt; y2++) {
for (let x2 = x1 - wt; x2 < x1 + wt; x2++) {
if (x2 >= 0 && x2 < fw && y2 >= 0 && y2 < fh) obstacle[y2 * fw + x2] = 0;
}
}
}
if (downOpen[rr * MAZE_COLS + cc]) {
for (let y3 = y1 - wt; y3 < y1 + wt; y3++) {
for (let x3 = x0 + wt; x3 < x1 - wt; x3++) {
if (x3 >= 0 && x3 < fw && y3 >= 0 && y3 < fh) obstacle[y3 * fw + x3] = 0;
}
}
}
}
}
obstacleRef.current = obstacle;
foodARef.current = [Math.round(cellW * 0.5), Math.round(cellH * (midR + 0.5))];
foodBRef.current = [Math.round(cellW * (MAZE_COLS - 0.5)), Math.round(cellH * (midR + 0.5))];
}
function buildFood(): void {
const fw = fwRef.current, fh = fhRef.current;
const food = new Float32Array(fw * fh);
const pts = [foodARef.current, foodBRef.current];
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
let best = 0;
for (let p = 0; p < pts.length; p++) {
const dx = x - pts[p][0], dy = y - pts[p][1];
const d2 = dx * dx + dy * dy;
const v = Math.exp(-d2 / (fw * 1.1));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
foodRef.current = food;
}
function sense(x: number, y: number): number {
const i = clampIdx(x, y);
if (obstacleRef.current[i]) return -1000;
return trailRef.current[i] + foodRef.current[i] * foodWRef.current * CAP;
}
function initAgents(): void {
const rand = randRef.current;
const obstacle = obstacleRef.current;
const fw = fwRef.current;
const A = agentsRef.current;
const open: number[] = [];
for (let i = 0; i < obstacle.length; i++) if (!obstacle[i]) open.push(i);
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
for (let k = 0; k < A; k++) {
const oi = open[Math.floor(rand() * open.length)];
ax[k] = oi % fw; ay[k] = Math.floor(oi / fw);
ah[k] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
const obstacle = obstacleRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.16;
const nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
const ni = clampIdx(nx, ny);
if (obstacle[ni]) {
h += Math.PI * 0.5 + (rand() - 0.5) * Math.PI;
} else {
ax[i] = Math.max(0, Math.min(fw - 1, nx));
ay[i] = Math.max(0, Math.min(fh - 1, ny));
trail[ni] = Math.min(CAP, trail[ni] + DEPOSIT);
}
ah[i] = h;
}
stepCountRef.current++;
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const obstacle = obstacleRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? 0 : y - 1, yp = y === fh - 1 ? fh - 1 : y + 1;
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
if (obstacle[i]) { trailB[i] = 0; continue; }
const xm = x === 0 ? 0 : x - 1, xp = x === fw - 1 ? fw - 1 : x + 1;
const avg = (trail[y * fw + xm] + trail[y * fw + xp] + trail[ym * fw + x] + trail[yp * fw + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const obstacle = obstacleRef.current;
const foodA = foodARef.current, foodB = foodBRef.current;
const data = img.data;
const pa = 0.55 + 0.45 * Math.sin(t * 2.6);
const pb = 0.55 + 0.45 * Math.sin(t * 2.6 + 2.1);
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
if (obstacle[i]) {
data[j] = col.wall[0] + sh; data[j + 1] = col.wall[1] + sh; data[j + 2] = col.wall[2] + sh; data[j + 3] = 255;
continue;
}
let v = trail[i] / CAP; if (v > 1) v = 1;
const k = v * v * (3 - 2 * v);
let rr = col.floor[0] + (col.trail[0] - col.floor[0]) * k;
let gg = col.floor[1] + (col.trail[1] - col.floor[1]) * k;
let bb = col.floor[2] + (col.trail[2] - col.floor[2]) * k;
const dA = Math.hypot(x - foodA[0], y - foodA[1]);
const dB = Math.hypot(x - foodB[0], y - foodB[1]);
const glow = Math.max(Math.exp(-dA * dA / 26) * pa, Math.exp(-dB * dB / 26) * pb);
rr += (col.food[0] - rr) * glow; gg += (col.food[1] - gg) * glow; bb += (col.food[2] - bb) * glow;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
stepCountRef.current = 0;
buildMaze();
buildFood();
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(1.9);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
foodWRef.current = foodWeight;
randRef.current = makePRNG(0x320a02);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
wall: tokRGB(root, "--sm-wall"),
trail: tokRGB(root, "--sm-trail"),
food: tokRGB(root, "--sm-food"),
};
buildPerm();
fit();
setN("0");
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, foodWeight]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>미로 벽을 장애물 필드로 깔고 입구·출구에 먹이를 두면, 점균 네트워크가 막다른 길의 트레일부터 말라 없어지고 가장 짧은 통로만 굵어지며 스스로 최단경로를 찾아냅니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b> 탐색 스텝 · 미로 풀이</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}02 · 미로 최단경로 — 이그노벨상 실험을 캔버스로 재현 — 측정 누적면적 43.166% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
미로 벽을 장애물 필드로 깔고 입구·출구에 먹이를 두면 막다른 길의 트레일부터 말라 없어지고 가장 짧은 통로만 굵어지며 스스로 최단경로를 찾아내는데, 2008년 인지과학 이그노벨상을 받은 점균 미로 실험과 같은 구조를 화면 안에서 돌려 보는 판입니다.
벽은 센서 값 -1000, 에이전트는 빈 칸에서만 태운다
function sense(x, y) {
var i = clampIdx(x, y);
if (obstacle[i]) return -1000;
return trail[i] + food[i] * FOOD_W * CAP;
}
function initAgents() {
var open = [];
for (var i = 0; i < obstacle.length; i++) if (!obstacle[i]) open.push(i);
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
for (var k = 0; k < AGENTS; k++) {
var oi = open[Math.floor(rand() * open.length)];
ax[k] = oi % fw; ay[k] = Math.floor(oi / fw);
ah[k] = rand() * Math.PI * 2;
}
}
벽 칸은 어떤 트레일보다 작은 -1000을 돌려주어 세 센서가 그쪽을 향하지 않게 만들고, 시작 시 에이전트는 벽이 아닌 칸 목록 open에서만 뽑아 태웁니다. 걸음마다 다음 칸이 장애물이면 방향을 크게 틀어 되돌아나오는 처리가 이어지는데, 이 세 가지만으로 안내 표지 없이 통로가 저절로 좁혀집니다.
03다중 먹이 네트워크 — 철도망처럼 최적 연결선을 그리기

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>화면에 먹이 지점을 여러 개 흩뿌리면 점균이 모든 지점을 잇는 굵은 간선과 가는 지선을 스스로 구분해, 효율적인 방사형 수송망을 그려냅니다</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="다중 먹이 점균 네트워크 실험판" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> 에이전트 · 방사형 수송망</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 다중 먹이 네트워크(03). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(먹이점 6개 바이어스 + 3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 볕든 밀짚색 — 01(밤)·02(종이) 다음 세 번째 순환.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.82 이상 — BD26 안전) ──
$sm-stage: #faf0d0; // 볕든 밀짚색 무대
@include stage($sm-stage);
$sm-floor: #faf0d0; // 필드 바닥 — 무대와 같은 톤
$sm-branch: #f7e6bf; // 가는 지선 — 바닥보다 한 톤 진한 밀짚
$sm-trunk: #f8ddaa; // 굵은 간선 — 더 진한 골든
$sm-food: #ffc4ad; // 먹이 맥동 — 파스텔 코랄(L 0.839)
$sm-text: #3a2f1a; // 볕든 무대 위 — 짙은 흙색 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-trunk: #{$sm-trunk};
--sm-branch: #{$sm-branch};
--sm-food: #{$sm-food};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.sm__meta { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
.sm__val { color: $sm-text; font-weight: 700; }
.sm__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 방사형 수송망이 수렴한 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__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: #faf0d0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #faf0d0;
--sm-trunk: #f8ddaa;
--sm-branch: #f7e6bf;
--sm-food: #ffc4ad;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #3a2f1a;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #faf0d0;
box-shadow: inset 0 0 0 1px rgba(58, 47, 26, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #3a2f1a;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sm__meta {
font-size: 10px;
font-weight: 500;
color: rgba(58, 47, 26, 0.72);
}
.sm__val {
color: #3a2f1a;
font-weight: 700;
}
.sm__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__field {
cursor: default !important;
}
}// 다중 먹이 네트워크(03). Physarum 스티그머지 + 먹이점 6개 —
// 화면에 먹이 앵커 6개를 흩뿌리고, 에이전트 센싱값에 "가장 가까운 먹이로 갈수록 유리"한
// 가우시안 바이어스를 트레일 위에 얹는다. 여러 먹이를 잇는 지름길은 왕복이 잦아 굵은 간선으로
// 남고, 외곽 먹이 하나로만 가는 가지는 가늘게 남는다 — 변형 "에이전트 수"(4천→1만2천)를
// 반영해 01 보다 개체 수를 크게 늘려(2200) 네트워크 밀도를 높였다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 필드 전체를 매 프레임 새로 계산해 얹는다.
// · 값노이즈 기반 주변광 요동이 바닥·간선을 가리지 않고 함께 흔들어 누적면적을 채운다.
// · 먹이점 6개가 각자 다른 위상으로 맥동해 화면 전역이 살아 있다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 2200; // 변형 "에이전트 수"(4천→1만2천)를 반영 — 01(1300)보다 밀도 상향
var DECAY = 0.038; // 증발률 — 간선/지선 구분이 살도록 01보다 살짝 느리게
var FOOD_W = 2.0; // 먹이향이 센서 판단에 끼치는 가중치
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.0;
var DIFFUSE = 0.5;
var CAP = 8;
var SUBSTEPS = 3;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A03);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_FLOOR = tokRGB('--sm-floor');
var C_TRUNK = tokRGB('--sm-trunk');
var C_BRANCH = tokRGB('--sm-branch');
var C_FOOD = tokRGB('--sm-food');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, food, foodMax, img, data;
var ax, ay, ah;
var stepCount = 0;
var foods = []; // [x, y, phase]
// ── 값노이즈(주변광 요동용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function buildFoods() {
foods = [];
var n = 6;
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.42;
for (var i = 0; i < n; i++) {
var a = (i / n) * Math.PI * 2 + rand() * 0.5;
var rr = r * (0.7 + rand() * 0.3);
foods.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, rand() * Math.PI * 2]);
}
food = new Float32Array(fw * fh);
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var best = 0;
for (var p = 0; p < foods.length; p++) {
var dx = x - foods[p][0], dy = y - foods[p][1];
var d2b = dx * dx + dy * dy;
var v = Math.exp(-d2b / (fw * 0.9));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
}
function sense(x, y) {
var i = clampIdx(x, y);
return trail[i] + food[i] * FOOD_W * CAP;
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.12;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var v = trail[i] / CAP; if (v > 1) v = 1;
// 앰비언트 글로우 레이어 — 전면 확산 구조(314 기법): 두 옥타브 값노이즈로
// 필드 전체를 크게 흔들어 간선·지선 구분과 별개로 누적면적을 화면 전역으로 채운다.
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 28 + (n2 - 0.5) * 18;
// 굵기 단계 — 옅은 트레일은 지선(branch) 색, 짙은 트레일은 간선(trunk) 색
var kBranch = Math.min(1, v * 2.2);
var kTrunk = Math.max(0, v - 0.45) * 1.8;
var rr = C_FLOOR[0] + (C_BRANCH[0] - C_FLOOR[0]) * kBranch + (C_TRUNK[0] - C_BRANCH[0]) * kTrunk;
var gg = C_FLOOR[1] + (C_BRANCH[1] - C_FLOOR[1]) * kBranch + (C_TRUNK[1] - C_BRANCH[1]) * kTrunk;
var bb = C_FLOOR[2] + (C_BRANCH[2] - C_FLOOR[2]) * kBranch + (C_TRUNK[2] - C_BRANCH[2]) * kTrunk;
for (var p = 0; p < foods.length; p++) {
var dx = x - foods[p][0], dy = y - foods[p][1];
var d2c = dx * dx + dy * dy;
var pulse = 0.55 + 0.45 * Math.sin(t * 2.4 + foods[p][2]);
var glow = Math.exp(-d2c / 20) * pulse;
rr += (C_FOOD[0] - rr) * glow; gg += (C_FOOD[1] - gg) * glow; bb += (C_FOOD[2] - bb) * glow;
}
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
stepCount = 0;
buildFoods();
initAgents();
}
function stillFrame() {
for (var k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
if (nOut) nOut.textContent = String(AGENTS);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// NetworkFoodSource — vanilla 판(run/320/demo/03-slime-network-food-source/index.html) 의 React 포트.
// 다중 먹이 네트워크. Physarum 스티그머지 + 먹이점 6개 — 화면에 먹이 앵커 6개를 흩뿌리고,
// 센싱값에 "가까운 먹이로 갈수록 유리"한 가우시안 바이어스를 트레일 위에 얹는다. 여러 먹이를
// 잇는 지름길은 왕복이 잦아 굵은 간선(trunk)으로 남고, 외곽 먹이 하나로만 가는 가지는 가는
// 지선(branch)으로 남는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NetworkFoodSource.module.scss";
export type NetworkFoodSourceProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number; // 01(1300)보다 밀도 상향
decay?: number; // 증발률 — 간선/지선 구분이 살도록 01보다 느리게
foodWeight?: number; // 먹이향이 센서 판단에 끼치는 가중치
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Food = [number, number, number];
const FIELD_LABEL = "다중 먹이 점균 네트워크 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.0;
const DIFFUSE = 0.5;
const CAP = 8;
const SUBSTEPS = 3;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function NetworkFoodSource({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f8ddaa",
agents = 2200,
decay = 0.038,
foodWeight = 2.0,
}: NetworkFoodSourceProps) {
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 colRef = useRef<{ floor: RGB; trunk: RGB; branch: RGB; food: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const foodWRef = useRef(foodWeight) as Box<number>;
const randRef = useRef(makePRNG(0x320a03)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodFieldRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodsRef = useRef<Food[]>([]) as Box<Food[]>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function buildFoods(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const foods: Food[] = [];
const nF = 6;
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.42;
for (let i = 0; i < nF; i++) {
const a = (i / nF) * Math.PI * 2 + rand() * 0.5;
const rr = r * (0.7 + rand() * 0.3);
foods.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, rand() * Math.PI * 2]);
}
foodsRef.current = foods;
const food = new Float32Array(fw * fh);
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
let best = 0;
for (let p = 0; p < foods.length; p++) {
const dx = x - foods[p][0], dy = y - foods[p][1];
const d2b = dx * dx + dy * dy;
const v = Math.exp(-d2b / (fw * 0.9));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
foodFieldRef.current = food;
}
function sense(x: number, y: number): number {
const i = clampIdx(x, y);
return trailRef.current[i] + foodFieldRef.current[i] * foodWRef.current * CAP;
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.12;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const foods = foodsRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let v = trail[i] / CAP; if (v > 1) v = 1;
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 28 + (n2 - 0.5) * 18;
const kBranch = Math.min(1, v * 2.2);
const kTrunk = Math.max(0, v - 0.45) * 1.8;
let rr = col.floor[0] + (col.branch[0] - col.floor[0]) * kBranch + (col.trunk[0] - col.branch[0]) * kTrunk;
let gg = col.floor[1] + (col.branch[1] - col.floor[1]) * kBranch + (col.trunk[1] - col.branch[1]) * kTrunk;
let bb = col.floor[2] + (col.branch[2] - col.floor[2]) * kBranch + (col.trunk[2] - col.branch[2]) * kTrunk;
for (let p = 0; p < foods.length; p++) {
const dx = x - foods[p][0], dy = y - foods[p][1];
const d2c = dx * dx + dy * dy;
const pulse = 0.55 + 0.45 * Math.sin(t * 2.4 + foods[p][2]);
const glow = Math.exp(-d2c / 20) * pulse;
rr += (col.food[0] - rr) * glow; gg += (col.food[1] - gg) * glow; bb += (col.food[2] - bb) * glow;
}
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
buildFoods();
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
setN(String(agentsRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
foodWRef.current = foodWeight;
randRef.current = makePRNG(0x320a03);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
trunk: tokRGB(root, "--sm-trunk"),
branch: tokRGB(root, "--sm-branch"),
food: tokRGB(root, "--sm-food"),
};
buildPerm();
fit();
setN("0");
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, foodWeight]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>화면에 먹이 지점을 여러 개 흩뿌리면 점균이 모든 지점을 잇는 굵은 간선과 가는 지선을 스스로 구분해, 효율적인 방사형 수송망을 그려냅니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b> 에이전트 · 방사형 수송망</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 · 다중 먹이 네트워크 — 철도망처럼 최적 연결선을 그리기 — 측정 누적면적 36.237% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
먹이 지점을 여러 개 흩뿌리면 점균이 모든 지점을 잇는 굵은 간선과 가는 지선을 스스로 구분해 효율적인 방사형 수송망을 그려내는데, 2010년 같은 연구진이 점균으로 도쿄 지하철 노선망과 비슷한 연결을 재현했다는 보고와 닿아 있는 판입니다.
먹이 가중치 하나가 지선을 간선으로 바꾼다
function sense(x, y) {
var i = clampIdx(x, y);
return trail[i] + food[i] * FOOD_W * CAP;
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.12;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
센서가 읽는 값은 트레일에 먹이 필드를 FOOD_W * CAP배로 얹은 합이라 먹이 주변은 항상 짙게 읽히고, 에이전트가 그 주변을 맴돌다 서로의 트레일을 겹치면 겹친 트레일이 다시 먹이로 향하는 흐름을 끌어당겨 굵은 간선이 됩니다. 시작은 화면 중앙의 작은 원반 안에 몰아 태워 성장을 모든 먹이 방향으로 동시에 뻗게 합니다.
04포인터 먹이·기피물 — 클릭한 자리로 네트워크가 자라거나 피하기

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>클릭한 좌표를 새 먹이로 심으면 네트워크가 그쪽으로 가지를 뻗고, 모드를 바꿔 소금(기피물질)로 두면 트레일이 그 자리를 피해 휘어 자랍니다</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="포인터 먹이·기피물 점균 실험판" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<button class="sm__toggle" id="smMode" type="button" aria-pressed="false">
<span id="smModeLabel">클릭 = 먹이</span>
</button>
<span class="sm__meta sm__meta--dim">canvas · pointermove · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포인터 먹이·기피물(04). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(클릭·자동각본 앵커 + 3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 코랄빛 노을 — 01(밤)·02(종이)·03(밀짚) 다음 네 번째 순환.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.83 이상 — BD26 안전) ──
$sm-stage: #ffe3ce; // 코랄빛 노을 무대
@include stage($sm-stage);
$sm-floor: #ffe3ce; // 필드 바닥 — 무대와 같은 톤
$sm-trail: #ffcdb0; // 트레일 — 살짝 진한 코랄
$sm-food: #ffc2ad; // 먹이 앵커 글로우 — 파스텔 코랄(끌림)
$sm-salt: #bfe3ff; // 소금 앵커 글로우 — 파스텔 스카이(기피, 무대와 색상 대비)
$sm-text: #4a2e18; // 노을 무대 위 — 짙은 테라코타 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-trail: #{$sm-trail};
--sm-food: #{$sm-food};
--sm-salt: #{$sm-salt};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
cursor: crosshair;
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__toggle {
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-pill;
background: $sm-trail; color: $sm-text;
font-size: 10px; font-weight: 700; line-height: 1;
cursor: pointer; transition: background $dur-quick $ease-out;
}
.sm__toggle:hover { background: $sm-food; }
.sm__toggle:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__toggle[aria-pressed="true"] { background: $sm-salt; }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 앵커 세 개가 자리 잡은 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__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: #ffe3ce;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #ffe3ce;
--sm-trail: #ffcdb0;
--sm-food: #ffc2ad;
--sm-salt: #bfe3ff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #4a2e18;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffe3ce;
box-shadow: inset 0 0 0 1px rgba(74, 46, 24, 0.18);
cursor: crosshair;
}
.sm__field:focus-visible {
outline: 2px solid #4a2e18;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 0;
border-radius: 999px;
background: #ffcdb0;
color: #4a2e18;
font-size: 10px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: background 220ms ease-out;
}
.sm__toggle:hover {
background: #ffc2ad;
}
.sm__toggle:focus-visible {
outline: 2px solid #4a2e18;
outline-offset: 2px;
}
.sm__toggle[aria-pressed=true] {
background: #bfe3ff;
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(74, 46, 24, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__field {
cursor: default !important;
}
}// 포인터 먹이·기피물(04). Physarum 스티그머지 + 실시간 유인/기피 앵커 —
// 클릭한 좌표를 부호 있는 향(sign: +먹이 / -소금)으로 심고 센싱값에 더한다. 에이전트는
// 먹이 앵커 쪽은 끌리고 소금 앵커 쪽은 밀려난다. .is-demo 동안은 실제 클릭 없이도 화면이
// 살아 있도록 스크립트가 좌표를 골라 자동으로 같은 심기 함수를 호출하고, 사람이 실제로
// 클릭·모드 버튼을 누르면 그 즉시 같은 로직이 반응한다(자동·수동 경로가 완전히 같다).
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 필드 전체를 매 프레임 새로 계산해 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
// · 먹이·소금 앵커는 심은 뒤 서서히 옅어지며 사라져(수명) 다음 클릭 자리를 비운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1500; // 동시에 걷는 에이전트 수
var DECAY = 0.05; // 증발률
var ANCHOR_LIFE = 5.2; // 앵커(먹이·소금) 수명(초)
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
var ANCHOR_W = 2.2;
var MAX_ANCHORS = 7;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var modeBtn = document.getElementById('smMode');
var modeLabel = document.getElementById('smModeLabel');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A04);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_FLOOR = tokRGB('--sm-floor');
var C_TRAIL = tokRGB('--sm-trail');
var C_FOOD = tokRGB('--sm-food');
var C_SALT = tokRGB('--sm-salt');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, img, data;
var ax, ay, ah;
var anchors = []; // {x,y,sign,born}
var mode = 1; // 1=먹이(끌림) · -1=소금(기피)
var curT = 0;
var autoIdx = 0;
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function placeAnchor(fx, fy, sign) {
anchors.push({ x: fx, y: fy, sign: sign, born: curT });
if (anchors.length > MAX_ANCHORS) anchors.shift();
}
function anchorField(x, y) {
var v = 0;
for (var i = 0; i < anchors.length; i++) {
var a = anchors[i];
var age = curT - a.born;
if (age < 0 || age > ANCHOR_LIFE) continue;
var life = 1 - age / ANCHOR_LIFE;
var dx = x - a.x, dy = y - a.y;
var d2 = dx * dx + dy * dy;
v += a.sign * life * Math.exp(-d2 / 30);
}
return v;
}
function sense(x, y) {
var i = clampIdx(x, y);
return trail[i] + anchorField(x, y) * ANCHOR_W * CAP;
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.16;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var v = trail[i] / CAP; if (v > 1) v = 1;
var k = v * v * (3 - 2 * v);
var rr = C_FLOOR[0] + (C_TRAIL[0] - C_FLOOR[0]) * k;
var gg = C_FLOOR[1] + (C_TRAIL[1] - C_FLOOR[1]) * k;
var bb = C_FLOOR[2] + (C_TRAIL[2] - C_FLOOR[2]) * k;
for (var ai = 0; ai < anchors.length; ai++) {
var a = anchors[ai];
var age = t - a.born;
if (age < 0 || age > ANCHOR_LIFE) continue;
var life = 1 - age / ANCHOR_LIFE;
var dx = x - a.x, dy = y - a.y;
var d2 = dx * dx + dy * dy;
var pulse = 0.6 + 0.4 * Math.sin(t * 3.1 + ai);
var glow = Math.exp(-d2 / 22) * life * pulse;
var col = a.sign > 0 ? C_FOOD : C_SALT;
rr += (col[0] - rr) * glow; gg += (col[1] - gg) * glow; bb += (col[2] - bb) * glow;
}
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 26 + (n2 - 0.5) * 16;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
anchors = []; curT = 0; autoIdx = 0;
initAgents();
}
function stillFrame() {
curT = 0.4; placeAnchor(fw * 0.28, fh * 0.5, 1);
curT = 1.1; placeAnchor(fw * 0.74, fh * 0.32, 1);
curT = 1.7; placeAnchor(fw * 0.5, fh * 0.78, -1);
curT = 2.4;
for (var k = 0; k < 240; k++) { stepAgents(); diffuseEvap(); curT += 0.02; }
render(curT);
}
// .is-demo 자동 시연 각본 — 사람이 안 눌러도 동일 placeAnchor() 로 화면이 산다.
var script = [
{ t: 0.3, x: 0.26, y: 0.5, sign: 1 },
{ t: 1.1, x: 0.7, y: 0.28, sign: 1 },
{ t: 1.9, x: 0.5, y: 0.78, sign: -1 },
{ t: 2.7, x: 0.78, y: 0.7, sign: 1 },
{ t: 3.6, x: 0.22, y: 0.24, sign: -1 }
];
function frame(now) {
if (t0 < 0) t0 = now;
curT = (now - t0) / 1000;
if (root.classList.contains('is-demo')) {
while (autoIdx < script.length && curT >= script[autoIdx].t) {
var s = script[autoIdx];
placeAnchor(fw * s.x, fh * s.y, s.sign);
autoIdx++;
}
if (autoIdx >= script.length && curT > script[script.length - 1].t + ANCHOR_LIFE) autoIdx = 0, t0 = now;
}
for (var i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
render(curT);
raf = requestAnimationFrame(frame);
}
function setMode(next) {
mode = next;
modeBtn.setAttribute('aria-pressed', mode < 0 ? 'true' : 'false');
if (modeLabel) modeLabel.textContent = mode > 0 ? '클릭 = 먹이' : '클릭 = 소금';
}
field.addEventListener('pointerdown', function (e) {
var rect = field.getBoundingClientRect();
var fx = (e.clientX - rect.left) / rect.width * fw;
var fy = (e.clientY - rect.top) / rect.height * fh;
placeAnchor(fx, fy, mode);
});
modeBtn.addEventListener('click', function () { setMode(-mode); });
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// PointerAttractRepel — vanilla 판(run/320/demo/04-slime-pointer-attract-repel/index.html) 의 React 포트.
// 포인터 먹이·기피물. Physarum 스티그머지 + 실시간 유인/기피 앵커 — 클릭한 좌표를 부호 있는
// 향(sign: +먹이 / -소금)으로 심고 센싱값에 더한다. 에이전트는 먹이 앵커 쪽은 끌리고 소금
// 앵커 쪽은 밀려난다. 사람이 클릭하지 않아도 자동 각본이 같은 심기 함수를 호출해 화면이 산다
// (자동·수동 경로가 완전히 같다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./PointerAttractRepel.module.scss";
export type PointerAttractRepelProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number; // 동시에 걷는 에이전트 수
decay?: number; // 증발률
anchorLife?: number; // 앵커(먹이·소금) 수명(초)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Anchor = { x: number; y: number; sign: number; born: number };
type Script = { t: number; x: number; y: number; sign: number };
const FIELD_LABEL = "포인터 먹이·기피물 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
const ANCHOR_W = 2.2;
const MAX_ANCHORS = 7;
const AUTO_SCRIPT: Script[] = [
{ t: 0.3, x: 0.26, y: 0.5, sign: 1 },
{ t: 1.1, x: 0.7, y: 0.28, sign: 1 },
{ t: 1.9, x: 0.5, y: 0.78, sign: -1 },
{ t: 2.7, x: 0.78, y: 0.7, sign: 1 },
{ t: 3.6, x: 0.22, y: 0.24, sign: -1 },
];
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function PointerAttractRepel({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffcdb0",
agents = 1500,
decay = 0.05,
anchorLife = 5.2,
}: PointerAttractRepelProps) {
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 colRef = useRef<{ floor: RGB; trail: RGB; food: RGB; salt: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const anchorLifeRef = useRef(anchorLife) as Box<number>;
const randRef = useRef(makePRNG(0x320a04)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const anchorsRef = useRef<Anchor[]>([]) as Box<Anchor[]>;
const modeRef = useRef(1) as Box<number>;
const curTRef = useRef(0) as Box<number>;
const autoIdxRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [modePressed, setModePressed] = useState(false);
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function placeAnchor(fx: number, fy: number, sign: number): void {
const anchors = anchorsRef.current;
anchors.push({ x: fx, y: fy, sign, born: curTRef.current });
if (anchors.length > MAX_ANCHORS) anchors.shift();
}
function anchorField(x: number, y: number): number {
const anchors = anchorsRef.current;
const life0 = anchorLifeRef.current;
let v = 0;
for (let i = 0; i < anchors.length; i++) {
const a = anchors[i];
const age = curTRef.current - a.born;
if (age < 0 || age > life0) continue;
const life = 1 - age / life0;
const dx = x - a.x, dy = y - a.y;
const d2 = dx * dx + dy * dy;
v += a.sign * life * Math.exp(-d2 / 30);
}
return v;
}
function sense(x: number, y: number): number {
const i = clampIdx(x, y);
return trailRef.current[i] + anchorField(x, y) * ANCHOR_W * CAP;
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.16;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const anchors = anchorsRef.current;
const life0 = anchorLifeRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let v = trail[i] / CAP; if (v > 1) v = 1;
const k = v * v * (3 - 2 * v);
let rr = col.floor[0] + (col.trail[0] - col.floor[0]) * k;
let gg = col.floor[1] + (col.trail[1] - col.floor[1]) * k;
let bb = col.floor[2] + (col.trail[2] - col.floor[2]) * k;
for (let ai = 0; ai < anchors.length; ai++) {
const a = anchors[ai];
const age = t - a.born;
if (age < 0 || age > life0) continue;
const life = 1 - age / life0;
const dx = x - a.x, dy = y - a.y;
const d2 = dx * dx + dy * dy;
const pulse = 0.6 + 0.4 * Math.sin(t * 3.1 + ai);
const glow = Math.exp(-d2 / 22) * life * pulse;
const acol = a.sign > 0 ? col.food : col.salt;
rr += (acol[0] - rr) * glow; gg += (acol[1] - gg) * glow; bb += (acol[2] - bb) * glow;
}
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 26 + (n2 - 0.5) * 16;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
anchorsRef.current = []; curTRef.current = 0; autoIdxRef.current = 0;
initAgents();
}
function stillFrame(): void {
curTRef.current = 0.4; placeAnchor(fwRef.current * 0.28, fhRef.current * 0.5, 1);
curTRef.current = 1.1; placeAnchor(fwRef.current * 0.74, fhRef.current * 0.32, 1);
curTRef.current = 1.7; placeAnchor(fwRef.current * 0.5, fhRef.current * 0.78, -1);
curTRef.current = 2.4;
for (let k = 0; k < 240; k++) { stepAgents(); diffuseEvap(); curTRef.current += 0.02; }
render(curTRef.current);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
curTRef.current = (now - t0) / 1000;
const script = AUTO_SCRIPT;
while (autoIdxRef.current < script.length && curTRef.current >= script[autoIdxRef.current].t) {
const s = script[autoIdxRef.current];
placeAnchor(fwRef.current * s.x, fhRef.current * s.y, s.sign);
autoIdxRef.current++;
}
if (autoIdxRef.current >= script.length && curTRef.current > script[script.length - 1].t + anchorLifeRef.current) {
autoIdxRef.current = 0; t0 = now;
}
for (let i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
render(curTRef.current);
rafRef.current = requestAnimationFrame(frame);
}
function setMode(next: number): void {
modeRef.current = next;
setModePressed(next < 0);
}
function onFieldPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const rect = field.getBoundingClientRect();
const fx = (e.clientX - rect.left) / rect.width * fwRef.current;
const fy = (e.clientY - rect.top) / rect.height * fhRef.current;
placeAnchor(fx, fy, modeRef.current);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
anchorLifeRef.current = anchorLife;
randRef.current = makePRNG(0x320a04);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
trail: tokRGB(root, "--sm-trail"),
food: tokRGB(root, "--sm-food"),
salt: tokRGB(root, "--sm-salt"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, anchorLife]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>클릭한 좌표를 새 먹이로 심으면 네트워크가 그쪽으로 가지를 뻗고, 모드를 바꿔 소금(기피물질)로 두면 트레일이 그 자리를 피해 휘어 자랍니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={onFieldPointerDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<button
className={styles.toggle}
type="button"
aria-pressed={modePressed}
onClick={() => setMode(-modeRef.current)}
>
<span>{modePressed ? "클릭 = 소금" : "클릭 = 먹이"}</span>
</button>
<span className={styles.metaDim}>canvas · pointermove · requestAnimationFrame</span>
</div>
</div>
</main>
);
}04 · 포인터 먹이·기피물 — 클릭한 자리로 네트워크가 자라거나 피하기 — 측정 누적면적 41.776% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
클릭한 좌표를 새 먹이로 심으면 네트워크가 그쪽으로 가지를 뻗고 모드를 바꿔 소금(기피물질)로 두면 트레일이 그 자리를 피해 휘어 자라니, 끌어당김과 밀어냄이 값 하나의 차이라는 걸 손으로 확인하는 판입니다.
닻의 부호와 수명으로 끌기와 밀기
function anchorField(x, y) {
var v = 0;
for (var i = 0; i < anchors.length; i++) {
var a = anchors[i];
var age = curT - a.born;
if (age < 0 || age > ANCHOR_LIFE) continue;
var life = 1 - age / ANCHOR_LIFE;
var dx = x - a.x, dy = y - a.y;
var d2 = dx * dx + dy * dy;
v += a.sign * life * Math.exp(-d2 / 30);
}
return v;
}
클릭할 때마다 좌표·시각·부호를 가진 닻이 쌓이고, 센서는 trail[i] + anchorField(x, y) * ANCHOR_W * CAP를 읽습니다. 부호 a.sign이 +1이면 먹이, -1이면 소금이라 같은 가우시안 감쇠(Math.exp(-d2 / 30))가 끌림과 밀림을 모두 담당하며, ANCHOR_LIFE가 지나면 닻이 사라져 화면이 원래 성장으로 되돌아갑니다.
05다종 영역 경쟁 — 서로 다른 색 두 군집이 화면을 나눠 갖기

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>종(species)마다 자기 트레일에만 반응하고 남의 트레일은 장애물로 취급하게 하면, 두 색 군집이 서로 밀어내며 화면을 경계선 뚜렷한 영역으로 나눕니다</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="다종 영역 경쟁 점균 실험판" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> 스텝 · 2종 경계선</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 다종 영역 경쟁(05). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(두 종 각자 3센서 감지 → 회전 → 자기 트레일 → 확산·증발)가 만든다.
// 무대는 다시 먹(ink) — 01(밤)·02(종이)·03(밀짚)·04(노을) 다음 다섯 번째 순환. 어두운 바닥 위에
// 두 파스텔 종(핑크·민트)이 발광하며 영역 경계를 그린다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(어두운 무대 L0.1 미만 + 파스텔 종 L0.9 이상 — BD26 안전) ──
$sm-stage: #1a1620; // 밤무대 먹 — 01 의 밤과 같은 계열
@include stage($sm-stage);
$sm-ink: #1a1620; // 필드 바닥 — 무대와 같은 톤
$sm-a: #ffd6e0; // 종 A — 파스텔 핑크
$sm-b: #d6f5e3; // 종 B — 파스텔 민트
$sm-text: #f3ecdd; // 먹 무대 위 — 크림 글자(대비)
.sm {
--sm-ink: #{$sm-ink};
--sm-a: #{$sm-a};
--sm-b: #{$sm-b};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-ink;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__val { color: $sm-a; }
.sm__meta { font-size: 10px; font-weight: 700; color: rgba($sm-text, .72); }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 두 종이 영역을 나눈 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a1620;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-ink: #1a1620;
--sm-a: #ffd6e0;
--sm-b: #d6f5e3;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #f3ecdd;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1a1620;
box-shadow: inset 0 0 0 1px rgba(243, 236, 221, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #f3ecdd;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__val {
color: #ffd6e0;
}
.sm__meta {
font-size: 10px;
font-weight: 700;
color: rgba(243, 236, 221, 0.72);
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(243, 236, 221, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still {
opacity: 1;
}
}// 다종 영역 경쟁(05). Physarum 스티그머지 + 두 페로몬 필드 상호 기피 —
// 종 A·B 는 각자의 트레일 필드만 따로 갖는다. 센싱 값은 "내 종 트레일 - 남의 종 트레일 × 기피가중"
// 이라 자기 흔적은 강화되고 상대 흔적은 장애물처럼 밀어낸다. 두 군집이 중앙에서 만나 서로
// 밀고 밀리며 화면이 뚜렷한 경계선을 가진 두 영역으로 갈라진다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 두 필드를 합성해 매 프레임 새로 계산해 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS_A = 700, AGENTS_B = 700; // 종마다 개체 수
var DECAY = 0.048; // 증발률
var REPEL_W = 1.7; // 상대 종 트레일 기피 가중치
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A05);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--sm-ink');
var C_A = tokRGB('--sm-a');
var C_B = tokRGB('--sm-b');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trailA, trailAB, trailB, trailBB, img, data;
var axA, ayA, ahA, axB, ayB, ahB;
var stepCount = 0;
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function senseFrom(own, other, x, y) {
var i = clampIdx(x, y);
return own[i] - other[i] * REPEL_W;
}
function initSpecies(count, cx0) {
var ax = new Float32Array(count), ay = new Float32Array(count), ah = new Float32Array(count);
var cx = fw * cx0, cy = fh / 2, r = Math.min(fw, fh) * 0.13;
for (var i = 0; i < count; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
return [ax, ay, ah];
}
function stepSpecies(ax, ay, ah, count, own, other) {
for (var i = 0; i < count; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = senseFrom(own, other, x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = senseFrom(own, other, x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = senseFrom(own, other, x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
own[ii] = Math.min(CAP, own[ii] + DEPOSIT);
}
}
function diffuseEvapOne(field0, field1) {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (field0[rowY + xm] + field0[rowY + xp] + field0[rowYm + x] + field0[rowYp + x]) * 0.25;
field1[i] = (field0[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var va = trailA[i] / CAP; if (va > 1) va = 1;
var vb = trailB[i] / CAP; if (vb > 1) vb = 1;
var ka = va * va * (3 - 2 * va);
var kb = vb * vb * (3 - 2 * vb);
var rr = C_INK[0] + (C_A[0] - C_INK[0]) * ka + (C_B[0] - C_INK[0]) * kb;
var gg = C_INK[1] + (C_A[1] - C_INK[1]) * ka + (C_B[1] - C_INK[1]) * kb;
var bb = C_INK[2] + (C_A[2] - C_INK[2]) * ka + (C_B[2] - C_INK[2]) * kb;
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 22 + (n2 - 0.5) * 14;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trailA = new Float32Array(fw * fh); trailAB = new Float32Array(fw * fh);
trailB = new Float32Array(fw * fh); trailBB = new Float32Array(fw * fh);
stepCount = 0;
var sa = initSpecies(AGENTS_A, 0.3); axA = sa[0]; ayA = sa[1]; ahA = sa[2];
var sb = initSpecies(AGENTS_B, 0.7); axB = sb[0]; ayB = sb[1]; ahB = sb[2];
}
function stepAll() {
stepSpecies(axA, ayA, ahA, AGENTS_A, trailA, trailB);
stepSpecies(axB, ayB, ahB, AGENTS_B, trailB, trailA);
diffuseEvapOne(trailA, trailAB);
diffuseEvapOne(trailB, trailBB);
var ta = trailA; trailA = trailAB; trailAB = ta;
var tb = trailB; trailB = trailBB; trailBB = tb;
stepCount++;
}
function stillFrame() {
for (var k = 0; k < 260; k++) stepAll();
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) stepAll();
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// MultiSpeciesTerritory — vanilla 판(run/320/demo/05-slime-multi-species-territory/index.html) 의 React 포트.
// 다종 영역 경쟁. 두 종(A·B) 이 각자의 트레일 필드만 갖고, 센싱값은 "내 종 - 남의 종 × 기피가중"
// 이라 자기 흔적은 강화되고 상대 흔적은 장애물처럼 밀어낸다 — 화면이 뚜렷한 경계선을 가진 두
// 영역으로 갈라진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MultiSpeciesTerritory.module.scss";
export type MultiSpeciesTerritoryProps = {
duration?: string;
easing?: string;
color?: string;
agentsA?: number;
agentsB?: number;
decay?: number;
repelWeight?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "다종 영역 경쟁 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function MultiSpeciesTerritory({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f3ecdd",
agentsA = 700,
agentsB = 700,
decay = 0.048,
repelWeight = 1.7,
}: MultiSpeciesTerritoryProps) {
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 colRef = useRef<{ ink: RGB; a: RGB; b: RGB } | null>(null);
const agentsARef = useRef(agentsA) as Box<number>;
const agentsBRef = useRef(agentsB) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const repelRef = useRef(repelWeight) as Box<number>;
const randRef = useRef(makePRNG(0x320a05)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailARef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailABRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axARef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayARef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahARef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function senseFrom(own: Float32Array, other: Float32Array, x: number, y: number): number {
const i = clampIdx(x, y);
return own[i] - other[i] * repelRef.current;
}
function initSpecies(count: number, cx0: number): [Float32Array, Float32Array, Float32Array] {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = new Float32Array(count), ay = new Float32Array(count), ah = new Float32Array(count);
const cx = fw * cx0, cy = fh / 2, r = Math.min(fw, fh) * 0.13;
for (let i = 0; i < count; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
return [ax, ay, ah];
}
function stepSpecies(
ax: Float32Array, ay: Float32Array, ah: Float32Array, count: number,
own: Float32Array, other: Float32Array,
): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
for (let i = 0; i < count; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = senseFrom(own, other, x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = senseFrom(own, other, x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = senseFrom(own, other, x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
own[ii] = Math.min(CAP, own[ii] + DEPOSIT);
}
}
function diffuseEvapOne(field0: Float32Array, field1: Float32Array): void {
const fw = fwRef.current, fh = fhRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (field0[rowY + xm] + field0[rowY + xp] + field0[rowYm + x] + field0[rowYp + x]) * 0.25;
field1[i] = (field0[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
}
function stepAll(): void {
stepSpecies(axARef.current, ayARef.current, ahARef.current, agentsARef.current, trailARef.current, trailBRef.current);
stepSpecies(axBRef.current, ayBRef.current, ahBRef.current, agentsBRef.current, trailBRef.current, trailARef.current);
diffuseEvapOne(trailARef.current, trailABRef.current);
diffuseEvapOne(trailBRef.current, trailBBRef.current);
const ta = trailARef.current; trailARef.current = trailABRef.current; trailABRef.current = ta;
const tb = trailBRef.current; trailBRef.current = trailBBRef.current; trailBBRef.current = tb;
stepCountRef.current++;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trailA = trailARef.current, trailB = trailBRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let va = trailA[i] / CAP; if (va > 1) va = 1;
let vb = trailB[i] / CAP; if (vb > 1) vb = 1;
const ka = va * va * (3 - 2 * va);
const kb = vb * vb * (3 - 2 * vb);
let rr = col.ink[0] + (col.a[0] - col.ink[0]) * ka + (col.b[0] - col.ink[0]) * kb;
let gg = col.ink[1] + (col.a[1] - col.ink[1]) * ka + (col.b[1] - col.ink[1]) * kb;
let bb = col.ink[2] + (col.a[2] - col.ink[2]) * ka + (col.b[2] - col.ink[2]) * kb;
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 22 + (n2 - 0.5) * 14;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailARef.current = new Float32Array(fw * fh);
trailABRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
trailBBRef.current = new Float32Array(fw * fh);
stepCountRef.current = 0;
const sa = initSpecies(agentsARef.current, 0.3);
axARef.current = sa[0]; ayARef.current = sa[1]; ahARef.current = sa[2];
const sb = initSpecies(agentsBRef.current, 0.7);
axBRef.current = sb[0]; ayBRef.current = sb[1]; ahBRef.current = sb[2];
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) stepAll();
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) stepAll();
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsARef.current = agentsA;
agentsBRef.current = agentsB;
decayRef.current = decay;
repelRef.current = repelWeight;
randRef.current = makePRNG(0x320a05);
colRef.current = {
ink: tokRGB(root, "--sm-ink"),
a: tokRGB(root, "--sm-a"),
b: tokRGB(root, "--sm-b"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agentsA, agentsB, decay, repelWeight]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>종(species)마다 자기 트레일에만 반응하고 남의 트레일은 장애물로 취급하게 하면, 두 색 군집이 서로 밀어내며 화면을 경계선 뚜렷한 영역으로 나눕니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>
<b className={styles.val}>{n}</b>
<span> 스텝 · 2종 경계선</span>
</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}05 · 다종 영역 경쟁 — 서로 다른 색 두 군집이 화면을 나눠 갖기 — 측정 누적면적 53.258% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
종마다 자기 트레일에만 반응하고 남의 트레일은 장애물로 취급하게 하면 두 색 군집이 서로 밀어내며 경계선 뚜렷한 영역으로 화면을 나누는데, 누적면적 53.258%·강도 평균 57.0으로 아홉 판 중 가장 진하게 타오르는 대결판입니다.
남의 트레일을 빼서 읽는 종별 센서
function stepSpecies(ax, ay, ah, count, own, other) {
for (var i = 0; i < count; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = senseFrom(own, other, x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = senseFrom(own, other, x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = senseFrom(own, other, x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
own[ii] = Math.min(CAP, own[ii] + DEPOSIT);
}
}
stepSpecies는 트레일 필드를 자기 것 own과 상대 것 other 두 개를 받아 같은 회전 규칙을 돌리는데, 이곳의 senseFrom(own, other, x, y)는 own[i] - other[i] * REPEL_W를 돌려주어 상대가 짙게 깔린 쪽은 값이 깎여 우회로 선택됩니다. 퇴적도 own에만 하므로 두 필드는 서로를 밀어내며 성장하고 경계가 저절로 또렷해집니다.
06트레일 나이 채색 — 최근 지나간 길일수록 밝게 빛나는 네온 잔상

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>각 셀에 마지막으로 밟힌 시각을 기록해 오래될수록 색을 어둡게 죽이면, 활발히 쓰이는 간선만 네온처럼 밝게 남고 버려진 지선은 자연히 사라집니다</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="트레일 나이 채색 점균 실험판" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> 스텝 · 트레일 나이</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 트레일 나이 채색(06). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(3센서 감지 → 회전 → 마지막 방문 시각 기록 → 나이 페이드)가 만든다.
// 무대는 다시 밤(ink) 바탕 — 네온 잔상 컨셉에 맞춘 여섯 번째 순환(01밤·02종이·03밀짚·04노을·05먹).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 팔레트(어두운 무대 L0.1 미만 + 네온 잔상 파스텔 L0.85 이상 — BD26 안전) ──
$sm-stage: #171426; // 새벽 남색 먹 무대
@include stage($sm-stage);
$sm-ink: #171426; // 필드 바닥 — 무대와 같은 톤
$sm-old: #4a4470; // 오래된 트레일 — 죽어가는 흐린 남보라(어두운 배경 위 낮은 채도)
$sm-fresh: #d9f7ff; // 방금 밟힌 트레일 — 파스텔 네온 시안(밝은 계열)
$sm-text: #f0eaf5; // 남색 먹 위 — 크림 라벤더 글자(대비)
.sm {
--sm-ink: #{$sm-ink};
--sm-old: #{$sm-old};
--sm-fresh: #{$sm-fresh};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-ink;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__val { color: $sm-fresh; }
.sm__meta { font-size: 10px; font-weight: 700; color: rgba($sm-text, .72); }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 나이가 섞인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #171426;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-ink: #171426;
--sm-old: #4a4470;
--sm-fresh: #d9f7ff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #f0eaf5;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #171426;
box-shadow: inset 0 0 0 1px rgba(240, 234, 245, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #f0eaf5;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__val {
color: #d9f7ff;
}
.sm__meta {
font-size: 10px;
font-weight: 700;
color: rgba(240, 234, 245, 0.72);
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(240, 234, 245, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still {
opacity: 1;
}
}// 트레일 나이 채색(06). Physarum 스티그머지 + 셀별 마지막 방문 시각 —
// trail 대신 lastVisit Float32Array 에 "이 셀을 마지막으로 밟은 스텝 번호"를 기록한다.
// 렌더는 나이(age = 현재스텝 - 마지막방문)로 색을 정한다: 방금 밟힌 셀은 네온처럼 밝고,
// 나이가 차면 어둡게 죽는다. 여러 먹이 앵커를 두고 트레일이 쓰이는 간선만 살아남는 모습.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 나이 필드를 매 프레임 새로 계산해 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1400; // 동시에 걷는 에이전트 수
var DECAY_STEPS = 46; // 트레일이 완전히 죽는 데 걸리는 스텝 수(나이 수명)
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DIFFUSE = 0.5;
var SUBSTEPS = 3;
var FOOD_W = 2.0;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A06);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--sm-ink');
var C_OLD = tokRGB('--sm-old');
var C_FRESH = tokRGB('--sm-fresh');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var lastVisit, trail, trailB, food, img, data;
var ax, ay, ah;
var stepCount = 0;
var foods = [];
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function buildFoods() {
foods = [];
var n = 5;
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.4;
for (var i = 0; i < n; i++) {
var a = (i / n) * Math.PI * 2 + rand() * 0.6;
var rr = r * (0.72 + rand() * 0.28);
foods.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, rand() * Math.PI * 2]);
}
food = new Float32Array(fw * fh);
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var best = 0;
for (var p = 0; p < foods.length; p++) {
var dx = x - foods[p][0], dy = y - foods[p][1];
var v = Math.exp(-(dx * dx + dy * dy) / (fw * 0.9));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
}
function sense(x, y) {
var i = clampIdx(x, y);
// 나이 필드를 감지에도 그대로 쓴다: 최근 밟힌 셀 = 진한 트레일
var lv = lastVisit[i];
var ageK = lv < 0 ? 0 : Math.max(0, 1 - (stepCount - lv) / DECAY_STEPS);
return ageK * 4 + food[i] * FOOD_W;
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.14;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
lastVisit[ii] = stepCount;
}
stepCount++;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var lv = lastVisit[i];
var k = 0;
if (lv >= 0) {
var age = stepCount - lv;
if (age < DECAY_STEPS) k = 1 - age / DECAY_STEPS;
}
k = k * k * (3 - 2 * k); // 나이 페이드를 부드럽게
var rr = C_INK[0] + (C_OLD[0] - C_INK[0]) * Math.min(1, k * 2.4) + (C_FRESH[0] - C_OLD[0]) * Math.max(0, k - 0.35) * 1.54;
var gg = C_INK[1] + (C_OLD[1] - C_INK[1]) * Math.min(1, k * 2.4) + (C_FRESH[1] - C_OLD[1]) * Math.max(0, k - 0.35) * 1.54;
var bb = C_INK[2] + (C_OLD[2] - C_INK[2]) * Math.min(1, k * 2.4) + (C_FRESH[2] - C_OLD[2]) * Math.max(0, k - 0.35) * 1.54;
for (var p = 0; p < foods.length; p++) {
var dx = x - foods[p][0], dy = y - foods[p][1];
var pulse = 0.55 + 0.45 * Math.sin(t * 2.2 + foods[p][2]);
var glow = Math.exp(-(dx * dx + dy * dy) / 18) * pulse * 0.5;
rr += (C_FRESH[0] - rr) * glow; gg += (C_FRESH[1] - gg) * glow; bb += (C_FRESH[2] - bb) * glow;
}
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
lastVisit = new Float32Array(fw * fh);
for (var i = 0; i < lastVisit.length; i++) lastVisit[i] = -9999;
stepCount = 0;
buildFoods();
initAgents();
}
function stillFrame() {
for (var k = 0; k < 300; k++) stepAgents();
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) stepAgents();
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// TrailDecayColorAge — vanilla 판(run/320/demo/06-slime-trail-decay-color-age/index.html) 의 React 포트.
// 트레일 나이 채색. trail 대신 lastVisit(마지막 방문 스텝)을 기록해 나이(age = 현재스텝 - 마지막방문)로
// 색을 정한다 — 방금 밟힌 셀은 네온처럼 밝고, 나이가 차면 어둡게 죽는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TrailDecayColorAge.module.scss";
export type TrailDecayColorAgeProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number;
decaySteps?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Food = [number, number, number];
const FIELD_LABEL = "트레일 나이 채색 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const SUBSTEPS = 3;
const FOOD_W = 2.0;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function TrailDecayColorAge({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f0eaf5",
agents = 1400,
decaySteps = 46,
}: TrailDecayColorAgeProps) {
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 colRef = useRef<{ ink: RGB; old: RGB; fresh: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayStepsRef = useRef(decaySteps) as Box<number>;
const randRef = useRef(makePRNG(0x320a06)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const lastVisitRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodsRef = useRef<Food[]>([]) as Box<Food[]>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function buildFoods(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const foods: Food[] = [];
const n2 = 5;
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.4;
for (let i = 0; i < n2; i++) {
const a = (i / n2) * Math.PI * 2 + rand() * 0.6;
const rr = r * (0.72 + rand() * 0.28);
foods.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, rand() * Math.PI * 2]);
}
foodsRef.current = foods;
const food = new Float32Array(fw * fh);
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
let best = 0;
for (let p = 0; p < foods.length; p++) {
const dx = x - foods[p][0], dy = y - foods[p][1];
const v = Math.exp(-(dx * dx + dy * dy) / (fw * 0.9));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
foodRef.current = food;
}
function sense(x: number, y: number): number {
const i = clampIdx(x, y);
const lv = lastVisitRef.current[i];
const ageK = lv < 0 ? 0 : Math.max(0, 1 - (stepCountRef.current - lv) / decayStepsRef.current);
return ageK * 4 + foodRef.current[i] * FOOD_W;
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.14;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const lastVisit = lastVisitRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
lastVisit[ii] = stepCountRef.current;
}
stepCountRef.current++;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const lastVisit = lastVisitRef.current;
const decaySteps0 = decayStepsRef.current;
const stepCount = stepCountRef.current;
const foods = foodsRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
const lv = lastVisit[i];
let k = 0;
if (lv >= 0) {
const age = stepCount - lv;
if (age < decaySteps0) k = 1 - age / decaySteps0;
}
k = k * k * (3 - 2 * k);
let rr = col.ink[0] + (col.old[0] - col.ink[0]) * Math.min(1, k * 2.4) + (col.fresh[0] - col.old[0]) * Math.max(0, k - 0.35) * 1.54;
let gg = col.ink[1] + (col.old[1] - col.ink[1]) * Math.min(1, k * 2.4) + (col.fresh[1] - col.old[1]) * Math.max(0, k - 0.35) * 1.54;
let bb = col.ink[2] + (col.old[2] - col.ink[2]) * Math.min(1, k * 2.4) + (col.fresh[2] - col.old[2]) * Math.max(0, k - 0.35) * 1.54;
for (let p = 0; p < foods.length; p++) {
const dx = x - foods[p][0], dy = y - foods[p][1];
const pulse = 0.55 + 0.45 * Math.sin(t * 2.2 + foods[p][2]);
const glow = Math.exp(-(dx * dx + dy * dy) / 18) * pulse * 0.5;
rr += (col.fresh[0] - rr) * glow; gg += (col.fresh[1] - gg) * glow; bb += (col.fresh[2] - bb) * glow;
}
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
const lastVisit = new Float32Array(fw * fh);
for (let i = 0; i < lastVisit.length; i++) lastVisit[i] = -9999;
lastVisitRef.current = lastVisit;
stepCountRef.current = 0;
buildFoods();
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 300; k++) stepAgents();
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) stepAgents();
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayStepsRef.current = decaySteps;
randRef.current = makePRNG(0x320a06);
colRef.current = {
ink: tokRGB(root, "--sm-ink"),
old: tokRGB(root, "--sm-old"),
fresh: tokRGB(root, "--sm-fresh"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decaySteps]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>각 셀에 마지막으로 밟힌 시각을 기록해 오래될수록 색을 어둡게 죽이면, 활발히 쓰이는 간선만 네온처럼 밝게 남고 버려진 지선은 자연히 사라집니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>
<b className={styles.val}>{n}</b>
<span> 스텝 · 트레일 나이</span>
</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}06 · 트레일 나이 채색 — 최근 지나간 길일수록 밝게 빛나는 네온 잔상 — 측정 누적면적 35.206% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
각 셀에 마지막으로 밟힌 시각을 기록해 오래될수록 색을 어둡게 죽이면 활발히 쓰이는 간선만 네온처럼 밝게 남고 버려진 지선은 자연히 사라지는데, 강도 평균 76.0은 아홉 판 중 최고로 밝은 길과 어두운 길의 대비가 가장 큰 판이라는 뜻입니다.
방문 시각의 나이를 색으로 바꾸는 보간
var i = y * fw + x;
var j = i * 4;
var lv = lastVisit[i];
var k = 0;
if (lv >= 0) {
var age = stepCount - lv;
if (age < DECAY_STEPS) k = 1 - age / DECAY_STEPS;
}
k = k * k * (3 - 2 * k); // 나이 페이드를 부드럽게
var rr = C_INK[0] + (C_OLD[0] - C_INK[0]) * Math.min(1, k * 2.4) + (C_FRESH[0] - C_OLD[0]) * Math.max(0, k - 0.35) * 1.54;
var gg = C_INK[1] + (C_OLD[1] - C_INK[1]) * Math.min(1, k * 2.4) + (C_FRESH[1] - C_OLD[1]) * Math.max(0, k - 0.35) * 1.54;
var bb = C_INK[2] + (C_OLD[2] - C_INK[2]) * Math.min(1, k * 2.4) + (C_FRESH[2] - C_OLD[2]) * Math.max(0, k - 0.35) * 1.54;
렌더는 별도의 트레일 강도 필드를 두지 않고 마지막 방문 걸음 lastVisit[i]와 현재 걸음 수의 차로 신선도 k를 계산해 smoothstep으로 부드럽게 만든 뒤, 잉크→오래된 색→새 색의 두 단 보간으로 픽셀을 찍습니다. 감지도 같은 나이 필드를 그대로 읽어 최근 밟힌 셀을 진한 트레일로 취급하므로, 증발을 나이 하나로 표현해도 규칙은 그대로 유지됩니다.
07센서 각도 모프 — 각도 하나로 산호에서 혈관까지 형태가 바뀌기

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>센서 벌림각과 거리 두 숫자만 슬라이더로 바꾸면 같은 규칙이 촘촘한 산호 모양, 성긴 혈관 모양, 격자형 도시 모양까지 완전히 다른 네트워크를 그려냅니다</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="센서 각도 모프 점균 실험판" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<label class="sm__ctl">
<span class="sm__lab" id="smAngLab">벌림각 29°</span>
<input class="sm__rng" id="smAng" type="range" min="10" max="90" step="1" value="29" aria-label="센서 벌림각">
</label>
<label class="sm__ctl">
<span class="sm__lab" id="smDistLab">거리 15</span>
<input class="sm__rng" id="smDist" type="range" min="4" max="30" step="1" value="15" aria-label="센서 거리">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 센서 각도 모프(07). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(슬라이더 파라미터 + 3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 종이(paper) — 일곱 번째 순환(01밤·02종이·03밀짚·04노을·05먹·06남색). 파라미터 실험판에는
// 밝은 종이가 계기판처럼 어울린다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.85 이상 — BD26 안전) ──
$sm-stage: #f4f7f2; // 연한 이끼 종이 무대
@include stage($sm-stage);
$sm-floor: #f4f7f2; // 필드 바닥 — 무대와 같은 톤
$sm-trail: #cfe3c8; // 트레일 — 파스텔 세이지
$sm-acct: #f6cfd4; // 파라미터 표시기 — 파스텔 로즈
$sm-text: #2c3326; // 종이 위 — 짙은 이끼 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-trail: #{$sm-trail};
--sm-acct: #{$sm-acct};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar {
display: flex; align-items: center; justify-content: space-between;
gap: $sp-2; flex-wrap: wrap;
}
.sm__ctl {
display: flex; align-items: center; gap: $sp-1;
min-width: 0;
}
.sm__lab {
font-size: 10px; font-weight: 700; line-height: 1;
color: rgba($sm-text, .72); white-space: nowrap;
}
.sm__rng {
appearance: none; -webkit-appearance: none;
width: clamp(64px, 20vw, 108px); height: 16px;
background: transparent; border: 0; padding: 0; margin: 0;
cursor: pointer;
}
.sm__rng::-webkit-slider-runnable-track {
height: 4px; border-radius: $r-pill;
background: rgba($sm-text, .22);
}
.sm__rng::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: 12px; height: 12px; margin-top: -4px;
border-radius: 50%; border: 0;
background: $sm-trail;
box-shadow: 0 0 0 2px rgba($sm-text, .35);
}
.sm__rng::-moz-range-track {
height: 4px; border-radius: $r-pill;
background: rgba($sm-text, .22);
}
.sm__rng::-moz-range-thumb {
width: 12px; height: 12px; border-radius: 50%; border: 0;
background: $sm-trail;
box-shadow: 0 0 0 2px rgba($sm-text, .35);
}
.sm__rng:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 82px; }
.sm__bar { flex-direction: column; align-items: stretch; gap: $sp-1; }
.sm__ctl { justify-content: space-between; }
.sm__rng { flex: 0 0 auto; }
}
// reduce — rAF 를 돌리지 않고 혈관 모양 파라미터로 자란 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__rng { pointer-events: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #f4f7f2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #f4f7f2;
--sm-trail: #cfe3c8;
--sm-acct: #f6cfd4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #2c3326;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #f4f7f2;
box-shadow: inset 0 0 0 1px rgba(44, 51, 38, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #2c3326;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.sm__ctl {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.sm__lab {
font-size: 10px;
font-weight: 700;
line-height: 1;
color: rgba(44, 51, 38, 0.72);
white-space: nowrap;
}
.sm__rng {
appearance: none;
-webkit-appearance: none;
width: clamp(64px, 20vw, 108px);
height: 16px;
background: transparent;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
}
.sm__rng::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: rgba(44, 51, 38, 0.22);
}
.sm__rng::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 12px;
height: 12px;
margin-top: -4px;
border-radius: 50%;
border: 0;
background: #cfe3c8;
box-shadow: 0 0 0 2px rgba(44, 51, 38, 0.35);
}
.sm__rng::-moz-range-track {
height: 4px;
border-radius: 999px;
background: rgba(44, 51, 38, 0.22);
}
.sm__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
border: 0;
background: #cfe3c8;
box-shadow: 0 0 0 2px rgba(44, 51, 38, 0.35);
}
.sm__rng:focus-visible {
outline: 2px solid #2c3326;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 82px;
}
.sm__bar {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.sm__ctl {
justify-content: space-between;
}
.sm__rng {
flex: 0 0 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__rng {
pointer-events: auto;
}
}// 센서 각도 모프(07). Physarum 스티그머지 + 실시간 파라미터 모프 —
// 센서 벌림각(도)과 센서 거리 두 값을 실제 range 슬라이더로 바꾼다. 같은 3센서 규칙이라도
// 좁은 각도+짧은 거리는 촘촘한 산호, 넓은 각도+긴 거리는 성긴 혈관·격자형 도시망을 그린다.
// .is-demo 동안은 슬라이더가 사람 손 없이 스스로 움직여(자동 시연) 형태 변화를 보여주고,
// 사람이 실제로 슬라이더를 잡으면 그 즉시 같은 파라미터가 시뮬레이션에 반영된다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 필드 전체를 매 프레임 새로 계산해 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1500; // 동시에 걷는 에이전트 수
var DECAY = 0.045; // 증발률
// ────────────────────────────────────
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
// 파라미터(슬라이더가 실시간으로 바꾼다)
var sensorAngleDeg = 29; // 도(°)
var sensorDist = 15;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var angRng = document.getElementById('smAng');
var distRng = document.getElementById('smDist');
var angLab = document.getElementById('smAngLab');
var distLab = document.getElementById('smDistLab');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A07);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_FLOOR = tokRGB('--sm-floor');
var C_TRAIL = tokRGB('--sm-trail');
var C_ACCT = tokRGB('--sm-acct');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, img, data;
var ax, ay, ah;
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function sense(x, y) {
return trail[clampIdx(x, y)];
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.15;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
var sa = sensorAngleDeg * Math.PI / 180;
var sd = sensorDist / 3;
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * sd, y + Math.sin(h) * sd);
var sl = sense(x + Math.cos(h + sa) * sd, y + Math.sin(h + sa) * sd);
var sr = sense(x + Math.cos(h - sa) * sd, y + Math.sin(h - sa) * sd);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var v = trail[i] / CAP; if (v > 1) v = 1;
var k = v * v * (3 - 2 * v);
var rr = C_FLOOR[0] + (C_TRAIL[0] - C_FLOOR[0]) * k;
var gg = C_FLOOR[1] + (C_TRAIL[1] - C_FLOOR[1]) * k;
var bb = C_FLOOR[2] + (C_TRAIL[2] - C_FLOOR[2]) * k;
// 파라미터 표시기 — 슬라이더 값이 화면 구석에 옅게 비친다(파라미터 실험 컨셉)
var meterX = x / fw;
var inAng = y < 5 && meterX < sensorAngleDeg / 90;
var inDist = y >= 6 && y < 11 && meterX < (sensorDist - 4) / 26;
if (inAng || inDist) {
rr += (C_ACCT[0] - rr) * 0.5; gg += (C_ACCT[1] - gg) * 0.5; bb += (C_ACCT[2] - bb) * 0.5;
}
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 28 + (n2 - 0.5) * 18;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
initAgents();
}
function setLabels() {
if (angLab) angLab.textContent = '벌림각 ' + sensorAngleDeg + '°';
if (distLab) distLab.textContent = '거리 ' + sensorDist;
if (angRng) angRng.value = String(sensorAngleDeg);
if (distRng) distRng.value = String(sensorDist);
}
function stillFrame() {
sensorAngleDeg = 45; sensorDist = 20; setLabels();
for (var k = 0; k < 280; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
// .is-demo 자동 시연 각본 — 사람이 안 만져도 슬라이더가 스스로 움직여 형태가 모프된다.
// 각 구간은 (벌림각 목표, 거리 목표) 쌍. 사인으로 왕복해 끊김 없이 잇는다.
function autoParams(t) {
var ang = 29 + 28 * Math.sin(t * 0.42) + 8 * Math.sin(t * 0.13);
var dist = 15 + 9 * Math.sin(t * 0.31 + 1.2);
if (ang < 10) ang = 10; if (ang > 90) ang = 90;
if (dist < 4) dist = 4; if (dist > 30) dist = 30;
sensorAngleDeg = Math.round(ang);
sensorDist = Math.round(dist);
}
var humanTouch = false;
angRng.addEventListener('input', function () {
humanTouch = true;
sensorAngleDeg = parseInt(angRng.value, 10);
setLabels();
});
distRng.addEventListener('input', function () {
humanTouch = true;
sensorDist = parseInt(distRng.value, 10);
setLabels();
});
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
if (root.classList.contains('is-demo') && !humanTouch) {
autoParams(t);
setLabels();
}
for (var i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
render(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
setLabels();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// SensorAngleMorph — vanilla 판(run/320/demo/07-slime-sensor-angle-morph/index.html) 의 React 포트.
// 센서 각도 모프. 센서 벌림각(도)과 센서 거리 두 값을 range 슬라이더로 바꾼다 — 좁은 각도+짧은
// 거리는 촘촘한 산호, 넓은 각도+긴 거리는 성긴 혈관·격자형 도시망을 그린다. 사람이 슬라이더를
// 만지기 전까지는 자동 각본이 두 값을 사인파로 왕복시켜 스스로 형태가 모프된다.
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./SensorAngleMorph.module.scss";
export type SensorAngleMorphProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number;
decay?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "센서 각도 모프 점균 실험판";
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function SensorAngleMorph({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2c3326",
agents = 1500,
decay = 0.045,
}: SensorAngleMorphProps) {
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 colRef = useRef<{ floor: RGB; trail: RGB; acct: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const randRef = useRef(makePRNG(0x320a07)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const sensorAngleDegRef = useRef(29) as Box<number>;
const sensorDistRef = useRef(15) as Box<number>;
const humanTouchRef = useRef(false) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const [angLab, setAngLab] = useState("벌림각 29°");
const [distLab, setDistLab] = useState("거리 15");
const [angVal, setAngVal] = useState(29);
const [distVal, setDistVal] = useState(15);
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function sense(x: number, y: number): number {
return trailRef.current[clampIdx(x, y)];
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.15;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
const sa = sensorAngleDegRef.current * Math.PI / 180;
const sd = sensorDistRef.current / 3;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * sd, y + Math.sin(h) * sd);
const sl = sense(x + Math.cos(h + sa) * sd, y + Math.sin(h + sa) * sd);
const sr = sense(x + Math.cos(h - sa) * sd, y + Math.sin(h - sa) * sd);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const angDeg = sensorAngleDegRef.current;
const dist = sensorDistRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let v = trail[i] / CAP; if (v > 1) v = 1;
const k = v * v * (3 - 2 * v);
let rr = col.floor[0] + (col.trail[0] - col.floor[0]) * k;
let gg = col.floor[1] + (col.trail[1] - col.floor[1]) * k;
let bb = col.floor[2] + (col.trail[2] - col.floor[2]) * k;
const meterX = x / fw;
const inAng = y < 5 && meterX < angDeg / 90;
const inDist = y >= 6 && y < 11 && meterX < (dist - 4) / 26;
if (inAng || inDist) {
rr += (col.acct[0] - rr) * 0.5; gg += (col.acct[1] - gg) * 0.5; bb += (col.acct[2] - bb) * 0.5;
}
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 28 + (n2 - 0.5) * 18;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
initAgents();
}
function setLabels(): void {
setAngLab(`벌림각 ${sensorAngleDegRef.current}°`);
setDistLab(`거리 ${sensorDistRef.current}`);
setAngVal(sensorAngleDegRef.current);
setDistVal(sensorDistRef.current);
}
function stillFrame(): void {
sensorAngleDegRef.current = 45; sensorDistRef.current = 20; setLabels();
for (let k = 0; k < 280; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
function autoParams(t: number): void {
let ang = 29 + 28 * Math.sin(t * 0.42) + 8 * Math.sin(t * 0.13);
let dist = 15 + 9 * Math.sin(t * 0.31 + 1.2);
if (ang < 10) ang = 10; if (ang > 90) ang = 90;
if (dist < 4) dist = 4; if (dist > 30) dist = 30;
sensorAngleDegRef.current = Math.round(ang);
sensorDistRef.current = Math.round(dist);
}
function onAngInput(e: ChangeEvent<HTMLInputElement>): void {
humanTouchRef.current = true;
sensorAngleDegRef.current = parseInt(e.target.value, 10);
setLabels();
}
function onDistInput(e: ChangeEvent<HTMLInputElement>): void {
humanTouchRef.current = true;
sensorDistRef.current = parseInt(e.target.value, 10);
setLabels();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
if (!humanTouchRef.current) {
autoParams(t);
setLabels();
}
for (let i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
render(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
randRef.current = makePRNG(0x320a07);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
trail: tokRGB(root, "--sm-trail"),
acct: tokRGB(root, "--sm-acct"),
};
buildPerm();
fit();
setLabels();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>센서 벌림각과 거리 두 숫자만 슬라이더로 바꾸면 같은 규칙이 촘촘한 산호 모양, 성긴 혈관 모양, 격자형 도시 모양까지 완전히 다른 네트워크를 그려냅니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<label className={styles.ctl}>
<span className={styles.lab}>{angLab}</span>
<input
className={styles.rng}
type="range"
min={10}
max={90}
step={1}
value={angVal}
aria-label="센서 벌림각"
onChange={onAngInput}
/>
</label>
<label className={styles.ctl}>
<span className={styles.lab}>{distLab}</span>
<input
className={styles.rng}
type="range"
min={4}
max={30}
step={1}
value={distVal}
aria-label="센서 거리"
onChange={onDistInput}
/>
</label>
</div>
</div>
</main>
);
}07 · 센서 각도 모프 — 각도 하나로 산호에서 혈관까지 형태가 바뀌기 — 측정 누적면적 49.385% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
센서 벌림각과 거리 두 숫자만 슬라이더로 바꾸면 같은 규칙이 촘촘한 산호 모양·성긴 혈관 모양·격자형 도시 모양까지 완전히 다른 네트워크를 그려내니, 규칙이 아니라 규칙의 인자가 형태를 정한다는 걸 보여주는 실험판입니다.
슬라이더 두 개가 곧장 규칙의 인자
angRng.addEventListener('input', function () {
humanTouch = true;
sensorAngleDeg = parseInt(angRng.value, 10);
setLabels();
});
distRng.addEventListener('input', function () {
humanTouch = true;
sensorDist = parseInt(distRng.value, 10);
setLabels();
});
벌림각 슬라이더는 10~90도, 거리 슬라이더는 4~30을 담당하며 값이 바뀌면 다음 걸음부터 각도·거리 계산에 즉시 반영되고, 데모 자동 진행 중이던 판도 손이 닿는 순간 humanTouch가 켜져 사용자 조작을 우선합니다. 각도를 좁히면 좌우 감각이 비슷해져 촘촘한 덤불이, 넓히면 한 걸음 차이가 커져 성긴 혈관이 나옵니다.
08유기체 텍스처 패널 — 고정 프레임을 캡처해 카드 배경으로 깔기

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>네트워크가 다 자란 순간의 캔버스를 정지 이미지로 굳혀 옅은 투명도로 깔면, 움직이지 않아도 손으로 그린 듯한 유기적 질감이 카드·섹션 배경이 됩니다</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="유기체 텍스처 패널 점균 실험판" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
<div class="sm__veil" id="smVeil" aria-hidden="true">
<div class="sm__card">
<p class="sm__card-t">Organic Section</p>
<p class="sm__card-s">hand-drawn texture · slime network</p>
</div>
</div>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> 캡처 · 정지 질감</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유기체 텍스처 패널(08). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(3센서 감지 → 회전 → 트레일 → 확산·증발 → 캡처)가 만든다.
// 무대는 크림 종이 — 여덟 번째 순환(01밤·02종이·03밀짚·04노을·05먹·06남색·07이끼).
// 질감이 UI 배경이 되는 컨셉이라 카드 목업(.sm__veil .sm__card)이 실제로 얹힌다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.85 이상 — BD26 안전) ──
$sm-stage: #faf3e8; // 크림 아마 종이 무대
@include stage($sm-stage);
$sm-floor: #faf3e8; // 필드 바닥 — 무대와 같은 톤
$sm-trail: #e8d9bd; // 굳은 질감 — 파스텔 아마
$sm-acct: #f2d3c4; // 성장 중 비침 — 파스텔 살구
$sm-card: #fffcf5; // 카드 목업 면 — 종이보다 반톤 밝게
$sm-text: #453524; // 종이 위 — 짙은 갈색 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-trail: #{$sm-trail};
--sm-acct: #{$sm-acct};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__veil {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
padding: $sp-3; pointer-events: none;
}
.sm__card {
max-width: 260px; width: 100%;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: rgba($sm-card, .82);
box-shadow: 0 6px 12px rgba(69, 53, 36, .18), inset 0 0 0 1px rgba(69, 53, 36, .1);
text-align: center;
}
.sm__card-t {
margin: 0;
font-weight: 700; font-size: clamp(12px, 3.4vw, 15px); line-height: 1.2;
color: $sm-text;
}
.sm__card-s {
margin: 0; margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.3;
color: rgba($sm-text, .72);
}
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__meta { font-size: 10px; font-weight: 700; color: rgba($sm-text, .72); }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__veil { padding: $sp-2; }
.sm__card { padding: $sp-2 $sp-3; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 캡처 한 장이 카드 배경으로 깔린 모습만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__veil { pointer-events: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #faf3e8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #faf3e8;
--sm-trail: #e8d9bd;
--sm-acct: #f2d3c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #453524;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #faf3e8;
box-shadow: inset 0 0 0 1px rgba(69, 53, 36, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #453524;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__veil {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
pointer-events: none;
}
.sm__card {
max-width: 260px;
width: 100%;
padding: 12px 16px;
border-radius: 16px;
background: rgba(255, 252, 245, 0.82);
box-shadow: 0 6px 12px rgba(69, 53, 36, 0.18), inset 0 0 0 1px rgba(69, 53, 36, 0.1);
text-align: center;
}
.sm__card-t {
margin: 0;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1.2;
color: #453524;
}
.sm__card-s {
margin: 0;
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3;
color: rgba(69, 53, 36, 0.72);
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__meta {
font-size: 10px;
font-weight: 700;
color: rgba(69, 53, 36, 0.72);
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(69, 53, 36, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__veil {
padding: 8px;
}
.sm__card {
padding: 8px 12px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__veil {
pointer-events: none;
}
}// 유기체 텍스처 패널(08). Physarum 스티그머지 + 성장 캡처 → 정지 질감 재사용 —
// 시뮬레이션이 GROW_STEPS 스텝 동안 네트워크를 키운다. 다 자란 순간 필드를 snapshot 배열에
// 통째로 복사해 "정지 이미지"로 굳히고, 이후 렌더는 그 스냅샷을 카드 배경처럼 옅게 깐다.
// 위에 얹은 카드 DOM(.sm__veil .sm__card)이 "이 질감이 실제 UI 배경으로 쓰이는 모습"을 보여준다.
// .is-demo 동안은 캡처→재성장을 주기적으로 반복해 질감이 계속 새로 만들어지듯 보이게 한다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 스냅샷 위에 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 흔들어 누적면적을 채운다.
// · 캡처 경계마다 질감이 스르르 크로스페이드되며 화면이 죽지 않는다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1300; // 동시에 걷는 에이전트 수
var DECAY = 0.042; // 증발률
var GROW_STEPS = 130; // 질감을 굳히기 전 성장 스텝 수
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
var FADE = 0.7; // 캡처 교체 크로스페이드 길이(초)
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A08);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_FLOOR = tokRGB('--sm-floor');
var C_TRAIL = tokRGB('--sm-trail');
var C_ACCT = tokRGB('--sm-acct');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, img, data;
var snapOld, snapNew; // 직전 질감 · 방금 잡은 질감
var ax, ay, ah;
var stepCount = 0;
var captureCount = 0;
var fadeT = -1; // 크로스페이드 시작 시각(-1 = 페이드 아님)
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade2(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade2(xf), v = fade2(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function sense(x, y) {
return trail[clampIdx(x, y)];
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.15;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function capture() {
// 지금 필드를 통째로 굳혀 새 질감으로 삼고, 필드는 비워 다음 성장을 시작한다.
var tmp = snapOld;
snapOld = snapNew;
snapNew = tmp;
if (!snapNew) snapNew = new Float32Array(fw * fh);
snapNew.set(trail);
captureCount++;
if (nOut) nOut.textContent = String(captureCount);
for (var i = 0; i < trail.length; i++) trail[i] = 0;
stepCount = 0;
initAgents();
}
function render(t) {
var mix = 1;
if (fadeT >= 0) {
mix = (t - fadeT) / FADE;
if (mix > 1) mix = 1;
mix = mix * mix * (3 - 2 * mix);
}
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
// 정지 질감 두 장의 크로스페이드 — 카드 배경은 "캡처된" 필드에서 온다
var vOld = snapOld && snapOld.length === trail.length ? snapOld[i] / CAP : 0;
var vNew = snapNew ? snapNew[i] / CAP : 0;
if (vOld > 1) vOld = 1; if (vNew > 1) vNew = 1;
var v = vOld + (vNew - vOld) * mix;
var k = v * v * (3 - 2 * v);
var rr = C_FLOOR[0] + (C_TRAIL[0] - C_FLOOR[0]) * k * 0.5;
var gg = C_FLOOR[1] + (C_TRAIL[1] - C_FLOOR[1]) * k * 0.5;
var bb = C_FLOOR[2] + (C_TRAIL[2] - C_FLOOR[2]) * k * 0.5;
// 성장 중인 필드는 뚜렷하게 함께 비친다(다음 질감이 자라나는 과정)
var vGrow = trail[i] / CAP; if (vGrow > 1) vGrow = 1;
var kGrow = vGrow * vGrow * (3 - 2 * vGrow);
rr += (C_ACCT[0] - rr) * kGrow * 0.5;
gg += (C_ACCT[1] - gg) * kGrow * 0.5;
bb += (C_ACCT[2] - bb) * kGrow * 0.5;
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
snapOld = null; snapNew = null;
stepCount = 0; captureCount = 0; fadeT = -1;
initAgents();
}
function stillFrame() {
for (var k = 0; k < GROW_STEPS; k++) { stepAgents(); diffuseEvap(); }
capture();
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
if (stepCount >= GROW_STEPS) { capture(); fadeT = t; }
if (fadeT >= 0 && t - fadeT > FADE) fadeT = -1;
render(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// VeinsTexturePanel — vanilla 판(run/320/demo/08-slime-veins-texture-panel/index.html) 의 React 포트.
// 유기체 텍스처 패널. 시뮬레이션이 GROW_STEPS 스텝 동안 네트워크를 키운 뒤 필드 전체를 snapshot
// 으로 굳혀 "정지 질감"으로 재사용한다 — 위에 얹은 카드 DOM 이 실제 UI 배경으로 쓰이는 모습을
// 보여준다. 캡처 경계마다 새·헌 질감이 크로스페이드된다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./VeinsTexturePanel.module.scss";
export type VeinsTexturePanelProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number;
decay?: number;
growSteps?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "유기체 텍스처 패널 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
const FADE = 0.7;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function VeinsTexturePanel({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#453524",
agents = 1300,
decay = 0.042,
growSteps = 130,
}: VeinsTexturePanelProps) {
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 colRef = useRef<{ floor: RGB; trail: RGB; acct: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const growStepsRef = useRef(growSteps) as Box<number>;
const randRef = useRef(makePRNG(0x320a08)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const snapOldRef = useRef<Float32Array | null>(null) as Box<Float32Array | null>;
const snapNewRef = useRef<Float32Array | null>(null) as Box<Float32Array | null>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const captureCountRef = useRef(0) as Box<number>;
const fadeTRef = useRef(-1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade2(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade2(xf), v = fade2(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function sense(x: number, y: number): number {
return trailRef.current[clampIdx(x, y)];
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.15;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCountRef.current++;
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function capture(): void {
const fw = fwRef.current, fh = fhRef.current;
const tmp = snapOldRef.current;
snapOldRef.current = snapNewRef.current;
let snapNew = tmp;
if (!snapNew) snapNew = new Float32Array(fw * fh);
snapNew.set(trailRef.current);
snapNewRef.current = snapNew;
captureCountRef.current++;
setN(String(captureCountRef.current));
const trail = trailRef.current;
for (let i = 0; i < trail.length; i++) trail[i] = 0;
stepCountRef.current = 0;
initAgents();
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const snapOld = snapOldRef.current;
const snapNew = snapNewRef.current;
const fadeT = fadeTRef.current;
let mix = 1;
if (fadeT >= 0) {
mix = (t - fadeT) / FADE;
if (mix > 1) mix = 1;
mix = mix * mix * (3 - 2 * mix);
}
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let vOld = snapOld && snapOld.length === trail.length ? snapOld[i] / CAP : 0;
let vNew = snapNew ? snapNew[i] / CAP : 0;
if (vOld > 1) vOld = 1; if (vNew > 1) vNew = 1;
const v = vOld + (vNew - vOld) * mix;
const k = v * v * (3 - 2 * v);
let rr = col.floor[0] + (col.trail[0] - col.floor[0]) * k * 0.5;
let gg = col.floor[1] + (col.trail[1] - col.floor[1]) * k * 0.5;
let bb = col.floor[2] + (col.trail[2] - col.floor[2]) * k * 0.5;
let vGrow = trail[i] / CAP; if (vGrow > 1) vGrow = 1;
const kGrow = vGrow * vGrow * (3 - 2 * vGrow);
rr += (col.acct[0] - rr) * kGrow * 0.5;
gg += (col.acct[1] - gg) * kGrow * 0.5;
bb += (col.acct[2] - bb) * kGrow * 0.5;
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
snapOldRef.current = null; snapNewRef.current = null;
stepCountRef.current = 0; captureCountRef.current = 0; fadeTRef.current = -1;
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < growStepsRef.current; k++) { stepAgents(); diffuseEvap(); }
capture();
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
if (stepCountRef.current >= growStepsRef.current) { capture(); fadeTRef.current = t; }
if (fadeTRef.current >= 0 && t - fadeTRef.current > FADE) fadeTRef.current = -1;
render(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
growStepsRef.current = growSteps;
randRef.current = makePRNG(0x320a08);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
trail: tokRGB(root, "--sm-trail"),
acct: tokRGB(root, "--sm-acct"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, growSteps]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>네트워크가 다 자란 순간의 캔버스를 정지 이미지로 굳혀 옅은 투명도로 깔면, 움직이지 않아도 손으로 그린 듯한 유기적 질감이 카드·섹션 배경이 됩니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<div className={styles.veil} aria-hidden="true">
<div className={styles.card}>
<p className={styles.cardT}>Organic Section</p>
<p className={styles.cardS}>hand-drawn texture · slime network</p>
</div>
</div>
</div>
<div className={styles.bar}>
<span className={styles.meta}>
<b className={styles.val}>{n}</b>
<span> 캡처 · 정지 질감</span>
</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 · 유기체 텍스처 패널 — 고정 프레임을 캡처해 카드 배경으로 깔기 — 측정 누적면적 34.819% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
네트워크가 다 자란 순간의 캔버스를 정지 이미지로 굳혀 옅은 투명도로 깔면 움직이지 않아도 손으로 그린 듯한 유기적 질감이 카드·섹션 배경이 되니, 질감이 필요한 자리에 애니메이션의 계산을 재활용하는 활용판입니다.
필드를 통째로 굳히고 비워 다시 기르기
function capture() {
// 지금 필드를 통째로 굳혀 새 질감으로 삼고, 필드는 비워 다음 성장을 시작한다.
var tmp = snapOld;
snapOld = snapNew;
snapNew = tmp;
if (!snapNew) snapNew = new Float32Array(fw * fh);
snapNew.set(trail);
captureCount++;
if (nOut) nOut.textContent = String(captureCount);
for (var i = 0; i < trail.length; i++) trail[i] = 0;
stepCount = 0;
initAgents();
}
캡처 시 현재 트레일 필드를 snapNew에 통째로 복사해 정지 질감으로 보관하고 실제 필드는 0으로 비워 에이전트를 처음부터 다시 태우며, 화면은 두 스냅샷을 크로스페이드하며 카드 배경용 질감을 갱신하니 한 번 자란 형태가 배경으로 남고 다음 성장이 그 위에서 다시 시작됩니다. 움직임 없는 정적 페이지에도 점균 질감을 얹을 수 있는 이유입니다.
09매크로 발광 씬 — 어두운 배경 위 다큐멘터리 조명의 점균 장면

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>검은 배경에 중앙 조명(radial gradient)을 깔고 네트워크를 크게 키우면 다큐멘터리 접사 촬영 같은 한 장면이 됩니다 — 트레일 끝의 여린 촉수가 계속 꿈틀대며 화면이 살아 있습니다</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="매크로 발광 점균 실험판" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> 스텝 · 매크로 조명</span></span>
<span class="sm__meta sm__meta--dim">canvas · createRadialGradient · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 매크로 발광 씬(09). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(createRadialGradient 조명 베이스 + 3센서 감지 → 회전 →
// 트레일 → 확산·증발 → 촉수 발광)가 만든다. 무대는 검은 배경(퀴 요구사항) — 아홉 번째 순환.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 팔레트(검은 무대 L0.06 미만 — BD26 예외 + 발광 파스텔 L0.85 이상) ──
$sm-stage: #0b0a10; // 다큐멘터리 접사용 완전한 검은 무대
@include stage($sm-stage);
$sm-dark: #0b0a10; // 조명 바깥 — 무대와 같은 톤
$sm-light: #3a3448; // 조명 중심 — 은은한 남보라 스포트라이트(중간톤, 조명이라 예외적으로 유지)
$sm-glow: #d8f2ea; // 트레일 발광 — 파스텔 민트 화이트
$sm-tip: #ffe6d8; // 촉수 끝 — 파스텔 피치 글로우
$sm-text: #f3ecdd; // 검은 무대 위 — 크림 글자(대비)
.sm {
--sm-dark: #{$sm-dark};
--sm-light: #{$sm-light};
--sm-glow: #{$sm-glow};
--sm-tip: #{$sm-tip};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-dark;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__val { color: $sm-glow; }
.sm__meta { font-size: 10px; font-weight: 700; color: rgba($sm-text, .72); }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 조명 아래 네트워크가 자란 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #0b0a10;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-dark: #0b0a10;
--sm-light: #3a3448;
--sm-glow: #d8f2ea;
--sm-tip: #ffe6d8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #f3ecdd;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a10;
box-shadow: inset 0 0 0 1px rgba(243, 236, 221, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #f3ecdd;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__val {
color: #d8f2ea;
}
.sm__meta {
font-size: 10px;
font-weight: 700;
color: rgba(243, 236, 221, 0.72);
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(243, 236, 221, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still {
opacity: 1;
}
}// 매크로 발광 씬(09). Physarum 스티그머지 + createRadialGradient 조명 베이스 —
// 검은 무대 위에 createRadialGradient 로 만든 중앙 조명 버퍼를 한 번 그려 base 캔버스에
// 굳히고, 매 프레임 base 를 drawImage 로 깔은 뒤 그 위에 트레일·에이전트 발광을 가산 합성한다.
// 트레일 끝(에이전트가 방금 지나간 자리)은 촉수처럼 밝게 꿈틀댄다 — 다큐멘터리 접사 톤.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 조명 베이스 위에 트레일·글로우를 매 프레임 새로 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1300; // 동시에 걷는 에이전트 수
var DECAY = 0.04; // 증발률
var LIGHT_RADIUS = 0.62; // 중앙 조명 반경(필드 짧은 변 대비 비율)
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A09);
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_DARK = tokRGB('--sm-dark');
var C_LIGHT = tokRGB('--sm-light');
var C_GLOW = tokRGB('--sm-glow');
var C_TIP = tokRGB('--sm-tip');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, lightBase, img, data;
var ax, ay, ah, lastVisit;
var stepCount = 0;
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
// createRadialGradient 로 만든 중앙 조명 베이스 — 한 번만 만들어 lightBase 에 픽셀로 굳힌다.
function buildLightBase() {
var off = document.createElement('canvas');
off.width = fw; off.height = fh;
var octx = off.getContext('2d');
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * LIGHT_RADIUS;
var g = octx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, 'rgb(' + C_LIGHT[0] + ',' + C_LIGHT[1] + ',' + C_LIGHT[2] + ')');
g.addColorStop(0.55, 'rgb(' + Math.round((C_LIGHT[0] + C_DARK[0]) / 2) + ',' + Math.round((C_LIGHT[1] + C_DARK[1]) / 2) + ',' + Math.round((C_LIGHT[2] + C_DARK[2]) / 2) + ')');
g.addColorStop(1, 'rgb(' + C_DARK[0] + ',' + C_DARK[1] + ',' + C_DARK[2] + ')');
octx.fillStyle = g;
octx.fillRect(0, 0, fw, fh);
var d = octx.getImageData(0, 0, fw, fh).data;
lightBase = new Float32Array(fw * fh * 3);
for (var i = 0, p = 0; i < d.length; i += 4, p += 3) {
lightBase[p] = d[i]; lightBase[p + 1] = d[i + 1]; lightBase[p + 2] = d[i + 2];
}
}
function sense(x, y) {
return trail[clampIdx(x, y)];
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.16;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
lastVisit[ii] = stepCount;
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var p = i * 3;
var rr = lightBase[p], gg = lightBase[p + 1], bb = lightBase[p + 2];
var v = trail[i] / CAP; if (v > 1) v = 1;
var k = v * v * (3 - 2 * v);
rr += (C_GLOW[0] - rr) * k; gg += (C_GLOW[1] - gg) * k; bb += (C_GLOW[2] - bb) * k;
// 촉수 끝 — 방금 밟힌(나이 짧은) 셀은 가산 발광으로 더 밝게 꿈틀댄다
var lv = lastVisit[i];
var age = lv < 0 ? 999 : stepCount - lv;
if (age < 10) {
var tipK = (1 - age / 10) * (0.55 + 0.45 * Math.sin(t * 5 + x * 0.3 + y * 0.2));
rr += (C_TIP[0] - rr) * tipK * 0.7;
gg += (C_TIP[1] - gg) * tipK * 0.7;
bb += (C_TIP[2] - bb) * tipK * 0.7;
}
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 20 + (n2 - 0.5) * 14;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
lastVisit = new Float32Array(fw * fh);
for (var i = 0; i < lastVisit.length; i++) lastVisit[i] = -9999;
stepCount = 0;
buildLightBase();
initAgents();
}
function stillFrame() {
for (var k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// MacroBioluminescentScene — vanilla 판(run/320/demo/09-slime-macro-bioluminescent-scene/index.html) 의
// React 포트. createRadialGradient 로 만든 중앙 조명 버퍼를 한 번 그려 lightBase 에 픽셀로 굳히고,
// 매 프레임 그 위에 트레일·촉수 발광을 가산 합성한다 — 다큐멘터리 접사 촬영 톤.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MacroBioluminescentScene.module.scss";
export type MacroBioluminescentSceneProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number;
decay?: number;
lightRadius?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "매크로 발광 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function MacroBioluminescentScene({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f3ecdd",
agents = 1300,
decay = 0.04,
lightRadius = 0.62,
}: MacroBioluminescentSceneProps) {
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 colRef = useRef<{ dark: RGB; light: RGB; glow: RGB; tip: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const lightRadiusRef = useRef(lightRadius) as Box<number>;
const randRef = useRef(makePRNG(0x320a09)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const lightBaseRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const lastVisitRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
// createRadialGradient 로 만든 중앙 조명 베이스 — 한 번만 만들어 lightBase 에 픽셀로 굳힌다.
function buildLightBase(): void {
const fw = fwRef.current, fh = fhRef.current;
const col = colRef.current;
if (!col) return;
const off = document.createElement("canvas");
off.width = fw; off.height = fh;
const octx = off.getContext("2d");
if (!octx) return;
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * lightRadiusRef.current;
const g = octx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, `rgb(${col.light[0]},${col.light[1]},${col.light[2]})`);
g.addColorStop(0.55, `rgb(${Math.round((col.light[0] + col.dark[0]) / 2)},${Math.round((col.light[1] + col.dark[1]) / 2)},${Math.round((col.light[2] + col.dark[2]) / 2)})`);
g.addColorStop(1, `rgb(${col.dark[0]},${col.dark[1]},${col.dark[2]})`);
octx.fillStyle = g;
octx.fillRect(0, 0, fw, fh);
const d = octx.getImageData(0, 0, fw, fh).data;
const lightBase = new Float32Array(fw * fh * 3);
for (let i = 0, p = 0; i < d.length; i += 4, p += 3) {
lightBase[p] = d[i]; lightBase[p + 1] = d[i + 1]; lightBase[p + 2] = d[i + 2];
}
lightBaseRef.current = lightBase;
}
function sense(x: number, y: number): number {
return trailRef.current[clampIdx(x, y)];
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.16;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
const lastVisit = lastVisitRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
lastVisit[ii] = stepCountRef.current;
}
stepCountRef.current++;
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const lastVisit = lastVisitRef.current;
const lightBase = lightBaseRef.current;
const stepCount = stepCountRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
const p = i * 3;
let rr = lightBase[p], gg = lightBase[p + 1], bb = lightBase[p + 2];
let v = trail[i] / CAP; if (v > 1) v = 1;
const k = v * v * (3 - 2 * v);
rr += (col.glow[0] - rr) * k; gg += (col.glow[1] - gg) * k; bb += (col.glow[2] - bb) * k;
const lv = lastVisit[i];
const age = lv < 0 ? 999 : stepCount - lv;
if (age < 10) {
const tipK = (1 - age / 10) * (0.55 + 0.45 * Math.sin(t * 5 + x * 0.3 + y * 0.2));
rr += (col.tip[0] - rr) * tipK * 0.7;
gg += (col.tip[1] - gg) * tipK * 0.7;
bb += (col.tip[2] - bb) * tipK * 0.7;
}
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 20 + (n2 - 0.5) * 14;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
const lastVisit = new Float32Array(fw * fh);
for (let i = 0; i < lastVisit.length; i++) lastVisit[i] = -9999;
lastVisitRef.current = lastVisit;
stepCountRef.current = 0;
buildLightBase();
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
lightRadiusRef.current = lightRadius;
randRef.current = makePRNG(0x320a09);
colRef.current = {
dark: tokRGB(root, "--sm-dark"),
light: tokRGB(root, "--sm-light"),
glow: tokRGB(root, "--sm-glow"),
tip: tokRGB(root, "--sm-tip"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, lightRadius]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>검은 배경에 중앙 조명(radial gradient)을 깔고 네트워크를 크게 키우면 다큐멘터리 접사 촬영 같은 한 장면이 됩니다 — 트레일 끝의 여린 촉수가 계속 꿈틀대며 화면이 살아 있습니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>
<b className={styles.val}>{n}</b>
<span> 스텝 · 매크로 조명</span>
</span>
<span className={styles.metaDim}>canvas · createRadialGradient · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 · 매크로 발광 씬 — 어두운 배경 위 다큐멘터리 조명의 점균 장면 — 측정 누적면적 49.253% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
검은 배경에 중앙 조명을 깔고 네트워크를 크게 키우면 다큐멘터리 접사 촬영 같은 한 장면이 되어 트레일 끝의 여린 촉수가 계속 꿈틀대며 화면이 살아 있고, 누적면적 49.253%·최대 면적 28.611%로 어두운 가장자리까지 골고루 뻗는 대형 판입니다.
조명 버퍼는 한 번만 만들어 픽셀로 굳힌다
function buildLightBase() {
var off = document.createElement('canvas');
off.width = fw; off.height = fh;
var octx = off.getContext('2d');
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * LIGHT_RADIUS;
var g = octx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, 'rgb(' + C_LIGHT[0] + ',' + C_LIGHT[1] + ',' + C_LIGHT[2] + ')');
g.addColorStop(0.55, 'rgb(' + Math.round((C_LIGHT[0] + C_DARK[0]) / 2) + ',' + Math.round((C_LIGHT[1] + C_DARK[1]) / 2) + ',' + Math.round((C_LIGHT[2] + C_DARK[2]) / 2) + ')');
g.addColorStop(1, 'rgb(' + C_DARK[0] + ',' + C_DARK[1] + ',' + C_DARK[2] + ')');
octx.fillStyle = g;
octx.fillRect(0, 0, fw, fh);
var d = octx.getImageData(0, 0, fw, fh).data;
lightBase = new Float32Array(fw * fh * 3);
for (var i = 0, p = 0; i < d.length; i += 4, p += 3) {
lightBase[p] = d[i]; lightBase[p + 1] = d[i + 1]; lightBase[p + 2] = d[i + 2];
}
}
중앙 조명은 방사형 그라디언트를 오프스크린 캔버스에 한 번 그려 getImageData로 픽셀 배열로 굳히고, 이후 매 프레임 렌더는 이 조명 색을 바탕으로 트레일 밝기만큼 발광색을 섞어 찍으므로 그라디언트를 다시 만들지 않습니다. 시뮬레이션 규칙은 01과 같고 무대 조명만 얹었는데, 트레일이 조명 밖에서 어둡게 죽는 효과만으로 접사 한 장면의 인상이 만들어집니다.
철도망 인포그래픽과 인터랙티브 히어로 — 이 아홉 개를 어디에 쓰나
교통·물류 데이터를 시각적으로 설명하고 싶다면 03 다중 먹이 네트워크가 가장 가깝습니다. 점균이 스스로 최적 연결선을 찾아가는 과정이 철도망 최적화 알고리즘을 몰라도 눈으로 납득되는 그림이 되어 줍니다. 방문자가 직접 클릭해 보길 바라는 인터랙티브 히어로 영역에는 04 포인터 먹이·기피물을 앉히면, 클릭한 자리로 네트워크가 자라거나 피하는 반응이 그대로 조작감이 됩니다.
다큐멘터리풍 인트로나 과학 콘텐츠의 표지 화면이라면 09 매크로 발광 씬이 맞습니다. 어두운 배경 위 조명 아래 한 마리 점균이 자라는 장면이 이미 완성돼 있어 타이틀만 얹으면 됩니다. 세 판 모두 파일 하나로 끝나 있어 별도 라이브러리 없이 정적 섹션에 바로 붙습니다.
점균은 왜 최단경로로 수렴하나? — 트레일 강화와 증발의 셈
확산과 증발은 격자 한 줄 계산
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
var t = trail; trail = trailB; trailB = t;
}
좌우상하 네 이웃의 평균과 자기 값을 DIFFUSE 비율로 섞은 뒤 (1 - DECAY)를 곱해 조금씩 증발시키고, 두 버퍼를 맞바꿉니다. 편미분 방정식을 세우지 않고 격자 위에서 네 이웃 평균으로 확산을 흉내 내는 것이 전부라 수식 없이도 두 배경 지식으로 설명이 끝납니다.
증발 속도가 점균류 네트워크의 굵기를 가른다
퇴적은 매 걸음 DEPOSIT만큼 더하고 증발은 매 걸음 DECAY만큼 곱합니다. 어떤 길이 살아남는지는 이 둘의 균형으로 정해지는데, 자주 밟히는 길은 퇴적이 증발을 앞질러 최댓값 CAP까지 굵어지고 드물게 지나는 길은 증발만 남아 희미해집니다. 혈관·지선 구분은 중앙 계획이 아니라 이 균형의 결과입니다.
최단경로만 남는 이유는 보강의 속도 차
긴 우회 경로와 짧은 경로가 같은 목적지를 잇고 있다고 합시다. 같은 수의 에이전트가 두 길을 쓴다면 짧은 쪽은 한 바퀴 도는 시간이 짧아 단위 시간당 퇴적이 더 쌓입니다. 퇴적이 짙은 길은 센서가 더 강하게 끌어당겨 에이전트를 더 모으고, 모일수록 퇴적은 더 짙어지는 양의 되먹임이 걸립니다. 반대로 긴 길은 보강이 늦어 증발과의 균형을 잃고 서서히 말라, 두 지점 사이에 굵은 통로 하나만 남습니다.
개미의 페로몬과 같은 셈, 다른 출발
이 구조는 개미가 페로몬을 남겨 먹이 길을 정하는 스티그머지와 정확히 같은 계산입니다. 차이는 개미가 먹이에서 둥지로 돌아오며 페로몬을 놓는 반면 점균은 먹이를 찾아 뻗으며 트레일을 놓는다는 점과, 점균의 몸 자체가 트레일이라는 점입니다. 이를 점균류 알고리즘으로 옮긴 연구는 최적화 기법의 하나로 자리 잡아 노선망 설계 같은 문제에 응용되었습니다. 군집의 방향을 정하는 스티어링 규칙이 궁금하시면 규칙 세 개로 무리를 이루는 보이드 시뮬레이션을, 격자 위에서 두 물질이 서로를 바꾸는 무늬는 확산 속도 차로 줄무늬를 만드는 튜링 반응 판을 이어 보시길 권합니다.
센서 벌림각과 트레일 감쇠율을 바꾸는 세 변형과 접근성
값 하나를 바꾼 세 변형
첫째, 07의 센서 벌림각을 22.5도에서 45도로 넓히면 네트워크가 더 성기고 넓게 퍼집니다. 둘째, 01의 트레일 감쇠율을 0.02에서 0.06으로 올리면 오래된 가지가 빨리 사라지고 최적 경로만 굵게 남아 길찾기 인상이 강해집니다. 셋째, 03의 에이전트를 4천 개에서 1만 2천 개로 늘리면 네트워크가 더 촘촘하고 빠르게 수렴합니다. 세 변형 모두 규칙은 건드리지 않고 숫자 하나만 바꾼다는 점이 이 시뮬레이션의 성격을 잘 보여줍니다.
움직임을 줄인 사용자에게 남기는 것
아홉 판 모두 matchMedia('(prefers-reduced-motion: reduce)')로 이 설정을 감지해, 켜져 있으면 프레임을 계속 흘리는 대신 시뮬레이션을 미리 여러 스텝 돌려 자란 순간 하나를 정지 프레임으로 그리고 멈춥니다. 08만은 이 정지 과정에 자기 캡처 기능까지 얹어 스냅샷을 질감으로 남기지만, 나머지 여덟 판도 각자 같은 방식으로 움직임을 끕니다. 움직임은 없어도 점균의 형태와 질감은 그대로 전달됩니다.
포인터로 만지는 한 판과 나머지 여덟 판
04만 포인터 입력을 받고 나머지 여덟 판은 손을 대지 않아도 완결됩니다. 터치 기기에서는 손가락으로 누른 자리가 그대로 닻으로 심기므로 실수 터치가 곧 장면 변화라는 점만 알아두시면 됩니다. 자동 진행 판들은 시드가 고정되어 있어 결과를 다시 재현할 수 있습니다.
증발을 끄면 최단경로 대신 만화경이 되는 함정
증발을 끄면 최단경로가 아니라 만화경이 된다
DECAY를 0에 가깝게 내리면 모든 트레일이 남아 화면이 만화경처럼 고르게 채워집니다. 길을 가리는 정보는 살아 있는 트레일이 아니라 죽어 가는 트레일과의 대비라, 증발이 없으면 선택이 없습니다. 반대로 너무 올리면 굵은 간선마저 말라 판이 텅 비니 01의 기본값 근처에서 조금씩만 움직여 보시길 권합니다.
에이전트 수를 올리면 렌더보다 샘플링이 먼저 무거워진다
개체를 몇 배로 늘릴 때 프레임이 끊기는 원인은 대개 그리기가 아니라 개체마다 세 지점씩 일어나는 필드 읽기입니다. 렌더는 화면 크기에만 비례하지만 샘플링은 개체 수에 비례하므로, 촘촘함을 원하면 개체를 늘리기 전에 STEP을 키워 같은 밀도를 더 적은 개체로 얻는 편이 셈이 셉니다.
퇴적을 포화값 근처로 올리면 첫 걸음에 판이 꽉 찬다
DEPOSIT을 CAP에 가깝게 잡으면 개체 몇 걸음 만에 필드가 포화되어 어디가 짙은지 구분이 사라집니다. 센서는 농도 차를 읽는 장치이므로 모든 칸이 최대치면 좌우가 항상 같아 회전이 동전 던지기가 됩니다. 퇴적은 최대치의 몇 분의 일 수준으로 시작해 눈으로 확인하며 올리는 것이 안전합니다.
이 아홉 판의 전체 소스와 각 항목의 html·css 는 zip 으로 묶여 있고 비번은 hv25u6ur 입니다. 풀면 각 판의 index.html 이 그대로 들어 있어 위 코드와 줄 단위로 대조해 보실 수 있습니다.
점균 네트워크를 페이지에 붙이기 전 자주 나오는 질문
점균류 지능은 정말로 생각하는 건가요?
뇌가 없는 생물이 길을 찾는다고 해서 의식이 있다는 뜻은 아닙니다. 관찰되는 것은 트레일 강화와 증발의 물리적 균형이 만드는 최적화이고, 이 글의 데모도 그 균형만으로 같은 행동을 재현합니다. 학습이나 기억처럼 보이는 현상도 트레일 필드에 새겨진 농도 기록으로 설명되는 경우가 대부분입니다.
이그노벨상 미로 실험은 무엇을 증명했나요?
2008년 인지과학 이그노벨상은 미로 양 끝에 먹이를 둔 점균이 두 지점을 잇는 최단경로만 남기는 것을 보인 연구에 주어졌습니다. 단순한 성장 규칙이 경로 최적화를 해낸다는 점이 학계의 주목을 받았고, 이후 노선망 설계 연구로 이어졌습니다. 이 글의 02 판이 그 실험의 구조를 그대로 옮긴 것입니다.
코드를 몰라도 이 데모를 제 페이지에 붙일 수 있나요?
가능합니다. zip 을 열어 항목 폴더의 index.html 을 그대로 올리고 원하는 자리에 넣으면 되고, 크기와 색은 파일 안의 토큰 값을 몇 개만 바꿔 조정합니다. 각 판은 서로를 참조하지 않는 단일 파일이라 하나만 골라 쓰셔도 나머지와 무관하게 동작합니다.









