snowflake generator 9種 — DLAで育つ雪結晶
snowflake generatorとは、雪片の絵を貼り付ける作業ではなく、粒子が結晶に触れて凍りつく規則を何千回も繰り返して形を育てる計算のことです。ライブラリ·WebGL なしで、外から飛んできた粒子1つが結晶に触れて付く瞬間を繰り返すだけで、六角の雪の結晶がひとりでに育ちます
デモ
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 基本の六角凝集 — ランダムウォーク粒子が触れた場所に付くDLAひと層
- 02 樹枝状結晶(dendrite) — 6方向の先端が密に分岐する木の成長
- 03 針・シダ結晶 — 湿度帯を変えると葉から針になる
- 04 六重対称ミラー — 60度ごとに折りたたみ6倍速で育つ
- 05 霜の降りた窓 — 縁から内側へ這うフラクタル氷
- 06 結晶格子の彩色 — 各セルの年齢をパステルの輪に刻む
- 07 ポインタの種 — クリックした場所で2つ目の結晶が一緒に育つ
- 08 溶けてまた凍る — 最外側の粒子を剥がして再付着させる循環
- 09 マクロ写真シーン — 暗い舞台に浅い被写界深度で浮かぶ結晶
付着確率ひとつで九枚の雪片を分けた順序
並び順の基準は派手さではなく付着確率が生む結晶の性格です。01の基本六角凝集はSTICK確率1.0で触れれば必ず付く原形で、02は先端ほど付きやすい偏りを持ち込んで樹枝状の枝を伸ばし、03は同じ規則のまま偏りの強さを変えて針とシダの間を行き来します。04は1セクタだけ計算して残りを回転コピーする対称の近道、05と06はそれぞれ温度場の縁凍結と年齢の彩色で「どこで」「いつ」付いたかを可視化へ進み、07は2つの種が競い合う相互成長、08は溶けと再凍結の呼吸、09は暗い舞台と被写界深度で締めくくります。snowflake generator onlineで検索してすぐ試せる9盤の中でも、この並びを貫くのは付着確率という1つの軸だけです。同じ乱数の種から山を盛り上げるノイズで山を作る9盤や模様がひとりでに育つチューリングパターンの9盤と同じく、複雑な形は1つの単純な規則の繰り返しから生まれます。
付着確率という1つの軸が9盤を貫く
01から09まで、粒子が結晶に付くかどうかを決めるstickProb(またはSTICK)という1つの値が、丸い塊になるか、鋭い枝になるか、針になるかを分けます。付着確率を先端ほど高くすると尖り、内側でも均等に付けば丸くなる——この1つのつまみの上に9つの見た目が並びます。
項目別コード
項目ごとに、成長を左右する定数の名前をそのまま示し、実際に動いているデモの関数を要約せずに引用します。省略した断片ではなく本物のコードなので、この画面のまま自分のページに貼って試せます。
01基本の六角凝集 — ランダムウォーク粒子が触れた場所に付くDLAひと層
結晶の種を1つ置き、外周から出た粒子が上下左右にランダムに歩き、結晶に触れた瞬間に付く規則を数千回繰り返すと、六角の尖ったクラスタが自ら育ちます。STICK確率を1.0に固定した最も単純な形で、24フレーム中23フレームが動き続け、累積面積は43.511%まで育ちます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
触れたら必ず付くSTICK=1.0が丸い塊を作る
付着確率STICKを常に1.0にすると、粒子は結晶に触れた瞬間ためらわず凍りつくため、先端も側面も同じ確率で埋まり、輪郭の尖りが均されて丸みを帯びたクラスタになります。
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();
}
02樹枝状結晶(dendrite) — 6方向の先端が密に分岐する木の成長
付着確率を最外側の先端へ偏らせると、丸い塊ではなく本物の雪の結晶の枝(樹枝)が伸びていきます。TIP_BIAS=2.6で先端ほど付きやすくすると、累積面積は44.129%、強度平均は65.4まで上がります。

