03Sphere Shading Where an Orbiting Light Turns Moon Phases into Dots
Using the dot product of the sphere's surface normal and a rotating light direction as brightness, then dithering it, turns the phase from crescent to full into changing dot density.
Best forMoon phase widgets and night mode decoration
LIGHT_SPEEDSPHERE_RAMBIENTATKINSON_KEEP

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="Sphere Shading Where an Orbiting Light Turns Moon Phases into Dots"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">Full moon — the dots are densest</p>
<button class="sc__btn" id="scBtn" type="button">Pause</button>
<p class="sc__caption">Sphere Shading Where an Orbiting Light Turns Moon Phases into Dots</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 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 = 'Full moon — the dots are densest';
const TXT_HALF = 'Half moon — only half switches on';
const TXT_CRESCENT = 'Crescent — a thin band of dots';
const TXT_NEW = 'New moon — the dots nearly go out';
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 Sphere Shading Where an Orbiting Light Turns Moon Phases into Dots — Measured area 30.397% · moving frames 22/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 UI02 Stripe bands — one parameter turns dots into lines9 Reaction Diffusion Simulations, No Library
Creative UI07 Sensor-angle morph — one angle value turns coral into blood vesselsSlime Mold Simulation, 9 Ways (No Library)