css dither 9パターン — 動く画面を点の並びで描くディザ処理
css dither は色を二つしか残さないのに、なぜグラデーションがかえってなめらかに見えるのか — 01番の閾値行列と02番の誤差拡散が答えだ。
九つのディザを画面でそのまま動かしてみる
01 dt-bayer-ordered-sweep
02 dt-error-diffusion-plasma
03 dt-lit-sphere-moon-phase
04 dt-gradient-noise-dissolve
05 dt-dithered-ripple-pond
06 dt-rotating-cube-flat-shade
07 dt-palette-sunset-quantize
08 dt-text-dither-dissolve
09 dt-pointer-halo-hero
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ベイヤー閾値行列で動くグラデーションを二色で打つ整列ディザ
- 02 誤差を隣の画素へ流して正弦プラズマを描く誤差拡散
- 03 光が一周して月の満ち欠けを点で作る球体シェーディング
- 04 画面が点に溶けて次の場面へ移るインターリーブノイズ遷移
- 05 波動方程式で広がる波紋を四階調で打つディザの池
- 06 回る立体の面の明るさを点の密度に変える3Dディザキューブ
- 07 四色で空のグラデーションを量子化するパレットディザの夕焼け
- 08 文字が点に散って再び集まるタイポグラフィのディザ
- 09 ポインターの周りだけ密になる光輪ディザのヒーロー
css dither 9パターン — 閾値・誤差・法線を計算して点が絵になる
どの場面も、160×100マスの小さな格子に点を一つずつ打ち、その格子を3倍に引き伸ばして舞台に載せています。引き伸ばすときは imageSmoothingEnabled を切り、CSS にも image-rendering: pixelated を指定してあるので、点はにじまず四角のまま残ります。舞台の幅は min(92vw, calc(160vh - 12.8vw)) で、レンダーの撮影ウィンドウでは横が約419pxで写りました。
ディザがやっているのは、明るさを数段階に減らしながら、減らしたせいで出る誤差が一か所に固まって帯にならないよう散らすことです。散らす道は二つあります。一つはマスごとに決めた閾値と明るさを比べる道(01・04・05・06・07・08・09)、もう一つは丸めて余った誤差を隣のマスへ渡す道(02・03)です。九つの場面は、まず二つの道をむき出しで見せ(01・02)、球に載せ(03)、そのあと切り替え・波紋・立体・パレット・文字・ポインター反応へ進む順に並べました。
どこから開くかは使い道で選べば足ります。ローディングの背景なら01、月のような球一枚なら03、ページ遷移なら04、クリックに反応する水のヒーローなら05、カーソルの周りだけ明るくなるファーストビューなら09から開いてください。同じキャンバスのピクセル計算を色のグラデーションで解くとレインボーグラデーション 9パターン — キャンバスで作るプラズマ背景になり、この記事は同じ明るさの場を二色と四色に削る側です。
css dither effect を技法別のコードで見る
下のコードは、場面ごとの index.html の <script> から続きの行をそのまま取り出したもので、一つの断片は25行を超えません。この手の動きも、突き詰めればこの中の二つの道、つまり閾値を比べる計算と誤差を渡す計算の上に立っています。
01ベイヤー閾値行列で動くグラデーションを二色で打つ整列ディザ
点ごとに斜めの正弦縞の明るさへ行列の閾値を足し、1以上なら点灯、未満なら消灯にします。行列は2×2から始めて倍々に育て、点(x、y)は行列の (y % n)·n + (x % n) 番目の値を使います。用途はレトロゲーム風のローディング背景です。測定は累積面積74.8%・平均強度182.8・動いたフレーム23/23でした。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="ベイヤー閾値行列で動くグラデーションを二色で打つ整列ディザ"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">整列ディザ — 閾値行列で二色だけを打つ</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">ベイヤー閾値行列で動くグラデーションを二色で打つ整列ディザ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x) { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c) {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a, b, k) {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf() {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx, b) {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const BAYER_SIZE = 4;
const LEVELS = 2;
const SWEEP_SPEED = 0.011;
const THRESHOLD_BIAS = 0;
const WAVES = 2.2;
const DARK_01 = [43, 36, 64];
const LIGHT_01 = [255, 214, 232];
const TXT_ORDERED = '整列ディザ — 閾値行列で二色だけを打つ';
const TXT_PLAIN = '閾値一つで切ると帯だけが残る';
const TXT_SCRUB = 'ポインターが縞を引っ張っていく';
function makeScene(inp, reduce) {
const buf = makeBuf();
const matrix = makeBayer(BAYER_SIZE);
const dark = pack(DARK_01);
const light = pack(LIGHT_01);
const q = LEVELS - 1;
let t = 0;
let plain = false;
let shift = 0;
const scene = {
step() {
t += 1;
const target = inp.idle < IDLE_TICKS ? (inp.x / W - 0.5) * 1.2 : 0;
shift = lerp(shift, target, 0.08);
},
draw(ctx) {
const phase = t * SWEEP_SPEED + shift;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
// 대각선 방향 좌표 u 를 사인에 넣어 줄무늬 그라디언트를 만든다
const u = (x / GW) * 0.62 + (y / GH) * 0.38;
const v = 0.5 + 0.5 * Math.sin(PI2 * (u * WAVES - phase));
const thr = plain ? 0.5 : matrix[(y % BAYER_SIZE) * BAYER_SIZE + (x % BAYER_SIZE)];
const o = clamp(Math.floor(v * q + thr + THRESHOLD_BIAS), 0, q) / q;
buf.px[y * GW + x] = o >= 1 ? light : (o <= 0 ? dark : mixPack(DARK_01, LIGHT_01, o));
}
}
flush(ctx, buf);
},
label() {
if (plain) {
return TXT_PLAIN;
}
return inp.idle < IDLE_TICKS ? TXT_SCRUB : TXT_ORDERED;
},
tap() {
plain = !plain;
}
};
if (reduce) {
for (let i = 0; i < 40; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./DtBayerOrderedSweep.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
interface Buf {
off: HTMLCanvasElement;
octx: CanvasRenderingContext2D;
img: ImageData;
px: Uint32Array;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x: number): number { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n: number): Float32Array {
let size = 2;
let m: number[] = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next: number[] = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c: RGB): number {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a: RGB, b: RGB, k: number): number {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf(): Buf {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx: CanvasRenderingContext2D, b: Buf): void {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const BAYER_SIZE = 4;
const LEVELS = 2;
const SWEEP_SPEED = 0.011;
const THRESHOLD_BIAS = 0;
const WAVES = 2.2;
const DARK_01: RGB = [43, 36, 64];
const LIGHT_01: RGB = [255, 214, 232];
const TXT_ORDERED = '정렬 디더 — 행렬 임계값으로 두 색만 찍는다';
const TXT_PLAIN = '임계값 하나로 자르면 띠만 남는다';
const TXT_SCRUB = '포인터가 줄무늬를 끌고 간다';
function makeScene(inp: Input, reduce: boolean): Scene {
const buf = makeBuf();
const matrix = makeBayer(BAYER_SIZE);
const dark = pack(DARK_01);
const light = pack(LIGHT_01);
const q = LEVELS - 1;
let t = 0;
let plain = false;
let shift = 0;
const scene: Scene = {
step(): void {
t += 1;
const target = inp.idle < IDLE_TICKS ? (inp.x / W - 0.5) * 1.2 : 0;
shift = lerp(shift, target, 0.08);
},
draw(ctx: CanvasRenderingContext2D): void {
const phase = t * SWEEP_SPEED + shift;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
// 대각선 방향 좌표 u 를 사인에 넣어 줄무늬 그라디언트를 만든다
const u = (x / GW) * 0.62 + (y / GH) * 0.38;
const v = 0.5 + 0.5 * Math.sin(PI2 * (u * WAVES - phase));
const thr = plain ? 0.5 : matrix[(y % BAYER_SIZE) * BAYER_SIZE + (x % BAYER_SIZE)];
const o = clamp(Math.floor(v * q + thr + THRESHOLD_BIAS), 0, q) / q;
buf.px[y * GW + x] = o >= 1 ? light : (o <= 0 ? dark : mixPack(DARK_01, LIGHT_01, o));
}
}
flush(ctx, buf);
},
label(): string {
if (plain) {
return TXT_PLAIN;
}
return inp.idle < IDLE_TICKS ? TXT_SCRUB : TXT_ORDERED;
},
tap(): void {
plain = !plain;
}
};
if (reduce) {
for (let i = 0; i < 40; i += 1) {
scene.step();
}
}
return scene;
}
export default function DtBayerOrderedSweep() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="베이어 임계 행렬로 움직이는 그라디언트를 두 색으로 찍는 정렬 디더" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">정렬 디더 — 행렬 임계값으로 두 색만 찍는다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>베이어 임계 행렬로 움직이는 그라디언트를 두 색으로 찍는 정렬 디더</p>
</div>
</main>
);
}01 · ベイヤー閾値行列で動くグラデーションを二色で打つ整列ディザ — 計測 累積面積 74.844% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// ベイヤー閾値行列 — n は2の冪乗。値は0より大きく1未満に収める
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
02誤差を隣の画素へ流して正弦プラズマを描く誤差拡散
正弦を四つ重ねた明るさの場で、点ごとに0.5より大きければ点灯とし、丸めて余った誤差を横に7/16、下に5/16、下の後ろ側に3/16、下の前側に1/16と隣へ配ります。奇数行は右から左へ進む SERPENTINE 方式で前側の向きが行ごとに反転し、二色だけで面を埋めるヒーロー背景によく合います。累積面積74.8%・平均強度165.7で、23/23フレームが動きました。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="誤差を隣の画素へ流して正弦プラズマを描く誤差拡散"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">誤差拡散 — 残った誤差を隣の画素へ流す</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">誤差を隣の画素へ流して正弦プラズマを描く誤差拡散</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x) { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c) {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a, b, k) {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf() {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx, b) {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const DIFFUSE = 1.0;
const FIELD_FREQ = 0.085;
const PHASE_SPEED = 0.03;
const SERPENTINE = 1;
const DARK_02 = [31, 46, 61];
const LIGHT_02 = [191, 233, 255];
const TXT_DIFFUSE = '誤差拡散 — 残った誤差を隣の画素へ流す';
const TXT_CUT = '誤差を捨てると単に白黒で切られる';
const TXT_ZOOM = 'ポインターの横位置が模様の細かさを変える';
function makeScene(inp, reduce) {
const buf = makeBuf();
const err = new Float32Array(GW * GH);
const dark = pack(DARK_02);
const light = pack(LIGHT_02);
let t = 0;
let cut = false;
let zoom = 1;
// 사인 네 개를 더한 밝기 장 — 0 이상 1 이하
function field(x, y, ph, f) {
const a = Math.sin(x * f + ph);
const b = Math.sin(y * f * 1.3 - ph * 0.8);
const c = Math.sin((x + y) * f * 0.7 + ph * 1.3);
const d = Math.sin(Math.hypot(x - GW * 0.5 + 30 * Math.sin(ph * 0.5), y - GH * 0.5) * f * 1.4 - ph * 1.2);
return clamp((0.5 + (a + b + c + d) * 0.125 - 0.5) * 1.5 + 0.5, 0, 1);
}
const scene = {
step() {
t += 1;
const target = inp.idle < IDLE_TICKS ? 0.6 + (inp.x / W) * 1.2 : 1;
zoom = lerp(zoom, target, 0.08);
},
draw(ctx) {
const ph = t * PHASE_SPEED;
const f = FIELD_FREQ * zoom;
const carry = cut ? 0 : DIFFUSE;
err.fill(0);
for (let y = 0; y < GH; y += 1) {
const dir = (SERPENTINE === 1 && (y & 1) === 1) ? -1 : 1;
for (let k = 0; k < GW; k += 1) {
const x = dir > 0 ? k : GW - 1 - k;
const i = y * GW + x;
const val = field(x, y, ph, f) + err[i];
const on = val > 0.5 ? 1 : 0;
buf.px[i] = on === 1 ? light : dark;
// Floyd-Steinberg — 옆 7/16, 아래 5/16, 아래 양옆 3/16 과 1/16
const e = (val - on) * carry;
const hasNext = x + dir >= 0 && x + dir < GW;
const hasPrev = x - dir >= 0 && x - dir < GW;
if (hasNext) {
err[i + dir] += e * 7 / 16;
}
if (y + 1 < GH) {
if (hasPrev) {
err[i + GW - dir] += e * 3 / 16;
}
err[i + GW] += e * 5 / 16;
if (hasNext) {
err[i + GW + dir] += e * 1 / 16;
}
}
}
}
flush(ctx, buf);
},
label() {
if (cut) {
return TXT_CUT;
}
return inp.idle < IDLE_TICKS ? TXT_ZOOM : TXT_DIFFUSE;
},
tap() {
cut = !cut;
}
};
if (reduce) {
for (let i = 0; i < 30; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./DtErrorDiffusionPlasma.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
interface Buf {
off: HTMLCanvasElement;
octx: CanvasRenderingContext2D;
img: ImageData;
px: Uint32Array;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x: number): number { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n: number): Float32Array {
let size = 2;
let m: number[] = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next: number[] = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c: RGB): number {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a: RGB, b: RGB, k: number): number {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf(): Buf {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx: CanvasRenderingContext2D, b: Buf): void {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const DIFFUSE = 1.0;
const FIELD_FREQ = 0.085;
const PHASE_SPEED = 0.03;
const SERPENTINE = 1;
const DARK_02: RGB = [31, 46, 61];
const LIGHT_02: RGB = [191, 233, 255];
const TXT_DIFFUSE = '오차 확산 — 남은 오차를 이웃 픽셀로 흘려보낸다';
const TXT_CUT = '오차를 버리면 그냥 흑백으로 잘린다';
const TXT_ZOOM = '포인터 가로 위치가 무늬의 촘촘함을 바꾼다';
function makeScene(inp: Input, reduce: boolean): Scene {
const buf = makeBuf();
const err = new Float32Array(GW * GH);
const dark = pack(DARK_02);
const light = pack(LIGHT_02);
let t = 0;
let cut = false;
let zoom = 1;
// 사인 네 개를 더한 밝기 장 — 0 이상 1 이하
function field(x: number, y: number, ph: number, f: number): number {
const a = Math.sin(x * f + ph);
const b = Math.sin(y * f * 1.3 - ph * 0.8);
const c = Math.sin((x + y) * f * 0.7 + ph * 1.3);
const d = Math.sin(Math.hypot(x - GW * 0.5 + 30 * Math.sin(ph * 0.5), y - GH * 0.5) * f * 1.4 - ph * 1.2);
return clamp((0.5 + (a + b + c + d) * 0.125 - 0.5) * 1.5 + 0.5, 0, 1);
}
const scene: Scene = {
step(): void {
t += 1;
const target = inp.idle < IDLE_TICKS ? 0.6 + (inp.x / W) * 1.2 : 1;
zoom = lerp(zoom, target, 0.08);
},
draw(ctx: CanvasRenderingContext2D): void {
const ph = t * PHASE_SPEED;
const f = FIELD_FREQ * zoom;
const carry = cut ? 0 : DIFFUSE;
err.fill(0);
for (let y = 0; y < GH; y += 1) {
const dir = (SERPENTINE === 1 && (y & 1) === 1) ? -1 : 1;
for (let k = 0; k < GW; k += 1) {
const x = dir > 0 ? k : GW - 1 - k;
const i = y * GW + x;
const val = field(x, y, ph, f) + err[i];
const on = val > 0.5 ? 1 : 0;
buf.px[i] = on === 1 ? light : dark;
// Floyd-Steinberg — 옆 7/16, 아래 5/16, 아래 양옆 3/16 과 1/16
const e = (val - on) * carry;
const hasNext = x + dir >= 0 && x + dir < GW;
const hasPrev = x - dir >= 0 && x - dir < GW;
if (hasNext) {
err[i + dir] += e * 7 / 16;
}
if (y + 1 < GH) {
if (hasPrev) {
err[i + GW - dir] += e * 3 / 16;
}
err[i + GW] += e * 5 / 16;
if (hasNext) {
err[i + GW + dir] += e * 1 / 16;
}
}
}
}
flush(ctx, buf);
},
label(): string {
if (cut) {
return TXT_CUT;
}
return inp.idle < IDLE_TICKS ? TXT_ZOOM : TXT_DIFFUSE;
},
tap(): void {
cut = !cut;
}
};
if (reduce) {
for (let i = 0; i < 30; i += 1) {
scene.step();
}
}
return scene;
}
export default function DtErrorDiffusionPlasma() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="오차를 이웃 픽셀로 흘려보내 사인 플라즈마를 그리는 오차 확산" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">오차 확산 — 남은 오차를 이웃 픽셀로 흘려보낸다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>오차를 이웃 픽셀로 흘려보내 사인 플라즈마를 그리는 오차 확산</p>
</div>
</main>
);
}02 · 誤差を隣の画素へ流して正弦プラズマを描く誤差拡散 — 計測 累積面積 74.85% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const dir = (SERPENTINE === 1 && (y & 1) === 1) ? -1 : 1;
for (let k = 0; k < GW; k += 1) {
const x = dir > 0 ? k : GW - 1 - k;
const i = y * GW + x;
const val = field(x, y, ph, f) + err[i];
const on = val > 0.5 ? 1 : 0;
buf.px[i] = on === 1 ? light : dark;
// Floyd-Steinberg — 横 7/16、下 5/16、下の両脇 3/16 と 1/16
const e = (val - on) * carry;
const hasNext = x + dir >= 0 && x + dir < GW;
const hasPrev = x - dir >= 0 && x - dir < GW;
if (hasNext) {
err[i + dir] += e * 7 / 16;
}
if (y + 1 < GH) {
if (hasPrev) {
err[i + GW - dir] += e * 3 / 16;
}
err[i + GW] += e * 5 / 16;
if (hasNext) {
err[i + GW + dir] += e * 1 / 16;
}
}
}
03光が一周して月の満ち欠けを点で作る球体シェーディング
球の表面の法線と光の向きの内積がそのまま明るさになり、その値を Atkinson 方式で打ちます。この方式は六つの隣へ誤差の1/8ずつしか渡さず4分の1を捨てるため、暗い側と明るい側がはっきりした色に固まり、光の位相が点の密度に変わります。累積面積30.4%・平均強度188.2で、動いたフレームは22/23でした。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="光が一周して月の満ち欠けを点で作る球体シェーディング"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">満月 — 点がいちばん密</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">光が一周して月の満ち欠けを点で作る球体シェーディング</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x) { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c) {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a, b, k) {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf() {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx, b) {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LIGHT_SPEED = 0.05;
const SPHERE_R = 40;
const AMBIENT = 0.04;
const ATKINSON_KEEP = 0.125;
const HALO_W = 9;
const HALO_GAIN = 0.34;
const DARK_03 = [36, 34, 56];
const LIGHT_03 = [255, 244, 214];
const TXT_FULL = '満月 — 点がいちばん密';
const TXT_HALF = '半月 — 半分だけ点灯する';
const TXT_CRESCENT = '三日月 — 細い点の帯';
const TXT_NEW = '新月 — 点がほとんど消える';
function makeScene(inp, reduce) {
const buf = makeBuf();
const err = new Float32Array(GW * GH);
const dark = pack(DARK_03);
const light = pack(LIGHT_03);
const rnd = lcg(453303);
const stars = [];
for (let i = 0; i < 26; i += 1) {
stars.push(Math.floor(rnd() * GW), Math.floor(rnd() * GH));
}
const sx = new Set();
for (let i = 0; i < stars.length; i += 2) {
sx.add(stars[i + 1] * GW + stars[i]);
}
const cx = GW / 2;
const cy = GH / 2;
let phi = 0;
let dir = 1;
function addErr(i, x, y, e) {
if (x >= 0 && x < GW && y < GH) {
err[i] += e;
}
}
const scene = {
step() {
if (inp.idle < IDLE_TICKS) {
const target = (inp.x / W) * PI2;
const diff = Math.atan2(Math.sin(target - phi), Math.cos(target - phi));
phi += diff * 0.15;
}
else {
phi += LIGHT_SPEED * dir;
}
},
draw(ctx) {
// 광원 방향 — phi 가 0 이면 보는 쪽 정면, π 면 구 뒤쪽
let lx = Math.sin(phi);
let ly = 0.22;
let lz = Math.cos(phi);
const ln = Math.hypot(lx, ly, lz);
lx /= ln;
ly /= ln;
lz /= ln;
const lit = (1 + Math.cos(phi)) / 2;
err.fill(0);
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const dx = x + 0.5 - cx;
const dy = y + 0.5 - cy;
const r = Math.hypot(dx, dy);
let v = 0;
if (r < SPHERE_R) {
const nx = dx / SPHERE_R;
const ny = dy / SPHERE_R;
const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));
// 법선과 광원 방향의 내적이 곧 밝기
v = clamp(Math.max(0, nx * lx + ny * ly + nz * lz) + AMBIENT, 0, 1);
}
else {
v = HALO_GAIN * lit * Math.exp(-(r - SPHERE_R) / HALO_W);
if (sx.has(i) && r > SPHERE_R + 4) {
v = Math.max(v, 0.9);
}
}
const val = v + err[i];
const on = val > 0.5 ? 1 : 0;
buf.px[i] = on === 1 ? light : dark;
// Atkinson — 여섯 이웃이 오차의 1/8 씩만 받고 나머지는 버린다
const e = (val - on) * ATKINSON_KEEP;
addErr(i + 1, x + 1, y, e);
addErr(i + 2, x + 2, y, e);
if (y + 1 < GH) {
addErr(i + GW - 1, x - 1, y + 1, e);
addErr(i + GW, x, y + 1, e);
addErr(i + GW + 1, x + 1, y + 1, e);
}
if (y + 2 < GH) {
addErr(i + 2 * GW, x, y + 2, e);
}
}
}
flush(ctx, buf);
},
label() {
const c = Math.cos(phi);
if (c > 0.6) {
return TXT_FULL;
}
if (c > -0.1) {
return TXT_HALF;
}
return c > -0.7 ? TXT_CRESCENT : TXT_NEW;
},
tap() {
dir = -dir;
}
};
if (reduce) {
for (let i = 0; i < 30; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./DtLitSphereMoonPhase.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
interface Buf {
off: HTMLCanvasElement;
octx: CanvasRenderingContext2D;
img: ImageData;
px: Uint32Array;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x: number): number { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n: number): Float32Array {
let size = 2;
let m: number[] = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next: number[] = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c: RGB): number {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a: RGB, b: RGB, k: number): number {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf(): Buf {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx: CanvasRenderingContext2D, b: Buf): void {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LIGHT_SPEED = 0.05;
const SPHERE_R = 40;
const AMBIENT = 0.04;
const ATKINSON_KEEP = 0.125;
const HALO_W = 9;
const HALO_GAIN = 0.34;
const DARK_03: RGB = [36, 34, 56];
const LIGHT_03: RGB = [255, 244, 214];
const TXT_FULL = '보름달 — 점이 가장 촘촘하다';
const TXT_HALF = '반달 — 절반만 켜진다';
const TXT_CRESCENT = '초승달 — 얇은 점의 띠';
const TXT_NEW = '그믐 — 점이 거의 꺼진다';
function makeScene(inp: Input, reduce: boolean): Scene {
const buf = makeBuf();
const err = new Float32Array(GW * GH);
const dark = pack(DARK_03);
const light = pack(LIGHT_03);
const rnd = lcg(453303);
const stars: number[] = [];
for (let i = 0; i < 26; i += 1) {
stars.push(Math.floor(rnd() * GW), Math.floor(rnd() * GH));
}
const sx = new Set<number>();
for (let i = 0; i < stars.length; i += 2) {
sx.add(stars[i + 1] * GW + stars[i]);
}
const cx = GW / 2;
const cy = GH / 2;
let phi = 0;
let dir = 1;
function addErr(i: number, x: number, y: number, e: number): void {
if (x >= 0 && x < GW && y < GH) {
err[i] += e;
}
}
const scene: Scene = {
step(): void {
if (inp.idle < IDLE_TICKS) {
const target = (inp.x / W) * PI2;
const diff = Math.atan2(Math.sin(target - phi), Math.cos(target - phi));
phi += diff * 0.15;
} else {
phi += LIGHT_SPEED * dir;
}
},
draw(ctx: CanvasRenderingContext2D): void {
// 광원 방향 — phi 가 0 이면 보는 쪽 정면, π 면 구 뒤쪽
let lx = Math.sin(phi);
let ly = 0.22;
let lz = Math.cos(phi);
const ln = Math.hypot(lx, ly, lz);
lx /= ln;
ly /= ln;
lz /= ln;
const lit = (1 + Math.cos(phi)) / 2;
err.fill(0);
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const dx = x + 0.5 - cx;
const dy = y + 0.5 - cy;
const r = Math.hypot(dx, dy);
let v = 0;
if (r < SPHERE_R) {
const nx = dx / SPHERE_R;
const ny = dy / SPHERE_R;
const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));
// 법선과 광원 방향의 내적이 곧 밝기
v = clamp(Math.max(0, nx * lx + ny * ly + nz * lz) + AMBIENT, 0, 1);
} else {
v = HALO_GAIN * lit * Math.exp(-(r - SPHERE_R) / HALO_W);
if (sx.has(i) && r > SPHERE_R + 4) {
v = Math.max(v, 0.9);
}
}
const val = v + err[i];
const on = val > 0.5 ? 1 : 0;
buf.px[i] = on === 1 ? light : dark;
// Atkinson — 여섯 이웃이 오차의 1/8 씩만 받고 나머지는 버린다
const e = (val - on) * ATKINSON_KEEP;
addErr(i + 1, x + 1, y, e);
addErr(i + 2, x + 2, y, e);
if (y + 1 < GH) {
addErr(i + GW - 1, x - 1, y + 1, e);
addErr(i + GW, x, y + 1, e);
addErr(i + GW + 1, x + 1, y + 1, e);
}
if (y + 2 < GH) {
addErr(i + 2 * GW, x, y + 2, e);
}
}
}
flush(ctx, buf);
},
label(): string {
const c = Math.cos(phi);
if (c > 0.6) {
return TXT_FULL;
}
if (c > -0.1) {
return TXT_HALF;
}
return c > -0.7 ? TXT_CRESCENT : TXT_NEW;
},
tap(): void {
dir = -dir;
}
};
if (reduce) {
for (let i = 0; i < 30; i += 1) {
scene.step();
}
}
return scene;
}
export default function DtLitSphereMoonPhase() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="광원이 한 바퀴 돌며 달의 위상을 점으로 만드는 구체 셰이딩" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">보름달 — 점이 가장 촘촘하다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>광원이 한 바퀴 돌며 달의 위상을 점으로 만드는 구체 셰이딩</p>
</div>
</main>
);
}03 · 光が一周して月の満ち欠けを点で作る球体シェーディング — 計測 累積面積 30.397% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));
// 法線と光の向きの内積がそのまま明るさ
v = clamp(Math.max(0, nx * lx + ny * ly + nz * lz) + AMBIENT, 0, 1);
}
else {
v = HALO_GAIN * lit * Math.exp(-(r - SPHERE_R) / HALO_W);
if (sx.has(i) && r > SPHERE_R + 4) {
v = Math.max(v, 0.9);
}
}
const val = v + err[i];
const on = val > 0.5 ? 1 : 0;
buf.px[i] = on === 1 ? light : dark;
// Atkinson — 六つの隣は誤差の1/8ずつだけ受け取り、残りは捨てる
const e = (val - on) * ATKINSON_KEEP;
addErr(i + 1, x + 1, y, e);
addErr(i + 2, x + 2, y, e);
if (y + 1 < GH) {
addErr(i + GW - 1, x - 1, y + 1, e);
addErr(i + GW, x, y + 1, e);
addErr(i + GW + 1, x + 1, y + 1, e);
}
if (y + 2 < GH) {
addErr(i + 2 * GW, x, y + 2, e);
}
04画面が点に溶けて次の場面へ移るインターリーブノイズ遷移
点ごとに座標だけで決まる0〜1の擬似乱数の閾値が進行度 p より小さければ縞の場面、そうでなければ輪の場面を描きます。閾値は均等に散っているので p が上がるぶん点がまばらに入れ替わり、ページ遷移やスライド送りに使えます。累積面積は九つで最大の75.3%、平均強度は111.8、23/23でした。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="画面が点に溶けて次の場面へ移るインターリーブノイズ遷移"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">輪の場面 — まもなく点に溶ける</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">画面が点に溶けて次の場面へ移るインターリーブノイズ遷移</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x) { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c) {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a, b, k) {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf() {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx, b) {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const NOISE_A = 0.06711056;
const NOISE_B = 0.00583715;
const FADE_PERIOD = 60;
const HOLD_FRAMES = 14;
const RING_FREQ = 0.28;
const STRIPE_FREQ = 0.22;
const RING_DARK = [54, 41, 80];
const RING_LIGHT = [255, 196, 214];
const STRIPE_DARK = [28, 52, 74];
const STRIPE_LIGHT = [186, 238, 225];
const TXT_RINGS = '輪の場面 — まもなく点に溶ける';
const TXT_MIX = '点単位で混ざっている — 閾値が進行度に従う';
const TXT_STRIPES = '縞の場面 — また溶けて戻る';
function makeScene(inp, reduce) {
const buf = makeBuf();
const cycle = 2 * (FADE_PERIOD + HOLD_FRAMES);
const ringLo = RING_DARK;
const ringHi = RING_LIGHT;
let t = 0;
let p = 0;
// 진행도 p — 0 이면 첫 장면, 1 이면 두 번째 장면
function progressAt(tick) {
const c = tick % cycle;
if (c < HOLD_FRAMES) {
return 0;
}
if (c < HOLD_FRAMES + FADE_PERIOD) {
return (c - HOLD_FRAMES) / FADE_PERIOD;
}
if (c < 2 * HOLD_FRAMES + FADE_PERIOD) {
return 1;
}
return 1 - (c - 2 * HOLD_FRAMES - FADE_PERIOD) / FADE_PERIOD;
}
const scene = {
step() {
t += 1;
const target = inp.idle < IDLE_TICKS ? clamp(inp.x / W, 0, 1) : progressAt(t);
p = lerp(p, target, 0.2);
},
draw(ctx) {
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
// 인터리브 그라디언트 노이즈 — 좌표만으로 정해지는 0~1 값
const thr = frac(52.9829189 * frac(NOISE_A * x + NOISE_B * y));
if (thr < p) {
const s = Math.sin((x * 0.7 + y * 0.7) * STRIPE_FREQ + t * 0.1);
buf.px[y * GW + x] = mixPack(STRIPE_DARK, STRIPE_LIGHT, smooth(s * 1.5 + 0.5));
}
else {
const s = Math.sin(Math.hypot(x - GW * 0.35, y - GH * 0.55) * RING_FREQ - t * 0.12);
buf.px[y * GW + x] = mixPack(ringLo, ringHi, smooth(s * 1.5 + 0.5));
}
}
}
flush(ctx, buf);
},
label() {
if (p < 0.02) {
return TXT_RINGS;
}
return p > 0.98 ? TXT_STRIPES : TXT_MIX;
},
tap() {
const c = t % cycle;
t = c < cycle / 2 ? HOLD_FRAMES : 2 * HOLD_FRAMES + FADE_PERIOD;
inp.idle = IDLE_TICKS;
}
};
if (reduce) {
t = HOLD_FRAMES + Math.floor(FADE_PERIOD * 0.5);
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./DtGradientNoiseDissolve.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
interface Buf {
off: HTMLCanvasElement;
octx: CanvasRenderingContext2D;
img: ImageData;
px: Uint32Array;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x: number): number { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n: number): Float32Array {
let size = 2;
let m: number[] = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next: number[] = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c: RGB): number {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a: RGB, b: RGB, k: number): number {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf(): Buf {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx: CanvasRenderingContext2D, b: Buf): void {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const NOISE_A = 0.06711056;
const NOISE_B = 0.00583715;
const FADE_PERIOD = 60;
const HOLD_FRAMES = 14;
const RING_FREQ = 0.28;
const STRIPE_FREQ = 0.22;
const RING_DARK: RGB = [54, 41, 80];
const RING_LIGHT: RGB = [255, 196, 214];
const STRIPE_DARK: RGB = [28, 52, 74];
const STRIPE_LIGHT: RGB = [186, 238, 225];
const TXT_RINGS = '고리 장면 — 곧 점으로 녹는다';
const TXT_MIX = '점 단위로 섞이는 중 — 임계값이 진행도를 따라간다';
const TXT_STRIPES = '줄무늬 장면 — 다시 녹아 돌아간다';
function makeScene(inp: Input, reduce: boolean): Scene {
const buf = makeBuf();
const cycle = 2 * (FADE_PERIOD + HOLD_FRAMES);
const ringLo = RING_DARK;
const ringHi = RING_LIGHT;
let t = 0;
let p = 0;
// 진행도 p — 0 이면 첫 장면, 1 이면 두 번째 장면
function progressAt(tick: number): number {
const c = tick % cycle;
if (c < HOLD_FRAMES) {
return 0;
}
if (c < HOLD_FRAMES + FADE_PERIOD) {
return (c - HOLD_FRAMES) / FADE_PERIOD;
}
if (c < 2 * HOLD_FRAMES + FADE_PERIOD) {
return 1;
}
return 1 - (c - 2 * HOLD_FRAMES - FADE_PERIOD) / FADE_PERIOD;
}
const scene: Scene = {
step(): void {
t += 1;
const target = inp.idle < IDLE_TICKS ? clamp(inp.x / W, 0, 1) : progressAt(t);
p = lerp(p, target, 0.2);
},
draw(ctx: CanvasRenderingContext2D): void {
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
// 인터리브 그라디언트 노이즈 — 좌표만으로 정해지는 0~1 값
const thr = frac(52.9829189 * frac(NOISE_A * x + NOISE_B * y));
if (thr < p) {
const s = Math.sin((x * 0.7 + y * 0.7) * STRIPE_FREQ + t * 0.1);
buf.px[y * GW + x] = mixPack(STRIPE_DARK, STRIPE_LIGHT, smooth(s * 1.5 + 0.5));
} else {
const s = Math.sin(Math.hypot(x - GW * 0.35, y - GH * 0.55) * RING_FREQ - t * 0.12);
buf.px[y * GW + x] = mixPack(ringLo, ringHi, smooth(s * 1.5 + 0.5));
}
}
}
flush(ctx, buf);
},
label(): string {
if (p < 0.02) {
return TXT_RINGS;
}
return p > 0.98 ? TXT_STRIPES : TXT_MIX;
},
tap(): void {
const c = t % cycle;
t = c < cycle / 2 ? HOLD_FRAMES : 2 * HOLD_FRAMES + FADE_PERIOD;
inp.idle = IDLE_TICKS;
}
};
if (reduce) {
t = HOLD_FRAMES + Math.floor(FADE_PERIOD * 0.5);
scene.step();
}
return scene;
}
export default function DtGradientNoiseDissolve() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="화면이 점으로 녹아 다음 장면으로 넘어가는 인터리브 노이즈 전환" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">고리 장면 — 곧 점으로 녹는다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>화면이 점으로 녹아 다음 장면으로 넘어가는 인터리브 노이즈 전환</p>
</div>
</main>
);
}04 · 画面が点に溶けて次の場面へ移るインターリーブノイズ遷移 — 計測 累積面積 75.253% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// インターリーブ・グラデーションノイズ — 座標だけで決まる0〜1の値
const thr = frac(52.9829189 * frac(NOISE_A * x + NOISE_B * y));
if (thr < p) {
const s = Math.sin((x * 0.7 + y * 0.7) * STRIPE_FREQ + t * 0.1);
buf.px[y * GW + x] = mixPack(STRIPE_DARK, STRIPE_LIGHT, smooth(s * 1.5 + 0.5));
}
else {
const s = Math.sin(Math.hypot(x - GW * 0.35, y - GH * 0.55) * RING_FREQ - t * 0.12);
buf.px[y * GW + x] = mixPack(ringLo, ringHi, smooth(s * 1.5 + 0.5));
}
05波動方程式で広がる波紋を四階調で打つディザの池
高さの場を隣四マスの和と前のフレームで更新して波紋を広げ、高さを四つの階調に分けて8×8のベイヤー行列で打ちます。ポインターを動かすと9ティックごとに水滴が落ち、押すと大きな水滴が落ちるので、クリックに反応する背景に向きます。累積面積63.0%・平均強度84.6で、23/23フレームが動きました。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="波動方程式で広がる波紋を四階調で打つディザの池"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">雨粒が落ちる — 波紋を四階調で打つ</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">波動方程式で広がる波紋を四階調で打つディザの池</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x) { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c) {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a, b, k) {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf() {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx, b) {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const DAMPING = 0.985;
const WAVE_C = 0.4;
const LEVELS = 4;
const DROP_EVERY = 42;
const SUBSTEPS = 2;
const DROP_R = 3.2;
const DROP_AMP = 2.2;
const TONE_GAIN = 0.9;
const PALETTE_05 = [[42, 58, 99], [90, 127, 181], [159, 201, 234], [232, 246, 255]];
const TXT_RAIN = '雨粒が落ちる — 波紋を四階調で打つ';
const TXT_DROP = 'ポインターが水滴を落とす';
function makeScene(inp, reduce) {
const buf = makeBuf();
const matrix = makeBayer(8);
const pal = PALETTE_05.map(pack);
const rnd = lcg(453505);
let h = new Float32Array(GW * GH);
let hp = new Float32Array(GW * GH);
let hn = new Float32Array(GW * GH);
let t = 0;
let lastDrop = 0;
function drop(cx, cy, amp) {
const r = Math.ceil(DROP_R * 2);
for (let y = Math.max(1, Math.floor(cy) - r); y < Math.min(GH - 1, Math.floor(cy) + r + 1); y += 1) {
for (let x = Math.max(1, Math.floor(cx) - r); x < Math.min(GW - 1, Math.floor(cx) + r + 1); x += 1) {
const d2 = (x - cx) * (x - cx) + (y - cy) * (y - cy);
h[y * GW + x] += amp * Math.exp(-d2 / (DROP_R * DROP_R));
}
}
}
// 파동 방정식 한 걸음 — 이웃 합과 이전 프레임으로 다음 높이를 구한다
function simulate() {
for (let y = 1; y < GH - 1; y += 1) {
for (let x = 1; x < GW - 1; x += 1) {
const i = y * GW + x;
const lap = h[i - 1] + h[i + 1] + h[i - GW] + h[i + GW] - 4 * h[i];
hn[i] = (2 * h[i] - hp[i] + WAVE_C * lap) * DAMPING;
}
}
const old = hp;
hp = h;
h = hn;
hn = old;
}
function randomDrop() {
drop(14 + rnd() * (GW - 28), 14 + rnd() * (GH - 28), DROP_AMP);
}
const scene = {
step() {
t += 1;
if (t % DROP_EVERY === 1) {
randomDrop();
}
if (inp.idle < IDLE_TICKS && t - lastDrop > 9 && inp.idle < 3) {
drop(inp.x * GW / W, inp.y * GH / H, DROP_AMP * 0.6);
lastDrop = t;
}
for (let s = 0; s < SUBSTEPS; s += 1) {
simulate();
}
},
draw(ctx) {
const q = LEVELS - 1;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const tone = clamp(0.5 + h[i] * TONE_GAIN, 0, 1);
const thr = matrix[(y & 7) * 8 + (x & 7)];
const k = clamp(Math.floor(tone * q + thr), 0, q);
buf.px[i] = pal[k];
}
}
flush(ctx, buf);
},
label() {
return inp.idle < 40 ? TXT_DROP : TXT_RAIN;
},
tap() {
drop(inp.x * GW / W, inp.y * GH / H, DROP_AMP * 1.2);
}
};
const prime = reduce ? 160 : 70;
for (let i = 0; i < prime; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./DtDitheredRipplePond.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
interface Buf {
off: HTMLCanvasElement;
octx: CanvasRenderingContext2D;
img: ImageData;
px: Uint32Array;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x: number): number { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n: number): Float32Array {
let size = 2;
let m: number[] = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next: number[] = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c: RGB): number {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a: RGB, b: RGB, k: number): number {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf(): Buf {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx: CanvasRenderingContext2D, b: Buf): void {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const DAMPING = 0.985;
const WAVE_C = 0.4;
const LEVELS = 4;
const DROP_EVERY = 42;
const SUBSTEPS = 2;
const DROP_R = 3.2;
const DROP_AMP = 2.2;
const TONE_GAIN = 0.9;
const PALETTE_05: RGB[] = [[42, 58, 99], [90, 127, 181], [159, 201, 234], [232, 246, 255]];
const TXT_RAIN = '빗방울이 떨어진다 — 물결을 네 단계 명암으로 찍는다';
const TXT_DROP = '포인터가 물방울을 떨어뜨린다';
function makeScene(inp: Input, reduce: boolean): Scene {
const buf = makeBuf();
const matrix = makeBayer(8);
const pal = PALETTE_05.map(pack);
const rnd = lcg(453505);
let h = new Float32Array(GW * GH);
let hp = new Float32Array(GW * GH);
let hn = new Float32Array(GW * GH);
let t = 0;
let lastDrop = 0;
function drop(cx: number, cy: number, amp: number): void {
const r = Math.ceil(DROP_R * 2);
for (let y = Math.max(1, Math.floor(cy) - r); y < Math.min(GH - 1, Math.floor(cy) + r + 1); y += 1) {
for (let x = Math.max(1, Math.floor(cx) - r); x < Math.min(GW - 1, Math.floor(cx) + r + 1); x += 1) {
const d2 = (x - cx) * (x - cx) + (y - cy) * (y - cy);
h[y * GW + x] += amp * Math.exp(-d2 / (DROP_R * DROP_R));
}
}
}
// 파동 방정식 한 걸음 — 이웃 합과 이전 프레임으로 다음 높이를 구한다
function simulate(): void {
for (let y = 1; y < GH - 1; y += 1) {
for (let x = 1; x < GW - 1; x += 1) {
const i = y * GW + x;
const lap = h[i - 1] + h[i + 1] + h[i - GW] + h[i + GW] - 4 * h[i];
hn[i] = (2 * h[i] - hp[i] + WAVE_C * lap) * DAMPING;
}
}
const old = hp;
hp = h;
h = hn;
hn = old;
}
function randomDrop(): void {
drop(14 + rnd() * (GW - 28), 14 + rnd() * (GH - 28), DROP_AMP);
}
const scene: Scene = {
step(): void {
t += 1;
if (t % DROP_EVERY === 1) {
randomDrop();
}
if (inp.idle < IDLE_TICKS && t - lastDrop > 9 && inp.idle < 3) {
drop(inp.x * GW / W, inp.y * GH / H, DROP_AMP * 0.6);
lastDrop = t;
}
for (let s = 0; s < SUBSTEPS; s += 1) {
simulate();
}
},
draw(ctx: CanvasRenderingContext2D): void {
const q = LEVELS - 1;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const tone = clamp(0.5 + h[i] * TONE_GAIN, 0, 1);
const thr = matrix[(y & 7) * 8 + (x & 7)];
const k = clamp(Math.floor(tone * q + thr), 0, q);
buf.px[i] = pal[k];
}
}
flush(ctx, buf);
},
label(): string {
return inp.idle < 40 ? TXT_DROP : TXT_RAIN;
},
tap(): void {
drop(inp.x * GW / W, inp.y * GH / H, DROP_AMP * 1.2);
}
};
const prime = reduce ? 160 : 70;
for (let i = 0; i < prime; i += 1) {
scene.step();
}
return scene;
}
export default function DtDitheredRipplePond() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="파동 방정식으로 퍼지는 물결을 네 단계 명암으로 찍는 디더 연못" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">빗방울이 떨어진다 — 물결을 네 단계 명암으로 찍는다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>파동 방정식으로 퍼지는 물결을 네 단계 명암으로 찍는 디더 연못</p>
</div>
</main>
);
}05 · 波動方程式で広がる波紋を四階調で打つディザの池 — 計測 累積面積 62.954% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 波動方程式の一歩 — 隣の和と前のフレームから次の高さを求める
function simulate() {
for (let y = 1; y < GH - 1; y += 1) {
for (let x = 1; x < GW - 1; x += 1) {
const i = y * GW + x;
const lap = h[i - 1] + h[i + 1] + h[i - GW] + h[i + GW] - 4 * h[i];
hn[i] = (2 * h[i] - hp[i] + WAVE_C * lap) * DAMPING;
}
}
const old = hp;
hp = h;
h = hn;
hn = old;
}
06回る立体の面の明るさを点の密度に変える3Dディザキューブ
立方体の面の中心と法線を回し、カメラを向いた面だけを残して、頂点を x·FOCAL/(z+CAM_DIST) で投影します。面ごとに光との角度から明るさを決めてベイヤー閾値と比べ、点で塗ります。凸の立体で見える面どうしが重ならないため奥行きの並べ替えは要らず、ロゴの周りの回転飾りに向きます。測定は累積面積26.4%・平均強度147.3・動いたフレーム23/23です。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="回る立体の面の明るさを点の密度に変える3Dディザキューブ"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">面の明るさが点の密度に変わる — キューブが回る</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">回る立体の面の明るさを点の密度に変える3Dディザキューブ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x) { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c) {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a, b, k) {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf() {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx, b) {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const ROT_SPEED = 0.03;
const CAM_DIST = 4.2;
const FOCAL = 112;
const SIDE = 0.8;
const LIGHT_DIR = [-0.45, 0.75, 0.5];
const FACE_SHADE = 0.2;
const CUBE_DARK = [35, 30, 52];
const CUBE_LIGHT = [255, 238, 206];
const EDGE = [255, 170, 200];
const BG_LIGHT = [98, 82, 138];
const TXT_SPIN = '面の明るさが点の密度に変わる — キューブが回る';
const TXT_LIGHT = 'ポインターが光の向きを決める';
function makeScene(inp, reduce) {
const buf = makeBuf();
const m8 = makeBayer(8);
const m4 = makeBayer(4);
const dark = pack(CUBE_DARK);
const light = pack(CUBE_LIGHT);
const edge = pack(EDGE);
const bgLight = pack(BG_LIGHT);
const faces = [
{ n: [1, 0, 0], u: [0, 1, 0], v: [0, 0, 1] },
{ n: [-1, 0, 0], u: [0, 1, 0], v: [0, 0, 1] },
{ n: [0, 1, 0], u: [1, 0, 0], v: [0, 0, 1] },
{ n: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1] },
{ n: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] },
{ n: [0, 0, -1], u: [1, 0, 0], v: [0, 1, 0] }
];
let t = 0;
let dir = 1;
let lightX = LIGHT_DIR[0];
let lightY = LIGHT_DIR[1];
let angle = 0.4;
function rot(p, a, b) {
const x1 = p[0] * Math.cos(a) + p[2] * Math.sin(a);
const z1 = -p[0] * Math.sin(a) + p[2] * Math.cos(a);
const y2 = p[1] * Math.cos(b) - z1 * Math.sin(b);
const z2 = p[1] * Math.sin(b) + z1 * Math.cos(b);
return [x1, y2, z2];
}
function drawFace(f, a, b, lx, ly, lz) {
const n = rot(f.n, a, b);
const c = rot([f.n[0] * SIDE, f.n[1] * SIDE, f.n[2] * SIDE], a, b);
// 카메라(0, 0, -CAM_DIST)를 향한 면만 그린다
const toCam = n[0] * (0 - c[0]) + n[1] * (0 - c[1]) + n[2] * (-CAM_DIST - c[2]);
if (toCam <= 0) {
return;
}
const signs = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
const pts = [];
for (let k = 0; k < 4; k += 1) {
const s = signs[k];
const corner = rot([
(f.n[0] + s[0] * f.u[0] + s[1] * f.v[0]) * SIDE,
(f.n[1] + s[0] * f.u[1] + s[1] * f.v[1]) * SIDE,
(f.n[2] + s[0] * f.u[2] + s[1] * f.v[2]) * SIDE
], a, b);
const k2 = FOCAL / (corner[2] + CAM_DIST);
pts.push([GW / 2 + corner[0] * k2, GH / 2 - corner[1] * k2]);
}
const lam = Math.max(0, n[0] * lx + n[1] * ly + n[2] * lz);
const shade = FACE_SHADE + (1 - FACE_SHADE) * lam;
let x0 = GW;
let x1 = 0;
let y0 = GH;
let y1 = 0;
for (let k = 0; k < 4; k += 1) {
x0 = Math.min(x0, pts[k][0]);
x1 = Math.max(x1, pts[k][0]);
y0 = Math.min(y0, pts[k][1]);
y1 = Math.max(y1, pts[k][1]);
}
for (let y = Math.max(0, Math.floor(y0)); y <= Math.min(GH - 1, Math.ceil(y1)); y += 1) {
for (let x = Math.max(0, Math.floor(x0)); x <= Math.min(GW - 1, Math.ceil(x1)); x += 1) {
const px = x + 0.5;
const py = y + 0.5;
let neg = 0;
let pos = 0;
let nearEdge = 99;
for (let k = 0; k < 4; k += 1) {
const p = pts[k];
const q = pts[(k + 1) % 4];
const ex = q[0] - p[0];
const ey = q[1] - p[1];
const cross = ex * (py - p[1]) - ey * (px - p[0]);
if (cross < 0) {
neg += 1;
}
else {
pos += 1;
}
nearEdge = Math.min(nearEdge, Math.abs(cross) / Math.hypot(ex, ey));
}
if (neg > 0 && pos > 0) {
continue;
}
const i = y * GW + x;
if (nearEdge < 0.8) {
buf.px[i] = edge;
}
else {
buf.px[i] = shade > m8[(y & 7) * 8 + (x & 7)] ? light : dark;
}
}
}
}
const scene = {
step() {
t += 1;
angle += ROT_SPEED * dir;
const tx = inp.idle < IDLE_TICKS ? (inp.x / W - 0.5) * 2 : LIGHT_DIR[0];
const ty = inp.idle < IDLE_TICKS ? -(inp.y / H - 0.5) * 2 : LIGHT_DIR[1];
lightX = lerp(lightX, tx, 0.1);
lightY = lerp(lightY, ty, 0.1);
},
draw(ctx) {
// 배경 — 느리게 흐르는 대각선 그라디언트를 4×4 행렬로 찍은 흐린 점무늬
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const v = 0.2 + 0.2 * (0.5 + 0.5 * Math.sin(((x * 0.5 + y * 0.8) / GH) * PI2 * 1.3 - t * 0.07));
buf.px[y * GW + x] = v > m4[(y & 3) * 4 + (x & 3)] ? bgLight : dark;
}
}
const a = angle;
const b = 0.55 + 0.35 * Math.sin(angle * 0.7);
const ln = Math.hypot(lightX, lightY, LIGHT_DIR[2]);
for (let k = 0; k < faces.length; k += 1) {
drawFace(faces[k], a, b, lightX / ln, lightY / ln, LIGHT_DIR[2] / ln);
}
flush(ctx, buf);
},
label() {
return inp.idle < IDLE_TICKS ? TXT_LIGHT : TXT_SPIN;
},
tap() {
dir = -dir;
}
};
if (reduce) {
for (let i = 0; i < 20; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./DtRotatingCubeFlatShade.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
interface Buf {
off: HTMLCanvasElement;
octx: CanvasRenderingContext2D;
img: ImageData;
px: Uint32Array;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x: number): number { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n: number): Float32Array {
let size = 2;
let m: number[] = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next: number[] = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c: RGB): number {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a: RGB, b: RGB, k: number): number {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf(): Buf {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx: CanvasRenderingContext2D, b: Buf): void {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const ROT_SPEED = 0.03;
const CAM_DIST = 4.2;
const FOCAL = 112;
const SIDE = 0.8;
const LIGHT_DIR: [number, number, number] = [-0.45, 0.75, 0.5];
const FACE_SHADE = 0.2;
const CUBE_DARK: RGB = [35, 30, 52];
const CUBE_LIGHT: RGB = [255, 238, 206];
const EDGE: RGB = [255, 170, 200];
const BG_LIGHT: RGB = [98, 82, 138];
const TXT_SPIN = '면의 밝기가 점 밀도로 바뀐다 — 큐브가 돈다';
const TXT_LIGHT = '포인터가 빛의 방향을 정한다';
interface Face { n: [number, number, number]; u: [number, number, number]; v: [number, number, number]; }
function makeScene(inp: Input, reduce: boolean): Scene {
const buf = makeBuf();
const m8 = makeBayer(8);
const m4 = makeBayer(4);
const dark = pack(CUBE_DARK);
const light = pack(CUBE_LIGHT);
const edge = pack(EDGE);
const bgLight = pack(BG_LIGHT);
const faces: Face[] = [
{ n: [1, 0, 0], u: [0, 1, 0], v: [0, 0, 1] },
{ n: [-1, 0, 0], u: [0, 1, 0], v: [0, 0, 1] },
{ n: [0, 1, 0], u: [1, 0, 0], v: [0, 0, 1] },
{ n: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1] },
{ n: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] },
{ n: [0, 0, -1], u: [1, 0, 0], v: [0, 1, 0] }
];
let t = 0;
let dir = 1;
let lightX = LIGHT_DIR[0];
let lightY = LIGHT_DIR[1];
let angle = 0.4;
function rot(p: [number, number, number], a: number, b: number): [number, number, number] {
const x1 = p[0] * Math.cos(a) + p[2] * Math.sin(a);
const z1 = -p[0] * Math.sin(a) + p[2] * Math.cos(a);
const y2 = p[1] * Math.cos(b) - z1 * Math.sin(b);
const z2 = p[1] * Math.sin(b) + z1 * Math.cos(b);
return [x1, y2, z2];
}
function drawFace(f: Face, a: number, b: number, lx: number, ly: number, lz: number): void {
const n = rot(f.n, a, b);
const c = rot([f.n[0] * SIDE, f.n[1] * SIDE, f.n[2] * SIDE], a, b);
// 카메라(0, 0, -CAM_DIST)를 향한 면만 그린다
const toCam = n[0] * (0 - c[0]) + n[1] * (0 - c[1]) + n[2] * (-CAM_DIST - c[2]);
if (toCam <= 0) {
return;
}
const signs: Array<[number, number]> = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
const pts: Array<[number, number]> = [];
for (let k = 0; k < 4; k += 1) {
const s = signs[k];
const corner = rot([
(f.n[0] + s[0] * f.u[0] + s[1] * f.v[0]) * SIDE,
(f.n[1] + s[0] * f.u[1] + s[1] * f.v[1]) * SIDE,
(f.n[2] + s[0] * f.u[2] + s[1] * f.v[2]) * SIDE
], a, b);
const k2 = FOCAL / (corner[2] + CAM_DIST);
pts.push([GW / 2 + corner[0] * k2, GH / 2 - corner[1] * k2]);
}
const lam = Math.max(0, n[0] * lx + n[1] * ly + n[2] * lz);
const shade = FACE_SHADE + (1 - FACE_SHADE) * lam;
let x0 = GW;
let x1 = 0;
let y0 = GH;
let y1 = 0;
for (let k = 0; k < 4; k += 1) {
x0 = Math.min(x0, pts[k][0]);
x1 = Math.max(x1, pts[k][0]);
y0 = Math.min(y0, pts[k][1]);
y1 = Math.max(y1, pts[k][1]);
}
for (let y = Math.max(0, Math.floor(y0)); y <= Math.min(GH - 1, Math.ceil(y1)); y += 1) {
for (let x = Math.max(0, Math.floor(x0)); x <= Math.min(GW - 1, Math.ceil(x1)); x += 1) {
const px = x + 0.5;
const py = y + 0.5;
let neg = 0;
let pos = 0;
let nearEdge = 99;
for (let k = 0; k < 4; k += 1) {
const p = pts[k];
const q = pts[(k + 1) % 4];
const ex = q[0] - p[0];
const ey = q[1] - p[1];
const cross = ex * (py - p[1]) - ey * (px - p[0]);
if (cross < 0) {
neg += 1;
} else {
pos += 1;
}
nearEdge = Math.min(nearEdge, Math.abs(cross) / Math.hypot(ex, ey));
}
if (neg > 0 && pos > 0) {
continue;
}
const i = y * GW + x;
if (nearEdge < 0.8) {
buf.px[i] = edge;
} else {
buf.px[i] = shade > m8[(y & 7) * 8 + (x & 7)] ? light : dark;
}
}
}
}
const scene: Scene = {
step(): void {
t += 1;
angle += ROT_SPEED * dir;
const tx = inp.idle < IDLE_TICKS ? (inp.x / W - 0.5) * 2 : LIGHT_DIR[0];
const ty = inp.idle < IDLE_TICKS ? -(inp.y / H - 0.5) * 2 : LIGHT_DIR[1];
lightX = lerp(lightX, tx, 0.1);
lightY = lerp(lightY, ty, 0.1);
},
draw(ctx: CanvasRenderingContext2D): void {
// 배경 — 느리게 흐르는 대각선 그라디언트를 4×4 행렬로 찍은 흐린 점무늬
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const v = 0.2 + 0.2 * (0.5 + 0.5 * Math.sin(((x * 0.5 + y * 0.8) / GH) * PI2 * 1.3 - t * 0.07));
buf.px[y * GW + x] = v > m4[(y & 3) * 4 + (x & 3)] ? bgLight : dark;
}
}
const a = angle;
const b = 0.55 + 0.35 * Math.sin(angle * 0.7);
const ln = Math.hypot(lightX, lightY, LIGHT_DIR[2]);
for (let k = 0; k < faces.length; k += 1) {
drawFace(faces[k], a, b, lightX / ln, lightY / ln, LIGHT_DIR[2] / ln);
}
flush(ctx, buf);
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_LIGHT : TXT_SPIN;
},
tap(): void {
dir = -dir;
}
};
if (reduce) {
for (let i = 0; i < 20; i += 1) {
scene.step();
}
}
return scene;
}
export default function DtRotatingCubeFlatShade() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="회전 입체의 면 밝기를 점 밀도로 바꾸는 3D 디더 큐브" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">면의 밝기가 점 밀도로 바뀐다 — 큐브가 돈다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>회전 입체의 면 밝기를 점 밀도로 바꾸는 3D 디더 큐브</p>
</div>
</main>
);
}06 · 回る立体の面の明るさを点の密度に変える3Dディザキューブ — 計測 累積面積 26.445% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const n = rot(f.n, a, b);
const c = rot([f.n[0] * SIDE, f.n[1] * SIDE, f.n[2] * SIDE], a, b);
// カメラ (0, 0, -CAM_DIST) を向いた面だけ描く
const toCam = n[0] * (0 - c[0]) + n[1] * (0 - c[1]) + n[2] * (-CAM_DIST - c[2]);
if (toCam <= 0) {
return;
}
const signs = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
const pts = [];
for (let k = 0; k < 4; k += 1) {
const s = signs[k];
const corner = rot([
(f.n[0] + s[0] * f.u[0] + s[1] * f.v[0]) * SIDE,
(f.n[1] + s[0] * f.u[1] + s[1] * f.v[1]) * SIDE,
(f.n[2] + s[0] * f.u[2] + s[1] * f.v[2]) * SIDE
], a, b);
const k2 = FOCAL / (corner[2] + CAM_DIST);
pts.push([GW / 2 + corner[0] * k2, GH / 2 - corner[1] * k2]);
}
const lam = Math.max(0, n[0] * lx + n[1] * ly + n[2] * lz);
const shade = FACE_SHADE + (1 - FACE_SHADE) * lam;
07四色で空のグラデーションを量子化するパレットディザの夕焼け
空のグラデーションと太陽の光輪、海の照り返しを作ってから、画素ごとにベイヤー値で色を少しずらし、四色のパレットから最も近い色を選びます。ずらす幅が DITHER_AMP なので、色の境目が帯ではなく点でつながって見え、低解像度の雰囲気のランディング背景に使えます。累積面積44.5%・平均強度79.9で、動いたフレームは23/23でした。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="四色で空のグラデーションを量子化するパレットディザの夕焼け"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">四色だけで夕焼けを打つ</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">四色で空のグラデーションを量子化するパレットディザの夕焼け</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x) { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c) {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a, b, k) {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf() {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx, b) {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const DITHER_AMP = 0.4;
const PALETTE_SIZE = 4;
const HORIZON_Y = 62;
const SUN_SPEED = 0.05;
const SUN_R = 9;
const PALETTE_07 = [[52, 40, 84], [142, 92, 160], [240, 132, 124], [255, 222, 170]];
const SKY_TOP = [32, 26, 70];
const SKY_LOW = [250, 170, 140];
const TXT_SUNSET = '四色だけで夕焼けを打つ';
const TXT_BAND = '振幅を0にすると境目に帯ができる';
const TXT_SUN = 'ポインターが太陽の横位置を決める';
function makeScene(inp, reduce) {
const buf = makeBuf();
const matrix = makeBayer(8);
const pal = PALETTE_07.slice(0, PALETTE_SIZE);
const palPack = pal.map(pack);
let t = 0;
let banded = false;
let sunX = GW * 0.5;
// 보정한 색에서 가장 가까운 팔레트 색의 번호
function nearest(r, g, b) {
let best = 0;
let bestD = 1e9;
for (let k = 0; k < pal.length; k += 1) {
const dr = r - pal[k][0];
const dg = g - pal[k][1];
const db = b - pal[k][2];
const d = dr * dr + dg * dg + db * db;
if (d < bestD) {
bestD = d;
best = k;
}
}
return best;
}
const scene = {
step() {
t += 1;
const target = inp.idle < IDLE_TICKS ? inp.x * GW / W : GW * (0.5 + 0.16 * Math.sin(t * 0.017));
sunX = lerp(sunX, target, 0.1);
},
draw(ctx) {
// 해는 수평선 위에서 오래 머물다 잠깐 아래로 잠긴다
const h = 0.25 + 0.75 * Math.sin(t * SUN_SPEED);
const sunY = HORIZON_Y - 8 - 34 * h;
const day = 0.62 + 0.38 * smooth((h + 0.5) / 1.1);
const amp = (banded ? 0 : DITHER_AMP) * 255;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
let r = 0;
let g = 0;
let b = 0;
if (y < HORIZON_Y) {
const s = Math.pow(y / HORIZON_Y, 1.3);
r = lerp(SKY_TOP[0], SKY_LOW[0], s);
g = lerp(SKY_TOP[1], SKY_LOW[1], s);
b = lerp(SKY_TOP[2], SKY_LOW[2], s);
const d = Math.hypot(x - sunX, y - sunY);
const glow = 0.8 * Math.exp(-(d * d) / (2 * 22 * 22)) * (0.4 + 0.6 * day);
r += 255 * glow * 0.8;
g += 200 * glow * 0.7;
b += 150 * glow * 0.5;
if (d < SUN_R && sunY < HORIZON_Y) {
r = 255;
g = 236;
b = 190;
}
r *= day;
g *= day;
b *= day;
}
else {
const depth = y - HORIZON_Y;
const base = 1 - 0.45 * Math.min(1, depth / 38);
r = SKY_LOW[0] * 0.55 * base;
g = SKY_LOW[1] * 0.42 * base;
b = SKY_LOW[2] * 0.6 * base + 26;
// 물결에 일렁이는 해의 반사 기둥
const wob = 0.5 + 0.5 * Math.sin(y * 1.2 - t * 0.2 + Math.sin(x * 0.15) * 2);
const width = 6 + depth * 0.25;
const refl = Math.exp(-((x - sunX) * (x - sunX)) / (2 * width * width)) * 0.7 * wob * (sunY < HORIZON_Y + 12 ? 1 : 0.4);
r += 255 * refl * day;
g += 205 * refl * day;
b += 160 * refl * day;
}
const off = (matrix[(y & 7) * 8 + (x & 7)] - 0.5) * amp;
buf.px[y * GW + x] = palPack[nearest(r + off, g + off, b + off)];
}
}
flush(ctx, buf);
},
label() {
if (banded) {
return TXT_BAND;
}
return inp.idle < IDLE_TICKS ? TXT_SUN : TXT_SUNSET;
},
tap() {
banded = !banded;
}
};
if (reduce) {
for (let i = 0; i < 24; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./DtPaletteSunsetQuantize.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
interface Buf {
off: HTMLCanvasElement;
octx: CanvasRenderingContext2D;
img: ImageData;
px: Uint32Array;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x: number): number { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n: number): Float32Array {
let size = 2;
let m: number[] = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next: number[] = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c: RGB): number {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a: RGB, b: RGB, k: number): number {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf(): Buf {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx: CanvasRenderingContext2D, b: Buf): void {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const DITHER_AMP = 0.4;
const PALETTE_SIZE = 4;
const HORIZON_Y = 62;
const SUN_SPEED = 0.05;
const SUN_R = 9;
const PALETTE_07: RGB[] = [[52, 40, 84], [142, 92, 160], [240, 132, 124], [255, 222, 170]];
const SKY_TOP: RGB = [32, 26, 70];
const SKY_LOW: RGB = [250, 170, 140];
const TXT_SUNSET = '네 가지 색만으로 노을을 찍는다';
const TXT_BAND = '진폭을 0 으로 두면 경계에 띠가 생긴다';
const TXT_SUN = '포인터가 해의 가로 위치를 정한다';
function makeScene(inp: Input, reduce: boolean): Scene {
const buf = makeBuf();
const matrix = makeBayer(8);
const pal = PALETTE_07.slice(0, PALETTE_SIZE);
const palPack = pal.map(pack);
let t = 0;
let banded = false;
let sunX = GW * 0.5;
// 보정한 색에서 가장 가까운 팔레트 색의 번호
function nearest(r: number, g: number, b: number): number {
let best = 0;
let bestD = 1e9;
for (let k = 0; k < pal.length; k += 1) {
const dr = r - pal[k][0];
const dg = g - pal[k][1];
const db = b - pal[k][2];
const d = dr * dr + dg * dg + db * db;
if (d < bestD) {
bestD = d;
best = k;
}
}
return best;
}
const scene: Scene = {
step(): void {
t += 1;
const target = inp.idle < IDLE_TICKS ? inp.x * GW / W : GW * (0.5 + 0.16 * Math.sin(t * 0.017));
sunX = lerp(sunX, target, 0.1);
},
draw(ctx: CanvasRenderingContext2D): void {
// 해는 수평선 위에서 오래 머물다 잠깐 아래로 잠긴다
const h = 0.25 + 0.75 * Math.sin(t * SUN_SPEED);
const sunY = HORIZON_Y - 8 - 34 * h;
const day = 0.62 + 0.38 * smooth((h + 0.5) / 1.1);
const amp = (banded ? 0 : DITHER_AMP) * 255;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
let r = 0;
let g = 0;
let b = 0;
if (y < HORIZON_Y) {
const s = Math.pow(y / HORIZON_Y, 1.3);
r = lerp(SKY_TOP[0], SKY_LOW[0], s);
g = lerp(SKY_TOP[1], SKY_LOW[1], s);
b = lerp(SKY_TOP[2], SKY_LOW[2], s);
const d = Math.hypot(x - sunX, y - sunY);
const glow = 0.8 * Math.exp(-(d * d) / (2 * 22 * 22)) * (0.4 + 0.6 * day);
r += 255 * glow * 0.8;
g += 200 * glow * 0.7;
b += 150 * glow * 0.5;
if (d < SUN_R && sunY < HORIZON_Y) {
r = 255;
g = 236;
b = 190;
}
r *= day;
g *= day;
b *= day;
} else {
const depth = y - HORIZON_Y;
const base = 1 - 0.45 * Math.min(1, depth / 38);
r = SKY_LOW[0] * 0.55 * base;
g = SKY_LOW[1] * 0.42 * base;
b = SKY_LOW[2] * 0.6 * base + 26;
// 물결에 일렁이는 해의 반사 기둥
const wob = 0.5 + 0.5 * Math.sin(y * 1.2 - t * 0.2 + Math.sin(x * 0.15) * 2);
const width = 6 + depth * 0.25;
const refl = Math.exp(-((x - sunX) * (x - sunX)) / (2 * width * width)) * 0.7 * wob * (sunY < HORIZON_Y + 12 ? 1 : 0.4);
r += 255 * refl * day;
g += 205 * refl * day;
b += 160 * refl * day;
}
const off = (matrix[(y & 7) * 8 + (x & 7)] - 0.5) * amp;
buf.px[y * GW + x] = palPack[nearest(r + off, g + off, b + off)];
}
}
flush(ctx, buf);
},
label(): string {
if (banded) {
return TXT_BAND;
}
return inp.idle < IDLE_TICKS ? TXT_SUN : TXT_SUNSET;
},
tap(): void {
banded = !banded;
}
};
if (reduce) {
for (let i = 0; i < 24; i += 1) {
scene.step();
}
}
return scene;
}
export default function DtPaletteSunsetQuantize() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="네 가지 색으로 하늘 그라디언트를 양자화하는 팔레트 디더 노을" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">네 가지 색만으로 노을을 찍는다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>네 가지 색으로 하늘 그라디언트를 양자화하는 팔레트 디더 노을</p>
</div>
</main>
);
}07 · 四色で空のグラデーションを量子化するパレットディザの夕焼け — 計測 累積面積 44.511% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 補正した色にいちばん近いパレット色の番号
function nearest(r, g, b) {
let best = 0;
let bestD = 1e9;
for (let k = 0; k < pal.length; k += 1) {
const dr = r - pal[k][0];
const dg = g - pal[k][1];
const db = b - pal[k][2];
const d = dr * dr + dg * dg + db * db;
if (d < bestD) {
bestD = d;
best = k;
}
}
return best;
}
08文字が点に散って再び集まるタイポグラフィのディザ
文字を見えないキャンバスに描き、getImageData で一度だけ読んで3×3の平均でぼかした明るさのマスクを作ります。毎フレーム、このマスクを時間で動く閾値と比べて左から点単位で消したり戻したりするので、タイトルやロゴの登場演出に向きます。累積面積21.9%は九つで最小、平均強度は136.9で、23/23フレームで動きが検出されました。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="文字が点に散って再び集まるタイポグラフィのディザ"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">文字がくっきり — まもなく点に散る</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">文字が点に散って再び集まるタイポグラフィのディザ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x) { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c) {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a, b, k) {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf() {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx, b) {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const DISSOLVE_SPEED = 0.014;
const FONT_PX = 38;
const MASK_BLUR = 1;
const BAYER_SIZE = 8;
const SKEW = 0.7;
const THR_DRIFT = 0.021;
const WORD = 'DITHER';
const INK = [35, 30, 52];
const TEXT_LIGHT = [255, 236, 214];
const GHOST = [118, 98, 160];
const BG_DOT = [66, 55, 98];
const TXT_SOLID = '文字がくっきり — まもなく点に散る';
const TXT_SCATTER = '閾値が左から文字を消していく';
const TXT_EMPTY = '文字がすべて点になって散った';
const TXT_GATHER = '点が再び集まって文字になる';
const TXT_SCRUB = 'ポインターの横位置が消えた度合いになる';
function makeScene(inp, reduce) {
const buf = makeBuf();
const matrix = makeBayer(BAYER_SIZE);
const ink = pack(INK);
const textLight = pack(TEXT_LIGHT);
const ghost = pack(GHOST);
const bgDot = pack(BG_DOT);
// 글자를 보이지 않는 캔버스에 그려 밝기 마스크로 읽는다
const mask = new Float32Array(GW * GH);
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (octx) {
octx.fillStyle = '#000000';
octx.fillRect(0, 0, GW, GH);
octx.fillStyle = '#ffffff';
octx.font = 'bold ' + FONT_PX + 'px sans-serif';
octx.textAlign = 'center';
octx.textBaseline = 'middle';
octx.fillText(WORD, GW / 2, GH / 2);
const data = octx.getImageData(0, 0, GW, GH).data;
const raw = new Float32Array(GW * GH);
for (let i = 0; i < raw.length; i += 1) {
raw[i] = data[i * 4] / 255;
}
// 상자 흐림 — 가장자리를 0 과 1 사이 값으로 풀어 점이 바깥까지 번지게 한다
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
let sum = 0;
let n = 0;
for (let dy = -MASK_BLUR; dy <= MASK_BLUR; dy += 1) {
for (let dx = -MASK_BLUR; dx <= MASK_BLUR; dx += 1) {
const xx = x + dx;
const yy = y + dy;
if (xx >= 0 && xx < GW && yy >= 0 && yy < GH) {
sum += raw[yy * GW + xx];
n += 1;
}
}
}
mask[y * GW + x] = sum / n;
}
}
}
let t = 0;
let prog = 0;
function triangle(tick) {
const w = (tick * DISSOLVE_SPEED) % 2;
return w < 1 ? w : 2 - w;
}
const scene = {
step() {
t += 1;
const target = inp.idle < IDLE_TICKS ? clamp(inp.x / W, 0, 1) : triangle(t);
prog = lerp(prog, target, 0.2);
},
draw(ctx) {
// 양 끝에서 잠깐 멈추도록 진행도를 다듬는다
const p = clamp((prog - 0.12) / 0.76, 0, 1);
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const xn = x / GW;
const vis = clamp(1 - (p * (1 + SKEW) - xn * SKEW), 0, 1);
const thr = frac(matrix[(y % BAYER_SIZE) * BAYER_SIZE + (x % BAYER_SIZE)] + t * THR_DRIFT);
const m = mask[i];
if (m * vis > thr) {
buf.px[i] = textLight;
}
else if (m > 0.5 && ((x + y) & 1) === 0) {
buf.px[i] = ghost;
}
else {
const v = 0.14 + 0.12 * (0.5 + 0.5 * Math.sin(x * 0.09 + y * 0.06 - t * 0.05));
buf.px[i] = v > matrix[(y % BAYER_SIZE) * BAYER_SIZE + (x % BAYER_SIZE)] ? bgDot : ink;
}
}
}
flush(ctx, buf);
},
label() {
if (inp.idle < IDLE_TICKS) {
return TXT_SCRUB;
}
const w = (t * DISSOLVE_SPEED) % 2;
if (prog < 0.12) {
return TXT_SOLID;
}
if (prog > 0.88) {
return TXT_EMPTY;
}
return w < 1 ? TXT_SCATTER : TXT_GATHER;
},
tap() {
t = 0;
prog = 0;
inp.idle = IDLE_TICKS;
}
};
if (reduce) {
t = Math.floor(0.45 / DISSOLVE_SPEED);
prog = 0.45;
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./DtTextDitherDissolve.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
interface Buf {
off: HTMLCanvasElement;
octx: CanvasRenderingContext2D;
img: ImageData;
px: Uint32Array;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x: number): number { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n: number): Float32Array {
let size = 2;
let m: number[] = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next: number[] = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c: RGB): number {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a: RGB, b: RGB, k: number): number {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf(): Buf {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx: CanvasRenderingContext2D, b: Buf): void {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const DISSOLVE_SPEED = 0.014;
const FONT_PX = 38;
const MASK_BLUR = 1;
const BAYER_SIZE = 8;
const SKEW = 0.7;
const THR_DRIFT = 0.021;
const WORD = 'DITHER';
const INK: RGB = [35, 30, 52];
const TEXT_LIGHT: RGB = [255, 236, 214];
const GHOST: RGB = [118, 98, 160];
const BG_DOT: RGB = [66, 55, 98];
const TXT_SOLID = '글자가 또렷하다 — 곧 점으로 흩어진다';
const TXT_SCATTER = '임계값이 왼쪽부터 글자를 지운다';
const TXT_EMPTY = '글자가 모두 점이 되어 흩어졌다';
const TXT_GATHER = '점이 다시 모여 글자가 된다';
const TXT_SCRUB = '포인터 가로 위치가 지워진 정도다';
function makeScene(inp: Input, reduce: boolean): Scene {
const buf = makeBuf();
const matrix = makeBayer(BAYER_SIZE);
const ink = pack(INK);
const textLight = pack(TEXT_LIGHT);
const ghost = pack(GHOST);
const bgDot = pack(BG_DOT);
// 글자를 보이지 않는 캔버스에 그려 밝기 마스크로 읽는다
const mask = new Float32Array(GW * GH);
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (octx) {
octx.fillStyle = '#000000';
octx.fillRect(0, 0, GW, GH);
octx.fillStyle = '#ffffff';
octx.font = 'bold ' + FONT_PX + 'px sans-serif';
octx.textAlign = 'center';
octx.textBaseline = 'middle';
octx.fillText(WORD, GW / 2, GH / 2);
const data = octx.getImageData(0, 0, GW, GH).data;
const raw = new Float32Array(GW * GH);
for (let i = 0; i < raw.length; i += 1) {
raw[i] = data[i * 4] / 255;
}
// 상자 흐림 — 가장자리를 0 과 1 사이 값으로 풀어 점이 바깥까지 번지게 한다
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
let sum = 0;
let n = 0;
for (let dy = -MASK_BLUR; dy <= MASK_BLUR; dy += 1) {
for (let dx = -MASK_BLUR; dx <= MASK_BLUR; dx += 1) {
const xx = x + dx;
const yy = y + dy;
if (xx >= 0 && xx < GW && yy >= 0 && yy < GH) {
sum += raw[yy * GW + xx];
n += 1;
}
}
}
mask[y * GW + x] = sum / n;
}
}
}
let t = 0;
let prog = 0;
function triangle(tick: number): number {
const w = (tick * DISSOLVE_SPEED) % 2;
return w < 1 ? w : 2 - w;
}
const scene: Scene = {
step(): void {
t += 1;
const target = inp.idle < IDLE_TICKS ? clamp(inp.x / W, 0, 1) : triangle(t);
prog = lerp(prog, target, 0.2);
},
draw(ctx: CanvasRenderingContext2D): void {
// 양 끝에서 잠깐 멈추도록 진행도를 다듬는다
const p = clamp((prog - 0.12) / 0.76, 0, 1);
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const xn = x / GW;
const vis = clamp(1 - (p * (1 + SKEW) - xn * SKEW), 0, 1);
const thr = frac(matrix[(y % BAYER_SIZE) * BAYER_SIZE + (x % BAYER_SIZE)] + t * THR_DRIFT);
const m = mask[i];
if (m * vis > thr) {
buf.px[i] = textLight;
} else if (m > 0.5 && ((x + y) & 1) === 0) {
buf.px[i] = ghost;
} else {
const v = 0.14 + 0.12 * (0.5 + 0.5 * Math.sin(x * 0.09 + y * 0.06 - t * 0.05));
buf.px[i] = v > matrix[(y % BAYER_SIZE) * BAYER_SIZE + (x % BAYER_SIZE)] ? bgDot : ink;
}
}
}
flush(ctx, buf);
},
label(): string {
if (inp.idle < IDLE_TICKS) {
return TXT_SCRUB;
}
const w = (t * DISSOLVE_SPEED) % 2;
if (prog < 0.12) {
return TXT_SOLID;
}
if (prog > 0.88) {
return TXT_EMPTY;
}
return w < 1 ? TXT_SCATTER : TXT_GATHER;
},
tap(): void {
t = 0;
prog = 0;
inp.idle = IDLE_TICKS;
}
};
if (reduce) {
t = Math.floor(0.45 / DISSOLVE_SPEED);
prog = 0.45;
}
return scene;
}
export default function DtTextDitherDissolve() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="글자가 점으로 흩어졌다 다시 모이는 타이포 디더" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">글자가 또렷하다 — 곧 점으로 흩어진다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>글자가 점으로 흩어졌다 다시 모이는 타이포 디더</p>
</div>
</main>
);
}08 · 文字が点に散って再び集まるタイポグラフィのディザ — 計測 累積面積 21.874% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const mask = new Float32Array(GW * GH);
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (octx) {
octx.fillStyle = '#000000';
octx.fillRect(0, 0, GW, GH);
octx.fillStyle = '#ffffff';
octx.font = 'bold ' + FONT_PX + 'px sans-serif';
octx.textAlign = 'center';
octx.textBaseline = 'middle';
octx.fillText(WORD, GW / 2, GH / 2);
const data = octx.getImageData(0, 0, GW, GH).data;
const raw = new Float32Array(GW * GH);
for (let i = 0; i < raw.length; i += 1) {
raw[i] = data[i * 4] / 255;
}
09ポインターの周りだけ密になる光輪ディザのヒーロー
ノイズで流れる背景の明るさへ、ポインターとの距離に応じたガウス値を足し、ベイヤー閾値と比べるとカーソルの周りだけ点が詰まります。ポインター座標を LERP で遅れて追うので光輪が滑るように動き、ヒーローの見せ場に向きます。累積面積は50.6%、平均強度は91.9で、23/23フレームが動きました。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="ポインターの周りだけ密になる光輪ディザのヒーロー"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">光輪が自分で漂う</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">ポインターの周りだけ密になる光輪ディザのヒーロー</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
image-rendering: pixelated;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x) { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n) {
let size = 2;
let m = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c) {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a, b, k) {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf() {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx, b) {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const HALO_R = 34;
const HALO_GAIN = 0.85;
const NOISE_SCALE = 0.05;
const LERP = 0.12;
const NOISE_SEED = 9;
const PULSE_TICKS = 40;
const DARK_09 = [34, 31, 58];
const LIGHT_BASE = [160, 196, 255];
const LIGHT_HALO = [255, 196, 222];
const TXT_AUTO = '光輪が自分で漂う';
const TXT_FOLLOW = '光輪がポインターに遅れて付いてくる';
const TXT_PULSE = '押すと光輪が一度ふくらんで静まる';
function makeScene(inp, reduce) {
const buf = makeBuf();
const matrix = makeBayer(8);
const dark = pack(DARK_09);
// 후광 세기 0~1 을 32 단계 색으로 미리 만들어 둔다
const tint = [];
for (let k = 0; k < 32; k += 1) {
tint.push(mixPack(LIGHT_BASE, LIGHT_HALO, k / 31));
}
let t = 0;
let hx = GW * 0.5;
let hy = GH * 0.5;
let pulse = 0;
const scene = {
step() {
t += 1;
let tx = GW * (0.5 + 0.32 * Math.sin(t * 0.021));
let ty = GH * (0.5 + 0.28 * Math.sin(t * 0.033 + 1));
if (inp.idle < IDLE_TICKS) {
tx = inp.x * GW / W;
ty = inp.y * GH / H;
}
hx = lerp(hx, tx, LERP);
hy = lerp(hy, ty, LERP);
if (pulse > 0) {
pulse -= 1;
}
},
draw(ctx) {
const breathe = 1 + 0.12 * Math.sin(t * 0.07) + 0.6 * smooth(pulse / PULSE_TICKS);
const sigma = (HALO_R / 1.6) * breathe;
const inv = 1 / (2 * sigma * sigma);
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const nx = x * NOISE_SCALE + t * 0.012;
const ny = y * NOISE_SCALE - t * 0.007;
const n = vnoise(nx, ny, NOISE_SEED) * 0.65 + vnoise(nx * 2.1 + 7, ny * 2.1 + 3, NOISE_SEED) * 0.35;
const dx = x - hx;
const dy = y - hy;
const halo = Math.exp(-(dx * dx + dy * dy) * inv);
const v = 0.05 + 0.5 * n + halo * HALO_GAIN;
const i = y * GW + x;
buf.px[i] = v > matrix[(y & 7) * 8 + (x & 7)] ? tint[Math.min(31, Math.floor(halo * 31))] : dark;
}
}
flush(ctx, buf);
},
label() {
if (pulse > 0) {
return TXT_PULSE;
}
return inp.idle < IDLE_TICKS ? TXT_FOLLOW : TXT_AUTO;
},
tap() {
pulse = PULSE_TICKS;
}
};
if (reduce) {
for (let i = 0; i < 40; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./DtPointerHaloHero.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 점을 찍는 작은 격자 — 화면에는 3배로 확대해 올린다
const GW = 160;
const GH = 100;
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
interface Buf {
off: HTMLCanvasElement;
octx: CanvasRenderingContext2D;
img: ImageData;
px: Uint32Array;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function frac(x: number): number { return x - Math.floor(x); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 좌표 두 개와 시드로 0 이상 1 미만의 값을 만드는 정수 해시 (난수 함수 없이도 같은 좌표는 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 격자 네 점의 해시를 smooth 로 이어 붙인 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
// 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
function makeBayer(n: number): Float32Array {
let size = 2;
let m: number[] = [0, 2, 3, 1];
const quad = [0, 2, 3, 1];
while (size < n) {
const s2 = size * 2;
const next: number[] = [];
for (let y = 0; y < s2; y += 1) {
for (let x = 0; x < s2; x += 1) {
const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
}
}
m = next;
size = s2;
}
const out = new Float32Array(n * n);
for (let i = 0; i < out.length; i += 1) {
out[i] = (m[i] + 0.5) / (n * n);
}
return out;
}
// 색 하나를 캔버스 픽셀 한 개(리틀 엔디언 RGBA)로 묶는다
function pack(c: RGB): number {
return ((255 << 24) | (Math.round(c[2]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[0])) >>> 0;
}
function mixPack(a: RGB, b: RGB, k: number): number {
return pack([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
function makeBuf(): Buf {
const off = document.createElement('canvas');
off.width = GW;
off.height = GH;
const octx = off.getContext('2d');
if (!octx) {
throw new Error('2d context unavailable');
}
const img = octx.createImageData(GW, GH);
return { off: off, octx: octx, img: img, px: new Uint32Array(img.data.buffer) };
}
// 작은 격자를 큰 캔버스에 보간 없이 확대해 올린다
function flush(ctx: CanvasRenderingContext2D, b: Buf): void {
b.octx.putImageData(b.img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(b.off, 0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const HALO_R = 34;
const HALO_GAIN = 0.85;
const NOISE_SCALE = 0.05;
const LERP = 0.12;
const NOISE_SEED = 9;
const PULSE_TICKS = 40;
const DARK_09: RGB = [34, 31, 58];
const LIGHT_BASE: RGB = [160, 196, 255];
const LIGHT_HALO: RGB = [255, 196, 222];
const TXT_AUTO = '빛무리가 스스로 떠돈다';
const TXT_FOLLOW = '빛무리가 포인터를 늦게 따라온다';
const TXT_PULSE = '누르면 빛무리가 한 번 부풀었다 가라앉는다';
function makeScene(inp: Input, reduce: boolean): Scene {
const buf = makeBuf();
const matrix = makeBayer(8);
const dark = pack(DARK_09);
// 후광 세기 0~1 을 32 단계 색으로 미리 만들어 둔다
const tint: number[] = [];
for (let k = 0; k < 32; k += 1) {
tint.push(mixPack(LIGHT_BASE, LIGHT_HALO, k / 31));
}
let t = 0;
let hx = GW * 0.5;
let hy = GH * 0.5;
let pulse = 0;
const scene: Scene = {
step(): void {
t += 1;
let tx = GW * (0.5 + 0.32 * Math.sin(t * 0.021));
let ty = GH * (0.5 + 0.28 * Math.sin(t * 0.033 + 1));
if (inp.idle < IDLE_TICKS) {
tx = inp.x * GW / W;
ty = inp.y * GH / H;
}
hx = lerp(hx, tx, LERP);
hy = lerp(hy, ty, LERP);
if (pulse > 0) {
pulse -= 1;
}
},
draw(ctx: CanvasRenderingContext2D): void {
const breathe = 1 + 0.12 * Math.sin(t * 0.07) + 0.6 * smooth(pulse / PULSE_TICKS);
const sigma = (HALO_R / 1.6) * breathe;
const inv = 1 / (2 * sigma * sigma);
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const nx = x * NOISE_SCALE + t * 0.012;
const ny = y * NOISE_SCALE - t * 0.007;
const n = vnoise(nx, ny, NOISE_SEED) * 0.65 + vnoise(nx * 2.1 + 7, ny * 2.1 + 3, NOISE_SEED) * 0.35;
const dx = x - hx;
const dy = y - hy;
const halo = Math.exp(-(dx * dx + dy * dy) * inv);
const v = 0.05 + 0.5 * n + halo * HALO_GAIN;
const i = y * GW + x;
buf.px[i] = v > matrix[(y & 7) * 8 + (x & 7)] ? tint[Math.min(31, Math.floor(halo * 31))] : dark;
}
}
flush(ctx, buf);
},
label(): string {
if (pulse > 0) {
return TXT_PULSE;
}
return inp.idle < IDLE_TICKS ? TXT_FOLLOW : TXT_AUTO;
},
tap(): void {
pulse = PULSE_TICKS;
}
};
if (reduce) {
for (let i = 0; i < 40; i += 1) {
scene.step();
}
}
return scene;
}
export default function DtPointerHaloHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="포인터 둘레만 촘촘해지는 빛무리 디더 히어로" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">빛무리가 스스로 떠돈다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>포인터 둘레만 촘촘해지는 빛무리 디더 히어로</p>
</div>
</main>
);
}09 · ポインターの周りだけ密になる光輪ディザのヒーロー — 計測 累積面積 50.6% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const breathe = 1 + 0.12 * Math.sin(t * 0.07) + 0.6 * smooth(pulse / PULSE_TICKS);
const sigma = (HALO_R / 1.6) * breathe;
const inv = 1 / (2 * sigma * sigma);
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const nx = x * NOISE_SCALE + t * 0.012;
const ny = y * NOISE_SCALE - t * 0.007;
const n = vnoise(nx, ny, NOISE_SEED) * 0.65 + vnoise(nx * 2.1 + 7, ny * 2.1 + 3, NOISE_SEED) * 0.35;
const dx = x - hx;
const dy = y - hy;
const halo = Math.exp(-(dx * dx + dy * dy) * inv);
const v = 0.05 + 0.5 * n + halo * HALO_GAIN;
const i = y * GW + x;
buf.px[i] = v > matrix[(y & 7) * 8 + (x & 7)] ? tint[Math.min(31, Math.floor(halo * 31))] : dark;
値が点模様を変えるところ — 行列の大きさ・誤差の割合・波の減衰
値一つで点模様がはっきり変わる場所は三つです。ここに並べる数字はブラウザーのレンダーではなく、ソースの同じ計算をノードの偽の DOM で直接動かして数えたものなので、あとの測定表とは分けて読んでください。
行列を4から8に広げると点の数の種類が17から44になる
01の BAYER_SIZE を4から8に替えて30ティック後の一フレームを見ると、行列の大きさのタイルごとに点灯した数の種類が17通りから44通りに増えます。理論上ありうる種類は17から65に増え、点灯率は0.520から0.523でほぼ同じです。明るさはそのままで目が細かくほぐれ、そのかわり繰り返しの周期が4マスから8マスに広がります。
誤差を渡す割合を下げると点灯が固まる
02の DIFFUSE を1.0から0.75に下げると、同じ横の行で点灯が続く平均の長さが三フレーム平均で1.88マスから2.11マスになります。0.5では2.67マス、0にすると36.3マスの帯に固まります。点灯率は0.529から0.533で、ほとんど動きません。
減衰を上げると大きな波が長く残る
05の DAMPING を0.985から0.995に上げると、いちばん暗い段階かいちばん明るい段階で打たれた画素の割合が、8フレーム平均で6.0%から27.1%になります。波の山と谷が消えずに積み重なるということです。
九つの場面を実際にレンダーして測った値は次の表のとおりです。最も広く動いたのは04の75.3%、最も狭いのは08の21.9%で、03だけが動いたフレームが22/23と一フレーム欠けました。その理由は確かめていません。
| 場面 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 整列ディザ | 74.8% | 182.8 | 23/23 |
| 02 誤差拡散 | 74.8% | 165.7 | 23/23 |
| 03 球体シェーディング | 30.4% | 188.2 | 22/23 |
| 04 ノイズ遷移 | 75.3% | 111.8 | 23/23 |
| 05 ディザの池 | 63.0% | 84.6 | 23/23 |
| 06 3Dキューブ | 26.4% | 147.3 | 23/23 |
| 07 パレットの夕焼け | 44.5% | 79.9 | 23/23 |
| 08 タイポグラフィ | 21.9% | 136.9 | 23/23 |
| 09 光輪 | 50.6% | 91.9 | 23/23 |
どこで壊れるか — 解像度が高いとき・狭い画面・動きを減らす設定
解像度が高くても点の格子は160×100のまま
画面の解像度を上げても計算するマスは160×100で変わらず、キャンバスの内側の解像度だけが devicePixelRatio に従い、最大2で頭打ちになります。そのかわり09は、マスごとに vnoise を二回と指数関数を一回、16000マスぶん回すので、ほかの場面より一フレームの計算が重くなります。格子を大きくすると計算がその分増えますが、大きくした値では測っていません。
誤差拡散は順番に進むので並列に分けられない
02は一つの点の結果が前の点の渡した誤差に左右されるため、マスを同時に処理できません。フレームごとに err.fill(0) で空にしてから16000マスを頭から回し直します。フレーム間で誤差を引き継がないので、明るさの場が動くと点の配置がまるごと入れ替わります。格子を小さく取って3倍に引き伸ばすのは、このためです。
Atkinson は誤差の4分の1を捨てる
03で光が正面にあるとき点灯した点は3975個、31ティック後は1251個、63ティック後に位相が反対側に届くと15個です。月がほとんど欠けた位相でも数点が残るのは、球の外に置いた背景の星26個のうち球から遠いものが点灯するからです。誤差を4分の1捨てる代わりに、暗い側と明るい側が純粋な色でよく固まるのが、この方式の特徴です。
遷移の閾値には時間の項がない
04の閾値は座標しか使わないので、同じ p なら選ばれる点はいつも同じです。thr < p の点の割合は、pを0.25・0.5・0.75にすると0.250・0.500・0.750でpと一致し、縞の側に選ばれた点が横に続く最大の長さは、pが0.25で1マス、0.5で2マス、0.75で6マスです。0.75でも6マスにとどまるので、固まらず散ります。
波の係数を上げると計算が発散する
05の WAVE_C を0.4から0.6に上げると、同じ8フレームの測定で極端な段階の割合が0.929になり、階調の種類が5に増えて、四段階の外の値が混ざります。端のマスは計算せず0のままにしてあり、減衰は一ティックに二回掛かるので、実際のティックあたりの減衰は DAMPING の二乗です。
キューブの定数を大きくすると格子の外で切れる
06の以前の定数(FOCAL 125・CAM_DIST 3.8・SIDE 0.85)では、縁の画素の外接枠が98×97で格子の上端に触れ、切れていました。いまの値(FOCAL 112・CAM_DIST 4.2・SIDE 0.8)は71×75で、125ティックのあいだ格子の中に収まります。toCam が0以下になった面は描画を飛ばすので、見える面は一度に最大三つです。06は makeBayer(8) と makeBayer(4) の二つの行列を使い分けます。
ディザの振れ幅が0だと境目が帯に固まる
07で縦に隣り合う二つの画素の色が違う回数は、振れ幅0.4のとき10921で、DITHER_AMP を0にすると1523です。およそ7分の1に減るので、ディザがなぜ要るのかを示す数字です。パレットが四色なので、画素ごとの距離計算も四回走ります。
文字の幅は端末のフォントが決める
08は bold 38px sans-serif で文字を描き、measureText では幅を測りません。端末ごとに既定のフォントが違えば文字の幅が変わり、格子の横160を超えると両端が切れるかもしれませんが、ほかのフォントでは動かしていません。キャンバスが取れないとマスが0になり、文字は出ません。
狭い画面では文字だけ小さくなり比率は変わらない
幅340px以下ではキャプション・ボタン・状態の文字が10pxに縮み、キャンバスの比率480:300は保たれます。舞台の幅がビューポートに比例するため、スマホでは点一つがそのぶん小さく見えます。
値を書き替えて境目を確かめたいときは、圧縮ファイルを開く必要があります。開くパスワードは ee8ur62p で、中には九つの場面の index.html・style.scss とリアクト版が入っています。
変形三つとアクセシビリティ
| 名前 | 値 | 感じ |
|---|---|---|
| 行列の大きさを変える | 01 BAYER_SIZE 4 → 8 | タイルごとの点灯数の種類が17通りから44通りに増えて目がほぐれ、点灯率は0.520から0.523でほぼ同じです。 |
| 誤差の渡す割合を変える | 02 DIFFUSE 1.0 → 0.75 | 横に続く点灯の平均が1.88マスから2.11マスになり、点が少し固まります。 |
| 波の減衰を変える | 05 DAMPING 0.985 → 0.995 | 極端な段階の画素が平均6.0%から27.1%に増え、波がいつまでも残ります。 |
三つの数字は、上の値の節と同じくノードでソースを動かして数えた値です。
操作は九つの場面が同じ枠を使います。場面ごとに <button> が一つあり、キャンバスは tabindex="0" なので Tab で届きます。Enter と Space は場面の押す動作、方向キーは仮想ポインターを14単位ずつ動かします。preventDefault はこの六つのキーでしか呼ばれず、Tab を含むほかのキーはそのまま通ります。状態の文は aria-live="polite" の <p> に入り、文が変わったときだけ書き替えます。
prefers-reduced-motion がオンのときは、九つの場面のどれも requestAnimationFrame のループを始めず、最初に1400msぶん進めた静止画を一枚だけ描きます。点模様は止まっていてもそのまま読み取れ、ボタンは 一時停止 の代わりに 次の場面 になって、押すたびに900msぶん進みます。ポインターやキーの入力があると6ティック進めてすぐ一度描くので、入力が画面にすぐ反映されます。
css dither filter だけで足りるか?
css dither filter という言葉から CSS のプロパティ一行を期待しがちですが、この記事の九つの場面はキャンバスに putImageData で点を打つ方式で、CSS が受け持つのは拡大した点をにじませない image-rendering: pixelated と舞台の大きさくらいです。点ごとに閾値を比べたり誤差を流したりする計算は、スクリプトの側で行われます。
そこで、すでにある写真や要素の上にかぶせる効果が要るのか、明るさの場を自分で計算して点にできるのかを先に分けておくと迷いません。後者なら、この九つの場面が出発点になります。同じキャンバス計算を3Dの場面へ持っていくならレイマーチング9選 — WebGLなし、キャンバスのピクセル計算で描く3Dシーン、炎をパーティクルで描きたいならキャンバス 炎 アニメーション9選 — シード固定パーティクルで描く火のシーンが続きます。キャンバスの使い方は MDN の putImageData のページにまとまっています。
css dither background を使い回す前によくある質問
css dither effect を自分の写真に使うにはどこを替えればいい?
九つの場面は写真を読まず、数式で作った明るさの場を使います。ただし08がキャンバスに描いた絵を getImageData で読み、明るさの配列にしている箇所があるので、写真一枚を同じ方法で読めば、その値が明るさになります。写真に差し替えては動かしていません。
格子を160×100より大きくしてもいい?
計算量はマスの数に比例して増えます。02は毎フレーム16000マスを順番に頭から回し直し、09はマスごとにノイズを二回計算するので、重いのはこの二つですが、大きくした値では測っていません。
dither pattern css にライブラリや WebGL は必要?
必要ありません。九つのファイルとも、ライブラリ・CDN・WebGL は使わず、Math.random も呼びません。乱数が要る03と05は種を固定した lcg を、04は座標のハッシュを、09は vnoise を使うので、同じティックなら同じフレームが出ます。









