ボロノイ図 書き方 9選 — Canvas生成
ボロノイ図は線を引く絵ではない — すべての点が最も近いシード座標値を見つけて塗られた結果だ。その座標値がひとつ動くだけで、1番はセルの色を塗り直し、5番は境界線を押しのけ、9番は場面全体を再計算する。この記事は「ボロノイ図 書き方」を探して来た方に向けて、9枚のキャンバスをソースコードごと見せます。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 シード個数値の分だけ色が満ちるモザイクセル
- 02 境界線太さ値が乾いた大地のように割れるセル
- 03 セルごとに光透過値が異なるステンドグラスパネル
- 04 頂点の揺れ値でモーフィングするドロネー三角メッシュ
- 05 ポインターが一つのシードとなり境界を押すセル
- 06 境界線だけが残り光るネットワークセル
- 07 反復回数値が増えるほど均等に整うセル
- 08 セルサイズのばらつき値で作る迷彩柄
- 09 複数の値が一つの画面で共に反応するヒーローシーン
以下の9枚は、どれも一つの最近傍計算が9通りの見た目をまとっているだけです。1番から3番まではシードを動かさず、同じ境界の隙間に起きることだけを変えています(平らなモザイク、インクで滲むひび、掃引するガラスパネル)。4番はセルの形そのものを、その双対にあたる三角メッシュへ置き換えます。5番はポインターの下で動くシードを一つ足し、6番は塗りを剥がして境界だけを残し、7番は毎フレームの再計算をあらかじめ済ませた緩和シーケンスに置き換え、8番は測る前にサンプル空間そのものをノイズで歪め、9番はそれら三つの計算を一つのヒーロー場面に重ねます。
ランディングヒーローやローディング画面で、実際にどう使うか
1番のモザイクと6番のネットワーク型セルは、どちらもランディングページの見出し裏で静かに座っている背景装飾として使えます。平坦なグラデーションを置く代わりにこの二つを敷くと、見出しの後ろに視線を邪魔しない程度の動きが生まれます。2番の乾いた大地の質感と7番の緩和シーケンスは、ブランドイントロやローディング完了の切り替え演出に向いています——コンテンツが表示される瞬間に、模様がちょうど整っていくように見えるからです。3番のステンドグラスパネルと8番の迷彩ワープは、プレミアムなカード背景やアウトドア・ファッション系の商品パネルとして機能します。質感そのものが主役になり、文字を乗せる器で終わらない点が強みです。
4番の三角メッシュはデータ可視化の背景に合います。塗りつぶされたセルが「領域」を連想させるのに対し、メッシュの線は「構造」を連想させるからです。5番と9番はポインターと直接やり取りする唯一の二つで、カーソルの動きに応じて配置そのものが変わるヒーロー演出は、repelSeeds()やapplyPointerRepel()の計算コストを払ってでも作る価値があります。訪れた人自身のマウス操作が、そのまま画面の一部になるからです。
9枚のボロノイセルを塗っているのは、結局どんな計算なのか
以下の9枚はすべて、表示サイズそのものではなく160×100の非表示キャンバスの上で計算しています。nearestTwo()が、あるピクセルに対してすべてのシードとの距離の二乗を総当たりで調べ、最も近いシード(ND)と次点(ND2)だけを残します。その小さなバッファを画面サイズへ拡大する際はimageSmoothingEnabledをfalseにしているため、ぼかされず輪郭がくっきりしたまま引き伸ばされます。ここには時計の値もクリック数も現実のデータセットも入っていません——各項目は409011から409091まで、項目ごとに固定した整数で自前の乱数生成器を種付けしているため、同じモザイクも同じひび割れも同じ三角メッシュも、リロードのたびに同一の形で描かれます。これら9枚が書き込むピクセルバッファについては、MDNのCanvas APIの概要が、renderField()が描画に使っている2Dコンテキストをまとめています。
01シード個数値の分だけ色が満ちるモザイクセル
各シード座標値が最も近い領域を多角形セルとして分け、各セルに固有の色を満たして背景モザイクを作ります。この塗りはnearestTwo()が担っています——各ピクセルとすべてのシードとの距離を二乗のまま比較して、最も近いシード(ND)と次点(ND2)を求め、その差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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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境界線太さ値が乾いた大地のように割れるセル
セル境界線の太さ値と明るさ値を組み合わせ、乾いた土が割れたような質感を作ります。同じnearestTwo()の差を使いますが、edgeがCRACK_WIDTH_VALUEを下回ると、正弦波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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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セルごとに光透過値が異なるステンドグラスパネル
各セルの色値と枠太さ値をガラスパネルのように配置し、光が通るステンドグラスの雰囲気を作ります。このシードは動かないため、nearestTwo()は一度だけ実行されてcellIdxとedgeValという二つの配列に結果をキャッシュします。以降の毎フレームは、その結果を使ってピクセルをスイープ角へ射影しガウス状の光の帯を足すだけなので、毎フレームの計算コストは光の帯一つ分に抑えられています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
(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);
測定パスは累積面積72.6%・強度平均42.4で、動いたフレームは20/23でした——9枚の中で最も少ないのは、セルの配置自体は変わらず光の帯だけが横切るためです。
04頂点の揺れ値でモーフィングするドロネー三角メッシュ
同じシード座標値から作った三角メッシュの頂点の揺れ値が時間とともに変わり、面がゆっくりと波打ちます。項目4は最近傍探索の代わりに、毎フレームDelaunay三角分割をゼロから組み直します——circumcircle()が3点を通る外接円を解き、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 };
}
測定パスは累積面積66.1%・強度平均75.1、動いたフレームは23/23でした——9枚の中で唯一、ピクセルバッファではなく三角形のパスをctx.fill()で55%の不透明度で塗り、そのうえからctx.stroke()で辺を描いています。
05ポインターが一つのシードとなり境界を押すセル
ポインター位置値を一時的なシードとして加え、カーソルが近づくほど周囲のセル境界値がその分押しのけられます。repelSeeds()は最近傍探索より先に走ります。実ポインター(操作していないときは仮想的に周回する点)との距離を各シードごとに測り、SEED_REPEL_RADIUS_PXの内側にあるシードだけをpush²の減衰で押し出してから、図形を描き始めます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
});
}
測定パスは9枚の中で最も軽く、累積面積54.1%・強度平均103.4——ここが9枚のうちいちばん高い強度で、動いたフレームは23/23でした。
06境界線だけが残り光るネットワークセル
セルの塗り値を消して境界線の明るさ値だけを残すことで、図が技術系ネットワーク図のように光ります。項目1と同じedge = ND2 - NDという差を使いますが、セルを塗りつぶす代わりに背景色からセルの色へ直接ブレンドするため、実際に見えるのは境界の帯だけになります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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回あらかじめ繰り返して並びを作っておき、renderField()は再生時間の進みに合わせてその並びの間を補間します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 };
});
}
測定パスは9枚で最も穏やかで、累積面積36.8%・強度平均57.1、動いたフレームは23/23でした——ほぼ緩和し終えた並びは、保存された状態の間で動きが小さくなるためです。
08セルサイズのばらつき値で作る迷彩柄
シード密度値を領域ごとに変え、セルサイズのばらつき値を大きくすることで、結果が動物の迷彩柄のように不規則になります。最近傍探索が走る前に、warpCoord()が9×6のランダム格子をバイリニア補間するvalueNoise2D()でサンプル座標そのものを歪めるため、本来同じセルに入るはずの2ピクセルが別のセルへ振り分けられることがあります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 };
}
測定パスは9枚で最も静かで、累積面積27.2%・強度平均37.7、動いたフレームは23/23でした——ノイズのワープはセル境界を小さく揺らすだけで、領域全体の大きさは変えないためです。
09複数の値が一つの画面で共に反応するヒーローシーン
シード座標値・ポインター反発値・緩和反復値を一つの場面に重ね、背景全体が生きて動くボロノイヒーローを作ります。renderField()はここまでの計算を一つのフレームに重ねます。relaxNudge()が各シードを生の位置と二重に緩和した位置の間でブレンドし、そのあとapplyPointerRepel()がカーソル付近に残っているシードだけを押しのけてから、最後に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-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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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——5番に次いで9枚中2番目に高い強度で、動いたフレームは23/23でした。緩和と反発を同じピクセルループへ重ねた分だけ負荷も上がっています。
境界線を動かす3つの値は、実際に何を担っているか
VORONOI_SEED_COUNT(1番、既定24)は、ほかの何よりも先に多角形がいくつ存在するかを決めます——これは動きではなく図の構造そのものを変える、唯一の値です。SEED_REPEL_RADIUS_PX(5番、既定120px、CALC_W / cw比で160×100の計算空間へ換算)は、ポインターの影響がカーソルからどれだけ離れた場所まで届くかを決めます。LLOYD_RELAXATION_STEPS(7番、既定6)は、再生が始まる前に重心平均化を何回済ませておくかを決めます——これが、1番の生モザイクより7番の緩和済みセルのほうが大きさが揃って見える理由です。
モーション低減設定・非力な端末・タッチ入力でどこが壊れるか
9枚すべてが最初のフレームより先にprefers-reduced-motionを確認しますが、そこで止まる静止状態は項目ごとにそろっていません——ここが本当の落とし穴です。多くの項目はループの半分の位置で止まりますが、2番はパルスの4分の1の位置で止まるため多くの人が動画の途中で見るより淡いひび割れになり、7番は緩和シーケンスの97%まで進んだ位置で止まるためほぼ最も整ったセル配置になり、5番はポインター自体を画面外の座標へ追い出すため、モーション低減を求めた人は反発をまったく見ず、ただ散らばったシードだけを見ることになります。ある項目のモーション低減分岐を別の項目へそのままコピーすると、間違った静止フレームを再現してしまいます。
nearestTwo()の走査だけでも、160×100の計算キャンバスにある16,000ピクセルすべてに対して最大32個のシードを毎フレーム比較します——9枚のうち最も重いのは、毎フレームゼロから作り直す4番のドロネー分割と、緩和と反発を重ねる9番で、非力な端末ではこの負荷がまず滑らかなループではなくフレーム落ちとして現れます。タッチ入力には専用の分岐がありません——pointermoveとpointerleaveはマウスと同じように指にも発火しますが、画面から指が離れる瞬間はpointerleaveと同じ扱いになるため、5番と9番はタッチ端末ではほとんど反発ではなく、待機中の仮想周回ポインターだけを見せることになります。実際のクリック位置も、実際の決済金額も、実際の利用者記録もここには一切登場しません——そのギャップが特定のレイアウトで重要になる場合、zipの中のy8nwxyuaが現実の画面に必要なものです。
9枚のボロノイキャンバスは、フレームごとに実際どれだけ動いたか
| 項目 | 累積面積 | 強度 | 動いたフレーム |
|---|---|---|---|
| 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 |
押す力を二乗で効かせる5番が、9枚の中でいちばん高い103.4という強度を記録しています——累積面積は塗りつぶし系より小さくても、少数のシードを強く押す動きは、画面全体をゆるく塗り直す動きに勝ります。
シード値をひとつだけ動かす3つのバリエーション
| バリエーション | 値 | 感触 |
|---|---|---|
| シード個数値を調整する | 01 VORONOI_SEED_COUNT 24 → 60 | 値を短くすると画面は少数の大きく疎なセルに分かれて単純に見え、値を伸ばすと多数の小さく密なセルに分かれて複雑なモザイクのように見えるはずです。 |
| ポインター反発半径値を調整する | 05 SEED_REPEL_RADIUS_PX 120 → 240 | 値を短くするとカーソルのすぐ周りのセルだけが狭く押しのけられ、値を伸ばすと画面の広い範囲のセルまで一緒に押しのけられるはずです。 |
| ロイド緩和の反復回数値を調整する | 07 LLOYD_RELAXATION_STEPS 6 → 20 | 値を短くするとセルの大きさはばらついたまま粗く見え、値を伸ばすとセルの大きさが次第に均等に整いなめらかに見えるはずです。 |
アクセシビリティ: prefers-reduced-motionが9つの静止状態から実際に消すもの
prefers-reduced-motion: reduceが設定されると、playingがfalseになり、9枚すべてが最初の静止フレームを描いたあとrequestAnimationFrameの呼び出しをやめます——ルート要素からis-demoクラスも外れ、付随スタイルシートにあるホバー用カーソルスタイルも消えます。モーションを減らしてほしいと伝えた人は、ドリフトもひび割れの脈動も光のスイープも、9番の緩和ブレンドも一切見ることなく、その項目のコードが割り当てた固定のelapsed値の上にそのまま着地します。だからこそ静止フレームは項目ごとに違って見え、9枚すべてが同じ一時停止の見た目にそろうわけではありません。
ボロノイ図 アルゴリズムは、最近傍探索としてここにどう現れているか
パレットと揺れを取り除けば、これは結局こういう仕組みです——平面上のどの点についても、あらかじめ決めたシードの集合のうち最も近い一つを見つけ、その点をどのシードが勝ったかで塗ります。nearestTwo()は、三角分割を使わないこのセットのすべての項目が呼び出す唯一の関数で、外部の数学ライブラリを使わない総当たりの実装です——シードについての入れ子ループを、ピクセルについての入れ子ループの中に置き、それまでに見た最小と二番目に小さい距離の二乗だけを残します。9枚のそれぞれは、しきい値・半径・反復回数・ループが走る前に加えるワープのいずれか一つの値だけを、その同じ中心ループから曲げて作った小さなボロノイ パターンの実例です。一言でまとめるなら、色を塗る前に距離を測る、それだけです。
ほかにも似た仕組みの実例を探すなら
CSS 背景パターンジェネレーター 自作9種は、この9枚が属するシード付きキャンバス背景という、より広い系列全体をカバーしています——平坦な生成パターンで足りるなら、セル全体を計算するこの仕組みまでは要らないケースの比較に向いています。ローディングアニメーションjs 9パターン — 物理演算で作るは、最近傍のルールの代わりに実際のバネと重力の計算を使っていて、ここの5番が使う押す力の二乗による反発と見比べる価値があります。ポインターや値がキャンバスをゆがめる、二つの異なるやり方が見えてきます。ライフゲーム9種 — キャンバスで世代を計算は構造がいちばん近い兄弟編です——どちらの系列も、小さなルールから毎フレーム全ピクセルのグリッドを計算し直します。ただしライフゲームは各セルを近傍の状態から決めるのに対し、上の9枚はどれもピクセルをシードまでの純粋な距離だけから決めます。これら9枚が実際に書き込んでいるピクセルバッファの方式については、ここではcreateImageDataとputImageDataの組が使われていて、MDNのgetImageDataのリファレンスは、その組の読み取り側にあたる、書き込んだキャンバスを読み戻すための姉妹メソッドを説明しています。
ボロノイ図を自分のプロジェクトで動かす前によくある質問に答えます
この9枚は本当にボロノイ図を計算しているのですか、それとも見た目をそれらしく似せているだけですか
計算しています——nearestTwo()は、160×100の計算キャンバスにあるすべてのピクセルについて、すべてのシードとの距離の二乗を本当に比較し、どのシードが最も近いかだけでピクセルの色を決めています。グラデーションもborder-radiusの小細工も、あらかじめ焼き込んだ画像も、セルの形を偽装するためには使っていません。
9枚のうちどれかを、そのままsvg版として使えますか
そのままでは使えません。ここではどの項目もImageDataバッファへ直接ピクセルを描いていて、ベクターのpathデータを持っていないからです。シードの配置や最近傍のルール、VORONOI_SEED_COUNTやSEED_REPEL_RADIUS_PXといった値そのものはSVG版へも応用できますが、ピクセルを塗る部分のコードは多角形の座標計算として書き直す必要があります。
なぜ4番だけ、ほかの8枚のようなセルではなく三角形に見えるのですか
4番は、ほかの8枚と同じシード座標から作ったドロネー三角分割——ボロノイ図の双対グラフ——を描いているからです。ほかの項目が「このピクセルに最も近いシードはどれか」を問うのに対し、4番は「外接円の内側にほかのシードを含まない三角形はどの3点で作れるか」を問うという、同じ入力点に対してまったく別の計算をしています。









