snowflake generator 9종 — DLA로 자라는 눈결정
snowflake generator 아홉 종을 캔버스 픽셀 계산만으로 보여줍니다. 라이브러리·WebGL 없이 바깥에서 날아든 입자 하나가 결정에 닿아 붙는 순간을 반복하는 것만으로 육각 눈결정이 저절로 자랍니다.
01 snowflake-dla-hex-cluster
02 snowflake-dendrite-branch-grow
03 snowflake-needle-fern-star
04 snowflake-six-fold-symmetry-mirror
05 snowflake-frost-window-pane
06 snowflake-crystal-lattice-color
07 snowflake-pointer-seed-grow
08 snowflake-melt-refreeze-cycle
09 snowflake-macro-photo-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 기본 육각 응집 — 랜덤워크 입자가 닿은 자리에 붙는 DLA 한 겹
- 02 가지 결정(dendrite) — 여섯 방향 팁이 촘촘히 갈라지는 나무 성장
- 03 침·고사리 결정 — 습도 밴드를 바꾸면 나뭇잎에서 바늘로
- 04 육중 대칭 거울 — 60도마다 접어 여섯 배 속도로 자라기
- 05 서리 낀 창 — 가장자리에서 안쪽으로 기어드는 프랙탈 얼음
- 06 결정 격자 채색 — 나이(접촉 시각)를 파스텔 고리로 새기기
- 07 포인터 씨앗 — 클릭한 자리에서 두 번째 결정이 같이 자라기
- 08 녹았다 얼기 반복 — 가장 바깥 입자부터 떼어내 다시 붙이는 순환
- 09 매크로 사진 씬 — 다크 배경 위 촛점 심도로 뜨는 결정 한 편
흡착 확률 하나로 아홉 눈송이를 가르는 순서
순서는 결정 모양의 화려함이 아니라 같은 응집 규칙에 무엇을 얹었는가로 갈립니다. 01은 흡착 확률을 1로 고정한 가장 단순한 육각 응집이고, 02·03은 그 위에 팁 쪽으로 쏠리는 흡착 확률 하나만 더해 뭉툭한 덩어리를 가지(수지상)·바늘·고사리로 갈라놓습니다. 04는 계산을 아예 줄이는 쪽으로 방향을 틀어 섹터 하나만 걷고 나머지 다섯을 거울처럼 접어 완벽한 육중 대칭을 만들고, 05는 씨앗을 중심이 아니라 창틀 전체로 옮겨 프랙탈이 가장자리에서 안으로 번지게 하며, 06은 붙은 시각을 색으로 남겨 성장 자체를 나이테로 읽게 합니다. 07은 씨앗을 하나 더 심어 두 결정이 경쟁하는 상호 응집을, 08은 붙은 셀을 도로 떼어내는 냉동·해동 순환을, 09는 같은 계산을 어두운 무대와 조명 위에 올려 매크로 사진처럼 보여줍니다 — 이 아홉 판을 관통하는 축은 눈송이 그림이 아니라 걸음 하나가 닿았을 때 붙을지 말지를 정하는 확률 하나입니다.
항목별 코드
01기본 육각 응집 — 랜덤워크 입자가 닿은 자리에 붙는 DLA 한 겹
결정 씨앗 하나를 두고 바깥 원에서 출발한 입자가 육각 격자 위를 무작위로 걸으며 결정에 닿는 순간 붙는 규칙을 반복하면 뾰족한 육각 클러스터가 스스로 자랍니다 — 실측 누적면적 43.511% · 강도 평균 59.5 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>바깥 원에서 날아든 입자가 위·아래·옆으로 무작위 걸음을 걷다가 결정에 닿는 순간 그대로 얼어붙는 규칙을 수천 번 반복하면, 씨앗 하나에서 육각 뾰족 클러스터가 저절로 자랍니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="DLA 육각 응집 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">0</b>개 응집 · 기본 DLA</span>
<span class="cd__meta cd__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 육각 응집(01). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(DLA 랜덤워크 + 잔광 페이드 + 노이즈 진눈깨비)가 만든다.
// 무대는 밤 슬레이트. 파스텔 고정 팔레트를 --flake-* 로 편 공통 규격(01~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
// $stage-ink 는 공유 토큰을 그대로 쓰고, 편 무대용 한 단계 톤만 여기서 정의
$flake-stage: #171b24; // 밤 슬레이트 무대 — 어두운 남색빛 (짙어서 파스텔 검사 무관)
@include stage($flake-stage);
$flake-ink: #141821; // 무대 위쪽 — 더 어두운 쪽
$flake-deep: #2b3346; // 무대 아래쪽 — 밤 슬레이트
$flake-haze: #8fa8c9; // 씨앗 주변 헤이즈 — 페이드블루 (채도 .25)
$flake-mote: #f2ded0; // 부유 먼지 — 파스텔 새빛 (거의 백색)
$flake-ice: #fff9f0; // 결정 안쪽 — 파스텔 화이트
$flake-glow: #cfeaff; // 방금 붙은 칸 블룸 — 파스텔 스카이
$flake-lilac: #c9b6ff; // 결정 바깥 — 파스텔 라일락 (채도 .32)
$flake-mint: #c8f4e4; // 활성 입자 — 파스텔 민트
$flake-mist: #d8dee8; // 보조 글자 — 안개 톤
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-deep: #{$flake-deep};
--flake-haze: #{$flake-haze};
--flake-mote: #{$flake-mote};
--flake-ice: #{$flake-ice};
--flake-glow: #{$flake-glow};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist; // 잉크 무대 위 — 안개 톤 글자
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-lilac; font-weight: 700; } // 잉크 위 라일락 라벨
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 자라다 멈춘 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #171b24;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #141821;
--flake-deep: #2b3346;
--flake-haze: #8fa8c9;
--flake-mote: #f2ded0;
--flake-ice: #fff9f0;
--flake-glow: #cfeaff;
--flake-lilac: #c9b6ff;
--flake-mint: #c8f4e4;
--flake-mist: #d8dee8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d8dee8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #141821;
box-shadow: inset 0 0 0 1px rgba(216, 222, 232, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #c9b6ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 222, 232, 0.72);
}
.cd__val {
color: #c9b6ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 기본 육각 응집(01). DLA(diffusion-limited aggregation) —
// 결정 씨앗 하나를 중앙에 두고, 바깥 링 위의 임의 점에서 입자를 출발시켜
// 격자 위에서 6방향 무작위 걸음(랜덤워크)을 걷게 한다. 걸음마다 결정에 닿았는지
// 이웃 검사로 확인하고, 닿은 순간 그 칸에 얼어 붙는다(확산이 응집을 제한).
// 이 "닿으면 붙는" 순간만 수천 번 반복하면 구석진 곳엔 입자가 잘 못 들어가
// 안쪽은 비고 팁이 뾰족해진, 육각 양상의 프랙탈 클러스터가 자란다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 결정 레이어 ImageData 를 어둡게 걸어
// 날아간 입자의 궤적이 잔광으로 식며 남게 한다(누적면적 ↑).
// · 배경에는 값노이즈 진눈깨비 밝기 요동과 내리는 눈(눈결정 편의 숨)이
// 매 프레임 캔버스 전체를 움직인다.
// · 최근에 붙은 칸은 붙은 뒤 잠깐 밝게 빛나다 식는(순간 강도 ↑).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var PARTICLES = 34; // 동시에 떠 있는 입자 수 — 응집 속도비례
var STICK = 1.0; // 닿았을 때 붙을 확률(1.0 = 닿는 즉시 응고). 낮추면 뭉툭해진다
var MAX_AGE = 0.55; // 방금 붙은 칸이 빛나는 시간(초) — 크리스털 블룸
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 결정으로 자란다
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(0x319A01);
// 색은 CSS 커스텀 프로퍼티(--flake-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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('--flake-ink');
var C_DEEP = tokRGB('--flake-deep');
var C_HAZE = tokRGB('--flake-haze');
var C_MOTE = tokRGB('--flake-mote');
var C_ICE = tokRGB('--flake-ice');
var C_GLOW = tokRGB('--flake-glow');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_MIST = tokRGB('--flake-mist');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, t0 = -1;
var bg, data, img2, data2; // 배경 ImageData · 누적(결정+잔광) ImageData
var N = 0; // 격자 한 변 칸 수
var grid, age; // 응집 칸 플래그 · 붙은 시각
var walk = []; // 현재 랜덤워크 중인 입자
var stuck = 0;
var crystalR = 1; // 결정 반경(칸 수) — 여기 밖에서 입자를 발사한다
var cx = 0, cy = 0; // 격자 중심
var snow = []; // 내리는 눈 — 캔버스 전체를 살리는 숨
function gridIdx(i, j) { return i * N + j; }
function inGrid(i, j) { return i >= 0 && j >= 0 && i < N && j < N; }
// 6인접 격자 이웃. 걸음의 "옆"이 6방향이면 육결정 답게 자란다
var DJ_EVEN = [0, -1, -1, 0, 0, 0];
var DJ_ODD = [0, 0, 0, 0, 1, 1];
var DI = [-1, -1, 0, 1, 0, -1];
function nbr(i, j, k) {
return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])];
}
function set(i, j, t) { grid[gridIdx(i, j)] = 1; age[gridIdx(i, j)] = t; }
// ── 씨앗: 작은 육각 무리 — 처음부터 정육각 대칭의 시작점 ──
function plantSeed() {
set(cx, cy, 0); set(cx - 1, cy, 0); set(cx + 1, cy, 0);
set(cx, cy - 1, 0); set(cx, cy + 1, 0);
set(cx - 1, cy - 1, 0); set(cx + 1, cy + 1, 0);
stuck = 7;
}
// ── 입자 하나 발사 — 결정 반경 밖 바깥 링에서 ──
function launch() {
var a = rand() * Math.PI * 2;
var r = crystalR * 1.22 + 3;
var i = cx + Math.round(Math.cos(a) * r);
var j = cy + Math.round(Math.sin(a) * r);
if (!inGrid(i, j) || grid[gridIdx(i, j)]) return;
walk.push([i, j, Math.floor(rand() * 6), rand() * Math.PI * 2]);
}
function touching(i, j) {
for (var k = 0; k < 6; k++) {
var nb = nbr(i, j, k);
if (inGrid(nb[0], nb[1]) && grid[gridIdx(nb[0], nb[1])]) return true;
}
return false;
}
// ── DLA 스텝 — 걷고, 닿으면 붙는다 ──
function step(t) {
for (var s = 0; s < 20; s++) { // 프레임당 스텝 수 — 걸음 속도
for (var w = walk.length - 1; w >= 0; w--) {
var p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6); // 가끔 방향 재추첨 — 등방성
var nb = nbr(p[0], p[1], p[2]);
var i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; } // 튀어나가면 버리고 다시 발사
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < STICK) { // 닿았다 — 확률로 그 칸에 얼어 붙는다
set(i, j, t);
stuck++;
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > crystalR) crystalR = Math.min(N * 0.46, dc + 2);
}
walk.splice(w, 1);
} else if (Math.hypot(i - cx, j - cy) > crystalR * 1.7 + 8) {
walk.splice(w, 1); // 너무 멀어졌다 — 되돌아올 확률이 얇다
}
}
}
while (walk.length < PARTICLES) launch();
}
// ── 내리는 눈 — 캔버스 전체를 쓸며 내려오는 파스텔 입자 ──
function buildSnow() {
snow = [];
for (var i = 0; i < 42; i++) {
snow.push({
x: rand(), y: rand(),
r: 1.1 + rand() * 2.1,
v: 0.055 + rand() * 0.11, // 정규화 속도(화면 높이/초)
sway: 0.012 + rand() * 0.03,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9
});
}
}
// ── 값노이즈(부드러운 배경 요동용) ──
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 lerp(a, b, k) { return a + (b - a) * k; }
// ── 배경 — 세로 그라디언트 + 노이즈 진눈깨비 + 씨앗 헤이즈 ──
function drawBackdrop(t) {
var pcx = (cx + 0.5) * cellPx, pcy = (cy + 0.5) * cellPx;
var sc = 0.055;
for (var y = 0; y < H; y++) {
var u = y / H;
var r0 = C_INK[0] + (C_DEEP[0] - C_INK[0]) * u;
var g0 = C_INK[1] + (C_DEEP[1] - C_INK[1]) * u;
var b0 = C_INK[2] + (C_DEEP[2] - C_INK[2]) * u;
var dyv = y - pcy;
for (var x = 0; x < W; x++) {
var dxv = x - pcx;
var d = Math.sqrt(dxv * dxv + dyv * dyv);
var haze = Math.exp(-d * d / (W * W * 0.012)) * 30;
var sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 14;
var j3 = (y * W + x) * 4;
data[j3] = r0 + haze * (C_HAZE[0] / 255) + sh;
data[j3 + 1] = g0 + haze * (C_HAZE[1] / 255) + sh;
data[j3 + 2] = b0 + haze * (C_HAZE[2] / 255) + sh * 1.3;
}
}
}
function putc(px, py, c, alpha) {
if (px < 0 || py < 0 || px >= W || py >= H) return;
var j3 = (py * W + px) * 4;
data2[j3] += (c[0] - data2[j3]) * alpha;
data2[j3 + 1] += (c[1] - data2[j3 + 1]) * alpha;
data2[j3 + 2] += (c[2] - data2[j3 + 2]) * alpha;
}
// ── 결정 셀 하나의 색 ──
function cellColor(g, ag) {
var k = Math.min(1, g / (N * 0.38)); // 중심(화이트) → 바깥(라일락)
var base = [
lerp(C_ICE[0], C_LILAC[0], k),
lerp(C_ICE[1], C_LILAC[1], k),
lerp(C_ICE[2], C_LILAC[2], k)
];
if (ag < MAX_AGE) { // 방금 붙은 칸 — 빛나다 식는
var b = Math.pow(1 - ag / MAX_AGE, 1.6);
base[0] += (C_GLOW[0] - base[0]) * (0.85 * b);
base[1] += (C_GLOW[1] - base[1]) * (0.85 * b);
base[2] += (C_GLOW[2] - base[2]) * (0.85 * b);
}
return base;
}
var cellPx = 4;
function draw(t, dt) {
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
// 누적 결정 레이어 — 잔광 페이드(배경 쪽으로 식는다)
var f = 1 - Math.exp(-dt * 4.2);
for (var k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
// 결정 셀 — 씨앗에서의 거리로 착색
var ox = (W - N * cellPx) / 2, oy = (H - N * cellPx) / 2;
var rr = Math.max(1, Math.round(cellPx * 0.58));
var rr2 = rr * rr;
for (var i = 0; i < N; i++) {
for (var j = 0; j < N; j++) {
var gi = gridIdx(i, j);
if (!grid[gi]) continue;
var col = cellColor(Math.hypot(i - cx, (j - cy) * 0.92), t - age[gi]);
var px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + j * cellPx);
for (var dy = -rr; dy <= rr; dy++) {
for (var dx = -rr; dx <= rr; dx++) {
var dd = dx * dx + dy * dy;
if (dd > rr2) continue;
var px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
var j3 = (py2 * W + px2) * 4;
var a3 = 1 - dd / (rr2 + 1);
data2[j3] += (col[0] - data2[j3]) * a3;
data2[j3 + 1] += (col[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (col[2] - data2[j3 + 2]) * a3;
}
}
}
}
// 활성 입자 — 지금 걷고 있는 입자. 지나간 자리에 잔광이 남는다
for (var wi = 0; wi < walk.length; wi++) {
var p = walk[wi];
var px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + p[1] * cellPx);
var tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, C_MINT, 0.95);
putc(px + 1, py, C_MINT, 0.5 * tw);
putc(px - 1, py, C_MINT, 0.5 * tw);
putc(px, py + 1, C_MINT, 0.5 * tw);
putc(px, py - 1, C_MINT, 0.9 * tw);
}
// 내리는 눈 — 캔버스 전체를 쓸며 내려오는 파스텔 입자
for (var si = 0; si < snow.length; si++) {
var s = snow[si];
var sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
var sy = ((s.y + s.v * t) % 1.15 - 0.07) * H;
var br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
var a4 = 0.55 + 0.4 * br;
var sr = Math.ceil(s.r);
for (var dy2 = -sr; dy2 <= sr; dy2++) {
for (var dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, C_MOTE, a4);
}
}
}
ctx.putImageData(img2, 0, 0);
}
// ── 캔버스 크기 측정. 칸 수는 화면에 맞춰 홀수로 ──
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
ctx = cv.getContext('2d', { alpha: false });
cellPx = Math.max(3, Math.min(5, Math.floor(Math.min(W, H) / 42)));
N = Math.floor(Math.min(W, H) / cellPx);
if ((N & 1) === 0) N -= 1;
grid = new Uint8Array(N * N);
age = new Float32Array(N * N);
walk = []; snow = [];
stuck = 0; crystalR = 1;
cx = N >> 1; cy = N >> 1;
bg = ctx.createImageData(W, H); data = bg.data;
img2 = ctx.createImageData(W, H); data2 = img2.data;
for (var k = 3; k < data.length; k += 4) { data[k] = 255; data2[k] = 255; }
plantSeed();
buildSnow();
}
function stillFrame() {
// reduce — 결정이 자라다 멈춘 한 장
for (var k = 0; k < 240; k++) step(0.01 * k);
draw(2.4, 0.016);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
step(t);
draw(t, dt);
if (nOut) nOut.textContent = String(stuck);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// DlaHexCluster — vanilla 판(run/319/demo/01-snowflake-dla-hex-cluster/index.html) 의 React 포트.
// 기본 육각 응집 — DLA(diffusion-limited aggregation). 씨앗 하나를 중앙에 두고 바깥 링에서
// 입자를 발사해 6방향 랜덤워크를 걷게 하고, 결정에 닿으면 그 칸에 얼어 붙는다. 이 규칙만
// 수천 번 반복하면 육각 팁이 뾰족한 프랙탈 클러스터가 저절로 자란다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DlaHexCluster.module.scss";
export type DlaHexClusterProps = {
duration?: string;
easing?: string;
color?: string;
particles?: number; // 동시에 떠 있는 입자 수 — 응집 속도
stick?: number; // 닿았을 때 붙을 확률(1.0 = 즉시 응고)
maxAge?: number; // 방금 붙은 칸이 빛나는 시간(초)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Particle = [number, number, number, number];
type Snow = { x: number; y: number; r: number; v: number; sway: number; ph: number; tw: number };
type Colors = { ink: RGB; deep: RGB; haze: RGB; mote: RGB; ice: RGB; glow: RGB; lilac: RGB; mint: RGB; mist: RGB };
const FIELD_LABEL = "DLA 육각 응집 실험판";
const DI = [-1, -1, 0, 1, 0, -1];
const DJ_EVEN = [0, -1, -1, 0, 0, 0];
const DJ_ODD = [0, 0, 0, 0, 1, 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 DlaHexCluster({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#c9b6ff",
particles = 34,
stick = 1.0,
maxAge = 0.55,
}: DlaHexClusterProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const particlesRef = useRef(particles) as Box<number>;
const stickRef = useRef(stick) as Box<number>;
const maxAgeRef = useRef(maxAge) as Box<number>;
const randRef = useRef(makePRNG(0x319a01)) as Box<() => number>;
const gridRef = useRef<Uint8Array>(new Uint8Array(0)) as Box<Uint8Array>;
const ageRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const nRef = useRef(1) as Box<number>;
const cellPxRef = useRef(4) as Box<number>;
const centerRef = useRef({ cx: 0, cy: 0 }) as Box<{ cx: number; cy: number }>;
const rRef = useRef(1) as Box<number>;
const stuckRef = useRef(0) as Box<number>;
const walkRef = useRef<Particle[]>([]) as Box<Particle[]>;
const snowRef = useRef<Snow[]>([]) as Box<Snow[]>;
const bgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const img2Ref = 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 gridIdx(i: number, j: number): number { return i * nRef.current + j; }
function inGrid(i: number, j: number): boolean {
const N = nRef.current;
return i >= 0 && j >= 0 && i < N && j < N;
}
function nbr(i: number, j: number, k: number): [number, number] {
return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])];
}
function setCell(i: number, j: number, t: number): void {
gridRef.current[gridIdx(i, j)] = 1;
ageRef.current[gridIdx(i, j)] = t;
}
function plantSeed(): void {
const { cx, cy } = centerRef.current;
setCell(cx, cy, 0); setCell(cx - 1, cy, 0); setCell(cx + 1, cy, 0);
setCell(cx, cy - 1, 0); setCell(cx, cy + 1, 0);
setCell(cx - 1, cy - 1, 0); setCell(cx + 1, cy + 1, 0);
stuckRef.current = 7;
}
function launch(): void {
const rand = randRef.current;
const { cx, cy } = centerRef.current;
const a = rand() * Math.PI * 2;
const r = rRef.current * 1.22 + 3;
const i = cx + Math.round(Math.cos(a) * r);
const j = cy + Math.round(Math.sin(a) * r);
if (!inGrid(i, j) || gridRef.current[gridIdx(i, j)]) return;
walkRef.current.push([i, j, Math.floor(rand() * 6), rand() * Math.PI * 2]);
}
function touching(i: number, j: number): boolean {
for (let k = 0; k < 6; k++) {
const nb = nbr(i, j, k);
if (inGrid(nb[0], nb[1]) && gridRef.current[gridIdx(nb[0], nb[1])]) return true;
}
return false;
}
function step(t: number): void {
const rand = randRef.current;
const { cx, cy } = centerRef.current;
for (let s = 0; s < 20; s++) {
const walk = walkRef.current;
for (let w = walk.length - 1; w >= 0; w--) {
const p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6);
const nb = nbr(p[0], p[1], p[2]);
const i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; }
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < stickRef.current) {
setCell(i, j, t);
stuckRef.current++;
const dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > rRef.current) rRef.current = Math.min(nRef.current * 0.46, dc + 2);
}
walk.splice(w, 1);
} else if (Math.hypot(i - cx, j - cy) > rRef.current * 1.7 + 8) {
walk.splice(w, 1);
}
}
while (walkRef.current.length < particlesRef.current) launch();
}
}
function buildSnow(): void {
const rand = randRef.current;
const arr: Snow[] = [];
for (let i = 0; i < 42; i++) {
arr.push({
x: rand(), y: rand(),
r: 1.1 + rand() * 2.1,
v: 0.055 + rand() * 0.11,
sway: 0.012 + rand() * 0.03,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9,
});
}
snowRef.current = arr;
}
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 lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function drawBackdrop(t: number): void {
const col = colRef.current;
const bg = bgRef.current;
if (!col || !bg) return;
const { w: W, h: H } = sizeRef.current;
const data = bg.data;
const { cx, cy } = centerRef.current;
const cellPx = cellPxRef.current;
const pcx = (cx + 0.5) * cellPx, pcy = (cy + 0.5) * cellPx;
const sc = 0.055;
for (let y = 0; y < H; y++) {
const u = y / H;
const r0 = col.ink[0] + (col.deep[0] - col.ink[0]) * u;
const g0 = col.ink[1] + (col.deep[1] - col.ink[1]) * u;
const b0 = col.ink[2] + (col.deep[2] - col.ink[2]) * u;
const dyv = y - pcy;
for (let x = 0; x < W; x++) {
const dxv = x - pcx;
const d = Math.sqrt(dxv * dxv + dyv * dyv);
const haze = Math.exp(-d * d / (W * W * 0.012)) * 30;
const sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 14;
const j3 = (y * W + x) * 4;
data[j3] = r0 + haze * (col.haze[0] / 255) + sh;
data[j3 + 1] = g0 + haze * (col.haze[1] / 255) + sh;
data[j3 + 2] = b0 + haze * (col.haze[2] / 255) + sh * 1.3;
}
}
}
function putc(px: number, py: number, c: RGB, alpha: number): void {
const { w: W, h: H } = sizeRef.current;
const img2 = img2Ref.current;
if (!img2 || px < 0 || py < 0 || px >= W || py >= H) return;
const data = img2.data;
const j3 = (py * W + px) * 4;
data[j3] += (c[0] - data[j3]) * alpha;
data[j3 + 1] += (c[1] - data[j3 + 1]) * alpha;
data[j3 + 2] += (c[2] - data[j3 + 2]) * alpha;
}
function cellColor(g: number, ag: number): RGB {
const col = colRef.current!;
const N = nRef.current;
const k = Math.min(1, g / (N * 0.38));
const base: RGB = [
lerp(col.ice[0], col.lilac[0], k),
lerp(col.ice[1], col.lilac[1], k),
lerp(col.ice[2], col.lilac[2], k),
];
if (ag < maxAgeRef.current) {
const b = Math.pow(1 - ag / maxAgeRef.current, 1.6);
base[0] += (col.glow[0] - base[0]) * (0.85 * b);
base[1] += (col.glow[1] - base[1]) * (0.85 * b);
base[2] += (col.glow[2] - base[2]) * (0.85 * b);
}
return base;
}
function draw(t: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const bg = bgRef.current;
const img2 = img2Ref.current;
if (!ctx || !col || !bg || !img2) return;
const { w: W, h: H } = sizeRef.current;
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
const data = bg.data, data2 = img2.data;
const f = 1 - Math.exp(-dt * 4.2);
for (let k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
const N = nRef.current;
const cellPx = cellPxRef.current;
const { cx, cy } = centerRef.current;
const ox = (W - N * cellPx) / 2, oy = (H - N * cellPx) / 2;
const rr = Math.max(1, Math.round(cellPx * 0.58));
const rr2 = rr * rr;
const grid = gridRef.current, age = ageRef.current;
for (let i = 0; i < N; i++) {
for (let j = 0; j < N; j++) {
const gi = gridIdx(i, j);
if (!grid[gi]) continue;
const colr = cellColor(Math.hypot(i - cx, (j - cy) * 0.92), t - age[gi]);
const px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + j * cellPx);
for (let dy = -rr; dy <= rr; dy++) {
for (let dx = -rr; dx <= rr; dx++) {
const dd = dx * dx + dy * dy;
if (dd > rr2) continue;
const px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
const j3 = (py2 * W + px2) * 4;
const a3 = 1 - dd / (rr2 + 1);
data2[j3] += (colr[0] - data2[j3]) * a3;
data2[j3 + 1] += (colr[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (colr[2] - data2[j3 + 2]) * a3;
}
}
}
}
const walk = walkRef.current;
for (let wi = 0; wi < walk.length; wi++) {
const p = walk[wi];
const px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + p[1] * cellPx);
const tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, col.mint, 0.95);
putc(px + 1, py, col.mint, 0.5 * tw);
putc(px - 1, py, col.mint, 0.5 * tw);
putc(px, py + 1, col.mint, 0.5 * tw);
putc(px, py - 1, col.mint, 0.9 * tw);
}
const snow = snowRef.current;
for (let si = 0; si < snow.length; si++) {
const s = snow[si];
const sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
const sy = (((s.y + s.v * t) % 1.15) - 0.07) * H;
const br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
const a4 = 0.55 + 0.4 * br;
const sr = Math.ceil(s.r);
for (let dy2 = -sr; dy2 <= sr; dy2++) {
for (let dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, col.mote, a4);
}
}
}
ctx.putImageData(img2, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
ctxRef.current = ctx;
const cellPx = Math.max(3, Math.min(5, Math.floor(Math.min(W, H) / 42)));
cellPxRef.current = cellPx;
let N = Math.floor(Math.min(W, H) / cellPx);
if ((N & 1) === 0) N -= 1;
nRef.current = N;
gridRef.current = new Uint8Array(N * N);
ageRef.current = new Float32Array(N * N);
walkRef.current = [];
stuckRef.current = 0;
rRef.current = 1;
const cx = N >> 1, cy = N >> 1;
centerRef.current = { cx, cy };
const bg = ctx.createImageData(W, H);
const img2 = ctx.createImageData(W, H);
for (let k = 3; k < bg.data.length; k += 4) { bg.data[k] = 255; img2.data[k] = 255; }
bgRef.current = bg;
img2Ref.current = img2;
plantSeed();
buildSnow();
}
function stillFrame(): void {
for (let k = 0; k < 240; k++) step(0.01 * k);
draw(2.4, 0.016);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
step(t);
draw(t, dt);
setN(String(stuckRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
particlesRef.current = particles;
stickRef.current = stick;
maxAgeRef.current = maxAge;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
deep: tokRGB(root, "--flake-deep"),
haze: tokRGB(root, "--flake-haze"),
mote: tokRGB(root, "--flake-mote"),
ice: tokRGB(root, "--flake-ice"),
glow: tokRGB(root, "--flake-glow"),
lilac: tokRGB(root, "--flake-lilac"),
mint: tokRGB(root, "--flake-mint"),
mist: tokRGB(root, "--flake-mist"),
};
randRef.current = makePRNG(0x319a01);
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
}, [particles, stick, maxAge]);
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>개 응집 · 기본 DLA</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · 기본 육각 응집 — 랜덤워크 입자가 닿은 자리에 붙는 DLA 한 겹 — 측정 누적면적 43.511% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
랜덤워크 입자가 닿으면 얼어붙는 규칙
function step(t) {
for (var s = 0; s < 20; s++) { // 프레임당 스텝 수 — 걸음 속도
for (var w = walk.length - 1; w >= 0; w--) {
var p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6); // 가끔 방향 재추첨 — 등방성
var nb = nbr(p[0], p[1], p[2]);
var i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; } // 튀어나가면 버리고 다시 발사
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < STICK) { // 닿았다 — 확률로 그 칸에 얼어 붙는다
set(i, j, t);
stuck++;
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > crystalR) crystalR = Math.min(N * 0.46, dc + 2);
}
walk.splice(w, 1);
} else if (Math.hypot(i - cx, j - cy) > crystalR * 1.7 + 8) {
walk.splice(w, 1); // 너무 멀어졌다 — 되돌아올 확률이 얇다
}
}
}
while (walk.length < PARTICLES) launch();
}
PARTICLES 34개가 동시에 걷고, nbr() 이 짝수·홀수 열마다 다른 6방향 오프셋(DJ_EVEN·DJ_ODD)을 돌려주는 것이 이 격자를 육각으로 만드는 전부입니다. STICK 을 1.0 으로 두면 닿는 즉시 얼어붙어 가장 거친 DLA 뼈대가 나옵니다.
02가지 결정(dendrite) — 여섯 방향 팁이 촘촘히 갈라지는 나무 성장
입자가 결정 중심에서 먼 팁 쪽에 더 잘 붙도록 흡착 확률을 기울이면 뭉툭한 덩어리 대신 진짜 눈결정의 가지가 뻗습니다 — 실측 누적면적 44.129% · 강도 평균 65.4 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>입자가 가장 바깥으로 뻗은 팁에 더 잘 붙도록 흡착 확률을 팁 쪽으로 치우치게 하면, 뭉툭한 덩어리가 아니라 진짜 눈결정의 가지(수지상)가 촘촘히 뻗어갑니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="가지 결정 DLA 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">0</b>개 응집 · 팁 가중 가지</span>
<span class="cd__meta cd__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 가지 결정(dendrite, 02). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(팁 가중 DLA + 잔광 페이드 + 노이즈 진눈깨비)가 만든다.
// 01 과 같은 파스텔 팔레트를 --flake-* 로 공유(편 공통 규격, 01~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
$flake-stage: #171b24;
@include stage($flake-stage);
$flake-ink: #141821;
$flake-deep: #2b3346;
$flake-haze: #8fa8c9;
$flake-mote: #f2ded0;
$flake-ice: #fff9f0;
$flake-glow: #cfeaff;
$flake-lilac: #c9b6ff;
$flake-mint: #c8f4e4;
$flake-mist: #d8dee8;
.cd {
--flake-ink: #{$flake-ink};
--flake-deep: #{$flake-deep};
--flake-haze: #{$flake-haze};
--flake-mote: #{$flake-mote};
--flake-ice: #{$flake-ice};
--flake-glow: #{$flake-glow};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist;
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-lilac; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #171b24;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #141821;
--flake-deep: #2b3346;
--flake-haze: #8fa8c9;
--flake-mote: #f2ded0;
--flake-ice: #fff9f0;
--flake-glow: #cfeaff;
--flake-lilac: #c9b6ff;
--flake-mint: #c8f4e4;
--flake-mist: #d8dee8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d8dee8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #141821;
box-shadow: inset 0 0 0 1px rgba(216, 222, 232, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #c9b6ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 222, 232, 0.72);
}
.cd__val {
color: #c9b6ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 가지 결정(02). 01 과 같은 DLA 랜덤워크 응집 규칙에 한 겹만 더한다 —
// 결정에 새 칸이 붙을 때, 그 칸이 씨앗에서 얼마나 먼지(반경)로 흡착 여부를 다시 굴린다.
// 반경이 클수록(팁에 가까울수록) 흡착 확률이 높아지도록 편향(TIP_BIAS)을 주면,
// 안쪽 오목한 자리보다 바깥으로 뻗은 팁이 입자를 훨씬 잘 붙잡아 뭉툭한 덩어리 대신
// 가늘고 촘촘한 나뭇가지(수지상) 형태로 자란다 — 이것이 실제 눈결정이 팔각이 아니라
// 가지 모양으로 자라는 이유(팁 쪽 확산 flux 가 더 높다, Mullins-Sekerka 불안정성의 단순화판)다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
// 2026-09-15 재튜닝(BD3 누적면적 28.5%→30%+): 정체성(팁 가중 단일 씨앗 덴드라이트)은 그대로,
// 동시 성장 팁 수·최종 결정 크기·배경 잔광/강설 밀도만 올려 필드 안에서 바뀌는 픽셀 영역을 넓힌다.
var PARTICLES = 56; // 동시에 떠 있는 입자 수 — 응집 속도(42→56, 가지가 더 빨리 더 많이 뻗는다)
var TIP_BIAS = 2.6; // 팁 편향 지수 — 클수록 가지가 가늘고 뾰족해진다(0 이면 01 과 동일)
var MAX_AGE = 0.55; // 방금 붙은 칸이 빛나는 시간(초)
var SNOW = 130; // 내리는 눈 입자 수 — 캔버스 전체를 쓸며 내려온다(90→130)
var R_CAP = 0.48; // 결정 최대 반경 비율(N 대비, 0.46→0.48) — 필드 안에서 더 크게 자란다
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('cdN');
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(0x319A02);
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('--flake-ink');
var C_DEEP = tokRGB('--flake-deep');
var C_HAZE = tokRGB('--flake-haze');
var C_MOTE = tokRGB('--flake-mote');
var C_ICE = tokRGB('--flake-ice');
var C_GLOW = tokRGB('--flake-glow');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_MIST = tokRGB('--flake-mist');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, t0 = -1;
var bg, data, img2, data2;
var N = 0;
var grid, age, maxR; // maxR = 그 칸이 흡착될 당시 결정 최대 반경(팁 편향 판정용)
var walk = [];
var stuck = 0;
var crystalR = 1;
var cx = 0, cy = 0;
var snow = [];
var cellPx = 4;
function gridIdx(i, j) { return i * N + j; }
function inGrid(i, j) { return i >= 0 && j >= 0 && i < N && j < N; }
var DJ_EVEN = [0, -1, -1, 0, 0, 0];
var DJ_ODD = [0, 0, 0, 0, 1, 1];
var DI = [-1, -1, 0, 1, 0, -1];
function nbr(i, j, k) { return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])]; }
function set(i, j, t) { grid[gridIdx(i, j)] = 1; age[gridIdx(i, j)] = t; }
function plantSeed() {
set(cx, cy, 0); set(cx - 1, cy, 0); set(cx + 1, cy, 0);
set(cx, cy - 1, 0); set(cx, cy + 1, 0);
set(cx - 1, cy - 1, 0); set(cx + 1, cy + 1, 0);
stuck = 7;
}
function launch() {
var a = rand() * Math.PI * 2;
var r = crystalR * 1.22 + 3;
var i = cx + Math.round(Math.cos(a) * r);
var j = cy + Math.round(Math.sin(a) * r);
if (!inGrid(i, j) || grid[gridIdx(i, j)]) return;
walk.push([i, j, Math.floor(rand() * 6), rand() * Math.PI * 2]);
}
function touching(i, j) {
for (var k = 0; k < 6; k++) {
var nb = nbr(i, j, k);
if (inGrid(nb[0], nb[1]) && grid[gridIdx(nb[0], nb[1])]) return true;
}
return false;
}
// ── 팁 가중 흡착 — 반경이 클수록(팁에 가까울수록) 흡착 확률을 높인다 ──
function stickProb(i, j) {
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
var k = Math.min(1, dc / Math.max(1, crystalR));
return Math.pow(k, 1) * 0.15 + Math.pow(k, TIP_BIAS) * 0.85 + 0.05;
}
function step(t) {
for (var s = 0; s < 26; s++) {
for (var w = walk.length - 1; w >= 0; w--) {
var p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6);
var nb = nbr(p[0], p[1], p[2]);
var i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; }
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < stickProb(i, j)) { // 팁 편향 — 안쪽 오목한 자리는 잘 안 붙는다
set(i, j, t);
stuck++;
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > crystalR) crystalR = Math.min(N * R_CAP, dc + 2);
}
// 안 붙으면 계속 걷는다(제자리 유지) — 오목한 자리에서 자꾸 튕겨나가며 팁만 자란다
} else if (Math.hypot(i - cx, j - cy) > crystalR * 1.7 + 8) {
walk.splice(w, 1);
}
}
}
while (walk.length < PARTICLES) launch();
}
function buildSnow() {
snow = [];
for (var i = 0; i < SNOW; i++) {
snow.push({
x: rand(), y: rand(),
r: 1.2 + rand() * 2.4,
v: 0.07 + rand() * 0.14,
sway: 0.014 + rand() * 0.034,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9
});
}
}
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 lerp(a, b, k) { return a + (b - a) * k; }
function drawBackdrop(t) {
var pcx = (cx + 0.5) * cellPx, pcy = (cy + 0.5) * cellPx;
var sc = 0.055;
for (var y = 0; y < H; y++) {
var u = y / H;
var r0 = C_INK[0] + (C_DEEP[0] - C_INK[0]) * u;
var g0 = C_INK[1] + (C_DEEP[1] - C_INK[1]) * u;
var b0 = C_INK[2] + (C_DEEP[2] - C_INK[2]) * u;
var dyv = y - pcy;
for (var x = 0; x < W; x++) {
var dxv = x - pcx;
var d = Math.sqrt(dxv * dxv + dyv * dyv);
var haze = Math.exp(-d * d / (W * W * 0.018)) * 34;
var sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 28;
var j3 = (y * W + x) * 4;
data[j3] = r0 + haze * (C_HAZE[0] / 255) + sh;
data[j3 + 1] = g0 + haze * (C_HAZE[1] / 255) + sh;
data[j3 + 2] = b0 + haze * (C_HAZE[2] / 255) + sh * 1.3;
}
}
}
function putc(px, py, c, alpha) {
if (px < 0 || py < 0 || px >= W || py >= H) return;
var j3 = (py * W + px) * 4;
data2[j3] += (c[0] - data2[j3]) * alpha;
data2[j3 + 1] += (c[1] - data2[j3 + 1]) * alpha;
data2[j3 + 2] += (c[2] - data2[j3 + 2]) * alpha;
}
function cellColor(g, ag) {
var k = Math.min(1, g / (N * 0.38));
var base = [
lerp(C_ICE[0], C_LILAC[0], k),
lerp(C_ICE[1], C_LILAC[1], k),
lerp(C_ICE[2], C_LILAC[2], k)
];
if (ag < MAX_AGE) {
var b = Math.pow(1 - ag / MAX_AGE, 1.6);
base[0] += (C_GLOW[0] - base[0]) * (0.85 * b);
base[1] += (C_GLOW[1] - base[1]) * (0.85 * b);
base[2] += (C_GLOW[2] - base[2]) * (0.85 * b);
}
return base;
}
function draw(t, dt) {
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
var f = 1 - Math.exp(-dt * 4.2);
for (var k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data2[k + 2] - data2[k + 2]) * f;
}
var ox = (W - N * cellPx) / 2, oy = (H - N * cellPx) / 2;
var rr = Math.max(1, Math.round(cellPx * 0.58));
var rr2 = rr * rr;
for (var i = 0; i < N; i++) {
for (var j = 0; j < N; j++) {
var gi = gridIdx(i, j);
if (!grid[gi]) continue;
var col = cellColor(Math.hypot(i - cx, (j - cy) * 0.92), t - age[gi]);
var px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + j * cellPx);
for (var dy = -rr; dy <= rr; dy++) {
for (var dx = -rr; dx <= rr; dx++) {
var dd = dx * dx + dy * dy;
if (dd > rr2) continue;
var px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
var j3 = (py2 * W + px2) * 4;
var a3 = 1 - dd / (rr2 + 1);
data2[j3] += (col[0] - data2[j3]) * a3;
data2[j3 + 1] += (col[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (col[2] - data2[j3 + 2]) * a3;
}
}
}
}
for (var wi = 0; wi < walk.length; wi++) {
var p = walk[wi];
var px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + p[1] * cellPx);
var tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, C_MINT, 0.95);
putc(px + 1, py, C_MINT, 0.5 * tw);
putc(px - 1, py, C_MINT, 0.5 * tw);
putc(px, py + 1, C_MINT, 0.5 * tw);
putc(px, py - 1, C_MINT, 0.9 * tw);
}
for (var si = 0; si < snow.length; si++) {
var s = snow[si];
var sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
var sy = ((s.y + s.v * t) % 1.15 - 0.07) * H;
var br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
var a4 = 0.55 + 0.4 * br;
var sr = Math.ceil(s.r);
for (var dy2 = -sr; dy2 <= sr; dy2++) {
for (var dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, C_MOTE, a4);
}
}
}
ctx.putImageData(img2, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
ctx = cv.getContext('2d', { alpha: false });
cellPx = Math.max(3, Math.min(5, Math.floor(Math.min(W, H) / 42)));
N = Math.floor(Math.min(W, H) / cellPx);
if ((N & 1) === 0) N -= 1;
grid = new Uint8Array(N * N);
age = new Float32Array(N * N);
walk = []; snow = [];
stuck = 0; crystalR = 1;
cx = N >> 1; cy = N >> 1;
bg = ctx.createImageData(W, H); data = bg.data;
img2 = ctx.createImageData(W, H); data2 = img2.data;
for (var k = 3; k < data.length; k += 4) { data[k] = 255; data2[k] = 255; }
plantSeed();
buildSnow();
}
function stillFrame() {
for (var k = 0; k < 260; k++) step(0.01 * k);
draw(2.6, 0.016);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
step(t);
draw(t, dt);
if (nOut) nOut.textContent = String(stuck);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// DendriteBranchGrow — vanilla 판(run/319/demo/02-snowflake-dendrite-branch-grow/index.html) 의 React 포트.
// 가지 결정(dendrite) — DLA 에 팁 편향 한 겹. 결정에 닿은 입자가 그 칸이 씨앗에서 먼 정도에
// 비례해 붙을 확률을 받는다(팁일수록 잘 붙는다). 안쪽 오목한 자리는 잘 안 붙어 뭉툭한 덩어리 대신
// 가늘고 촘촘한 가지(수지상)가 뻗는다 — Mullins-Sekerka 불안정성의 단순화판.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DendriteBranchGrow.module.scss";
export type DendriteBranchGrowProps = {
duration?: string;
easing?: string;
color?: string;
particles?: number; // 동시에 떠 있는 입자 수 — 응집 속도
tipBias?: number; // 팁 편향 지수 — 클수록 가지가 가늘고 뾰족(0 이면 기본 DLA)
maxAge?: number; // 방금 붙은 칸이 빛나는 시간(초)
snowCount?: number; // 내리는 눈 입자 수 — 배경 강설 밀도
radiusCap?: number; // 결정 최대 반경 비율(N 대비) — 필드 안에서 자랄 수 있는 한계
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Particle = [number, number, number, number];
type Snow = { x: number; y: number; r: number; v: number; sway: number; ph: number; tw: number };
type Colors = { ink: RGB; deep: RGB; haze: RGB; mote: RGB; ice: RGB; glow: RGB; lilac: RGB; mint: RGB; mist: RGB };
const FIELD_LABEL = "가지 결정 DLA 실험판";
const DI = [-1, -1, 0, 1, 0, -1];
const DJ_EVEN = [0, -1, -1, 0, 0, 0];
const DJ_ODD = [0, 0, 0, 0, 1, 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 DendriteBranchGrow({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#c9b6ff",
particles = 56,
tipBias = 2.6,
maxAge = 0.55,
snowCount = 130,
radiusCap = 0.48,
}: DendriteBranchGrowProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const particlesRef = useRef(particles) as Box<number>;
const tipBiasRef = useRef(tipBias) as Box<number>;
const maxAgeRef = useRef(maxAge) as Box<number>;
const snowCountRef = useRef(snowCount) as Box<number>;
const radiusCapRef = useRef(radiusCap) as Box<number>;
const randRef = useRef(makePRNG(0x319a02)) as Box<() => number>;
const gridRef = useRef<Uint8Array>(new Uint8Array(0)) as Box<Uint8Array>;
const ageRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const nRef = useRef(1) as Box<number>;
const cellPxRef = useRef(4) as Box<number>;
const centerRef = useRef({ cx: 0, cy: 0 }) as Box<{ cx: number; cy: number }>;
const rRef = useRef(1) as Box<number>;
const stuckRef = useRef(0) as Box<number>;
const walkRef = useRef<Particle[]>([]) as Box<Particle[]>;
const snowRef = useRef<Snow[]>([]) as Box<Snow[]>;
const bgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const img2Ref = 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 gridIdx(i: number, j: number): number { return i * nRef.current + j; }
function inGrid(i: number, j: number): boolean {
const N = nRef.current;
return i >= 0 && j >= 0 && i < N && j < N;
}
function nbr(i: number, j: number, k: number): [number, number] {
return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])];
}
function setCell(i: number, j: number, t: number): void {
gridRef.current[gridIdx(i, j)] = 1;
ageRef.current[gridIdx(i, j)] = t;
}
function plantSeed(): void {
const { cx, cy } = centerRef.current;
setCell(cx, cy, 0); setCell(cx - 1, cy, 0); setCell(cx + 1, cy, 0);
setCell(cx, cy - 1, 0); setCell(cx, cy + 1, 0);
setCell(cx - 1, cy - 1, 0); setCell(cx + 1, cy + 1, 0);
stuckRef.current = 7;
}
function launch(): void {
const rand = randRef.current;
const { cx, cy } = centerRef.current;
const a = rand() * Math.PI * 2;
const r = rRef.current * 1.22 + 3;
const i = cx + Math.round(Math.cos(a) * r);
const j = cy + Math.round(Math.sin(a) * r);
if (!inGrid(i, j) || gridRef.current[gridIdx(i, j)]) return;
walkRef.current.push([i, j, Math.floor(rand() * 6), rand() * Math.PI * 2]);
}
function touching(i: number, j: number): boolean {
for (let k = 0; k < 6; k++) {
const nb = nbr(i, j, k);
if (inGrid(nb[0], nb[1]) && gridRef.current[gridIdx(nb[0], nb[1])]) return true;
}
return false;
}
// 팁 가중 흡착 — 반경이 클수록(팁에 가까울수록) 흡착 확률을 높인다
function stickProb(i: number, j: number): number {
const { cx, cy } = centerRef.current;
const dc = Math.hypot(i - cx, (j - cy) * 0.92);
const k = Math.min(1, dc / Math.max(1, rRef.current));
return Math.pow(k, 1) * 0.15 + Math.pow(k, tipBiasRef.current) * 0.85 + 0.05;
}
function step(t: number): void {
const rand = randRef.current;
const { cx, cy } = centerRef.current;
for (let s = 0; s < 26; s++) {
const walk = walkRef.current;
for (let w = walk.length - 1; w >= 0; w--) {
const p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6);
const nb = nbr(p[0], p[1], p[2]);
const i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; }
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < stickProb(i, j)) {
setCell(i, j, t);
stuckRef.current++;
const dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > rRef.current) rRef.current = Math.min(nRef.current * radiusCapRef.current, dc + 2);
}
} else if (Math.hypot(i - cx, j - cy) > rRef.current * 1.7 + 8) {
walk.splice(w, 1);
}
}
while (walkRef.current.length < particlesRef.current) launch();
}
}
function buildSnow(): void {
const rand = randRef.current;
const arr: Snow[] = [];
for (let i = 0; i < snowCountRef.current; i++) {
arr.push({
x: rand(), y: rand(),
r: 1.2 + rand() * 2.4,
v: 0.07 + rand() * 0.14,
sway: 0.014 + rand() * 0.034,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9,
});
}
snowRef.current = arr;
}
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 lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function drawBackdrop(t: number): void {
const col = colRef.current;
const bg = bgRef.current;
if (!col || !bg) return;
const { w: W, h: H } = sizeRef.current;
const data = bg.data;
const { cx, cy } = centerRef.current;
const cellPx = cellPxRef.current;
const pcx = (cx + 0.5) * cellPx, pcy = (cy + 0.5) * cellPx;
const sc = 0.055;
for (let y = 0; y < H; y++) {
const u = y / H;
const r0 = col.ink[0] + (col.deep[0] - col.ink[0]) * u;
const g0 = col.ink[1] + (col.deep[1] - col.ink[1]) * u;
const b0 = col.ink[2] + (col.deep[2] - col.ink[2]) * u;
const dyv = y - pcy;
for (let x = 0; x < W; x++) {
const dxv = x - pcx;
const d = Math.sqrt(dxv * dxv + dyv * dyv);
const haze = Math.exp(-d * d / (W * W * 0.018)) * 34;
const sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 28;
const j3 = (y * W + x) * 4;
data[j3] = r0 + haze * (col.haze[0] / 255) + sh;
data[j3 + 1] = g0 + haze * (col.haze[1] / 255) + sh;
data[j3 + 2] = b0 + haze * (col.haze[2] / 255) + sh * 1.3;
}
}
}
function putc(px: number, py: number, c: RGB, alpha: number): void {
const { w: W, h: H } = sizeRef.current;
const img2 = img2Ref.current;
if (!img2 || px < 0 || py < 0 || px >= W || py >= H) return;
const data = img2.data;
const j3 = (py * W + px) * 4;
data[j3] += (c[0] - data[j3]) * alpha;
data[j3 + 1] += (c[1] - data[j3 + 1]) * alpha;
data[j3 + 2] += (c[2] - data[j3 + 2]) * alpha;
}
function cellColor(g: number, ag: number): RGB {
const col = colRef.current!;
const N = nRef.current;
const k = Math.min(1, g / (N * 0.38));
const base: RGB = [
lerp(col.ice[0], col.lilac[0], k),
lerp(col.ice[1], col.lilac[1], k),
lerp(col.ice[2], col.lilac[2], k),
];
if (ag < maxAgeRef.current) {
const b = Math.pow(1 - ag / maxAgeRef.current, 1.6);
base[0] += (col.glow[0] - base[0]) * (0.85 * b);
base[1] += (col.glow[1] - base[1]) * (0.85 * b);
base[2] += (col.glow[2] - base[2]) * (0.85 * b);
}
return base;
}
function draw(t: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const bg = bgRef.current;
const img2 = img2Ref.current;
if (!ctx || !col || !bg || !img2) return;
const { w: W, h: H } = sizeRef.current;
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
const data = bg.data, data2 = img2.data;
const f = 1 - Math.exp(-dt * 4.2);
for (let k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
const N = nRef.current;
const cellPx = cellPxRef.current;
const { cx, cy } = centerRef.current;
const ox = (W - N * cellPx) / 2, oy = (H - N * cellPx) / 2;
const rr = Math.max(1, Math.round(cellPx * 0.58));
const rr2 = rr * rr;
const grid = gridRef.current, age = ageRef.current;
for (let i = 0; i < N; i++) {
for (let j = 0; j < N; j++) {
const gi = gridIdx(i, j);
if (!grid[gi]) continue;
const colr = cellColor(Math.hypot(i - cx, (j - cy) * 0.92), t - age[gi]);
const px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + j * cellPx);
for (let dy = -rr; dy <= rr; dy++) {
for (let dx = -rr; dx <= rr; dx++) {
const dd = dx * dx + dy * dy;
if (dd > rr2) continue;
const px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
const j3 = (py2 * W + px2) * 4;
const a3 = 1 - dd / (rr2 + 1);
data2[j3] += (colr[0] - data2[j3]) * a3;
data2[j3 + 1] += (colr[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (colr[2] - data2[j3 + 2]) * a3;
}
}
}
}
const walk = walkRef.current;
for (let wi = 0; wi < walk.length; wi++) {
const p = walk[wi];
const px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + p[1] * cellPx);
const tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, col.mint, 0.95);
putc(px + 1, py, col.mint, 0.5 * tw);
putc(px - 1, py, col.mint, 0.5 * tw);
putc(px, py + 1, col.mint, 0.5 * tw);
putc(px, py - 1, col.mint, 0.9 * tw);
}
const snow = snowRef.current;
for (let si = 0; si < snow.length; si++) {
const s = snow[si];
const sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
const sy = (((s.y + s.v * t) % 1.15) - 0.07) * H;
const br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
const a4 = 0.55 + 0.4 * br;
const sr = Math.ceil(s.r);
for (let dy2 = -sr; dy2 <= sr; dy2++) {
for (let dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, col.mote, a4);
}
}
}
ctx.putImageData(img2, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
ctxRef.current = ctx;
const cellPx = Math.max(3, Math.min(5, Math.floor(Math.min(W, H) / 42)));
cellPxRef.current = cellPx;
let N = Math.floor(Math.min(W, H) / cellPx);
if ((N & 1) === 0) N -= 1;
nRef.current = N;
gridRef.current = new Uint8Array(N * N);
ageRef.current = new Float32Array(N * N);
walkRef.current = [];
stuckRef.current = 0;
rRef.current = 1;
const cx = N >> 1, cy = N >> 1;
centerRef.current = { cx, cy };
const bg = ctx.createImageData(W, H);
const img2 = ctx.createImageData(W, H);
for (let k = 3; k < bg.data.length; k += 4) { bg.data[k] = 255; img2.data[k] = 255; }
bgRef.current = bg;
img2Ref.current = img2;
plantSeed();
buildSnow();
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) step(0.01 * k);
draw(2.6, 0.016);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
step(t);
draw(t, dt);
setN(String(stuckRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
particlesRef.current = particles;
tipBiasRef.current = tipBias;
maxAgeRef.current = maxAge;
snowCountRef.current = snowCount;
radiusCapRef.current = radiusCap;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
deep: tokRGB(root, "--flake-deep"),
haze: tokRGB(root, "--flake-haze"),
mote: tokRGB(root, "--flake-mote"),
ice: tokRGB(root, "--flake-ice"),
glow: tokRGB(root, "--flake-glow"),
lilac: tokRGB(root, "--flake-lilac"),
mint: tokRGB(root, "--flake-mint"),
mist: tokRGB(root, "--flake-mist"),
};
randRef.current = makePRNG(0x319a02);
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
}, [particles, tipBias, maxAge, snowCount, radiusCap]);
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 · 가지 결정(dendrite) — 여섯 방향 팁이 촘촘히 갈라지는 나무 성장 — 측정 누적면적 44.129% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
팁 가중 흡착으로 가지를 뻗는 법
// ── 팁 가중 흡착 — 반경이 클수록(팁에 가까울수록) 흡착 확률을 높인다 ──
function stickProb(i, j) {
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
var k = Math.min(1, dc / Math.max(1, crystalR));
return Math.pow(k, 1) * 0.15 + Math.pow(k, TIP_BIAS) * 0.85 + 0.05;
}
칸이 중심에서 멀수록(k 가 1에 가까울수록) Math.pow(k, TIP_BIAS) 항이 커져 흡착 확률이 뛰고, TIP_BIAS 2.6이 이 편향의 세기입니다 — 소스 주석대로 이 값을 0으로 낮추면 01과 똑같은 뭉툭한 클러스터로 돌아갑니다.
03침·고사리 결정 — 습도 밴드를 바꾸면 나뭇잎에서 바늘로
같은 팁 가중 응집 규칙에 편향 지수 하나만 다르게 준 두 결정을 나란히 두면, 값이 낮은 쪽은 넓은 고사리형으로 값이 높은 쪽은 좁은 침형으로 갈라집니다 — 실측 누적면적 41.614% · 강도 평균 75.9 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>같은 응집 규칙에 파라미터 하나(가지 폭 확률)만 달리한 두 결정을 나란히 키우면, 넓은 고사리형 잎과 좁은 침형이 한 화면에서 갈라집니다 — 진짜 눈결정이 온도·습도 조건에 따라 모양이 갈리는 지도입니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="침·고사리 결정 대비 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">좌 <b class="cd__val" id="cdL">0</b> · 우 <b class="cd__val cd__val--alt" id="cdR">0</b>개 응집</span>
<span class="cd__meta cd__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 침·고사리 결정(03). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(좌우 두 DLA 동시 성장 + 잔광 + 진눈깨비)가 만든다.
// 01·02 와 같은 파스텔 팔레트에 우(침) 결정용 모래빛 한 색만 더한다(--flake-sand).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
$flake-stage: #171b24;
@include stage($flake-stage);
$flake-ink: #141821;
$flake-deep: #2b3346;
$flake-haze: #8fa8c9;
$flake-mote: #f2ded0;
$flake-ice: #fff9f0;
$flake-glow: #cfeaff;
$flake-lilac: #c9b6ff;
$flake-mint: #c8f4e4;
$flake-sand: #e6d9b8; // 우(침) 결정 바깥 — 모래빛 파스텔(채도 .18)
$flake-mist: #d8dee8;
.cd {
--flake-ink: #{$flake-ink};
--flake-deep: #{$flake-deep};
--flake-haze: #{$flake-haze};
--flake-mote: #{$flake-mote};
--flake-ice: #{$flake-ice};
--flake-glow: #{$flake-glow};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-sand: #{$flake-sand};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist;
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-lilac; font-weight: 700; }
.cd__val--alt { color: $flake-sand; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #171b24;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #141821;
--flake-deep: #2b3346;
--flake-haze: #8fa8c9;
--flake-mote: #f2ded0;
--flake-ice: #fff9f0;
--flake-glow: #cfeaff;
--flake-lilac: #c9b6ff;
--flake-mint: #c8f4e4;
--flake-sand: #e6d9b8;
--flake-mist: #d8dee8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d8dee8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #141821;
box-shadow: inset 0 0 0 1px rgba(216, 222, 232, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #c9b6ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 222, 232, 0.72);
}
.cd__val {
color: #c9b6ff;
font-weight: 700;
}
.cd__val--alt {
color: #e6d9b8;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 침·고사리 결정(03). 02 의 팁 가중 DLA 에서 흡착 확률 곡선의 "폭" 만 바꾸면
// 결정 습관이 갈라진다 —
// · 고사리(fernite, 좌): 폭 넓게(BIAS 1.6) — 팁과 오목한 자리의 흡착 차가 작아
// 가지가 옆으로도 붙으며 넓은 잎(고사리 무늬)으로 자란다.
// · 침(needle, 우): 폭 좁게(BIAS 4.6) — 바깥 팁만 붙고 안쪽은 전부 튕겨나가
// 가늘고 긴 바늘 다발로 자란다.
// 실제 눈결정의 형성 지도(온도·과포화도에 따라 판·침·고사리가 갈리는 Nakaya 다이어그램)의
// 축 하나를 파라미터 하나로 압축한 대비 시연이다. 두 결정은 같은 배경 위에서 동시에 자란다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var BIAS_FERN = 1.6; // 좌(고사리) 팁 편향 지수 — 낮을수록 폭이 넓고 잎이 무성
var BIAS_NEEDLE = 4.6; // 우(침) 팁 편향 지수 — 높을수록 가늘고 긴 바늘
var PARTICLES = 22; // 결정 하나당 동시 입자 수
var MAX_AGE = 0.55; // 방금 붙은 칸이 빛나는 시간(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d', { alpha: false });
var lOut = document.getElementById('cdL');
var rOut = document.getElementById('cdR');
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(0x319A03);
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('--flake-ink');
var C_DEEP = tokRGB('--flake-deep');
var C_HAZE = tokRGB('--flake-haze');
var C_MOTE = tokRGB('--flake-mote');
var C_ICE = tokRGB('--flake-ice');
var C_GLOW = tokRGB('--flake-glow');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_SAND = tokRGB('--flake-sand'); // 우(침) 결정 바깥 색 — 모래빛 파스텔
var C_MIST = tokRGB('--flake-mist');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, t0 = -1;
var bg, data, img2, data2;
// ── 결정 하나의 상태 — 좌우 두 개를 같은 구조로 ──
function makeCrystal(n, ox, bias, seed) {
return {
N: n, ox: ox, bias: bias,
grid: new Uint8Array(n * n),
age: new Float32Array(n * n),
walk: [],
rand: makeRand(seed),
stuck: 0, r: 1,
cx: n >> 1, cy: n >> 1
};
}
var L = null, R = null;
var snow = [];
var cellPx = 4;
function gridIdx(c, i, j) { return i * c.N + j; }
function inGrid(c, i, j) { return i >= 0 && j >= 0 && i < c.N && j < c.N; }
var DJ_EVEN = [0, -1, -1, 0, 0, 0];
var DJ_ODD = [0, 0, 0, 0, 1, 1];
var DI = [-1, -1, 0, 1, 0, -1];
function nbr(c, i, j, k) { return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])]; }
function setCell(c, i, j, t) { c.grid[gridIdx(c, i, j)] = 1; c.age[gridIdx(c, i, j)] = t; }
function plantSeed(c) {
var cx = c.cx, cy = c.cy;
setCell(c, cx, cy, 0); setCell(c, cx - 1, cy, 0); setCell(c, cx + 1, cy, 0);
setCell(c, cx, cy - 1, 0); setCell(c, cx, cy + 1, 0);
setCell(c, cx - 1, cy - 1, 0); setCell(c, cx + 1, cy + 1, 0);
c.stuck = 7;
}
function launch(c) {
var rnd = c.rand;
var a = rnd() * Math.PI * 2;
var r = c.r * 1.22 + 3;
var i = c.cx + Math.round(Math.cos(a) * r);
var j = c.cy + Math.round(Math.sin(a) * r);
if (!inGrid(c, i, j) || c.grid[gridIdx(c, i, j)]) return;
c.walk.push([i, j, Math.floor(rnd() * 6), rnd() * Math.PI * 2]);
}
function touching(c, i, j) {
for (var k = 0; k < 6; k++) {
var nb = nbr(c, i, j, k);
if (inGrid(c, nb[0], nb[1]) && c.grid[gridIdx(c, nb[0], nb[1])]) return true;
}
return false;
}
// 팁 가중 흡착 — bias 가 결정의 습관(폭)을 정한다
function stickProb(c, i, j) {
var dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
var k = Math.min(1, dc / Math.max(1, c.r));
return Math.pow(k, 1) * 0.15 + Math.pow(k, c.bias) * 0.85 + 0.05;
}
function step(c, t) {
var rnd = c.rand;
for (var s = 0; s < 20; s++) {
for (var w = c.walk.length - 1; w >= 0; w--) {
var p = c.walk[w];
if (rnd() < 0.25) p[2] = Math.floor(rnd() * 6);
var nb = nbr(c, p[0], p[1], p[2]);
var i = nb[0], j = nb[1];
if (!inGrid(c, i, j)) { c.walk.splice(w, 1); continue; }
if (rnd() < 0.2) p[3] += (rnd() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(c, i, j)) {
if (rnd() < stickProb(c, i, j)) {
setCell(c, i, j, t);
c.stuck++;
var dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
if (dc + 2 > c.r) c.r = Math.min(c.N * 0.46, dc + 2);
}
} else if (Math.hypot(i - c.cx, j - c.cy) > c.r * 1.7 + 8) {
c.walk.splice(w, 1);
}
}
while (c.walk.length < PARTICLES) launch(c);
}
}
function buildSnow() {
for (var i = 0; i < 60; i++) {
snow.push({
x: rand(), y: rand(),
r: 1.1 + rand() * 2.2,
v: 0.06 + rand() * 0.13,
sway: 0.013 + rand() * 0.032,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9
});
}
}
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 lerp(a, b, k) { return a + (b - a) * k; }
function drawBackdrop(t) {
var sc = 0.055;
for (var y = 0; y < H; y++) {
var u = y / H;
var r0 = C_INK[0] + (C_DEEP[0] - C_INK[0]) * u;
var g0 = C_INK[1] + (C_DEEP[1] - C_INK[1]) * u;
var b0 = C_INK[2] + (C_DEEP[2] - C_INK[2]) * u;
for (var x = 0; x < W; x++) {
var sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 16;
var j3 = (y * W + x) * 4;
data[j3] = r0 + sh;
data[j3 + 1] = g0 + sh;
data[j3 + 2] = b0 + sh * 1.3;
}
}
// 각 결정 주위 헤이즈 — 좌우 각각
var halves = [[L, W * 0.27], [R, W * 0.73]];
for (var h = 0; h < 2; h++) {
var c = halves[h][0], pcx = halves[h][1], pcy = H * 0.5;
for (var y2 = 0; y2 < H; y2 += 2) {
var dyv = y2 - pcy;
for (var x2 = Math.max(0, pcx - W * 0.28); x2 < Math.min(W, pcx + W * 0.28); x2 += 2) {
var dxv = x2 - pcx;
var d2 = dxv * dxv + dyv * dyv;
var haze = Math.exp(-d2 / (W * W * 0.008)) * 24;
for (var yy = y2; yy < Math.min(H, y2 + 2); yy++) {
for (var xx = x2; xx < Math.min(W, x2 + 2); xx++) {
var j4 = (yy * W + xx) * 4;
data[j4] += haze * (C_HAZE[0] / 255);
data[j4 + 1] += haze * (C_HAZE[1] / 255);
data[j4 + 2] += haze * (C_HAZE[2] / 255);
}
}
}
}
}
}
function putc(px, py, c, alpha) {
if (px < 0 || py < 0 || px >= W || py >= H) return;
var j3 = (py * W + px) * 4;
data2[j3] += (c[0] - data2[j3]) * alpha;
data2[j3 + 1] += (c[1] - data2[j3 + 1]) * alpha;
data2[j3 + 2] += (c[2] - data2[j3 + 2]) * alpha;
}
// 좌(고사리)는 화이트→라일락, 우(침)는 화이트→모래빛 — 색으로도 습관을 구분
function cellColor(c, g, ag, outer) {
var k = Math.min(1, g / (c.N * 0.38));
var base = [
lerp(C_ICE[0], outer[0], k),
lerp(C_ICE[1], outer[1], k),
lerp(C_ICE[2], outer[2], k)
];
if (ag < MAX_AGE) {
var b = Math.pow(1 - ag / MAX_AGE, 1.6);
base[0] += (C_GLOW[0] - base[0]) * (0.85 * b);
base[1] += (C_GLOW[1] - base[1]) * (0.85 * b);
base[2] += (C_GLOW[2] - base[2]) * (0.85 * b);
}
return base;
}
function drawCrystal(c, t, outer) {
var ox = c.ox, oy = (H - c.N * cellPx) / 2;
var rr = Math.max(1, Math.round(cellPx * 0.58));
var rr2 = rr * rr;
for (var i = 0; i < c.N; i++) {
for (var j = 0; j < c.N; j++) {
var gi = gridIdx(c, i, j);
if (!c.grid[gi]) continue;
var col = cellColor(c, Math.hypot(i - c.cx, (j - c.cy) * 0.92), t - c.age[gi], outer);
var px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + j * cellPx);
for (var dy = -rr; dy <= rr; dy++) {
for (var dx = -rr; dx <= rr; dx++) {
var dd = dx * dx + dy * dy;
if (dd > rr2) continue;
var px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
var j3 = (py2 * W + px2) * 4;
var a3 = 1 - dd / (rr2 + 1);
data2[j3] += (col[0] - data2[j3]) * a3;
data2[j3 + 1] += (col[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (col[2] - data2[j3 + 2]) * a3;
}
}
}
}
for (var wi = 0; wi < c.walk.length; wi++) {
var p = c.walk[wi];
var px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + p[1] * cellPx);
var tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, C_MINT, 0.95);
putc(px + 1, py, C_MINT, 0.5 * tw);
putc(px - 1, py, C_MINT, 0.5 * tw);
putc(px, py + 1, C_MINT, 0.5 * tw);
putc(px, py - 1, C_MINT, 0.9 * tw);
}
}
function draw(t, dt) {
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
var f = 1 - Math.exp(-dt * 4.2);
for (var k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
drawCrystal(L, t, C_LILAC);
drawCrystal(R, t, C_SAND);
for (var si = 0; si < snow.length; si++) {
var s = snow[si];
var sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
var sy = ((s.y + s.v * t) % 1.15 - 0.07) * H;
var br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
var a4 = 0.55 + 0.4 * br;
var sr = Math.ceil(s.r);
for (var dy2 = -sr; dy2 <= sr; dy2++) {
for (var dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, C_MOTE, a4);
}
}
}
// 가운데 경계 — 얇은 안개 라인(대비 구분)
var mx = Math.round(W / 2);
for (var y3 = 0; y3 < H; y3++) {
putc(mx, y3, C_MIST, 0.10 + 0.06 * Math.sin(t * 1.4 + y3 * 0.05));
}
ctx.putImageData(img2, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
ctx = cv.getContext('2d', { alpha: false });
cellPx = Math.max(2, Math.min(4, Math.floor(Math.min(W / 2, H) / 46)));
var N = Math.floor(Math.min(W / 2, H) / cellPx);
if ((N & 1) === 0) N -= 1;
L = makeCrystal(N, (W / 2 - N * cellPx) / 2, BIAS_FERN, 0x319B01);
R = makeCrystal(N, W / 2 + (W / 2 - N * cellPx) / 2, BIAS_NEEDLE, 0x319B02);
plantSeed(L); plantSeed(R);
snow = [];
bg = ctx.createImageData(W, H); data = bg.data;
img2 = ctx.createImageData(W, H); data2 = img2.data;
for (var k = 3; k < data.length; k += 4) { data[k] = 255; data2[k] = 255; }
buildSnow();
}
function stillFrame() {
for (var k = 0; k < 240; k++) { step(L, 0.01 * k); step(R, 0.01 * k); }
draw(2.4, 0.016);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
step(L, t); step(R, t);
draw(t, dt);
if (lOut) lOut.textContent = String(L.stuck);
if (rOut) rOut.textContent = String(R.stuck);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// NeedleFernStar — vanilla 판(run/319/demo/03-snowflake-needle-fern-star/index.html) 의 React 포트.
// 침·고사리 결정 — 같은 DLA 규칙에 팁 편향 지수(가지 폭 확률) 하나만 달리한 두 결정을
// 좌우로 나란히 동시에 키운다. 좌(bias 1.6)는 넓은 고사리 잎, 우(bias 4.6)는 가늘고 긴 침.
// 실제 눈결정 형성 지도(Nakaya 다이어그램)의 축 하나를 파라미터로 압축한 대비 시연.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NeedleFernStar.module.scss";
export type NeedleFernStarProps = {
duration?: string;
easing?: string;
color?: string;
biasFern?: number; // 좌(고사리) 팁 편향 지수 — 낮을수록 폭이 넓고 잎이 무성
biasNeedle?: number; // 우(침) 팁 편향 지수 — 높을수록 가늘고 긴 바늘
maxAge?: number; // 방금 붙은 칸이 빛나는 시간(초)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Particle = [number, number, number, number];
type Snow = { x: number; y: number; r: number; v: number; sway: number; ph: number; tw: number };
type Colors = { ink: RGB; deep: RGB; haze: RGB; mote: RGB; ice: RGB; glow: RGB; lilac: RGB; mint: RGB; sand: RGB; mist: RGB };
type Crystal = {
N: number; ox: number; bias: number;
grid: Uint8Array; age: Float32Array;
walk: Particle[];
rand: () => number;
stuck: number; r: number;
cx: number; cy: number;
};
const FIELD_LABEL = "침·고사리 결정 대비 실험판";
const DI = [-1, -1, 0, 1, 0, -1];
const DJ_EVEN = [0, -1, -1, 0, 0, 0];
const DJ_ODD = [0, 0, 0, 0, 1, 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 NeedleFernStar({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#c9b6ff",
biasFern = 1.6,
biasNeedle = 4.6,
maxAge = 0.55,
}: NeedleFernStarProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const biasFernRef = useRef(biasFern) as Box<number>;
const biasNeedleRef = useRef(biasNeedle) as Box<number>;
const maxAgeRef = useRef(maxAge) as Box<number>;
const randRef = useRef(makePRNG(0x319a03)) as Box<() => number>;
const lRef = useRef<Crystal | null>(null) as Box<Crystal | null>;
const rRef2 = useRef<Crystal | null>(null) as Box<Crystal | null>;
const cellPxRef = useRef(4) as Box<number>;
const snowRef = useRef<Snow[]>([]) as Box<Snow[]>;
const bgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const img2Ref = 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 [nl, setNl] = useState("0");
const [nr, setNr] = 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 makeCrystal(n: number, ox: number, bias: number, seed: number): Crystal {
return {
N: n, ox, bias,
grid: new Uint8Array(n * n),
age: new Float32Array(n * n),
walk: [],
rand: makePRNG(seed),
stuck: 0, r: 1,
cx: n >> 1, cy: n >> 1,
};
}
function gridIdx(c: Crystal, i: number, j: number): number { return i * c.N + j; }
function inGrid(c: Crystal, i: number, j: number): boolean {
return i >= 0 && j >= 0 && i < c.N && j < c.N;
}
function nbr(c: Crystal, i: number, j: number, k: number): [number, number] {
return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])];
}
function setCell(c: Crystal, i: number, j: number, t: number): void {
c.grid[gridIdx(c, i, j)] = 1;
c.age[gridIdx(c, i, j)] = t;
}
function plantSeed(c: Crystal): void {
const cx = c.cx, cy = c.cy;
setCell(c, cx, cy, 0); setCell(c, cx - 1, cy, 0); setCell(c, cx + 1, cy, 0);
setCell(c, cx, cy - 1, 0); setCell(c, cx, cy + 1, 0);
setCell(c, cx - 1, cy - 1, 0); setCell(c, cx + 1, cy + 1, 0);
c.stuck = 7;
}
function launch(c: Crystal): void {
const rnd = c.rand;
const a = rnd() * Math.PI * 2;
const r = c.r * 1.22 + 3;
const i = c.cx + Math.round(Math.cos(a) * r);
const j = c.cy + Math.round(Math.sin(a) * r);
if (!inGrid(c, i, j) || c.grid[gridIdx(c, i, j)]) return;
c.walk.push([i, j, Math.floor(rnd() * 6), rnd() * Math.PI * 2]);
}
function touching(c: Crystal, i: number, j: number): boolean {
for (let k = 0; k < 6; k++) {
const nb = nbr(c, i, j, k);
if (inGrid(c, nb[0], nb[1]) && c.grid[gridIdx(c, nb[0], nb[1])]) return true;
}
return false;
}
function stickProb(c: Crystal, i: number, j: number): number {
const dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
const k = Math.min(1, dc / Math.max(1, c.r));
return Math.pow(k, 1) * 0.15 + Math.pow(k, c.bias) * 0.85 + 0.05;
}
function step(c: Crystal, t: number): void {
const rnd = c.rand;
for (let s = 0; s < 20; s++) {
for (let w = c.walk.length - 1; w >= 0; w--) {
const p = c.walk[w];
if (rnd() < 0.25) p[2] = Math.floor(rnd() * 6);
const nb = nbr(c, p[0], p[1], p[2]);
const i = nb[0], j = nb[1];
if (!inGrid(c, i, j)) { c.walk.splice(w, 1); continue; }
if (rnd() < 0.2) p[3] += (rnd() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(c, i, j)) {
if (rnd() < stickProb(c, i, j)) {
setCell(c, i, j, t);
c.stuck++;
const dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
if (dc + 2 > c.r) c.r = Math.min(c.N * 0.46, dc + 2);
}
} else if (Math.hypot(i - c.cx, j - c.cy) > c.r * 1.7 + 8) {
c.walk.splice(w, 1);
}
}
while (c.walk.length < 22) launch(c);
}
}
function buildSnow(): void {
const rand = randRef.current;
const arr: Snow[] = [];
for (let i = 0; i < 60; i++) {
arr.push({
x: rand(), y: rand(),
r: 1.1 + rand() * 2.2,
v: 0.06 + rand() * 0.13,
sway: 0.013 + rand() * 0.032,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9,
});
}
snowRef.current = arr;
}
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 lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function drawBackdrop(t: number): void {
const col = colRef.current;
const bg = bgRef.current;
if (!col || !bg) return;
const { w: W, h: H } = sizeRef.current;
const data = bg.data;
const sc = 0.055;
for (let y = 0; y < H; y++) {
const u = y / H;
const r0 = col.ink[0] + (col.deep[0] - col.ink[0]) * u;
const g0 = col.ink[1] + (col.deep[1] - col.ink[1]) * u;
const b0 = col.ink[2] + (col.deep[2] - col.ink[2]) * u;
for (let x = 0; x < W; x++) {
const sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 16;
const j3 = (y * W + x) * 4;
data[j3] = r0 + sh;
data[j3 + 1] = g0 + sh;
data[j3 + 2] = b0 + sh * 1.3;
}
}
const halves: [Crystal | null, number][] = [[lRef.current, W * 0.27], [rRef2.current, W * 0.73]];
for (let h = 0; h < 2; h++) {
const c = halves[h][0];
if (!c) continue;
const pcx = halves[h][1], pcy = H * 0.5;
for (let y2 = 0; y2 < H; y2 += 2) {
const dyv = y2 - pcy;
for (let x2 = Math.max(0, pcx - W * 0.28); x2 < Math.min(W, pcx + W * 0.28); x2 += 2) {
const dxv = x2 - pcx;
const d2 = dxv * dxv + dyv * dyv;
const haze = Math.exp(-d2 / (W * W * 0.008)) * 24;
for (let yy = y2; yy < Math.min(H, y2 + 2); yy++) {
for (let xx = x2; xx < Math.min(W, x2 + 2); xx++) {
const j4 = (yy * W + xx) * 4;
data[j4] += haze * (col.haze[0] / 255);
data[j4 + 1] += haze * (col.haze[1] / 255);
data[j4 + 2] += haze * (col.haze[2] / 255);
}
}
}
}
}
}
function putc(px: number, py: number, c: RGB, alpha: number): void {
const { w: W, h: H } = sizeRef.current;
const img2 = img2Ref.current;
if (!img2 || px < 0 || py < 0 || px >= W || py >= H) return;
const data = img2.data;
const j3 = (py * W + px) * 4;
data[j3] += (c[0] - data[j3]) * alpha;
data[j3 + 1] += (c[1] - data[j3 + 1]) * alpha;
data[j3 + 2] += (c[2] - data[j3 + 2]) * alpha;
}
function cellColor(c: Crystal, g: number, ag: number, outer: RGB): RGB {
const col = colRef.current!;
const k = Math.min(1, g / (c.N * 0.38));
const base: RGB = [
lerp(col.ice[0], outer[0], k),
lerp(col.ice[1], outer[1], k),
lerp(col.ice[2], outer[2], k),
];
if (ag < maxAgeRef.current) {
const b = Math.pow(1 - ag / maxAgeRef.current, 1.6);
base[0] += (col.glow[0] - base[0]) * (0.85 * b);
base[1] += (col.glow[1] - base[1]) * (0.85 * b);
base[2] += (col.glow[2] - base[2]) * (0.85 * b);
}
return base;
}
function drawCrystal(c: Crystal, t: number, outer: RGB): void {
const { w: W, h: H } = sizeRef.current;
const cellPx = cellPxRef.current;
const ox = c.ox, oy = (H - c.N * cellPx) / 2;
const rr = Math.max(1, Math.round(cellPx * 0.58));
const rr2 = rr * rr;
for (let i = 0; i < c.N; i++) {
for (let j = 0; j < c.N; j++) {
const gi = gridIdx(c, i, j);
if (!c.grid[gi]) continue;
const colr = cellColor(c, Math.hypot(i - c.cx, (j - c.cy) * 0.92), t - c.age[gi], outer);
const px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + j * cellPx);
for (let dy = -rr; dy <= rr; dy++) {
for (let dx = -rr; dx <= rr; dx++) {
const dd = dx * dx + dy * dy;
if (dd > rr2) continue;
const px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
const j3 = (py2 * W + px2) * 4;
const a3 = 1 - dd / (rr2 + 1);
const data2 = img2Ref.current!.data;
data2[j3] += (colr[0] - data2[j3]) * a3;
data2[j3 + 1] += (colr[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (colr[2] - data2[j3 + 2]) * a3;
}
}
}
}
for (let wi = 0; wi < c.walk.length; wi++) {
const p = c.walk[wi];
const px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + p[1] * cellPx);
const tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, colRef.current!.mint, 0.95);
putc(px + 1, py, colRef.current!.mint, 0.5 * tw);
putc(px - 1, py, colRef.current!.mint, 0.5 * tw);
putc(px, py + 1, colRef.current!.mint, 0.5 * tw);
putc(px, py - 1, colRef.current!.mint, 0.9 * tw);
}
}
function draw(t: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const bg = bgRef.current;
const img2 = img2Ref.current;
if (!ctx || !col || !bg || !img2) return;
const { w: W, h: H } = sizeRef.current;
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
const data = bg.data, data2 = img2.data;
const f = 1 - Math.exp(-dt * 4.2);
for (let k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
drawCrystal(lRef.current!, t, col.lilac);
drawCrystal(rRef2.current!, t, col.sand);
const snow = snowRef.current;
for (let si = 0; si < snow.length; si++) {
const s = snow[si];
const sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
const sy = (((s.y + s.v * t) % 1.15) - 0.07) * H;
const br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
const a4 = 0.55 + 0.4 * br;
const sr = Math.ceil(s.r);
for (let dy2 = -sr; dy2 <= sr; dy2++) {
for (let dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, col.mote, a4);
}
}
}
const mx = Math.round(W / 2);
for (let y3 = 0; y3 < H; y3++) {
putc(mx, y3, col.mist, 0.1 + 0.06 * Math.sin(t * 1.4 + y3 * 0.05));
}
ctx.putImageData(img2, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
ctxRef.current = ctx;
const cellPx = Math.max(2, Math.min(4, Math.floor(Math.min(W / 2, H) / 46)));
cellPxRef.current = cellPx;
let N = Math.floor(Math.min(W / 2, H) / cellPx);
if ((N & 1) === 0) N -= 1;
lRef.current = makeCrystal(N, (W / 2 - N * cellPx) / 2, biasFernRef.current, 0x319b01);
rRef2.current = makeCrystal(N, W / 2 + (W / 2 - N * cellPx) / 2, biasNeedleRef.current, 0x319b02);
plantSeed(lRef.current);
plantSeed(rRef2.current);
const bg = ctx.createImageData(W, H);
const img2 = ctx.createImageData(W, H);
for (let k = 3; k < bg.data.length; k += 4) { bg.data[k] = 255; img2.data[k] = 255; }
bgRef.current = bg;
img2Ref.current = img2;
buildSnow();
}
function stillFrame(): void {
for (let k = 0; k < 240; k++) {
step(lRef.current!, 0.01 * k);
step(rRef2.current!, 0.01 * k);
}
draw(2.4, 0.016);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
step(lRef.current!, t);
step(rRef2.current!, t);
draw(t, dt);
setNl(String(lRef.current!.stuck));
setNr(String(rRef2.current!.stuck));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
biasFernRef.current = biasFern;
biasNeedleRef.current = biasNeedle;
maxAgeRef.current = maxAge;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
deep: tokRGB(root, "--flake-deep"),
haze: tokRGB(root, "--flake-haze"),
mote: tokRGB(root, "--flake-mote"),
ice: tokRGB(root, "--flake-ice"),
glow: tokRGB(root, "--flake-glow"),
lilac: tokRGB(root, "--flake-lilac"),
mint: tokRGB(root, "--flake-mint"),
sand: tokRGB(root, "--flake-sand"),
mist: tokRGB(root, "--flake-mist"),
};
randRef.current = makePRNG(0x319a03);
buildPerm();
fit();
setNl("0"); setNr("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
}, [biasFern, biasNeedle, maxAge]);
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}>{nl}</b> · 우 <b className={styles.valAlt}>{nr}</b>개 응집</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 · 침·고사리 결정 — 습도 밴드를 바꾸면 나뭇잎에서 바늘로 — 측정 누적면적 41.614% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
습도 편향 값 하나로 갈라지는 두 결정
// 팁 가중 흡착 — bias 가 결정의 습관(폭)을 정한다
function stickProb(c, i, j) {
var dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
var k = Math.min(1, dc / Math.max(1, c.r));
return Math.pow(k, 1) * 0.15 + Math.pow(k, c.bias) * 0.85 + 0.05;
}
02의 stickProb 와 거의 같은 함수지만 bias 를 결정 객체 c 마다 따로 받는 것이 다릅니다 — 왼쪽은 BIAS_FERN 1.6, 오른쪽은 BIAS_NEEDLE 4.6을 물려 같은 함수 하나가 두 습관을 동시에 그립니다.
04육중 대칭 거울 — 60도마다 접어 여섯 배 속도로 자라기
섹터 하나(60도 부채꼴)만 시뮬레이션하고 나머지 다섯을 회전 복사하면 계산량이 6분의 1이 되면서도 대칭이 어긋나지 않는 눈결정을 얻습니다 — 실측 누적면적 40.29% · 강도 평균 45.8 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="22" stroke-linecap="round"><path d="M128 28v200"/><path d="M45 76l166 104"/><path d="M211 76L45 180"/></g></svg>
<span>60도 부채꼴 섹터 하나만 시뮬레이션하고 나머지를 회전·거울 복사합니다 — 계산은 1/6, 대칭은 어긋나지 않는 육각 결정이 자랍니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="육중 대칭 결정 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">섹터 <b class="cd__val" id="cdSector"></b> · 도장 <b class="cd__val" id="cdCopies"></b> · 응집 <b class="cd__val" id="cdRate"></b>/초</span>
<span class="cd__meta cd__meta--dim">캔버스 · rotate · ImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 육중 대칭 거울(04). @keyframes 를 아예 두지 않는다 — 움직임은 전부 캔버스 rAF 루프
// (섹터 DLA 응집 + 회전·거울 도장 + 배경 오로라 맥동 + 부유 수증기)가 만든다.
// 무대는 한겨울 밤잉크. 파스텔 팔레트를 --flake-* 로 편다(편 319 공통 규격).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flake-ink: #20242e; // 한겨울 밤 무대 — 316 bolt 보다 푸른 잉크
$flake-dusk: #4a516e; // 슬레이트
$flake-core: #f4f0fa; // 결정 코어 — 서리 화이트
$flake-halo: #b8cce8; // 헤일로 아이스블루
$flake-mint: #c8e0d8; // 민트 보조
$flake-sky: #a4bcd8; // 스카이 보조
$flake-mist: #dce8f4; // 서리 안개
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-dusk: #{$flake-dusk};
--flake-core: #{$flake-core};
--flake-halo: #{$flake-halo};
--flake-mint: #{$flake-mint};
--flake-sky: #{$flake-sky};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist; // 잉크 무대 위 — 서리 톤 글자
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-halo; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-halo; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-halo; font-weight: 700; } // 잉크 위 아이스블루 라벨
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 자라난 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #20242e;
--flake-dusk: #4a516e;
--flake-core: #f4f0fa;
--flake-halo: #b8cce8;
--flake-mint: #c8e0d8;
--flake-sky: #a4bcd8;
--flake-mist: #dce8f4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #dce8f4;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b8cce8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #20242e;
box-shadow: inset 0 0 0 1px rgba(220, 232, 244, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b8cce8;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(220, 232, 244, 0.72);
}
.cd__val {
color: #b8cce8;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 육중 대칭 거울(04). 60도 섹터 하나에서만 DLA 랜덤워크를 돌린다. 입자가 섹터를 벗어나면
// 각도를 60도 주기로 접어(회전 접기) 안으로 되돌려 보내고, 붙은 셀은 화면에 그릴 때
// 여섯 방향 회전 + 거울(±각) 도장 12장으로 펼친다. 시뮬레이션 비용은 1/6 인데 대칭은
// 픽셀 하나 안 어긋난다 — 눈결정이 원래 가진 D6 대칭(회전 6 × 거울) 그대로.
// 화면은 매 프레임 배경 그라디언트 + 오로라 맥동 + 부유 입자 + 결정 도장을 ImageData 한 장으로 다시 쓴다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var SECTOR_DEG = 60; // 섹터 각(도) — 변형 "대칭 접기" 축(60 → 30, 도장 수는 360/각×2)
var SPS = 320; // 초당 응집 입자 수 — 변형 "여섯 배 속도" 축(90 → 320)
var N = 44; // 섹터 격자 반경(셀 수) — 셀이 커질수록 결정이 화면을 넓게 채운다
// ────────────────────────────────────────────────────
var SECTOR = SECTOR_DEG * Math.PI / 180;
var COPIES = Math.round(360 / SECTOR_DEG);
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
document.getElementById('cdSector').textContent = SECTOR_DEG + '°';
document.getElementById('cdCopies').textContent = String(COPIES * 2);
document.getElementById('cdRate').textContent = String(SPS);
// 시드 고정 PRNG — mulberry32
var SEED = 0x3190404;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 scss 의 --flake-* 커스텀 프로퍼티에서 받는다 — 파스텔 토큰이 scss 한 곳에
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('--flake-ink');
var C_DUSK = tokRGB('--flake-dusk');
var C_CORE = tokRGB('--flake-core');
var C_HALO = tokRGB('--flake-halo');
var C_MINT = tokRGB('--flake-mint');
var C_SKY = tokRGB('--flake-sky');
var C_MIST = tokRGB('--flake-mist');
// LUT — 나이 t(0=코어 쪽 오래된 셀, 1=막 붙은 팁)를 잉크→어스름→라일락→코어→흰끼 ramp 로
var lutR = new Uint8ClampedArray(256), lutG = new Uint8ClampedArray(256), lutB = new Uint8ClampedArray(256);
function buildLUT() {
var m = [0, 0.30, 0.60, 0.86, 1];
var cs = [
[C_INK[0], C_INK[1], C_INK[2]],
[C_INK[0] + (C_DUSK[0] - C_INK[0]) * 0.7, C_INK[1] + (C_DUSK[1] - C_INK[1]) * 0.7, C_INK[2] + (C_DUSK[2] - C_INK[2]) * 0.7],
[C_HALO[0], C_HALO[1], C_HALO[2]],
[C_CORE[0], C_CORE[1], C_CORE[2]],
[255, 253, 255]
];
for (var i = 0; i < 256; i++) {
var t = i / 255, k = 0;
while (k < 3 && t > m[k + 1]) k++;
var f = (t - m[k]) / (m[k + 1] - m[k] || 1);
lutR[i] = cs[k][0] + (cs[k + 1][0] - cs[k][0]) * f;
lutG[i] = cs[k][1] + (cs[k + 1][1] - cs[k][1]) * f;
lutB[i] = cs[k][2] + (cs[k + 1][2] - cs[k][2]) * f;
}
}
// ── 섹터 격자 — 꼭짓점(결정 중심)을 원점, +y 방향 ±30도 부채꼴 ──
var WS = 2 * N + 8, HS = N + 6, AX = N + 4, AY = 2;
var occ = new Uint8Array(WS * HS);
var cells = []; // 붙은 셀 [각도, 반경, 순서] 납작 배열 — 도장할 때 그대로 순회
var order = 0, maxR = 1.5;
var phase = 0; // 0 자라기 · 1 샤인(다 자란 뒤 반짝임) · 리셋
var shineT = 0;
var walkers = []; // {fx, fy} — 꼭짓점 기준 상대 좌표
var gRot = 0; // 결정 전체의 느린 자전(도 단위로 치면 분당 몇 바퀴)
var WALKERS = 42;
// 각도를 섹터(-SECTOR/2 .. SECTOR/2)로 60도 주기 접는다 — 회전 접기(C6). 걸음이 부채꼴을
// 나가도 접혀 안으로 되돌아오므로 시뮬레이션 공간이 섹터 하나로 끝난다.
function foldTh(th) {
var P = SECTOR, m = (th + P * 0.5) % P;
if (m < 0) m += P;
return m - P * 0.5;
}
// 상대 좌표 → 접힌 (각도, 반경)
function fold(fx, fy) {
var r = Math.sqrt(fx * fx + fy * fy);
var th = r < 1e-9 ? 0 : foldTh(Math.atan2(fx, fy));
return [th, r];
}
function cellOcc(fx, fy) {
var fo = fold(fx, fy);
if (fo[1] > N) return 0;
var gx = AX + Math.round(Math.sin(fo[0]) * fo[1]);
var gy = AY + Math.round(Math.cos(fo[0]) * fo[1]);
if (gx < 1 || gx >= WS - 1 || gy < 1 || gy >= HS - 1) return 0;
return occ[gy * WS + gx];
}
function respawn(w) {
var r0 = Math.min(maxR + 12, N - 1);
var a = (rand() * 2 - 1) * (SECTOR * 0.46);
w.fx = Math.sin(a) * r0;
w.fy = Math.cos(a) * r0;
}
var DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
function stepWalker(w) {
var d = (rand() * 4) | 0;
w.fx += DX[d]; w.fy += DY[d];
var fo = fold(w.fx, w.fy);
if (fo[1] > N) { respawn(w); return false; }
var gx = AX + Math.round(Math.sin(fo[0]) * fo[1]);
var gy = AY + Math.round(Math.cos(fo[0]) * fo[1]);
if (gx < 1 || gx >= WS - 1 || gy < 1 || gy >= HS - 1) { respawn(w); return false; }
var idx = gy * WS + gx;
if (occ[idx]) return false;
// 이웃 접촉 — 이웃도 접어서 본다(부채꼴 경계 너머 접촉을 놓치지 않게)
var nc = cellOcc(w.fx + 1, w.fy) + cellOcc(w.fx - 1, w.fy) + cellOcc(w.fx, w.fy + 1) + cellOcc(w.fx, w.fy - 1);
if (!nc) return false;
// 팁 쪽에 잘 붙게 — 이웃이 많은 자리(움푹한 곳)는 붙을 확률을 낮춘다(수지상 가지)
var p = nc === 1 ? 1.0 : nc === 2 ? 0.62 : nc === 3 ? 0.22 : 0.10;
if (rand() >= p) { respawn(w); return false; }
occ[idx] = 1;
cells.push(fo[0], fo[1], order);
order++;
if (fo[1] > maxR) maxR = fo[1];
respawn(w);
return true;
}
function resetSim() {
occ.fill(0);
cells = [];
order = 0;
maxR = 3;
phase = 0;
shineT = 0;
// 씨앗 — 꼭짓점에서 +y 로 세 셀(중심축)
for (var s = 1; s <= 3; s++) { occ[(AY + s) * WS + AX] = 1; cells.push(0, s, order); order++; }
walkers = [];
for (var i = 0; i < WALKERS; i++) { var w = { fx: 0, fy: 0 }; respawn(w); walkers.push(w); }
}
// ── 화면 ──
var W = 0, H = 0, img = null;
var motes = []; // 부유 수증기 — 결정 중심으로 흘러들며 먹이가 된다
var MOTES = 90;
function spawnMote(m, edge) {
var side = (rand() * 4) | 0;
if (side === 0) { m.x = rand() * W; m.y = edge ? -4 : rand() * H; }
else if (side === 1) { m.x = edge ? W + 4 : rand() * W; m.y = rand() * H; }
else if (side === 2) { m.x = rand() * W; m.y = edge ? H + 4 : rand() * H; }
else { m.x = edge ? -4 : rand() * W; m.y = rand() * H; }
m.c = rand();
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
img = ctx.createImageData(W, H);
var d = img.data;
for (var i = 3; i < d.length; i += 4) d[i] = 255;
prngState = SEED >>> 0;
gRot = 0;
motes = [];
for (var m = 0; m < MOTES; m++) { var mo = { x: 0, y: 0, c: 0 }; spawnMote(mo, false); motes.push(mo); }
resetSim();
}
function sim(dt) {
if (phase === 1) {
shineT += dt;
if (shineT > 950) resetSim();
return;
}
var quota = SPS * dt / 1000; // 이번 프레임에 붙일 입자 수(시간 비례)
var stuck = 0, steps = 0, maxSteps = (dt / 16.7) * WALKERS * 9;
while (stuck < quota && steps < maxSteps) {
for (var i = 0; i < walkers.length; i++) {
if (stepWalker(walkers[i])) stuck++;
steps++;
if (stuck >= quota || steps >= maxSteps) break;
}
}
if (maxR > N - 2) { phase = 1; shineT = 0; }
}
// 알파 블렌드 도장 — img 위에 직접(배경은 이미 그려져 있다)
function stamp(px, py, R, G, B, a) {
var x = px | 0, y = py | 0;
if (x < 0 || x >= W || y < 0 || y >= H) return;
var j = (y * W + x) << 2, d = img.data;
d[j] = d[j] * (1 - a) + R * a;
d[j + 1] = d[j + 1] * (1 - a) + G * a;
d[j + 2] = d[j + 2] * (1 - a) + B * a;
}
function stampSoft(px, py, R, G, B, a) {
// 5×5 옅은 헤일로 → 3×3 반투명 → 중심 진하게
var x = px | 0, y = py | 0;
for (var yy = y - 2; yy <= y + 2; yy++)
for (var xx = x - 2; xx <= x + 2; xx++)
stamp(xx, yy, C_HALO[0], C_HALO[1], C_HALO[2], a * 0.10);
for (var y2 = y - 1; y2 <= y + 1; y2++)
for (var x2 = x - 1; x2 <= x + 1; x2++)
stamp(x2, y2, R, G, B, a * 0.42);
stamp(x, y, R, G, B, Math.min(1, a));
}
var fr = 0;
function compose() {
var d = img.data;
// ① 배경 — 세로 그라디언트(잉크→어스름) + 움직이는 오로라 얼룩 네 개(결정 주위 수증기 빛)
var topR = C_INK[0] + 6, topG = C_INK[1] + 6, topB = C_INK[2] + 9;
var botR = C_INK[0] + (C_DUSK[0] - C_INK[0]) * 0.34, botG = C_INK[1] + (C_DUSK[1] - C_INK[1]) * 0.34, botB = C_INK[2] + (C_DUSK[2] - C_INK[2]) * 0.34;
var a1x = W * (0.18 + 0.10 * Math.sin(fr * 0.011)), a1y = H * (0.24 + 0.12 * Math.cos(fr * 0.013));
var a2x = W * (0.84 + 0.09 * Math.cos(fr * 0.009)), a2y = H * (0.28 + 0.10 * Math.sin(fr * 0.015));
var a3x = W * (0.24 + 0.08 * Math.cos(fr * 0.012 + 2)), a3y = H * (0.82 + 0.09 * Math.sin(fr * 0.010 + 1));
var a4x = W * (0.78 + 0.10 * Math.sin(fr * 0.008 + 4)), a4y = H * (0.78 + 0.11 * Math.cos(fr * 0.014 + 3));
var p1 = 0.085 + 0.050 * Math.sin(fr * 0.021);
var p2 = 0.075 + 0.045 * Math.cos(fr * 0.017);
var p3 = 0.070 + 0.040 * Math.sin(fr * 0.019 + 2);
var p4 = 0.080 + 0.048 * Math.cos(fr * 0.016 + 1);
var sig = 1 / (H * H * 0.55);
for (var y = 0; y < H; y++) {
var ty = y / H;
var br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
var row = y * W;
for (var x = 0; x < W; x++) {
var dx1 = x - a1x, dy1 = y - a1y, dx2 = x - a2x, dy2 = y - a2y;
var dx3 = x - a3x, dy3 = y - a3y, dx4 = x - a4x, dy4 = y - a4y;
var g = p1 * Math.exp(-(dx1 * dx1 + dy1 * dy1) * sig)
+ p2 * Math.exp(-(dx2 * dx2 + dy2 * dy2) * sig)
+ p3 * Math.exp(-(dx3 * dx3 + dy3 * dy3) * sig)
+ p4 * Math.exp(-(dx4 * dx4 + dy4 * dy4) * sig);
var j = (row + x) << 2;
d[j] = br + (C_HALO[0] - br) * g;
d[j + 1] = bg + (C_HALO[1] - bg) * g;
d[j + 2] = bb + (C_HALO[2] - bb) * g;
}
}
// ② 결정 도장 — 섹터 셀 하나를 회전 COPIES + 거울(±각) 2배 = COPIES*2 장으로 펼친다
var cx = W / 2, cy = H / 2;
var scale = (Math.min(W, H) / 2 - 8) / N;
var shineWave = phase === 1 ? shineT * 0.018 : 0;
for (var i = 0; i < cells.length; i += 3) {
var th = cells[i], r = cells[i + 1], q = order > 1 ? cells[i + 2] / order : 1;
var li = 0.40 + 0.60 * q; // 새 팁일수록 밝다
if (phase === 1) li += 0.30 * Math.sin(shineWave - r * 0.22); // 샤인 — 반경 파동
if (li < 0) li = 0; else if (li > 1) li = 1;
var ci = (li * 255) | 0;
var R = lutR[ci], G = lutG[ci], B = lutB[ci];
var rr = r * scale;
for (var k = 0; k < COPIES; k++) {
var base = gRot + k * SECTOR;
stampSoft(cx + rr * Math.sin(base + th), cy + rr * Math.cos(base + th), R, G, B, 0.85);
stampSoft(cx + rr * Math.sin(base - th), cy + rr * Math.cos(base - th), R, G, B, 0.85);
}
}
// ③ 부유 수증기 — 중심으로 살랑이며 흘러들어가는 먹이
for (var m = 0; m < motes.length; m++) {
var mo = motes[m];
var ddx = cx - mo.x, ddy = cy - mo.y, dd = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
var sp = 0.05 + mo.c * 0.05;
mo.x += (ddx / dd) * sp + (rand() - 0.5) * 1.5;
mo.y += (ddy / dd) * sp + (rand() - 0.5) * 1.5;
if (dd < 12) spawnMote(mo, true);
var mc = mo.c < 0.34 ? C_MINT : (mo.c < 0.67 ? C_SKY : C_MIST);
var ma = 0.34 + 0.22 * Math.sin(fr * 0.09 + m * 1.7);
stamp(mo.x, mo.y, mc[0], mc[1], mc[2], ma);
stamp(mo.x + 1, mo.y, mc[0], mc[1], mc[2], ma * 0.45);
stamp(mo.x, mo.y + 1, mc[0], mc[1], mc[2], ma * 0.45);
}
ctx.putImageData(img, 0, 0);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, prev = 0;
function frame(now) {
var dt = Math.min(50, now - (prev || now));
prev = now;
gRot += dt * 0.00022; // 느린 자전 — 결정 전체가 12도/초 로 돈다
sim(dt);
compose();
fr++;
raf = requestAnimationFrame(frame);
}
// reduce 용 한 장 — 1.4초 치 성장을 몰아 시뮬레이션하고 정지 화면 한 장만 남긴다
function stillDraw() {
fit();
for (var i = 0; i < 42; i++) sim(33);
gRot = 0.3;
compose();
}
window.addEventListener('resize', function () { if (reduce) stillDraw(); });
fit();
buildLUT();
if (reduce) {
stillDraw();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// SixFoldMirror — vanilla 판(run/319/demo/04-snowflake-six-fold-symmetry-mirror/index.html) 의 React 포트.
// 60도 섹터 하나에만 DLA 랜덤워크를 돌리고(각도를 60도 주기로 접어 부채꼴 밖을 안으로 되돌린다),
// 붙은 셀을 화면에 그릴 때 회전 COPIES + 거울(±각) 도장으로 펼친다 — 계산은 1/6, 대칭은 완벽.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SixFoldMirror.module.scss";
export type SixFoldMirrorProps = {
duration?: string;
easing?: string;
color?: string;
sectorDeg?: number; // 섹터 각(도) — 변형 "대칭 접기" 축(60 → 30)
sps?: number; // 초당 응집 입자 수 — 변형 "여섯 배 속도" 축(90 → 320)
n?: number; // 섹터 격자 반경(셀 수)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Walker = { fx: number; fy: number };
type Mote = { x: number; y: number; c: number };
type Colors = { ink: RGB; dusk: RGB; core: RGB; halo: RGB; mint: RGB; sky: RGB; mist: RGB };
const FIELD_LABEL = "육중 대칭 결정 실험판";
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 SixFoldMirror({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b8cce8",
sectorDeg = 60,
sps = 320,
n = 44,
}: SixFoldMirrorProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const lutRef = useRef<{ r: Uint8ClampedArray; g: Uint8ClampedArray; b: Uint8ClampedArray } | null>(null);
const randRef = useRef<() => number>(makePRNG(0x3190404)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [copiesShown] = useState(Math.round(360 / sectorDeg) * 2);
const [spsShown] = useState(sps);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// ── 섹터 상태(ref 로 보관 — rAF 루프에서만 갱신, 리렌더 유발 안 함) ──
const SECTOR = (sectorDeg * Math.PI) / 180;
const COPIES = Math.round(360 / sectorDeg);
const WS = 2 * n + 8, HS = n + 6, AX = n + 4, AY = 2;
const occRef = useRef<Uint8Array>(new Uint8Array(WS * HS)) as Box<Uint8Array>;
const cellsRef = useRef<number[]>([]) as Box<number[]>;
const orderRef = useRef(0) as Box<number>;
const maxRRef = useRef(1.5) as Box<number>;
const phaseRef = useRef(0) as Box<number>;
const shineTRef = useRef(0) as Box<number>;
const walkersRef = useRef<Walker[]>([]) as Box<Walker[]>;
const gRotRef = useRef(0) as Box<number>;
const frRef = useRef(0) as Box<number>;
const motesRef = useRef<Mote[]>([]) as Box<Mote[]>;
const WALKERS = 42, MOTES = 90;
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 buildLUT(col: Colors): void {
const r = new Uint8ClampedArray(256), g = new Uint8ClampedArray(256), b = new Uint8ClampedArray(256);
const m = [0, 0.30, 0.60, 0.86, 1];
const cs: RGB[] = [
col.ink,
[col.ink[0] + (col.dusk[0] - col.ink[0]) * 0.7, col.ink[1] + (col.dusk[1] - col.ink[1]) * 0.7, col.ink[2] + (col.dusk[2] - col.ink[2]) * 0.7],
col.halo,
col.core,
[255, 253, 255],
];
for (let i = 0; i < 256; i++) {
const t = i / 255;
let k = 0;
while (k < 3 && t > m[k + 1]) k++;
const f = (t - m[k]) / (m[k + 1] - m[k] || 1);
r[i] = cs[k][0] + (cs[k + 1][0] - cs[k][0]) * f;
g[i] = cs[k][1] + (cs[k + 1][1] - cs[k][1]) * f;
b[i] = cs[k][2] + (cs[k + 1][2] - cs[k][2]) * f;
}
lutRef.current = { r, g, b };
}
function foldTh(th: number): number {
const P = SECTOR;
let m = (th + P * 0.5) % P;
if (m < 0) m += P;
return m - P * 0.5;
}
function fold(fx: number, fy: number): [number, number] {
const r = Math.sqrt(fx * fx + fy * fy);
const th = r < 1e-9 ? 0 : foldTh(Math.atan2(fx, fy));
return [th, r];
}
function cellOcc(fx: number, fy: number): number {
const occ = occRef.current;
const fo = fold(fx, fy);
if (fo[1] > n) return 0;
const gx = AX + Math.round(Math.sin(fo[0]) * fo[1]);
const gy = AY + Math.round(Math.cos(fo[0]) * fo[1]);
if (gx < 1 || gx >= WS - 1 || gy < 1 || gy >= HS - 1) return 0;
return occ[gy * WS + gx];
}
function respawn(w: Walker): void {
const rand = randRef.current;
const r0 = Math.min(maxRRef.current + 12, n - 1);
const a = (rand() * 2 - 1) * (SECTOR * 0.46);
w.fx = Math.sin(a) * r0;
w.fy = Math.cos(a) * r0;
}
const DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
function stepWalker(w: Walker): boolean {
const rand = randRef.current;
const occ = occRef.current;
const d = (rand() * 4) | 0;
w.fx += DX[d]; w.fy += DY[d];
const fo = fold(w.fx, w.fy);
if (fo[1] > n) { respawn(w); return false; }
const gx = AX + Math.round(Math.sin(fo[0]) * fo[1]);
const gy = AY + Math.round(Math.cos(fo[0]) * fo[1]);
if (gx < 1 || gx >= WS - 1 || gy < 1 || gy >= HS - 1) { respawn(w); return false; }
const idx = gy * WS + gx;
if (occ[idx]) return false;
const nc = cellOcc(w.fx + 1, w.fy) + cellOcc(w.fx - 1, w.fy) + cellOcc(w.fx, w.fy + 1) + cellOcc(w.fx, w.fy - 1);
if (!nc) return false;
const p = nc === 1 ? 1.0 : nc === 2 ? 0.62 : nc === 3 ? 0.22 : 0.10;
if (rand() >= p) { respawn(w); return false; }
occ[idx] = 1;
cellsRef.current.push(fo[0], fo[1], orderRef.current);
orderRef.current++;
if (fo[1] > maxRRef.current) maxRRef.current = fo[1];
respawn(w);
return true;
}
function resetSim(): void {
occRef.current.fill(0);
cellsRef.current = [];
orderRef.current = 0;
maxRRef.current = 3;
phaseRef.current = 0;
shineTRef.current = 0;
for (let s = 1; s <= 3; s++) {
occRef.current[(AY + s) * WS + AX] = 1;
cellsRef.current.push(0, s, orderRef.current);
orderRef.current++;
}
const ws: Walker[] = [];
for (let i = 0; i < WALKERS; i++) { const w: Walker = { fx: 0, fy: 0 }; respawn(w); ws.push(w); }
walkersRef.current = ws;
}
function spawnMote(m: Mote, edge: boolean): void {
const rand = randRef.current;
const { w: W, h: H } = sizeRef.current;
const side = (rand() * 4) | 0;
if (side === 0) { m.x = rand() * W; m.y = edge ? -4 : rand() * H; }
else if (side === 1) { m.x = edge ? W + 4 : rand() * W; m.y = rand() * H; }
else if (side === 2) { m.x = rand() * W; m.y = edge ? H + 4 : rand() * H; }
else { m.x = edge ? -4 : rand() * W; m.y = rand() * H; }
m.c = rand();
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
const W = Math.max(2, Math.round(r.width));
const H = Math.max(2, Math.round(r.height));
sizeRef.current.w = W;
sizeRef.current.h = H;
cv.width = W; cv.height = H;
ctxRef.current = ctx;
const img = ctx.createImageData(W, H);
const d = img.data;
for (let i = 3; i < d.length; i += 4) d[i] = 255;
imgRef.current = img;
randRef.current = makePRNG(0x3190404);
gRotRef.current = 0;
frRef.current = 0;
const ms: Mote[] = [];
for (let m = 0; m < MOTES; m++) { const mo: Mote = { x: 0, y: 0, c: 0 }; spawnMote(mo, false); ms.push(mo); }
motesRef.current = ms;
resetSim();
}
function sim(dt: number): void {
if (phaseRef.current === 1) {
shineTRef.current += dt;
if (shineTRef.current > 950) resetSim();
return;
}
const quota = (sps * dt) / 1000;
let stuck = 0, steps = 0;
const maxSteps = (dt / 16.7) * WALKERS * 9;
const walkers = walkersRef.current;
while (stuck < quota && steps < maxSteps) {
for (let i = 0; i < walkers.length; i++) {
if (stepWalker(walkers[i])) stuck++;
steps++;
if (stuck >= quota || steps >= maxSteps) break;
}
}
if (maxRRef.current > n - 2) { phaseRef.current = 1; shineTRef.current = 0; }
}
function stamp(px: number, py: number, R: number, G: number, B: number, a: number): void {
const img = imgRef.current;
const { w: W, h: H } = sizeRef.current;
if (!img) return;
const x = px | 0, y = py | 0;
if (x < 0 || x >= W || y < 0 || y >= H) return;
const j = (y * W + x) << 2, d = img.data;
d[j] = d[j] * (1 - a) + R * a;
d[j + 1] = d[j + 1] * (1 - a) + G * a;
d[j + 2] = d[j + 2] * (1 - a) + B * a;
}
function stampSoft(px: number, py: number, R: number, G: number, B: number, a: number): void {
const col = colRef.current;
if (!col) return;
const x = px | 0, y = py | 0;
for (let yy = y - 2; yy <= y + 2; yy++)
for (let xx = x - 2; xx <= x + 2; xx++)
stamp(xx, yy, col.halo[0], col.halo[1], col.halo[2], a * 0.10);
for (let y2 = y - 1; y2 <= y + 1; y2++)
for (let x2 = x - 1; x2 <= x + 1; x2++)
stamp(x2, y2, R, G, B, a * 0.42);
stamp(x, y, R, G, B, Math.min(1, a));
}
function compose(): void {
const ctx = ctxRef.current;
const img = imgRef.current;
const col = colRef.current;
const lut = lutRef.current;
const { w: W, h: H } = sizeRef.current;
if (!ctx || !img || !col || !lut) return;
const d = img.data;
const fr = frRef.current;
const topR = col.ink[0] + 6, topG = col.ink[1] + 6, topB = col.ink[2] + 9;
const botR = col.ink[0] + (col.dusk[0] - col.ink[0]) * 0.34, botG = col.ink[1] + (col.dusk[1] - col.ink[1]) * 0.34, botB = col.ink[2] + (col.dusk[2] - col.ink[2]) * 0.34;
const a1x = W * (0.18 + 0.10 * Math.sin(fr * 0.011)), a1y = H * (0.24 + 0.12 * Math.cos(fr * 0.013));
const a2x = W * (0.84 + 0.09 * Math.cos(fr * 0.009)), a2y = H * (0.28 + 0.10 * Math.sin(fr * 0.015));
const a3x = W * (0.24 + 0.08 * Math.cos(fr * 0.012 + 2)), a3y = H * (0.82 + 0.09 * Math.sin(fr * 0.010 + 1));
const a4x = W * (0.78 + 0.10 * Math.sin(fr * 0.008 + 4)), a4y = H * (0.78 + 0.11 * Math.cos(fr * 0.014 + 3));
const p1 = 0.085 + 0.050 * Math.sin(fr * 0.021);
const p2 = 0.075 + 0.045 * Math.cos(fr * 0.017);
const p3 = 0.070 + 0.040 * Math.sin(fr * 0.019 + 2);
const p4 = 0.080 + 0.048 * Math.cos(fr * 0.016 + 1);
const sig = 1 / (H * H * 0.55);
for (let y = 0; y < H; y++) {
const ty = y / H;
const br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
const row = y * W;
for (let x = 0; x < W; x++) {
const dx1 = x - a1x, dy1 = y - a1y, dx2 = x - a2x, dy2 = y - a2y;
const dx3 = x - a3x, dy3 = y - a3y, dx4 = x - a4x, dy4 = y - a4y;
const g = p1 * Math.exp(-(dx1 * dx1 + dy1 * dy1) * sig)
+ p2 * Math.exp(-(dx2 * dx2 + dy2 * dy2) * sig)
+ p3 * Math.exp(-(dx3 * dx3 + dy3 * dy3) * sig)
+ p4 * Math.exp(-(dx4 * dx4 + dy4 * dy4) * sig);
const j = (row + x) << 2;
d[j] = br + (col.halo[0] - br) * g;
d[j + 1] = bg + (col.halo[1] - bg) * g;
d[j + 2] = bb + (col.halo[2] - bb) * g;
}
}
const cx = W / 2, cy = H / 2;
const scale = (Math.min(W, H) / 2 - 8) / n;
const shineWave = phaseRef.current === 1 ? shineTRef.current * 0.018 : 0;
const cells = cellsRef.current;
const order = orderRef.current;
const gRot = gRotRef.current;
for (let i = 0; i < cells.length; i += 3) {
const th = cells[i], r = cells[i + 1], q = order > 1 ? cells[i + 2] / order : 1;
let li = 0.40 + 0.60 * q;
if (phaseRef.current === 1) li += 0.30 * Math.sin(shineWave - r * 0.22);
if (li < 0) li = 0; else if (li > 1) li = 1;
const ci = (li * 255) | 0;
const R = lut.r[ci], G = lut.g[ci], B = lut.b[ci];
const rr = r * scale;
for (let k = 0; k < COPIES; k++) {
const base = gRot + k * SECTOR;
stampSoft(cx + rr * Math.sin(base + th), cy + rr * Math.cos(base + th), R, G, B, 0.85);
stampSoft(cx + rr * Math.sin(base - th), cy + rr * Math.cos(base - th), R, G, B, 0.85);
}
}
const rand = randRef.current;
const motes = motesRef.current;
for (let m = 0; m < motes.length; m++) {
const mo = motes[m];
const ddx = cx - mo.x, ddy = cy - mo.y, dd = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
const sp = 0.05 + mo.c * 0.05;
mo.x += (ddx / dd) * sp + (rand() - 0.5) * 1.5;
mo.y += (ddy / dd) * sp + (rand() - 0.5) * 1.5;
if (dd < 12) spawnMote(mo, true);
const mc = mo.c < 0.34 ? col.mint : mo.c < 0.67 ? col.sky : col.mist;
const ma = 0.34 + 0.22 * Math.sin(fr * 0.09 + m * 1.7);
stamp(mo.x, mo.y, mc[0], mc[1], mc[2], ma);
stamp(mo.x + 1, mo.y, mc[0], mc[1], mc[2], ma * 0.45);
stamp(mo.x, mo.y + 1, mc[0], mc[1], mc[2], ma * 0.45);
}
ctx.putImageData(img, 0, 0);
}
let prev = 0;
function frame(now: number): void {
const dt = Math.min(50, now - (prev || now));
prev = now;
gRotRef.current += dt * 0.00022;
sim(dt);
compose();
frRef.current++;
rafRef.current = requestAnimationFrame(frame);
}
function stillDraw(): void {
fit();
for (let i = 0; i < 42; i++) sim(33);
gRotRef.current = 0.3;
compose();
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
dusk: tokRGB(root, "--flake-dusk"),
core: tokRGB(root, "--flake-core"),
halo: tokRGB(root, "--flake-halo"),
mint: tokRGB(root, "--flake-mint"),
sky: tokRGB(root, "--flake-sky"),
mist: tokRGB(root, "--flake-mist"),
};
fit();
buildLUT(colRef.current);
function onResize(): void {
fit();
if (colRef.current) buildLUT(colRef.current);
if (reduced()) stillDraw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillDraw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectorDeg, sps, n]);
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}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" strokeWidth={22} strokeLinecap="round"><path d="M128 28v200"/><path d="M45 76l166 104"/><path d="M211 76L45 180"/></g></svg>
<span>60도 부채꼴 섹터 하나만 시뮬레이션하고 나머지를 회전·거울 복사합니다 — 계산은 1/6, 대칭은 어긋나지 않는 육각 결정이 자랍니다</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}>{sectorDeg}°</b> · 도장 <b className={styles.val}>{copiesShown}</b> · 응집 <b className={styles.val}>{spsShown}</b>/초</span>
<span className={styles.metaDim}>캔버스 · rotate · ImageData</span>
</div>
</div>
</main>
);
}04 · 육중 대칭 거울 — 60도마다 접어 여섯 배 속도로 자라기 — 측정 누적면적 40.29% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
섹터 하나만 걷고 나머지를 접어서 그리는 법
// 각도를 섹터(-SECTOR/2 .. SECTOR/2)로 60도 주기 접는다 — 회전 접기(C6). 걸음이 부채꼴을
// 나가도 접혀 안으로 되돌아오므로 시뮬레이션 공간이 섹터 하나로 끝난다.
function foldTh(th) {
var P = SECTOR, m = (th + P * 0.5) % P;
if (m < 0) m += P;
return m - P * 0.5;
}
// 상대 좌표 → 접힌 (각도, 반경)
function fold(fx, fy) {
var r = Math.sqrt(fx * fx + fy * fy);
var th = r < 1e-9 ? 0 : foldTh(Math.atan2(fx, fy));
return [th, r];
}
걸음이 섹터 경계를 넘으면 foldTh 가 그 각도를 곧바로 되접어 시뮬레이션 공간을 섹터 하나로 가두고, cellOcc 는 fold() 로 되접은 좌표를 다시 조회해 점유 여부를 판정합니다. COPIES(Math.round(360 / SECTOR_DEG))가 접힌 섹터를 몇 번 회전 복사할지 정합니다.
05서리 낀 창 — 가장자리에서 안쪽으로 기어드는 프랙탈 얼음
씨앗을 중앙이 아니라 창틀 가장자리 전체에 깔고 온도 필드를 안쪽으로 낮추면, 응집이 창가 프랙탈 무늬처럼 가장자리에서 안쪽으로 뻗어갑니다 — 실측 누적면적 45.515% · 강도 평균 51.6 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="18" d="M20 20h216v216H20z"/><path fill="none" stroke="currentColor" stroke-width="14" stroke-linecap="round" d="M40 40l60 60M216 40l-60 60M40 216l60-60M216 216l-60-60M128 20v70M128 166v70M20 128h70M166 128h70"/></svg>
<span>씨앗을 창틀 가장자리 전체에 깔고 온도 필드를 안쪽으로 낮추면, 응집이 창가 서리처럼 가장자리에서 안쪽으로 프랙탈 무늬로 뻗어갑니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="서리 낀 창 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">격자 <b class="cd__val" id="cdGrid"></b> · 응집 <b class="cd__val" id="cdRate"></b>/초</span>
<span class="cd__meta cd__meta--dim">캔버스 · ImageData · putImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 서리 낀 창(05). @keyframes 를 아예 두지 않는다 — 움직임은 전부 캔버스 rAF 루프
// (창틀 씨앗 DLA 응집 + 온도 필드 확률 + 격자 확대 putImageData)가 만든다.
// 무대는 한겨울 밤잉크. 파스텔 팔레트는 편 319 공통 --flake-* (04 와 동일 값 — 편마다 다시 정하지 않는다).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(04 와 동일) ──
$flake-ink: #20242e;
$flake-dusk: #4a516e;
$flake-core: #f4f0fa;
$flake-halo: #b8cce8;
$flake-mint: #c8e0d8;
$flake-sky: #a4bcd8;
$flake-mist: #dce8f4;
.cd {
--flake-ink: #{$flake-ink};
--flake-dusk: #{$flake-dusk};
--flake-core: #{$flake-core};
--flake-halo: #{$flake-halo};
--flake-mint: #{$flake-mint};
--flake-sky: #{$flake-sky};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-halo; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-halo; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-halo; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #20242e;
--flake-dusk: #4a516e;
--flake-core: #f4f0fa;
--flake-halo: #b8cce8;
--flake-mint: #c8e0d8;
--flake-sky: #a4bcd8;
--flake-mist: #dce8f4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #dce8f4;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b8cce8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #20242e;
box-shadow: inset 0 0 0 1px rgba(220, 232, 244, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b8cce8;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(220, 232, 244, 0.72);
}
.cd__val {
color: #b8cce8;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 서리 낀 창(05). 창틀 네 변 전체를 씨앗으로 깔고, 온도 필드(가장자리=따뜻해 잘 얼고 →
// 중심=차가워 얼기 어려운 역방향 그라디언트)를 반영한 확률로 DLA 랜덤워크를 돌린다.
// 워커는 아직 얼지 않은 안쪽 어디서나 출발해 사방으로 걷다가 이미 언 이웃에 닿으면
// (이웃 수·온도로 정해지는 확률로) 얼어붙는다 — 서리가 유리창 가장자리에서 안으로
// 기어드는 진짜 모양이다. 격자(저해상도) 점유를 ImageData 한 장으로 그려 확대해 붙인다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var GW = 72, GH = 45; // 격자(480×300 과 같은 8:5 비율) — 커질수록 서리 결이 촘촘해진다
var SPS = 2600; // 초당 응집 셀 수 — 변형 "번지는 속도" 축(1400 → 3600)
var COLD = 0.34; // 중심 냉기 계수 — 클수록 중심까지 다 얼기 전에 가지가 가늘어진다
var BIAS = 0.55; // 온도 필드 드리프트 — 걸음이 가장 가까운 창틀 쪽으로 쏠리는 비율(0.55 = 절반 조금 넘게)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
document.getElementById('cdGrid').textContent = GW + '×' + GH;
document.getElementById('cdRate').textContent = String(SPS);
var SEED = 0x3190405;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
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('--flake-ink');
var C_DUSK = tokRGB('--flake-dusk');
var C_CORE = tokRGB('--flake-core');
var C_HALO = tokRGB('--flake-halo');
var C_MIST = tokRGB('--flake-mist');
// ── 격자 점유 — 네 변 전체가 씨앗, 중심 냉기 필드에 따라 안쪽으로 자란다 ──
var occ = new Uint8Array(GW * GH);
var noise = new Float32Array(GW * GH); // 셀마다 고정 반짝임 계수(정적 프리컴퓨트) — 얼음 결 질감
var cx0 = (GW - 1) / 2, cy0 = (GH - 1) / 2;
var maxDist = Math.sqrt(cx0 * cx0 + cy0 * cy0);
var walkers = [];
var WALKERS = 60;
var filled = 0, TOTAL = GW * GH;
var phase = 0, shineT = 0;
function idx(gx, gy) { return gy * GW + gx; }
function edgeDist(gx, gy) { return Math.min(gx, gy, GW - 1 - gx, GH - 1 - gy); }
function respawn(w) {
var tries = 0;
do {
w.gx = 1 + ((rand() * (GW - 2)) | 0);
w.gy = 1 + ((rand() * (GH - 2)) | 0);
tries++;
} while (occ[idx(w.gx, w.gy)] && tries < 40);
}
var DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
// 방향 index: 0=+x(DX[0]=1) · 1=-x(DX[1]=-1) · 2=+y(DY[2]=1) · 3=-y(DY[3]=-1)
function stepWalker(w) {
var d;
if (rand() < BIAS) {
// 온도 필드 드리프트 — 가장 가까운 창틀로 한 걸음(방향 index: 0=+x,1=-x,2=+y,3=-y)
var dl = w.gx, dr = GW - 1 - w.gx, dt = w.gy, db = GH - 1 - w.gy;
var m = Math.min(dl, dr, dt, db);
d = m === dl ? 1 : m === dr ? 0 : m === dt ? 3 : 2;
} else {
d = (rand() * 4) | 0; // 무작위 걸음 — 이게 없으면 직선으로만 얼어 가지가 안 갈라진다
}
var nx = w.gx + DX[d], ny = w.gy + DY[d];
if (nx < 0 || nx >= GW || ny < 0 || ny >= GH) { respawn(w); return false; }
w.gx = nx; w.gy = ny;
var i = idx(nx, ny);
if (occ[i]) return false;
var nc = (nx > 0 && occ[idx(nx - 1, ny)] ? 1 : 0) + (nx < GW - 1 && occ[idx(nx + 1, ny)] ? 1 : 0) +
(ny > 0 && occ[idx(nx, ny - 1)] ? 1 : 0) + (ny < GH - 1 && occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// 온도 필드 — 중심에 가까울수록(dist 작을수록) 차가워 얼기 어렵다(가지가 가늘어진다)
var dist = Math.min(1, edgeDist(nx, ny) / maxDist);
var temp = 1 - dist * COLD;
var p = (nc === 1 ? 1.0 : nc === 2 ? 0.58 : nc === 3 ? 0.24 : 0.12) * temp;
if (rand() >= p) return false;
occ[i] = 1;
filled++;
respawn(w);
return true;
}
function resetSim() {
occ.fill(0);
for (var x = 0; x < GW; x++) { occ[idx(x, 0)] = 1; occ[idx(x, GH - 1)] = 1; }
for (var y = 0; y < GH; y++) { occ[idx(0, y)] = 1; occ[idx(GW - 1, y)] = 1; }
filled = 2 * (GW + GH) - 4;
phase = 0; shineT = 0;
walkers = [];
for (var i = 0; i < WALKERS; i++) { var w = { gx: 1, gy: 1 }; respawn(w); walkers.push(w); }
}
for (var ni = 0; ni < noise.length; ni++) noise[ni] = rand();
resetSim();
function sim(dt) {
if (phase === 1) {
shineT += dt;
if (shineT > 650) resetSim();
return;
}
var quota = SPS * dt / 1000;
var stuck = 0, steps = 0, maxSteps = 400000; // 순수 배열 연산이라 프레임당 이 정도는 가볍다
var t0 = performance.now(), budgetMs = 9; // 시간 예산으로 자른다 — 워커 수 공식보다 실측이 정확하다
while (stuck < quota && steps < maxSteps) {
for (var i = 0; i < walkers.length; i++) {
if (stepWalker(walkers[i])) stuck++;
steps++;
if (stuck >= quota) break;
}
if ((steps & 2047) === 0 && performance.now() - t0 > budgetMs) break;
}
if (filled > TOTAL * 0.62) { phase = 1; shineT = 0; } // 다 자라면 잠깐 반짝이고 리셋
}
var W = 0, H = 0, img = null, gimg = null, gctx = null, gcanvas = null;
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
img = ctx.createImageData(W, H);
var d = img.data;
for (var i = 3; i < d.length; i += 4) d[i] = 255;
gcanvas = document.createElement('canvas');
gcanvas.width = GW; gcanvas.height = GH;
gctx = gcanvas.getContext('2d');
gimg = gctx.createImageData(GW, GH);
var gd = gimg.data;
for (var j = 3; j < gd.length; j += 4) gd[j] = 255;
prngState = SEED >>> 0;
resetSim();
}
// 격자 점유 → 작은 캔버스에 ImageData 로 그리고, 큰 캔버스에 확대해 putImageData 결과를 그린다
function drawGrid() {
var gd = gimg.data;
for (var gy = 0; gy < GH; gy++) {
for (var gx = 0; gx < GW; gx++) {
var i = gy * GW + gx, j = i << 2;
if (!occ[i]) { gd[j] = C_INK[0]; gd[j + 1] = C_INK[1]; gd[j + 2] = C_INK[2]; continue; }
var dist = Math.min(1, edgeDist(gx, gy) / maxDist);
var wave = Math.sin(fr * 0.11 - dist * 5.2 + noise[i] * 6.283); // 결정을 타고 도는 결빛 파동(나무결 반짝임) — 여러 파가 겹쳐 자리마다 다른 박자로 밝아진다
var sparkle = 0.36 + 0.28 * noise[i] + 0.36 * (0.5 + 0.5 * wave);
var t = Math.min(1, dist * 1.15) * sparkle;
var r0 = C_HALO[0] + (C_CORE[0] - C_HALO[0]) * t, g0 = C_HALO[1] + (C_CORE[1] - C_HALO[1]) * t, b0 = C_HALO[2] + (C_CORE[2] - C_HALO[2]) * t;
gd[j] = r0; gd[j + 1] = g0; gd[j + 2] = b0;
}
}
gctx.putImageData(gimg, 0, 0);
}
var fr = 0;
function compose(now) {
var d = img.data;
// ① 유리창 배경 — 세로 그라디언트 + 숨쉬는 성에 안개(가장자리 밝게)
var topR = C_INK[0] + 4, topG = C_INK[1] + 4, topB = C_INK[2] + 8;
var botR = C_INK[0] + (C_DUSK[0] - C_INK[0]) * 0.30, botG = C_INK[1] + (C_DUSK[1] - C_INK[1]) * 0.30, botB = C_INK[2] + (C_DUSK[2] - C_INK[2]) * 0.30;
var breathe = 0.5 + 0.5 * Math.sin(fr * 0.05);
for (var y = 0; y < H; y++) {
var ty = y / H;
var br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
var edgeY = Math.min(y, H - 1 - y) / (H * 0.5);
var row = y * W;
for (var x = 0; x < W; x++) {
var edgeX = Math.min(x, W - 1 - x) / (W * 0.5);
var edge = 1 - Math.min(edgeX, edgeY);
var vig = Math.pow(Math.max(0, edge), 2.2) * (0.10 + 0.05 * breathe);
var j = (row + x) << 2;
d[j] = br + (C_MIST[0] - br) * vig;
d[j + 1] = bg + (C_MIST[1] - bg) * vig;
d[j + 2] = bb + (C_MIST[2] - bb) * vig;
}
}
// ② 서리 결정 — 격자 이미지를 확대해 얹는다(가까운 이웃 보간 없이 또렷하게)
drawGrid();
var sx = W / GW, sy = H / GH;
for (var gy2 = 0; gy2 < GH; gy2++) {
var gy0 = (gy2 * sy) | 0, gy1 = ((gy2 + 1) * sy) | 0;
for (var gx2 = 0; gx2 < GW; gx2++) {
var gi = (gy2 * GW + gx2) << 2;
if (gimg.data[gi] === C_INK[0] && gimg.data[gi + 1] === C_INK[1] && gimg.data[gi + 2] === C_INK[2]) continue;
var R = gimg.data[gi], G = gimg.data[gi + 1], B = gimg.data[gi + 2];
var gx0 = (gx2 * sx) | 0, gx1 = ((gx2 + 1) * sx) | 0;
for (var yy = gy0; yy < gy1 && yy < H; yy++) {
var rowo = yy * W;
for (var xx = gx0; xx < gx1 && xx < W; xx++) {
var jj = (rowo + xx) << 2;
d[jj] = R; d[jj + 1] = G; d[jj + 2] = B;
}
}
}
}
ctx.putImageData(img, 0, 0);
// ③ 창을 닦고 지나가는 결빛 스윕 — 자란 뒤에도 화면이 계속 산다(성에 위 대각선 하이라이트)
for (var sw = 0; sw < 2; sw++) { // 두 줄기가 반 주기 어긋나게 — 쉬는 구간 없다
var ph = ((now * 0.0013) % 1 + 0.5 * sw) % 1; // 0→1 순환(≈0.77초 주기)
var cx = (ph * 1.7 - 0.35) * W, cy = (-0.15 + ph * 0.5) * H;
var rr = W * 0.34, r2 = rr * rr;
var x0 = Math.max(0, (cx - rr) | 0), x1 = Math.min(W - 1, (cx + rr) | 0);
var y0 = Math.max(0, (cy - rr) | 0), y1 = Math.min(H - 1, (cy + rr) | 0);
if (x1 < x0 || y1 < y0) continue;
var pd = ctx.getImageData(x0, y0, x1 - x0 + 1, y1 - y0 + 1);
var pdd = pd.data, pw = x1 - x0 + 1;
for (var yy = y0; yy <= y1; yy++) {
var dyy = yy - cy;
for (var xx = x0; xx <= x1; xx++) {
var dxx = xx - cx;
var q = (dxx * dxx + dyy * dyy) / r2;
if (q > 1) continue;
var a = Math.pow(1 - q, 2) * 0.16; // 부드러운 가우시안 느낌의 산
var pj = ((yy - y0) * pw + (xx - x0)) << 2;
pdd[pj] += (C_CORE[0] - pdd[pj]) * a;
pdd[pj + 1] += (C_CORE[1] - pdd[pj + 1]) * a;
pdd[pj + 2] += (C_CORE[2] - pdd[pj + 2]) * a;
}
}
ctx.putImageData(pd, x0, y0);
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, prev = 0;
function frame(now) {
var dt = Math.min(50, now - (prev || now));
prev = now;
sim(dt);
compose(now);
fr++;
raf = requestAnimationFrame(frame);
}
function stillDraw() {
fit();
for (var i = 0; i < 30; i++) sim(33);
compose(0);
}
window.addEventListener('resize', function () { if (reduce) stillDraw(); });
fit();
if (reduce) {
stillDraw();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FrostWindowPane — vanilla 판(run/319/demo/05-snowflake-frost-window-pane/index.html) 의 React 포트.
// 창틀 네 변 전체를 씨앗으로 깔고 온도 필드(가장자리=잘 얼고 → 중심=얼기 어려운 역방향 그라디언트)를
// 확률에 반영한 DLA 랜덤워크 — 서리가 유리창 가장자리에서 안쪽으로 프랙탈로 기어드는 진짜 모양.
// 격자(저해상도) 점유를 ImageData 한 장에 그려 확대해 붙이고 결빛 스윕이 지나간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FrostWindowPane.module.scss";
export type FrostWindowPaneProps = {
duration?: string;
easing?: string;
color?: string;
gw?: number; // 격자 폭(셀 수) — 변형 "결의 촘촘함" 축(72 ↔ 96)
sps?: number; // 초당 응집 셀 수 — 변형 "번지는 속도" 축(1400 ↔ 3600)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Walker = { gx: number; gy: number };
type Colors = { ink: RGB; dusk: RGB; core: RGB; halo: RGB; mist: RGB };
const FIELD_LABEL = "서리 낀 창 실험판";
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 FrostWindowPane({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b8cce8",
gw = 72,
sps = 2600,
}: FrostWindowPaneProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const gimgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const gctxRef = useRef<CanvasRenderingContext2D | null>(null);
const randRef = useRef<() => number>(makePRNG(0x3190405)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [gridShown] = useState(`${gw}×${Math.round(gw * 0.625)}`);
const [spsShown] = useState(sps);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// ── 서리 상태(ref 로 보관 — rAF 루프에서만 갱신, 리렌더 유발 안 함) ──
const GH = Math.round(gw * 0.625);
const COLD = 0.34;
const BIAS = 0.55;
const occRef = useRef<Uint8Array>(new Uint8Array(gw * GH)) as Box<Uint8Array>;
const noiseRef = useRef<Float32Array>(new Float32Array(gw * GH)) as Box<Float32Array>;
const filledRef = useRef(0) as Box<number>;
const phaseRef = useRef(0) as Box<number>;
const shineTRef = useRef(0) as Box<number>;
const walkersRef = useRef<Walker[]>([]) as Box<Walker[]>;
const frRef = useRef(0) as Box<number>;
const maxDistRef = useRef(1) as Box<number>;
const WALKERS = 60;
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 idx(gx: number, gy: number): number {
return gy * gw + gx;
}
function edgeDist(gx: number, gy: number): number {
return Math.min(gx, gy, gw - 1 - gx, GH - 1 - gy);
}
function respawn(w: Walker): void {
const rand = randRef.current;
let tries = 0;
do {
w.gx = 1 + ((rand() * (gw - 2)) | 0);
w.gy = 1 + ((rand() * (GH - 2)) | 0);
tries++;
} while (occRef.current[idx(w.gx, w.gy)] && tries < 40);
}
const DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
// 방향 index: 0=+x · 1=-x · 2=+y · 3=-y
function stepWalker(w: Walker): boolean {
const rand = randRef.current;
const occ = occRef.current;
let d: number;
if (rand() < BIAS) {
// 온도 필드 드리프트 — 가장 가까운 창틀로 한 걸음
const dl = w.gx, dr = gw - 1 - w.gx, dt = w.gy, db = GH - 1 - w.gy;
const m = Math.min(dl, dr, dt, db);
d = m === dl ? 1 : m === dr ? 0 : m === dt ? 3 : 2;
} else {
d = (rand() * 4) | 0; // 무작위 걸음 — 이게 없으면 가지가 안 갈라진다
}
const nx = w.gx + DX[d], ny = w.gy + DY[d];
if (nx < 0 || nx >= gw || ny < 0 || ny >= GH) { respawn(w); return false; }
w.gx = nx; w.gy = ny;
const i = idx(nx, ny);
if (occ[i]) return false;
const nc = (nx > 0 && occ[idx(nx - 1, ny)] ? 1 : 0) + (nx < gw - 1 && occ[idx(nx + 1, ny)] ? 1 : 0) +
(ny > 0 && occ[idx(nx, ny - 1)] ? 1 : 0) + (ny < GH - 1 && occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// 온도 필드 — 중심에 가까울수록 차가워 얼기 어렵다
const dist = Math.min(1, edgeDist(nx, ny) / maxDistRef.current);
const temp = 1 - dist * COLD;
const p = (nc === 1 ? 1.0 : nc === 2 ? 0.58 : nc === 3 ? 0.24 : 0.12) * temp;
if (rand() >= p) return false;
occ[i] = 1;
filledRef.current++;
respawn(w);
return true;
}
function resetSim(): void {
const occ = occRef.current;
occ.fill(0);
for (let x = 0; x < gw; x++) { occ[idx(x, 0)] = 1; occ[idx(x, GH - 1)] = 1; }
for (let y = 0; y < GH; y++) { occ[idx(0, y)] = 1; occ[idx(gw - 1, y)] = 1; }
filledRef.current = 2 * (gw + GH) - 4;
phaseRef.current = 0;
shineTRef.current = 0;
const ws: Walker[] = [];
for (let i = 0; i < WALKERS; i++) { const w = { gx: 1, gy: 1 }; respawn(w); ws.push(w); }
walkersRef.current = ws;
}
function sim(dt: number): void {
if (phaseRef.current === 1) {
shineTRef.current += dt;
if (shineTRef.current > 650) resetSim();
return;
}
const quota = sps * dt / 1000;
let stuck = 0, steps = 0;
const maxSteps = 400000;
const t0 = performance.now(), budgetMs = 9;
while (stuck < quota && steps < maxSteps) {
const ws = walkersRef.current;
for (let i = 0; i < ws.length; i++) {
if (stepWalker(ws[i])) stuck++;
steps++;
if (stuck >= quota) break;
}
if ((steps & 2047) === 0 && performance.now() - t0 > budgetMs) break;
}
if (filledRef.current > gw * GH * 0.62) { phaseRef.current = 1; shineTRef.current = 0; }
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.max(2, Math.round(r.width));
const H = Math.max(2, Math.round(r.height));
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(W, H);
for (let i = 3; i < img.data.length; i += 4) img.data[i] = 255;
imgRef.current = img;
const gcanvas = document.createElement("canvas");
gcanvas.width = gw; gcanvas.height = GH;
const gctx = gcanvas.getContext("2d");
if (!gctx) return;
gctxRef.current = gctx;
const gimg = gctx.createImageData(gw, GH);
for (let j = 3; j < gimg.data.length; j += 4) gimg.data[j] = 255;
gimgRef.current = gimg;
const cx0 = (gw - 1) / 2, cy0 = (GH - 1) / 2;
maxDistRef.current = Math.sqrt(cx0 * cx0 + cy0 * cy0);
randRef.current = makePRNG(0x3190405);
const noise = noiseRef.current;
for (let ni = 0; ni < noise.length; ni++) noise[ni] = randRef.current();
resetSim();
}
// 격자 점유 → 작은 캔버스 ImageData → 큰 캔버스에 확대해 얹는다
function drawGrid(): void {
const gimg = gimgRef.current, occ = occRef.current, noise = noiseRef.current, col = colRef.current;
if (!gimg || !col) return;
const gd = gimg.data;
const fr = frRef.current;
for (let gy = 0; gy < GH; gy++) {
for (let gx = 0; gx < gw; gx++) {
const i = gy * gw + gx, j = i << 2;
if (!occ[i]) { gd[j] = col.ink[0]; gd[j + 1] = col.ink[1]; gd[j + 2] = col.ink[2]; continue; }
const dist = Math.min(1, edgeDist(gx, gy) / maxDistRef.current);
const wave = Math.sin(fr * 0.11 - dist * 5.2 + noise[i] * 6.283); // 결빛 파동 — 자리마다 다른 박자
const sparkle = 0.36 + 0.28 * noise[i] + 0.36 * (0.5 + 0.5 * wave);
const t = Math.min(1, dist * 1.15) * sparkle;
gd[j] = col.halo[0] + (col.core[0] - col.halo[0]) * t;
gd[j + 1] = col.halo[1] + (col.core[1] - col.halo[1]) * t;
gd[j + 2] = col.halo[2] + (col.core[2] - col.halo[2]) * t;
}
}
if (gctxRef.current) gctxRef.current.putImageData(gimg, 0, 0);
}
function compose(now: number): void {
const ctx = ctxRef.current, img = imgRef.current, col = colRef.current;
if (!ctx || !img || !col) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const d = img.data;
// ① 유리창 배경 — 세로 그라디언트 + 숨쉬는 성에 안개
const topR = col.ink[0] + 4, topG = col.ink[1] + 4, topB = col.ink[2] + 8;
const botR = col.ink[0] + (col.dusk[0] - col.ink[0]) * 0.30, botG = col.ink[1] + (col.dusk[1] - col.ink[1]) * 0.30, botB = col.ink[2] + (col.dusk[2] - col.ink[2]) * 0.30;
const breathe = 0.5 + 0.5 * Math.sin(frRef.current * 0.05);
for (let y = 0; y < H; y++) {
const ty = y / H;
const br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
const edgeY = Math.min(y, H - 1 - y) / (H * 0.5);
const row = y * W;
for (let x = 0; x < W; x++) {
const edgeX = Math.min(x, W - 1 - x) / (W * 0.5);
const edge = 1 - Math.min(edgeX, edgeY);
const vig = Math.pow(Math.max(0, edge), 2.2) * (0.10 + 0.05 * breathe);
const j = (row + x) << 2;
d[j] = br + (col.mist[0] - br) * vig;
d[j + 1] = bg + (col.mist[1] - bg) * vig;
d[j + 2] = bb + (col.mist[2] - bb) * vig;
}
}
// ② 서리 결정 — 격자 이미지를 확대해 얹는다
drawGrid();
const gimg = gimgRef.current;
if (gimg) {
const sx = W / gw, sy = H / GH;
for (let gy2 = 0; gy2 < GH; gy2++) {
const gy0 = (gy2 * sy) | 0, gy1 = ((gy2 + 1) * sy) | 0;
for (let gx2 = 0; gx2 < gw; gx2++) {
const gi = (gy2 * gw + gx2) << 2;
if (gimg.data[gi] === col.ink[0] && gimg.data[gi + 1] === col.ink[1] && gimg.data[gi + 2] === col.ink[2]) continue;
const R = gimg.data[gi], G = gimg.data[gi + 1], B = gimg.data[gi + 2];
const gx0 = (gx2 * sx) | 0, gx1 = ((gx2 + 1) * sx) | 0;
for (let yy = gy0; yy < gy1 && yy < H; yy++) {
const rowo = yy * W;
for (let xx = gx0; xx < gx1 && xx < W; xx++) {
const jj = (rowo + xx) << 2;
d[jj] = R; d[jj + 1] = G; d[jj + 2] = B;
}
}
}
}
}
ctx.putImageData(img, 0, 0);
// ③ 창을 닦고 지나가는 결빛 스윕 — 자란 뒤에도 화면이 계속 산다
for (let sw = 0; sw < 2; sw++) {
const ph = ((now * 0.0013) % 1 + 0.5 * sw) % 1;
const cx = (ph * 1.7 - 0.35) * W, cy = (-0.15 + ph * 0.5) * H;
const rr = W * 0.34, r2 = rr * rr;
const x0 = Math.max(0, (cx - rr) | 0), x1 = Math.min(W - 1, (cx + rr) | 0);
const y0 = Math.max(0, (cy - rr) | 0), y1 = Math.min(H - 1, (cy + rr) | 0);
if (x1 < x0 || y1 < y0) continue;
const pd = ctx.getImageData(x0, y0, x1 - x0 + 1, y1 - y0 + 1);
const pdd = pd.data, pw = x1 - x0 + 1;
for (let yy = y0; yy <= y1; yy++) {
const dyy = yy - cy;
for (let xx = x0; xx <= x1; xx++) {
const dxx = xx - cx;
const q = (dxx * dxx + dyy * dyy) / r2;
if (q > 1) continue;
const a = Math.pow(1 - q, 2) * 0.16;
const pj = ((yy - y0) * pw + (xx - x0)) << 2;
pdd[pj] += (col.core[0] - pdd[pj]) * a;
pdd[pj + 1] += (col.core[1] - pdd[pj + 1]) * a;
pdd[pj + 2] += (col.core[2] - pdd[pj + 2]) * a;
}
}
ctx.putImageData(pd, x0, y0);
}
}
const prevRef = useRef(0) as Box<number>;
function frame(now: number): void {
const dt = Math.min(50, now - (prevRef.current || now));
prevRef.current = now;
sim(dt);
compose(now);
frRef.current++;
rafRef.current = requestAnimationFrame(frame);
}
function stillDraw(): void {
fit();
for (let i = 0; i < 30; i++) sim(33);
compose(0);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
dusk: tokRGB(root, "--flake-dusk"),
core: tokRGB(root, "--flake-core"),
halo: tokRGB(root, "--flake-halo"),
mist: tokRGB(root, "--flake-mist"),
};
fit();
function onResize(): void {
fit();
if (reduced()) stillDraw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillDraw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gw, sps]);
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}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><path fill="none" stroke="currentColor" strokeWidth={18} d="M20 20h216v216H20z"/><path fill="none" stroke="currentColor" strokeWidth={14} strokeLinecap="round" d="M40 40l60 60M216 40l-60 60M40 216l60-60M216 216l-60-60M128 20v70M128 166v70M20 128h70M166 128h70"/></svg>
<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}>{gridShown}</b> · 응집 <b className={styles.val}>{spsShown}</b>/초</span>
<span className={styles.metaDim}>캔버스 · ImageData · putImageData</span>
</div>
</div>
</main>
);
}05 · 서리 낀 창 — 가장자리에서 안쪽으로 기어드는 프랙탈 얼음 — 측정 누적면적 45.515% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
창틀로 쏠리는 드리프트와 중심 냉기
function stepWalker(w) {
var d;
if (rand() < BIAS) {
// 온도 필드 드리프트 — 가장 가까운 창틀로 한 걸음(방향 index: 0=+x,1=-x,2=+y,3=-y)
var dl = w.gx, dr = GW - 1 - w.gx, dt = w.gy, db = GH - 1 - w.gy;
var m = Math.min(dl, dr, dt, db);
d = m === dl ? 1 : m === dr ? 0 : m === dt ? 3 : 2;
} else {
d = (rand() * 4) | 0; // 무작위 걸음 — 이게 없으면 직선으로만 얼어 가지가 안 갈라진다
}
var nx = w.gx + DX[d], ny = w.gy + DY[d];
if (nx < 0 || nx >= GW || ny < 0 || ny >= GH) { respawn(w); return false; }
w.gx = nx; w.gy = ny;
var i = idx(nx, ny);
if (occ[i]) return false;
var nc = (nx > 0 && occ[idx(nx - 1, ny)] ? 1 : 0) + (nx < GW - 1 && occ[idx(nx + 1, ny)] ? 1 : 0) +
(ny > 0 && occ[idx(nx, ny - 1)] ? 1 : 0) + (ny < GH - 1 && occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// 온도 필드 — 중심에 가까울수록(dist 작을수록) 차가워 얼기 어렵다(가지가 가늘어진다)
var dist = Math.min(1, edgeDist(nx, ny) / maxDist);
var temp = 1 - dist * COLD;
var p = (nc === 1 ? 1.0 : nc === 2 ? 0.58 : nc === 3 ? 0.24 : 0.12) * temp;
if (rand() >= p) return false;
occ[i] = 1;
BIAS 0.55 확률로 걸음이 가장 가까운 창틀 쪽 한 걸음을 택하고, 나머지는 무작위로 흔들려 직선이 아니라 갈라지는 가지를 만듭니다. 붙은 이웃 수 nc 와 중심까지 거리로 낮아지는 temp 를 곱해 흡착 확률 p 를 정하는데, 이 값이 05의 서리 결이 창틀에서 진하고 중심으로 갈수록 성겨지는 이유입니다.
06결정 격자 채색 — 나이(접촉 시각)를 파스텔 고리로 새기기
입자가 붙은 순간의 시각을 기록해 두었다가 나이별로 색을 달리하면, 결정의 성장 연륜이 나무 나이테처럼 파스텔 동심원으로 읽힙니다 — 실측 누적면적 9.325% · 강도 평균 73.3 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><circle cx="128" cy="128" r="16" fill="none" stroke="currentColor" stroke-width="16"/><circle cx="128" cy="128" r="48" fill="none" stroke="currentColor" stroke-width="12" opacity=".75"/><circle cx="128" cy="128" r="80" fill="none" stroke="currentColor" stroke-width="10" opacity=".5"/><circle cx="128" cy="128" r="108" fill="none" stroke="currentColor" stroke-width="8" opacity=".3"/></svg>
<span>입자가 붙은 순간을 번호로 기록해 나이별로 색을 달리하면, 결정의 성장 연륜이 나무 나이테처럼 파스텔 동심원으로 읽힙니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="결정 격자 채색 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">연륜 <b class="cd__val" id="cdRings"></b> · 응집 <b class="cd__val" id="cdRate"></b>/초</span>
<span class="cd__meta cd__meta--dim">캔버스 · ImageData · putImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 결정 격자 채색(06). @keyframes 를 아예 두지 않는다 — 움직임은 전부 캔버스 rAF 루프
// (나이(접촉 순번) 기록 DLA + 나이테 파스텔 램프 LUT + 눈가루 부유)가 만든다.
// 무대는 한겨울 밤잉크. 파스텔 팔레트는 편 319 공통 --flake-* (04·05 와 동일 값).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(04·05 와 동일) ──
$flake-ink: #20242e;
$flake-dusk: #4a516e;
$flake-core: #f4f0fa;
$flake-halo: #b8cce8;
$flake-mint: #c8e0d8;
$flake-sky: #a4bcd8;
$flake-mist: #dce8f4;
.cd {
--flake-ink: #{$flake-ink};
--flake-dusk: #{$flake-dusk};
--flake-core: #{$flake-core};
--flake-halo: #{$flake-halo};
--flake-mint: #{$flake-mint};
--flake-sky: #{$flake-sky};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-halo; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-halo; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-halo; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #20242e;
--flake-dusk: #4a516e;
--flake-core: #f4f0fa;
--flake-halo: #b8cce8;
--flake-mint: #c8e0d8;
--flake-sky: #a4bcd8;
--flake-mist: #dce8f4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #dce8f4;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b8cce8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #20242e;
box-shadow: inset 0 0 0 1px rgba(220, 232, 244, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b8cce8;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(220, 232, 244, 0.72);
}
.cd__val {
color: #b8cce8;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 결정 격자 채색(06). DLA 는 붙는 순간을 기록한다 — order 배열에 셀 인덱스를 stick 순서대로 쌓고,
// 그려질 때 나이(order / 지금까지 총 응집)를 8단 파스텔 램프로 읽는다. 같은 시각에 붙은 셀은
// 같은 나이 → 결정 안에 나무 나이테 같은 동심원이 새겨진다. 성장이 다 차면 반짝 회전 후 리셋.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N = 46; // 격자 반경(셀 수) — 커질수록 나이테가 촘촘해진다
var SPS = 300; // 초당 응집 입자 수 — 변형 "응집 속도" 축(90 → 700)
var RINGS = 8; // 연륜 단수 — 나이를 몇 개의 파스텔 띠로 나눌지(6 → 12)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
document.getElementById('cdRings').textContent = String(RINGS);
document.getElementById('cdRate').textContent = String(SPS);
var SEED = 0x3190406;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
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('--flake-ink');
var C_DUSK = tokRGB('--flake-dusk');
var C_CORE = tokRGB('--flake-core');
var C_HALO = tokRGB('--flake-halo');
var C_MINT = tokRGB('--flake-mint');
var C_SKY = tokRGB('--flake-sky');
var C_MIST = tokRGB('--flake-mist');
// ── 격자 — 중앙 씨앗 하나에서 자라는 방사 DLA ──
var GW = 2 * N + 8, GH = GW; // 정사각 격자, 중심 = 씨앗
var CX = (GW / 2) | 0, CY = CX;
var occ = new Uint8Array(GW * GH);
var age = new Int32Array(GW * GH); // 셀별 나이(stick 순서, 1부터 — 0=빈칸)
var order = []; // stick 순서대로 셀 인덱스를 쌓는다(나이테의 역사)
var walkers = [];
var WALKERS = 40;
var phase = 0, shineT = 0;
function idx(gx, gy) { return gy * GW + gx; }
function respawn(w) { // 씨앗 근처 구름에서 출발 — 멀리서 오면 프레임 예산이 새는 곳으로 걸음 낭비
var a = rand() * 6.283, r = 6 + rand() * Math.min(10, N * 0.35);
w.gx = CX + Math.round(Math.cos(a) * r);
w.gy = CY + Math.round(Math.sin(a) * r);
if (w.gx < 1 || w.gx >= GW - 1 || w.gy < 1 || w.gy >= GH - 1) { w.gx = CX + 2; w.gy = CY; }
}
var DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
function stepWalker(w) {
var d = (rand() * 4) | 0;
var nx = w.gx + DX[d], ny = w.gy + DY[d];
var dx = nx - CX, dy = ny - CY;
if (dx * dx + dy * dy > N * N) { respawn(w); return false; } // 격자 밖 → 구름으로 되돌린다
w.gx = nx; w.gy = ny;
var i = idx(nx, ny);
if (occ[i]) return false;
var nc = (occ[idx(nx - 1, ny)] ? 1 : 0) + (occ[idx(nx + 1, ny)] ? 1 : 0) +
(occ[idx(nx, ny - 1)] ? 1 : 0) + (occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// 가지 확률 — 이웃이 적게 닿은 외곽일수록 잘 붙는다(DLA 수지상 편향)
var p = nc === 1 ? 1.0 : nc === 2 ? 0.62 : nc === 3 ? 0.22 : 0.10;
if (rand() >= p) return false;
occ[i] = 1;
order.push(i);
age[i] = order.length; // 나이 = 붙은 순번 — 색의 유일한 출처
respawn(w);
return true;
}
function resetSim() {
occ.fill(0); age.fill(0); order = [];
occ[idx(CX, CY)] = 1; order.push(idx(CX, CY)); age[idx(CX, CY)] = 1;
phase = 0; shineT = 0;
walkers = [];
for (var i = 0; i < WALKERS; i++) { var w = { gx: CX, gy: CY }; respawn(w); walkers.push(w); }
}
resetSim();
function sim(dt) {
if (phase === 1) {
shineT += dt;
if (shineT > 1200) resetSim(); // 완성 결정을 충분히 보여 준 다음 다음 세대
return;
}
var quota = SPS * dt / 1000;
var stuck = 0, steps = 0, maxSteps = 500000;
var t0 = performance.now(), budgetMs = 9;
while (stuck < quota && steps < maxSteps) {
for (var i = 0; i < walkers.length; i++) {
if (stepWalker(walkers[i])) stuck++;
steps++;
if (stuck >= quota) break;
}
if ((steps & 2047) === 0 && performance.now() - t0 > budgetMs) break;
}
if (order.length > SPS * 14) { phase = 1; shineT = 0; } // 약 14초 치 결정이면 완성
}
var W = 0, H = 0, img = null, gimg = null, gctx = null, gcanvas = null;
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
img = ctx.createImageData(W, H);
var d = img.data;
for (var i = 3; i < d.length; i += 4) d[i] = 255;
gcanvas = document.createElement('canvas');
gcanvas.width = GW; gcanvas.height = GH;
gctx = gcanvas.getContext('2d');
gimg = gctx.createImageData(GW, GH);
var gd = gimg.data;
for (var j = 3; j < gd.length; j += 4) gd[j] = 255;
prngState = SEED >>> 0;
resetSim();
}
// 나이 → 파스텔 램프 LUT(256단계) — 나이테 RINGS 개의 띠를 지나며 색이 바뀐다
var lut = { r: new Uint8ClampedArray(256), g: new Uint8ClampedArray(256), b: new Uint8ClampedArray(256) };
function buildLUT() {
// 띠 경계마다 살짝 밝은 테(나이테 선) — 같은 나이끼리 묶여 동심원으로 보인다
var cs = [C_DUSK, C_DUSK, C_HALO, C_MINT, C_MIST, C_SKY, C_MINT, C_CORE];
for (var i = 0; i < 256; i++) {
var t = i / 255;
var band = Math.min(cs.length - 1, 1 + Math.floor(t * (cs.length - 1)));
var c0 = cs[band - 1], c1 = cs[band];
var f = Math.min(1, t * (cs.length - 1) - (band - 1) * 2 + 1); // 띠 안에서 부드럽게
var ring = t * (cs.length - 1) % 1;
var edge = ring < 0.16 ? 0.35 * (1 - ring / 0.16) : 0; // 띠 시작 = 밝은 나이테 선
var r0 = c0[0] + (c1[0] - c0[0]) * f, g0 = c0[1] + (c1[1] - c0[1]) * f, b0 = c0[2] + (c1[2] - c0[2]) * f;
lut.r[i] = r0 + (C_CORE[0] - r0) * edge;
lut.g[i] = g0 + (C_CORE[1] - g0) * edge;
lut.b[i] = b0 + (C_CORE[2] - b0) * edge;
}
}
buildLUT();
// 격자 → 작은 캔버스 ImageData(나이 색) → 큰 캔버스에 확대
function drawGrid(pulse) {
var gd = gimg.data;
var total = Math.max(2, order.length);
for (var gy = 0; gy < GH; gy++) {
for (var gx = 0; gx < GW; gx++) {
var i = gy * GW + gx, j = i << 2;
var a = age[i];
if (!a) { gd[j] = C_INK[0]; gd[j + 1] = C_INK[1]; gd[j + 2] = C_INK[2]; continue; }
var t = a / total; // 나이 비율(0=첫 씨앗 → 1=방금 붙은 가장자리)
var li = Math.max(0, Math.min(255, (t * 255 * pulse) | 0));
gd[j] = lut.r[li]; gd[j + 1] = lut.g[li]; gd[j + 2] = lut.b[li];
}
}
gctx.putImageData(gimg, 0, 0);
}
var fr = 0;
function compose(now) {
var d = img.data;
// ① 무대 — 세로 그라디언트 + 중앙 성광(결정이 자라는 자리를 은은히 밝힌다)
var topR = C_INK[0] + 3, topG = C_INK[1] + 3, topB = C_INK[2] + 6;
var botR = C_INK[0] + (C_DUSK[0] - C_INK[0]) * 0.34, botG = C_INK[1] + (C_DUSK[1] - C_INK[1]) * 0.34, botB = C_INK[2] + (C_DUSK[2] - C_INK[2]) * 0.34;
var breathe = 0.5 + 0.5 * Math.sin(now * 0.0011);
var gx0 = 0.5 + 0.06 * Math.sin(now * 0.0007), gy0 = 0.5 + 0.05 * Math.cos(now * 0.0009);
for (var y = 0; y < H; y++) {
var ty = y / H;
var br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
var row = y * W, dy = (y / H - gy0) * H;
for (var x = 0; x < W; x++) {
var dx = (x / W - gx0) * W * 1.35;
var q = (dx * dx + dy * dy) / (H * H * 1.6);
var glow = Math.exp(-q) * (0.10 + 0.05 * breathe);
var j = (row + x) << 2;
d[j] = br + (C_MIST[0] - br) * glow;
d[j + 1] = bg + (C_MIST[1] - bg) * glow;
d[j + 2] = bb + (C_MIST[2] - bb) * glow;
}
}
// ② 결정 나이테 — 나이 비율에 살짝 숨쉬는 맥(색 램프 인덱스를 조금씩 흔든다)
var pulse = 0.94 + 0.06 * Math.sin(now * 0.0023);
drawGrid(pulse);
var sx = W / GW, sy = H / GH;
var ox = ((W - GW * sx) / 2) | 0, oy = ((H - GH * sy) / 2) | 0;
for (var gy2 = 0; gy2 < GH; gy2++) {
var yA = oy + ((gy2 * sy) | 0), yB = oy + (((gy2 + 1) * sy) | 0);
for (var gx2 = 0; gx2 < GW; gx2++) {
var gi = (gy2 * GW + gx2) << 2;
if (gimg.data[gi] === C_INK[0] && gimg.data[gi + 1] === C_INK[1] && gimg.data[gi + 2] === C_INK[2]) continue;
var R = gimg.data[gi], G = gimg.data[gi + 1], B = gimg.data[gi + 2];
var xA = ox + ((gx2 * sx) | 0), xB = ox + (((gx2 + 1) * sx) | 0);
for (var yy = Math.max(0, yA); yy < Math.min(H, yB); yy++) {
var rowo = yy * W;
for (var xx = Math.max(0, xA); xx < Math.min(W, xB); xx++) {
var jj = (rowo + xx) << 2;
d[jj] = R; d[jj + 1] = G; d[jj + 2] = B;
}
}
}
}
ctx.putImageData(img, 0, 0);
// ③ 떠다니는 눈가루 — 결정 위를 파스텔 가루가 느리게 흘러 화면이 쉬지 않는다
for (var m = 0; m < 26; m++) {
var ph = (m / 26 + now * 0.00006 * (1 + (m % 3) * 0.4)) % 1;
var mx = (0.12 + 0.76 * ((m * 0.137 + now * 0.00004 * (1 + (m % 5) * 0.3)) % 1)) * W;
var my = ph * H;
var mr = 1 + (m % 3), ma = 0.24 + 0.16 * Math.sin(now * 0.003 + m);
ctx.fillStyle = 'rgba(' + C_MIST + ',' + ma.toFixed(3) + ')';
ctx.beginPath();
ctx.arc(mx, my, mr, 0, 6.283);
ctx.fill();
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, prev = 0;
function frame(now) {
var dt = Math.min(50, now - (prev || now));
prev = now;
sim(dt);
compose(now);
fr++;
raf = requestAnimationFrame(frame);
}
function stillDraw() {
fit();
for (var i = 0; i < 400; i++) sim(33);
compose(0);
}
window.addEventListener('resize', function () { if (reduce) stillDraw(); });
fit();
if (reduce) {
stillDraw();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// CrystalLatticeColor — vanilla 판(run/319/demo/06-snowflake-crystal-lattice-color/index.html) 의 React 포트.
// DLA 는 입자가 붙는 "순간"을 기록한다 — order 배열에 셀 인덱스를 stick 순서대로 쌓고, 그려질 때
// 나이(순번 / 지금까지 총 응집)를 8단 파스텔 LUT 로 읽는다. 같은 시각에 붙은 셀은 같은 나이 →
// 결정 안에 나무 나이테 같은 동심원이 새겨진다. 격자(저해상도) 점유를 ImageData 한 장에 그려
// 확대해 붙이고, 성장이 다 차면 반짝 회전 후 리셋한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CrystalLatticeColor.module.scss";
export type CrystalLatticeColorProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 격자 반경(셀 수) — 변형 "촘촘함" 축(46 커질수록 나이테가 촘촘해진다)
sps?: number; // 초당 응집 입자 수 — 변형 "응집 속도" 축(90 ↔ 700)
rings?: number; // 연륜 단수 — 나이를 몇 개의 파스텔 띠로 나눌지(6 ↔ 12)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Walker = { gx: number; gy: number };
type Colors = { ink: RGB; dusk: RGB; core: RGB; halo: RGB; mint: RGB; sky: RGB; mist: RGB };
const FIELD_LABEL = "결정 격자 채색 실험판";
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 CrystalLatticeColor({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#a4bcd8",
n = 46,
sps = 300,
rings = 8,
}: CrystalLatticeColorProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const gimgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const gctxRef = useRef<CanvasRenderingContext2D | null>(null);
const randRef = useRef<() => number>(makePRNG(0x3190406)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [ringsShown] = useState(rings);
const [spsShown] = useState(sps);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// ── 결정 상태(ref 로 보관 — rAF 루프에서만 갱신, 리렌더 유발 안 함) ──
const GW = 2 * n + 8, GH = GW;
const CX = (GW / 2) | 0, CY = CX;
const occRef = useRef<Uint8Array>(new Uint8Array(GW * GH)) as Box<Uint8Array>;
const ageRef = useRef<Int32Array>(new Int32Array(GW * GH)) as Box<Int32Array>; // 셀별 나이(stick 순서, 1부터 — 0=빈칸)
const orderRef = useRef<number[]>([]) as Box<number[]>; // stick 순서대로 셀 인덱스를 쌓는다(나이테의 역사)
const walkersRef = useRef<Walker[]>([]) as Box<Walker[]>;
const phaseRef = useRef(0) as Box<number>;
const shineTRef = useRef(0) as Box<number>;
const frRef = useRef(0) as Box<number>;
const lutRef = useRef<{ r: Uint8ClampedArray; g: Uint8ClampedArray; b: Uint8ClampedArray } | null>(null);
const WALKERS = 40;
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 idx(gx: number, gy: number): number {
return gy * GW + gx;
}
function respawn(w: Walker): void {
const rand = randRef.current;
const a = rand() * 6.283, r = 6 + rand() * Math.min(10, n * 0.35);
w.gx = CX + Math.round(Math.cos(a) * r);
w.gy = CY + Math.round(Math.sin(a) * r);
if (w.gx < 1 || w.gx >= GW - 1 || w.gy < 1 || w.gy >= GH - 1) { w.gx = CX + 2; w.gy = CY; }
}
const DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
function stepWalker(w: Walker): boolean {
const rand = randRef.current;
const occ = occRef.current;
const age = ageRef.current;
const order = orderRef.current;
const d = (rand() * 4) | 0;
const nx = w.gx + DX[d], ny = w.gy + DY[d];
const dx = nx - CX, dy = ny - CY;
if (dx * dx + dy * dy > n * n) { respawn(w); return false; } // 격자 밖 → 구름으로 되돌린다
w.gx = nx; w.gy = ny;
const i = idx(nx, ny);
if (occ[i]) return false;
const nc = (occ[idx(nx - 1, ny)] ? 1 : 0) + (occ[idx(nx + 1, ny)] ? 1 : 0) +
(occ[idx(nx, ny - 1)] ? 1 : 0) + (occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// 가지 확률 — 이웃이 적게 닿은 외곽일수록 잘 붙는다(DLA 수지상 편향)
const p = nc === 1 ? 1.0 : nc === 2 ? 0.62 : nc === 3 ? 0.22 : 0.10;
if (rand() >= p) return false;
occ[i] = 1;
order.push(i);
age[i] = order.length; // 나이 = 붙은 순번 — 색의 유일한 출처
respawn(w);
return true;
}
function resetSim(): void {
const occ = occRef.current, age = ageRef.current;
occ.fill(0); age.fill(0); orderRef.current = [];
const order = orderRef.current;
occ[idx(CX, CY)] = 1; order.push(idx(CX, CY)); age[idx(CX, CY)] = 1;
phaseRef.current = 0; shineTRef.current = 0;
const ws: Walker[] = [];
for (let i = 0; i < WALKERS; i++) { const w = { gx: CX, gy: CY }; respawn(w); ws.push(w); }
walkersRef.current = ws;
}
function sim(dt: number): void {
if (phaseRef.current === 1) {
shineTRef.current += dt;
if (shineTRef.current > 1200) resetSim(); // 완성 결정을 충분히 보여 준 다음 다음 세대
return;
}
const quota = sps * dt / 1000;
let stuck = 0, steps = 0;
const maxSteps = 500000;
const t0 = performance.now(), budgetMs = 9;
while (stuck < quota && steps < maxSteps) {
const ws = walkersRef.current;
for (let i = 0; i < ws.length; i++) {
if (stepWalker(ws[i])) stuck++;
steps++;
if (stuck >= quota) break;
}
if ((steps & 2047) === 0 && performance.now() - t0 > budgetMs) break;
}
if (orderRef.current.length > sps * 14) { phaseRef.current = 1; shineTRef.current = 0; } // 약 14초 치 결정이면 완성
}
// 나이 → 파스텔 램프 LUT(256단계) — 나이테 rings 개의 띠를 지나며 색이 바뀐다
function buildLUT(): void {
const col = colRef.current;
if (!col) return;
const lut = {
r: new Uint8ClampedArray(256),
g: new Uint8ClampedArray(256),
b: new Uint8ClampedArray(256),
};
// 띠 경계마다 살짝 밝은 테(나이테 선) — 같은 나이끼리 묶여 동심원으로 보인다
const cs: RGB[] = [col.dusk, col.dusk, col.halo, col.mint, col.mist, col.sky, col.mint, col.core];
for (let i = 0; i < 256; i++) {
const t = i / 255;
const band = Math.min(cs.length - 1, 1 + Math.floor(t * (cs.length - 1)));
const c0 = cs[band - 1], c1 = cs[band];
const f = Math.min(1, t * (cs.length - 1) - (band - 1) * 2 + 1); // 띠 안에서 부드럽게
const ring = (t * (cs.length - 1)) % 1;
const edge = ring < 0.16 ? 0.35 * (1 - ring / 0.16) : 0; // 띠 시작 = 밝은 나이테 선
const r0 = c0[0] + (c1[0] - c0[0]) * f, g0 = c0[1] + (c1[1] - c0[1]) * f, b0 = c0[2] + (c1[2] - c0[2]) * f;
lut.r[i] = r0 + (col.core[0] - r0) * edge;
lut.g[i] = g0 + (col.core[1] - g0) * edge;
lut.b[i] = b0 + (col.core[2] - b0) * edge;
}
lutRef.current = lut;
}
// 격자 → 작은 캔버스 ImageData(나이 색) → 큰 캔버스에 확대
function drawGrid(pulse: number): void {
const gimg = gimgRef.current, col = colRef.current, lut = lutRef.current;
if (!gimg || !col || !lut) return;
const gd = gimg.data;
const age = ageRef.current;
const total = Math.max(2, orderRef.current.length);
for (let gy = 0; gy < GH; gy++) {
for (let gx = 0; gx < GW; gx++) {
const i = gy * GW + gx, j = i << 2;
const a = age[i];
if (!a) { gd[j] = col.ink[0]; gd[j + 1] = col.ink[1]; gd[j + 2] = col.ink[2]; continue; }
const t = a / total; // 나이 비율(0=첫 씨앗 → 1=방금 붙은 가장자리)
const li = Math.max(0, Math.min(255, (t * 255 * pulse) | 0));
gd[j] = lut.r[li]; gd[j + 1] = lut.g[li]; gd[j + 2] = lut.b[li];
}
}
if (gctxRef.current) gctxRef.current.putImageData(gimg, 0, 0);
}
function compose(now: number): void {
const ctx = ctxRef.current, img = imgRef.current, col = colRef.current, gimg = gimgRef.current;
if (!ctx || !img || !col || !gimg) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const d = img.data;
// ① 무대 — 세로 그라디언트 + 중앙 성광(결정이 자라는 자리를 은은히 밝힌다)
const topR = col.ink[0] + 3, topG = col.ink[1] + 3, topB = col.ink[2] + 6;
const botR = col.ink[0] + (col.dusk[0] - col.ink[0]) * 0.34, botG = col.ink[1] + (col.dusk[1] - col.ink[1]) * 0.34, botB = col.ink[2] + (col.dusk[2] - col.ink[2]) * 0.34;
const breathe = 0.5 + 0.5 * Math.sin(now * 0.0011);
const gx0 = 0.5 + 0.06 * Math.sin(now * 0.0007), gy0 = 0.5 + 0.05 * Math.cos(now * 0.0009);
for (let y = 0; y < H; y++) {
const ty = y / H;
const br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
const row = y * W, dy = (y / H - gy0) * H;
for (let x = 0; x < W; x++) {
const dx = (x / W - gx0) * W * 1.35;
const q = (dx * dx + dy * dy) / (H * H * 1.6);
const glow = Math.exp(-q) * (0.10 + 0.05 * breathe);
const j = (row + x) << 2;
d[j] = br + (col.mist[0] - br) * glow;
d[j + 1] = bg + (col.mist[1] - bg) * glow;
d[j + 2] = bb + (col.mist[2] - bb) * glow;
}
}
// ② 결정 나이테 — 나이 비율에 살짝 숨쉬는 맥(색 램프 인덱스를 조금씩 흔든다)
const pulse = 0.94 + 0.06 * Math.sin(now * 0.0023);
drawGrid(pulse);
const sx = W / GW, sy = H / GH;
const ox = ((W - GW * sx) / 2) | 0, oy = ((H - GH * sy) / 2) | 0;
for (let gy2 = 0; gy2 < GH; gy2++) {
const yA = oy + ((gy2 * sy) | 0), yB = oy + (((gy2 + 1) * sy) | 0);
for (let gx2 = 0; gx2 < GW; gx2++) {
const gi = (gy2 * GW + gx2) << 2;
if (gimg.data[gi] === col.ink[0] && gimg.data[gi + 1] === col.ink[1] && gimg.data[gi + 2] === col.ink[2]) continue;
const R = gimg.data[gi], G = gimg.data[gi + 1], B = gimg.data[gi + 2];
const xA = ox + ((gx2 * sx) | 0), xB = ox + (((gx2 + 1) * sx) | 0);
for (let yy = Math.max(0, yA); yy < Math.min(H, yB); yy++) {
const rowo = yy * W;
for (let xx = Math.max(0, xA); xx < Math.min(W, xB); xx++) {
const jj = (rowo + xx) << 2;
d[jj] = R; d[jj + 1] = G; d[jj + 2] = B;
}
}
}
}
ctx.putImageData(img, 0, 0);
// ③ 떠다니는 눈가루 — 결정 위를 파스텔 가루가 느리게 흘러 화면이 쉬지 않는다
for (let m = 0; m < 26; m++) {
const ph = (m / 26 + now * 0.00006 * (1 + (m % 3) * 0.4)) % 1;
const mx = (0.12 + 0.76 * ((m * 0.137 + now * 0.00004 * (1 + (m % 5) * 0.3)) % 1)) * W;
const my = ph * H;
const mr = 1 + (m % 3), ma = 0.24 + 0.16 * Math.sin(now * 0.003 + m);
ctx.fillStyle = `rgba(${col.mist[0]},${col.mist[1]},${col.mist[2]},${ma.toFixed(3)})`;
ctx.beginPath();
ctx.arc(mx, my, mr, 0, 6.283);
ctx.fill();
}
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.max(2, Math.round(r.width));
const H = Math.max(2, Math.round(r.height));
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(W, H);
for (let i = 3; i < img.data.length; i += 4) img.data[i] = 255;
imgRef.current = img;
const gcanvas = document.createElement("canvas");
gcanvas.width = GW; gcanvas.height = GH;
const gctx = gcanvas.getContext("2d");
if (!gctx) return;
gctxRef.current = gctx;
const gimg = gctx.createImageData(GW, GH);
for (let j = 3; j < gimg.data.length; j += 4) gimg.data[j] = 255;
gimgRef.current = gimg;
randRef.current = makePRNG(0x3190406);
resetSim();
}
const prevRef = useRef(0) as Box<number>;
function frame(now: number): void {
const dt = Math.min(50, now - (prevRef.current || now));
prevRef.current = now;
sim(dt);
compose(now);
frRef.current++;
rafRef.current = requestAnimationFrame(frame);
}
function stillDraw(): void {
fit();
for (let i = 0; i < 400; i++) sim(33);
compose(0);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
dusk: tokRGB(root, "--flake-dusk"),
core: tokRGB(root, "--flake-core"),
halo: tokRGB(root, "--flake-halo"),
mint: tokRGB(root, "--flake-mint"),
sky: tokRGB(root, "--flake-sky"),
mist: tokRGB(root, "--flake-mist"),
};
buildLUT();
fit();
function onResize(): void {
if (reduced()) stillDraw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillDraw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, sps, rings]);
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}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><circle cx={128} cy={128} r={16} fill="none" stroke="currentColor" strokeWidth={16}/><circle cx={128} cy={128} r={48} fill="none" stroke="currentColor" strokeWidth={12} opacity={.75}/><circle cx={128} cy={128} r={80} fill="none" stroke="currentColor" strokeWidth={10} opacity={.5}/><circle cx={128} cy={128} r={108} fill="none" stroke="currentColor" strokeWidth={8} opacity={.3}/></svg>
<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}>{ringsShown}</b> · 응집 <b className={styles.val}>{spsShown}</b>/초</span>
<span className={styles.metaDim}>캔버스 · ImageData · putImageData</span>
</div>
</div>
</main>
);
}06 · 결정 격자 채색 — 나이(접촉 시각)를 파스텔 고리로 새기기 — 측정 누적면적 9.325% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
접촉 시각을 파스텔 나이테로 읽는 법
function buildLUT() {
// 띠 경계마다 살짝 밝은 테(나이테 선) — 같은 나이끼리 묶여 동심원으로 보인다
var cs = [C_DUSK, C_DUSK, C_HALO, C_MINT, C_MIST, C_SKY, C_MINT, C_CORE];
for (var i = 0; i < 256; i++) {
var t = i / 255;
var band = Math.min(cs.length - 1, 1 + Math.floor(t * (cs.length - 1)));
var c0 = cs[band - 1], c1 = cs[band];
var f = Math.min(1, t * (cs.length - 1) - (band - 1) * 2 + 1); // 띠 안에서 부드럽게
var ring = t * (cs.length - 1) % 1;
var edge = ring < 0.16 ? 0.35 * (1 - ring / 0.16) : 0; // 띠 시작 = 밝은 나이테 선
var r0 = c0[0] + (c1[0] - c0[0]) * f, g0 = c0[1] + (c1[1] - c0[1]) * f, b0 = c0[2] + (c1[2] - c0[2]) * f;
lut.r[i] = r0 + (C_CORE[0] - r0) * edge;
lut.g[i] = g0 + (C_CORE[1] - g0) * edge;
lut.b[i] = b0 + (C_CORE[2] - b0) * edge;
}
}
RINGS 8단으로 나눈 파스텔 색을 256단계 LUT로 미리 구워 두고, 그리는 쪽은 age / total(붙은 시각을 전체 응집 수로 나눈 비율)로 이 표를 찾아보기만 합니다 — edge 항이 띠가 바뀌는 경계마다 밝은 테를 얹어 나이테 선을 만들고, 격자가 정사각 46칸으로 좁아 프레임 사이 바뀌는 픽셀 비율(누적면적)은 아홉 판 중 가장 낮게 잡혔습니다.
07포인터 씨앗 — 클릭한 자리에서 두 번째 결정이 같이 자라기
클릭(또는 터치)한 지점에 씨앗을 하나 더 심으면 입자들이 두 결정 사이에서 경쟁하며 응집합니다 — 닿는 순간 서로의 가지가 얽히는 상호 성장이고, 실측 누적면적 34.032% · 강도 평균 56.9 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="14" stroke-linecap="round"><path d="M128 22v212"/><path d="M38.9 73.5l178.2 129"/><path d="M217.1 73.5L38.9 202.5"/><path d="M104 46l24 20 24-20"/><path d="M104 210l24-20 24 20"/></g></svg>
<span>중앙 결정에 더해 클릭한 자리마다 씨앗을 하나 더 심으면 입자들이 결정들 사이를 오가며 경쟁으로 붙습니다 — 가지가 만나 얽히는 상호 성장입니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="포인터 씨앗 결정 경쟁 성장 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv" width="424" height="180"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdVal">1</b>개 결정 · 클릭으로 씨앗 추가</span>
<span class="cd__meta cd__meta--dim">pointermove · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포인터 씨앗(07). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(6각 격자 DLA 응집 + 광륜 blur + 잔광 꼬리)가 만든다.
// 무대는 밤하늘 잉크. 결정은 파스텔 화이트 코어에 결정마다 라일락·민트·핑크 고유 색.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD26: vivid 순색 없음 — 전부 파스텔이거나 어스름 무채색) ──
$flake-ink: #141720; // 밤하늘 무대
$flake-sky: #232840; // 깊은 슬레이트
$flake-core: #f4f7ff; // 결정 코어 — 서리 화이트
$flake-lilac: #dbd6eb; // 결정 1 라일락
$flake-mint: #a8dbc8; // 결정 2 민트
$flake-pink: #f2b9cf; // 결정 3 핑크
$flake-node: #d5d9e4; // 안개색 글자·테두리
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-sky: #{$flake-sky};
--flake-core: #{$flake-core};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-pink: #{$flake-pink};
--flake-node: #{$flake-node};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-node; // 잉크 무대 위 — 안개색 글자(대비 10.9:1)
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-lilac; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-node, .18);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flake-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-node, .78); }
.cd__val { color: $flake-mint; font-weight: 700; } // 잉크 위 민트 라벨(대비 9.9:1)
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 다 자란 한 장만 남는다(JS 가 is-still 부착).
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #141720;
--flake-sky: #232840;
--flake-core: #f4f7ff;
--flake-lilac: #dbd6eb;
--flake-mint: #a8dbc8;
--flake-pink: #f2b9cf;
--flake-node: #d5d9e4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d5d9e4;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #dbd6eb;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #141720;
box-shadow: inset 0 0 0 1px rgba(213, 217, 228, 0.18);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #dbd6eb;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(213, 217, 228, 0.78);
}
.cd__val {
color: #a8dbc8;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 포인터 씨앗(07). DLA(확산제한응집) — 바깥 링에서 태어난 입자가 6각 격자 위를 랜덤워크하다
// 결정에 닿는 순간 붙는다. 씨앗(결정 중심)을 여러 개 심으면 입자들이 어느 결정에 닿느냐를
// 두고 경쟁하며, 가지가 만나는 지점에서 서로 얽혀 붙는다(만나도 규칙은 그대로 — 닿는 쪽에 붙는 것뿐).
// 화면은 4겹으로 산다: ① 숨쉬는 조명·떠도는 워시 배경 ② 결정 광륜(격자 확대 blur + lighter)
// ③ 격자 ImageData 를 2배로 확대한 결정 본체 ④ 잔광 꼬리를 남기는 부유 입자 + 반짝임.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var STICKS_PER_FRAME = 58; // 한 프레임에 붙일 입자 수 — 늘리면 결정이 빨리 자란다
var RMAX = 34; // 결정 최대 반경(셀) — 이 안에서만 붙는다
var WALKER_MAX_STEPS = 420; // 입자 하나의 최대 걸음 — 넘으면 죽고 새 입자가 태어난다
var ATTEMPT_CAP = 420; // 프레임당 입자 시도 상한 — 결정이 다 차도 루프가 안 멈추게
var MAX_CRYSTALS = 5; // 씨앗 상한
// 포인터가 없는 자동 렌더(측정 gif/mp4) 폴백 — [시각ms, 가로비, 세로비]마다 씨앗을 자동으로 심는다
var AUTO_SEEDS = [[450, 0.72, 0.34], [1150, 0.26, 0.30]];
// ────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var valOut = document.getElementById('cdVal');
// 내부 해상도는 424×180 로 고정 — CSS 로 축소하므로 창 크기가 바뀌어도 격자 상태가 안 흔들린다
var W = cv.width, H = cv.height;
var CELL = 2, GW = W / CELL, GH = H / CELL; // 격자 212×90
// 시드 고정 PRNG — mulberry32. 결정의 둥근 결이 매 실행 같은 성격으로 흐른다
var prngState = 0x31907a >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 scss 의 --flake-* 에서 getComputedStyle 로 — 파스텔 토큰이 scss 한 곳에
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('--flake-ink');
var C_CORE = tokRGB('--flake-core');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_PINK = tokRGB('--flake-pink');
var TINTS = [C_LILAC, C_MINT, C_PINK]; // 결정마다 고유 파스텔 — 경쟁하는 둘을 눈으로 구분한다
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// ── 격자 상태 ──
var tStick = new Int32Array(GW * GH); // 셀에 붙은 시각(ms), -1 = 빈칸
var owner = new Int8Array(GW * GH); // 셀을 소유한 결정 번호
for (var i0 = 0; i0 < tStick.length; i0++) { tStick[i0] = -1; owner[i0] = -1; }
// 6각 인접(odd-r 행 오프셋) — 정사각 픽셀 위에 60° 방향의 가지를 세우는 눈결정 위상
var NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
var NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
var crystals = []; // {cx, cy, maxR, born, count, id}
var sparkles = []; // {x, y, t} 갓 붙은 자리의 반짝임
var rings = []; // {x, y, t} 씨앗을 심은 순간의 확산 링
var motes = []; // 부유 입자 — 잔광 꼬리
function idx(x, y) { return y * GW + x; }
function inside(x, y) { return x >= 0 && x < GW && y >= 0 && y < GH; }
function hexNbr(y) { return (y & 1) ? NX_ODD : NX_EVEN; }
function hexNbrY(y) { return (y & 1) ? NY_ODD : NY_EVEN; }
function filledNb(x, y) {
var nx = hexNbr(y), ny = hexNbrY(y), n = 0;
for (var k = 0; k < 6; k++) {
var ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] >= 0) n++;
}
return n;
}
function stick(x, y, own, now) {
var id = idx(x, y);
tStick[id] = now; owner[id] = own;
if (sparkles.length > 96) sparkles.shift();
sparkles.push({ x: x * CELL + 1, y: y * CELL + 1, t: now });
}
function plantSeed(fx, fy, now) {
if (crystals.length >= MAX_CRYSTALS) return;
var cx = Math.round(fx * GW), cy = Math.round(fy * GH);
if (!inside(cx, cy) || tStick[idx(cx, cy)] >= 0) return;
var id = crystals.length;
crystals.push({ cx: cx, cy: cy, maxR: 2, born: now, count: 0, id: id });
tStick[idx(cx, cy)] = now; owner[idx(cx, cy)] = id;
var nx = hexNbr(cy), ny = hexNbrY(cy); // 씨앗 주변 한 바퀴를 얼려 출발점을 만든다
for (var k = 0; k < 6; k++) {
var ax = cx + nx[k], ay = cy + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] < 0) { tStick[idx(ax, ay)] = now; owner[idx(ax, ay)] = id; }
}
rings.push({ x: cx * CELL + 1, y: cy * CELL + 1, t: now });
}
// ── DLA 응집 — 씨앗 링에서 입자를 뿌려 6각 랜덤워크, 닿으면 확률로 붙는다 ──
function aggregate(now) {
if (!crystals.length) return;
var stuck = 0, attempts = 0;
while (stuck < STICKS_PER_FRAME && attempts < ATTEMPT_CAP) {
attempts++;
var cr = crystals[(rand() * crystals.length) | 0];
var ang = rand() * Math.PI * 2;
var rr = cr.maxR + 6 + rand() * 14;
var x = Math.round(cr.cx + Math.cos(ang) * rr);
var y = Math.round(cr.cy + Math.sin(ang) * rr * 0.92);
var steps = 0;
while (steps < WALKER_MAX_STEPS) {
steps++;
var dir = (rand() * 6) | 0;
var nb = hexNbr(y), sb = hexNbrY(y);
x += nb[dir]; y += sb[dir];
if (!inside(x, y)) break;
if (tStick[idx(x, y)] >= 0) { x -= nb[dir]; y -= sb[dir]; continue; } // 언 셀엔 못 들어간다
var n = filledNb(x, y);
if (n > 0) {
var p = n === 1 ? 1 : n === 2 ? 0.55 : 0.14; // 팁 바이어스 — 가지가 뻗는다
var dist = Math.hypot(x - cr.cx, y - cr.cy);
p *= 0.35 + 0.65 * Math.min(1, dist / (cr.maxR + 4)); // 바깥쪽에 더 잘 붙는다
if (dist < RMAX && rand() < p) {
var own = ownerOfNeighbor(x, y);
stick(x, y, own, now);
cr.count++; stuck++;
if (dist > cr.maxR) cr.maxR = dist;
break;
}
}
}
}
}
function ownerOfNeighbor(x, y) {
var nx = hexNbr(y), ny = hexNbrY(y);
for (var k = 0; k < 6; k++) {
var ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] >= 0) return owner[idx(ax, ay)]; // 닿은 결정의 소유가 된다 — 경쟁
}
return 0;
}
// ── 격자 → ImageData → 2배 확대 캔버스 ──
var gridCv = document.createElement('canvas'); gridCv.width = GW; gridCv.height = GH;
var gtx = gridCv.getContext('2d');
var img = gtx.createImageData(GW, GH);
var px = img.data;
function repaintGrid(now) {
for (var y = 0; y < GH; y++) {
for (var x = 0; x < GW; x++) {
var id = y * GW + x, o = id * 4;
var t0 = tStick[id];
if (t0 < 0) { px[o + 3] = 0; continue; }
var age = now - t0;
var tint = TINTS[owner[id] % TINTS.length];
var k = Math.min(1, age / 900); // 갓 붙은 하양 → 결정 고유 파스텔로 정착
var lift = filledNb(x, y) <= 2 ? 30 : 0; // 팁(인접 ≤2)은 서리 반짝임으로 더 밝다
px[o] = Math.min(255, C_CORE[0] + (tint[0] - C_CORE[0]) * k + lift);
px[o + 1] = Math.min(255, C_CORE[1] + (tint[1] - C_CORE[1]) * k + lift);
px[o + 2] = Math.min(255, C_CORE[2] + (tint[2] - C_CORE[2]) * k + lift);
px[o + 3] = 255;
}
}
gtx.putImageData(img, 0, 0);
}
// ── 배경 — 숨쉬는 중앙 조명 + 리사주로 떠도는 민트 워시 ──
var bgCv = document.createElement('canvas'); bgCv.width = W; bgCv.height = H;
var btx = bgCv.getContext('2d');
function repaintBg(now) {
btx.globalCompositeOperation = 'source-over';
btx.fillStyle = rgba(C_INK, 1);
btx.fillRect(0, 0, W, H);
var a1 = 0.13 + 0.09 * Math.sin(now / 700);
var g1 = btx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, 235);
g1.addColorStop(0, rgba(C_LILAC, a1));
g1.addColorStop(1, rgba(C_LILAC, 0));
btx.fillStyle = g1; btx.fillRect(0, 0, W, H);
var wx = W * (0.5 + 0.30 * Math.sin(now / 2600));
var wy = H * (0.5 + 0.26 * Math.sin(now / 1900 + 1.7));
var g2 = btx.createRadialGradient(wx, wy, 0, wx, wy, 170);
g2.addColorStop(0, rgba(C_MINT, 0.10));
g2.addColorStop(1, rgba(C_MINT, 0));
btx.fillStyle = g2; btx.fillRect(0, 0, W, H);
// 반대편에서 도는 핑크 안개 — 배경 세 겹이 각기 다른 주기로 숨쉰다
var px2 = W * (0.5 - 0.32 * Math.sin(now / 3100));
var py2 = H * (0.5 - 0.24 * Math.sin(now / 2200 + 0.8));
var g3 = btx.createRadialGradient(px2, py2, 0, px2, py2, 160);
g3.addColorStop(0, rgba(C_PINK, 0.085));
g3.addColorStop(1, rgba(C_PINK, 0));
btx.fillStyle = g3; btx.fillRect(0, 0, W, H);
}
// ── 잔광 꼬리 레이어 — 지우지 않고 destination-out 로 녹여 꼬리를 남긴다 ──
var trailCv = document.createElement('canvas'); trailCv.width = W; trailCv.height = H;
var ttx = trailCv.getContext('2d');
function initMotes() {
var cols = [C_LILAC, C_MINT, C_PINK, C_CORE];
for (var m = 0; m < 44; m++) {
motes.push({
x: rand() * W, y: rand() * H,
vx: (rand() - 0.5) * 1.3, vy: (rand() - 0.5) * 0.85,
soft: rand() < 0.3, col: cols[(rand() * cols.length) | 0]
});
}
}
function repaintTrails() {
ttx.globalCompositeOperation = 'destination-out';
ttx.fillStyle = 'rgba(0,0,0,0.13)';
ttx.fillRect(0, 0, W, H);
ttx.globalCompositeOperation = 'lighter';
for (var i = 0; i < motes.length; i++) {
var mo = motes[i];
mo.x += mo.vx; mo.y += mo.vy;
if (mo.x < -8) mo.x = W + 8; else if (mo.x > W + 8) mo.x = -8;
if (mo.y < -8) mo.y = H + 8; else if (mo.y > H + 8) mo.y = -8;
if (mo.soft) {
var g = ttx.createRadialGradient(mo.x, mo.y, 0, mo.x, mo.y, 5);
g.addColorStop(0, rgba(mo.col, 0.20));
g.addColorStop(1, rgba(mo.col, 0));
ttx.fillStyle = g;
ttx.fillRect(mo.x - 5, mo.y - 5, 10, 10);
} else {
ttx.fillStyle = rgba(mo.col, 0.5);
ttx.fillRect(mo.x, mo.y, 1.6, 1.6);
}
}
}
// ── 포인터 — 움직이면 헤일로가 따라오고, 누르면 그 자리에 씨앗 ──
var ptr = { x: -99, y: -99, t: -9999, on: false };
function toLocal(e) {
var r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
}
field.addEventListener('pointermove', function (e) {
var p = toLocal(e);
ptr.x = p[0]; ptr.y = p[1]; ptr.t = perfNow(); ptr.on = true;
});
field.addEventListener('pointerleave', function () { ptr.on = false; });
field.addEventListener('pointerdown', function (e) {
var p = toLocal(e);
plantSeed(p[0] / W, p[1] / H, perfNow());
valOut.textContent = String(crystals.length);
});
var t0 = -1;
function perfNow() { return t0 < 0 ? 0 : performance.now() - t0; }
// ── 합성 ──
function draw(now) {
repaintBg(now);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1; ctx.filter = 'none';
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bgCv, 0, 0);
// 결정 광륜 — 격자를 뿌옇게 확대해 lighter 로 쌓는다(호흡하는 할로)
ctx.globalCompositeOperation = 'lighter';
ctx.filter = 'blur(5px)';
ctx.globalAlpha = 0.26 + 0.08 * Math.sin(now / 820);
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.filter = 'none'; ctx.globalAlpha = 1;
// 결정 본체 — 픽셀 그대로 (셀 2px 각진 서리 결)
ctx.globalCompositeOperation = 'source-over';
ctx.imageSmoothingEnabled = false;
ctx.drawImage(gridCv, 0, 0, W, H);
// 잔광 꼬리
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(trailCv, 0, 0);
// 씨앗 확산 링
for (var i = rings.length - 1; i >= 0; i--) {
var r = rings[i], ra = now - r.t;
if (ra > 620) { rings.splice(i, 1); continue; }
ctx.beginPath();
ctx.arc(r.x, r.y, 5 + ra * 0.085, 0, Math.PI * 2);
ctx.strokeStyle = rgba(C_LILAC, 0.38 * (1 - ra / 620));
ctx.lineWidth = 1.6;
ctx.stroke();
}
// 갓 붙은 입자의 반짝임
for (var s = sparkles.length - 1; s >= 0; s--) {
var sp = sparkles[s], sa = now - sp.t;
if (sa > 340) { sparkles.splice(s, 1); continue; }
var fal = 1 - sa / 340;
ctx.fillStyle = rgba(C_CORE, 0.75 * fal);
ctx.fillRect(sp.x - 1.2, sp.y - 1.2, 2.4, 2.4);
}
// 포인터 헤일로 — 결정 위를 스치는 은은한 수증기
if (ptr.on && now - ptr.t < 420) {
var pg = ctx.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, 26);
pg.addColorStop(0, rgba(C_CORE, 0.16));
pg.addColorStop(1, rgba(C_CORE, 0));
ctx.fillStyle = pg;
ctx.fillRect(ptr.x - 26, ptr.y - 26, 52, 52);
}
ctx.globalCompositeOperation = 'source-over';
}
var lastVal = 0;
function frame(nowRaw) {
if (t0 < 0) t0 = nowRaw;
var now = nowRaw - t0;
// 자동 씨앗 폴백 — 포인터가 없는 측정 렌더에서도 결정이 여럿 경쟁하게 한다
for (var i = 0; i < AUTO_SEEDS.length; i++) {
if (AUTO_SEEDS[i][0] > 0 && now >= AUTO_SEEDS[i][0]) {
plantSeed(AUTO_SEEDS[i][1], AUTO_SEEDS[i][2], now);
AUTO_SEEDS[i][0] = 0;
}
}
aggregate(now);
repaintGrid(now);
repaintTrails();
draw(now);
if (now - lastVal > 480) { lastVal = now; valOut.textContent = String(crystals.length); }
requestAnimationFrame(frame);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
initMotes();
plantSeed(0.46, 0.50, 0);
if (reduce) {
// 결정이 다 자란 한 장만 남긴다 — 3초치 응집을 동기로 몰아 그린다
for (var w = 0; w < 90; w++) aggregate(w * 16.7);
repaintGrid(3000);
repaintBg(3000);
draw(3000);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// PointerSeedGrow — vanilla 판(run/319/demo/07-snowflake-pointer-seed-grow/index.html) 의 React 포트.
// 6각 격자 DLA(확산제한응집) — 바깥에서 태어난 입자가 랜덤워크하다 결정에 닿으면 붙는다.
// 클릭(포인터다운)한 자리에 씨앗을 더 심으면 입자들이 여러 결정 사이에서 경쟁하며 붙는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./PointerSeedGrow.module.scss";
export type PointerSeedGrowProps = {
duration?: string;
easing?: string;
color?: string;
sticksPerFrame?: number; // 한 프레임에 붙일 입자 수 — 늘리면 결정이 빨리 자란다
rMax?: number; // 결정 최대 반경(셀)
maxCrystals?: number; // 씨앗 상한
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Crystal = { cx: number; cy: number; maxR: number; count: number };
type Sparkle = { x: number; y: number; t: number };
type Ring = { x: number; y: number; t: number };
type Mote = { x: number; y: number; vx: number; vy: number; soft: boolean; col: RGB };
const FIELD_LABEL = "포인터 씨앗 결정 경쟁 성장 실험판";
const AUTO_SEEDS_INIT: [number, number, number][] = [[450, 0.72, 0.34], [1150, 0.26, 0.30]];
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;
};
}
const NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
const NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
export default function PointerSeedGrow({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a8ef",
sticksPerFrame = 58,
rMax = 34,
maxCrystals = 5,
}: PointerSeedGrowProps) {
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; core: RGB; lilac: RGB; mint: RGB; pink: RGB } | null>(null);
const randRef = useRef(makePRNG(0x31907a)) as Box<() => number>;
const [valLabel, setValLabel] = useState(1);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// 격자 상태 — 캔버스 내부 해상도는 424x180 고정
const W = 424, H = 180, CELL = 2, GW = W / CELL, GH = H / CELL;
const tStickRef = useRef<Int32Array>(new Int32Array(GW * GH).fill(-1)) as Box<Int32Array>;
const ownerRef = useRef<Int8Array>(new Int8Array(GW * GH).fill(-1)) as Box<Int8Array>;
const crystalsRef = useRef<Crystal[]>([]) as Box<Crystal[]>;
const sparklesRef = useRef<Sparkle[]>([]) as Box<Sparkle[]>;
const ringsRef = useRef<Ring[]>([]) as Box<Ring[]>;
const motesRef = useRef<Mote[]>([]) as Box<Mote[]>;
const autoSeedsRef = useRef<[number, number, number][]>(AUTO_SEEDS_INIT.map((s) => [...s] as [number, number, number])) as Box<[number, number, number][]>;
const ptrRef = useRef({ x: -99, y: -99, t: -9999, on: false }) as Box<{ x: number; y: number; t: number; on: boolean }>;
const gridCvRef = useRef<HTMLCanvasElement | null>(null);
const bgCvRef = useRef<HTMLCanvasElement | null>(null);
const trailCvRef = useRef<HTMLCanvasElement | null>(null);
const rafRef = useRef(0) as Box<number>;
function idx(x: number, y: number): number { return y * GW + x; }
function inside(x: number, y: number): boolean { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y: number): number[] { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y: number): number[] { return (y & 1) ? NY_ODD : NY_EVEN; }
function filledNb(x: number, y: number): number {
const tStick = tStickRef.current;
const nx = nbX(y), ny = nbY(y);
let n = 0;
for (let k = 0; k < 6; k++) {
const ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] >= 0) n++;
}
return n;
}
function stick(x: number, y: number, own: number, now: number): void {
const id = idx(x, y);
tStickRef.current[id] = now;
ownerRef.current[id] = own;
const sp = sparklesRef.current;
if (sp.length > 96) sp.shift();
sp.push({ x: x * CELL + 1, y: y * CELL + 1, t: now });
}
function ownerOfNeighbor(x: number, y: number): number {
const tStick = tStickRef.current, owner = ownerRef.current;
const nx = nbX(y), ny = nbY(y);
for (let k = 0; k < 6; k++) {
const ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] >= 0) return owner[idx(ax, ay)];
}
return 0;
}
function plantSeed(fx: number, fy: number, now: number): void {
const crystals = crystalsRef.current;
if (crystals.length >= maxCrystals) return;
const cx = Math.round(fx * GW), cy = Math.round(fy * GH);
if (!inside(cx, cy) || tStickRef.current[idx(cx, cy)] >= 0) return;
const id = crystals.length;
crystals.push({ cx, cy, maxR: 2, count: 0 });
stick(cx, cy, id, now);
const nx = nbX(cy), ny = nbY(cy);
for (let k = 0; k < 6; k++) {
const ax = cx + nx[k], ay = cy + ny[k];
if (inside(ax, ay) && tStickRef.current[idx(ax, ay)] < 0) stick(ax, ay, id, now);
}
ringsRef.current.push({ x: cx * CELL + 1, y: cy * CELL + 1, t: now });
}
function aggregate(now: number): void {
const crystals = crystalsRef.current;
if (!crystals.length) return;
const rand = randRef.current, tStick = tStickRef.current;
let stuck = 0, attempts = 0;
const ATTEMPT_CAP = 420, WALKER_MAX_STEPS = 420;
while (stuck < sticksPerFrame && attempts < ATTEMPT_CAP) {
attempts++;
const cr = crystals[(rand() * crystals.length) | 0];
const ang = rand() * Math.PI * 2;
const rr = cr.maxR + 6 + rand() * 14;
let x = Math.round(cr.cx + Math.cos(ang) * rr);
let y = Math.round(cr.cy + Math.sin(ang) * rr * 0.92);
let steps = 0;
while (steps < WALKER_MAX_STEPS) {
steps++;
const dir = (rand() * 6) | 0;
const bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (tStick[idx(x, y)] >= 0) { x -= bx[dir]; y -= by[dir]; continue; }
const n = filledNb(x, y);
if (n > 0) {
let p = n === 1 ? 1 : n === 2 ? 0.55 : 0.14;
const dist = Math.hypot(x - cr.cx, y - cr.cy);
p *= 0.35 + 0.65 * Math.min(1, dist / (cr.maxR + 4));
if (dist < rMax && rand() < p) {
const own = ownerOfNeighbor(x, y);
stick(x, y, own, now);
cr.count++; stuck++;
if (dist > cr.maxR) cr.maxR = dist;
break;
}
}
}
}
}
function rgba(c: RGB, a: number): string { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function repaintGrid(now: number): void {
const gridCv = gridCvRef.current;
if (!gridCv) return;
const gtx = gridCv.getContext("2d");
if (!gtx) return;
const col = colRef.current;
if (!col) return;
const tStick = tStickRef.current, owner = ownerRef.current;
const TINTS: RGB[] = [col.lilac, col.mint, col.pink];
const img = gtx.createImageData(GW, GH);
const px = img.data;
for (let y = 0; y < GH; y++) {
for (let x = 0; x < GW; x++) {
const id = y * GW + x, o = id * 4;
const t0 = tStick[id];
if (t0 < 0) { px[o + 3] = 0; continue; }
const age = now - t0;
const tint = TINTS[((owner[id] % 3) + 3) % 3];
const k = Math.min(1, age / 900);
const lift = filledNb(x, y) <= 2 ? 30 : 0;
px[o] = Math.min(255, col.core[0] + (tint[0] - col.core[0]) * k + lift);
px[o + 1] = Math.min(255, col.core[1] + (tint[1] - col.core[1]) * k + lift);
px[o + 2] = Math.min(255, col.core[2] + (tint[2] - col.core[2]) * k + lift);
px[o + 3] = 255;
}
}
gtx.putImageData(img, 0, 0);
}
function repaintBg(now: number): void {
const bgCv = bgCvRef.current, col = colRef.current;
if (!bgCv || !col) return;
const btx = bgCv.getContext("2d");
if (!btx) return;
btx.globalCompositeOperation = "source-over";
btx.fillStyle = rgba(col.ink, 1);
btx.fillRect(0, 0, W, H);
const a1 = 0.09 + 0.07 * Math.sin(now / 700);
const g1 = btx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, 205);
g1.addColorStop(0, rgba(col.lilac, a1));
g1.addColorStop(1, rgba(col.lilac, 0));
btx.fillStyle = g1; btx.fillRect(0, 0, W, H);
const wx = W * (0.5 + 0.30 * Math.sin(now / 2600));
const wy = H * (0.5 + 0.26 * Math.sin(now / 1900 + 1.7));
const g2 = btx.createRadialGradient(wx, wy, 0, wx, wy, 150);
g2.addColorStop(0, rgba(col.mint, 0.07));
g2.addColorStop(1, rgba(col.mint, 0));
btx.fillStyle = g2; btx.fillRect(0, 0, W, H);
}
function initMotes(): void {
const col = colRef.current;
if (!col) return;
const rand = randRef.current;
const cols: RGB[] = [col.lilac, col.mint, col.pink, col.core];
const motes = motesRef.current;
for (let m = 0; m < 26; m++) {
motes.push({
x: rand() * W, y: rand() * H,
vx: (rand() - 0.5) * 1.15, vy: (rand() - 0.5) * 0.75,
soft: rand() < 0.22, col: cols[(rand() * cols.length) | 0],
});
}
}
function repaintTrails(): void {
const trailCv = trailCvRef.current;
if (!trailCv) return;
const ttx = trailCv.getContext("2d");
if (!ttx) return;
ttx.globalCompositeOperation = "destination-out";
ttx.fillStyle = "rgba(0,0,0,0.13)";
ttx.fillRect(0, 0, W, H);
ttx.globalCompositeOperation = "lighter";
const motes = motesRef.current;
for (let i = 0; i < motes.length; i++) {
const mo = motes[i];
mo.x += mo.vx; mo.y += mo.vy;
if (mo.x < -8) mo.x = W + 8; else if (mo.x > W + 8) mo.x = -8;
if (mo.y < -8) mo.y = H + 8; else if (mo.y > H + 8) mo.y = -8;
if (mo.soft) {
const g = ttx.createRadialGradient(mo.x, mo.y, 0, mo.x, mo.y, 5);
g.addColorStop(0, rgba(mo.col, 0.20));
g.addColorStop(1, rgba(mo.col, 0));
ttx.fillStyle = g;
ttx.fillRect(mo.x - 5, mo.y - 5, 10, 10);
} else {
ttx.fillStyle = rgba(mo.col, 0.5);
ttx.fillRect(mo.x, mo.y, 1.6, 1.6);
}
}
}
function draw(now: number): void {
const ctx = ctxRef.current, col = colRef.current;
const gridCv = gridCvRef.current, bgCv = bgCvRef.current, trailCv = trailCvRef.current;
if (!ctx || !col || !gridCv || !bgCv || !trailCv) return;
repaintBg(now);
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1; ctx.filter = "none";
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bgCv, 0, 0);
ctx.globalCompositeOperation = "lighter";
ctx.filter = "blur(5px)";
ctx.globalAlpha = 0.26 + 0.08 * Math.sin(now / 820);
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.filter = "none"; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
ctx.imageSmoothingEnabled = false;
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
ctx.drawImage(trailCv, 0, 0);
const rings = ringsRef.current;
for (let i = rings.length - 1; i >= 0; i--) {
const r = rings[i], ra = now - r.t;
if (ra > 620) { rings.splice(i, 1); continue; }
ctx.beginPath();
ctx.arc(r.x, r.y, 5 + ra * 0.085, 0, Math.PI * 2);
ctx.strokeStyle = rgba(col.lilac, 0.38 * (1 - ra / 620));
ctx.lineWidth = 1.6;
ctx.stroke();
}
const sparkles = sparklesRef.current;
for (let s = sparkles.length - 1; s >= 0; s--) {
const sp = sparkles[s], sa = now - sp.t;
if (sa > 340) { sparkles.splice(s, 1); continue; }
const fal = 1 - sa / 340;
ctx.fillStyle = rgba(col.core, 0.75 * fal);
ctx.fillRect(sp.x - 1.2, sp.y - 1.2, 2.4, 2.4);
}
const ptr = ptrRef.current;
if (ptr.on && now - ptr.t < 420) {
const pg = ctx.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, 26);
pg.addColorStop(0, rgba(col.core, 0.16));
pg.addColorStop(1, rgba(col.core, 0));
ctx.fillStyle = pg;
ctx.fillRect(ptr.x - 26, ptr.y - 26, 52, 52);
}
ctx.globalCompositeOperation = "source-over";
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>): void {
const cv = cvRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const ptr = ptrRef.current;
ptr.x = (e.clientX - r.left) * (W / r.width);
ptr.y = (e.clientY - r.top) * (H / r.height);
ptr.on = true;
}
function handlePointerLeave(): void { ptrRef.current.on = false; }
function handlePointerDown(e: PointerEvent<HTMLDivElement>): void {
const cv = cvRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const fx = ((e.clientX - r.left) * (W / r.width)) / W;
const fy = ((e.clientY - r.top) * (H / r.height)) / H;
plantSeed(fx, fy, performance.now());
setValLabel(crystalsRef.current.length);
}
useEffect(() => {
const root = rootRef.current, cv = cvRef.current;
if (!root || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
gridCvRef.current = document.createElement("canvas");
gridCvRef.current.width = GW; gridCvRef.current.height = GH;
bgCvRef.current = document.createElement("canvas");
bgCvRef.current.width = W; bgCvRef.current.height = H;
trailCvRef.current = document.createElement("canvas");
trailCvRef.current.width = W; trailCvRef.current.height = H;
function tokRGB(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)];
}
colRef.current = {
ink: tokRGB("--flake-ink"), core: tokRGB("--flake-core"),
lilac: tokRGB("--flake-lilac"), mint: tokRGB("--flake-mint"), pink: tokRGB("--flake-pink"),
};
randRef.current = makePRNG(0x31907a);
tStickRef.current = new Int32Array(GW * GH).fill(-1);
ownerRef.current = new Int8Array(GW * GH).fill(-1);
crystalsRef.current = [];
sparklesRef.current = [];
ringsRef.current = [];
motesRef.current = [];
autoSeedsRef.current = AUTO_SEEDS_INIT.map((s) => [...s] as [number, number, number]);
initMotes();
plantSeed(0.46, 0.50, 0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let t0 = -1, lastVal = 0;
function frame(nowRaw: number): void {
if (t0 < 0) t0 = nowRaw;
const now = nowRaw - t0;
const seeds = autoSeedsRef.current;
for (let i = 0; i < seeds.length; i++) {
if (seeds[i][0] > 0 && now >= seeds[i][0]) {
plantSeed(seeds[i][1], seeds[i][2], now);
seeds[i][0] = 0;
}
}
aggregate(now);
repaintGrid(now);
repaintTrails();
draw(now);
if (now - lastVal > 480) { lastVal = now; setValLabel(crystalsRef.current.length); }
rafRef.current = requestAnimationFrame(frame);
}
if (reduce) {
for (let w = 0; w < 90; w++) aggregate(w * 16.7);
repaintGrid(3000);
repaintBg(3000);
draw(3000);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => cancelAnimationFrame(rafRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sticksPerFrame, rMax, maxCrystals]);
const rootCls = still ? `${styles.cd} ${styles.isStill}` : styles.cd;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" strokeWidth={14} strokeLinecap="round"><path d="M128 22v212" /><path d="M38.9 73.5l178.2 129" /><path d="M217.1 73.5L38.9 202.5" /><path d="M104 46l24 20 24-20" /><path d="M104 210l24-20 24 20" /></g></svg>
<span>중앙 결정에 더해 클릭한 자리마다 씨앗을 하나 더 심으면 입자들이 결정들 사이를 오가며 경쟁으로 붙습니다 — 가지가 만나 얽히는 상호 성장입니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onPointerDown={handlePointerDown}
>
<canvas className={styles.cv} ref={cvRef} width={W} height={H}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{valLabel}</b>개 결정 · 클릭으로 씨앗 추가</span>
<span className={styles.metaDim}>pointermove · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}07 · 포인터 씨앗 — 클릭한 자리에서 두 번째 결정이 같이 자라기 — 측정 누적면적 34.032% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
두 씨앗이 경쟁하며 응집하는 규칙
function stick(x, y, own, now) {
var id = idx(x, y);
tStick[id] = now; owner[id] = own;
if (sparkles.length > 96) sparkles.shift();
sparkles.push({ x: x * CELL + 1, y: y * CELL + 1, t: now });
}
function plantSeed(fx, fy, now) {
if (crystals.length >= MAX_CRYSTALS) return;
var cx = Math.round(fx * GW), cy = Math.round(fy * GH);
if (!inside(cx, cy) || tStick[idx(cx, cy)] >= 0) return;
var id = crystals.length;
crystals.push({ cx: cx, cy: cy, maxR: 2, born: now, count: 0, id: id });
tStick[idx(cx, cy)] = now; owner[idx(cx, cy)] = id;
var nx = hexNbr(cy), ny = hexNbrY(cy); // 씨앗 주변 한 바퀴를 얼려 출발점을 만든다
for (var k = 0; k < 6; k++) {
var ax = cx + nx[k], ay = cy + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] < 0) { tStick[idx(ax, ay)] = now; owner[idx(ax, ay)] = id; }
}
rings.push({ x: cx * CELL + 1, y: cy * CELL + 1, t: now });
}
owner 배열이 칸마다 어느 결정에 붙었는지 기억해 두는 것이 경쟁의 전부입니다 — AUTO_SEEDS 두 자리에서 자동으로 시작하고, 화면을 클릭하면 plantSeed 가 그 자리에 결정을 하나 더 등록해 같은 응집 계산을 두 owner 로 나눠 갖습니다. TINTS 배열이 결정마다 다른 파스텔을 입혀 어느 쪽이 어느 씨앗에서 자랐는지 눈으로 구분됩니다.
08녹았다 얼기 반복 — 가장 바깥 입자부터 떼어내 다시 붙이는 순환
일정 시간마다 결정 표면의 입자를 확률적으로 떼어내 주변으로 풀어놓으면 결정이 오래된 부분은 남기고 가장자리만 다시 자라는 숨쉬는 결정이 됩니다 — 실측 누적면적 37.253% · 강도 평균 32.7 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="14" stroke-linecap="round"><path d="M128 26v204"/><path d="M43 74l170 128"/><path d="M213 74L43 202"/><circle cx="128" cy="128" r="18"/></g></svg>
<span>결정 표면의 입자를 확률적으로 떼어내 주변에 다시 풀어놓으면 오래된 코어는 남고 가장자리만 계속 다시 자랍니다 — 숨을 쉬는 결정의 온도 순환입니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="녹기·재결정 순환 결정 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv" width="424" height="180"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">주기 <b class="cd__val" id="cdVal">4.0</b>s · 표면 녹이기 → 재응집</span>
<span class="cd__meta cd__meta--dim">ImageData · putImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 녹았다 얼기 반복(08). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(표면 박리 → 방류 입자 랜덤워크 → 재응집 + 온도 순환 조명)가 만든다.
// 무대는 밤하늘 잉크. 결정은 나이 밴드(연륜)를 파스텔 화이트→라일락→민트→핑크로 순환.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD26: vivid 순색 없음 — 전부 파스텔이거나 어스름 무채색) ──
$flake-ink: #12161f; // 밤하늘 무대
$flake-sky: #20263a; // 깊은 슬레이트
$flake-core: #f3f6fb; // 결정 코어 — 서리 화이트
$flake-lilac: #dad6eb; // 찬 기류 라일락(연륜 1)
$flake-mint: #a5d9c6; // 재응집 민트(연륜 2)
$flake-pink: #f0bbd2; // 더운 기류 핑크(연륜 3)
$flake-node: #d3d7e2; // 안개색 글자·테두리
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-sky: #{$flake-sky};
--flake-core: #{$flake-core};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-pink: #{$flake-pink};
--flake-node: #{$flake-node};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-node; // 잉크 무대 위 — 안개색 글자(대비 11.2:1)
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-mint; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-node, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-mint; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-node, .78); }
.cd__val { color: $flake-lilac; font-weight: 700; } // 잉크 위 라일락 라벨(대비 7.6:1)
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 다 자란 한 장만 남는다(JS 가 is-still 부착).
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #12161f;
--flake-sky: #20263a;
--flake-core: #f3f6fb;
--flake-lilac: #dad6eb;
--flake-mint: #a5d9c6;
--flake-pink: #f0bbd2;
--flake-node: #d3d7e2;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d3d7e2;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #a5d9c6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12161f;
box-shadow: inset 0 0 0 1px rgba(211, 215, 226, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #a5d9c6;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(211, 215, 226, 0.78);
}
.cd__val {
color: #dad6eb;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 녹았다 얼기 반복(08). DLA 로 완전히 자란 결정을 무대에 올리고, 온도 사이클을 돌린다.
// 주기의 뒷반(더운 기류)에는 결정 표면(노출된 셀)을 확률적으로 떼어내 근처에 랜덤워크 입자로
// 풀어놓고, 앞반(찬 기류)에는 그 입자들이 표면에 다시 붙는다. 코어(오래된 셀)는 녹을 확률이
// 낮아 남고, 가장자리만 계속 새로 자란다 — 나무 나이테 같은 온도 연륜이 남는다.
// 화면은 5겹으로 산다: ① 온도에 따라 색온도가 도는 배경 조명 ② 격자 확대 blur 광륜
// ③ 격자 ImageData 본체(나이 색) ④ 녹아 떨어져 떠다니는 방류 입자 ⑤ 표면 반짝임.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var CYCLE_MS = 2000; // 온도 순환 한 바퀴(측정 gif 2초의 약수 — 주기가 정확히 겹친다)
var MELT_FRAC = 0.5; // 주기 뒷반(녹는 구간) 비율
var MELT_RATE = 4.6; // 프레임당 표면 박리 시도 수(온도 스케일) — 늘리면 더 많이 녹는다
var FREEZE_PER_FR = 60; // 프레임당 재응집 입자 수
var RMAX = 38; // 결정 반경(셀)
// ────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var valOut = document.getElementById('cdVal');
var W = cv.width, H = cv.height; // 내부 해상도 고정 424×180
var CELL = 2, GW = W / CELL, GH = H / CELL; // 격자 212×90
var CX = (GW / 2) | 0, CY = ((GH / 2) | 0) - 6;
// 시드 고정 PRNG — mulberry32. 순환의 결이 매 실행 같은 성격으로 흐른다
var prngState = 0x31908c >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 scss 의 --flake-* 에서 getComputedStyle 로
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('--flake-ink');
var C_CORE = tokRGB('--flake-core');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_PINK = tokRGB('--flake-pink');
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// ── 격자 상태 ──
var tStick = new Float32Array(GW * GH); // 셀에 붙은 시각(ms) — 나이·연륜의 원료
var frozen = new Uint8Array(GW * GH); // 1 = 언 셀
tStick.fill(-1);
// 6각 인접(odd-r)
var NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
var NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
function idx(x, y) { return y * GW + x; }
function inside(x, y) { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y) { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y) { return (y & 1) ? NY_ODD : NY_EVEN; }
function frozenNb(x, y) {
var nx = nbX(y), ny = nbY(y), n = 0;
for (var k = 0; k < 6; k++) {
var ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && frozen[idx(ax, ay)]) n++;
}
return n;
}
function stick(x, y, now) { frozen[idx(x, y)] = 1; tStick[idx(x, y)] = now; }
// ── 초기 성장 — DLA 로 이글 하나를 완전히 자려둔다(첫 프레임 전에 끝난다) ──
function growFull() {
stick(CX, CY, -9900);
var nx = nbX(CY), ny = nbY(CY);
for (var k = 0; k < 6; k++) {
var ax = CX + nx[k], ay = CY + ny[k];
if (inside(ax, ay)) stick(ax, ay, -9800);
}
var maxR = 2, want = 2050;
var got = 7, tries = 0;
while (got < want && tries < want * 260) {
tries++;
var ang = rand() * Math.PI * 2;
var rr = maxR + 6 + rand() * 16;
var x = Math.round(CX + Math.cos(ang) * rr);
var y = Math.round(CY + Math.sin(ang) * rr * 0.94);
var steps = 0;
while (steps < 480) {
steps++;
var dir = (rand() * 6) | 0;
var bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (frozen[idx(x, y)]) { x -= bx[dir]; y -= by[dir]; continue; }
var n = frozenNb(x, y);
if (n > 0) {
var p = n === 1 ? 1 : n === 2 ? 0.55 : 0.14;
var dist = Math.hypot(x - CX, y - CY);
p *= 0.35 + 0.65 * Math.min(1, dist / (maxR + 4));
if (dist < RMAX && rand() < p) {
stick(x, y, -9900 + got * 3); // 나이를 골고루 — 연륜 여러 겹
got++;
if (dist > maxR) maxR = dist;
break;
}
}
}
}
return got;
}
// ── 녹기 — 표면(노출 ≥3) 셀을 온도 확률로 떼어 방류 입자로 바꾼다 ──
var drifters = []; // {x, y, vx, vy, born, hot} 얼었다 녹아 떠도는 입자
function meltStep(heat, now) {
var tries = Math.round(MELT_RATE * heat * 10);
for (var i = 0; i < tries; i++) {
var x = (rand() * GW) | 0, y = (rand() * GH) | 0;
var id = idx(x, y);
if (!frozen[id]) continue;
var n = frozenNb(x, y);
if (n >= 6) continue; // 완전 매립 셀은 안 녹는다
var age = now - tStick[id];
var pCore = Math.max(0.02, 1 - age / 5200); // 오래된 코어는 녹을 확률이 낮다
var p = (n / 6) * heat * pCore * 0.5;
if (rand() < p) {
frozen[id] = 0;
var ang = rand() * Math.PI * 2, sp = 0.25 + rand() * 0.8;
drifters.push({
x: x * CELL + 1, y: y * CELL + 1,
vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
born: now, hot: true
});
}
}
}
// ── 재응집 — 방류 입자를 결정 표면에 다시 붙인다 ──
function refreezeStep(now) {
var stuck = 0, i = 0;
while (i < drifters.length && stuck < FREEZE_PER_FR) {
var d = drifters[i];
// 방류 입자의 표면 스캔 — 격자 좌표로 환산해 인접 언 셀을 찾는다
var gx = (d.x / CELL) | 0, gy = (d.y / CELL) | 0;
var landed = false;
if (inside(gx, gy) && !frozen[idx(gx, gy)]) {
var n = frozenNb(gx, gy);
if (n > 0) {
var p = n === 1 ? 0.9 : n === 2 ? 0.5 : 0.12;
if (rand() < p) { stick(gx, gy, now); landed = true; stuck++; }
}
}
if (landed) drifters.splice(i, 1); else i++;
}
}
// 방류 입자 물리 — 찬 기류엔 결정 쪽으로 끌려간다(응집의 시각적 예고)
function stepDrifters(now, heat) {
for (var i = drifters.length - 1; i >= 0; i--) {
var d = drifters[i];
d.hot = now - d.born < 900;
var toC = [CX * CELL - d.x, CY * CELL - d.y];
var len = Math.hypot(toC[0], toC[1]) || 1;
var pull = (1 - heat) * 0.028;
d.vx += (toC[0] / len) * pull + (rand() - 0.5) * 0.05;
d.vy += (toC[1] / len) * pull + (rand() - 0.5) * 0.05;
var v = Math.hypot(d.vx, d.vy);
if (v > 1.4) { d.vx *= 1.4 / v; d.vy *= 1.4 / v; }
d.x += d.vx; d.y += d.vy;
if (d.x < 1 || d.x > W - 1 || d.y < 1 || d.y > H - 1) { drifters.splice(i, 1); }
}
}
// ── 격자 → ImageData — 나이(붙은 시각)를 파스텔 연륜으로 새긴다 ──
var gridCv = document.createElement('canvas'); gridCv.width = GW; gridCv.height = GH;
var gtx = gridCv.getContext('2d');
var img = gtx.createImageData(GW, GH);
var px = img.data;
var RINGS = [C_CORE, C_LILAC, C_MINT, C_PINK, C_CORE, C_LILAC]; // 나이 밴드 순환
function repaintGrid(now) {
for (var y = 0; y < GH; y++) {
for (var x = 0; x < GW; x++) {
var id = y * GW + x, o = id * 4;
if (!frozen[id]) { px[o + 3] = 0; continue; }
var age = now - tStick[id];
var band = ((age / 1050) | 0) % RINGS.length; // 나이테 — 약 1초당 한 색
var tint = RINGS[band];
var k = Math.min(1, age / 500) * 0.8;
var lift = frozenNb(x, y) <= 2 ? 26 : 0;
px[o] = Math.min(255, C_CORE[0] + (tint[0] - C_CORE[0]) * k + lift);
px[o + 1] = Math.min(255, C_CORE[1] + (tint[1] - C_CORE[1]) * k + lift);
px[o + 2] = Math.min(255, C_CORE[2] + (tint[2] - C_CORE[2]) * k + lift);
px[o + 3] = 255;
}
}
gtx.putImageData(img, 0, 0);
}
// ── 배경 — 온도 색온도가 도는 조명. 찬 기류 = 라일락, 더운 기류 = 핑크 워시 ──
var bgCv = document.createElement('canvas'); bgCv.width = W; bgCv.height = H;
var btx = bgCv.getContext('2d');
function repaintBg(now, heat) {
btx.globalCompositeOperation = 'source-over';
btx.fillStyle = rgba(C_INK, 1);
btx.fillRect(0, 0, W, H);
var warm = btx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 240);
warm.addColorStop(0, rgba(C_LILAC, 0.13 + 0.07 * Math.sin(now / 830)));
warm.addColorStop(1, rgba(C_LILAC, 0));
btx.fillStyle = warm; btx.fillRect(0, 0, W, H);
var wx = W * (0.5 + 0.32 * Math.sin(now / 2300));
var wy = H * (0.28 + 0.16 * Math.sin(now / 1700 + 2.1));
var wg = btx.createRadialGradient(wx, wy, 0, wx, wy, 190);
wg.addColorStop(0, rgba(C_PINK, 0.04 + 0.095 * heat)); // 더워질수록 핑크 워시가 깊어진다
wg.addColorStop(1, rgba(C_PINK, 0));
btx.fillStyle = wg; btx.fillRect(0, 0, W, H);
var cg = btx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 150);
cg.addColorStop(0, rgba(C_MINT, 0.08 + 0.07 * (1 - heat)));
cg.addColorStop(1, rgba(C_MINT, 0));
btx.fillStyle = cg; btx.fillRect(0, 0, W, H);
// 반대편 코너를 도는 두 번째 안개 — 네 겹이 각자 다른 주기로 숨쉰다
var ax2 = W * (0.5 - 0.34 * Math.sin(now / 2800));
var ay2 = H * (0.85 + 0.12 * Math.sin(now / 2000 + 1.1));
var ag = btx.createRadialGradient(ax2, ay2, 0, ax2, ay2, 175);
ag.addColorStop(0, rgba(C_MINT, 0.06 + 0.04 * Math.sin(now / 1450 + 3.1)));
ag.addColorStop(1, rgba(C_MINT, 0));
btx.fillStyle = ag; btx.fillRect(0, 0, W, H);
}
// ── 합성 ──
function draw(now, heat) {
repaintBg(now, heat);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1; ctx.filter = 'none';
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bgCv, 0, 0);
ctx.globalCompositeOperation = 'lighter';
ctx.filter = 'blur(5px)';
ctx.globalAlpha = 0.24 + 0.09 * Math.sin(now / 900) + 0.05 * heat;
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.filter = 'none'; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
ctx.imageSmoothingEnabled = false;
ctx.drawImage(gridCv, 0, 0, W, H);
// 방류 입자 — 뜨거울 땐 핑크로 반짝, 식으면 코어 화이트로 접어든다
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < drifters.length; i++) {
var d = drifters[i];
var col = d.hot ? C_PINK : C_CORE;
var r = d.hot ? 1.9 : 1.4;
ctx.fillStyle = rgba(col, d.hot ? 0.8 : 0.55);
ctx.beginPath();
ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
ctx.fill();
}
// 표면 반짝임 — 최근에 붙은 셀 근처에서 미세 광점이 흐른다
var n = 14;
for (var s = 0; s < n; s++) {
var a2 = (s / n) * Math.PI * 2 + now / 1400;
var rr2 = RMAX * CELL * (0.86 + 0.09 * Math.sin(now / 620 + s * 1.3));
var sx = CX * CELL + Math.cos(a2) * rr2;
var sy = CY * CELL + Math.sin(a2) * rr2 * 0.94;
var tw = 0.5 + 0.5 * Math.sin(now / 210 + s * 2.2);
ctx.fillStyle = rgba(C_MINT, 0.10 + 0.22 * tw * (1 - heat));
ctx.fillRect(sx - 1, sy - 1, 2, 2);
}
ctx.globalCompositeOperation = 'source-over';
}
function heatOf(now) {
// 주기 뒷반 = 더운(녹는) 구간 — 사인으로 부드럽게 오르내린다
var ph = (now % CYCLE_MS) / CYCLE_MS;
return ph < (1 - MELT_FRAC) ? 0 : Math.sin((ph - (1 - MELT_FRAC)) / MELT_FRAC * Math.PI);
}
var t0 = -1;
function frame(nowRaw) {
if (t0 < 0) t0 = nowRaw;
var now = nowRaw - t0;
var heat = heatOf(now);
if (heat > 0) meltStep(heat, now);
refreezeStep(now);
stepDrifters(now, heat);
repaintGrid(now);
draw(now, heat);
requestAnimationFrame(frame);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var total = growFull();
valOut.textContent = (CYCLE_MS / 1000).toFixed(1);
if (reduce) {
repaintGrid(0); repaintBg(0, 0); draw(0, 0);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// MeltRefreezeCycle — vanilla 판(run/319/demo/08-snowflake-melt-refreeze-cycle/index.html) 의 React 포트.
// DLA 로 완전히 자란 결정에 온도 순환을 돌린다: 주기 뒷반(더운 기류)엔 표면 셀을 확률적으로
// 떼어내 방류 입자로 풀어놓고, 앞반(찬 기류)엔 그 입자들이 표면에 다시 붙는다.
// 오래된 코어는 녹을 확률이 낮아 남고 가장자리만 계속 다시 자란다 — 숨쉬는 결정.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./MeltRefreezeCycle.module.scss";
export type MeltRefreezeCycleProps = {
duration?: string;
easing?: string;
color?: string;
cycleMs?: number; // 온도 순환 한 바퀴
meltRate?: number; // 프레임당 표면 박리 시도 수(온도 스케일)
rMax?: number; // 결정 반경(셀)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Drifter = { x: number; y: number; vx: number; vy: number; born: number; hot: boolean };
type Colors = { ink: RGB; core: RGB; lilac: RGB; mint: RGB; pink: RGB };
const FIELD_LABEL = "녹기·재결정 순환 결정 실험판";
const MELT_FRAC = 0.5;
const FREEZE_PER_FR = 60;
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;
};
}
const NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
const NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
export default function MeltRefreezeCycle({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b4a6ec",
cycleMs = 2000,
meltRate = 3.4,
rMax = 38,
}: MeltRefreezeCycleProps) {
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<Colors | null>(null);
const randRef = useRef(makePRNG(0x31908c)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const W = 424, H = 180, CELL = 2, GW = W / CELL, GH = H / CELL;
const CX = Math.floor(GW / 2), CY = Math.floor(GH / 2) - 6;
const frozenRef = useRef<Uint8Array>(new Uint8Array(GW * GH)) as Box<Uint8Array>;
const tStickRef = useRef<Float32Array>(new Float32Array(GW * GH)) as Box<Float32Array>;
const driftersRef = useRef<Drifter[]>([]) as Box<Drifter[]>;
const maxRRef = useRef(2) as Box<number>;
const gridCvRef = useRef<HTMLCanvasElement | null>(null);
const bgCvRef = useRef<HTMLCanvasElement | null>(null);
function idx(x: number, y: number): number { return y * GW + x; }
function inside(x: number, y: number): boolean { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y: number): number[] { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y: number): number[] { return (y & 1) ? NY_ODD : NY_EVEN; }
function frozenNb(x: number, y: number): number {
const frozen = frozenRef.current;
const nx = nbX(y), ny = nbY(y);
let n = 0;
for (let k = 0; k < 6; k++) {
const ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && frozen[idx(ax, ay)]) n++;
}
return n;
}
function stick(x: number, y: number, now: number): void {
frozenRef.current[idx(x, y)] = 1;
tStickRef.current[idx(x, y)] = now;
}
function growFull(): void {
const rand = randRef.current;
stick(CX, CY, -9900);
const nx = nbX(CY), ny = nbY(CY);
for (let k = 0; k < 6; k++) {
const ax = CX + nx[k], ay = CY + ny[k];
if (inside(ax, ay)) stick(ax, ay, -9800);
}
let maxR = 2, got = 7, tries = 0;
const want = 2050;
while (got < want && tries < want * 260) {
tries++;
const ang = rand() * Math.PI * 2;
const rr = maxR + 6 + rand() * 16;
let x = Math.round(CX + Math.cos(ang) * rr);
let y = Math.round(CY + Math.sin(ang) * rr * 0.94);
let steps = 0;
while (steps < 480) {
steps++;
const dir = (rand() * 6) | 0;
const bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (frozenRef.current[idx(x, y)]) { x -= bx[dir]; y -= by[dir]; continue; }
const n = frozenNb(x, y);
if (n > 0) {
let p = n === 1 ? 1 : n === 2 ? 0.55 : 0.14;
const dist = Math.hypot(x - CX, y - CY);
p *= 0.35 + 0.65 * Math.min(1, dist / (maxR + 4));
if (dist < rMax && rand() < p) {
stick(x, y, -9900 + got * 3);
got++;
if (dist > maxR) maxR = dist;
break;
}
}
}
}
maxRRef.current = maxR;
}
function meltStep(heat: number, now: number): void {
const rand = randRef.current, frozen = frozenRef.current, tStick = tStickRef.current;
const tries = Math.round(meltRate * heat * 10);
for (let i = 0; i < tries; i++) {
const x = (rand() * GW) | 0, y = (rand() * GH) | 0;
const id = idx(x, y);
if (!frozen[id]) continue;
const n = frozenNb(x, y);
if (n >= 6) continue;
const age = now - tStick[id];
const pCore = Math.max(0.02, 1 - age / 5200);
const p = (n / 6) * heat * pCore * 0.5;
if (rand() < p) {
frozen[id] = 0;
const ang = rand() * Math.PI * 2, sp = 0.25 + rand() * 0.8;
driftersRef.current.push({
x: x * CELL + 1, y: y * CELL + 1,
vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
born: now, hot: true,
});
}
}
}
function refreezeStep(now: number): void {
const rand = randRef.current;
const drifters = driftersRef.current;
let stuck = 0, i = 0;
while (i < drifters.length && stuck < FREEZE_PER_FR) {
const d = drifters[i];
const gx = (d.x / CELL) | 0, gy = (d.y / CELL) | 0;
let landed = false;
if (inside(gx, gy) && !frozenRef.current[idx(gx, gy)]) {
const n = frozenNb(gx, gy);
if (n > 0) {
const p = n === 1 ? 0.9 : n === 2 ? 0.5 : 0.12;
if (rand() < p) { stick(gx, gy, now); landed = true; stuck++; }
}
}
if (landed) drifters.splice(i, 1); else i++;
}
}
function stepDrifters(now: number, heat: number): void {
const rand = randRef.current;
const drifters = driftersRef.current;
for (let i = drifters.length - 1; i >= 0; i--) {
const d = drifters[i];
d.hot = now - d.born < 900;
const toCx = CX * CELL - d.x, toCy = CY * CELL - d.y;
const len = Math.hypot(toCx, toCy) || 1;
const pull = (1 - heat) * 0.028;
d.vx += (toCx / len) * pull + (rand() - 0.5) * 0.05;
d.vy += (toCy / len) * pull + (rand() - 0.5) * 0.05;
const v = Math.hypot(d.vx, d.vy);
if (v > 1.4) { d.vx *= 1.4 / v; d.vy *= 1.4 / v; }
d.x += d.vx; d.y += d.vy;
if (d.x < 1 || d.x > W - 1 || d.y < 1 || d.y > H - 1) drifters.splice(i, 1);
}
}
function rgba(c: RGB, a: number): string { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function repaintGrid(now: number): void {
const gridCv = gridCvRef.current, col = colRef.current;
if (!gridCv || !col) return;
const gtx = gridCv.getContext("2d");
if (!gtx) return;
const frozen = frozenRef.current, tStick = tStickRef.current;
const RINGS: RGB[] = [col.core, col.lilac, col.mint, col.pink, col.core, col.lilac];
const img = gtx.createImageData(GW, GH);
const px = img.data;
for (let y = 0; y < GH; y++) {
for (let x = 0; x < GW; x++) {
const id = y * GW + x, o = id * 4;
if (!frozen[id]) { px[o + 3] = 0; continue; }
const age = now - tStick[id];
const band = ((age / 1050) | 0) % RINGS.length;
const tint = RINGS[band];
const k = Math.min(1, age / 500) * 0.8;
const lift = frozenNb(x, y) <= 2 ? 26 : 0;
px[o] = Math.min(255, col.core[0] + (tint[0] - col.core[0]) * k + lift);
px[o + 1] = Math.min(255, col.core[1] + (tint[1] - col.core[1]) * k + lift);
px[o + 2] = Math.min(255, col.core[2] + (tint[2] - col.core[2]) * k + lift);
px[o + 3] = 255;
}
}
gtx.putImageData(img, 0, 0);
}
function repaintBg(now: number, heat: number): void {
const bgCv = bgCvRef.current, col = colRef.current;
if (!bgCv || !col) return;
const btx = bgCv.getContext("2d");
if (!btx) return;
btx.globalCompositeOperation = "source-over";
btx.fillStyle = rgba(col.ink, 1);
btx.fillRect(0, 0, W, H);
const warm = btx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 215);
warm.addColorStop(0, rgba(col.lilac, 0.085 + 0.05 * Math.sin(now / 830)));
warm.addColorStop(1, rgba(col.lilac, 0));
btx.fillStyle = warm; btx.fillRect(0, 0, W, H);
const wx = W * (0.5 + 0.32 * Math.sin(now / 2300));
const wy = H * (0.28 + 0.16 * Math.sin(now / 1700 + 2.1));
const wg = btx.createRadialGradient(wx, wy, 0, wx, wy, 165);
wg.addColorStop(0, rgba(col.pink, 0.025 + 0.065 * heat));
wg.addColorStop(1, rgba(col.pink, 0));
btx.fillStyle = wg; btx.fillRect(0, 0, W, H);
const cg = btx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 130);
cg.addColorStop(0, rgba(col.mint, 0.05 + 0.05 * (1 - heat)));
cg.addColorStop(1, rgba(col.mint, 0));
btx.fillStyle = cg; btx.fillRect(0, 0, W, H);
}
function draw(now: number, heat: number): void {
const ctx = ctxRef.current, col = colRef.current;
const gridCv = gridCvRef.current, bgCv = bgCvRef.current;
if (!ctx || !col || !gridCv || !bgCv) return;
repaintBg(now, heat);
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1; ctx.filter = "none";
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bgCv, 0, 0);
ctx.globalCompositeOperation = "lighter";
ctx.filter = "blur(5px)";
ctx.globalAlpha = 0.24 + 0.09 * Math.sin(now / 900) + 0.05 * heat;
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.filter = "none"; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
ctx.imageSmoothingEnabled = false;
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
const drifters = driftersRef.current;
for (let i = 0; i < drifters.length; i++) {
const d = drifters[i];
const dcol = d.hot ? col.pink : col.core;
const r = d.hot ? 1.9 : 1.4;
ctx.fillStyle = rgba(dcol, d.hot ? 0.8 : 0.55);
ctx.beginPath();
ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
ctx.fill();
}
const n = 14, maxR = maxRRef.current;
for (let s = 0; s < n; s++) {
const a2 = (s / n) * Math.PI * 2 + now / 1400;
const rr2 = maxR * CELL * (0.86 + 0.09 * Math.sin(now / 620 + s * 1.3));
const sx = CX * CELL + Math.cos(a2) * rr2;
const sy = CY * CELL + Math.sin(a2) * rr2 * 0.94;
const tw = 0.5 + 0.5 * Math.sin(now / 210 + s * 2.2);
ctx.fillStyle = rgba(col.mint, 0.10 + 0.22 * tw * (1 - heat));
ctx.fillRect(sx - 1, sy - 1, 2, 2);
}
ctx.globalCompositeOperation = "source-over";
}
function heatOf(now: number): number {
const ph = (now % cycleMs) / cycleMs;
return ph < (1 - MELT_FRAC) ? 0 : Math.sin((ph - (1 - MELT_FRAC)) / MELT_FRAC * Math.PI);
}
useEffect(() => {
const root = rootRef.current, cv = cvRef.current;
if (!root || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
gridCvRef.current = document.createElement("canvas");
gridCvRef.current.width = GW; gridCvRef.current.height = GH;
bgCvRef.current = document.createElement("canvas");
bgCvRef.current.width = W; bgCvRef.current.height = H;
function tokRGB(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)];
}
colRef.current = {
ink: tokRGB("--flake-ink"), core: tokRGB("--flake-core"),
lilac: tokRGB("--flake-lilac"), mint: tokRGB("--flake-mint"), pink: tokRGB("--flake-pink"),
};
randRef.current = makePRNG(0x31908c);
frozenRef.current = new Uint8Array(GW * GH);
tStickRef.current = new Float32Array(GW * GH).fill(-1);
driftersRef.current = [];
growFull();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let t0 = -1;
function frame(nowRaw: number): void {
if (t0 < 0) t0 = nowRaw;
const now = nowRaw - t0;
const heat = heatOf(now);
if (heat > 0) meltStep(heat, now);
refreezeStep(now);
stepDrifters(now, heat);
repaintGrid(now);
draw(now, heat);
rafRef.current = requestAnimationFrame(frame);
}
if (reduce) {
repaintGrid(0); repaintBg(0, 0); draw(0, 0);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => cancelAnimationFrame(rafRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cycleMs, meltRate, rMax]);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
const cycleLabel = (cycleMs / 1000).toFixed(1);
return (
<main className={styles.stage}>
<div className={styles.cd} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" strokeWidth={14} strokeLinecap="round"><path d="M128 26v204" /><path d="M43 74l170 128" /><path d="M213 74L43 202" /><circle cx={128} cy={128} r={18} /></g></svg>
<span>결정 표면의 입자를 확률적으로 떼어내 주변에 다시 풀어놓으면 오래된 코어는 남고 가장자리만 계속 다시 자랍니다 — 숨을 쉬는 결정의 온도 순환입니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>주기 <b className={styles.val}>{cycleLabel}</b>s · 표면 녹이기 → 재응집</span>
<span className={styles.metaDim}>ImageData · putImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 · 녹았다 얼기 반복 — 가장 바깥 입자부터 떼어내 다시 붙이는 순환 — 측정 누적면적 37.253% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
표면 셀을 확률로 녹이고 다시 얼리는 순환
function meltStep(heat, now) {
var tries = Math.round(MELT_RATE * heat * 10);
for (var i = 0; i < tries; i++) {
var x = (rand() * GW) | 0, y = (rand() * GH) | 0;
var id = idx(x, y);
if (!frozen[id]) continue;
var n = frozenNb(x, y);
if (n >= 6) continue; // 완전 매립 셀은 안 녹는다
var age = now - tStick[id];
var pCore = Math.max(0.02, 1 - age / 5200); // 오래된 코어는 녹을 확률이 낮다
var p = (n / 6) * heat * pCore * 0.5;
if (rand() < p) {
frozen[id] = 0;
var ang = rand() * Math.PI * 2, sp = 0.25 + rand() * 0.8;
drifters.push({
x: x * CELL + 1, y: y * CELL + 1,
vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
born: now, hot: true
});
}
}
}
주변이 이미 꽉 채워진(n >= 6) 코어 칸은 아예 후보에서 빠지고, pCore 가 오래 붙어 있던 칸일수록 녹을 확률을 낮춰 표면만 녹았다 얼기를 반복합니다. 녹은 칸은 drifters 로 풀려났다가 refreezeStep 이 표면 이웃 수에 비례한 확률로 다시 붙입니다.
09매크로 사진 씬 — 다크 배경 위 촛점 심도로 뜨는 결정 한 편
검은 배경에 중앙 조명을 깔고 결정을 크게 키우면 현미경 사진 같은 한 컷이 됩니다 — 잔광과 부유 입자로 화면이 계속 살아 움직이고, 실측 누적면적 36.536% · 강도 평균 38.3 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="14" stroke-linecap="round"><path d="M128 26v204"/><path d="M43 74l170 128"/><path d="M213 74L43 202"/><path d="M106 40l22 18 22-18"/><path d="M106 216l22-18 22 18"/><path d="M40 106l18 22-18 22"/><path d="M216 106l-18 22 18 22"/></g></svg>
<span>검은 무대에 중앙 조명을 깔고 결정을 크게 키우면 현미경 매크로의 한 컷이 됩니다 — 잔광 줄기와 부유 입자가 프레임을 계속 살아 움직이게 합니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="매크로 사진 결정 씬 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv" width="424" height="180"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">초점 거리 <b class="cd__val" id="cdVal">1.00</b> · 얕은 심도</span>
<span class="cd__meta cd__meta--dim">createRadialGradient · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 매크로 사진 씬(09). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(DLA 성장 → 표면 재배열 + 필름 그레인 + 흐르는 보케)가 만든다.
// 무대는 스튜디오 블랙. 중앙 키 라이트 아래 결정이 얕은 심도(선명 코어·흐린 가지)로 뜬다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD26: vivid 순색 없음 — 전부 파스텔이거나 어스름 무채색) ──
$flake-ink: #0d0f16; // 스튜디오 블랙 무대
$flake-lamp: #efe3ff; // 키 라이트 색(연한 라벤더 화이트)
$flake-core: #f2f5fb; // 결정 코어 — 서리 화이트
$flake-lilac: #dbd6eb; // 결정 편광 라일락
$flake-mint: #a2d8c4; // 결정 편광 민트
$flake-pink: #eeb8cf; // 결정 편광 핑크
$flake-node: #d0d4e0; // 안개색 글자·테두리
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-lamp: #{$flake-lamp};
--flake-core: #{$flake-core};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-pink: #{$flake-pink};
--flake-node: #{$flake-node};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-node; // 잉크 무대 위 — 안개색 글자(대비 10.6:1)
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-pink; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-node, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-pink; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-node, .78); }
.cd__val { color: $flake-mint; font-weight: 700; } // 잉크 위 민트 라벨(대비 9.6:1)
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 다 자란 한 장만 남는다(JS 가 is-still 부착).
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #0d0f16;
--flake-lamp: #efe3ff;
--flake-core: #f2f5fb;
--flake-lilac: #dbd6eb;
--flake-mint: #a2d8c4;
--flake-pink: #eeb8cf;
--flake-node: #d0d4e0;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d0d4e0;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #eeb8cf;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d0f16;
box-shadow: inset 0 0 0 1px rgba(208, 212, 224, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #eeb8cf;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(208, 212, 224, 0.78);
}
.cd__val {
color: #a2d8c4;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 매크로 사진 씬(09). DLA 로 결정 하나를 크게 자라게 하고, 촬영 스튜디오처럼 연출한다.
// ① 중앙 radial gradient 키 라이트(현미경 광원) ② 미세 필름 그레인(매 프레임 새로운 노이즈 —
// 어두운 배경이 정적으로 죽지 않게 한다) ③ 결정 본체(격자 ImageData, 촛점면 안쪽은 선명하게
// 바깥 가지는 DOF 만큼 뿌옇게 — 얕은 심도) ④ 잔광 줄기(라이트에서 비스듬히 흐르는 반짝 먼지)
// ⑤ 결정 주위 부유 입자(포어그라운드 보케 — 크고 흐리게).
// 결정도 멈추지 않는다: 성장이 다 차면 표면이 슬금슬금 재배열되며 결이 계속 꿈틀한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var STICKS_PER_FRAME = 160; // 한 프레임에 붙는 입자 — 2초 안에 결정이 화면을 채우는 타임랩스 속도
var RMAX = 40; // 결정 반경(셀) — 화면의 절반 이상을 차지는 큰 결정
var DOF_CELLS = 26; // 초점면 깊이(셀) — 이 안은 선명, 바깥은 뿌옇다
var RESHUFFLE_RATE = 1.6; // 성장 완료 후 프레임당 표면 재배열 시도(성김) — 결이 꿈틀한다
// ────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var valOut = document.getElementById('cdVal');
var W = cv.width, H = cv.height; // 내부 424×180 고정
var CELL = 2, GW = W / CELL, GH = H / CELL;
var CX = (GW / 2) | 0, CY = ((GH / 2) | 0) - 4;
// 시드 고정 PRNG — mulberry32
var prngState = 0x31909d >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 scss 의 --flake-* 에서 getComputedStyle 로
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('--flake-ink');
var C_LAMP = tokRGB('--flake-lamp');
var C_CORE = tokRGB('--flake-core');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_PINK = tokRGB('--flake-pink');
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// ── 격자 상태 ──
var frozen = new Uint8Array(GW * GH);
var tStick = new Float32Array(GW * GH); tStick.fill(-1);
var distC = new Float32Array(GW * GH); // 중심 거리(초점 심도 계산용) — 캐시
for (var yy = 0; yy < GH; yy++) for (var xx = 0; xx < GW; xx++) distC[yy * GW + xx] = Math.hypot(xx - CX, yy - CY);
var NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
var NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
function idx(x, y) { return y * GW + x; }
function inside(x, y) { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y) { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y) { return (y & 1) ? NY_ODD : NY_EVEN; }
function frozenNb(x, y) {
var nx = nbX(y), ny = nbY(y), n = 0;
for (var k = 0; k < 6; k++) {
var ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && frozen[idx(ax, ay)]) n++;
}
return n;
}
function stick(x, y, now) { frozen[idx(x, y)] = 1; tStick[idx(x, y)] = now; }
// ── DLA 성장 — 팁 바이어스로 가지가 뻗는 큰 결정 ──
var maxR = 2, grown = false, grownAt = 0;
function aggregate(now, budget) {
var stuck = 0, attempts = 0;
while (stuck < budget && attempts < budget * 9) {
attempts++;
var ang = rand() * Math.PI * 2;
var rr = maxR + 6 + rand() * 16;
var x = Math.round(CX + Math.cos(ang) * rr);
var y = Math.round(CY + Math.sin(ang) * rr * 0.95);
var steps = 0;
while (steps < 460) {
steps++;
var dir = (rand() * 6) | 0;
var bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (frozen[idx(x, y)]) { x -= bx[dir]; y -= by[dir]; continue; }
var n = frozenNb(x, y);
if (n > 0) {
var p = n === 1 ? 1 : n === 2 ? 0.5 : 0.13;
var dist = distC[idx(x, y)];
p *= 0.35 + 0.65 * Math.min(1, dist / (maxR + 4));
if (dist < RMAX && rand() < p) {
stick(x, y, now); stuck++;
if (dist > maxR) maxR = dist;
break;
}
}
}
}
if (maxR >= RMAX - 1.5) { grown = true; grownAt = now; }
}
// ── 재배열 — 완성된 결정의 표면이 느리게 꿈틀(녹았다 붙는 미세 순환) ──
function reshuffle(now) {
for (var i = 0; i < RESHUFFLE_RATE * 10; i++) {
var x = (rand() * GW) | 0, y = (rand() * GH) | 0;
var id = idx(x, y);
if (!frozen[id]) continue;
var n = frozenNb(x, y);
if (n >= 5 || n === 0) continue; // 매립·고립 셀은 그대로
if (distC[id] < maxR * 0.55) continue; // 코어는 안정 — 가장자리만
if (rand() < 0.35) { frozen[id] = 0; } // 떼고
// 그 자리 근처에 다시 붙여 결이 미세하게 밀린다
var nx2 = nbX(y), ny2 = nbY(y);
var k = (rand() * 6) | 0;
var ax = x + nx2[k], ay = y + ny2[k];
if (inside(ax, ay) && !frozen[idx(ax, ay)] && frozenNb(ax, ay) > 0) stick(ax, ay, now);
}
}
// ── 격자 → ImageData(선명 레이어) + 블러 레이어(초점 밖 가지) ──
var sharpCv = document.createElement('canvas'); sharpCv.width = GW; sharpCv.height = GH;
var stx = sharpCv.getContext('2d');
var softCv = document.createElement('canvas'); softCv.width = GW; softCv.height = GH;
var ftx = softCv.getContext('2d');
var imgS = stx.createImageData(GW, GH), pxS = imgS.data;
var imgF = ftx.createImageData(GW, GH), pxF = imgF.data;
function repaintGrids(now) {
for (var y = 0; y < GH; y++) {
for (var x = 0; x < GW; x++) {
var id = y * GW + x;
var o = id * 4;
if (!frozen[id]) { pxS[o + 3] = 0; pxF[o + 3] = 0; continue; }
var age = now - tStick[id];
var k = Math.min(1, age / 850);
// 결정 축 방향에 따른 은은한 색 분산 — 현미경 편광 느낌의 파스텔
var ang = Math.atan2(y - CY, x - CX);
var tint = (ang > -0.5 && ang <= 0.5) || (ang > Math.PI - 0.5 || ang <= -Math.PI + 0.5) ? C_LILAC
: ang > 0.5 && ang <= 2.1 ? C_MINT : C_PINK;
var lift = frozenNb(x, y) <= 2 ? 24 : 0;
var R = Math.min(255, C_CORE[0] + (tint[0] - C_CORE[0]) * k * 0.55 + lift);
var G = Math.min(255, C_CORE[1] + (tint[1] - C_CORE[1]) * k * 0.55 + lift);
var B = Math.min(255, C_CORE[2] + (tint[2] - C_CORE[2]) * k * 0.55 + lift);
pxS[o] = R; pxS[o + 1] = G; pxS[o + 2] = B; pxS[o + 3] = 255;
// 초점면 안쪽 = 선명 레이어에, 바깥 = 소프트 레이어로 나눠 싣는다
var dof = distC[id] <= DOF_CELLS;
if (dof) { pxF[o + 3] = 0; }
else {
pxF[o] = R; pxF[o + 1] = G; pxF[o + 2] = B; pxF[o + 3] = 255;
pxS[o + 3] = 0;
}
}
}
stx.putImageData(imgS, 0, 0);
ftx.putImageData(imgF, 0, 0);
}
// ── 배경 — 키 라이트 + 숨쉬는 림 + 필름 그레인 ──
var grainCv = document.createElement('canvas'); grainCv.width = W; grainCv.height = H;
var gtx2 = grainCv.getContext('2d');
var gImg = gtx2.createImageData(W, H), gPx = gImg.data;
function repaintGrain() {
// 매 프레임 새로운 미세 노이즈 + 산발적 핫픽셀(센서 반응) — 어두운 면이 정적으로 죽지 않는다
for (var i = 0; i < gPx.length; i += 4) {
var v = 10 + (rand() * 34) | 0;
var hot = rand() < 0.0022 ? 150 + (rand() * 90 | 0) : 0;
gPx[i] = Math.min(255, v + hot); gPx[i + 1] = Math.min(255, v + hot); gPx[i + 2] = Math.min(255, v + 5 + hot);
gPx[i + 3] = 22 + (hot ? 60 : 0);
}
gtx2.putImageData(gImg, 0, 0);
}
// ── 잔광 줄기 — 라이트에서 비스듬히 흐르는 반짝 먼지(부유 입자) ──
var motes = [];
var MOTE_COLS = [C_CORE, C_LILAC, C_MINT, C_PINK];
function initMotes() {
for (var i = 0; i < 78; i++) {
motes.push({
x: rand() * W, y: rand() * H,
vx: 0.12 + rand() * 0.5, vy: -0.05 - rand() * 0.16,
r: 0.7 + rand() * 1.8, ph: rand() * Math.PI * 2,
col: MOTE_COLS[(rand() * MOTE_COLS.length) | 0]
});
}
}
function stepMotes(now) {
for (var i = 0; i < motes.length; i++) {
var mo = motes[i];
mo.x += mo.vx * (0.7 + 0.3 * Math.sin(now / 900 + mo.ph));
mo.y += mo.vy;
if (mo.x > W + 6) { mo.x = -6; mo.y = rand() * H; }
if (mo.y < -6) { mo.y = H + 6; mo.x = rand() * W * 0.6; }
}
}
// ── 합성 ──
function draw(now) {
// 1. 무대 — 잉크 바탕 + 중앙 키 라이트(radial) + 숨쉬는 림 라이트
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1; ctx.filter = 'none';
ctx.fillStyle = rgba(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var key = ctx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 240);
key.addColorStop(0, rgba(C_LAMP, 0.24 + 0.09 * Math.sin(now / 760)));
key.addColorStop(0.55, rgba(C_LILAC, 0.09 + 0.05 * Math.sin(now / 760)));
key.addColorStop(1, rgba(C_LILAC, 0));
ctx.fillStyle = key; ctx.fillRect(0, 0, W, H);
var rimX = W * (0.5 + 0.34 * Math.sin(now / 2900));
var rim = ctx.createRadialGradient(rimX, -30, 0, rimX, -30, 220);
rim.addColorStop(0, rgba(C_MINT, 0.09 + 0.08 * Math.sin(now / 1250 + 1.2)));
rim.addColorStop(1, rgba(C_MINT, 0));
ctx.fillStyle = rim; ctx.fillRect(0, 0, W, H);
// 두 번째 워시 — 반대편에서 도는 핑크 안개(필름의 온도 분산)
var wx2 = W * (0.5 - 0.34 * Math.sin(now / 3400));
var wash = ctx.createRadialGradient(wx2, H + 20, 0, wx2, H + 20, 200);
wash.addColorStop(0, rgba(C_PINK, 0.07 + 0.07 * Math.sin(now / 1000 + 2.6)));
wash.addColorStop(1, rgba(C_PINK, 0));
ctx.fillStyle = wash; ctx.fillRect(0, 0, W, H);
// 2. 필름 그레인 — 매 프레임 새 노이즈
repaintGrain();
ctx.globalAlpha = 0.9;
ctx.drawImage(grainCv, 0, 0);
ctx.globalAlpha = 1;
// 3. 결정 광륜 — 전체 격자를 뿌옇게(할로)
ctx.globalCompositeOperation = 'lighter';
ctx.filter = 'blur(7px)';
ctx.globalAlpha = 0.30 + 0.07 * Math.sin(now / 880);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(sharpCv, 0, 0, W, H);
ctx.drawImage(softCv, 0, 0, W, H);
// 4. 초점면 바깥 가지 — 소프트 레이어(얕은 심도)
ctx.filter = 'blur(2.6px)';
ctx.globalAlpha = 0.9;
ctx.drawImage(softCv, 0, 0, W, H);
// 5. 결정 본체 — 초점면 안쪽 선명 픽셀
ctx.filter = 'none'; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
ctx.imageSmoothingEnabled = false;
ctx.drawImage(sharpCv, 0, 0, W, H);
// 6. 잔광 줄기 + 부유 입자 — 흐르는 보케
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < motes.length; i++) {
var mo = motes[i];
var tw = 0.45 + 0.55 * Math.sin(now / 260 + mo.ph * 3);
var a = (mo.r > 1.6 ? 0.10 : 0.30) * tw;
if (mo.r > 1.6) {
var g = ctx.createRadialGradient(mo.x, mo.y, 0, mo.x, mo.y, mo.r * 4);
g.addColorStop(0, rgba(mo.col, a));
g.addColorStop(1, rgba(mo.col, 0));
ctx.fillStyle = g;
ctx.fillRect(mo.x - mo.r * 4, mo.y - mo.r * 4, mo.r * 8, mo.r * 8);
} else {
ctx.fillStyle = rgba(mo.col, a);
ctx.beginPath();
ctx.arc(mo.x, mo.y, mo.r, 0, Math.PI * 2);
ctx.fill();
}
}
// 7. 비네트 — 매크로 렌즈의 어두운 가장자리. 살짝 숨쉬게 해서 테두리도 죽지 않게
ctx.globalCompositeOperation = 'source-over';
var vg = ctx.createRadialGradient(W / 2, H / 2, 95, W / 2, H / 2, 250);
vg.addColorStop(0, rgba(C_INK, 0));
vg.addColorStop(1, rgba(C_INK, 0.50 + 0.05 * Math.sin(now / 1500)));
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
var t0 = -1;
var dofT = -1;
function frame(nowRaw) {
if (t0 < 0) t0 = nowRaw;
var now = nowRaw - t0;
if (!grown) aggregate(now, STICKS_PER_FRAME);
else reshuffle(now);
stepMotes(now);
repaintGrids(now);
draw(now);
// 초점 거리 라벨 — 미세하게 흔들리는 랙킹(촛점 당김)
if (now - dofT > 120) { dofT = now; valOut.textContent = (0.96 + 0.08 * Math.abs(Math.sin(now / 1700))).toFixed(2); }
requestAnimationFrame(frame);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
initMotes();
stick(CX, CY, -9000);
var nx0 = nbX(CY), ny0 = nbY(CY);
for (var k0 = 0; k0 < 6; k0++) {
var ax0 = CX + nx0[k0], ay0 = CY + ny0[k0];
if (inside(ax0, ay0)) stick(ax0, ay0, -9000);
}
if (reduce) {
aggregate(0, 4000); // 다 자란 한 장
repaintGrids(2000);
draw(2000);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// MacroPhotoScene — vanilla 판(run/319/demo/09-snowflake-macro-photo-scene/index.html) 의 React 포트.
// 검은 스튜디오 무대에 중앙 radial gradient 키 라이트를 깔고 DLA 결정을 크게 키운다.
// 초점면 안쪽(선명 레이어)과 바깥(소프트 레이어)을 나눠 얕은 심도를 만들고,
// 필름 그레인(매 프레임 새 노이즈)과 흐르는 보케 입자로 배경이 계속 살아 움직인다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./MacroPhotoScene.module.scss";
export type MacroPhotoSceneProps = {
duration?: string;
easing?: string;
color?: string;
sticksPerFrame?: number; // 한 프레임에 붙는 입자
rMax?: number; // 결정 반경(셀)
dofCells?: number; // 초점면 깊이(셀) — 이 안은 선명, 바깥은 뿌옇다
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Mote = { x: number; y: number; vx: number; vy: number; r: number; ph: number; col: RGB };
type Colors = { ink: RGB; lamp: RGB; core: RGB; lilac: RGB; mint: RGB; pink: RGB };
const FIELD_LABEL = "매크로 사진 결정 씬 실험판";
const RESHUFFLE_RATE = 0.9;
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;
};
}
const NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
const NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
export default function MacroPhotoScene({
duration = "2s",
easing = "ease-out",
color = "#b8a7ea",
sticksPerFrame = 26,
rMax = 40,
dofCells = 26,
}: MacroPhotoSceneProps) {
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<Colors | null>(null);
const randRef = useRef(makePRNG(0x31909d)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const W = 424, H = 180, CELL = 2, GW = W / CELL, GH = H / CELL;
const CX = Math.floor(GW / 2), CY = Math.floor(GH / 2) - 4;
const frozenRef = useRef<Uint8Array>(new Uint8Array(GW * GH)) as Box<Uint8Array>;
const tStickRef = useRef<Float32Array>(new Float32Array(GW * GH)) as Box<Float32Array>;
const distCRef = useRef<Float32Array>(new Float32Array(GW * GH)) as Box<Float32Array>;
const motesRef = useRef<Mote[]>([]) as Box<Mote[]>;
const maxRRef = useRef(2) as Box<number>;
const grownRef = useRef(false) as Box<boolean>;
const sharpCvRef = useRef<HTMLCanvasElement | null>(null);
const softCvRef = useRef<HTMLCanvasElement | null>(null);
const grainCvRef = useRef<HTMLCanvasElement | null>(null);
function idx(x: number, y: number): number { return y * GW + x; }
function inside(x: number, y: number): boolean { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y: number): number[] { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y: number): number[] { return (y & 1) ? NY_ODD : NY_EVEN; }
function frozenNb(x: number, y: number): number {
const frozen = frozenRef.current;
const nx = nbX(y), ny = nbY(y);
let n = 0;
for (let k = 0; k < 6; k++) {
const ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && frozen[idx(ax, ay)]) n++;
}
return n;
}
function stick(x: number, y: number, now: number): void {
frozenRef.current[idx(x, y)] = 1;
tStickRef.current[idx(x, y)] = now;
}
function aggregate(now: number, budget: number): void {
const rand = randRef.current, frozen = frozenRef.current, distC = distCRef.current;
let stuck = 0, attempts = 0;
while (stuck < budget && attempts < budget * 9) {
attempts++;
const ang = rand() * Math.PI * 2;
const rr = maxRRef.current + 6 + rand() * 16;
let x = Math.round(CX + Math.cos(ang) * rr);
let y = Math.round(CY + Math.sin(ang) * rr * 0.95);
let steps = 0;
while (steps < 460) {
steps++;
const dir = (rand() * 6) | 0;
const bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (frozen[idx(x, y)]) { x -= bx[dir]; y -= by[dir]; continue; }
const n = frozenNb(x, y);
if (n > 0) {
let p = n === 1 ? 1 : n === 2 ? 0.5 : 0.13;
const dist = distC[idx(x, y)];
p *= 0.35 + 0.65 * Math.min(1, dist / (maxRRef.current + 4));
if (dist < rMax && rand() < p) {
stick(x, y, now); stuck++;
if (dist > maxRRef.current) maxRRef.current = dist;
break;
}
}
}
}
if (maxRRef.current >= rMax - 1.5) grownRef.current = true;
}
function reshuffle(now: number): void {
const rand = randRef.current, frozen = frozenRef.current, distC = distCRef.current;
for (let i = 0; i < RESHUFFLE_RATE * 10; i++) {
const x = (rand() * GW) | 0, y = (rand() * GH) | 0;
const id = idx(x, y);
if (!frozen[id]) continue;
const n = frozenNb(x, y);
if (n >= 5 || n === 0) continue;
if (distC[id] < maxRRef.current * 0.55) continue;
if (rand() < 0.35) frozen[id] = 0;
const nx2 = nbX(y), ny2 = nbY(y);
const k = (rand() * 6) | 0;
const ax = x + nx2[k], ay = y + ny2[k];
if (inside(ax, ay) && !frozen[idx(ax, ay)] && frozenNb(ax, ay) > 0) stick(ax, ay, now);
}
}
function rgba(c: RGB, a: number): string { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function repaintGrids(now: number): void {
const sharpCv = sharpCvRef.current, softCv = softCvRef.current, col = colRef.current;
if (!sharpCv || !softCv || !col) return;
const stx = sharpCv.getContext("2d"), ftx = softCv.getContext("2d");
if (!stx || !ftx) return;
const frozen = frozenRef.current, tStick = tStickRef.current, distC = distCRef.current;
const imgS = stx.createImageData(GW, GH), pxS = imgS.data;
const imgF = ftx.createImageData(GW, GH), pxF = imgF.data;
for (let y = 0; y < GH; y++) {
for (let x = 0; x < GW; x++) {
const id = y * GW + x, o = id * 4;
if (!frozen[id]) { pxS[o + 3] = 0; pxF[o + 3] = 0; continue; }
const age = now - tStick[id];
const k = Math.min(1, age / 850);
const ang = Math.atan2(y - CY, x - CX);
const tint: RGB = (ang > -0.5 && ang <= 0.5) || (ang > Math.PI - 0.5 || ang <= -Math.PI + 0.5) ? col.lilac
: ang > 0.5 && ang <= 2.1 ? col.mint : col.pink;
const lift = frozenNb(x, y) <= 2 ? 24 : 0;
const R = Math.min(255, col.core[0] + (tint[0] - col.core[0]) * k * 0.55 + lift);
const G = Math.min(255, col.core[1] + (tint[1] - col.core[1]) * k * 0.55 + lift);
const B = Math.min(255, col.core[2] + (tint[2] - col.core[2]) * k * 0.55 + lift);
pxS[o] = R; pxS[o + 1] = G; pxS[o + 2] = B; pxS[o + 3] = 255;
if (distC[id] <= dofCells) { pxF[o + 3] = 0; }
else {
pxF[o] = R; pxF[o + 1] = G; pxF[o + 2] = B; pxF[o + 3] = 255;
pxS[o + 3] = 0;
}
}
}
stx.putImageData(imgS, 0, 0);
ftx.putImageData(imgF, 0, 0);
}
function repaintGrain(): void {
const grainCv = grainCvRef.current;
if (!grainCv) return;
const gtx = grainCv.getContext("2d");
if (!gtx) return;
const rand = randRef.current;
const gImg = gtx.createImageData(W, H), gPx = gImg.data;
for (let i = 0; i < gPx.length; i += 4) {
const v = 10 + ((rand() * 26) | 0);
gPx[i] = v; gPx[i + 1] = v; gPx[i + 2] = v + 4; gPx[i + 3] = 14;
}
gtx.putImageData(gImg, 0, 0);
}
function initMotes(): void {
const col = colRef.current;
if (!col) return;
const rand = randRef.current;
const cols: RGB[] = [col.core, col.lilac, col.mint, col.pink];
const motes = motesRef.current;
for (let i = 0; i < 46; i++) {
motes.push({
x: rand() * W, y: rand() * H,
vx: 0.12 + rand() * 0.5, vy: -0.05 - rand() * 0.16,
r: 0.7 + rand() * 1.5, ph: rand() * Math.PI * 2,
col: cols[(rand() * cols.length) | 0],
});
}
}
function stepMotes(now: number): void {
const motes = motesRef.current;
for (let i = 0; i < motes.length; i++) {
const mo = motes[i];
mo.x += mo.vx * (0.7 + 0.3 * Math.sin(now / 900 + mo.ph));
mo.y += mo.vy;
if (mo.x > W + 6) { mo.x = -6; mo.y = Math.random() * H; }
if (mo.y < -6) { mo.y = H + 6; mo.x = Math.random() * W * 0.6; }
}
}
function draw(now: number): void {
const ctx = ctxRef.current, col = colRef.current;
const sharpCv = sharpCvRef.current, softCv = softCvRef.current, grainCv = grainCvRef.current;
if (!ctx || !col || !sharpCv || !softCv || !grainCv) return;
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1; ctx.filter = "none";
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const key = ctx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 210);
key.addColorStop(0, rgba(col.lamp, 0.20 + 0.035 * Math.sin(now / 760)));
key.addColorStop(0.55, rgba(col.lilac, 0.05));
key.addColorStop(1, rgba(col.lilac, 0));
ctx.fillStyle = key; ctx.fillRect(0, 0, W, H);
const rimX = W * (0.5 + 0.34 * Math.sin(now / 2900));
const rim = ctx.createRadialGradient(rimX, -30, 0, rimX, -30, 190);
rim.addColorStop(0, rgba(col.mint, 0.06 + 0.035 * Math.sin(now / 1250 + 1.2)));
rim.addColorStop(1, rgba(col.mint, 0));
ctx.fillStyle = rim; ctx.fillRect(0, 0, W, H);
repaintGrain();
ctx.globalAlpha = 0.9;
ctx.drawImage(grainCv, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "lighter";
ctx.filter = "blur(7px)";
ctx.globalAlpha = 0.30 + 0.07 * Math.sin(now / 880);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(sharpCv, 0, 0, W, H);
ctx.drawImage(softCv, 0, 0, W, H);
ctx.filter = "blur(2.6px)";
ctx.globalAlpha = 0.9;
ctx.drawImage(softCv, 0, 0, W, H);
ctx.filter = "none"; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
ctx.imageSmoothingEnabled = false;
ctx.drawImage(sharpCv, 0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
const motes = motesRef.current;
for (let i = 0; i < motes.length; i++) {
const mo = motes[i];
const tw = 0.45 + 0.55 * Math.sin(now / 260 + mo.ph * 3);
const a = (mo.r > 1.6 ? 0.10 : 0.30) * tw;
if (mo.r > 1.6) {
const g = ctx.createRadialGradient(mo.x, mo.y, 0, mo.x, mo.y, mo.r * 4);
g.addColorStop(0, rgba(mo.col, a));
g.addColorStop(1, rgba(mo.col, 0));
ctx.fillStyle = g;
ctx.fillRect(mo.x - mo.r * 4, mo.y - mo.r * 4, mo.r * 8, mo.r * 8);
} else {
ctx.fillStyle = rgba(mo.col, a);
ctx.beginPath();
ctx.arc(mo.x, mo.y, mo.r, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalCompositeOperation = "source-over";
const vg = ctx.createRadialGradient(W / 2, H / 2, 95, W / 2, H / 2, 250);
vg.addColorStop(0, rgba(col.ink, 0));
vg.addColorStop(1, rgba(col.ink, 0.50 + 0.05 * Math.sin(now / 1500)));
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
useEffect(() => {
const root = rootRef.current, cv = cvRef.current;
if (!root || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
sharpCvRef.current = document.createElement("canvas");
sharpCvRef.current.width = GW; sharpCvRef.current.height = GH;
softCvRef.current = document.createElement("canvas");
softCvRef.current.width = GW; softCvRef.current.height = GH;
grainCvRef.current = document.createElement("canvas");
grainCvRef.current.width = W; grainCvRef.current.height = H;
function tokRGB(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)];
}
colRef.current = {
ink: tokRGB("--flake-ink"), lamp: tokRGB("--flake-lamp"), core: tokRGB("--flake-core"),
lilac: tokRGB("--flake-lilac"), mint: tokRGB("--flake-mint"), pink: tokRGB("--flake-pink"),
};
randRef.current = makePRNG(0x31909d);
frozenRef.current = new Uint8Array(GW * GH);
tStickRef.current = new Float32Array(GW * GH).fill(-1);
const distC = distCRef.current;
for (let yy = 0; yy < GH; yy++) {
for (let xx = 0; xx < GW; xx++) {
distC[yy * GW + xx] = Math.hypot(xx - CX, yy - CY);
}
}
motesRef.current = [];
maxRRef.current = 2;
grownRef.current = false;
initMotes();
stick(CX, CY, -9000);
const nx0 = nbX(CY), ny0 = nbY(CY);
for (let k0 = 0; k0 < 6; k0++) {
const ax0 = CX + nx0[k0], ay0 = CY + ny0[k0];
if (inside(ax0, ay0)) stick(ax0, ay0, -9000);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let t0 = -1;
function frame(nowRaw: number): void {
if (t0 < 0) t0 = nowRaw;
const now = nowRaw - t0;
if (!grownRef.current) aggregate(now, sticksPerFrame);
else reshuffle(now);
stepMotes(now);
repaintGrids(now);
draw(now);
rafRef.current = requestAnimationFrame(frame);
}
if (reduce) {
aggregate(0, 4000);
repaintGrids(2000);
draw(2000);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => cancelAnimationFrame(rafRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sticksPerFrame, rMax, dofCells]);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={styles.cd} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" strokeWidth={14} strokeLinecap="round"><path d="M128 26v204" /><path d="M43 74l170 128" /><path d="M213 74L43 202" /><path d="M106 40l22 18 22-18" /><path d="M106 216l22-18 22 18" /><path d="M40 106l18 22-18 22" /><path d="M216 106l-18 22 18 22" /></g></svg>
<span>검은 무대에 중앙 조명을 깔고 결정을 크게 키우면 현미경 매크로의 한 컷이 됩니다 — 잔광 줄기와 부유 입자가 프레임을 계속 살아 움직이게 합니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>초점 거리 <b className={styles.val}>1.00</b> · 얕은 심도</span>
<span className={styles.metaDim}>createRadialGradient · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 · 매크로 사진 씬 — 다크 배경 위 촛점 심도로 뜨는 결정 한 편 — 측정 누적면적 36.536% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
조명 아래서 확대되는 응집 예산
function aggregate(now, budget) {
var stuck = 0, attempts = 0;
while (stuck < budget && attempts < budget * 9) {
attempts++;
var ang = rand() * Math.PI * 2;
var rr = maxR + 6 + rand() * 16;
var x = Math.round(CX + Math.cos(ang) * rr);
var y = Math.round(CY + Math.sin(ang) * rr * 0.95);
var steps = 0;
while (steps < 460) {
steps++;
var dir = (rand() * 6) | 0;
var bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (frozen[idx(x, y)]) { x -= bx[dir]; y -= by[dir]; continue; }
var n = frozenNb(x, y);
if (n > 0) {
var p = n === 1 ? 1 : n === 2 ? 0.5 : 0.13;
var dist = distC[idx(x, y)];
p *= 0.35 + 0.65 * Math.min(1, dist / (maxR + 4));
if (dist < RMAX && rand() < p) {
stick(x, y, now); stuck++;
if (dist > maxR) maxR = dist;
STICKS_PER_FRAME 160개를 한 프레임 예산으로 태워 2초 안에 결정이 화면을 채우는 타임랩스 속도를 냅니다. p 를 정할 때 붙은 이웃 수뿐 아니라 중심까지 거리(dist)까지 곱해, 결정이 커질수록 바깥 팁이 안쪽보다 잘 붙는 09만의 성장 곡선을 만듭니다.
사진같은 히어로와 포인터로 키우는 결정 — 이 아홉 개를 어디에 쓰나
눈 내리는 시즌 캠페인이나 겨울 테마 랜딩 페이지의 히어로라면 09 매크로 사진 씬이 가장 빠릅니다. 다크 배경 위 촛점 심도로 뜬 결정 한 편이 이미 사진처럼 완성돼 있어 카피만 얹으면 됩니다. 방문자가 직접 눌러 보길 바라는 인터랙티브 영역에는 07 포인터 씨앗을 앉히면, 클릭한 자리에서 두 번째 결정이 자라나는 반응이 그대로 체험이 됩니다.
습도와 결정 형태의 관계를 설명하는 과학 교육 콘텐츠라면 03 침·고사리 결정이 맞습니다. 습도 밴드 값 하나를 바꾸는 것만으로 나뭇잎 모양에서 바늘 모양으로 넘어가는 과정이 화면에서 바로 확인됩니다. 세 판 모두 파일 하나로 끝나 있어 별도 프레임워크 없이 정적 섹션에 바로 붙습니다.
눈결정은 왜 DLA 로 자라나? — 랜덤워크 응집과 육각 대칭의 셈
랜덤워크가 결정 모양을 만드는 이유
이 아홉 판이 공유하는 시뮬레이션 규칙은 단 한 줄입니다 — 입자 하나가 결정 밖에서 무작위로 걷다가 이미 붙은 칸에 닿으면, 그 자리에서 확률만큼 얼어붙습니다. 이 확산제한응집(DLA)은 눈결정이 실제로 자라는 방식과 같은 셈입니다 — 수증기 분자가 공기 중에서 무작위로 떠돌다 결정 표면에 닿아야만 얼음이 되고, 한 번 붙은 자리는 다시 떨어지지 않습니다.
육각 대칭이 격자에서 저절로 나온다
01·02·03·07·08·09는 DJ_EVEN·DJ_ODD 같은 짝수·홀수 열 오프셋 배열로 이웃 6칸을 정의하는 육각 격자를 씁니다 — 사각 격자 4이웃 대신 6이웃을 쓰는 것만으로 랜덤워크가 항상 60도 간격으로만 꺾여 눈결정 특유의 육각 대칭이 계산에서 저절로 나옵니다. 04는 반대로 격자를 접어(회전 대칭 C6) 계산을 줄이는 쪽으로, 05·06은 정사각 격자에 4방향 이웃만 써서 결을 다르게 냅니다.
흡착 확률 하나가 가지 모양을 정한다
01은 흡착 확률을 1로 고정해 가장 거친 뼈대를 남기고, 02·03·05는 반경(또는 창틀까지 거리)이 멀수록 확률을 높이는 stickProb·temp 항 하나만 더해 뭉툭한 덩어리를 뾰족한 가지·바늘·서리로 바꿉니다. 실제 눈결정도 과냉각·습도에 따라 이 흡착이 얼마나 팁에 쏠리는지가 달라져 판·바늘·별가지 같은 서로 다른 형태로 갈립니다.
격자 방식과 파티클 방식이 나뉘는 지점
01~04·06·07·09는 격자 칸에 1을 채우는 방식으로 응집을 기록하고, 05·08도 같은 격자를 쓰지만 08은 붙은 칸을 다시 지울 수 있다는 점이 다릅니다. 격자에 값을 채우거나 지우는 것만으로 성장·해빙을 함께 표현할 수 있다는 것이 파티클을 계속 새로 그리는 방식보다 이 아홉 판에 더 잘 맞는 이유입니다.
습도 밴드와 대칭 배수를 바꾸는 세 변형과 접근성
값 하나를 바꾼 세 변형
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 가지 확률 | 02 수지상 가지 확률 0.3 → 0.6 | 가지가 촘촘해질수록 진짜 눈결정에 가까운 레이스가 된다 |
| 응집 속도 | 01 입자 220개/초 → 700개/초 | 속도를 올리면 결정이 눈에 띄게 빨리 자라며 데모가 짧아진다 |
| 대칭 접기 | 04 섹터 각 60도 → 30도 | 섹터를 절반으로 줄이면 가지가 더 가늘고 정교해진다 |
세 축 모두 응집 규칙 자체는 건드리지 않고 상수 하나만 고쳐 밀도·속도·정교함을 조절합니다.
움직임을 줄인 사용자에게 남는 정지 프레임
prefers-reduced-motion: reduce 를 켠 사용자에게는 아홉 판 모두 requestAnimationFrame 루프를 시작하지 않고 stillFrame·stillDraw 함수가 결정이 자라다 만 한 장을 미리 계산해 그립니다 — 01~03은 랜덤워크를 정해진 스텝만큼 미리 돌린 뒤 멈춘 장면을, 04~09는 격자 시뮬레이션을 같은 방식으로 몇 스텝 앞서 돌린 장면을 보여 줍니다. 매 프레임 다시 그리지 않을 뿐 응집 계산 자체는 그대로라 결정 형태는 움직임을 켰을 때와 다르지 않습니다.
포인터로 만지는 한 판과 나머지 여덟 판
아홉 판 전부 role="application" 과 tabindex="0" 을 가진 실험판이라 탭으로 초점을 옮길 수 있습니다. 그중 07만 포인터(터치 포함)로 화면을 눌러 그 자리에 두 번째 결정 씨앗을 심을 수 있고, 나머지 여덟 판은 자동으로 자라는 장면이라 초점을 옮기면 포커스 링만 뜹니다 — snowflake generator online free 서비스 다수가 결과만 보여 주는 것과 달리, 이 아홉 판은 결정이 자라는 계산 전체가 zip 소스에 그대로 들어 있습니다.
팁 가중치보다 동시 성장 팁 수가 면적을 가르는 함정
팁 편향을 0으로 낮추면 가지가 아니라 덩어리가 된다
02의 소스 주석은 TIP_BIAS 를 "0이면 01과 동일"이라고 못 박아 둡니다 — 팁 쪽 흡착을 밀어주는 지수가 없으면 입자가 오목한 자리에도 똑같이 잘 붙어 가지 사이가 금방 메워지고, 01의 뭉툭한 육각 클러스터로 되돌아갑니다. 가지를 더 가늘게 만들고 싶을 땐 지수를 낮추는 게 아니라 오히려 키워야 합니다.
화면이 바뀌는 비율만 보고 판단하면 잘못 고친다
02는 2026-09-15 재튜닝 전 누적면적이 28.5%로 품질 기준(30%+)에 못 미쳤는데, 원인은 팁 가중치가 약해서가 아니라 동시에 걷는 입자 수와 결정이 자랄 수 있는 최대 반경이 작았던 탓이었습니다. PARTICLES 를 42에서 56으로, R_CAP 을 0.46에서 0.48로 올리고 나서야 화면에서 바뀌는 픽셀 비율이 44.129%까지 올라왔습니다 — 정체성(팁 가중 단일 씨앗 덴드라이트)은 그대로 두고 동시 성장 팁 수·최종 크기만 키운 것입니다. 아홉 판의 zip 안 소스에서 이 상수들을 직접 흔들어 보려면 암호 zip 을 열어야 하고, 여는 암호는 fcyyjg6v 입니다.
걸음 수 예산을 늘려 주지 않으면 성장이 멈춘 것처럼 보인다
07의 WALKER_MAX_STEPS 는 420걸음이 지나면 입자를 포기시키고 새로 태어나게 하는데, 소스 주석대로 "넘으면 죽고 새 입자가 태어난다"가 반복되면 결정이 커져 씨앗까지 거리가 멀어진 뒤로는 그 구간의 응집이 눈에 띄게 느려집니다. 결정이 화면 절반을 넘게 자라는 장면을 만들려면 걸음 수 예산도 그만큼 같이 키워야 합니다.
눈결정 성장을 페이지에 붙이기 전 자주 나오는 질문
snowflake generator online 도구와 이 데모는 뭐가 다른가요?
웹에서 흔히 보는 snowflake generator online 도구는 미리 그려둔 패턴을 회전·확대해 보여 주는 경우가 많습니다. 여기 아홉 판은 매 프레임 랜덤워크 입자가 걷고 붙는 계산을 실제로 돌려 시드가 같아도 매번 조금씩 다른 결이 남는 DLA 응집이고, 그 계산 자체를 코드로 그대로 들고 갈 수 있습니다.
snowflake generator svg 처럼 벡터로 저장할 수 있나요?
이 아홉 판은 canvas 픽셀(ImageData)에 격자 칸을 찍는 방식이라 결과는 래스터입니다. 벡터로 쓰려면 격자 칸 좌표를 다각형 경로로 바꾸는 별도 단계가 필요하고, zip 안 소스에는 그 변환이 들어 있지 않습니다 — snowflake generator python 스크립트로 같은 DLA 를 짜 본 사람이라면 이 랜덤워크·흡착 확률 구조가 언어만 바뀐 채 그대로 옮겨진다는 걸 알아볼 수 있습니다.
코드를 몰라도 이 결정을 내 페이지에 붙여 쓸 수 있나요?
붙여 쓸 수 있습니다. 프레임워크나 빌드 도구 없이 캔버스 태그 하나와 순수 자바스크립트 함수만으로 움직여, 암호 zip 에서 바닐라 판을 꺼내 <script> 로 붙이면 그대로 동작합니다. 색은 각 판 scss 맨 위의 파스텔 토큰 변수만 바꾸면 되고, React 판도 같은 zip 에 들어 있습니다.









