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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 가까워질수록 길게 늘어나는 워프 별줄기
- 02 깊이 층마다 다른 속도로 흘러가는 별 세 겹
- 03 노이즈 장이 별마다 다르게 반짝이게 하는 밤하늘
- 04 속도에 따라 푸르게 쏠리고 붉게 늘어지는 별빛
- 05 멈춰 있다가 빛의 속도로 뛰어드는 점프 곡선
- 06 구면 위의 별을 회전 행렬로 돌려 보는 하늘 돔
- 07 가까운 별끼리 선으로 이어지는 별자리
- 08 꼬리가 점점 옅어지며 휘어 떨어지는 별똥별
- 09 블랙홀 가까이에서 별빛이 고리로 휘는 중력 렌즈
css starfield 9가지 — 깊이·회전·굴절을 계산해야 별이 살아난다
먼저 밝혀 둘 것이 있다. 이 편의 아홉 장면은 CSS 키프레임이 아니라 캔버스 2D 와 자바스크립트로 그린 별이고, CSS 는 480×300 캔버스 틀과 캡션·버튼 자리, 포커스 외곽선, touch-action: none 을 맡는다. 별 수백 개가 각자 자기 거리로 나눈 위치에 찍혀야 깊이가 생기는데, 그 나눗셈은 키프레임에 적어 두기보다 프레임마다 코드로 계산하는 쪽이 맞다.
아홉 파일은 start() 가 한 글자도 다르지 않다(60~197줄을 해시로 견주면 같다). 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 격자 점의 값 — 정수 두 개와 시드를 섞어 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);
}
// 느린 장(이웃이 닮는다)과 빠른 장(별마다 다르다)을 섞어 밝기를 정한다
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 로 픽셀을 읽고 블랙홀 터널인 05 는 읽지 않는데, 여기 아홉 파일에는 그 호출이 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 밖에 찍히는데, 화면 밖으로 허용하는 폭(01 80px·04 80px·05 100px)을 넘으면 place(s, true) 로 되돌리는 조건이 그걸 막는다. Z_NEAR 도 01 24·04 22·05 20 으로 장면마다 다르다. 그 조건과 Z_NEAR 검사를 빼면 선 하나가 프레임을 먹을 수 있다.
층을 밀어 넣으면 별이 반대편에서 튀어나온다
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(소스 상수 LENS_MASS 의 제곱근)에 0.3 을 곱한 약 15px 로 정해지고, 탭으로 질량이 1.9배가 되어도 그릴 때만 링이 커질 뿐 이 구멍은 15px 그대로다. 링이 커지면 바깥 상이 원래 위치에서 멀어져 가장자리 별이 화면 밖으로 나간다.
좁은 화면과 직접 확인하는 법
캔버스는 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 키프레임만으로 충분한가?
점 몇 개가 제자리에서 깜박이거나 한 방향으로 흐르기만 하면 충분하다. 별이 다가오는 느낌, 층마다 다른 속도, 구 위에서 도는 하늘은 별마다 서로 다른 값을 계산해야 해서 별 배경을 캔버스 계산으로 넘기는 지점이 되고, 그때부터 css starfield background 는 틀만 CSS 가 잡고 별은 코드가 그린다.
경계를 정하는 기준은 별 하나가 자기 거리나 이웃을 알아야 하는가이다. 별이 서로의 위치를 몰라도 되는 장식이면 CSS 로 충분하고, 01 처럼 별마다 z 로 나누거나 07 처럼 가까운 별을 찾아 이어야 하면 코드가 맞다. 또 이 편의 코드는 프레임마다 흐른 시간을 requestAnimationFrame 타임스탬프로 재어 고정 16밀리초 틱으로 쪼개 진행하고, 밀린 시간은 한 프레임에 최대 160밀리초까지만 따라잡아서 화면이 느려져도 별의 속도는 같다.
그래도 CSS 는 틀을 맡는다. 이 편의 style.scss 는 캔버스를 aspect-ratio 로 맞추고 touch-action: none 으로 포인터 드래그가 스크롤로 새지 않게 하며, 장면 안의 별은 전부 캔버스 안에서 계산된다.
javascript starfield 를 만들기 전에 자주 묻는 질문
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 로 그려서, 파일 하나만 옮겨도 돈다.