<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) — 6方向の先端が密に分岐する木の成長 — 計測 累積面積 44.129% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
TIP_BIAS指数が先端への偏りの強さを決める
stickProbは結晶の中心からの距離を0〜1に正規化したkのTIP_BIAS乗を主成分として返すため、指数を上げるほど中心付近より先端付近の付着確率が急激に高くなり、枝が伸びる方向に成長が集中します。
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;
}
03針・シダ結晶 — 湿度帯を変えると葉から針になる
同じ凝集規則に温度・湿度パラメータを1つ変えるだけで、広がるシダ型から細い針型へ結晶の形が分かれます——本物の雪の形成条件の地図です。BIAS_FERN=1.6とBIAS_NEEDLE=4.6という2つの偏りを切り替えるだけで、累積面積41.614%・強度平均75.9という9種の中で最も強いコントラストが出ます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
biasの値ひとつでシダと針を行き来する
stickProbのc.bias(偏りの指数)をBIAS_FERN寄りに下げると広い扇状のシダに、BIAS_NEEDLE寄りに上げると細く鋭い針に変わり、実際の雪結晶が気温と湿度で形を変える現象と同じ軸をなぞります。
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;
}
04六重対称ミラー — 60度ごとに折りたたみ6倍速で育つ
1つのセクタ(60度扇)だけシミュレートし残り5つを回転コピーすると、計算量は1/6で対称の狂わない完璧な結晶になります。SECTOR_DEG=60で計算し、累積面積は9種中5番目に大きい40.29%に達します。

<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度ごとに折りたたみ6倍速で育つ — 計測 累積面積 40.29% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
fold関数が角度を60度幅に折りたたむ
foldThは入力角度をSECTOR(60度)の幅に収まるよう剰余で折り返し、foldはその角度と半径から占有セルを1回だけ調べるので、残り5コピーは計算ではなく描画時の回転コピーで済みます。
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];
}
05霜の降りた窓 — 縁から内側へ這うフラクタル氷
種を中央ではなく窓枠の縁全体に敷き、温度場を内側へ下げると、凝集が窓の霜のように縁から内側へ伸びていきます。COLD=0.34で内側ほど凍りにくくすると、累積面積45.515%・面積の最大値35.31という9種で最も広い1枚になります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
resetSimが窓枠全体に種を敷き詰める
resetSimは四辺のセルすべてをoccに1として書き込んでから中央へ向かう歩行者を配置するため、結晶は1点からではなく窓の縁という線から同時多発的に育ち始めます。
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); }
}
06結晶格子の彩色 — 各セルの年齢をパステルの輪に刻む
粒子が付いた瞬間の時刻を記録し年齢ごとに色を変えると、結晶の成長の年輪が木のようにパステルの同心円で読めます。付着順をage配列に記録するだけの軽い処理で、累積面積は9種で最小の9.325%ながら強度平均は73.3まで上がります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
age配列が付着順を年輪として記録する
stepWalkerは粒子が付着した瞬間にorder.push(i)でセルの通し番号を並べ、age[i] = order.lengthでその時点の順番をそのまま年齢として刻むので、後の描画は年齢を読むだけで成長の年輪を色分けできます。
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;
}
07ポインタの種 — クリックした場所で2つ目の結晶が一緒に育つ
クリック(タップ)した場所にもう1つ種を植えると、粒子が2つの結晶の間で競いながら凝集します——出会った瞬間、枝が絡み合う相互成長です。累積面積34.032%、強度平均56.9で、ポインタ操作がなければ自動追加された種で同じ相互成長を再現します。

