08Melt-and-refreeze — peeling the outermost particles and re-sticking them
Every so often, probabilistically peel surface particles and let them re-stick nearby — the old core survives while the rim keeps re-growing, a breathing crystal.
Best forMeditation backdrops, long-dwell heroes
ImageDataputImageDatarequestAnimationFrame

<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>Detach surface particles by chance and drop them nearby again — the old core stays put while the edge keeps regrowing, a breathing crystal's cycle</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Melt-Recrystallize Cycle Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv" width="424" height="180"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">cycle <b class="cd__val" id="cdVal">4.0</b>s · melt → re-aggregate</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겹으로 산다: ① 온도에 따라 색온도가 도는 배경 조명 ② grid cells 확대 blur 광륜
// ③ grid cells ImageData 본체(나이 색) ④ 녹아 떨어져 떠다니는 방류 입자 ⑤ 표면 반짝임.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var CYCLE_MS = 2000; // 온도 순환 한 바퀴(측정 gif 2초의 약수 — 주기가 정확히 겹친다)
var MELT_FRAC = 0.5; // cycle 뒷반(녹는 구간) 비율
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; // grid cells 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 + ')'; }
// ── grid cells 상태 ──
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); // 나이를 골고루 — growth rings 여러 겹
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];
// 방류 입자의 표면 스캔 — grid cells 좌표로 환산해 인접 언 셀을 찾는다
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); }
}
}
// ── grid cells → 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) {
// cycle 뒷반 = 더운(녹는) 구간 — 사인으로 부드럽게 오르내린다
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 Melt-and-refreeze — peeling the outermost particles and re-sticking them — Measured area 37.253% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Snowflake Generator, 9 Ways (DLA, No Library)
The post this demo lives in
Snowflake Generator, 9 Ways (DLA, No Library)No library, no WebGL — repeat one moment, a wandering particle touching the crystal and sticking, and a six-fold snowfla See all demos in this post
If you like this effect - similar demos
Creative UI03 Torus ring — the donut distance and rim light9 Ray Marching Scenes in Canvas (No WebGL)
Creative UI03 Maze — stripes kink into corridors in the middle ground9 Reaction Diffusion Simulations, No Library
Creative UI04 Afterglow diffusion — the bolt's halo bleeding through a pixel buffer9 Lightning Effect Background Scenes, No WebGL