06A 3D Dither Cube That Turns Face Brightness into Dot Density
Cube vertices are rotated and projected, each face gets brightness from its angle to the light, and painting it as dot density yields a pixel-art style solid.
Best forRotating logo and icon decoration
ROT_SPEEDLIGHT_DIRCAM_DISTFACE_SHADE

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="A 3D Dither Cube That Turns Face Brightness into Dot Density"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">Face brightness becomes dot density — the cube spins</p>
<button class="sc__btn" id="scBtn" type="button">Pause</button>
<p class="sc__caption">A 3D Dither Cube That Turns Face Brightness into Dot Density</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 = 'Pause';
const BTN_PLAY = 'Play';
const BTN_NEXT = 'Next scene';
// 점을 찍는 작은 격자 — 화면에는 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 = 'Face brightness becomes dot density — the cube spins';
const TXT_LIGHT = 'The pointer sets the light direction';
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 A 3D Dither Cube That Turns Face Brightness into Dot Density — Measured area 26.445% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Dither: 9 Scenes Drawn by Threshold Math
The post this demo lives in
CSS Dither: 9 Scenes Drawn by Threshold MathA css dither throws away every color but two, so why does the gradient look smoother? Scene 01's threshold matrix and sc See all demos in this post
If you like this effect - similar demos
Creative UI07 Shading That Moves a Sphere's Light and Shadow Into Glyph DensityASCII Art JavaScript: 9 Canvas Glyph Scenes
Creative UI06 Density cloud — visit counts piling into pixel-brightness fogLorenz Attractor: 9 Butterfly Chaos Demos
Creative UI08 Text that scatters into particlesCanvas Particle Animation: 9 Engine Patterns