<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 · ポインタの種 — クリックした場所で2つ目の結晶が一緒に育つ — 計測 累積面積 34.032% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
stickが接した結晶の所有者をセルへ書き込む
filledNbで6方向の隣接凍結セル数を数えてから、stickはownerにどちらの結晶が付けたかを記録するため、2つの種が伸びて出会う境界では互いの所有セルが入り組み、枝が絡み合う模様になります。
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 });
}
08溶けてまた凍る — 最外側の粒子を剥がして再付着させる循環
一定時間ごとに表面の粒子を確率的に剥がして周囲に放つと、古い核は残り縁だけが育ち直す、息をする結晶になります。CYCLE_MS=2000ごとにMELT_FRAC=0.5の周期で溶かし、累積面積37.253%まで育ちながら面積の最大値は0.452と9種で最も小さく保たれます。

<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>秒 · 表面融解 → 再凝集</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
pCoreが古い核ほど溶けにくくする
meltStepは隣接凍結数nが少なく年齢が浅い表面セルほど高い確率pで剥がし、pCoreで年齢が古いコアほど確率を下げるため、中心の骨格は残ったまま縁だけが溶けては再び凍りつきます。
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
});
}
}
}
09マクロ写真シーン — 暗い舞台に浅い被写界深度で浮かぶ結晶
黒い背景に中央照明(放射グラデーション)を敷き結晶を大きく育てると、顕微鏡写真のような1枚になります——残光と浮遊粒子で画面が動き続けます。DOF_CELLS=26を境に鮮明レイヤーとぼかしレイヤーへ分け、累積面積36.536%、強度平均38.3で9種の中では2番目に落ち着いた明るさです。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
distC<=DOF_CELLSが鮮明層とぼかし層を分ける
repaintGridsはセルの中心からの距離distCがDOF_CELLS以下なら鮮明レイヤーに、それを超えればぼかしレイヤーへ同じ色を書き込むので、後段のフィルタは焦点内と焦点外を別々のキャンバスとしてぼかすだけで済みます。
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;
}
}
}
写真調のヒーローとクリックで育つ結晶 — この9枚を実際どこで使うか
冬季キャンペーンや雪テーマのランディングページの表紙なら09 マクロ写真シーンが一番早い方法です。暗い舞台に浅い被写界深度で浮かぶ結晶一枚がすでに写真のように完成しているので、見出しを重ねるだけで済みます。訪問者に直接クリックしてほしいヒーロー領域には07 ポインタの種を置くと、クリックした場所で2つ目の結晶が競うように育つ反応がそのまま体験になります。
湿度と結晶の形の関係を説明する科学コンテンツには03 針・シダ結晶が向いています。湿度帯の値1つを動かすだけで葉の形から針の形へ移る過程が、別のグラフなしで画面上に見えます。3枚ともファイル1つで完結しているので、追加のフレームワークなしで静的セクションにそのまま組み込めます。
雪の結晶はなぜDLAで育つか — ランダムウォーク凝集と六角対称の計算
ランダムウォークは長さより衝突の回数が形を決める
粒子が結晶に触れるまで歩く距離そのものは形に関係せず、触れた瞬間に何%の確率で凍りつくかという1つの数だけが、丸い塊になるか枝分かれした樹枝になるかを分けます。
6方向の隣接判定が正六角形の骨格を作る
01・02・03・07・08・09は上下左右4方向ではなく6方向の隣接セルを調べるnbr(またはhexNbr・frozenNb)関数を使うことで、格子の歩行がそのまま正六角形の対称を持ち、四角格子を60度回転で近似する方法より結晶の角が自然に尖ります。04は同じ六方向の対称を保ったまま1セクタだけ計算して折りたたみ、05・06はあえて正方格子の4方向隣接を使うことで霜や年輪のような結晶らしくない不揃いな質感を出しています。snowflake generator svgの中にはベクターパスとして結晶を書き出すものもありますが、ここではセルの格子(ピクセル)を直接塗るラスター計算なので、境界の滑らかさより粒子1つ1つの凝集そのものを目で追えます。
付着確率という1つのつまみが9つの性格を生む
STICK・TIP_BIAS・BIAS_FERN・BIAS_NEEDLE・COLDという名前は違っても、どれも「触れた瞬間に何%で凍るか」という同じ関数の引数で、この1つの軸を動かすだけで01から09までの見た目が変わります。
セクタ折りたたみが六角対称の計算量を1/6にする
60度のセクタ1つだけをシミュレートして残りを回転コピーする04の手法は、対称性がある形を計算するときの一般的な近道で、フルの格子を6回計算するより速く、しかも対称が数値誤差でずれない利点があります。
湿度帯と鏡映数を変える3つの変種とreduced-motion
定数ひとつで変わる3つのバリエーション
| 名前 | 変える値 | 手触り |
|---|---|---|
| 枝分かれの確率 | 02 樹枝状結晶 分岐確率 0.3 → 0.6 | 枝が密になるほど本物の雪結晶に近いレース模様になる |
| 凝集の速さ | 01 基本の六角凝集 粒子220個/秒 → 700個/秒 | 速度を上げると結晶が目に見えて速く育ちデモが短くなる |
| 対称の折りたたみ角 | 04 六重対称ミラー セクタ角60度 → 30度 | セクタを半分にすると枝がさらに細く精巧になる |
動きを減らす設定で残る静止フレーム
動きを減らす設定(prefers-reduced-motion: reduce)が検知されたとき、9盤すべてがループを止め、.is-stillクラスを付けてその盤が最も読める一場面だけを計算し直します。01・02・03は粒子が半分ほど付いた雪片の途中経過を、04は1セクタの計算が済んだ直後の対称形を、07は2つの結晶が触れ合う瞬間を、09は被写界深度が定まった1枚を静止画として描きます。
9つの全盤がタブで焦点を受け取る
9盤ともrole="application"とtabindex="0"を持つので、タブキーで焦点を移して盤に入れます。ポインター入力を受け取るのは07だけで、クリックした場所にもう1つの種を植える反応が盤の主題なので、動きを減らす設定の利用者でも操作した瞬間の凝集を計算し直して描きます。
四角格子のまま60度回転しても六角形にならない落とし穴
四角格子に60度回転を足すだけでは六角にならない
隣接判定を4方向のまま60度だけ回転させても、セルの並び自体は四角格子のままなので、育つ結晶は角が微妙にずれた擬似六角形になります。nbr関数で本物の6方向テーブルを使うのが唯一の回避策です。
STICKを1.0に固定したまま先端を尖らせようとすると詰まる
01のようにSTICKが常に1.0だと、先端だけでなく側面のあらゆるセルが同じ確率で埋まるため、TIP_BIASのような先端偏重の項を別に足さない限り、いくら粒子を増やしても丸いまま尖りません。
溶けと再凍結を自分の値で試すにはzipを開く
08のMELT_RATEやpCoreの重みを自分の値で試したくなったら、9つのソース一式が入ったzipを開くことになり、解凍のパスワードはfcyyjg6vです。
雪結晶DLAを組み込む前によくある質問
snowflake generatorはどうやって六角形の対称を保っていますか?
01・02・03・07・08・09は6方向の隣接テーブル(nbr・hexNbr・frozenNb)を使って格子上を歩かせているため、四角格子を回転させる近似と違い、粒子がどの方向に歩いても正六角形の対称が数値的に崩れません。04はその六方向対称を保ったまま1セクタだけ計算して折りたたみ、霜や年輪を描く05・06はあえて正方格子の4方向隣接で不揃いな質感を選んでいます。
9枚とも同じ乱数の種で毎回同じ結晶になりますか?
はい。mulberry32という1つの関数を各盤ごとに固定シードで初期化しているため、リロードしても同じ手順で同じ結晶が育ちます。粒子の数や確率の定数を変えれば別の結晶になります。
snowflake generator stlのように立体データを出力できますか?
このデモはcanvasの2Dピクセルに凝集した結果を描くだけで、3Dプリンタ向けのSTLのような立体データは生成しません。格子上のオン/オフ配列をそのまま読み取れば、自分でSTLへ変換する土台には使えます。









