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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 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줄을 넘지 않는다. css image dithering 이라는 이름으로 모이는 움직임도 따지고 보면 이 안의 두 길, 곧 임계값을 견주는 계산과 오차를 넘기는 계산 위에 서 있다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 베이어 임계 행렬 — 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 방식이라 앞쪽 방향이 줄마다 뒤집히고, 두 색만으로 면을 채우는 히어로 배경에 어울린다. 평균 강도 165.7 에 누적면적은 74.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 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 인터리브 그라디언트 노이즈 — 좌표만으로 정해지는 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 파동 방정식 한 걸음 — 이웃 합과 이전 프레임으로 다음 높이를 구한다
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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네 가지 색으로 하늘 그라디언트를 양자화하는 팔레트 디더 노을
하늘 그라디언트와 해의 후광, 바다 반사를 만든 다음 픽셀마다 베이어 값으로 색을 조금씩 어긋나게 하고 4색 팔레트 중 가장 가까운 색을 고른다. 어긋나는 폭이 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 보정한 색에서 가장 가까운 팔레트 색의 번호
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 두 번과 지수 함수 한 번을 16,000칸에 돌려 다른 장면보다 한 프레임의 계산이 무겁다. 격자를 키우면 계산이 그만큼 늘어나는데 키운 값으로 재 보지는 않았다.
오차 확산은 순차라 병렬로 못 쪼갠다
02 는 한 점의 결과가 앞 점이 넘긴 오차에 달려 있어서 칸을 동시에 처리할 수 없고, 프레임마다 err.fill(0) 으로 비운 뒤 16,000칸을 처음부터 다시 돈다. 프레임 사이에 오차를 이어받지 않으므로 밝기장이 움직이면 점 배치가 통째로 바뀐다. 격자를 작게 잡고 3배로 늘리는 것이 이 때문이다.
Atkinson 은 오차의 4분의 1 을 버린다
03 에서 광원이 정면일 때 켜진 점은 3,975개이고 31틱 뒤에는 1,251개, 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칸이라 어느 쪽도 큰 덩어리로 뭉치지 않고 흩어진다.
파동 계수를 올리면 계산이 발산한다
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 이하인 면은 건너뛰므로 보이는 면은 한 번에 최대 세 개다.
디더 진폭이 0 이면 경계가 띠로 굳는다
07 에서 세로로 이웃한 두 픽셀의 색이 다른 횟수가 진폭 0.4 일 때 10,921 이고 DITHER_AMP 를 0 으로 두면 1,523 이다. 7배쯤 줄어든다는 뜻이고, 디더가 왜 필요한지 보여 주는 숫자다. 팔레트가 4색이라 픽셀마다 거리 계산도 네 번 돈다.
글자 폭은 기기의 글꼴이 정한다
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 루프를 시작하지 않고 몇 틱을 미리 돌린 정지 한 장만 그린다. 점무늬는 정지한 채로도 그대로 보이고, 버튼은 멈춤 대신 다음 장면 이 되어 누를 때마다 900ms 분량만큼 진행한다. 포인터나 키 입력이 들어오면 6틱을 진행해 곧바로 한 번 그리므로 입력이 화면에 바로 반영된다.
css dither filter 만으로 충분한가?
css dither filter 라는 말에서 속성 한 줄을 기대하기 쉽지만, 이 편의 아홉 장면은 캔버스에 putImageData 로 점을 찍는 방식이라 CSS 속성이 하는 일은 확대된 점이 번지지 않게 하는 image-rendering: pixelated 와 무대 크기 정도다. 점마다 임계값을 견주거나 오차를 흘려보내는 계산은 스크립트에서 일어난다.
그래서 이미 있는 사진이나 요소 위에 덮는 효과가 필요한지, 아니면 밝기장을 직접 계산해서 점으로 만들 수 있는지를 먼저 가르는 것이 좋다. 후자라면 이 아홉 장면이 출발점이 되고, 같은 캔버스 계산을 3D 장면으로 가져가고 싶으면 레이 마칭 9종 — 캔버스 픽셀 계산으로 그리는 3D 장면 (WebGL 없이)가, 불꽃을 파티클로 그리고 싶으면 캔버스 불 애니메이션 9종 — 시드 고정 파티클로 그리는 불꽃 효과가 이어진다. 캔버스 쓰는 법은 MDN 의 putImageData 문서에 정리되어 있다.
css dithering gradient 를 가져다 쓰기 전에 자주 묻는 질문
css image dithering 을 내 사진에 쓰려면 어디를 바꾸나?
아홉 장면은 사진을 읽지 않고 수식으로 만든 밝기장을 쓴다. 다만 08 이 캔버스에 그린 그림을 getImageData 로 읽어 밝기 배열로 쓰는 자리라서, 사진 한 장을 같은 방식으로 읽으면 그 값이 밝기가 된다. 사진으로 바꿔 돌려 보지는 않았다.
격자를 160×100 보다 크게 잡아도 되나?
계산량은 칸 수에 비례해 늘어난다. 02 는 프레임마다 16,000칸을 순서대로 처음부터 다시 돌고 09 는 칸마다 노이즈를 두 번 계산하므로 무거운 쪽은 이 둘이고, 키운 값으로 재 보지는 않았다.
라이브러리나 WebGL 이 필요한가?
필요 없다. 아홉 파일 모두 라이브러리·CDN·WebGL 이 없고 Math.random 도 쓰지 않는다. 난수가 필요한 03(lcg(453303))과 05(lcg(453505))만 시드를 고정한 lcg 를 부르고, 04 는 좌표 해시를, 09 는 vnoise(NOISE_SEED 9)를 쓰므로 같은 틱이면 같은 프레임이 나온다. lcg·hash2·vnoise·makeBayer 는 아홉 파일에 모두 정의돼 있지만 쓰는 장면에서만 호출된다.









