01基本の六角凝集 — ランダムウォーク粒子が触れた場所に付くDLAひと層
結晶の種を1つ置き、外周から出た粒子が上下左右にランダムに歩き、結晶に触れた瞬間に付く規則を数千回繰り返すと、六角の尖ったクラスタが自ら育ちます。
使いどころヒーロー背景でゆっくり育つ結晶、冬シーズンのリード画像
ImageDataputImageDatarequestAnimationFrame

<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 に同梱 · snowflake generator 9種 — DLAで育つ雪結晶
このデモが属する記事
snowflake generator 9種 — DLAで育つ雪結晶snowflake generatorとは、雪片の絵を貼り付ける作業ではなく、粒子が結晶に触れて凍りつく規則を何千回も繰り返して形を育てる計算のことです。ライブラリ·WebGL なしで、外から飛んできた粒子1つが結晶に触れて付く瞬間を繰り返 この記事のデモをすべて見る


