보로노이 다이어그램 그리는 법 9가지 — Canvas 코드
보로노이 다이어그램 그리는 법은 손으로 선을 잇는 게 아니라 계산이 대신 긋는 것이다. 보로노이 다이어그램은 선을 긋는 그림이 아니다 — 모든 점이 가장 가까운 시드 좌표 값을 찾아 칠해진 결과다.
01 voronoi-cell-mosaic-fill
02 voronoi-cracked-earth-texture
03 voronoi-stained-glass-panel
04 delaunay-triangle-mesh-morph
05 voronoi-pointer-seed-repel
06 voronoi-edge-network-glow
07 voronoi-lloyd-relaxation-converge
08 voronoi-organic-camouflage-pattern
09 voronoi-hero-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 시드 개수 값만큼 색이 채워지는 모자이크 셀
- 02 경계선 두께 값이 마른 땅처럼 갈라지는 셀
- 03 셀마다 빛 투과 값이 다른 스테인드글라스 패널
- 04 정점 흔들림 값으로 모핑하는 들로네 삼각 메쉬
- 05 포인터가 하나의 시드가 되어 경계를 미는 셀
- 06 경계선만 남아 빛나는 네트워크 셀
- 07 반복 횟수 값이 늘수록 고르게 정돈되는 셀
- 08 셀 크기 편차 값으로 만드는 위장 무늬
- 09 여러 값이 한 화면에서 함께 반응하는 히어로 장면
아홉 개는 무작위처럼 보여도 순서가 있다 — 셀을 채우는 가장 단순한 보로노이 패턴에서 시작해 01번이 기본 모자이크를 보여주고, 경계 두께(02)·빛 투과(03)·정점(04)·포인터(05)·경계만 남기기(06)·완화(07)·노이즈(08) 순으로 계산 층을 하나씩 얹다가, 09번에서 표류·포인터 반발·완화를 한 화면에 모아 쓴다.
시드 좌표 값 하나가 왜 셀 전체를 다시 계산하게 만드는가
보로노이 다이어그램은 셀마다 선을 따로 그리는 게 아니라, 오프스크린 160×100 픽셀 하나하나가 nearestTwo 함수로 자신과 가장 가까운 시드·두 번째로 가까운 시드까지 거리를 재서 스스로 소속 셀과 경계 여부를 정하는 방식으로 만들어진다. 계산이 끝나면 putImageData 로 그 결과를 오프스크린 캔버스에 찍고 화면 크기로 늘려 그린다. 시드 좌표 하나만 옮겨도 그 시드에 가까웠던 모든 픽셀이 재계산 대상이 되므로, CSS 트랜지션처럼 속성 하나를 보간하는 게 아니라 16,000개 픽셀 전부가 프레임마다 거리 비교를 다시 하는 계산이다.
01시드 개수 값만큼 색이 채워지는 모자이크 셀
VORONOI_SEED_COUNT 값(24개)만큼 뿌린 시드 좌표가 프레임마다 sin·cos 로 살짝 흔들리고, 오프스크린의 모든 픽셀이 nearestTwo 로 가장 가까운 시드와 두 번째로 가까운 시드까지 거리를 비교해 자기 색을 고른다. 두 거리 차(edge = ND2 - ND)가 1.5보다 작아지는 픽셀만 어둡게 눌러 셀 경계선을 만든다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">시드 개수 값만큼 색이 채워지는 모자이크 셀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}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);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var VORONOI_SEED_COUNT = 24;
var DRIFT_AMP = 9;
var DRIFT_FREQ = Math.PI * 2 / LOOP_MS;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(409011);
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var offImg = octx.createImageData(CALC_W, CALC_H);
var offBuf = offImg.data;
function hexToRgb(hex) {
var v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
var NEI = -1, ND = 0, ND2 = 0;
function nearestTwo(px, py, arr) {
var bd = Infinity, bd2 = Infinity, bi = -1;
for (var i = 0; i < arr.length; i += 1) {
var dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
var mosaicPalette = ['#2f6df6', '#ff9ebb', '#4cd4a6', '#b9a0ff', '#6ec8ff', '#fff7e6'];
var paletteRgb = {};
mosaicPalette.forEach(function (h) { paletteRgb[h] = hexToRgb(h); });
var seeds = [];
for (var i0 = 0; i0 < VORONOI_SEED_COUNT; i0 += 1) {
seeds.push({
bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2,
c: mosaicPalette[i0 % mosaicPalette.length]
});
}
function driftSeeds(t) {
seeds.forEach(function (s) {
s.x = s.bx + Math.sin(t * DRIFT_FREQ + s.phaseX) * DRIFT_AMP;
s.y = s.by + Math.cos(t * DRIFT_FREQ * 0.8 + s.phaseY) * DRIFT_AMP;
});
}
function renderField(t) {
driftSeeds(t);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
var rgb = paletteRgb[seeds[NEI].c];
var edge = ND2 - ND;
var dark = edge < 1.5 ? 0.5 + edge / 3 : 1;
offBuf[idx] = rgb[0] * dark;
offBuf[idx + 1] = rgb[1] * dark;
offBuf[idx + 2] = rgb[2] * dark;
offBuf[idx + 3] = 255;
idx += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./VoronoiCellMosaicFill.module.scss";
export default function VoronoiCellMosaicFill() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const VORONOI_SEED_COUNT = 24;
const DRIFT_AMP = 9;
const DRIFT_FREQ = (Math.PI * 2) / LOOP_MS;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(409011);
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
const offImg = octx.createImageData(CALC_W, CALC_H);
const offBuf = offImg.data;
function hexToRgb(hex: string): [number, number, number] {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
type Vec = { x: number; y: number };
let NEI = -1, ND = 0, ND2 = 0;
function nearestTwo<T extends Vec>(px: number, py: number, arr: T[]): void {
let bd = Infinity, bd2 = Infinity, bi = -1;
for (let i = 0; i < arr.length; i += 1) {
const dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
type Seed = { bx: number; by: number; x: number; y: number; phaseX: number; phaseY: number; c: string };
const mosaicPalette = ["#2f6df6", "#ff9ebb", "#4cd4a6", "#b9a0ff", "#6ec8ff", "#fff7e6"];
const paletteRgb: Record<string, [number, number, number]> = {};
mosaicPalette.forEach((h) => { paletteRgb[h] = hexToRgb(h); });
const seeds: Seed[] = [];
for (let i0 = 0; i0 < VORONOI_SEED_COUNT; i0 += 1) {
seeds.push({
bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2,
c: mosaicPalette[i0 % mosaicPalette.length]
});
}
function driftSeeds(t: number) {
seeds.forEach((s) => {
s.x = s.bx + Math.sin(t * DRIFT_FREQ + s.phaseX) * DRIFT_AMP;
s.y = s.by + Math.cos(t * DRIFT_FREQ * 0.8 + s.phaseY) * DRIFT_AMP;
});
}
function renderField(t: number) {
driftSeeds(t);
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
const rgb = paletteRgb[seeds[NEI].c];
const edge = ND2 - ND;
const dark = edge < 1.5 ? 0.5 + edge / 3 : 1;
offBuf[idx] = rgb[0] * dark;
offBuf[idx + 1] = rgb[1] * dark;
offBuf[idx + 2] = rgb[2] * dark;
offBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx!.imageSmoothingEnabled = false;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>시드 개수 값만큼 색이 채워지는 모자이크 셀</p>
</div>
</main>
);
}01 · 시드 개수 값만큼 색이 채워지는 모자이크 셀 — 측정 누적면적 60.149% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function renderField(t) {
driftSeeds(t);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
var rgb = paletteRgb[seeds[NEI].c];
var edge = ND2 - ND;
var dark = edge < 1.5 ? 0.5 + edge / 3 : 1;
offBuf[idx] = rgb[0] * dark;
offBuf[idx + 1] = rgb[1] * dark;
offBuf[idx + 2] = rgb[2] * dark;
offBuf[idx + 3] = 255;
idx += 4;
}
}
}
측정: 누적면적 60.1% · 강도 평균 84.0 · 23/23 프레임에서 움직였다.
02경계선 두께 값이 마른 땅처럼 갈라지는 셀
CRACK_WIDTH_VALUE(1.6)보다 두 시드 사이 거리 차가 좁은 픽셀만 잉크색(INK_RGB)으로 끌어당기고, 그 혼합 비율은 sin 파동(crackPulsePhase)에 따라 시간마다 오르내려 균열이 숨 쉬듯 짙어졌다 옅어졌다 한다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">경계선 두께 값이 마른 땅처럼 갈라지는 셀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var CRACK_WIDTH_VALUE = 1.6;
var DRIFT_AMP = 6;
var DRIFT_FREQ = Math.PI * 2 / LOOP_MS;
var PULSE_FREQ = Math.PI * 2 / LOOP_MS;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(409021);
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var offImg = octx.createImageData(CALC_W, CALC_H);
var offBuf = offImg.data;
function hexToRgb(hex) {
var v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
var NEI = -1, ND = 0, ND2 = 0;
function nearestTwo(px, py, arr) {
var bd = Infinity, bd2 = Infinity, bi = -1;
for (var i = 0; i < arr.length; i += 1) {
var dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
var earthPalette = ['#fff7e6', '#ffd23f', '#c93000'];
var paletteRgb = {};
earthPalette.forEach(function (h) { paletteRgb[h] = hexToRgb(h); });
var SEED_COUNT02 = 30;
var seeds = [];
for (var i1 = 0; i1 < SEED_COUNT02; i1 += 1) {
seeds.push({
bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2,
c: earthPalette[i1 % earthPalette.length]
});
}
function driftSeeds(t) {
seeds.forEach(function (s) {
s.x = s.bx + Math.sin(t * DRIFT_FREQ + s.phaseX) * DRIFT_AMP;
s.y = s.by + Math.cos(t * DRIFT_FREQ + s.phaseY) * DRIFT_AMP;
});
}
var INK_RGB = [23, 20, 26];
function renderField(t) {
driftSeeds(t);
var crackPulsePhase = 0.5 + 0.5 * Math.sin(t * PULSE_FREQ);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
var rgb = paletteRgb[seeds[NEI].c];
var band = 0.85 + 0.15 * Math.sin(ND * 0.4 + NEI);
var edge = ND2 - ND;
var r, g, b;
if (edge < CRACK_WIDTH_VALUE) {
var m = 0.15 + 0.55 * crackPulsePhase;
r = rgb[0] + (INK_RGB[0] - rgb[0]) * m;
g = rgb[1] + (INK_RGB[1] - rgb[1]) * m;
b = rgb[2] + (INK_RGB[2] - rgb[2]) * m;
} else {
r = rgb[0] * band; g = rgb[1] * band; b = rgb[2] * band;
}
offBuf[idx] = r; offBuf[idx + 1] = g; offBuf[idx + 2] = b; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.25 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./VoronoiCrackedEarthTexture.module.scss";
export default function VoronoiCrackedEarthTexture() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const CRACK_WIDTH_VALUE = 1.6;
const DRIFT_AMP = 6;
const DRIFT_FREQ = (Math.PI * 2) / LOOP_MS;
const PULSE_FREQ = (Math.PI * 2) / LOOP_MS;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(409021);
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
const offImg = octx.createImageData(CALC_W, CALC_H);
const offBuf = offImg.data;
function hexToRgb(hex: string): [number, number, number] {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
type Vec = { x: number; y: number };
let NEI = -1, ND = 0, ND2 = 0;
function nearestTwo<T extends Vec>(px: number, py: number, arr: T[]): void {
let bd = Infinity, bd2 = Infinity, bi = -1;
for (let i = 0; i < arr.length; i += 1) {
const dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
type Seed = { bx: number; by: number; x: number; y: number; phaseX: number; phaseY: number; c: string };
const earthPalette = ["#fff7e6", "#ffd23f", "#c93000"];
const paletteRgb: Record<string, [number, number, number]> = {};
earthPalette.forEach((h) => { paletteRgb[h] = hexToRgb(h); });
const SEED_COUNT02 = 30;
const seeds: Seed[] = [];
for (let i1 = 0; i1 < SEED_COUNT02; i1 += 1) {
seeds.push({
bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2,
c: earthPalette[i1 % earthPalette.length]
});
}
function driftSeeds(t: number) {
seeds.forEach((s) => {
s.x = s.bx + Math.sin(t * DRIFT_FREQ + s.phaseX) * DRIFT_AMP;
s.y = s.by + Math.cos(t * DRIFT_FREQ + s.phaseY) * DRIFT_AMP;
});
}
const INK_RGB: [number, number, number] = [23, 20, 26];
function renderField(t: number) {
driftSeeds(t);
const crackPulsePhase = 0.5 + 0.5 * Math.sin(t * PULSE_FREQ);
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
const rgb = paletteRgb[seeds[NEI].c];
const band = 0.85 + 0.15 * Math.sin(ND * 0.4 + NEI);
const edge = ND2 - ND;
let r: number, g: number, b: number;
if (edge < CRACK_WIDTH_VALUE) {
const m = 0.15 + 0.55 * crackPulsePhase;
r = rgb[0] + (INK_RGB[0] - rgb[0]) * m;
g = rgb[1] + (INK_RGB[1] - rgb[1]) * m;
b = rgb[2] + (INK_RGB[2] - rgb[2]) * m;
} else {
r = rgb[0] * band; g = rgb[1] * band; b = rgb[2] * band;
}
offBuf[idx] = r; offBuf[idx + 1] = g; offBuf[idx + 2] = b; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx!.imageSmoothingEnabled = false;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.25 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>경계선 두께 값이 마른 땅처럼 갈라지는 셀</p>
</div>
</main>
);
}02 · 경계선 두께 값이 마른 땅처럼 갈라지는 셀 — 측정 누적면적 73.787% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function renderField(t) {
driftSeeds(t);
var crackPulsePhase = 0.5 + 0.5 * Math.sin(t * PULSE_FREQ);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
var rgb = paletteRgb[seeds[NEI].c];
var band = 0.85 + 0.15 * Math.sin(ND * 0.4 + NEI);
var edge = ND2 - ND;
var r, g, b;
if (edge < CRACK_WIDTH_VALUE) {
var m = 0.15 + 0.55 * crackPulsePhase;
r = rgb[0] + (INK_RGB[0] - rgb[0]) * m;
g = rgb[1] + (INK_RGB[1] - rgb[1]) * m;
b = rgb[2] + (INK_RGB[2] - rgb[2]) * m;
} else {
r = rgb[0] * band; g = rgb[1] * band; b = rgb[2] * band;
}
offBuf[idx] = r; offBuf[idx + 1] = g; offBuf[idx + 2] = b; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
측정: 누적면적 73.8% · 강도 평균 47.9 · 23/23 프레임에서 움직였다.
03셀마다 빛 투과 값이 다른 스테인드글라스 패널
cosA·sinA 로 만든 방향값에 픽셀 좌표를 투영(proj)해 시간에 따라 이동하는 광선 띠(bandCenter)를 만들고, 그 띠에 가까운 픽셀일수록 가우시안 값(glow)이 커져 셀이 유리처럼 밝아진다 — 경계 픽셀은 GLASS_BORDER_PX 안쪽에서 따로 어두워진다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">셀마다 빛 투과 값이 다른 스테인드글라스 패널</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var GLASS_BORDER_PX = 1.8;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(409031);
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var offImg = octx.createImageData(CALC_W, CALC_H);
var offBuf = offImg.data;
function hexToRgb(hex) {
var v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
var NEI = -1, ND = 0, ND2 = 0;
function nearestTwo(px, py, arr) {
var bd = Infinity, bd2 = Infinity, bi = -1;
for (var i = 0; i < arr.length; i += 1) {
var dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
var stainedPalette = ['#2f6df6', '#ff9ebb', '#4cd4a6', '#b9a0ff', '#6ec8ff'];
var paletteRgb = {};
stainedPalette.forEach(function (h) { paletteRgb[h] = hexToRgb(h); });
var SEED_COUNT03 = 20;
var seeds = [];
for (var i2 = 0; i2 < SEED_COUNT03; i2 += 1) {
seeds.push({ x: rand() * CALC_W, y: rand() * CALC_H, c: stainedPalette[i2 % stainedPalette.length] });
}
var cellIdx = new Int16Array(CALC_W * CALC_H);
var edgeVal = new Float32Array(CALC_W * CALC_H);
(function precompute() {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
cellIdx[idx] = NEI;
edgeVal[idx] = ND2 - ND;
idx += 1;
}
}
})();
var lightSweepAngle = 0.62;
var cosA = Math.cos(lightSweepAngle), sinA = Math.sin(lightSweepAngle);
var projSpan = Math.abs(CALC_W * cosA) + Math.abs(CALC_H * sinA);
function renderField(t) {
var bandCenter = ((t / LOOP_MS) * (projSpan * 1.6)) - projSpan * 0.3;
var idx4 = 0, idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var ni = cellIdx[idx];
var rgb = paletteRgb[seeds[ni].c];
var proj = x * cosA + y * sinA;
var glow = Math.exp(-Math.pow((proj - bandCenter) / 14, 2)) * 0.6;
var e = edgeVal[idx];
var borderMix = e < GLASS_BORDER_PX ? 0.35 : 0;
var r = rgb[0] * (1 - borderMix) * (1 + glow);
var g = rgb[1] * (1 - borderMix) * (1 + glow);
var b = rgb[2] * (1 - borderMix) * (1 + glow);
offBuf[idx4] = Math.min(255, r);
offBuf[idx4 + 1] = Math.min(255, g);
offBuf[idx4 + 2] = Math.min(255, b);
offBuf[idx4 + 3] = 255;
idx += 1; idx4 += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./VoronoiStainedGlassPanel.module.scss";
export default function VoronoiStainedGlassPanel() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const GLASS_BORDER_PX = 1.8;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(409031);
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
const offImg = octx.createImageData(CALC_W, CALC_H);
const offBuf = offImg.data;
function hexToRgb(hex: string): [number, number, number] {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
type Vec = { x: number; y: number };
let NEI = -1, ND = 0, ND2 = 0;
function nearestTwo<T extends Vec>(px: number, py: number, arr: T[]): void {
let bd = Infinity, bd2 = Infinity, bi = -1;
for (let i = 0; i < arr.length; i += 1) {
const dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
type Seed = { x: number; y: number; c: string };
const stainedPalette = ["#2f6df6", "#ff9ebb", "#4cd4a6", "#b9a0ff", "#6ec8ff"];
const paletteRgb: Record<string, [number, number, number]> = {};
stainedPalette.forEach((h) => { paletteRgb[h] = hexToRgb(h); });
const SEED_COUNT03 = 20;
const seeds: Seed[] = [];
for (let i2 = 0; i2 < SEED_COUNT03; i2 += 1) {
seeds.push({ x: rand() * CALC_W, y: rand() * CALC_H, c: stainedPalette[i2 % stainedPalette.length] });
}
const cellIdx = new Int16Array(CALC_W * CALC_H);
const edgeVal = new Float32Array(CALC_W * CALC_H);
(function precompute() {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
cellIdx[idx] = NEI;
edgeVal[idx] = ND2 - ND;
idx += 1;
}
}
})();
const lightSweepAngle = 0.62;
const cosA = Math.cos(lightSweepAngle), sinA = Math.sin(lightSweepAngle);
const projSpan = Math.abs(CALC_W * cosA) + Math.abs(CALC_H * sinA);
function renderField(t: number) {
const bandCenter = ((t / LOOP_MS) * (projSpan * 1.6)) - projSpan * 0.3;
let idx4 = 0, idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
const ni = cellIdx[idx];
const rgb = paletteRgb[seeds[ni].c];
const proj = x * cosA + y * sinA;
const glow = Math.exp(-Math.pow((proj - bandCenter) / 14, 2)) * 0.6;
const e = edgeVal[idx];
const borderMix = e < GLASS_BORDER_PX ? 0.35 : 0;
const r = rgb[0] * (1 - borderMix) * (1 + glow);
const g = rgb[1] * (1 - borderMix) * (1 + glow);
const b = rgb[2] * (1 - borderMix) * (1 + glow);
offBuf[idx4] = Math.min(255, r);
offBuf[idx4 + 1] = Math.min(255, g);
offBuf[idx4 + 2] = Math.min(255, b);
offBuf[idx4 + 3] = 255;
idx += 1; idx4 += 4;
}
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx!.imageSmoothingEnabled = false;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>셀마다 빛 투과 값이 다른 스테인드글라스 패널</p>
</div>
</main>
);
}03 · 셀마다 빛 투과 값이 다른 스테인드글라스 패널 — 측정 누적면적 72.633% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function renderField(t) {
var bandCenter = ((t / LOOP_MS) * (projSpan * 1.6)) - projSpan * 0.3;
var idx4 = 0, idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var ni = cellIdx[idx];
var rgb = paletteRgb[seeds[ni].c];
var proj = x * cosA + y * sinA;
var glow = Math.exp(-Math.pow((proj - bandCenter) / 14, 2)) * 0.6;
var e = edgeVal[idx];
var borderMix = e < GLASS_BORDER_PX ? 0.35 : 0;
var r = rgb[0] * (1 - borderMix) * (1 + glow);
var g = rgb[1] * (1 - borderMix) * (1 + glow);
var b = rgb[2] * (1 - borderMix) * (1 + glow);
offBuf[idx4] = Math.min(255, r);
offBuf[idx4 + 1] = Math.min(255, g);
offBuf[idx4 + 2] = Math.min(255, b);
offBuf[idx4 + 3] = 255;
idx += 1; idx4 += 4;
}
}
}
측정: 누적면적 72.6% · 강도 평균 42.4 · 20/23 프레임에서 움직였다.
04정점 흔들림 값으로 모핑하는 들로네 삼각 메쉬
VERTEX_JITTER_PX(10)만큼 매 프레임 sin·cos 로 흔들린 정점 좌표를 delaunayTriangulate 에 다시 넣어, 외접원 안에 다른 점이 들어오는 삼각형만 골라 갈아엎는 방식으로 그물 전체를 다시 짠다. 이 삼각 그물은 같은 시드로 그리는 보로노이 다이어그램 들로네 삼각분할의 짝이다 — 외접원 안에 다른 점이 없어야 한다는 조건만으로 선이 정해진다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">정점 흔들림 값으로 모핑하는 들로네 삼각 메쉬</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LOOP_MS = 2000;
var VERTEX_JITTER_PX = 10;
var JITTER_FREQ = Math.PI * 2 / LOOP_MS;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(409041);
var meshPalette = ['#2f6df6', '#b9a0ff', '#6ec8ff', '#4cd4a6', '#ff9ebb'];
var BASE_W = 480, BASE_H = 300;
var points = [];
var GRID_X = 7, GRID_Y = 5;
for (var gy = 0; gy < GRID_Y; gy += 1) {
for (var gx = 0; gx < GRID_X; gx += 1) {
var jx = (rand() - 0.5) * (BASE_W / GRID_X) * 0.7;
var jy = (rand() - 0.5) * (BASE_H / GRID_Y) * 0.7;
points.push({
bx: (gx + 0.5) * (BASE_W / GRID_X) + jx,
by: (gy + 0.5) * (BASE_H / GRID_Y) + jy,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2
});
}
}
function circumcircle(a, b, c) {
var ax = a[0], ay = a[1], bx = b[0], by = b[1], cx = c[0], cy = c[1];
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if (Math.abs(d) < 1e-6) return null;
var ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
var uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
var r2 = (ux - ax) * (ux - ax) + (uy - ay) * (uy - ay);
return { x: ux, y: uy, r2: r2 };
}
function delaunayTriangulate(pts) {
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
pts.forEach(function (p) {
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
});
var dx = maxX - minX, dy = maxY - minY, dmax = Math.max(dx, dy) || 1;
var midx = (minX + maxX) / 2, midy = (minY + maxY) / 2;
var work = pts.slice();
var s0 = work.length, s1 = work.length + 1, s2 = work.length + 2;
work.push([midx - 20 * dmax, midy - dmax], [midx, midy + 20 * dmax], [midx + 20 * dmax, midy - dmax]);
var tris = [[s0, s1, s2]];
for (var i = 0; i < pts.length; i += 1) {
var p = work[i];
var bad = [];
for (var t = 0; t < tris.length; t += 1) {
var tri = tris[t];
var cc = circumcircle(work[tri[0]], work[tri[1]], work[tri[2]]);
if (!cc) continue;
var ddx = p[0] - cc.x, ddy = p[1] - cc.y;
if (ddx * ddx + ddy * ddy <= cc.r2) bad.push(t);
}
var edgeCount = {};
bad.forEach(function (t2) {
var tri = tris[t2];
var edges = [[tri[0], tri[1]], [tri[1], tri[2]], [tri[2], tri[0]]];
edges.forEach(function (e) {
var key = Math.min(e[0], e[1]) + '_' + Math.max(e[0], e[1]);
if (!edgeCount[key]) edgeCount[key] = { e: e, n: 0 };
edgeCount[key].n += 1;
});
});
var boundary = [];
Object.keys(edgeCount).forEach(function (k) { if (edgeCount[k].n === 1) boundary.push(edgeCount[k].e); });
bad.sort(function (a2, b2) { return b2 - a2; }).forEach(function (t3) { tris.splice(t3, 1); });
boundary.forEach(function (e) { tris.push([e[0], e[1], i]); });
}
return tris.filter(function (tri) { return tri[0] < pts.length && tri[1] < pts.length && tri[2] < pts.length; });
}
var cw = 480, ch = 300, scaleX = 1, scaleY = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
scaleX = cw / BASE_W; scaleY = ch / BASE_H;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
var jittered = points.map(function (p) {
return [
p.bx + Math.sin(elapsed * JITTER_FREQ + p.phaseX) * VERTEX_JITTER_PX,
p.by + Math.cos(elapsed * JITTER_FREQ * 0.85 + p.phaseY) * VERTEX_JITTER_PX
];
});
var tris = delaunayTriangulate(jittered);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cw, ch);
tris.forEach(function (tri, ti) {
var a = jittered[tri[0]], b = jittered[tri[1]], c = jittered[tri[2]];
ctx.beginPath();
ctx.moveTo(a[0] * scaleX, a[1] * scaleY);
ctx.lineTo(b[0] * scaleX, b[1] * scaleY);
ctx.lineTo(c[0] * scaleX, c[1] * scaleY);
ctx.closePath();
ctx.fillStyle = meshPalette[ti % meshPalette.length];
ctx.globalAlpha = 0.55;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgba(23,20,26,.5)';
ctx.lineWidth = 1;
ctx.stroke();
});
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) { elapsed += dt; if (elapsed >= LOOP_MS) elapsed -= LOOP_MS; }
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./DelaunayTriangleMeshMorph.module.scss";
type Point = { bx: number; by: number; phaseX: number; phaseY: number };
type Circum = { x: number; y: number; r2: number };
export default function DelaunayTriangleMeshMorph() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LOOP_MS = 2000;
const VERTEX_JITTER_PX = 10;
const JITTER_FREQ = (Math.PI * 2) / LOOP_MS;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(409041);
const meshPalette = ["#2f6df6", "#b9a0ff", "#6ec8ff", "#4cd4a6", "#ff9ebb"];
const BASE_W = 480, BASE_H = 300;
const points: Point[] = [];
const GRID_X = 7, GRID_Y = 5;
for (let gy = 0; gy < GRID_Y; gy += 1) {
for (let gx = 0; gx < GRID_X; gx += 1) {
const jx = (rand() - 0.5) * (BASE_W / GRID_X) * 0.7;
const jy = (rand() - 0.5) * (BASE_H / GRID_Y) * 0.7;
points.push({
bx: (gx + 0.5) * (BASE_W / GRID_X) + jx,
by: (gy + 0.5) * (BASE_H / GRID_Y) + jy,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2
});
}
}
function circumcircle(a: [number, number], b: [number, number], c: [number, number]): Circum | null {
const ax = a[0], ay = a[1], bx = b[0], by = b[1], cx = c[0], cy = c[1];
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if (Math.abs(d) < 1e-6) return null;
const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
const r2 = (ux - ax) * (ux - ax) + (uy - ay) * (uy - ay);
return { x: ux, y: uy, r2: r2 };
}
function delaunayTriangulate(pts: [number, number][]): [number, number, number][] {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
pts.forEach((p) => {
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
});
const dx = maxX - minX, dy = maxY - minY, dmax = Math.max(dx, dy) || 1;
const midx = (minX + maxX) / 2, midy = (minY + maxY) / 2;
const work: [number, number][] = pts.slice();
const s0 = work.length, s1 = work.length + 1, s2 = work.length + 2;
work.push([midx - 20 * dmax, midy - dmax], [midx, midy + 20 * dmax], [midx + 20 * dmax, midy - dmax]);
let tris: [number, number, number][] = [[s0, s1, s2]];
for (let i = 0; i < pts.length; i += 1) {
const p = work[i];
const bad: number[] = [];
for (let t = 0; t < tris.length; t += 1) {
const tri = tris[t];
const cc = circumcircle(work[tri[0]], work[tri[1]], work[tri[2]]);
if (!cc) continue;
const ddx = p[0] - cc.x, ddy = p[1] - cc.y;
if (ddx * ddx + ddy * ddy <= cc.r2) bad.push(t);
}
const edgeCount: Record<string, { e: [number, number]; n: number }> = {};
bad.forEach((t2) => {
const tri = tris[t2];
const edges: [number, number][] = [[tri[0], tri[1]], [tri[1], tri[2]], [tri[2], tri[0]]];
edges.forEach((e) => {
const key = Math.min(e[0], e[1]) + "_" + Math.max(e[0], e[1]);
if (!edgeCount[key]) edgeCount[key] = { e: e, n: 0 };
edgeCount[key].n += 1;
});
});
const boundary: [number, number][] = [];
Object.keys(edgeCount).forEach((k) => { if (edgeCount[k].n === 1) boundary.push(edgeCount[k].e); });
bad.sort((a2, b2) => b2 - a2).forEach((t3) => { tris.splice(t3, 1); });
boundary.forEach((e) => { tris.push([e[0], e[1], i]); });
}
return tris.filter((tri) => tri[0] < pts.length && tri[1] < pts.length && tri[2] < pts.length);
}
let cw = 480, ch = 300, scaleX = 1, scaleY = 1;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
scaleX = cw / BASE_W; scaleY = ch / BASE_H;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
const jittered: [number, number][] = points.map((p) => [
p.bx + Math.sin(elapsed * JITTER_FREQ + p.phaseX) * VERTEX_JITTER_PX,
p.by + Math.cos(elapsed * JITTER_FREQ * 0.85 + p.phaseY) * VERTEX_JITTER_PX
]);
const tris = delaunayTriangulate(jittered);
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "#ffffff";
ctx!.fillRect(0, 0, cw, ch);
tris.forEach((tri, ti) => {
const a = jittered[tri[0]], b = jittered[tri[1]], c = jittered[tri[2]];
ctx!.beginPath();
ctx!.moveTo(a[0] * scaleX, a[1] * scaleY);
ctx!.lineTo(b[0] * scaleX, b[1] * scaleY);
ctx!.lineTo(c[0] * scaleX, c[1] * scaleY);
ctx!.closePath();
ctx!.fillStyle = meshPalette[ti % meshPalette.length];
ctx!.globalAlpha = 0.55;
ctx!.fill();
ctx!.globalAlpha = 1;
ctx!.strokeStyle = "rgba(23,20,26,.5)";
ctx!.lineWidth = 1;
ctx!.stroke();
});
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) { elapsed += dt; if (elapsed >= LOOP_MS) elapsed -= LOOP_MS; }
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>정점 흔들림 값으로 모핑하는 들로네 삼각 메쉬</p>
</div>
</main>
);
}04 · 정점 흔들림 값으로 모핑하는 들로네 삼각 메쉬 — 측정 누적면적 66.146% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var jittered = points.map(function (p) {
return [
p.bx + Math.sin(elapsed * JITTER_FREQ + p.phaseX) * VERTEX_JITTER_PX,
p.by + Math.cos(elapsed * JITTER_FREQ * 0.85 + p.phaseY) * VERTEX_JITTER_PX
];
});
var tris = delaunayTriangulate(jittered);
측정: 누적면적 66.1% · 강도 평균 75.1 · 23/23 프레임에서 움직였다.
05포인터가 하나의 시드가 되어 경계를 미는 셀
포인터 좌표(없으면 virtualPointerOrbit 가 만든 가상 궤도)를 기준으로 SEED_REPEL_RADIUS_PX(120) 안의 시드만 거리 비율의 제곱(push*push)만큼 바깥으로 밀어내고, 반경 밖 시드는 원래 자리(bx, by)로 그대로 둔다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">포인터가 하나의 시드가 되어 경계를 미는 셀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}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);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var SEED_REPEL_RADIUS_PX = 120;
var ORBIT_FREQ = Math.PI * 2 / LOOP_MS;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(409051);
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var offImg = octx.createImageData(CALC_W, CALC_H);
var offBuf = offImg.data;
function hexToRgb(hex) {
var v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
var NEI = -1, ND = 0, ND2 = 0;
function nearestTwo(px, py, arr) {
var bd = Infinity, bd2 = Infinity, bi = -1;
for (var i = 0; i < arr.length; i += 1) {
var dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
var repelPalette = ['#2f6df6', '#ff9ebb', '#4cd4a6', '#b9a0ff', '#6ec8ff', '#fff7e6'];
var paletteRgb = {};
repelPalette.forEach(function (h) { paletteRgb[h] = hexToRgb(h); });
var SEED_COUNT05 = 26;
var seeds = [];
for (var i5 = 0; i5 < SEED_COUNT05; i5 += 1) {
seeds.push({ bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0, c: repelPalette[i5 % repelPalette.length] });
}
var pointerActive = false;
var realPointer = { x: CALC_W / 2, y: CALC_H / 2 };
function virtualPointerOrbit(t) {
var cx = CALC_W / 2, cy = CALC_H / 2;
var orbR = Math.min(CALC_W, CALC_H) * 0.4;
return { x: cx + Math.cos(t * ORBIT_FREQ) * orbR, y: cy + Math.sin(t * ORBIT_FREQ * 1.3) * orbR * 0.7 };
}
function repelSeeds(pointerCalc) {
var radiusCalc = SEED_REPEL_RADIUS_PX * (CALC_W / cw);
seeds.forEach(function (s) {
var dx = s.bx - pointerCalc.x, dy = s.by - pointerCalc.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
if (dist < radiusCalc) {
var push = (radiusCalc - dist) / radiusCalc;
var k = push * push * 0.95;
s.x = s.bx + (dx / dist) * k * radiusCalc;
s.y = s.by + (dy / dist) * k * radiusCalc;
} else {
s.x = s.bx; s.y = s.by;
}
});
}
function renderField(t) {
var pointerCalc = pointerActive ? realPointer : (reduce ? { x: -999, y: -999 } : virtualPointerOrbit(t));
repelSeeds(pointerCalc);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
var rgb = paletteRgb[seeds[NEI].c];
var edge = ND2 - ND;
var dark = edge < 1.5 ? 0.5 + edge / 3 : 1;
offBuf[idx] = rgb[0] * dark; offBuf[idx + 1] = rgb[1] * dark; offBuf[idx + 2] = rgb[2] * dark; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? 0 : 0;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * cw;
var relY = (t0.clientY - r.top) / r.height * ch;
realPointer.x = relX * (CALC_W / cw);
realPointer.y = relY * (CALC_H / ch);
}
wrap.addEventListener('pointermove', function (e) {
pointerActive = true;
calcFromEvent(e);
draw(lastElapsed);
});
wrap.addEventListener('pointerleave', function () {
pointerActive = false;
draw(lastElapsed);
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./VoronoiPointerSeedRepel.module.scss";
export default function VoronoiPointerSeedRepel() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const SEED_REPEL_RADIUS_PX = 120;
const ORBIT_FREQ = (Math.PI * 2) / LOOP_MS;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(409051);
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
const offImg = octx.createImageData(CALC_W, CALC_H);
const offBuf = offImg.data;
function hexToRgb(hex: string): [number, number, number] {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
type Vec = { x: number; y: number };
let NEI = -1, ND = 0, ND2 = 0;
function nearestTwo<T extends Vec>(px: number, py: number, arr: T[]): void {
let bd = Infinity, bd2 = Infinity, bi = -1;
for (let i = 0; i < arr.length; i += 1) {
const dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
type Seed = { bx: number; by: number; x: number; y: number; c: string };
const repelPalette = ["#2f6df6", "#ff9ebb", "#4cd4a6", "#b9a0ff", "#6ec8ff", "#fff7e6"];
const paletteRgb: Record<string, [number, number, number]> = {};
repelPalette.forEach((h) => { paletteRgb[h] = hexToRgb(h); });
const SEED_COUNT05 = 26;
const seeds: Seed[] = [];
for (let i5 = 0; i5 < SEED_COUNT05; i5 += 1) {
seeds.push({ bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0, c: repelPalette[i5 % repelPalette.length] });
}
let pointerActive = false;
const realPointer = { x: CALC_W / 2, y: CALC_H / 2 };
function virtualPointerOrbit(t: number): Vec {
const cx = CALC_W / 2, cy = CALC_H / 2;
const orbR = Math.min(CALC_W, CALC_H) * 0.4;
return { x: cx + Math.cos(t * ORBIT_FREQ) * orbR, y: cy + Math.sin(t * ORBIT_FREQ * 1.3) * orbR * 0.7 };
}
function repelSeeds(pointerCalc: Vec) {
const radiusCalc = SEED_REPEL_RADIUS_PX * (CALC_W / cw);
seeds.forEach((s) => {
const dx = s.bx - pointerCalc.x, dy = s.by - pointerCalc.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
if (dist < radiusCalc) {
const push = (radiusCalc - dist) / radiusCalc;
const k = push * push * 0.95;
s.x = s.bx + (dx / dist) * k * radiusCalc;
s.y = s.by + (dy / dist) * k * radiusCalc;
} else {
s.x = s.bx; s.y = s.by;
}
});
}
function renderField(t: number) {
const pointerCalc = pointerActive ? realPointer : (reduce ? { x: -999, y: -999 } : virtualPointerOrbit(t));
repelSeeds(pointerCalc);
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
const rgb = paletteRgb[seeds[NEI].c];
const edge = ND2 - ND;
const dark = edge < 1.5 ? 0.5 + edge / 3 : 1;
offBuf[idx] = rgb[0] * dark; offBuf[idx + 1] = rgb[1] * dark; offBuf[idx + 2] = rgb[2] * dark; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx!.imageSmoothingEnabled = false;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? 0 : 0;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent) {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * cw;
const relY = ((e.clientY - r.top) / r.height) * ch;
realPointer.x = relX * (CALC_W / cw);
realPointer.y = relY * (CALC_H / ch);
}
function onPointerMove(e: PointerEvent) {
pointerActive = true;
calcFromEvent(e);
draw(lastElapsed);
}
function onPointerLeave() {
pointerActive = false;
draw(lastElapsed);
}
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>포인터가 하나의 시드가 되어 경계를 미는 셀</p>
</div>
</main>
);
}05 · 포인터가 하나의 시드가 되어 경계를 미는 셀 — 측정 누적면적 54.09% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function repelSeeds(pointerCalc) {
var radiusCalc = SEED_REPEL_RADIUS_PX * (CALC_W / cw);
seeds.forEach(function (s) {
var dx = s.bx - pointerCalc.x, dy = s.by - pointerCalc.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
if (dist < radiusCalc) {
var push = (radiusCalc - dist) / radiusCalc;
var k = push * push * 0.95;
s.x = s.bx + (dx / dist) * k * radiusCalc;
s.y = s.by + (dy / dist) * k * radiusCalc;
} else {
s.x = s.bx; s.y = s.by;
}
});
}
측정: 누적면적 54.1% · 강도 평균 103.4 · 23/23 프레임에서 움직였다.
06경계선만 남아 빛나는 네트워크 셀
셀 채우기는 버리고 두 시드 사이 거리 차가 EDGE_GLOW_WIDTH(3.4) 안에 들 때만 배경색(BG_RGB)에서 셀 색으로 glowT 비율만큼 섞어, 남는 건 경계선의 빛뿐인 네트워크 장면을 만든다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">경계선만 남아 빛나는 네트워크 셀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var EDGE_GLOW_WIDTH = 3.4;
var DRIFT_AMP = 8;
var DRIFT_FREQ = Math.PI * 2 / LOOP_MS;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(409061);
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var offImg = octx.createImageData(CALC_W, CALC_H);
var offBuf = offImg.data;
function hexToRgb(hex) {
var v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
var NEI = -1, ND = 0, ND2 = 0;
function nearestTwo(px, py, arr) {
var bd = Infinity, bd2 = Infinity, bi = -1;
for (var i = 0; i < arr.length; i += 1) {
var dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
var glowPalette = ['#6ec8ff', '#5b8cf8', '#4cd4a6', '#b9a0ff'];
var paletteRgb = {};
glowPalette.forEach(function (h) { paletteRgb[h] = hexToRgb(h); });
var SEED_COUNT06 = 28;
var seeds = [];
for (var i6 = 0; i6 < SEED_COUNT06; i6 += 1) {
seeds.push({
bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2,
c: glowPalette[i6 % glowPalette.length]
});
}
function driftSeeds(t) {
seeds.forEach(function (s) {
s.x = s.bx + Math.sin(t * DRIFT_FREQ + s.phaseX) * DRIFT_AMP;
s.y = s.by + Math.cos(t * DRIFT_FREQ + s.phaseY) * DRIFT_AMP;
});
}
var BG_RGB = [24, 22, 30];
function renderField(t) {
driftSeeds(t);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
var edge = ND2 - ND;
var r, g, b;
if (edge < EDGE_GLOW_WIDTH) {
var glowT = 1 - edge / EDGE_GLOW_WIDTH;
var rgb = paletteRgb[seeds[NEI].c];
r = BG_RGB[0] + (rgb[0] - BG_RGB[0]) * glowT;
g = BG_RGB[1] + (rgb[1] - BG_RGB[1]) * glowT;
b = BG_RGB[2] + (rgb[2] - BG_RGB[2]) * glowT;
} else {
r = BG_RGB[0]; g = BG_RGB[1]; b = BG_RGB[2];
}
offBuf[idx] = r; offBuf[idx + 1] = g; offBuf[idx + 2] = b; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./VoronoiEdgeNetworkGlow.module.scss";
export default function VoronoiEdgeNetworkGlow() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const EDGE_GLOW_WIDTH = 3.4;
const DRIFT_AMP = 8;
const DRIFT_FREQ = (Math.PI * 2) / LOOP_MS;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(409061);
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
const offImg = octx.createImageData(CALC_W, CALC_H);
const offBuf = offImg.data;
function hexToRgb(hex: string): [number, number, number] {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
type Vec = { x: number; y: number };
let NEI = -1, ND = 0, ND2 = 0;
function nearestTwo<T extends Vec>(px: number, py: number, arr: T[]): void {
let bd = Infinity, bd2 = Infinity, bi = -1;
for (let i = 0; i < arr.length; i += 1) {
const dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
type Seed = { bx: number; by: number; x: number; y: number; phaseX: number; phaseY: number; c: string };
const glowPalette = ["#6ec8ff", "#5b8cf8", "#4cd4a6", "#b9a0ff"];
const paletteRgb: Record<string, [number, number, number]> = {};
glowPalette.forEach((h) => { paletteRgb[h] = hexToRgb(h); });
const SEED_COUNT06 = 28;
const seeds: Seed[] = [];
for (let i6 = 0; i6 < SEED_COUNT06; i6 += 1) {
seeds.push({
bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2,
c: glowPalette[i6 % glowPalette.length]
});
}
function driftSeeds(t: number) {
seeds.forEach((s) => {
s.x = s.bx + Math.sin(t * DRIFT_FREQ + s.phaseX) * DRIFT_AMP;
s.y = s.by + Math.cos(t * DRIFT_FREQ + s.phaseY) * DRIFT_AMP;
});
}
const BG_RGB: [number, number, number] = [24, 22, 30];
function renderField(t: number) {
driftSeeds(t);
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
const edge = ND2 - ND;
let r: number, g: number, b: number;
if (edge < EDGE_GLOW_WIDTH) {
const glowT = 1 - edge / EDGE_GLOW_WIDTH;
const rgb = paletteRgb[seeds[NEI].c];
r = BG_RGB[0] + (rgb[0] - BG_RGB[0]) * glowT;
g = BG_RGB[1] + (rgb[1] - BG_RGB[1]) * glowT;
b = BG_RGB[2] + (rgb[2] - BG_RGB[2]) * glowT;
} else {
r = BG_RGB[0]; g = BG_RGB[1]; b = BG_RGB[2];
}
offBuf[idx] = r; offBuf[idx + 1] = g; offBuf[idx + 2] = b; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx!.imageSmoothingEnabled = false;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>경계선만 남아 빛나는 네트워크 셀</p>
</div>
</main>
);
}06 · 경계선만 남아 빛나는 네트워크 셀 — 측정 누적면적 63.17% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function renderField(t) {
driftSeeds(t);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
var edge = ND2 - ND;
var r, g, b;
if (edge < EDGE_GLOW_WIDTH) {
var glowT = 1 - edge / EDGE_GLOW_WIDTH;
var rgb = paletteRgb[seeds[NEI].c];
r = BG_RGB[0] + (rgb[0] - BG_RGB[0]) * glowT;
g = BG_RGB[1] + (rgb[1] - BG_RGB[1]) * glowT;
b = BG_RGB[2] + (rgb[2] - BG_RGB[2]) * glowT;
} else {
r = BG_RGB[0]; g = BG_RGB[1]; b = BG_RGB[2];
}
offBuf[idx] = r; offBuf[idx + 1] = g; offBuf[idx + 2] = b; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
측정: 누적면적 63.2% · 강도 평균 84.7 · 23/23 프레임에서 움직였다.
07반복 횟수 값이 늘수록 고르게 정돈되는 셀
computeCentroids 가 현재 시드 배치로 만든 셀마다 그 안의 모든 픽셀 좌표를 더해 평균(중심)을 구하고, 그 중심을 다음 시드 위치로 삼는 계산을 LLOYD_RELAXATION_STEPS(6)번 미리 돌려 저장해 둔 뒤 프레임마다 그 사이를 보간해 재생한다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">반복 횟수 값이 늘수록 고르게 정돈되는 셀</p> <span class="vd__step" id="vdStep">정리 진행 0/6</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var LLOYD_RELAXATION_STEPS = 6;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(409071);
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var offImg = octx.createImageData(CALC_W, CALC_H);
var offBuf = offImg.data;
function hexToRgb(hex) {
var v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
var NEI = -1, ND = 0, ND2 = 0;
function nearestTwo(px, py, arr) {
var bd = Infinity, bd2 = Infinity, bi = -1;
for (var i = 0; i < arr.length; i += 1) {
var dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
var relaxPalette = ['#2f6df6', '#ff9ebb', '#4cd4a6', '#b9a0ff', '#6ec8ff', '#fff7e6'];
var paletteRgb = {};
relaxPalette.forEach(function (h) { paletteRgb[h] = hexToRgb(h); });
var SEED_COUNT07 = 22;
var baseSeeds = [];
for (var i7 = 0; i7 < SEED_COUNT07; i7 += 1) {
baseSeeds.push({ x: rand() * CALC_W, y: rand() * CALC_H, c: relaxPalette[i7 % relaxPalette.length] });
}
function computeCentroids(positions) {
var sumX = new Array(positions.length).fill(0);
var sumY = new Array(positions.length).fill(0);
var cnt = new Array(positions.length).fill(0);
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, positions);
sumX[NEI] += x + 0.5; sumY[NEI] += y + 0.5; cnt[NEI] += 1;
}
}
return positions.map(function (p, i) {
if (!cnt[i]) return { x: p.x, y: p.y, c: p.c };
return { x: sumX[i] / cnt[i], y: sumY[i] / cnt[i], c: p.c };
});
}
var relaxationStates = [baseSeeds];
(function buildStates() {
var cur = baseSeeds;
for (var k = 0; k < LLOYD_RELAXATION_STEPS; k += 1) {
cur = computeCentroids(cur);
relaxationStates.push(cur);
}
})();
var stepLabels = ['정리 진행 0/6', '정리 진행 1/6', '정리 진행 2/6', '정리 진행 3/6', '정리 진행 4/6', '정리 진행 5/6', '정리 진행 6/6'];
var stepEl = document.getElementById('vdStep');
function renderField(t) {
var frac = (t % LOOP_MS) / LOOP_MS;
var segFloat = frac * LLOYD_RELAXATION_STEPS;
var seg = Math.min(LLOYD_RELAXATION_STEPS - 1, Math.floor(segFloat));
var local = segFloat - seg;
var a = relaxationStates[seg], b = relaxationStates[seg + 1];
var cur = a.map(function (p, i) {
return { x: p.x + (b[i].x - p.x) * local, y: p.y + (b[i].y - p.y) * local, c: p.c };
});
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, cur);
var rgb = paletteRgb[cur[NEI].c];
var edge = ND2 - ND;
var dark = edge < 1.5 ? 0.5 + edge / 3 : 1;
offBuf[idx] = rgb[0] * dark; offBuf[idx + 1] = rgb[1] * dark; offBuf[idx + 2] = rgb[2] * dark; offBuf[idx + 3] = 255;
idx += 4;
}
}
if (stepEl) stepEl.textContent = stepLabels[Math.min(LLOYD_RELAXATION_STEPS, seg + (local > 0.5 ? 1 : 0))];
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.97 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef, useState } from "react";
import styles from "./VoronoiLloydRelaxationConverge.module.scss";
export default function VoronoiLloydRelaxationConverge() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [stepLabel, setStepLabel] = useState("정리 진행 0/6");
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const LLOYD_RELAXATION_STEPS = 6;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(409071);
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
const offImg = octx.createImageData(CALC_W, CALC_H);
const offBuf = offImg.data;
function hexToRgb(hex: string): [number, number, number] {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
type Vec = { x: number; y: number };
let NEI = -1, ND = 0, ND2 = 0;
function nearestTwo<T extends Vec>(px: number, py: number, arr: T[]): void {
let bd = Infinity, bd2 = Infinity, bi = -1;
for (let i = 0; i < arr.length; i += 1) {
const dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
type Seed = { x: number; y: number; c: string };
const relaxPalette = ["#2f6df6", "#ff9ebb", "#4cd4a6", "#b9a0ff", "#6ec8ff", "#fff7e6"];
const paletteRgb: Record<string, [number, number, number]> = {};
relaxPalette.forEach((h) => { paletteRgb[h] = hexToRgb(h); });
const SEED_COUNT07 = 22;
const baseSeeds: Seed[] = [];
for (let i7 = 0; i7 < SEED_COUNT07; i7 += 1) {
baseSeeds.push({ x: rand() * CALC_W, y: rand() * CALC_H, c: relaxPalette[i7 % relaxPalette.length] });
}
function computeCentroids(positions: Seed[]): Seed[] {
const sumX = new Array(positions.length).fill(0);
const sumY = new Array(positions.length).fill(0);
const cnt = new Array(positions.length).fill(0);
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, positions);
sumX[NEI] += x + 0.5; sumY[NEI] += y + 0.5; cnt[NEI] += 1;
}
}
return positions.map((p, i) => {
if (!cnt[i]) return { x: p.x, y: p.y, c: p.c };
return { x: sumX[i] / cnt[i], y: sumY[i] / cnt[i], c: p.c };
});
}
const relaxationStates: Seed[][] = [baseSeeds];
(function buildStates() {
let cur = baseSeeds;
for (let k = 0; k < LLOYD_RELAXATION_STEPS; k += 1) {
cur = computeCentroids(cur);
relaxationStates.push(cur);
}
})();
const stepLabels = ["정리 진행 0/6", "정리 진행 1/6", "정리 진행 2/6", "정리 진행 3/6", "정리 진행 4/6", "정리 진행 5/6", "정리 진행 6/6"];
function renderField(t: number) {
const frac = (t % LOOP_MS) / LOOP_MS;
const segFloat = frac * LLOYD_RELAXATION_STEPS;
const seg = Math.min(LLOYD_RELAXATION_STEPS - 1, Math.floor(segFloat));
const local = segFloat - seg;
const a = relaxationStates[seg], b = relaxationStates[seg + 1];
const cur: Seed[] = a.map((p, i) => ({
x: p.x + (b[i].x - p.x) * local, y: p.y + (b[i].y - p.y) * local, c: p.c
}));
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, cur);
const rgb = paletteRgb[cur[NEI].c];
const edge = ND2 - ND;
const dark = edge < 1.5 ? 0.5 + edge / 3 : 1;
offBuf[idx] = rgb[0] * dark; offBuf[idx + 1] = rgb[1] * dark; offBuf[idx + 2] = rgb[2] * dark; offBuf[idx + 3] = 255;
idx += 4;
}
}
setStepLabel(stepLabels[Math.min(LLOYD_RELAXATION_STEPS, seg + (local > 0.5 ? 1 : 0))]);
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx!.imageSmoothingEnabled = false;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.97 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>반복 횟수 값이 늘수록 고르게 정돈되는 셀</p>
<p className={styles.vd__step}>{stepLabel}</p>
</div>
</main>
);
}07 · 반복 횟수 값이 늘수록 고르게 정돈되는 셀 — 측정 누적면적 36.754% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function computeCentroids(positions) {
var sumX = new Array(positions.length).fill(0);
var sumY = new Array(positions.length).fill(0);
var cnt = new Array(positions.length).fill(0);
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, positions);
sumX[NEI] += x + 0.5; sumY[NEI] += y + 0.5; cnt[NEI] += 1;
}
}
return positions.map(function (p, i) {
if (!cnt[i]) return { x: p.x, y: p.y, c: p.c };
return { x: sumX[i] / cnt[i], y: sumY[i] / cnt[i], c: p.c };
});
}
측정: 누적면적 36.8% · 강도 평균 57.1 · 23/23 프레임에서 움직였다.
08셀 크기 편차 값으로 만드는 위장 무늬
valueNoise2D 가 좌표 그리드를 이중선형보간해 만든 노이즈 값 두 개로 픽셀 좌표 자체를 NOISE_WARP_PX(13)만큼 뒤틀어(warpCoord) 놓고 나서 nearestTwo 를 부르므로, 시드는 그대로인데 왜곡된 좌표가 찾는 셀 경계만 구불구불해진다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">셀 크기 편차 값으로 만드는 위장 무늬</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var NOISE_WARP_PX = 13;
var WARP_FREQ = Math.PI * 2 / LOOP_MS;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(409081);
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var offImg = octx.createImageData(CALC_W, CALC_H);
var offBuf = offImg.data;
function hexToRgb(hex) {
var v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
var NEI = -1, ND = 0, ND2 = 0;
function nearestTwo(px, py, arr) {
var bd = Infinity, bd2 = Infinity, bi = -1;
for (var i = 0; i < arr.length; i += 1) {
var dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
var camoPalette = ['#4cd4a6', '#6ec8ff', '#b9a0ff', '#17141a'];
var paletteRgb = {};
camoPalette.forEach(function (h) { paletteRgb[h] = hexToRgb(h); });
var MACRO_COUNT = 4;
var macroCenters = [];
for (var m0 = 0; m0 < MACRO_COUNT; m0 += 1) {
macroCenters.push({ x: rand() * CALC_W, y: rand() * CALC_H, spread: 10 + rand() * 26 });
}
var SEED_COUNT08 = 46;
var seeds = [];
for (var i8 = 0; i8 < SEED_COUNT08; i8 += 1) {
var mc = macroCenters[i8 % MACRO_COUNT];
seeds.push({
x: Math.min(CALC_W, Math.max(0, mc.x + (rand() - 0.5) * mc.spread * 2)),
y: Math.min(CALC_H, Math.max(0, mc.y + (rand() - 0.5) * mc.spread * 2)),
c: camoPalette[i8 % camoPalette.length]
});
}
var NOISE_GRID_W = 9, NOISE_GRID_H = 6;
var noiseGridX = [], noiseGridY = [];
for (var n0 = 0; n0 < NOISE_GRID_W * NOISE_GRID_H; n0 += 1) {
noiseGridX.push(rand()); noiseGridY.push(rand());
}
function valueNoise2D(gx, gy, arr) {
var x0 = Math.floor(gx), y0 = Math.floor(gy);
var x1 = Math.min(NOISE_GRID_W - 1, x0 + 1), y1 = Math.min(NOISE_GRID_H - 1, y0 + 1);
var tx = gx - x0, ty = gy - y0;
var v00 = arr[y0 * NOISE_GRID_W + x0], v10 = arr[y0 * NOISE_GRID_W + x1];
var v01 = arr[y1 * NOISE_GRID_W + x0], v11 = arr[y1 * NOISE_GRID_W + x1];
var top = v00 + (v10 - v00) * tx, bot = v01 + (v11 - v01) * tx;
return top + (bot - top) * ty;
}
function warpCoord(x, y, breathe) {
var gx = x / CALC_W * (NOISE_GRID_W - 1), gy = y / CALC_H * (NOISE_GRID_H - 1);
var n1 = valueNoise2D(gx, gy, noiseGridX), n2 = valueNoise2D(gx, gy, noiseGridY);
return { x: x + (n1 - 0.5) * 2 * NOISE_WARP_PX * breathe, y: y + (n2 - 0.5) * 2 * NOISE_WARP_PX * breathe };
}
function renderField(t) {
var breathe = 0.55 + 0.45 * Math.sin(t * WARP_FREQ);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var w = warpCoord(x + 0.5, y + 0.5, breathe);
nearestTwo(w.x, w.y, seeds);
var rgb = paletteRgb[seeds[NEI].c];
var edge = ND2 - ND;
var dark = edge < 1.6 ? 0.55 + edge / 3.6 : 1;
offBuf[idx] = rgb[0] * dark; offBuf[idx + 1] = rgb[1] * dark; offBuf[idx + 2] = rgb[2] * dark; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./VoronoiOrganicCamouflagePattern.module.scss";
export default function VoronoiOrganicCamouflagePattern() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const NOISE_WARP_PX = 13;
const WARP_FREQ = (Math.PI * 2) / LOOP_MS;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(409081);
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
const offImg = octx.createImageData(CALC_W, CALC_H);
const offBuf = offImg.data;
function hexToRgb(hex: string): [number, number, number] {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
type Vec = { x: number; y: number };
let NEI = -1, ND = 0, ND2 = 0;
function nearestTwo<T extends Vec>(px: number, py: number, arr: T[]): void {
let bd = Infinity, bd2 = Infinity, bi = -1;
for (let i = 0; i < arr.length; i += 1) {
const dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
type Seed = { x: number; y: number; c: string };
const camoPalette = ["#4cd4a6", "#6ec8ff", "#b9a0ff", "#17141a"];
const paletteRgb: Record<string, [number, number, number]> = {};
camoPalette.forEach((h) => { paletteRgb[h] = hexToRgb(h); });
const MACRO_COUNT = 4;
const macroCenters: { x: number; y: number; spread: number }[] = [];
for (let m0 = 0; m0 < MACRO_COUNT; m0 += 1) {
macroCenters.push({ x: rand() * CALC_W, y: rand() * CALC_H, spread: 10 + rand() * 26 });
}
const SEED_COUNT08 = 46;
const seeds: Seed[] = [];
for (let i8 = 0; i8 < SEED_COUNT08; i8 += 1) {
const mc = macroCenters[i8 % MACRO_COUNT];
seeds.push({
x: Math.min(CALC_W, Math.max(0, mc.x + (rand() - 0.5) * mc.spread * 2)),
y: Math.min(CALC_H, Math.max(0, mc.y + (rand() - 0.5) * mc.spread * 2)),
c: camoPalette[i8 % camoPalette.length]
});
}
const NOISE_GRID_W = 9, NOISE_GRID_H = 6;
const noiseGridX: number[] = [], noiseGridY: number[] = [];
for (let n0 = 0; n0 < NOISE_GRID_W * NOISE_GRID_H; n0 += 1) {
noiseGridX.push(rand()); noiseGridY.push(rand());
}
function valueNoise2D(gx: number, gy: number, arr: number[]): number {
const x0 = Math.floor(gx), y0 = Math.floor(gy);
const x1 = Math.min(NOISE_GRID_W - 1, x0 + 1), y1 = Math.min(NOISE_GRID_H - 1, y0 + 1);
const tx = gx - x0, ty = gy - y0;
const v00 = arr[y0 * NOISE_GRID_W + x0], v10 = arr[y0 * NOISE_GRID_W + x1];
const v01 = arr[y1 * NOISE_GRID_W + x0], v11 = arr[y1 * NOISE_GRID_W + x1];
const top = v00 + (v10 - v00) * tx, bot = v01 + (v11 - v01) * tx;
return top + (bot - top) * ty;
}
function warpCoord(x: number, y: number, breathe: number): Vec {
const gx = (x / CALC_W) * (NOISE_GRID_W - 1), gy = (y / CALC_H) * (NOISE_GRID_H - 1);
const n1 = valueNoise2D(gx, gy, noiseGridX), n2 = valueNoise2D(gx, gy, noiseGridY);
return { x: x + (n1 - 0.5) * 2 * NOISE_WARP_PX * breathe, y: y + (n2 - 0.5) * 2 * NOISE_WARP_PX * breathe };
}
function renderField(t: number) {
const breathe = 0.55 + 0.45 * Math.sin(t * WARP_FREQ);
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
const w = warpCoord(x + 0.5, y + 0.5, breathe);
nearestTwo(w.x, w.y, seeds);
const rgb = paletteRgb[seeds[NEI].c];
const edge = ND2 - ND;
const dark = edge < 1.6 ? 0.55 + edge / 3.6 : 1;
offBuf[idx] = rgb[0] * dark; offBuf[idx + 1] = rgb[1] * dark; offBuf[idx + 2] = rgb[2] * dark; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx!.imageSmoothingEnabled = false;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>셀 크기 편차 값으로 만드는 위장 무늬</p>
</div>
</main>
);
}08 · 셀 크기 편차 값으로 만드는 위장 무늬 — 측정 누적면적 27.205% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function valueNoise2D(gx, gy, arr) {
var x0 = Math.floor(gx), y0 = Math.floor(gy);
var x1 = Math.min(NOISE_GRID_W - 1, x0 + 1), y1 = Math.min(NOISE_GRID_H - 1, y0 + 1);
var tx = gx - x0, ty = gy - y0;
var v00 = arr[y0 * NOISE_GRID_W + x0], v10 = arr[y0 * NOISE_GRID_W + x1];
var v01 = arr[y1 * NOISE_GRID_W + x0], v11 = arr[y1 * NOISE_GRID_W + x1];
var top = v00 + (v10 - v00) * tx, bot = v01 + (v11 - v01) * tx;
return top + (bot - top) * ty;
}
function warpCoord(x, y, breathe) {
var gx = x / CALC_W * (NOISE_GRID_W - 1), gy = y / CALC_H * (NOISE_GRID_H - 1);
var n1 = valueNoise2D(gx, gy, noiseGridX), n2 = valueNoise2D(gx, gy, noiseGridY);
return { x: x + (n1 - 0.5) * 2 * NOISE_WARP_PX * breathe, y: y + (n2 - 0.5) * 2 * NOISE_WARP_PX * breathe };
}
측정: 누적면적 27.2% · 강도 평균 37.7 · 23/23 프레임에서 움직였다.
09여러 값이 한 화면에서 함께 반응하는 히어로 장면
01의 표류·05의 포인터 반발·07의 로이드 완화를 한 renderField 안에서 순서대로 더한다 — relaxNudge(t) 가 만든 블렌드 값으로 표류 좌표와 정리된 좌표를 섞고, 그 위에 applyPointerRepel 로 다시 한 번 미는 3단 계산이 겹친다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">여러 값이 한 화면에서 함께 반응하는 히어로 장면</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}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);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__step {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption, .vd__step {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var HERO_SEED_COUNT = 32;
var DRIFT_AMP = 10;
var DRIFT_FREQ = Math.PI * 2 / LOOP_MS;
var RELAX_BLEND_FREQ = Math.PI * 2 / (LOOP_MS * 1.5);
var HERO_REPEL_RADIUS_PX = 100;
var ORBIT_FREQ = Math.PI * 2 / LOOP_MS;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(409091);
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var offImg = octx.createImageData(CALC_W, CALC_H);
var offBuf = offImg.data;
function hexToRgb(hex) {
var v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
var NEI = -1, ND = 0, ND2 = 0;
function nearestTwo(px, py, arr) {
var bd = Infinity, bd2 = Infinity, bi = -1;
for (var i = 0; i < arr.length; i += 1) {
var dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
var heroPalette = ['#2f6df6', '#ff9ebb', '#4cd4a6', '#b9a0ff', '#6ec8ff', '#fff7e6'];
var paletteRgb = {};
heroPalette.forEach(function (h) { paletteRgb[h] = hexToRgb(h); });
var seeds = [];
for (var i9 = 0; i9 < HERO_SEED_COUNT; i9 += 1) {
seeds.push({
bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2,
c: heroPalette[i9 % heroPalette.length]
});
}
function computeCentroids(positions) {
var sumX = new Array(positions.length).fill(0);
var sumY = new Array(positions.length).fill(0);
var cnt = new Array(positions.length).fill(0);
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, positions);
sumX[NEI] += x + 0.5; sumY[NEI] += y + 0.5; cnt[NEI] += 1;
}
}
return positions.map(function (p, i) {
if (!cnt[i]) return { x: p.x, y: p.y };
return { x: sumX[i] / cnt[i], y: sumY[i] / cnt[i] };
});
}
var baseForRelax = seeds.map(function (s) { return { x: s.bx, y: s.by }; });
var relaxedLayout = computeCentroids(computeCentroids(baseForRelax));
function relaxNudge(t) {
return 0.5 + 0.5 * Math.sin(t * RELAX_BLEND_FREQ);
}
var pointerActive = false;
var realPointer = { x: CALC_W / 2, y: CALC_H / 2 };
function virtualPointerOrbit(t) {
var cx = CALC_W / 2, cy = CALC_H / 2;
var orbR = Math.min(CALC_W, CALC_H) * 0.44;
return { x: cx + Math.cos(t * ORBIT_FREQ) * orbR, y: cy + Math.sin(t * ORBIT_FREQ * 1.4) * orbR * 0.75 };
}
function applyPointerRepel(pointerCalc) {
var radiusCalc = HERO_REPEL_RADIUS_PX * (CALC_W / cw);
seeds.forEach(function (s) {
var dx = s.x - pointerCalc.x, dy = s.y - pointerCalc.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
if (dist < radiusCalc) {
var push = (radiusCalc - dist) / radiusCalc;
var k = push * push * 0.85;
s.x += (dx / dist) * k * radiusCalc;
s.y += (dy / dist) * k * radiusCalc;
}
});
}
function renderField(t) {
var blend = relaxNudge(t);
seeds.forEach(function (s, i) {
var dx = s.bx + Math.sin(t * DRIFT_FREQ + s.phaseX) * DRIFT_AMP;
var dy = s.by + Math.cos(t * DRIFT_FREQ * 0.8 + s.phaseY) * DRIFT_AMP;
s.x = dx + (relaxedLayout[i].x - s.bx) * blend * 0.6;
s.y = dy + (relaxedLayout[i].y - s.by) * blend * 0.6;
});
var pointerCalc = pointerActive ? realPointer : (reduce ? { x: -999, y: -999 } : virtualPointerOrbit(t));
applyPointerRepel(pointerCalc);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
var rgb = paletteRgb[seeds[NEI].c];
var edge = ND2 - ND;
var dark = edge < 1.6 ? 0.5 + edge / 3.4 : 1;
offBuf[idx] = rgb[0] * dark; offBuf[idx + 1] = rgb[1] * dark; offBuf[idx + 2] = rgb[2] * dark; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * cw;
var relY = (t0.clientY - r.top) / r.height * ch;
realPointer.x = relX * (CALC_W / cw);
realPointer.y = relY * (CALC_H / ch);
}
wrap.addEventListener('pointermove', function (e) {
pointerActive = true;
calcFromEvent(e);
draw(lastElapsed);
});
wrap.addEventListener('pointerleave', function () {
pointerActive = false;
draw(lastElapsed);
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./VoronoiHeroScene.module.scss";
export default function VoronoiHeroScene() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const HERO_SEED_COUNT = 32;
const DRIFT_AMP = 10;
const DRIFT_FREQ = (Math.PI * 2) / LOOP_MS;
const RELAX_BLEND_FREQ = (Math.PI * 2) / (LOOP_MS * 1.5);
const HERO_REPEL_RADIUS_PX = 100;
const ORBIT_FREQ = (Math.PI * 2) / LOOP_MS;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(409091);
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
const offImg = octx.createImageData(CALC_W, CALC_H);
const offBuf = offImg.data;
function hexToRgb(hex: string): [number, number, number] {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
type Vec = { x: number; y: number };
let NEI = -1, ND = 0, ND2 = 0;
function nearestTwo<T extends Vec>(px: number, py: number, arr: T[]): void {
let bd = Infinity, bd2 = Infinity, bi = -1;
for (let i = 0; i < arr.length; i += 1) {
const dx = px - arr[i].x, dy = py - arr[i].y, d = dx * dx + dy * dy;
if (d < bd) { bd2 = bd; bd = d; bi = i; } else if (d < bd2) { bd2 = d; }
}
NEI = bi; ND = Math.sqrt(bd); ND2 = Math.sqrt(bd2);
}
type Seed = { bx: number; by: number; x: number; y: number; phaseX: number; phaseY: number; c: string };
const heroPalette = ["#2f6df6", "#ff9ebb", "#4cd4a6", "#b9a0ff", "#6ec8ff", "#fff7e6"];
const paletteRgb: Record<string, [number, number, number]> = {};
heroPalette.forEach((h) => { paletteRgb[h] = hexToRgb(h); });
const seeds: Seed[] = [];
for (let i9 = 0; i9 < HERO_SEED_COUNT; i9 += 1) {
seeds.push({
bx: rand() * CALC_W, by: rand() * CALC_H, x: 0, y: 0,
phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2,
c: heroPalette[i9 % heroPalette.length]
});
}
function computeCentroids(positions: Vec[]): Vec[] {
const sumX = new Array(positions.length).fill(0);
const sumY = new Array(positions.length).fill(0);
const cnt = new Array(positions.length).fill(0);
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, positions);
sumX[NEI] += x + 0.5; sumY[NEI] += y + 0.5; cnt[NEI] += 1;
}
}
return positions.map((p, i) => {
if (!cnt[i]) return { x: p.x, y: p.y };
return { x: sumX[i] / cnt[i], y: sumY[i] / cnt[i] };
});
}
const baseForRelax: Vec[] = seeds.map((s) => ({ x: s.bx, y: s.by }));
const relaxedLayout = computeCentroids(computeCentroids(baseForRelax));
function relaxNudge(t: number): number {
return 0.5 + 0.5 * Math.sin(t * RELAX_BLEND_FREQ);
}
let pointerActive = false;
const realPointer = { x: CALC_W / 2, y: CALC_H / 2 };
function virtualPointerOrbit(t: number): Vec {
const cx = CALC_W / 2, cy = CALC_H / 2;
const orbR = Math.min(CALC_W, CALC_H) * 0.44;
return { x: cx + Math.cos(t * ORBIT_FREQ) * orbR, y: cy + Math.sin(t * ORBIT_FREQ * 1.4) * orbR * 0.75 };
}
function applyPointerRepel(pointerCalc: Vec) {
const radiusCalc = HERO_REPEL_RADIUS_PX * (CALC_W / cw);
seeds.forEach((s) => {
const dx = s.x - pointerCalc.x, dy = s.y - pointerCalc.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
if (dist < radiusCalc) {
const push = (radiusCalc - dist) / radiusCalc;
const k = push * push * 0.85;
s.x += (dx / dist) * k * radiusCalc;
s.y += (dy / dist) * k * radiusCalc;
}
});
}
function renderField(t: number) {
const blend = relaxNudge(t);
seeds.forEach((s, i) => {
const dx = s.bx + Math.sin(t * DRIFT_FREQ + s.phaseX) * DRIFT_AMP;
const dy = s.by + Math.cos(t * DRIFT_FREQ * 0.8 + s.phaseY) * DRIFT_AMP;
s.x = dx + (relaxedLayout[i].x - s.bx) * blend * 0.6;
s.y = dy + (relaxedLayout[i].y - s.by) * blend * 0.6;
});
const pointerCalc = pointerActive ? realPointer : (reduce ? { x: -999, y: -999 } : virtualPointerOrbit(t));
applyPointerRepel(pointerCalc);
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
nearestTwo(x + 0.5, y + 0.5, seeds);
const rgb = paletteRgb[seeds[NEI].c];
const edge = ND2 - ND;
const dark = edge < 1.6 ? 0.5 + edge / 3.4 : 1;
offBuf[idx] = rgb[0] * dark; offBuf[idx + 1] = rgb[1] * dark; offBuf[idx + 2] = rgb[2] * dark; offBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderField(elapsed);
octx.putImageData(offImg, 0, 0);
ctx!.imageSmoothingEnabled = false;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent) {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * cw;
const relY = ((e.clientY - r.top) / r.height) * ch;
realPointer.x = relX * (CALC_W / cw);
realPointer.y = relY * (CALC_H / ch);
}
function onPointerMove(e: PointerEvent) {
pointerActive = true;
calcFromEvent(e);
draw(lastElapsed);
}
function onPointerLeave() {
pointerActive = false;
draw(lastElapsed);
}
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>여러 값이 한 화면에서 함께 반응하는 히어로 장면</p>
</div>
</main>
);
}09 · 여러 값이 한 화면에서 함께 반응하는 히어로 장면 — 측정 누적면적 72.469% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function applyPointerRepel(pointerCalc) {
var radiusCalc = HERO_REPEL_RADIUS_PX * (CALC_W / cw);
seeds.forEach(function (s) {
var dx = s.x - pointerCalc.x, dy = s.y - pointerCalc.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
if (dist < radiusCalc) {
var push = (radiusCalc - dist) / radiusCalc;
var k = push * push * 0.85;
s.x += (dx / dist) * k * radiusCalc;
s.y += (dy / dist) * k * radiusCalc;
}
});
}
측정: 누적면적 72.5% · 강도 평균 89.4 · 23/23 프레임에서 움직였다.
아홉 장면이 픽셀 단위로 얼마나 움직였는가 — 측정값 모음
캡처 사이 23구간마다 인접한 두 장의 픽셀 차이를 비교한 값이다. 누적면적은 그 23번 비교마다 바뀐 화면 비율을 전부 더한 값이라 100%를 넘을 수 있다.
| 항목 | 누적면적 | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 모자이크 셀 | 60.1% | 84.0 | 23/23 |
| 02 균열 텍스처 | 73.8% | 47.9 | 23/23 |
| 03 스테인드글라스 | 72.6% | 42.4 | 20/23 |
| 04 들로네 메쉬 | 66.1% | 75.1 | 23/23 |
| 05 포인터 반발 | 54.1% | 103.4 | 23/23 |
| 06 엣지 글로우 | 63.2% | 84.7 | 23/23 |
| 07 로이드 완화 | 36.8% | 57.1 | 23/23 |
| 08 위장 무늬 | 27.2% | 37.7 | 23/23 |
| 09 히어로 장면 | 72.5% | 89.4 | 23/23 |
05 포인터 반발은 강도 평균이 103.4로 아홉 중 가장 높지만 누적면적(54.1%)은 중간이다 — 시드가 반경 안에서 한 번에 크게 튕겨 나가면서 그 경계 픽셀의 색 차이가 크게 벌어지기 때문이다. 반대로 08 위장 무늬는 누적면적(27.2%)과 강도 평균(37.7) 모두 가장 낮다 — 노이즈로 좌표만 살짝 뒤트는 정도라 픽셀 변화가 가장 잔잔하다.
숫자 하나로 셀 배치가 달라지는 변형 세 가지
| 변형 | 바꾼 값 | 느낌 |
|---|---|---|
| 시드 개수 값 조정 | 01 VORONOI_SEED_COUNT 24 → 60 | 24개일 때는 큰 조각 몇 개로 성기게 보이고, 60개로 올리면 같은 화면이 작은 타일 수십 개로 빽빽하게 쪼개진 모자이크처럼 바뀐다 |
| 포인터 반발 반경 값 조정 | 05 SEED_REPEL_RADIUS_PX 120 → 240 | 120px 에서는 커서 바로 옆 셀만 살짝 물러나고, 240px 로 늘리면 캔버스 절반 가까이의 셀이 통째로 밀려나는 느낌을 준다 |
| 로이드 완화 반복 횟수 값 조정 | 07 LLOYD_RELAXATION_STEPS 6 → 20 | 6번만 돌리면 정리 도중 상태라 셀 크기가 여전히 들쭉날쭉하지만, 20번까지 밀어붙이면 벌집처럼 고른 크기로 수렴한 모습을 볼 수 있다 |
이 아홉 장면을 실제로 쓰는 화면
모자이크(01)·균열(02)·스테인드글라스(03) 는 그 자체로 안정된 배경이라 랜딩 페이지 히어로나 브랜드 인트로 로딩 화면, 프리미엄 카드 패널의 뒷배경으로 바로 쓸 수 있다. 데이터 시각화 팀이라면 04 들로네 메쉬를 대시보드 배경 장식으로, 기술 소개 섹션이라면 06 엣지 글로우를 네트워크 다이어그램 느낌으로 붙인다. 05 포인터 반발과 09 히어로 장면은 커서가 지나갈 때 반응이 필요한 인터랙티브 랜딩에, 07 로이드 완화는 로딩이 끝나고 화면이 정돈되는 전환 연출에, 08 위장 무늬는 아웃도어·패션 제품 배경처럼 유기적인 질감이 필요한 자리에 맞는다.
보로노이 다이어그램 원리는 이 데모에서 어떻게 보이나 — 293·395·400편과 다른 점
293번 CSS 배경 패턴 9종 은 시드 하나로 반복 타일을 다시 그리는 배경 패턴이고, 395번 캔버스 로딩 애니메이션 효과 9가지 는 rAF 로 물리량을 흘려보내는 로더, 400번 생명게임 시뮬레이터 9가지 는 셀마다 이웃 수를 세는 규칙 기반 오토마타다. 이번 9종은 그 세 갈래와 달리 매 픽셀이 "누가 나와 가장 가까운가"라는 최근접 탐색 하나로 소속을 정한다 — 규칙표도, 이웃 세기도 없이 거리 비교만으로 경계가 생기는 게 보로노이 다이어그램 원리다.
오프스크린 갱신을 건너뛰면 깨지는 함정
nearestTwo 는 시드 배열을 처음부터 끝까지 훑는 탐색이라, 픽셀 하나마다 시드 수만큼 거리 계산을 반복한다 — 160×100 오프스크린에서 시드 24개면 프레임마다 384,000번, 변형 1처럼 60개로 늘리면 960,000번까지 뛴다. 화면 해상도를 그대로 계산 해상도로 써버리면(오프스크린 없이 480×300 을 바로 돈다면) 픽셀 수가 9배로 뛰어 저사양 기기에서 프레임이 뚝뚝 끊긴다 — 오프스크린을 160×100 으로 고정해 두고 drawImage 로만 확대하는 이유가 여기 있다. 05·09 의 radiusCalc = SEED_REPEL_RADIUS_PX * (CALC_W / cw) 줄도 놓치기 쉬운 지점이다. 포인터 좌표는 화면 표시 픽셀(cw)로 들어오는데 시드는 160px 짜리 계산 좌표계에 살고 있어서, 이 비율 변환을 빼먹으면 화면을 늘렸을 때 반발 반경이 실제 커서 위치와 어긋난다. 9개 index.html·style.scss 원본에서 이 변환 줄을 직접 보고 대조하고 싶다면 y8nwxyua 으로 zip 을 열면 된다.
동작 줄임 설정에서 아홉 장면이 멈추는 접근성 처리
아홉 파일 모두 스크립트가 시작할 때 matchMedia('(prefers-reduced-motion: reduce)').matches 를 한 번 읽어 playing 을 끄고 elapsed 값을 항목별로 고정한다 — 대부분 LOOP_MS 의 절반 지점에서 멈추지만 02 는 균열 펄스가 가장 옅은 25% 지점, 07 은 완화가 97%까지 끝난 지점을 골라 뒀다. requestAnimationFrame 루프 자체는 멈추지 않고 매 프레임 같은 elapsed 로 renderField 를 다시 불러 같은 픽셀을 다시 그리므로, 계산이 아예 꺼지는 게 아니라 입력값이 고정돼 결과만 정적으로 보이는 쪽이다. 05·09 는 포인터 좌표까지 { x: -999, y: -999 } 로 캔버스 밖에 둬 가상 궤도 자체를 켜지 않고, root.classList.remove('is-demo') 로 CSS 쪽 재생 표시도 같이 끈다. prefers-reduced-motion 을 이렇게 스크립트 시작 시점에 직접 감지해 입력을 고정하는 쪽이, 계산 자체를 통째로 멈추는 것보다 다루기 쉽다.
보로노이 다이어그램 그리는 법을 적용하기 전에 자주 나오는 질문
보로노이 다이어그램을 SVG 로 직접 그릴 수도 있나요?
된다. 이 데모는 매 프레임 다시 계산해야 하는 애니메이션이라 픽셀(Canvas)로 그렸지만, 시드 좌표가 고정된 정적인 그림이라면 좌표만 구해 <path> 폴리곤으로 그리는 쪽이 더 가볍다. 다만 이 zip 은 라이브러리 없이 바닐라 JS 로 픽셀 최근접 탐색만으로 만든 버전이다.
시드 개수를 실제 프로젝트에서 얼마나 늘려도 되나요?
오프스크린이 160×100(16,000픽셀)으로 고정돼 있어서 시드를 60개까지 늘려도(변형 1) 체감 프레임 저하는 크지 않았다. 다만 nearestTwo 가 시드 수에 비례해 느려지는 만큼, 수백 개 단위로 올릴 계획이면 화면을 격자로 나눠 가까운 시드만 비교하는 방식으로 바꾸는 걸 권한다.
데모에서 보이는 셀 색 배치는 실제 방문자 데이터인가요?
아니다. seedRand 라는 결정적 PRNG(항목마다 다른 정수로 초기화)가 만든 좌표라 새로고침해도 똑같은 배치가 나온다. 실제 데이터를 연결하려면 시드 좌표를 API 응답 같은 실제 값으로 바꿔 넣기만 하면 같은 계산 구조를 그대로 재사용할 수 있다.









