css starfield 9パターン — 星が奥行きに沿って流れる宇宙の背景
css starfield を点数個の CSS アニメーションだけで作ると奥行きがなく速さも一定で平らになる — 01番・02番・05番が遠近投影・層ごとの移動量・指数曲線で解く。九つの場面はすべてキャンバスに直接描いた星なので、圧縮ファイル一つを開けば動きます。
九つの星の背景をこのページで飛んでみる
01 sf-perspective-warp-streaks
02 sf-parallax-depth-layers
03 sf-noise-twinkle-field
04 sf-depth-color-temperature
05 sf-hyperspace-jump-ease
06 sf-rotating-sky-dome
07 sf-constellation-links
08 sf-shooting-star-trail
09 sf-gravity-lens-field
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 近づくほど長く伸びるワープの星の筋
- 02 奥行きの層ごとに違う速さで流れる三重の星
- 03 ノイズの場が星ごとに違う瞬きを与える夜空
- 04 速さで青に寄り、縁で橙に伸びる星の光
- 05 止まった状態から光速へ飛び込むジャンプ曲線
- 06 球面上の星を回転行列で回して見る空のドーム
- 07 近い星同士が線でつながる星座
- 08 尾が薄れながら曲がって落ちる流れ星
- 09 ブラックホールの近くで星の光が輪に曲がる重力レンズ
css starfield 9パターン — 奥行き・回転・屈折を計算して星が生きる
まず断っておくと、この記事の九つの場面は CSS のキーフレームではなく、キャンバス2D と JavaScript で描いた星です。CSS が受け持つのは480×300のキャンバスの枠、キャプションとボタンの置き場、フォーカスの輪郭、touch-action: none までです。星が何百個も、それぞれ自分の距離で割った位置に打たれて初めて奥行きが出るので、その割り算はキーフレームに書き込むより、フレームごとにコードで計算するほうが筋に合います。
九つのファイルは start() を共通にしていて、16ミリ秒(FIXED_DT)刻みで scene.step() を呼び、ポインターが2.4秒(IDLE_TICKS の150)届かないと、場面ごとに決めた自動の曲線へ戻ります。乱数は Math.random ではなく種を持つ lcg なので、再読み込みしても星は同じ位置に並び、種は場面の番号に沿って450101から450909まで振ってあります。
| 場面 | 受ける入力 | 核になる計算 |
|---|---|---|
| 01 ワープの筋 | ポインター=消失点、タップ=速さの噴出 | 奥行き z で割る投影 |
| 02 三層の視差 | ポインター=視差の向き、タップ=流れの反転 | 層の深さで割った移動量 |
| 03 ノイズの夜空 | ポインターの横=ノイズの原点、タップ=種に1を足す | 二重の値ノイズ |
| 04 色温度 | ポインター=消失点、タップ=回転方向の反転 | 視線角のコサイン |
| 05 ジャンプ | タップ=ジャンプを最初から | 2の冪乗の加速曲線 |
| 06 天球 | ポインター=首振り、タップ=60°回す | yaw・pitch の回転行列 |
| 07 星座 | ポインター=星一つ、タップ=速さの引き直し | 格子マスの隣接探索 |
| 08 流れ星 | タップ=ポインターの位置から一発 | 位置の履歴と重力 |
| 09 重力レンズ | ポインター=レンズの中心、タップ=質量を1.9倍 | 点質量のレンズ方程式 |
05はポインターの動きを読まず、08はタップしたときのポインターの位置だけを使います。Math.random・Date・fetch といった呼び出しはソースを grep すると0件で、ライブラリ・CDN・WebGL もないため、星は外部のスクリプトなしで描かれます。
css starfield background を技法別のコードで見る
コード片は各場面の index.html から取った連続する行で、一片が25行を超えることはありません。最初から最後まで通したファイル全体は、後ろに出てくる圧縮ファイルに入っています。
01近づくほど長く伸びるワープの星の筋
星ごとに (x, y, z) を持たせ、画面の位置は消失点の vx・vy に x·FOV÷z を足して決めます。筋は今の z と speed·STREAK_LEN だけ奥の z という二つの投影点を結んだ線なので速さがそのまま長さになり、z が Z_NEAR の24を割るか画面の外へ80px はみ出した星は Z_FAR の1000へ置き直されます。測定は累積面積57.2%、動いたフレームは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;
}
.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;
}
.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 = '次の場面';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 FOV = 190;
const SPEED = 12;
const STREAK_LEN = 1.6;
const STAR_COUNT = 280;
const Z_FAR = 1000;
const Z_NEAR = 24;
const SPREAD = 760;
const BURST_TICKS = 70;
const BURST_GAIN = 2.4;
const AUTO_PERIOD = 380;
const TXT_CRUISE = '巡航 — 消失点がゆっくり回る';
const TXT_FOLLOW = '消失点がポインターに付いてくる';
const TXT_BURST = '加速の爆発 — 筋が長くなる';
function makeScene(inp, reduce) {
const rnd = lcg(450101);
const stars = [];
let vx = W / 2;
let vy = H / 2;
let t = 0;
let burst = 0;
let speed = SPEED;
let primed = false;
// 별 하나를 새로 놓는다. far 면 가장 먼 곳에서 시작한다
function place(s, far) {
s.x = (rnd() * 2 - 1) * SPREAD;
s.y = (rnd() * 2 - 1) * SPREAD * 0.65;
s.z = far ? Z_FAR : Z_NEAR + rnd() * (Z_FAR - Z_NEAR);
}
for (let i = 0; i < STAR_COUNT; i += 1) {
const s = { x: 0, y: 0, z: 0, c: STAR_RGBS[i % STAR_RGBS.length] };
place(s, false);
stars.push(s);
}
const scene = {
step() {
t += 1;
let tx = W / 2 + Math.cos(t * PI2 / AUTO_PERIOD) * 110;
let ty = H / 2 + Math.sin(t * PI2 * 2 / AUTO_PERIOD) * 56;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
vx = lerp(vx, tx, 0.06);
vy = lerp(vy, ty, 0.06);
if (burst > 0) {
burst -= 1;
}
speed = SPEED * (1 + (BURST_GAIN - 1) * smooth(burst / BURST_TICKS));
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
s.z -= speed;
const sx = vx + s.x * FOV / s.z;
const sy = vy + s.y * FOV / s.z;
if (s.z < Z_NEAR || sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
place(s, true);
}
}
},
draw(ctx) {
paintBg(ctx, primed ? 0.5 : 1);
primed = true;
glow(ctx, vx, vy, 90, [208, 196, 255], 0.2);
ctx.lineCap = 'round';
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const k = 1 - s.z / Z_FAR;
const zb = Math.min(s.z + speed * STREAK_LEN, Z_FAR * 1.3);
ctx.strokeStyle = rgba(s.c, 0.25 + 0.75 * k);
ctx.lineWidth = 0.6 + 2.6 * k * k;
ctx.beginPath();
ctx.moveTo(vx + s.x * FOV / zb, vy + s.y * FOV / zb);
ctx.lineTo(vx + s.x * FOV / s.z, vy + s.y * FOV / s.z);
ctx.stroke();
}
},
label() {
if (burst > 0) {
return TXT_BURST;
}
return inp.idle < IDLE_TICKS ? TXT_FOLLOW : TXT_CRUISE;
},
tap() {
burst = BURST_TICKS;
}
};
if (reduce) {
for (let i = 0; i < 12; 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 "./SfPerspectiveWarpStreaks.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 = '다음 장면';
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;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 FOV = 190;
const SPEED = 12;
const STREAK_LEN = 1.6;
const STAR_COUNT = 280;
const Z_FAR = 1000;
const Z_NEAR = 24;
const SPREAD = 760;
const BURST_TICKS = 70;
const BURST_GAIN = 2.4;
const AUTO_PERIOD = 380;
const TXT_CRUISE = '순항 — 소실점이 천천히 돈다';
const TXT_FOLLOW = '소실점이 포인터를 따라간다';
const TXT_BURST = '속도 폭발 — 줄기가 길어진다';
interface Star { x: number; y: number; z: number; c: RGB; }
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450101);
const stars: Star[] = [];
let vx = W / 2;
let vy = H / 2;
let t = 0;
let burst = 0;
let speed = SPEED;
let primed = false;
// 별 하나를 새로 놓는다. far 면 가장 먼 곳에서 시작한다
function place(s: Star, far: boolean): void {
s.x = (rnd() * 2 - 1) * SPREAD;
s.y = (rnd() * 2 - 1) * SPREAD * 0.65;
s.z = far ? Z_FAR : Z_NEAR + rnd() * (Z_FAR - Z_NEAR);
}
for (let i = 0; i < STAR_COUNT; i += 1) {
const s: Star = { x: 0, y: 0, z: 0, c: STAR_RGBS[i % STAR_RGBS.length] };
place(s, false);
stars.push(s);
}
const scene: Scene = {
step(): void {
t += 1;
let tx = W / 2 + Math.cos(t * PI2 / AUTO_PERIOD) * 110;
let ty = H / 2 + Math.sin(t * PI2 * 2 / AUTO_PERIOD) * 56;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
vx = lerp(vx, tx, 0.06);
vy = lerp(vy, ty, 0.06);
if (burst > 0) {
burst -= 1;
}
speed = SPEED * (1 + (BURST_GAIN - 1) * smooth(burst / BURST_TICKS));
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
s.z -= speed;
const sx = vx + s.x * FOV / s.z;
const sy = vy + s.y * FOV / s.z;
if (s.z < Z_NEAR || sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
place(s, true);
}
}
},
draw(ctx: CanvasRenderingContext2D): void {
paintBg(ctx, primed ? 0.5 : 1);
primed = true;
glow(ctx, vx, vy, 90, [208, 196, 255], 0.2);
ctx.lineCap = 'round';
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const k = 1 - s.z / Z_FAR;
const zb = Math.min(s.z + speed * STREAK_LEN, Z_FAR * 1.3);
ctx.strokeStyle = rgba(s.c, 0.25 + 0.75 * k);
ctx.lineWidth = 0.6 + 2.6 * k * k;
ctx.beginPath();
ctx.moveTo(vx + s.x * FOV / zb, vy + s.y * FOV / zb);
ctx.lineTo(vx + s.x * FOV / s.z, vy + s.y * FOV / s.z);
ctx.stroke();
}
},
label(): string {
if (burst > 0) { return TXT_BURST; }
return inp.idle < IDLE_TICKS ? TXT_FOLLOW : TXT_CRUISE;
},
tap(): void {
burst = BURST_TICKS;
}
};
if (reduce) {
for (let i = 0; i < 12; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfPerspectiveWarpStreaks() {
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 · 近づくほど長く伸びるワープの星の筋 — 計測 累積面積 57.23% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
s.z -= speed;
const sx = vx + s.x * FOV / s.z;
const sy = vy + s.y * FOV / s.z;
if (s.z < Z_NEAR || sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
place(s, true);
}
}
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const k = 1 - s.z / Z_FAR;
const zb = Math.min(s.z + speed * STREAK_LEN, Z_FAR * 1.3);
ctx.strokeStyle = rgba(s.c, 0.25 + 0.75 * k);
ctx.lineWidth = 0.6 + 2.6 * k * k;
ctx.beginPath();
ctx.moveTo(vx + s.x * FOV / zb, vy + s.y * FOV / zb);
ctx.lineTo(vx + s.x * FOV / s.z, vy + s.y * FOV / s.z);
ctx.stroke();
}
02奥行きの層ごとに違う速さで流れる三重の星
層の移動量は px·PARALLAX÷深さ + drift÷深さ で、深さが4・2・1の三層なので、ポインターを端まで押すと手前の層は70px、いちばん奥の層は17.5px ずれます。半径は DEPTH_SCALE÷深さ^0.8、明るさは 0.35 + 0.65÷√深さ で小さく暗くなり、にじみの光は深さ1の層にだけ付きます。測定は累積面積52.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;
}
.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;
}
.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 = '次の場面';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 LAYER_COUNT = 3;
const PARALLAX = 70;
const DRIFT = 0.9;
const DEPTH_SCALE = 2.4;
const LAYER_STARS = [110, 80, 50];
const LAYER_DEPTH = [4, 2, 1];
const TXT_AUTO = '自動の曲線 — 手前の層ほど大きく動く';
const TXT_POINTER = 'ポインターに合わせて三つの層がずれる';
function makeScene(inp, reduce) {
const rnd = lcg(450202);
const layers = [];
const mists = [];
for (let li = 0; li < LAYER_COUNT; li += 1) {
const d = LAYER_DEPTH[li];
const dots = [];
for (let i = 0; i < LAYER_STARS[li]; i += 1) {
dots.push({ x: rnd() * W, y: rnd() * H, r: DEPTH_SCALE / Math.pow(d, 0.8) * (0.55 + 0.9 * rnd()), c: STAR_RGBS[Math.floor(rnd() * STAR_RGBS.length)] });
}
layers.push(dots);
const ms = [];
for (let i = 0; i < 2; i += 1) {
ms.push({ x: rnd() * W, y: rnd() * H, r: 70 + 30 * rnd() + 18 * (LAYER_COUNT - li), c: STAR_RGBS[(li * 2 + i + 2) % STAR_RGBS.length] });
}
mists.push(ms);
}
let t = 0;
let dir = 1;
let drift = 0;
let px = 0;
let py = 0;
function wrap(v, m) {
return ((v % m) + m) % m;
}
const scene = {
step() {
t += 1;
drift += dir * DRIFT;
let tx = Math.sin(t * 0.017) * 0.95;
let ty = Math.sin(t * 0.011) * 0.8;
if (inp.idle < IDLE_TICKS) {
tx = (inp.x - W / 2) / (W / 2);
ty = (inp.y - H / 2) / (H / 2);
}
px = lerp(px, tx, 0.1);
py = lerp(py, ty, 0.1);
},
draw(ctx) {
paintBg(ctx, 1);
for (let li = 0; li < LAYER_COUNT; li += 1) {
const d = LAYER_DEPTH[li];
const ox = px * PARALLAX / d + drift / d;
const oy = py * PARALLAX * 0.6 / d;
const ms = mists[li];
for (let i = 0; i < ms.length; i += 1) {
glow(ctx, wrap(ms[i].x - ox, W + 160) - 80, wrap(ms[i].y - oy, H + 120) - 60, ms[i].r, ms[i].c, 0.07 + 0.05 / d);
}
const dots = layers[li];
const alpha = 0.35 + 0.65 / Math.sqrt(d);
for (let i = 0; i < dots.length; i += 1) {
const s = dots[i];
const sx = wrap(s.x - ox, W);
const sy = wrap(s.y - oy, H);
if (d === 1) {
glow(ctx, sx, sy, s.r * 4, s.c, 0.3);
}
ctx.fillStyle = rgba(s.c, alpha);
ctx.beginPath();
ctx.arc(sx, sy, s.r, 0, PI2);
ctx.fill();
}
}
},
label() {
return inp.idle < IDLE_TICKS ? TXT_POINTER : TXT_AUTO;
},
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 "./SfParallaxDepthLayers.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 = '다음 장면';
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;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 LAYER_COUNT = 3;
const PARALLAX = 70;
const DRIFT = 0.9;
const DEPTH_SCALE = 2.4;
const LAYER_STARS = [110, 80, 50];
const LAYER_DEPTH = [4, 2, 1];
const TXT_AUTO = '자동 곡선 — 앞 층이 더 크게 움직인다';
const TXT_POINTER = '포인터를 따라 세 층이 어긋난다';
interface Dot { x: number; y: number; r: number; c: RGB; }
interface Mist { x: number; y: number; r: number; c: RGB; }
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450202);
const layers: Dot[][] = [];
const mists: Mist[][] = [];
for (let li = 0; li < LAYER_COUNT; li += 1) {
const d = LAYER_DEPTH[li];
const dots: Dot[] = [];
for (let i = 0; i < LAYER_STARS[li]; i += 1) {
dots.push({ x: rnd() * W, y: rnd() * H, r: DEPTH_SCALE / Math.pow(d, 0.8) * (0.55 + 0.9 * rnd()), c: STAR_RGBS[Math.floor(rnd() * STAR_RGBS.length)] });
}
layers.push(dots);
const ms: Mist[] = [];
for (let i = 0; i < 2; i += 1) {
ms.push({ x: rnd() * W, y: rnd() * H, r: 70 + 30 * rnd() + 18 * (LAYER_COUNT - li), c: STAR_RGBS[(li * 2 + i + 2) % STAR_RGBS.length] });
}
mists.push(ms);
}
let t = 0;
let dir = 1;
let drift = 0;
let px = 0;
let py = 0;
function wrap(v: number, m: number): number {
return ((v % m) + m) % m;
}
const scene: Scene = {
step(): void {
t += 1;
drift += dir * DRIFT;
let tx = Math.sin(t * 0.017) * 0.95;
let ty = Math.sin(t * 0.011) * 0.8;
if (inp.idle < IDLE_TICKS) {
tx = (inp.x - W / 2) / (W / 2);
ty = (inp.y - H / 2) / (H / 2);
}
px = lerp(px, tx, 0.1);
py = lerp(py, ty, 0.1);
},
draw(ctx: CanvasRenderingContext2D): void {
paintBg(ctx, 1);
for (let li = 0; li < LAYER_COUNT; li += 1) {
const d = LAYER_DEPTH[li];
const ox = px * PARALLAX / d + drift / d;
const oy = py * PARALLAX * 0.6 / d;
const ms = mists[li];
for (let i = 0; i < ms.length; i += 1) {
glow(ctx, wrap(ms[i].x - ox, W + 160) - 80, wrap(ms[i].y - oy, H + 120) - 60, ms[i].r, ms[i].c, 0.07 + 0.05 / d);
}
const dots = layers[li];
const alpha = 0.35 + 0.65 / Math.sqrt(d);
for (let i = 0; i < dots.length; i += 1) {
const s = dots[i];
const sx = wrap(s.x - ox, W);
const sy = wrap(s.y - oy, H);
if (d === 1) {
glow(ctx, sx, sy, s.r * 4, s.c, 0.3);
}
ctx.fillStyle = rgba(s.c, alpha);
ctx.beginPath();
ctx.arc(sx, sy, s.r, 0, PI2);
ctx.fill();
}
}
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_POINTER : TXT_AUTO;
},
tap(): void {
dir = -dir;
}
};
if (reduce) {
for (let i = 0; i < 30; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfParallaxDepthLayers() {
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 · 奥行きの層ごとに違う速さで流れる三重の星 — 計測 累積面積 52.597% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let li = 0; li < LAYER_COUNT; li += 1) {
const d = LAYER_DEPTH[li];
const ox = px * PARALLAX / d + drift / d;
const oy = py * PARALLAX * 0.6 / d;
const ms = mists[li];
for (let i = 0; i < ms.length; i += 1) {
glow(ctx, wrap(ms[i].x - ox, W + 160) - 80, wrap(ms[i].y - oy, H + 120) - 60, ms[i].r, ms[i].c, 0.07 + 0.05 / d);
}
const dots = layers[li];
const alpha = 0.35 + 0.65 / Math.sqrt(d);
for (let i = 0; i < dots.length; i += 1) {
const s = dots[i];
const sx = wrap(s.x - ox, W);
const sy = wrap(s.y - oy, H);
if (d === 1) {
glow(ctx, sx, sy, s.r * 4, s.c, 0.3);
}
ctx.fillStyle = rgba(s.c, alpha);
ctx.beginPath();
ctx.arc(sx, sy, s.r, 0, PI2);
ctx.fill();
}
}
03ノイズの場が星ごとに違う瞬きを与える夜空
星360個の明るさは、ゆっくりした値ノイズ(座標に NOISE_SCALE の0.011を掛けたもの)と速い値ノイズ(座標に0.5を掛けたもの)を0.65対0.35で混ぜ、CONTRAST の2.6乗にした値なので、近い星はまとまって、遠い星は別々に瞬きます。奥の霧は60×38ピクセルの画像を同じノイズで埋めて480×300へ引き伸ばしたもので、九つのうちこの場面だけが createImageData を使います。測定は累積面積69.4%、動いたフレームは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;
}
.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;
}
.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 = '次の場面';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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_SCALE = 0.011;
const TWINKLE_SPEED = 0.035;
const CONTRAST = 2.6;
const SEED = 7;
const STAR_TOTAL = 360;
const NEBULA_W = 60;
const NEBULA_H = 38;
const TXT_FIELD = 'ノイズの場 — 隣り合う星が一緒に揺れる';
const TXT_RESEED = '新しいシード — 同じ式、違う空';
// 격자 점의 값 — 정수 두 개와 시드를 섞어 0 이상 1 미만으로
function hash2(ix, iy, seed) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263) + Math.imul(seed | 0, 1442695041);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 노이즈 — 네 격자 점을 부드럽게 보간
function vnoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy, seed), hash2(ix + 1, iy, seed), fx), lerp(hash2(ix, iy + 1, seed), hash2(ix + 1, iy + 1, seed), fx), fy);
}
function makeScene(inp, reduce) {
const rnd = lcg(450303);
const sparks = [];
for (let i = 0; i < STAR_TOTAL; i += 1) {
sparks.push({ x: rnd() * W, y: rnd() * H, r: 0.6 + 1.5 * rnd() * rnd(), c: STAR_RGBS[Math.floor(rnd() * STAR_RGBS.length)] });
}
const fog = document.createElement('canvas');
fog.width = NEBULA_W;
fog.height = NEBULA_H;
const fogCtx = fog.getContext('2d');
let seed = SEED;
let t = 0;
let reseedLeft = 0;
let ox = 0;
function sky() {
return inp.idle < IDLE_TICKS ? (inp.x - W / 2) * 0.03 : Math.sin(t * 0.01) * 6;
}
const scene = {
step() {
t += 1;
ox = lerp(ox, sky(), 0.08);
if (reseedLeft > 0) {
reseedLeft -= 1;
}
},
draw(ctx) {
paintBg(ctx, 1);
const phase = t * TWINKLE_SPEED;
if (fogCtx) {
const img = fogCtx.createImageData(NEBULA_W, NEBULA_H);
for (let py = 0; py < NEBULA_H; py += 1) {
for (let px = 0; px < NEBULA_W; px += 1) {
const n1 = vnoise(px * 0.09 + phase * 0.6 + ox * 0.1, py * 0.09 - phase * 0.3, seed + 3);
const n2 = vnoise(px * 0.2 - phase, py * 0.2 + phase * 0.5, seed + 4);
const m = clamp(n1 * 0.75 + n2 * 0.35 - 0.2, 0, 1);
const c = mix([120, 168, 232], [186, 150, 255], vnoise(px * 0.05, py * 0.05, seed + 5));
const o = (py * NEBULA_W + px) * 4;
img.data[o] = c[0];
img.data[o + 1] = c[1];
img.data[o + 2] = c[2];
img.data[o + 3] = Math.round(255 * Math.pow(m, 1.6) * 0.75);
}
}
fogCtx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(fog, 0, 0, W, H);
}
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
// 느린 장(이웃이 닮는다)과 빠른 장(별마다 다르다)을 섞어 밝기를 정한다
const slow = vnoise(s.x * NOISE_SCALE + ox * 0.05, s.y * NOISE_SCALE + phase, seed);
const fast = vnoise(s.x * 0.5 + phase * 7, s.y * 0.5, seed + 1);
const v = slow * 0.65 + fast * 0.35;
const b = clamp(Math.pow(v * 1.6, CONTRAST), 0, 1);
if (b > 0.55) {
glow(ctx, s.x, s.y, 7 * s.r, s.c, 0.4 * b);
}
ctx.fillStyle = rgba(s.c, 0.12 + 0.88 * b);
ctx.beginPath();
ctx.arc(s.x, s.y, s.r * (0.7 + 0.5 * b), 0, PI2);
ctx.fill();
}
},
label() {
return reseedLeft > 0 ? TXT_RESEED : TXT_FIELD;
},
tap() {
seed += 1;
reseedLeft = 90;
}
};
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 "./SfNoiseTwinkleField.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 = '다음 장면';
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;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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_SCALE = 0.011;
const TWINKLE_SPEED = 0.035;
const CONTRAST = 2.6;
const SEED = 7;
const STAR_TOTAL = 360;
const NEBULA_W = 60;
const NEBULA_H = 38;
const TXT_FIELD = '노이즈 장 — 이웃 별이 함께 번진다';
const TXT_RESEED = '새 시드 — 같은 식, 다른 하늘';
interface Spark { x: number; y: number; r: number; c: RGB; }
// 격자 점의 값 — 정수 두 개와 시드를 섞어 0 이상 1 미만으로
function hash2(ix: number, iy: number, seed: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263) + Math.imul(seed | 0, 1442695041);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 노이즈 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy, seed), hash2(ix + 1, iy, seed), fx), lerp(hash2(ix, iy + 1, seed), hash2(ix + 1, iy + 1, seed), fx), fy);
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450303);
const sparks: Spark[] = [];
for (let i = 0; i < STAR_TOTAL; i += 1) {
sparks.push({ x: rnd() * W, y: rnd() * H, r: 0.6 + 1.5 * rnd() * rnd(), c: STAR_RGBS[Math.floor(rnd() * STAR_RGBS.length)] });
}
const fog = document.createElement('canvas');
fog.width = NEBULA_W;
fog.height = NEBULA_H;
const fogCtx = fog.getContext('2d');
let seed = SEED;
let t = 0;
let reseedLeft = 0;
let ox = 0;
function sky(): number {
return inp.idle < IDLE_TICKS ? (inp.x - W / 2) * 0.03 : Math.sin(t * 0.01) * 6;
}
const scene: Scene = {
step(): void {
t += 1;
ox = lerp(ox, sky(), 0.08);
if (reseedLeft > 0) {
reseedLeft -= 1;
}
},
draw(ctx: CanvasRenderingContext2D): void {
paintBg(ctx, 1);
const phase = t * TWINKLE_SPEED;
if (fogCtx) {
const img = fogCtx.createImageData(NEBULA_W, NEBULA_H);
for (let py = 0; py < NEBULA_H; py += 1) {
for (let px = 0; px < NEBULA_W; px += 1) {
const n1 = vnoise(px * 0.09 + phase * 0.6 + ox * 0.1, py * 0.09 - phase * 0.3, seed + 3);
const n2 = vnoise(px * 0.2 - phase, py * 0.2 + phase * 0.5, seed + 4);
const m = clamp(n1 * 0.75 + n2 * 0.35 - 0.2, 0, 1);
const c = mix([120, 168, 232], [186, 150, 255], vnoise(px * 0.05, py * 0.05, seed + 5));
const o = (py * NEBULA_W + px) * 4;
img.data[o] = c[0];
img.data[o + 1] = c[1];
img.data[o + 2] = c[2];
img.data[o + 3] = Math.round(255 * Math.pow(m, 1.6) * 0.75);
}
}
fogCtx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(fog, 0, 0, W, H);
}
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
// 느린 장(이웃이 닮는다)과 빠른 장(별마다 다르다)을 섞어 밝기를 정한다
const slow = vnoise(s.x * NOISE_SCALE + ox * 0.05, s.y * NOISE_SCALE + phase, seed);
const fast = vnoise(s.x * 0.5 + phase * 7, s.y * 0.5, seed + 1);
const v = slow * 0.65 + fast * 0.35;
const b = clamp(Math.pow(v * 1.6, CONTRAST), 0, 1);
if (b > 0.55) {
glow(ctx, s.x, s.y, 7 * s.r, s.c, 0.4 * b);
}
ctx.fillStyle = rgba(s.c, 0.12 + 0.88 * b);
ctx.beginPath();
ctx.arc(s.x, s.y, s.r * (0.7 + 0.5 * b), 0, PI2);
ctx.fill();
}
},
label(): string {
return reseedLeft > 0 ? TXT_RESEED : TXT_FIELD;
},
tap(): void {
seed += 1;
reseedLeft = 90;
}
};
if (reduce) {
for (let i = 0; i < 40; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfNoiseTwinkleField() {
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 · ノイズの場が星ごとに違う瞬きを与える夜空 — 計測 累積面積 69.392% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 格子点の値 — 整数二つとシードを混ぜて 0 以上 1 未満にする
function hash2(ix, iy, seed) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263) + Math.imul(seed | 0, 1442695041);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 二次元の値ノイズ — 四つの格子点をなめらかに補間する
function vnoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy, seed), hash2(ix + 1, iy, seed), fx), lerp(hash2(ix, iy + 1, seed), hash2(ix + 1, iy + 1, seed), fx), fy);
}
// 遅い場(隣が似る)と速い場(星ごとに違う)を混ぜて明るさを決める
const slow = vnoise(s.x * NOISE_SCALE + ox * 0.05, s.y * NOISE_SCALE + phase, seed);
const fast = vnoise(s.x * 0.5 + phase * 7, s.y * 0.5, seed + 1);
const v = slow * 0.65 + fast * 0.35;
const b = clamp(Math.pow(v * 1.6, CONTRAST), 0, 1);
04速さで青に寄り、縁で橙に伸びる星の光
消失点から画面上で距離 r にある星の視線角のコサイン FOV÷√(r²+FOV²) を色温度 k に入れるので、中央は青白く、離れるほど橙へ傾きます。k は COLOR_TEMP の0.5を起点に SHIFT_GAIN·(cosA − CONE_COS)·(0.5 + 速度比) だけ動き、速度比が正弦曲線で上下するため、偏りも呼吸するように変わります。測定は累積面積62.4%、動いたフレームは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;
}
.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;
}
.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 = '次の場面';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 COLOR_TEMP = 0.5;
const SHIFT_GAIN = 2.2;
const HEADING = 0.014;
const CONE_COS = 0.72;
const FOV = 150;
const SPEED_BASE = 9;
const STAR_COUNT = 240;
const Z_FAR = 900;
const Z_NEAR = 22;
const SPREAD = 640;
const WARM = [255, 184, 132];
const WHITE = [236, 240, 255];
const COOL = [168, 200, 255];
const TXT_SPLIT = '前方は青白、縁は橙';
const TXT_FAST = '加速中 — 青への偏りが大きくなる';
// 0 은 주황, 0.5 는 흰빛, 1 은 청백
function temperature(k) {
return k < 0.5 ? mix(WARM, WHITE, k * 2) : mix(WHITE, COOL, (k - 0.5) * 2);
}
function makeScene(inp, reduce) {
const rnd = lcg(450404);
const stars = [];
let vx = W / 2;
let vy = H / 2;
let theta = 0;
let turn = 1;
let t = 0;
let ratio = 0.6;
let speed = SPEED_BASE;
let primed = false;
function place(s, far) {
s.x = (rnd() * 2 - 1) * SPREAD;
s.y = (rnd() * 2 - 1) * SPREAD * 0.65;
s.z = far ? Z_FAR : Z_NEAR + rnd() * (Z_FAR - Z_NEAR);
}
for (let i = 0; i < STAR_COUNT; i += 1) {
const s = { x: 0, y: 0, z: 0 };
place(s, false);
stars.push(s);
}
const scene = {
step() {
t += 1;
theta += HEADING * turn;
let tx = W / 2 + Math.cos(theta) * 120;
let ty = H / 2 + Math.sin(theta) * 50;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
vx = lerp(vx, tx, 0.07);
vy = lerp(vy, ty, 0.07);
ratio = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(t * 0.04));
speed = SPEED_BASE * (0.5 + ratio);
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
s.z -= speed;
const sx = vx + s.x * FOV / s.z;
const sy = vy + s.y * FOV / s.z;
if (s.z < Z_NEAR || sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
place(s, true);
}
}
},
draw(ctx) {
paintBg(ctx, primed ? 0.45 : 1);
primed = true;
glow(ctx, vx, vy, 110, temperature(clamp(COLOR_TEMP + 0.4 * (ratio - 0.5), 0, 1)), 0.22);
ctx.lineCap = 'round';
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const sx = vx + s.x * FOV / s.z;
const sy = vy + s.y * FOV / s.z;
const r = Math.hypot(sx - vx, sy - vy);
// 시선과 별 방향 사이 각도의 코사인 — 소실점에서 멀수록 작다
const cosA = FOV / Math.hypot(r, FOV);
const k = clamp(COLOR_TEMP + SHIFT_GAIN * (cosA - CONE_COS) * (0.5 + ratio), 0, 1);
const c = temperature(k);
const near = 1 - s.z / Z_FAR;
const zb = Math.min(s.z + speed * 1.2, Z_FAR * 1.3);
const a = (0.3 + 0.7 * cosA * cosA) * (0.3 + 0.7 * near);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = 0.8 + 2.4 * near * near;
ctx.beginPath();
ctx.moveTo(vx + s.x * FOV / zb, vy + s.y * FOV / zb);
ctx.lineTo(sx, sy);
ctx.stroke();
if (near > 0.55) {
glow(ctx, sx, sy, 3 + 9 * near, c, 0.32 * a);
}
}
},
label() {
return ratio > 0.8 ? TXT_FAST : TXT_SPLIT;
},
tap() {
turn = -turn;
}
};
if (reduce) {
for (let i = 0; i < 12; 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 "./SfDepthColorTemperature.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 = '다음 장면';
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;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 COLOR_TEMP = 0.5;
const SHIFT_GAIN = 2.2;
const HEADING = 0.014;
const CONE_COS = 0.72;
const FOV = 150;
const SPEED_BASE = 9;
const STAR_COUNT = 240;
const Z_FAR = 900;
const Z_NEAR = 22;
const SPREAD = 640;
const WARM: RGB = [255, 184, 132];
const WHITE: RGB = [236, 240, 255];
const COOL: RGB = [168, 200, 255];
const TXT_SPLIT = '앞쪽은 청백, 가장자리는 주황';
const TXT_FAST = '가속 중 — 푸른 쏠림이 커진다';
interface Star { x: number; y: number; z: number; }
// 0 은 주황, 0.5 는 흰빛, 1 은 청백
function temperature(k: number): RGB {
return k < 0.5 ? mix(WARM, WHITE, k * 2) : mix(WHITE, COOL, (k - 0.5) * 2);
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450404);
const stars: Star[] = [];
let vx = W / 2;
let vy = H / 2;
let theta = 0;
let turn = 1;
let t = 0;
let ratio = 0.6;
let speed = SPEED_BASE;
let primed = false;
function place(s: Star, far: boolean): void {
s.x = (rnd() * 2 - 1) * SPREAD;
s.y = (rnd() * 2 - 1) * SPREAD * 0.65;
s.z = far ? Z_FAR : Z_NEAR + rnd() * (Z_FAR - Z_NEAR);
}
for (let i = 0; i < STAR_COUNT; i += 1) {
const s: Star = { x: 0, y: 0, z: 0 };
place(s, false);
stars.push(s);
}
const scene: Scene = {
step(): void {
t += 1;
theta += HEADING * turn;
let tx = W / 2 + Math.cos(theta) * 120;
let ty = H / 2 + Math.sin(theta) * 50;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
vx = lerp(vx, tx, 0.07);
vy = lerp(vy, ty, 0.07);
ratio = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(t * 0.04));
speed = SPEED_BASE * (0.5 + ratio);
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
s.z -= speed;
const sx = vx + s.x * FOV / s.z;
const sy = vy + s.y * FOV / s.z;
if (s.z < Z_NEAR || sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
place(s, true);
}
}
},
draw(ctx: CanvasRenderingContext2D): void {
paintBg(ctx, primed ? 0.45 : 1);
primed = true;
glow(ctx, vx, vy, 110, temperature(clamp(COLOR_TEMP + 0.4 * (ratio - 0.5), 0, 1)), 0.22);
ctx.lineCap = 'round';
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const sx = vx + s.x * FOV / s.z;
const sy = vy + s.y * FOV / s.z;
const r = Math.hypot(sx - vx, sy - vy);
// 시선과 별 방향 사이 각도의 코사인 — 소실점에서 멀수록 작다
const cosA = FOV / Math.hypot(r, FOV);
const k = clamp(COLOR_TEMP + SHIFT_GAIN * (cosA - CONE_COS) * (0.5 + ratio), 0, 1);
const c = temperature(k);
const near = 1 - s.z / Z_FAR;
const zb = Math.min(s.z + speed * 1.2, Z_FAR * 1.3);
const a = (0.3 + 0.7 * cosA * cosA) * (0.3 + 0.7 * near);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = 0.8 + 2.4 * near * near;
ctx.beginPath();
ctx.moveTo(vx + s.x * FOV / zb, vy + s.y * FOV / zb);
ctx.lineTo(sx, sy);
ctx.stroke();
if (near > 0.55) {
glow(ctx, sx, sy, 3 + 9 * near, c, 0.32 * a);
}
}
},
label(): string {
return ratio > 0.8 ? TXT_FAST : TXT_SPLIT;
},
tap(): void {
turn = -turn;
}
};
if (reduce) {
for (let i = 0; i < 12; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfDepthColorTemperature() {
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 · 速さで青に寄り、縁で橙に伸びる星の光 — 計測 累積面積 62.376% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 0 は橙、0.5 は白、1 は青白
function temperature(k) {
return k < 0.5 ? mix(WARM, WHITE, k * 2) : mix(WHITE, COOL, (k - 0.5) * 2);
}
const sx = vx + s.x * FOV / s.z;
const sy = vy + s.y * FOV / s.z;
const r = Math.hypot(sx - vx, sy - vy);
// 視線と星の方向のあいだの角度のコサイン — 消失点から遠いほど小さい
const cosA = FOV / Math.hypot(r, FOV);
const k = clamp(COLOR_TEMP + SHIFT_GAIN * (cosA - CONE_COS) * (0.5 + ratio), 0, 1);
const c = temperature(k);
05止まった状態から光速へ飛び込むジャンプ曲線
一周期は待機18・加速72・閃光24・到着56ティックの合計170ティック(2.72秒)で、加速区間の速さは BASE_SPEED から MAX_SPEED まで (2^(JUMP_EASE·p) − 1)÷(2^JUMP_EASE − 1) の割合で上がります。筋の長さも同じ speed から出し、閃光の透明度も FLASH·(1 − q)² という一本の式に結ばれています。測定は累積面積75.4%、動いたフレームは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;
}
.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;
}
.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 = '次の場面';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 JUMP_EASE = 6;
const MAX_SPEED = 70;
const FLASH = 1;
const HOLD = 18;
const RAMP = 72;
const FLASH_TICKS = 24;
const SETTLE = 56;
const BASE_SPEED = 1.6;
const STREAK = 1.1;
const FOV = 170;
const STAR_COUNT = 260;
const Z_FAR = 1000;
const Z_NEAR = 20;
const SPREAD = 700;
const CYCLE = HOLD + RAMP + FLASH_TICKS + SETTLE;
const TXT_HOLD = '静止 — ジャンプの準備';
const TXT_RAMP = '加速 — 指数曲線で上がる';
const TXT_FLASH = 'ジャンプ — 閃光';
const TXT_SETTLE = '到着 — 速さが戻る';
// t 틱 시점의 속도. 올라갈 때만 2 의 거듭제곱 곡선이고 나머지는 부드러운 복귀
function speedAt(t) {
if (t < HOLD) {
return BASE_SPEED;
}
if (t < HOLD + RAMP) {
const p = (t - HOLD) / RAMP;
return BASE_SPEED + (MAX_SPEED - BASE_SPEED) * (Math.pow(2, JUMP_EASE * p) - 1) / (Math.pow(2, JUMP_EASE) - 1);
}
if (t < HOLD + RAMP + FLASH_TICKS) {
const q = (t - HOLD - RAMP) / FLASH_TICKS;
return lerp(MAX_SPEED, BASE_SPEED, smooth(q));
}
return BASE_SPEED;
}
function flashAt(t) {
const q = (t - HOLD - RAMP) / FLASH_TICKS;
if (q < 0 || q > 1) {
return 0;
}
return FLASH * (1 - q) * (1 - q);
}
function makeScene(inp, reduce) {
const rnd = lcg(450505);
const stars = [];
let t = 0;
let speed = BASE_SPEED;
let primed = false;
function place(s, far) {
s.x = (rnd() * 2 - 1) * SPREAD;
s.y = (rnd() * 2 - 1) * SPREAD * 0.65;
s.z = far ? Z_FAR : Z_NEAR + rnd() * (Z_FAR - Z_NEAR);
}
for (let i = 0; i < STAR_COUNT; i += 1) {
const s = { x: 0, y: 0, z: 0, c: STAR_RGBS[i % STAR_RGBS.length] };
place(s, false);
stars.push(s);
}
const scene = {
step() {
t = (t + 1) % CYCLE;
speed = speedAt(t);
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
s.z -= speed;
const sx = W / 2 + s.x * FOV / s.z;
const sy = H / 2 + s.y * FOV / s.z;
if (s.z < Z_NEAR || sx < -100 || sx > W + 100 || sy < -100 || sy > H + 100) {
place(s, true);
}
}
},
draw(ctx) {
paintBg(ctx, primed ? (speed > 12 ? 0.55 : 1) : 1);
primed = true;
ctx.lineCap = 'round';
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const k = 1 - s.z / Z_FAR;
// 줄기 길이도 같은 speed 값에서 나온다
const zb = Math.min(s.z + speed * STREAK, Z_FAR * 1.5);
ctx.strokeStyle = rgba(s.c, 0.3 + 0.7 * k);
ctx.lineWidth = 0.7 + 2.4 * k * k;
ctx.beginPath();
ctx.moveTo(W / 2 + s.x * FOV / zb, H / 2 + s.y * FOV / zb);
ctx.lineTo(W / 2 + s.x * FOV / s.z, H / 2 + s.y * FOV / s.z);
ctx.stroke();
}
const f = flashAt(t);
const ramp = clamp((speed - BASE_SPEED) / (MAX_SPEED - BASE_SPEED), 0, 1);
if (ramp > 0.05) {
glow(ctx, W / 2, H / 2, 40 + 200 * ramp, [208, 196, 255], 0.45 * ramp);
}
if (f > 0) {
ctx.fillStyle = rgba([244, 240, 255], 0.9 * f);
ctx.fillRect(0, 0, W, H);
glow(ctx, W / 2, H / 2, 260, [255, 255, 255], f);
}
},
label() {
if (t < HOLD) {
return TXT_HOLD;
}
if (t < HOLD + RAMP) {
return TXT_RAMP;
}
if (t < HOLD + RAMP + FLASH_TICKS) {
return TXT_FLASH;
}
return TXT_SETTLE;
},
tap() {
t = HOLD;
}
};
if (reduce) {
t = HOLD + 50;
for (let i = 0; i < 6; 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 "./SfHyperspaceJumpEase.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 = '다음 장면';
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;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 JUMP_EASE = 6;
const MAX_SPEED = 70;
const FLASH = 1;
const HOLD = 18;
const RAMP = 72;
const FLASH_TICKS = 24;
const SETTLE = 56;
const BASE_SPEED = 1.6;
const STREAK = 1.1;
const FOV = 170;
const STAR_COUNT = 260;
const Z_FAR = 1000;
const Z_NEAR = 20;
const SPREAD = 700;
const CYCLE = HOLD + RAMP + FLASH_TICKS + SETTLE;
const TXT_HOLD = '정지 — 점프 준비';
const TXT_RAMP = '가속 — 지수 곡선으로 오른다';
const TXT_FLASH = '점프 — 섬광';
const TXT_SETTLE = '도착 — 속도가 돌아온다';
interface Star { x: number; y: number; z: number; c: RGB; }
// t 틱 시점의 속도. 올라갈 때만 2 의 거듭제곱 곡선이고 나머지는 부드러운 복귀
function speedAt(t: number): number {
if (t < HOLD) {
return BASE_SPEED;
}
if (t < HOLD + RAMP) {
const p = (t - HOLD) / RAMP;
return BASE_SPEED + (MAX_SPEED - BASE_SPEED) * (Math.pow(2, JUMP_EASE * p) - 1) / (Math.pow(2, JUMP_EASE) - 1);
}
if (t < HOLD + RAMP + FLASH_TICKS) {
const q = (t - HOLD - RAMP) / FLASH_TICKS;
return lerp(MAX_SPEED, BASE_SPEED, smooth(q));
}
return BASE_SPEED;
}
function flashAt(t: number): number {
const q = (t - HOLD - RAMP) / FLASH_TICKS;
if (q < 0 || q > 1) {
return 0;
}
return FLASH * (1 - q) * (1 - q);
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450505);
const stars: Star[] = [];
let t = 0;
let speed = BASE_SPEED;
let primed = false;
function place(s: Star, far: boolean): void {
s.x = (rnd() * 2 - 1) * SPREAD;
s.y = (rnd() * 2 - 1) * SPREAD * 0.65;
s.z = far ? Z_FAR : Z_NEAR + rnd() * (Z_FAR - Z_NEAR);
}
for (let i = 0; i < STAR_COUNT; i += 1) {
const s: Star = { x: 0, y: 0, z: 0, c: STAR_RGBS[i % STAR_RGBS.length] };
place(s, false);
stars.push(s);
}
const scene: Scene = {
step(): void {
t = (t + 1) % CYCLE;
speed = speedAt(t);
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
s.z -= speed;
const sx = W / 2 + s.x * FOV / s.z;
const sy = H / 2 + s.y * FOV / s.z;
if (s.z < Z_NEAR || sx < -100 || sx > W + 100 || sy < -100 || sy > H + 100) {
place(s, true);
}
}
},
draw(ctx: CanvasRenderingContext2D): void {
paintBg(ctx, primed ? (speed > 12 ? 0.55 : 1) : 1);
primed = true;
ctx.lineCap = 'round';
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const k = 1 - s.z / Z_FAR;
// 줄기 길이도 같은 speed 값에서 나온다
const zb = Math.min(s.z + speed * STREAK, Z_FAR * 1.5);
ctx.strokeStyle = rgba(s.c, 0.3 + 0.7 * k);
ctx.lineWidth = 0.7 + 2.4 * k * k;
ctx.beginPath();
ctx.moveTo(W / 2 + s.x * FOV / zb, H / 2 + s.y * FOV / zb);
ctx.lineTo(W / 2 + s.x * FOV / s.z, H / 2 + s.y * FOV / s.z);
ctx.stroke();
}
const f = flashAt(t);
const ramp = clamp((speed - BASE_SPEED) / (MAX_SPEED - BASE_SPEED), 0, 1);
if (ramp > 0.05) {
glow(ctx, W / 2, H / 2, 40 + 200 * ramp, [208, 196, 255], 0.45 * ramp);
}
if (f > 0) {
ctx.fillStyle = rgba([244, 240, 255], 0.9 * f);
ctx.fillRect(0, 0, W, H);
glow(ctx, W / 2, H / 2, 260, [255, 255, 255], f);
}
},
label(): string {
if (t < HOLD) { return TXT_HOLD; }
if (t < HOLD + RAMP) { return TXT_RAMP; }
if (t < HOLD + RAMP + FLASH_TICKS) { return TXT_FLASH; }
return TXT_SETTLE;
},
tap(): void {
t = HOLD;
}
};
if (reduce) {
t = HOLD + 50;
for (let i = 0; i < 6; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfHyperspaceJumpEase() {
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 · 止まった状態から光速へ飛び込むジャンプ曲線 — 計測 累積面積 75.433% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// t ティック時点の速さ。上がるときだけ 2 の冪乗の曲線で、残りはなめらかな復帰
function speedAt(t) {
if (t < HOLD) {
return BASE_SPEED;
}
if (t < HOLD + RAMP) {
const p = (t - HOLD) / RAMP;
return BASE_SPEED + (MAX_SPEED - BASE_SPEED) * (Math.pow(2, JUMP_EASE * p) - 1) / (Math.pow(2, JUMP_EASE) - 1);
}
if (t < HOLD + RAMP + FLASH_TICKS) {
const q = (t - HOLD - RAMP) / FLASH_TICKS;
return lerp(MAX_SPEED, BASE_SPEED, smooth(q));
}
return BASE_SPEED;
}
function flashAt(t) {
const q = (t - HOLD - RAMP) / FLASH_TICKS;
if (q < 0 || q > 1) {
return 0;
}
return FLASH * (1 - q) * (1 - q);
}
06球面上の星を回転行列で回して見る空のドーム
星900個を球面上に均等に、空を横切る明るい帯の星520個を傾いた大円の周りに集めておき、project が yaw の行列と pitch の行列を順に掛けてから W/2 + FOV·x÷z で点を打ちます。z が NEAR_CUT の0.08以下の点は視野の外なので描かず、緯線5本と経線10本は点だけで描いて、回る向きが見えるようにしています。測定は累積面積56.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;
}
.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;
}
.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 = '次の場面';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 YAW_RATE = 0.012;
const PITCH = 0.38;
const FOV = 150;
const STAR_COUNT = 900;
const BAND_STARS = 520;
const GRID_STEP = 72;
const NEAR_CUT = 0.08;
const TXT_AUTO = '空のドームがゆっくり回る';
const TXT_LOOK = 'ポインターの方へ首を向ける';
function makeScene(inp, reduce) {
const rnd = lcg(450606);
const stars = [];
// 구면 위 고른 분포 — 높이 z 를 -1 이상 1 이하에서 고르게, 방위각도 고르게
for (let i = 0; i < STAR_COUNT; i += 1) {
const zz = rnd() * 2 - 1;
const ph = rnd() * PI2;
const rr = Math.sqrt(1 - zz * zz);
stars.push({ x: rr * Math.cos(ph), y: zz, z: rr * Math.sin(ph), m: rnd(), c: STAR_RGBS[i % STAR_RGBS.length] });
}
// 은하수 띠 — 기울어진 큰 원 둘레에 좁게 모은 별
const tilt = 0.6;
for (let i = 0; i < BAND_STARS; i += 1) {
const ph = rnd() * PI2;
const lat = (rnd() + rnd() + rnd() - 1.5) * 0.2;
const bx = Math.cos(lat) * Math.cos(ph);
const by = Math.sin(lat);
const bz = Math.cos(lat) * Math.sin(ph);
stars.push({ x: bx, y: by * Math.cos(tilt) - bz * Math.sin(tilt), z: by * Math.sin(tilt) + bz * Math.cos(tilt), m: rnd() * 0.5, c: STAR_RGBS[2] });
}
let yaw = 0;
let pitch = PITCH;
let t = 0;
let look = 0;
let lookP = 0;
function project(x, y, z, cy, sy, cp, sp) {
const x1 = x * cy + z * sy;
const z1 = -x * sy + z * cy;
const y2 = y * cp - z1 * sp;
const z2 = y * sp + z1 * cp;
return [W / 2 + FOV * x1 / z2, H / 2 - FOV * y2 / z2, z2];
}
const scene = {
step() {
t += 1;
let ly = 0;
let lp = 0.22 * Math.sin(t * 0.008);
if (inp.idle < IDLE_TICKS) {
ly = (inp.x - W / 2) / W * 1.6;
lp = (H / 2 - inp.y) / H * 0.9;
}
look = lerp(look, ly, 0.06);
lookP = lerp(lookP, lp, 0.06);
yaw += YAW_RATE;
pitch = PITCH + lookP;
},
draw(ctx) {
paintBg(ctx, 1);
const cyw = Math.cos(yaw + look);
const syw = Math.sin(yaw + look);
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
// 위도선과 경선 — 점으로만 그려 돌아가는 방향이 보이게
ctx.fillStyle = rgba([208, 196, 255], 0.55);
for (let la = -60; la <= 60; la += 30) {
const rl = Math.cos(la * Math.PI / 180);
const yl = Math.sin(la * Math.PI / 180);
for (let a = 0; a < 360; a += 5) {
const p = project(rl * Math.cos(a * Math.PI / 180), yl, rl * Math.sin(a * Math.PI / 180), cyw, syw, cp, sp);
if (p[2] > NEAR_CUT) {
ctx.fillRect(p[0] - 0.9, p[1] - 0.9, 1.9, 1.9);
}
}
}
for (let me = 0; me < 360; me += GRID_STEP / 2) {
for (let a = -80; a <= 80; a += 5) {
const rl = Math.cos(a * Math.PI / 180);
const p = project(rl * Math.cos(me * Math.PI / 180), Math.sin(a * Math.PI / 180), rl * Math.sin(me * Math.PI / 180), cyw, syw, cp, sp);
if (p[2] > NEAR_CUT) {
ctx.fillRect(p[0] - 0.9, p[1] - 0.9, 1.9, 1.9);
}
}
}
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const p = project(s.x, s.y, s.z, cyw, syw, cp, sp);
if (p[2] <= NEAR_CUT || p[0] < -6 || p[0] > W + 6 || p[1] < -6 || p[1] > H + 6) {
continue;
}
const r = 0.5 + 1.9 * s.m * s.m * s.m + (i < STAR_COUNT ? 0.3 : 0);
const a = 0.35 + 0.65 * clamp(p[2] * 1.2, 0, 1);
if (r > 1.5) {
glow(ctx, p[0], p[1], r * 5, s.c, 0.35 * a);
}
ctx.fillStyle = rgba(s.c, a);
ctx.beginPath();
ctx.arc(p[0], p[1], r, 0, PI2);
ctx.fill();
}
},
label() {
return inp.idle < IDLE_TICKS ? TXT_LOOK : TXT_AUTO;
},
tap() {
yaw += Math.PI / 3;
}
};
if (reduce) {
for (let i = 0; i < 60; 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 "./SfRotatingSkyDome.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 = '다음 장면';
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;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 YAW_RATE = 0.012;
const PITCH = 0.38;
const FOV = 150;
const STAR_COUNT = 900;
const BAND_STARS = 520;
const GRID_STEP = 72;
const NEAR_CUT = 0.08;
const TXT_AUTO = '하늘 돔이 천천히 돈다';
const TXT_LOOK = '포인터 방향으로 고개를 돌린다';
interface Sky { x: number; y: number; z: number; m: number; c: RGB; }
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450606);
const stars: Sky[] = [];
// 구면 위 고른 분포 — 높이 z 를 -1 이상 1 이하에서 고르게, 방위각도 고르게
for (let i = 0; i < STAR_COUNT; i += 1) {
const zz = rnd() * 2 - 1;
const ph = rnd() * PI2;
const rr = Math.sqrt(1 - zz * zz);
stars.push({ x: rr * Math.cos(ph), y: zz, z: rr * Math.sin(ph), m: rnd(), c: STAR_RGBS[i % STAR_RGBS.length] });
}
// 은하수 띠 — 기울어진 큰 원 둘레에 좁게 모은 별
const tilt = 0.6;
for (let i = 0; i < BAND_STARS; i += 1) {
const ph = rnd() * PI2;
const lat = (rnd() + rnd() + rnd() - 1.5) * 0.2;
const bx = Math.cos(lat) * Math.cos(ph);
const by = Math.sin(lat);
const bz = Math.cos(lat) * Math.sin(ph);
stars.push({ x: bx, y: by * Math.cos(tilt) - bz * Math.sin(tilt), z: by * Math.sin(tilt) + bz * Math.cos(tilt), m: rnd() * 0.5, c: STAR_RGBS[2] });
}
let yaw = 0;
let pitch = PITCH;
let t = 0;
let look = 0;
let lookP = 0;
function project(x: number, y: number, z: number, cy: number, sy: number, cp: number, sp: number): [number, number, number] {
const x1 = x * cy + z * sy;
const z1 = -x * sy + z * cy;
const y2 = y * cp - z1 * sp;
const z2 = y * sp + z1 * cp;
return [W / 2 + FOV * x1 / z2, H / 2 - FOV * y2 / z2, z2];
}
const scene: Scene = {
step(): void {
t += 1;
let ly = 0;
let lp = 0.22 * Math.sin(t * 0.008);
if (inp.idle < IDLE_TICKS) {
ly = (inp.x - W / 2) / W * 1.6;
lp = (H / 2 - inp.y) / H * 0.9;
}
look = lerp(look, ly, 0.06);
lookP = lerp(lookP, lp, 0.06);
yaw += YAW_RATE;
pitch = PITCH + lookP;
},
draw(ctx: CanvasRenderingContext2D): void {
paintBg(ctx, 1);
const cyw = Math.cos(yaw + look);
const syw = Math.sin(yaw + look);
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
// 위도선과 경선 — 점으로만 그려 돌아가는 방향이 보이게
ctx.fillStyle = rgba([208, 196, 255], 0.55);
for (let la = -60; la <= 60; la += 30) {
const rl = Math.cos(la * Math.PI / 180);
const yl = Math.sin(la * Math.PI / 180);
for (let a = 0; a < 360; a += 5) {
const p = project(rl * Math.cos(a * Math.PI / 180), yl, rl * Math.sin(a * Math.PI / 180), cyw, syw, cp, sp);
if (p[2] > NEAR_CUT) {
ctx.fillRect(p[0] - 0.9, p[1] - 0.9, 1.9, 1.9);
}
}
}
for (let me = 0; me < 360; me += GRID_STEP / 2) {
for (let a = -80; a <= 80; a += 5) {
const rl = Math.cos(a * Math.PI / 180);
const p = project(rl * Math.cos(me * Math.PI / 180), Math.sin(a * Math.PI / 180), rl * Math.sin(me * Math.PI / 180), cyw, syw, cp, sp);
if (p[2] > NEAR_CUT) {
ctx.fillRect(p[0] - 0.9, p[1] - 0.9, 1.9, 1.9);
}
}
}
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const p = project(s.x, s.y, s.z, cyw, syw, cp, sp);
if (p[2] <= NEAR_CUT || p[0] < -6 || p[0] > W + 6 || p[1] < -6 || p[1] > H + 6) {
continue;
}
const r = 0.5 + 1.9 * s.m * s.m * s.m + (i < STAR_COUNT ? 0.3 : 0);
const a = 0.35 + 0.65 * clamp(p[2] * 1.2, 0, 1);
if (r > 1.5) {
glow(ctx, p[0], p[1], r * 5, s.c, 0.35 * a);
}
ctx.fillStyle = rgba(s.c, a);
ctx.beginPath();
ctx.arc(p[0], p[1], r, 0, PI2);
ctx.fill();
}
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_LOOK : TXT_AUTO;
},
tap(): void {
yaw += Math.PI / 3;
}
};
if (reduce) {
for (let i = 0; i < 60; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfRotatingSkyDome() {
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>
);
}06 · 球面上の星を回転行列で回して見る空のドーム — 計測 累積面積 56.859% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function project(x, y, z, cy, sy, cp, sp) {
const x1 = x * cy + z * sy;
const z1 = -x * sy + z * cy;
const y2 = y * cp - z1 * sp;
const z2 = y * sp + z1 * cp;
return [W / 2 + FOV * x1 / z2, H / 2 - FOV * y2 / z2, z2];
}
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
const p = project(s.x, s.y, s.z, cyw, syw, cp, sp);
if (p[2] <= NEAR_CUT || p[0] < -6 || p[0] > W + 6 || p[1] < -6 || p[1] > H + 6) {
continue;
}
const r = 0.5 + 1.9 * s.m * s.m * s.m + (i < STAR_COUNT ? 0.3 : 0);
const a = 0.35 + 0.65 * clamp(p[2] * 1.2, 0, 1);
if (r > 1.5) {
glow(ctx, p[0], p[1], r * 5, s.c, 0.35 * a);
}
ctx.fillStyle = rgba(s.c, a);
ctx.beginPath();
ctx.arc(p[0], p[1], r, 0, PI2);
ctx.fill();
}
07近い星同士が線でつながる星座
星110個とポインターになる星一つを、GRID_CELL(LINK_RADIUS と同じ72px)のマスへ番号で入れておき、各星は自分のマスを囲む3×3マスだけを調べて、距離が72未満の相手に線を引きます。線の透明度は LINE_ALPHA·(1 − d÷LINK_RADIUS)^1.3 で、一つの星につく線は3本(ポインターは6本)までです。測定は累積面積56.2%、動いたフレームは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;
}
.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;
}
.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 = '次の場面';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 LINK_RADIUS = 72;
const MAX_LINKS = 3;
const GRID_CELL = LINK_RADIUS;
const LINE_ALPHA = 0.6;
const NODE_COUNT = 110;
const CURSOR_LINKS = 6;
const TXT_GRID = '隣のマスだけ計算 — 近い星どうしを線で結ぶ';
const TXT_CURSOR = 'ポインターが星の一つになる';
function makeScene(inp, reduce) {
const rnd = lcg(450707);
const nodes = [];
for (let i = 0; i <= NODE_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.35 + 0.65 * rnd();
nodes.push({ x: rnd() * W, y: rnd() * H, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, c: STAR_RGBS[i % STAR_RGBS.length], n: 0 });
}
// 마지막 노드는 커서 별 — 포인터가 없으면 자동 곡선을 돈다
const cur = nodes[NODE_COUNT];
cur.vx = 0;
cur.vy = 0;
let t = 0;
const cols = Math.ceil(W / GRID_CELL);
const rows = Math.ceil(H / GRID_CELL);
const scene = {
step() {
t += 1;
for (let i = 0; i < NODE_COUNT; i += 1) {
const s = nodes[i];
s.x += s.vx;
s.y += s.vy;
if (s.x < 0 || s.x > W) {
s.vx = -s.vx;
s.x = clamp(s.x, 0, W);
}
if (s.y < 0 || s.y > H) {
s.vy = -s.vy;
s.y = clamp(s.y, 0, H);
}
}
let tx = W / 2 + Math.cos(t * 0.021) * 170;
let ty = H / 2 + Math.sin(t * 0.034) * 90;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
cur.x = lerp(cur.x, tx, 0.12);
cur.y = lerp(cur.y, ty, 0.12);
},
draw(ctx) {
paintBg(ctx, 1);
// 격자 칸마다 별 번호를 모아 두면 이웃 3x3 칸만 보면 된다
const cells = [];
for (let i = 0; i < cols * rows; i += 1) {
cells.push([]);
}
for (let i = 0; i < nodes.length; i += 1) {
nodes[i].n = 0;
const cx = clamp(Math.floor(nodes[i].x / GRID_CELL), 0, cols - 1);
const cy = clamp(Math.floor(nodes[i].y / GRID_CELL), 0, rows - 1);
cells[cy * cols + cx].push(i);
}
ctx.lineCap = 'round';
for (let i = 0; i < nodes.length; i += 1) {
const a = nodes[i];
const cx = clamp(Math.floor(a.x / GRID_CELL), 0, cols - 1);
const cy = clamp(Math.floor(a.y / GRID_CELL), 0, rows - 1);
const cap = i === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
for (let gy = Math.max(0, cy - 1); gy <= Math.min(rows - 1, cy + 1); gy += 1) {
for (let gx = Math.max(0, cx - 1); gx <= Math.min(cols - 1, cx + 1); gx += 1) {
const list = cells[gy * cols + gx];
for (let q = 0; q < list.length; q += 1) {
const j = list[q];
const b = nodes[j];
if (j <= i || a.n >= cap) {
continue;
}
const capB = j === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
if (b.n >= capB) {
continue;
}
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d >= LINK_RADIUS) {
continue;
}
a.n += 1;
b.n += 1;
const k = 1 - d / LINK_RADIUS;
ctx.strokeStyle = rgba(mix(a.c, b.c, 0.5), LINE_ALPHA * Math.pow(k, 1.3));
ctx.lineWidth = 0.6 + 1.4 * k;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
}
for (let i = 0; i < nodes.length; i += 1) {
const s = nodes[i];
glow(ctx, s.x, s.y, i === NODE_COUNT ? 22 : 9, s.c, i === NODE_COUNT ? 0.6 : 0.35);
ctx.fillStyle = rgba(s.c, 0.95);
ctx.beginPath();
ctx.arc(s.x, s.y, i === NODE_COUNT ? 3.4 : 1.7, 0, PI2);
ctx.fill();
}
},
label() {
return inp.idle < IDLE_TICKS ? TXT_CURSOR : TXT_GRID;
},
tap() {
for (let i = 0; i < NODE_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.35 + 0.65 * rnd();
nodes[i].vx = Math.cos(a) * sp;
nodes[i].vy = Math.sin(a) * sp;
}
}
};
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 "./SfConstellationLinks.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 = '다음 장면';
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;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 LINK_RADIUS = 72;
const MAX_LINKS = 3;
const GRID_CELL = LINK_RADIUS;
const LINE_ALPHA = 0.6;
const NODE_COUNT = 110;
const CURSOR_LINKS = 6;
const TXT_GRID = '이웃 칸만 계산 — 가까운 별끼리 선';
const TXT_CURSOR = '포인터가 별 하나가 된다';
interface Mote { x: number; y: number; vx: number; vy: number; c: RGB; n: number; }
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450707);
const nodes: Mote[] = [];
for (let i = 0; i <= NODE_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.35 + 0.65 * rnd();
nodes.push({ x: rnd() * W, y: rnd() * H, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, c: STAR_RGBS[i % STAR_RGBS.length], n: 0 });
}
// 마지막 노드는 커서 별 — 포인터가 없으면 자동 곡선을 돈다
const cur = nodes[NODE_COUNT];
cur.vx = 0;
cur.vy = 0;
let t = 0;
const cols = Math.ceil(W / GRID_CELL);
const rows = Math.ceil(H / GRID_CELL);
const scene: Scene = {
step(): void {
t += 1;
for (let i = 0; i < NODE_COUNT; i += 1) {
const s = nodes[i];
s.x += s.vx;
s.y += s.vy;
if (s.x < 0 || s.x > W) { s.vx = -s.vx; s.x = clamp(s.x, 0, W); }
if (s.y < 0 || s.y > H) { s.vy = -s.vy; s.y = clamp(s.y, 0, H); }
}
let tx = W / 2 + Math.cos(t * 0.021) * 170;
let ty = H / 2 + Math.sin(t * 0.034) * 90;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
cur.x = lerp(cur.x, tx, 0.12);
cur.y = lerp(cur.y, ty, 0.12);
},
draw(ctx: CanvasRenderingContext2D): void {
paintBg(ctx, 1);
// 격자 칸마다 별 번호를 모아 두면 이웃 3x3 칸만 보면 된다
const cells: number[][] = [];
for (let i = 0; i < cols * rows; i += 1) {
cells.push([]);
}
for (let i = 0; i < nodes.length; i += 1) {
nodes[i].n = 0;
const cx = clamp(Math.floor(nodes[i].x / GRID_CELL), 0, cols - 1);
const cy = clamp(Math.floor(nodes[i].y / GRID_CELL), 0, rows - 1);
cells[cy * cols + cx].push(i);
}
ctx.lineCap = 'round';
for (let i = 0; i < nodes.length; i += 1) {
const a = nodes[i];
const cx = clamp(Math.floor(a.x / GRID_CELL), 0, cols - 1);
const cy = clamp(Math.floor(a.y / GRID_CELL), 0, rows - 1);
const cap = i === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
for (let gy = Math.max(0, cy - 1); gy <= Math.min(rows - 1, cy + 1); gy += 1) {
for (let gx = Math.max(0, cx - 1); gx <= Math.min(cols - 1, cx + 1); gx += 1) {
const list = cells[gy * cols + gx];
for (let q = 0; q < list.length; q += 1) {
const j = list[q];
const b = nodes[j];
if (j <= i || a.n >= cap) { continue; }
const capB = j === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
if (b.n >= capB) { continue; }
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d >= LINK_RADIUS) { continue; }
a.n += 1;
b.n += 1;
const k = 1 - d / LINK_RADIUS;
ctx.strokeStyle = rgba(mix(a.c, b.c, 0.5), LINE_ALPHA * Math.pow(k, 1.3));
ctx.lineWidth = 0.6 + 1.4 * k;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
}
for (let i = 0; i < nodes.length; i += 1) {
const s = nodes[i];
glow(ctx, s.x, s.y, i === NODE_COUNT ? 22 : 9, s.c, i === NODE_COUNT ? 0.6 : 0.35);
ctx.fillStyle = rgba(s.c, 0.95);
ctx.beginPath();
ctx.arc(s.x, s.y, i === NODE_COUNT ? 3.4 : 1.7, 0, PI2);
ctx.fill();
}
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_CURSOR : TXT_GRID;
},
tap(): void {
for (let i = 0; i < NODE_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.35 + 0.65 * rnd();
nodes[i].vx = Math.cos(a) * sp;
nodes[i].vy = Math.sin(a) * sp;
}
}
};
if (reduce) {
for (let i = 0; i < 30; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfConstellationLinks() {
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 · 近い星同士が線でつながる星座 — 計測 累積面積 56.17% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let i = 0; i < nodes.length; i += 1) {
nodes[i].n = 0;
const cx = clamp(Math.floor(nodes[i].x / GRID_CELL), 0, cols - 1);
const cy = clamp(Math.floor(nodes[i].y / GRID_CELL), 0, rows - 1);
cells[cy * cols + cx].push(i);
}
for (let gy = Math.max(0, cy - 1); gy <= Math.min(rows - 1, cy + 1); gy += 1) {
for (let gx = Math.max(0, cx - 1); gx <= Math.min(cols - 1, cx + 1); gx += 1) {
const list = cells[gy * cols + gx];
for (let q = 0; q < list.length; q += 1) {
const j = list[q];
const b = nodes[j];
if (j <= i || a.n >= cap) {
continue;
}
const capB = j === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
if (b.n >= capB) {
continue;
}
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d >= LINK_RADIUS) {
continue;
}
a.n += 1;
b.n += 1;
const k = 1 - d / LINK_RADIUS;
ctx.strokeStyle = rgba(mix(a.c, b.c, 0.5), LINE_ALPHA * Math.pow(k, 1.3));
08尾が薄れながら曲がって落ちる流れ星
流れ星はティックごとに位置を履歴配列の先頭へ入れ、TRAIL_LEN の44個の節までを残して、節の透明度は (1 − 年齢)^1.6、太さは 6·(1 − 年齢) + 0.8 です。縦の速さに毎ティック GRAVITY の0.06を足して経路を曲げ、背景を透明度0.8でしか塗り直さないので残光が残ります。測定は累積面積24.5%、動いたフレームは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;
}
.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;
}
.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 = '次の場面';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 TRAIL_LEN = 44;
const METEOR_RATE = 16;
const ANGLE = 0.62;
const GRAVITY = 0.06;
const METEOR_SPEED = 6.5;
const FADE = 0.2;
const BG_STARS = 140;
const HEAD = [255, 244, 232];
const TAIL = [190, 170, 255];
const TXT_FLOW = '流れ星 — 尾は古いほど薄くなる';
const TXT_OWN = '自分で放った流れ星 — 重力が尾を曲げる';
function makeScene(inp, reduce) {
const rnd = lcg(450808);
const dims = [];
for (let i = 0; i < BG_STARS; i += 1) {
dims.push({ x: rnd() * W, y: rnd() * H, r: 0.5 + 1.1 * rnd(), p: rnd() * PI2 });
}
const meteors = [];
let t = 0;
let ownLeft = 0;
let primed = false;
function launch(x, y) {
const ang = ANGLE + (rnd() - 0.5) * 0.3;
const sp = METEOR_SPEED * (0.8 + 0.5 * rnd());
meteors.push({ x: x, y: y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, hist: [], live: true });
}
const scene = {
step() {
t += 1;
if (ownLeft > 0) {
ownLeft -= 1;
}
if (t % METEOR_RATE === 1) {
launch(-30 + rnd() * W * 0.9, -20 + rnd() * H * 0.35);
}
for (let i = meteors.length - 1; i >= 0; i -= 1) {
const m = meteors[i];
if (m.live) {
m.hist.unshift(m.x, m.y);
m.vy += GRAVITY;
m.x += m.vx;
m.y += m.vy;
if (m.x > W + 70 || m.y > H + 70) {
m.live = false;
}
}
else {
m.hist.pop();
m.hist.pop();
}
if (m.hist.length > TRAIL_LEN * 2) {
m.hist.length = TRAIL_LEN * 2;
}
if (!m.live && m.hist.length === 0) {
meteors.splice(i, 1);
}
}
},
draw(ctx) {
// 매 프레임 배경을 덜 덮어 옛 흔적이 남는다 — 잔광
paintBg(ctx, primed ? 1 - FADE : 1);
primed = true;
for (let i = 0; i < dims.length; i += 1) {
const s = dims[i];
ctx.fillStyle = rgba(STAR_RGBS[i % STAR_RGBS.length], 0.35 + 0.3 * Math.sin(t * 0.04 + s.p));
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, PI2);
ctx.fill();
}
ctx.lineCap = 'round';
for (let i = 0; i < meteors.length; i += 1) {
const m = meteors[i];
const n = Math.floor(m.hist.length / 2);
let px = m.live ? m.x : (n > 0 ? m.hist[0] : 0);
let py = m.live ? m.y : (n > 0 ? m.hist[1] : 0);
for (let k = 0; k < n; k += 1) {
const age = k / TRAIL_LEN;
const a = Math.pow(1 - age, 1.6);
const c = mix(HEAD, TAIL, age);
const hx = m.hist[k * 2];
const hy = m.hist[k * 2 + 1];
ctx.strokeStyle = rgba(c, 0.22 * a);
ctx.lineWidth = (7 * (1 - age) + 1) * 2.2;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(hx, hy);
ctx.stroke();
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = 6 * (1 - age) + 0.8;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(hx, hy);
ctx.stroke();
px = hx;
py = hy;
}
if (m.live) {
glow(ctx, m.x, m.y, 16, HEAD, 0.75);
}
}
},
label() {
return ownLeft > 0 ? TXT_OWN : TXT_FLOW;
},
tap() {
launch(inp.x, inp.y);
ownLeft = 90;
}
};
if (reduce) {
for (let i = 0; i < 70; 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 "./SfShootingStarTrail.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 = '다음 장면';
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;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 TRAIL_LEN = 44;
const METEOR_RATE = 16;
const ANGLE = 0.62;
const GRAVITY = 0.06;
const METEOR_SPEED = 6.5;
const FADE = 0.2;
const BG_STARS = 140;
const HEAD: RGB = [255, 244, 232];
const TAIL: RGB = [190, 170, 255];
const TXT_FLOW = '별똥별 — 꼬리는 나이만큼 옅어진다';
const TXT_OWN = '내가 쏜 별똥별 — 중력이 꼬리를 휜다';
interface Meteor { x: number; y: number; vx: number; vy: number; hist: number[]; live: boolean; }
interface Dim { x: number; y: number; r: number; p: number; }
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450808);
const dims: Dim[] = [];
for (let i = 0; i < BG_STARS; i += 1) {
dims.push({ x: rnd() * W, y: rnd() * H, r: 0.5 + 1.1 * rnd(), p: rnd() * PI2 });
}
const meteors: Meteor[] = [];
let t = 0;
let ownLeft = 0;
let primed = false;
function launch(x: number, y: number): void {
const ang = ANGLE + (rnd() - 0.5) * 0.3;
const sp = METEOR_SPEED * (0.8 + 0.5 * rnd());
meteors.push({ x: x, y: y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, hist: [], live: true });
}
const scene: Scene = {
step(): void {
t += 1;
if (ownLeft > 0) {
ownLeft -= 1;
}
if (t % METEOR_RATE === 1) {
launch(-30 + rnd() * W * 0.9, -20 + rnd() * H * 0.35);
}
for (let i = meteors.length - 1; i >= 0; i -= 1) {
const m = meteors[i];
if (m.live) {
m.hist.unshift(m.x, m.y);
m.vy += GRAVITY;
m.x += m.vx;
m.y += m.vy;
if (m.x > W + 70 || m.y > H + 70) {
m.live = false;
}
} else {
m.hist.pop();
m.hist.pop();
}
if (m.hist.length > TRAIL_LEN * 2) {
m.hist.length = TRAIL_LEN * 2;
}
if (!m.live && m.hist.length === 0) {
meteors.splice(i, 1);
}
}
},
draw(ctx: CanvasRenderingContext2D): void {
// 매 프레임 배경을 덜 덮어 옛 흔적이 남는다 — 잔광
paintBg(ctx, primed ? 1 - FADE : 1);
primed = true;
for (let i = 0; i < dims.length; i += 1) {
const s = dims[i];
ctx.fillStyle = rgba(STAR_RGBS[i % STAR_RGBS.length], 0.35 + 0.3 * Math.sin(t * 0.04 + s.p));
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, PI2);
ctx.fill();
}
ctx.lineCap = 'round';
for (let i = 0; i < meteors.length; i += 1) {
const m = meteors[i];
const n = Math.floor(m.hist.length / 2);
let px = m.live ? m.x : (n > 0 ? m.hist[0] : 0);
let py = m.live ? m.y : (n > 0 ? m.hist[1] : 0);
for (let k = 0; k < n; k += 1) {
const age = k / TRAIL_LEN;
const a = Math.pow(1 - age, 1.6);
const c = mix(HEAD, TAIL, age);
const hx = m.hist[k * 2];
const hy = m.hist[k * 2 + 1];
ctx.strokeStyle = rgba(c, 0.22 * a);
ctx.lineWidth = (7 * (1 - age) + 1) * 2.2;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(hx, hy);
ctx.stroke();
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = 6 * (1 - age) + 0.8;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(hx, hy);
ctx.stroke();
px = hx;
py = hy;
}
if (m.live) {
glow(ctx, m.x, m.y, 16, HEAD, 0.75);
}
}
},
label(): string {
return ownLeft > 0 ? TXT_OWN : TXT_FLOW;
},
tap(): void {
launch(inp.x, inp.y);
ownLeft = 90;
}
};
if (reduce) {
for (let i = 0; i < 70; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfShootingStarTrail() {
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 · 尾が薄れながら曲がって落ちる流れ星 — 計測 累積面積 24.526% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let i = meteors.length - 1; i >= 0; i -= 1) {
const m = meteors[i];
if (m.live) {
m.hist.unshift(m.x, m.y);
m.vy += GRAVITY;
m.x += m.vx;
m.y += m.vy;
if (m.x > W + 70 || m.y > H + 70) {
m.live = false;
}
}
else {
m.hist.pop();
m.hist.pop();
}
if (m.hist.length > TRAIL_LEN * 2) {
m.hist.length = TRAIL_LEN * 2;
}
if (!m.live && m.hist.length === 0) {
meteors.splice(i, 1);
}
}
for (let k = 0; k < n; k += 1) {
const age = k / TRAIL_LEN;
const a = Math.pow(1 - age, 1.6);
const c = mix(HEAD, TAIL, age);
const hx = m.hist[k * 2];
const hy = m.hist[k * 2 + 1];
ctx.strokeStyle = rgba(c, 0.22 * a);
ctx.lineWidth = (7 * (1 - age) + 1) * 2.2;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(hx, hy);
ctx.stroke();
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = 6 * (1 - age) + 0.8;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(hx, hy);
ctx.stroke();
px = hx;
py = hy;
}
09ブラックホールの近くで星の光が輪に曲がる重力レンズ
星がレンズの中心から b 離れているとき、像は (b + √(b²+4M))÷2 と反対側の (√(b²+4M) − b)÷2 の二か所に結ばれ、LENS_MASS が2600なら b が0の星は半径√2600の約51px の輪に広がります。背景の絵には触れず星の座標だけを動かし、SOFTEN の5は b が0のとき倍率の式が0で割られるのを防ぎます。測定は累積面積30.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;
}
.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;
}
.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 = '次の場面';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 LENS_MASS = 2600;
const RING_RADIUS = Math.sqrt(LENS_MASS);
const FALLOFF = 1.1;
const SOFTEN = 5;
const FIELD_STARS = 260;
const GALAXY_STARS = 70;
const BOOST_TICKS = 80;
const BOOST_GAIN = 1.9;
const TXT_RING = 'レンズの質量 — 輪が星の光を集める';
const TXT_HEAVY = '質量が増えた — 輪が大きくなる';
function makeScene(inp, reduce) {
const rnd = lcg(450909);
const field = [];
for (let i = 0; i < FIELD_STARS; i += 1) {
field.push({ x: rnd() * W, y: rnd() * H, r: 0.7 + 1.3 * rnd() * rnd(), c: STAR_RGBS[Math.floor(rnd() * STAR_RGBS.length)] });
}
// 렌즈 바로 뒤의 작은 은하 — 상대 위치가 0 에 가까워 고리를 만든다
const galaxy = [];
for (let i = 0; i < GALAXY_STARS; i += 1) {
const a = rnd() * PI2;
const rr = 14 * Math.sqrt(rnd());
galaxy.push({ x: Math.cos(a) * rr, y: Math.sin(a) * rr, r: 0.9 + 0.8 * rnd(), c: STAR_RGBS[i % 2 === 0 ? 1 : 4] });
}
let cx = W / 2;
let cy = H / 2;
let t = 0;
let boost = 0;
function mass() {
return LENS_MASS * (1 + (BOOST_GAIN - 1) * smooth(boost / BOOST_TICKS));
}
// 점질량 렌즈: 원천이 렌즈 중심에서 b 만큼 떨어져 있으면 상 두 개가 b 방향의 +th 와 반대쪽 th2 에 맺힌다
function image(ctx, bx, by, r, c, m, alpha) {
const b = Math.hypot(bx, by);
const bs = Math.sqrt(b * b + SOFTEN * SOFTEN);
const root = Math.sqrt(bs * bs + 4 * m);
const thP = (bs + root) / 2;
const thM = (root - bs) / 2;
const u = bs / Math.sqrt(m);
const mag = (u * u + 2) / (2 * u * Math.sqrt(u * u + 4));
const muP = clamp(mag + 0.5, 1, 9);
const muM = clamp(mag - 0.5, 0, 9);
const ux = b > 0.001 ? bx / b : 1;
const uy = b > 0.001 ? by / b : 0;
const ang = Math.atan2(uy, ux) + Math.PI / 2;
const stretchP = clamp(thP / bs, 1, 6);
ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muP, FALLOFF) * 0.8, 0.3, 1));
ctx.beginPath();
ctx.ellipse(cx + ux * thP, cy + uy * thP, r * stretchP, r * 0.8, ang, 0, PI2);
ctx.fill();
if (muM > 0.04) {
const stretchM = clamp(thM / bs, 0.4, 6);
ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muM, FALLOFF) * 0.8, 0, 0.9));
ctx.beginPath();
ctx.ellipse(cx - ux * thM, cy - uy * thM, r * Math.max(stretchM, 0.8), r * 0.7, ang, 0, PI2);
ctx.fill();
}
}
const scene = {
step() {
t += 1;
if (boost > 0) {
boost -= 1;
}
let tx = W / 2 + Math.sin(t * 0.012) * 110;
let ty = H / 2 + Math.sin(t * 0.017) * 50;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
cx = lerp(cx, tx, 0.1);
cy = lerp(cy, ty, 0.1);
for (let i = 0; i < field.length; i += 1) {
field[i].x -= 0.7;
if (field[i].x < -8) {
field[i].x += W + 16;
}
}
},
draw(ctx) {
paintBg(ctx, 1);
const m = mass();
for (let i = 0; i < field.length; i += 1) {
const s = field[i];
image(ctx, s.x - cx, s.y - cy, s.r, s.c, m, 0.9);
}
const gx = Math.sin(t * 0.02) * 7;
const gy = Math.cos(t * 0.027) * 5;
for (let i = 0; i < galaxy.length; i += 1) {
const s = galaxy[i];
image(ctx, s.x + gx, s.y + gy, s.r, s.c, m, 0.8);
}
const ring = Math.sqrt(m);
ctx.strokeStyle = rgba([208, 196, 255], 0.14);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, ring, 0, PI2);
ctx.stroke();
glow(ctx, cx, cy, ring * 1.1, [186, 150, 255], 0.12);
const hole = ctx.createRadialGradient(cx, cy, 0, cx, cy, RING_RADIUS * 0.3);
hole.addColorStop(0, rgba(BG, 1));
hole.addColorStop(0.7, rgba(BG, 1));
hole.addColorStop(1, rgba(BG, 0));
ctx.fillStyle = hole;
ctx.fillRect(cx - RING_RADIUS, cy - RING_RADIUS, RING_RADIUS * 2, RING_RADIUS * 2);
},
label() {
return boost > 0 ? TXT_HEAVY : TXT_RING;
},
tap() {
boost = BOOST_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 "./SfGravityLensField.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 = '다음 장면';
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;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(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 LENS_MASS = 2600;
const RING_RADIUS = Math.sqrt(LENS_MASS);
const FALLOFF = 1.1;
const SOFTEN = 5;
const FIELD_STARS = 260;
const GALAXY_STARS = 70;
const BOOST_TICKS = 80;
const BOOST_GAIN = 1.9;
const TXT_RING = '렌즈 질량 — 고리가 별빛을 모은다';
const TXT_HEAVY = '질량 증가 — 고리가 커진다';
interface Src { x: number; y: number; r: number; c: RGB; }
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450909);
const field: Src[] = [];
for (let i = 0; i < FIELD_STARS; i += 1) {
field.push({ x: rnd() * W, y: rnd() * H, r: 0.7 + 1.3 * rnd() * rnd(), c: STAR_RGBS[Math.floor(rnd() * STAR_RGBS.length)] });
}
// 렌즈 바로 뒤의 작은 은하 — 상대 위치가 0 에 가까워 고리를 만든다
const galaxy: Src[] = [];
for (let i = 0; i < GALAXY_STARS; i += 1) {
const a = rnd() * PI2;
const rr = 14 * Math.sqrt(rnd());
galaxy.push({ x: Math.cos(a) * rr, y: Math.sin(a) * rr, r: 0.9 + 0.8 * rnd(), c: STAR_RGBS[i % 2 === 0 ? 1 : 4] });
}
let cx = W / 2;
let cy = H / 2;
let t = 0;
let boost = 0;
function mass(): number {
return LENS_MASS * (1 + (BOOST_GAIN - 1) * smooth(boost / BOOST_TICKS));
}
// 점질량 렌즈: 원천이 렌즈 중심에서 b 만큼 떨어져 있으면 상 두 개가 b 방향의 +th 와 반대쪽 th2 에 맺힌다
function image(ctx: CanvasRenderingContext2D, bx: number, by: number, r: number, c: RGB, m: number, alpha: number): void {
const b = Math.hypot(bx, by);
const bs = Math.sqrt(b * b + SOFTEN * SOFTEN);
const root = Math.sqrt(bs * bs + 4 * m);
const thP = (bs + root) / 2;
const thM = (root - bs) / 2;
const u = bs / Math.sqrt(m);
const mag = (u * u + 2) / (2 * u * Math.sqrt(u * u + 4));
const muP = clamp(mag + 0.5, 1, 9);
const muM = clamp(mag - 0.5, 0, 9);
const ux = b > 0.001 ? bx / b : 1;
const uy = b > 0.001 ? by / b : 0;
const ang = Math.atan2(uy, ux) + Math.PI / 2;
const stretchP = clamp(thP / bs, 1, 6);
ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muP, FALLOFF) * 0.8, 0.3, 1));
ctx.beginPath();
ctx.ellipse(cx + ux * thP, cy + uy * thP, r * stretchP, r * 0.8, ang, 0, PI2);
ctx.fill();
if (muM > 0.04) {
const stretchM = clamp(thM / bs, 0.4, 6);
ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muM, FALLOFF) * 0.8, 0, 0.9));
ctx.beginPath();
ctx.ellipse(cx - ux * thM, cy - uy * thM, r * Math.max(stretchM, 0.8), r * 0.7, ang, 0, PI2);
ctx.fill();
}
}
const scene: Scene = {
step(): void {
t += 1;
if (boost > 0) {
boost -= 1;
}
let tx = W / 2 + Math.sin(t * 0.012) * 110;
let ty = H / 2 + Math.sin(t * 0.017) * 50;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
cx = lerp(cx, tx, 0.1);
cy = lerp(cy, ty, 0.1);
for (let i = 0; i < field.length; i += 1) {
field[i].x -= 0.7;
if (field[i].x < -8) {
field[i].x += W + 16;
}
}
},
draw(ctx: CanvasRenderingContext2D): void {
paintBg(ctx, 1);
const m = mass();
for (let i = 0; i < field.length; i += 1) {
const s = field[i];
image(ctx, s.x - cx, s.y - cy, s.r, s.c, m, 0.9);
}
const gx = Math.sin(t * 0.02) * 7;
const gy = Math.cos(t * 0.027) * 5;
for (let i = 0; i < galaxy.length; i += 1) {
const s = galaxy[i];
image(ctx, s.x + gx, s.y + gy, s.r, s.c, m, 0.8);
}
const ring = Math.sqrt(m);
ctx.strokeStyle = rgba([208, 196, 255], 0.14);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, ring, 0, PI2);
ctx.stroke();
glow(ctx, cx, cy, ring * 1.1, [186, 150, 255], 0.12);
const hole = ctx.createRadialGradient(cx, cy, 0, cx, cy, RING_RADIUS * 0.3);
hole.addColorStop(0, rgba(BG, 1));
hole.addColorStop(0.7, rgba(BG, 1));
hole.addColorStop(1, rgba(BG, 0));
ctx.fillStyle = hole;
ctx.fillRect(cx - RING_RADIUS, cy - RING_RADIUS, RING_RADIUS * 2, RING_RADIUS * 2);
},
label(): string {
return boost > 0 ? TXT_HEAVY : TXT_RING;
},
tap(): void {
boost = BOOST_TICKS;
}
};
if (reduce) {
for (let i = 0; i < 40; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfGravityLensField() {
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 · ブラックホールの近くで星の光が輪に曲がる重力レンズ — 計測 累積面積 30.934% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function image(ctx, bx, by, r, c, m, alpha) {
const b = Math.hypot(bx, by);
const bs = Math.sqrt(b * b + SOFTEN * SOFTEN);
const root = Math.sqrt(bs * bs + 4 * m);
const thP = (bs + root) / 2;
const thM = (root - bs) / 2;
const u = bs / Math.sqrt(m);
const mag = (u * u + 2) / (2 * u * Math.sqrt(u * u + 4));
const muP = clamp(mag + 0.5, 1, 9);
const muM = clamp(mag - 0.5, 0, 9);
const ux = b > 0.001 ? bx / b : 1;
const uy = b > 0.001 ? by / b : 0;
const ang = Math.atan2(uy, ux) + Math.PI / 2;
const stretchP = clamp(thP / bs, 1, 6);
ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muP, FALLOFF) * 0.8, 0.3, 1));
ctx.beginPath();
ctx.ellipse(cx + ux * thP, cy + uy * thP, r * stretchP, r * 0.8, ang, 0, PI2);
ctx.fill();
if (muM > 0.04) {
const stretchM = clamp(thM / bs, 0.4, 6);
ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muM, FALLOFF) * 0.8, 0, 0.9));
ctx.beginPath();
ctx.ellipse(cx - ux * thM, cy - uy * thM, r * Math.max(stretchM, 0.8), r * 0.7, ang, 0, PI2);
ctx.fill();
}
}
ヒーローとローディングとつながりの背景のどこに星を敷くか
場面ごとに合う置き場所が違います。ランディングの最初の画面やヒーローには01のワープと06の天球が合い、ポインターやスクロールに合わせて奥行きを感じさせたい背景には02の三層の視差、止まった夜空に息を吹き込みたいときは03、宇宙やSFの色づかいを合わせたいときは04です。ローディングやイントロの切り替えには05のジャンプが170ティックの一周期で回るので、HOLD と RAMP を読み込みの時間に合わせて伸ばせばよく、ネットワークやつながりを語る背景は07、催しや雰囲気の背景は08、画面の中で視線を集めたい一点は09が受け持ちます。
star field animation を背景に敷くときは、場面ごとに一フレームでする仕事が違う点を先に見ます。06は星1420個と緯線・経線の点690個で、一フレームに project を2110回呼び、03は vnoise を霧の2280ピクセルに3回ずつ、星360個に2回ずつ呼ぶので7560回になります。この記事ではフレームの時間を測っていないので、本文の後ろに敷く場面は自分の画面で先に動かしてみてください。
同じサイトの別のキャンバス記事と重なる部分もあります。星どうしを引き合わせる側は 銀河 シミュレーション 9種 — ぶつかり合う星が渦巻腕と潮汐尾を作る へ続き、この記事の09は星を動かさずレンズだけを計算します。html5 canvas アニメーション9種 — 水滴とブラックホール の01と07は getImageData でピクセルを読みますが、ここの九つのファイルにはその呼び出しが0件で、動かすのは星の座標だけです。スクロールに沿って流れる粒の背景は パーティクル 背景 9選 — スクロール値で流れが変わるキャンバスシーン が扱います。
値が速さの感じを変えるところ — 速さ・つなぐ半径・レンズの質量
値を一つ変えるだけで場面が変わる所が三か所あります。01の SPEED、07の LINK_RADIUS、09の LENS_MASS で、下の数字は同じソースを偽の2Dコンテキストで200ティック回したあとの一フレームから数えた、ノードのシミュレーションで測った値です。ブラウザーの画面で直接測った数値とは区別して読んでください。
速さを二倍にすると筋は二倍以上に伸びる
SPEED が12のとき筋の線分は280本で、平均の長さは5.0px です。24に上げても線分は280本のままで、平均の長さは11.4px になり、速さが二倍のとき筋は二倍を超えて伸びます。
つなぐ半径を広げても線の数はほとんど増えない
LINK_RADIUS が72のとき引かれた線は158本で、平均の長さは47.3px です。110に広げると線は161本でほぼ同じまま、平均の長さだけが80.7px へ伸び、星ごとに3本という MAX_LINKS の上限が数を押さえていると分かります。
質量が二倍なら輪の半径は約1.41倍
LENS_MASS が2600のとき輪の半径は51px で、描かれた像は454個です。5200に上げると半径は72px、像は510個になり、半径が √M なので質量が二倍で約1.41倍になる式と合います。
レンダリングで測った九つの場面の値は、次の表にまとめました。動いたフレームは九つとも23/23で、累積面積がいちばん小さいのは08の24.5%と09の30.9%、いちばん大きいのは05の75.4%と03の69.4%です。
| 項目 | 累積面積 | 平均の強度 | 動いたフレーム |
|---|---|---|---|
| 01 ワープの筋 | 57.2% | 47.3 | 23/23 |
| 02 三層の視差 | 52.6% | 49.8 | 23/23 |
| 03 ノイズの夜空 | 69.4% | 23.0 | 23/23 |
| 04 色温度 | 62.4% | 35.4 | 23/23 |
| 05 ジャンプ曲線 | 75.4% | 65.1 | 23/23 |
| 06 天球 | 56.9% | 50.1 | 23/23 |
| 07 星座 | 56.2% | 41.8 | 23/23 |
| 08 流れ星 | 24.5% | 68.4 | 23/23 |
| 09 重力レンズ | 30.9% | 83.1 | 23/23 |
どこで壊れるか — 星の数が多いとき・狭い画面・動きを減らす設定
近づいた星の筋が画面を横切る
01・04・05は星が近づくほど x·FOV÷z が大きくなります。01で SPREAD が760の星が Z_NEAR の24まで来ると、計算上は消失点から約6017px 外に打たれますが、画面の外へ80px はみ出した星を place(s, true) で戻す条件がそれを防いでいます。その条件と Z_NEAR の検査を外すと、線一本が一フレームを食うことがあります。04と05にも同じ見張りがあり、Z_NEAR は22と20、はみ出しの許容は80pxと100pxです。
層を押し込むと星が反対側から飛び出す
02は移動量が深さでしか割られないので、PARALLAX を大きくすると手前の層の星が一度に W を越え、wrap で反対側から現れます。流れの向きを反転するとき時間に掛けると跳びが出るので、累積変数 drift に dir を掛けて足しています。
種を替えると空が一フレームで塗り替わる
03の tap は seed に1を足し、ハッシュをまるごと替えるので、隣り合う種の空は似ません(補間ではありません)。霧は60×38を引き伸ばしたもので、devicePixelRatio が2だと縦横とも約16倍に伸びてにじんで見え、毎フレーム createImageData を一度呼びます。
視線角を画面上の距離で近似しているので縁までの話になる
04は r が240の縁でも、コサインは0.53(約58°)までしか下がりません。視線から90°を超える向きは扱わない近似なので、色の偏りは画面の縁までに収まります。コサインが0.72(CONE_COS)になるのは消失点から約145px の地点で、そこが白い光です。
ジャンプを押すと閃光の途中でも切れる
05は JUMP_EASE が6で p が0.5のとき、上がる幅の11%((2³−1)÷(2⁶−1) = 0.111)しか上がらず、速さは約9.2で、最大の70には遠いままです。そのため上がる区間の大半は止まって見え、終盤で急に上がります。tap はどの区間でも t を HOLD へ戻すので、閃光の最中に押すと突然切れます。
広角なので縁の星が伸びる
06の FOV 150は、縁で星が伸びて見える広角です。一周は2π÷0.012で約524ティック(8.4秒)、yaw は積み上がる値なので、放っておくと大きな数の三角関数を呼び続けます。動きを減らした静止画面では、点の格子がうっすら見えます。
線が残る側が番号で決まる
07は星の番号の順に先に線を引いた側が MAX_LINKS の枠を埋めるので、星が動くとどの線が残るかが番号に左右され、線が瞬くように入れ替わることがあります。GRID_CELL を LINK_RADIUS より小さくすると、隣のマスの外にいる相手を見落とします。
残光が真っ黒に戻らない
08は背景を透明度0.8でしか塗り重ねないので、暗い残像が8ビットの丸めの限界で残ります。尾を unshift で先頭へ入れる配列なので TRAIL_LEN を大きくするとティックごとのコピーが増え、丸い線の端が重なって尾が数珠のように見えます。
タップで質量が増えても中心の穴は変わらない
09の中心の暗い穴は定数 RING_RADIUS の0.3倍(約15px)で描かれるため、タップで質量が1.9倍になっても穴は同じ大きさのままで、輪だけが育ちます。RING_RADIUS は Math.sqrt(LENS_MASS) の定数なので、ソースの LENS_MASS を書き換えると穴も一緒に大きくなります。輪が育つと外側の像が元の位置から遠ざかり、縁の星が画面の外へ出ていきます。
狭い画面と自分で確かめる方法
キャンバスは aspect-ratio の480 / 300で幅だけが縮み、340px 以下ではキャプション・ボタン・状態の文字が10px に小さくなります。ここまでの境界は、ソースの定数を一つずつ変えていけば自分で探せます。圧縮ファイルはパスワード 2yqn4mvf を入れると開くので、中の index.html で SPREAD・PARALLAX・LINK_RADIUS・LENS_MASS の値を書き換えて試してください。
変形三つと、動きを減らす設定のアクセシビリティ
| 名前 | 値 | 感じ |
|---|---|---|
| 速さを変える | 01 SPEED 12 → 24 | 筋の平均の長さが5.0px から11.4px に伸び、中心の星が一瞬で縁へ広がります。 |
| つなぐ半径を変える | 07 LINK_RADIUS 72 → 110 | 線は158本から161本とほぼ同じなのに、平均の長さが47.3px から80.7px へ伸びて遠い星ともつながります。 |
| レンズの質量を変える | 09 LENS_MASS 2600 → 5200 | 輪の半径が51px から72px へ育ち、描かれた像は454個から510個に増えて、星の光が中心からいっそう遠くへ押されます。 |
表の数字は三行とも、ブラウザーではなくノードのシミュレーションで測った値です。
prefers-reduced-motion が有効なとき、九つのファイルはどれも requestAnimationFrame のループを始めず、各ファイルが自分の場面を、始まるときに advance(1400) で88ティック先へ進めてから一枚だけ描きます。ボタンは「次の場面」に変わり、押すたびに REDUCE_STEP_MS の900ミリ秒(56ティック)ぶん進めて一度描き、ポインター・キー・タップの入力は refresh() が6ティック(96ミリ秒)回して、すぐ画面に反映します。
キャンバスは tabindex が0でフォーカスを受け、Enter と Space は tap()、矢印キーは仮想ポインターを KEY_STEP の14px ずつ動かします。この六つのキーだけが preventDefault を呼び、Tab を含む残りのキーはそのまま通ります。状態の文字は、場面が決めた文言が変わったときだけ aria-live="polite" の領域を書き換えます。
css starfield animation は CSS キーフレームだけで足りるか?
点が数個その場で瞬くか、一方向に流れるだけなら足ります。星が近づいてくる感じ、層ごとに違う速さ、球の上で回る空は、星ごとに別々の値を計算しなければならず、そこが星の背景を計算へ渡す分かれ目です。そこから先は javascript starfield animation の領域で、枠だけを CSS が持ち、星はコードが描くことになります。
境目の基準は、星が自分の距離や隣を知る必要があるかどうかです。星が互いの位置を知らなくてよい飾りなら CSS で足り、01のように星ごとに z で割る、07のように近い星を探して結ぶという場面ならコードが合います。この記事のコードは経過時間を16ミリ秒のティックに区切って進めるので、描画が遅れても星の動きは経過時間に沿い、一度に取り戻すのは最大160ミリ秒ぶんまでです。
それでも CSS は枠を受け持ちます。この記事の style.scss はキャンバスを aspect-ratio で整え、touch-action: none でポインターのドラッグがスクロールへ漏れないようにしていて、場面の中の星はすべてキャンバスの内側で計算されます。
javascript starfield を作る前によくある質問
星の背景でまず変える値はどれですか?
01なら SPEED と STAR_COUNT です。速さの感じが足りなければ SPEED から上げ、筋が長すぎれば STREAK_LEN を減らします。この二つは独立しているので、一つずつ変えるとどちらの影響かが見えます。星の数の定数は場面ごとに STAR_TOTAL・NODE_COUNT などと名前が違いますが、数字一つで印象が変わり、減らせば画面も軽くなります。
星の色はどこで変えますか?
STAR_RGBS の五つの値(青みの白・杏・ライラック・ミント・ピンク)と背景の BG で選びます。04は WARM・WHITE・COOL の三つを別に使い、08は HEAD・TAIL で尾の色を決めます。濃い色へ替えるときは BG との明るさの差を一緒に見て、07の線のように二つの星の色を混ぜる所は、両方を揃えて替えないとずれます。
ライブラリや画像は必要ですか?
要りません。九つのファイルは外部のアドレスを呼ばず、星をすべてキャンバス2Dで描くので、ファイル一つを移すだけで動きます。









