03A Night Sky Where a Noise Field Twinkles Each Star Differently
Each star's position and the frame count go into 2D value noise to set brightness, so neighbors shimmer together while distant stars blink apart.
Best forLife for static backdrops
NOISE_SCALETWINKLE_SPEEDCONTRASTSEED

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="A Night Sky Where a Noise Field Twinkles Each Star Differently"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">Noise field — neighboring stars shimmer together</p>
<button class="sc__btn" id="scBtn" type="button">Pause</button>
<p class="sc__caption">A Night Sky Where a Noise Field Twinkles Each Star Differently</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 = 'Pause';
const BTN_PLAY = 'Play';
const BTN_NEXT = 'Next scene';
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 = 'Noise field — neighboring stars shimmer together';
const TXT_RESEED = 'New seed — same formula, different sky';
// 격자 점의 값 — 정수 두 개와 시드를 섞어 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 A Night Sky Where a Noise Field Twinkles Each Star Differently — Measured area 69.392% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Starfield: 9 Backgrounds Stars Fly Through
The post this demo lives in
CSS Starfield: 9 Backgrounds Stars Fly ThroughA css starfield of a few CSS-animated dots has no depth and a flat constant speed — scenes 01, 02 and 05 solve it with p See all demos in this post
If you like this effect - similar demos
Creative UI06 Twinkling Stars That Hide When the Aurora PassesAurora Background CSS: 9 Living Night-Sky Scenes
Creative UI09 Starfield drift — noise-density star field9 Perlin Noise Animations, No Libraries, Pure Math
Creative UI09 A Dither Hero Where Only the Area Around the Pointer Gets DenseCSS Dither: 9 Scenes Drawn by Threshold Math