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


