구름 애니메이션 9종 — 구름 생성 원리부터 구름바다까지
구름 애니메이션은 캔버스에서 칸마다 물방울이 얼마나 짙은지 계산해 하늘을 그리는 장면이다. 뭉게구름 아랫면은 왜 칼로 자른 듯 평평할까? 03번은 올라가던 공기가 식어 응결 높이를 넘은 자리부터 퍼프를 남기고, 04번은 해 높이만큼 비껴 난 그림자를 언덕 위에 눕히며, 05번은
구름은 그림이 아니라 짙기다 — 구름 애니메이션은 칸마다 물방울이 얼마나 짙은지 계산하는 데서 시작한다
01 cld-puff-union-soft-outline
02 cld-silver-lining-light-march
03 cld-thermal-billow-flat-base
04 cld-ground-shadow-sun-offset
05 cld-shear-cirrus-streak
06 cld-sea-plane-horizon
07 cld-seamless-parallax-bands
08 cld-evaporate-dry-air-mix
09 cld-hero-sunrise-above-sea
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 퍼프를 녹여 붙인 만화 구름 윤곽
- 02 해를 등진 구름의 은빛 테두리
- 03 응결 높이에서 평평해지는 뭉게구름
- 04 해 높이만큼 비껴 지나는 언덕 위 구름 그림자
- 05 높이마다 다른 바람에 늘어나는 새털구름
- 06 비행기 창밖으로 펼쳐지는 구름바다
- 07 이음매 없이 흐르는 여러 겹 구름 띠
- 08 마른 공기와 섞여 가장자리부터 흩어지는 구름
- 09 해돋이 구름 위 히어로 장면
아홉 장면은 같은 노이즈 함수 몇 개를 서로 다른 곳에 꽂은 것이다. 01·02 는 구름 한 덩이의 윤곽과 빛, 03 은 구름이 생기는 높이, 04·05 는 땅과 바람이 구름에 남기는 흔적을 다룬다. 06·07 은 배경으로 깔 수 있는 평면과 띠, 08 은 흩어지는 구름이고 09 는 06·02·07 의 계산을 한 화면에 겹친다.
구름이 짙기 숫자 하나로 줄어드는 과정
하늘의 한 칸에는 구름이 있거나 없는 것이 아니라 물방울이 얼마나 짙은지의 숫자가 들어 있다. 그 숫자를 여러 겹 노이즈 합으로 만들고, 빛이 짙기를 지나며 얼마나 깎이는지를 계산하면 구름의 속은 어둡고 가장자리는 밝은 모양이 저절로 나온다. 움직이는 구름 gif 는 이미 그려진 프레임을 되풀이하지만, 이 편의 장면은 시각을 받아 매 프레임 짙기를 다시 계산하므로 해 높이나 바람 값을 바꾸면 다른 하늘이 나온다.
구름 애니메이션 소스는 캔버스 2D 하나로 끝난다
아홉 장면 모두 라이브러리·WebGL·CDN 과 사진 파일 없이 캔버스 2D 와 requestAnimationFrame 만 쓰고, 하늘·구름·땅은 전부 계산으로 칠한다. 화소 계산은 장면마다 작은 격자(02 는 판 하나 120×150 두 장, 03·08 은 160×100, 05·06·09 는 200×125, 01·04·07 은 240×150)에서 하고, putImageData 로 올린 그림을 480×300 으로 늘려 그린다. 난수는 시드를 고정한 생성기 하나뿐이라 Math.random 도 0/9 이며, 같은 코드는 같은 하늘을 그린다.
이웃한 편과 갈리는 지점
노이즈로 지형을 만드는 편은 캔버스 노이즈 지형, 같은 캔버스 장면의 기준선은 캔버스 특수효과 편이 다룬다. 하늘 색 띠 쪽은 노을 하늘 그라디언트 편, 같은 계산 방식으로 눈을 쌓는 장면은 눈 내리는 효과 편에 있다. 이 편은 구름 한 덩이가 어떻게 생기고 빛을 받고 흩어지는지를 아홉 가지로 쪼갠 것이 다르다.
구름 애니메이션 9종 — 퍼프를 녹여 붙인 만화 구름 윤곽·해를 등진 구름의 은빛 테두리·응결 높이에서 평평해지는 뭉게구름·해 높이만큼 비껴 지나는 언덕 위 구름 그림자·높이마다 다른 바람에 늘어나는 새털구름·비행기 창밖으로 펼쳐지는 구름바다·이음매 없이 흐르는 여러 겹 구름 띠·마른 공기와 섞여 가장자리부터 흩어지는 구름·해돋이 구름 위 히어로 장면
01 은 구름 한 덩이가 어떻게 이어 붙는지, 02 는 그 덩이가 빛을 어떻게 받는지부터 시작한다. 03 은 구름이 하늘 어디서 생기는지, 04·05 는 땅과 바람이 구름에 남기는 흔적을 보인다. 06·07 은 배경 장면이고 08 은 구름이 사라지는 쪽, 09 는 앞의 계산을 한 장면에 모은다.
아홉 장면을 렌더해서 얻은 면적·프레임·강도
| 장면 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 퍼프 윤곽 | 23.1% | 23/23 | 40.0 |
| 02 은빛 테두리 | 18.7% | 23/23 | 18.1 |
| 03 뭉게구름 | 8.3% | 23/23 | 25.1 |
| 04 구름 그림자 | 17.8% | 23/23 | 38.0 |
| 05 새털구름 | 29.6% | 23/23 | 19.3 |
| 06 구름바다 | 33.0% | 23/23 | 24.3 |
| 07 구름 띠 | 43.2% | 23/23 | 32.2 |
| 08 흩어지는 구름 | 14.2% | 13/23 | 54.9 |
| 09 해돋이 히어로 | 49.2% | 23/23 | 18.7 |
화면 대부분이 하늘 색으로 바뀌는 09 가 49.2% 로 가장 넓고, 구름 덩이 하나가 좁은 곳에서만 피어오르는 03 이 8.3% 로 가장 좁다. 강도 평균은 짙기가 걷혔다 차오르는 08 이 54.9 로 가장 높고 은빛 테두리 장면인 02 가 18.1 로 가장 낮다. 움직인 프레임은 08 만 13/23 이고 나머지 여덟 장면은 23/23 이다. 08 이 왜 13 인지는 이번 측정에서 따로 재지 않았다.
01퍼프를 녹여 붙인 만화 구름 윤곽
랜딩 첫 화면의 날씨 일러스트로 쓰면 원 11개가 하나의 윤곽으로 녹아 붙은 채 숨 쉬듯 출렁인다. smin 은 두 거리를 SMOOTH_K 18px 안에서 부드럽게 섞어, 8px 벌어진 두 원 한가운데 거리를 최솟값 4 에서 −0.5 로 안쪽에 메우고 틈 9px(SMOOTH_K / 2)까지 이어 붙인다. 퍼프 반지름은 PUFF_STIFFNESS 60 과 PUFF_DAMPING 5 의 스프링이라 한 번 차면 최대 +9.97px 부풀었다 −3.39px 넘치며 돌아오고, 감쇠비는 0.323 이다.

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="원 열한 개가 녹아 붙은 만화 구름 두 덩이가 좌우로 흐르고, 퍼프마다 반지름이 스프링으로 부풀었다 가라앉아도 윤곽이 끊기지 않으며, 점선 원이 섞기 전 퍼프 자리를 보여 준다"></canvas>
<p class="cl__status" data-role="status" aria-live="off">하늘을 읽는 중</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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-cream;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
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: #fff7e6;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return '오른쪽으로';
}
if (dir < 0) {
return '왼쪽으로';
}
return '잔잔함';
}
function skyText(cover, sunDeg, windDir) {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s, x, y, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 01 퍼프를 녹여 붙인 만화 구름 윤곽 ───────────────────────
// 퍼프(원) i 까지의 거리 d_i = |p − c_i| − r_i (안쪽이 음수). 이 값들을 부드러운 최솟값으로 차례로 섞어 윤곽 하나로
// smin(a, b) = min(a, b) − h² × SMOOTH_K / 4, h = max(SMOOTH_K − |a − b|, 0) / SMOOTH_K — 두 원이 SMOOTH_K px 안으로 가까워지면 그 사이가 메워진다
const SMOOTH_K = 18;
// 퍼프 반지름 스프링 — 가속 = −PUFF_STIFFNESS × (반지름 − 목표) − PUFF_DAMPING × 속도
const PUFF_STIFFNESS = 60;
const PUFF_DAMPING = 5;
// 1/240초 잔걸음마다 속도를 먼저, 그 속도로 반지름을 고친다(반 걸음 적분)
const PUFF_SUBSTEP = 1 / 240;
// 목표 반지름이 숨 쉬는 폭(원래 반지름의 몫)
const BREATH_AMP = 0.08;
// KICK_EVERY 초마다 퍼프 하나를 정해진 순서로 골라 반지름 속도를 (원래 반지름 × KICK_SHARE) px/초 만큼 밀어 준다 — 스프링이 넘쳤다 돌아오는 모습
const KICK_EVERY = 0.7;
const KICK_SHARE = 2.4;
// 격자 — 가로 240 × 세로 150 칸(칸 하나 2px)에서 거리를 계산해 화면으로 늘린다
const GRID_W = 240;
const GRID_H = 150;
const CELL = W / GRID_W;
// 구름 전체가 좌우로 흔들리는 폭(px)과 빠르기(라디안/초)
const SWAY_PX = 26;
const SWAY_RATE = 0.9;
const SUN_DEG_01 = 55;
const LOOP_S = 0;
const START_T = 1.2;
const REST_T = 1.2;
const rand01 = seeded(101);
// 큰 구름 퍼프 7개(가운데 아래쪽이 넓게) + 작은 구름 4개 — 구름 가운데에서 잰 자리(px)
const PUFF_LAYOUT = [
[-96, 22, 38, 0], [-52, -4, 50, 0], [0, -26, 62, 0], [56, -8, 52, 0], [100, 20, 38, 0], [-30, 34, 40, 0], [36, 36, 42, 0],
[-26, 4, 22, 1], [0, -10, 28, 1], [26, 2, 22, 1], [4, 10, 20, 1],
];
const puffs = PUFF_LAYOUT.map(function (p) {
return { ox: p[0], oy: p[1], r0: p[2], r: p[2], v: 0, rate: 1.1 + rand01() * 1.4, phase: rand01() * TAU, cloud: p[3] };
});
const KICK_ORDER = [2, 5, 8, 0, 3, 6, 9, 1, 4, 10, 7];
let lastKick = -1;
const dist01 = makeField(GRID_W, GRID_H);
// 구름 두 개의 가운데 자리 — 큰 것은 좌우로 흔들리고, 작은 것은 오른쪽으로 흘러 화면 끝에서 왼쪽 끝으로 돌아온다
function cloudCenter(cloud, t) {
if (cloud === 0) {
return [250 + Math.sin(t * SWAY_RATE) * SWAY_PX, 178];
}
const span = W + 140;
const x = ((t * 22 + 120) % span + span) % span - 70;
return [x, 70];
}
function smin(a, b, k) {
const h = Math.max(k - Math.abs(a - b), 0) / k;
return Math.min(a, b) - h * h * k / 4;
}
// 화면 점 (x, y) 에서 구름 윤곽까지의 거리(px, 안쪽 음수) — 퍼프 거리를 차례로 부드럽게 섞는다
function cloudDistance(x, y, k) {
let d = 1e9;
for (let c = 0; c < 2; c += 1) {
const cc = cloudCenter(c, sceneT);
let dc = 1e9;
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
if (p.cloud !== c) {
continue;
}
const di = Math.hypot(x - cc[0] - p.ox, y - cc[1] - p.oy) - p.r;
dc = k > 0 ? smin(dc, di, k) : Math.min(dc, di);
}
d = Math.min(d, dc);
}
return d;
}
function puffTarget(p, t) { return p.r0 * (1 + BREATH_AMP * Math.sin(t * p.rate + p.phase)); }
function springStep(p, h, t) {
const a = -PUFF_STIFFNESS * (p.r - puffTarget(p, t)) - PUFF_DAMPING * p.v;
p.v += a * h;
p.r += p.v * h;
}
function reset() {
for (const p of puffs) {
p.r = p.r0;
p.v = 0;
}
lastKick = -1;
}
function update(dt) {
const k = Math.floor(sceneT / KICK_EVERY);
if (k !== lastKick) {
lastKick = k;
const p = puffs[KICK_ORDER[((k % KICK_ORDER.length) + KICK_ORDER.length) % KICK_ORDER.length]];
p.v += p.r0 * KICK_SHARE;
}
let left = dt;
const t0 = sceneT - dt;
while (left > 1e-9) {
const h = Math.min(PUFF_SUBSTEP, left);
for (const p of puffs) {
springStep(p, h, t0 + (dt - left));
}
left -= h;
}
}
// 격자 칸마다 거리를 채운다 — 화면 쪽이 그리기 직전에 한 번 부른다
function buildField() {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
dist01.v[gy * GRID_W + gx] = cloudDistance((gx + 0.5) * CELL, (gy + 0.5) * CELL, SMOOTH_K);
}
}
}
function insideShare() {
let n = 0;
for (let i = 0; i < dist01.v.length; i += 1) {
if (dist01.v[i] < 0) {
n += 1;
}
}
return n / dist01.v.length;
}
// ── 01 화면 ──────────────────────────────────────────────────
const pix01 = makePix(GRID_W, GRID_H);
// 해 자리(px) — 오른쪽 위
const SUN_X_01 = 404;
const SUN_Y_01 = 58;
const OUTLINE_01 = [104, 96, 158];
// 빛이 오는 쪽 이웃 칸(왼쪽 1칸·위 2칸) — 그 칸이 더 안쪽이면 지금 칸은 빛을 등진 아랫면
const LIGHT_DX = -1;
const LIGHT_DY = -2;
const LIGHT_LEN = Math.hypot(LIGHT_DX, LIGHT_DY) * CELL;
function draw() {
buildField();
const d = pix01.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
const sunD = Math.hypot(x - SUN_X_01, y - SUN_Y_01);
let c = mix(sky, BUTTER, clamp(1 - (sunD - 26) / 70, 0, 1) * 0.55);
if (sunD < 26) {
c = BUTTER;
}
const dv = dist01.v[i];
if (dv < 1.5) {
const nx = clamp(gx + LIGHT_DX, 0, GRID_W - 1);
const ny = clamp(gy + LIGHT_DY, 0, GRID_H - 1);
const s = dv - dist01.v[ny * GRID_W + nx];
// 빛 쪽 이웃과의 거리 차로 그늘 — 윤곽 가까운 띠(안쪽 16px)에서만 쓰고, 더 안쪽은 옅은 한 톤(퍼프 사이 골이 줄무늬로 보이지 않게)
const deep = 1 - band(-16, -6, dv);
const shade = band(-LIGHT_LEN * 0.3, LIGHT_LEN, s) * (1 - deep) + 0.12 * deep;
let cl = mix(CLOUD, CLOUD_SHADE, shade * 0.62);
// 윤곽선 — 경계 안쪽 4px 띠를 진한 라벤더로
cl = mix(cl, OUTLINE_01, band(-5, -2.5, dv) * (1 - band(-0.5, 1.2, dv)));
c = mix(c, cl, 1 - band(-0.2, 1.4, dv));
}
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
}
blit(pix01, 0, 0, W, H);
// 퍼프 원 — 섞기 전의 원 자리를 점선으로(녹아 붙은 자리가 원보다 넓게 메워진 것이 보이게)
ctx.setLineDash([3, 4]);
for (const p of puffs) {
const cc = cloudCenter(p.cloud, sceneT);
ring(cc[0] + p.ox, cc[1] + p.oy, p.r, 1, INK, 0.28);
}
ctx.setLineDash([]);
tag('녹임 폭 {k}px'.replace('{k}', String(SMOOTH_K)), 14, H - 20, INK, 11, 'left');
}
function statusText() { return skyText(insideShare(), SUN_DEG_01, 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// PuffUnionOutline - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./PuffUnionOutline.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PuffUnionOutline() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s: string, x: number, y: number, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 01 퍼프를 녹여 붙인 만화 구름 윤곽 ───────────────────────
// 퍼프(원) i 까지의 거리 d_i = |p − c_i| − r_i (안쪽이 음수). 이 값들을 부드러운 최솟값으로 차례로 섞어 윤곽 하나로
// smin(a, b) = min(a, b) − h² × SMOOTH_K / 4, h = max(SMOOTH_K − |a − b|, 0) / SMOOTH_K — 두 원이 SMOOTH_K px 안으로 가까워지면 그 사이가 메워진다
const SMOOTH_K = 18;
// 퍼프 반지름 스프링 — 가속 = −PUFF_STIFFNESS × (반지름 − 목표) − PUFF_DAMPING × 속도
const PUFF_STIFFNESS = 60;
const PUFF_DAMPING = 5;
// 1/240초 잔걸음마다 속도를 먼저, 그 속도로 반지름을 고친다(반 걸음 적분)
const PUFF_SUBSTEP = 1 / 240;
// 목표 반지름이 숨 쉬는 폭(원래 반지름의 몫)
const BREATH_AMP = 0.08;
// KICK_EVERY 초마다 퍼프 하나를 정해진 순서로 골라 반지름 속도를 (원래 반지름 × KICK_SHARE) px/초 만큼 밀어 준다 — 스프링이 넘쳤다 돌아오는 모습
const KICK_EVERY = 0.7;
const KICK_SHARE = 2.4;
// 격자 — 가로 240 × 세로 150 칸(칸 하나 2px)에서 거리를 계산해 화면으로 늘린다
const GRID_W = 240;
const GRID_H = 150;
const CELL = W / GRID_W;
// 구름 전체가 좌우로 흔들리는 폭(px)과 빠르기(라디안/초)
const SWAY_PX = 26;
const SWAY_RATE = 0.9;
const SUN_DEG_01 = 55;
const LOOP_S = 0;
const START_T = 1.2;
const REST_T = 1.2;
type Puff = { ox: number; oy: number; r0: number; r: number; v: number; rate: number; phase: number; cloud: number };
const rand01 = seeded(101);
// 큰 구름 퍼프 7개(가운데 아래쪽이 넓게) + 작은 구름 4개 — 구름 가운데에서 잰 자리(px)
const PUFF_LAYOUT: number[][] = [
[-96, 22, 38, 0], [-52, -4, 50, 0], [0, -26, 62, 0], [56, -8, 52, 0], [100, 20, 38, 0], [-30, 34, 40, 0], [36, 36, 42, 0],
[-26, 4, 22, 1], [0, -10, 28, 1], [26, 2, 22, 1], [4, 10, 20, 1],
];
const puffs: Puff[] = PUFF_LAYOUT.map(function (p: number[]): Puff {
return { ox: p[0], oy: p[1], r0: p[2], r: p[2], v: 0, rate: 1.1 + rand01() * 1.4, phase: rand01() * TAU, cloud: p[3] };
});
const KICK_ORDER: number[] = [2, 5, 8, 0, 3, 6, 9, 1, 4, 10, 7];
let lastKick = -1;
const dist01 = makeField(GRID_W, GRID_H);
// 구름 두 개의 가운데 자리 — 큰 것은 좌우로 흔들리고, 작은 것은 오른쪽으로 흘러 화면 끝에서 왼쪽 끝으로 돌아온다
function cloudCenter(cloud: number, t: number): [number, number] {
if (cloud === 0) { return [250 + Math.sin(t * SWAY_RATE) * SWAY_PX, 178]; }
const span = W + 140;
const x = ((t * 22 + 120) % span + span) % span - 70;
return [x, 70];
}
function smin(a: number, b: number, k: number): number {
const h = Math.max(k - Math.abs(a - b), 0) / k;
return Math.min(a, b) - h * h * k / 4;
}
// 화면 점 (x, y) 에서 구름 윤곽까지의 거리(px, 안쪽 음수) — 퍼프 거리를 차례로 부드럽게 섞는다
function cloudDistance(x: number, y: number, k: number): number {
let d = 1e9;
for (let c = 0; c < 2; c += 1) {
const cc = cloudCenter(c, sceneT);
let dc = 1e9;
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
if (p.cloud !== c) { continue; }
const di = Math.hypot(x - cc[0] - p.ox, y - cc[1] - p.oy) - p.r;
dc = k > 0 ? smin(dc, di, k) : Math.min(dc, di);
}
d = Math.min(d, dc);
}
return d;
}
function puffTarget(p: Puff, t: number): number { return p.r0 * (1 + BREATH_AMP * Math.sin(t * p.rate + p.phase)); }
function springStep(p: Puff, h: number, t: number): void {
const a = -PUFF_STIFFNESS * (p.r - puffTarget(p, t)) - PUFF_DAMPING * p.v;
p.v += a * h;
p.r += p.v * h;
}
function reset(): void {
for (const p of puffs) { p.r = p.r0; p.v = 0; }
lastKick = -1;
}
function update(dt: number): void {
const k = Math.floor(sceneT / KICK_EVERY);
if (k !== lastKick) {
lastKick = k;
const p = puffs[KICK_ORDER[((k % KICK_ORDER.length) + KICK_ORDER.length) % KICK_ORDER.length]];
p.v += p.r0 * KICK_SHARE;
}
let left = dt;
const t0 = sceneT - dt;
while (left > 1e-9) {
const h = Math.min(PUFF_SUBSTEP, left);
for (const p of puffs) { springStep(p, h, t0 + (dt - left)); }
left -= h;
}
}
// 격자 칸마다 거리를 채운다 — 화면 쪽이 그리기 직전에 한 번 부른다
function buildField(): void {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
dist01.v[gy * GRID_W + gx] = cloudDistance((gx + 0.5) * CELL, (gy + 0.5) * CELL, SMOOTH_K);
}
}
}
function insideShare(): number {
let n = 0;
for (let i = 0; i < dist01.v.length; i += 1) { if (dist01.v[i] < 0) { n += 1; } }
return n / dist01.v.length;
}
// ── 01 화면 ──────────────────────────────────────────────────
const pix01 = makePix(GRID_W, GRID_H);
// 해 자리(px) — 오른쪽 위
const SUN_X_01 = 404;
const SUN_Y_01 = 58;
const OUTLINE_01: RGB = [104, 96, 158];
// 빛이 오는 쪽 이웃 칸(왼쪽 1칸·위 2칸) — 그 칸이 더 안쪽이면 지금 칸은 빛을 등진 아랫면
const LIGHT_DX = -1;
const LIGHT_DY = -2;
const LIGHT_LEN = Math.hypot(LIGHT_DX, LIGHT_DY) * CELL;
function draw(): void {
buildField();
const d = pix01.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
const sunD = Math.hypot(x - SUN_X_01, y - SUN_Y_01);
let c = mix(sky, BUTTER, clamp(1 - (sunD - 26) / 70, 0, 1) * 0.55);
if (sunD < 26) { c = BUTTER; }
const dv = dist01.v[i];
if (dv < 1.5) {
const nx = clamp(gx + LIGHT_DX, 0, GRID_W - 1);
const ny = clamp(gy + LIGHT_DY, 0, GRID_H - 1);
const s = dv - dist01.v[ny * GRID_W + nx];
// 빛 쪽 이웃과의 거리 차로 그늘 — 윤곽 가까운 띠(안쪽 16px)에서만 쓰고, 더 안쪽은 옅은 한 톤(퍼프 사이 골이 줄무늬로 보이지 않게)
const deep = 1 - band(-16, -6, dv);
const shade = band(-LIGHT_LEN * 0.3, LIGHT_LEN, s) * (1 - deep) + 0.12 * deep;
let cl = mix(CLOUD, CLOUD_SHADE, shade * 0.62);
// 윤곽선 — 경계 안쪽 4px 띠를 진한 라벤더로
cl = mix(cl, OUTLINE_01, band(-5, -2.5, dv) * (1 - band(-0.5, 1.2, dv)));
c = mix(c, cl, 1 - band(-0.2, 1.4, dv));
}
const k = i * 4;
d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = 255;
}
}
blit(pix01, 0, 0, W, H);
// 퍼프 원 — 섞기 전의 원 자리를 점선으로(녹아 붙은 자리가 원보다 넓게 메워진 것이 보이게)
ctx.setLineDash([3, 4]);
for (const p of puffs) {
const cc = cloudCenter(p.cloud, sceneT);
ring(cc[0] + p.ox, cc[1] + p.oy, p.r, 1, INK, 0.28);
}
ctx.setLineDash([]);
tag('녹임 폭 {k}px'.replace('{k}', String(SMOOTH_K)), 14, H - 20, INK, 11, 'left');
}
function statusText(): string { return skyText(insideShare(), SUN_DEG_01, 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'원 열한 개가 녹아 붙은 만화 구름 두 덩이가 좌우로 흐르고, 퍼프마다 반지름이 스프링으로 부풀었다 가라앉아도 윤곽이 끊기지 않으며, 점선 원이 섞기 전 퍼프 자리를 보여 준다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}01 · 퍼프를 녹여 붙인 만화 구름 윤곽 — 측정 누적면적 23.124% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function smin(a, b, k) {
const h = Math.max(k - Math.abs(a - b), 0) / k;
return Math.min(a, b) - h * h * k / 4;
}
// 화면 점 (x, y) 에서 구름 윤곽까지의 거리(px, 안쪽 음수) — 퍼프 거리를 차례로 부드럽게 섞는다
function cloudDistance(x, y, k) {
let d = 1e9;
for (let c = 0; c < 2; c += 1) {
const cc = cloudCenter(c, sceneT);
let dc = 1e9;
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
if (p.cloud !== c) {
continue;
}
const di = Math.hypot(x - cc[0] - p.ox, y - cc[1] - p.oy) - p.r;
dc = k > 0 ? smin(dc, di, k) : Math.min(dc, di);
}
d = Math.min(d, dc);
}
return d;
}
02해를 등진 구름의 은빛 테두리
서비스 소개 페이지의 해 질 녘 배경에 어울린다. lightSum 은 칸마다 해 쪽으로 6걸음 걸으며 짙기를 모으고 e^(−ABSORB × S) 로 빛을 깎아, 짙기 합 1 을 지나면 투과가 0.407 만 남는다. 시연 2초 화면에서 구름 속(짙기 0.85 위)의 평균 투과는 0.135 이고, 하늘 빛 하한이 있는 오른쪽 판은 얇은 가장자리가 밝기 0.879, 속이 0.742 로 가장자리가 더 밝다.

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="해 질 녘 해를 등진 같은 구름 두 장이 나란히 있고, 왼쪽은 주변광 없이 해를 등진 쪽이 거의 까맣게, 오른쪽은 하늘 빛 하한을 더해 속은 어둡고 얇은 가장자리만 하얗게 빛난다"></canvas>
<p class="cl__status" data-role="status" aria-live="off">하늘을 읽는 중</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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-cream;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
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: #fff7e6;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return '오른쪽으로';
}
if (dir < 0) {
return '왼쪽으로';
}
return '잔잔함';
}
function skyText(cover, sunDeg, windDir) {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s, x, y, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 02 해를 등진 구름의 은빛 테두리 ───────────────────────────
// 같은 구름을 두 장(왼쪽 = 주변광 없음, 오른쪽 = 하늘 빛 하한) 나란히 — 짙기·빛 걸음은 한 번만 계산하고 색만 두 번 칠한다
const PANEL_W = 240;
const PANEL_H = 300;
const GRID_W = 120;
const GRID_H = 150;
const CELL = PANEL_W / GRID_W;
// 해 쪽 걸음 — 칸마다 해를 향해 LIGHT_STEPS 걸음(한 걸음 LIGHT_STEP_PX)을 걸으며 짙기 × LIGHT_STEP_FRAC 를 더해 S 를 만든다
const LIGHT_STEPS = 6;
const LIGHT_STEP_PX = 7;
const LIGHT_STEP_FRAC = 0.45;
// 지수로 깎이는 투과 — T = e^(−ABSORB × S). ABSORB 가 클수록 같은 짙기에서 빛이 빨리 줄어든다
const ABSORB = 0.9;
// 앞쪽 산란 — 시선과 해 방향의 코사인 c 에 대해 FORWARD_GAIN × c^FORWARD_POWER. 해 가까이 볼수록 얇은 곳이 밝아진다
const FORWARD_POWER = 8;
const FORWARD_GAIN = 1.2;
// 해에서 바로 오는 빛의 기본 몫
const BASE_LIGHT = 0.35;
// 하늘 빛 하한 — 해가 다 깎여도 하늘 전체에서 받는 빛. 실패판(왼쪽)은 0
const AMBIENT_LIGHT = 0.6;
// 짙기가 화면에서 가리는 정도 — 불투명도 = 1 − e^(−짙기 × OPACITY)
const OPACITY = 3.2;
// 시선 모형 — 눈은 판 가운데 앞 FOCAL_PX 에 있다
const FOCAL_PX = 200;
// 해 자리(판 안 px) — 구름 오른쪽 위 뒤편
const SUN_X = 168;
const SUN_Y = 92;
const SUN_DEG_02 = 9;
// 노이즈 흐름 — 초당 노이즈 좌표를 이만큼 민다
const DRIFT_X = 0.35;
const DRIFT_Y = -0.1;
const NOISE_SCALE_PX = 34;
const LOOP_S = 0;
const START_T = 2;
const REST_T = 2;
const dens02 = makeField(GRID_W, GRID_H);
const trans02 = new Float32Array(GRID_W * GRID_H);
// 구름 덩어리 모양 — 원 넷까지 거리의 최솟값(판 안 px, 안쪽 음수)
const BLOB = [[92, 176, 62], [140, 150, 58], [176, 190, 44], [60, 214, 40]];
function blobDistance(x, y) {
let d = 1e9;
for (const b of BLOB) {
d = Math.min(d, Math.hypot(x - b[0], y - b[1]) - b[2]);
}
return d;
}
// 칸 (x, y) 의 짙기 — 덩어리 안쪽일수록 크고, 여러 겹 노이즈 합이 가장자리를 뜯어낸다
function density02(x, y, t) {
const shape = 1 - band(-34, 26, blobDistance(x, y));
const n = layeredNoise(x / NOISE_SCALE_PX + t * DRIFT_X, y / NOISE_SCALE_PX + t * DRIFT_Y, 4, 7);
return clamp((shape * 1.15 + (n - 0.5) * 1.3 - 0.42) * 1.6, 0, 1);
}
function buildDensity(t) {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
dens02.v[gy * GRID_W + gx] = density02((gx + 0.5) * CELL, (gy + 0.5) * CELL, t);
}
}
}
// 칸에서 해 쪽으로 걸어 모은 짙기 합 S(자기 칸 반 걸음 포함)
function lightSum(gx, gy, steps) {
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
const dx = SUN_X - x;
const dy = SUN_Y - y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len * LIGHT_STEP_PX / CELL;
const uy = dy / len * LIGHT_STEP_PX / CELL;
let s = dens02.v[gy * GRID_W + gx] * 0.5;
for (let k = 1; k <= steps; k += 1) {
s += sampleField(dens02, gx + ux * k, gy + uy * k);
}
return s * LIGHT_STEP_FRAC;
}
// 앞쪽 산란 몫 — 판 안 점 (x, y) 를 보는 시선과 해 쪽 시선의 코사인을 거듭제곱
function forwardPhase(x, y) {
const vx = x - PANEL_W / 2;
const vy = y - PANEL_H / 2;
const sx = SUN_X - PANEL_W / 2;
const sy = SUN_Y - PANEL_H / 2;
const c = (vx * sx + vy * sy + FOCAL_PX * FOCAL_PX) / (Math.hypot(vx, vy, FOCAL_PX) * Math.hypot(sx, sy, FOCAL_PX));
return FORWARD_GAIN * Math.pow(Math.max(0, c), FORWARD_POWER);
}
// 칸의 구름 밝기 — 해 빛 T × (기본 몫 + 앞쪽 산란) + 하늘 빛 하한
function cloudLight(T, phase, ambient) { return T * (BASE_LIGHT + phase) + ambient; }
function buildLight() {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
trans02[i] = dens02.v[i] > 0 ? Math.exp(-ABSORB * lightSum(gx, gy, LIGHT_STEPS)) : 1;
}
}
}
function reset() { }
function update(dt) { }
// ── 02 화면 ──────────────────────────────────────────────────
const pixFail02 = makePix(GRID_W, GRID_H);
const pixFix02 = makePix(GRID_W, GRID_H);
const DUSK_HIGH = [166, 156, 214];
const DUSK_LOW = [250, 204, 176];
const SUN_TINT = [255, 242, 224];
const AMBIENT_TINT = [206, 194, 240];
function paintPanel02(p, ambient) {
const d = p.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(DUSK_HIGH, DUSK_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
const sunD = Math.hypot(x - SUN_X, y - SUN_Y);
let c = mix(sky, SUN_TINT, clamp(1 - (sunD - 16) / 90, 0, 1) * 0.7);
if (sunD < 16) {
c = SUN_TINT;
}
const dv = dens02.v[i];
if (dv > 0) {
const L = trans02[i] * (BASE_LIGHT + forwardPhase(x, y));
const cl = [
SUN_TINT[0] * L * 1.15 + AMBIENT_TINT[0] * ambient,
SUN_TINT[1] * L * 1.15 + AMBIENT_TINT[1] * ambient,
SUN_TINT[2] * L * 1.15 + AMBIENT_TINT[2] * ambient,
];
c = mix(c, cl, 1 - Math.exp(-dv * OPACITY));
}
const k = i * 4;
d[k] = clamp(c[0], 0, 255);
d[k + 1] = clamp(c[1], 0, 255);
d[k + 2] = clamp(c[2], 0, 255);
d[k + 3] = 255;
}
}
}
function draw() {
buildDensity(sceneT);
buildLight();
paintPanel02(pixFail02, 0);
paintPanel02(pixFix02, AMBIENT_LIGHT);
blit(pixFail02, 0, 0, PANEL_W, PANEL_H);
blit(pixFix02, PANEL_W, 0, PANEL_W, PANEL_H);
seg(PANEL_W, 0, PANEL_W, H, 2, PAPER, 0.9, 0);
tag('주변광 없음', PANEL_W / 2, H - 22, CORAL_DEEP, 12, 'center');
tag('하늘 빛 하한 {a}'.replace('{a}', String(AMBIENT_LIGHT)), PANEL_W + PANEL_W / 2, H - 22, MINT_DEEP, 12, 'center');
}
function statusText() { return skyText(coverOf(dens02, 0.1), SUN_DEG_02, 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SilverLiningLight - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SilverLiningLight.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SilverLiningLight() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s: string, x: number, y: number, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 02 해를 등진 구름의 은빛 테두리 ───────────────────────────
// 같은 구름을 두 장(왼쪽 = 주변광 없음, 오른쪽 = 하늘 빛 하한) 나란히 — 짙기·빛 걸음은 한 번만 계산하고 색만 두 번 칠한다
const PANEL_W = 240;
const PANEL_H = 300;
const GRID_W = 120;
const GRID_H = 150;
const CELL = PANEL_W / GRID_W;
// 해 쪽 걸음 — 칸마다 해를 향해 LIGHT_STEPS 걸음(한 걸음 LIGHT_STEP_PX)을 걸으며 짙기 × LIGHT_STEP_FRAC 를 더해 S 를 만든다
const LIGHT_STEPS = 6;
const LIGHT_STEP_PX = 7;
const LIGHT_STEP_FRAC = 0.45;
// 지수로 깎이는 투과 — T = e^(−ABSORB × S). ABSORB 가 클수록 같은 짙기에서 빛이 빨리 줄어든다
const ABSORB = 0.9;
// 앞쪽 산란 — 시선과 해 방향의 코사인 c 에 대해 FORWARD_GAIN × c^FORWARD_POWER. 해 가까이 볼수록 얇은 곳이 밝아진다
const FORWARD_POWER = 8;
const FORWARD_GAIN = 1.2;
// 해에서 바로 오는 빛의 기본 몫
const BASE_LIGHT = 0.35;
// 하늘 빛 하한 — 해가 다 깎여도 하늘 전체에서 받는 빛. 실패판(왼쪽)은 0
const AMBIENT_LIGHT = 0.6;
// 짙기가 화면에서 가리는 정도 — 불투명도 = 1 − e^(−짙기 × OPACITY)
const OPACITY = 3.2;
// 시선 모형 — 눈은 판 가운데 앞 FOCAL_PX 에 있다
const FOCAL_PX = 200;
// 해 자리(판 안 px) — 구름 오른쪽 위 뒤편
const SUN_X = 168;
const SUN_Y = 92;
const SUN_DEG_02 = 9;
// 노이즈 흐름 — 초당 노이즈 좌표를 이만큼 민다
const DRIFT_X = 0.35;
const DRIFT_Y = -0.1;
const NOISE_SCALE_PX = 34;
const LOOP_S = 0;
const START_T = 2;
const REST_T = 2;
const dens02 = makeField(GRID_W, GRID_H);
const trans02 = new Float32Array(GRID_W * GRID_H);
// 구름 덩어리 모양 — 원 넷까지 거리의 최솟값(판 안 px, 안쪽 음수)
const BLOB: number[][] = [[92, 176, 62], [140, 150, 58], [176, 190, 44], [60, 214, 40]];
function blobDistance(x: number, y: number): number {
let d = 1e9;
for (const b of BLOB) { d = Math.min(d, Math.hypot(x - b[0], y - b[1]) - b[2]); }
return d;
}
// 칸 (x, y) 의 짙기 — 덩어리 안쪽일수록 크고, 여러 겹 노이즈 합이 가장자리를 뜯어낸다
function density02(x: number, y: number, t: number): number {
const shape = 1 - band(-34, 26, blobDistance(x, y));
const n = layeredNoise(x / NOISE_SCALE_PX + t * DRIFT_X, y / NOISE_SCALE_PX + t * DRIFT_Y, 4, 7);
return clamp((shape * 1.15 + (n - 0.5) * 1.3 - 0.42) * 1.6, 0, 1);
}
function buildDensity(t: number): void {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
dens02.v[gy * GRID_W + gx] = density02((gx + 0.5) * CELL, (gy + 0.5) * CELL, t);
}
}
}
// 칸에서 해 쪽으로 걸어 모은 짙기 합 S(자기 칸 반 걸음 포함)
function lightSum(gx: number, gy: number, steps: number): number {
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
const dx = SUN_X - x;
const dy = SUN_Y - y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len * LIGHT_STEP_PX / CELL;
const uy = dy / len * LIGHT_STEP_PX / CELL;
let s = dens02.v[gy * GRID_W + gx] * 0.5;
for (let k = 1; k <= steps; k += 1) { s += sampleField(dens02, gx + ux * k, gy + uy * k); }
return s * LIGHT_STEP_FRAC;
}
// 앞쪽 산란 몫 — 판 안 점 (x, y) 를 보는 시선과 해 쪽 시선의 코사인을 거듭제곱
function forwardPhase(x: number, y: number): number {
const vx = x - PANEL_W / 2;
const vy = y - PANEL_H / 2;
const sx = SUN_X - PANEL_W / 2;
const sy = SUN_Y - PANEL_H / 2;
const c = (vx * sx + vy * sy + FOCAL_PX * FOCAL_PX) / (Math.hypot(vx, vy, FOCAL_PX) * Math.hypot(sx, sy, FOCAL_PX));
return FORWARD_GAIN * Math.pow(Math.max(0, c), FORWARD_POWER);
}
// 칸의 구름 밝기 — 해 빛 T × (기본 몫 + 앞쪽 산란) + 하늘 빛 하한
function cloudLight(T: number, phase: number, ambient: number): number { return T * (BASE_LIGHT + phase) + ambient; }
function buildLight(): void {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
trans02[i] = dens02.v[i] > 0 ? Math.exp(-ABSORB * lightSum(gx, gy, LIGHT_STEPS)) : 1;
}
}
}
function reset(): void { /* 상태가 없다 — 짙기는 시각으로만 정해진다 */ }
function update(dt: number): void { /* 시각만 흐른다 */ }
// ── 02 화면 ──────────────────────────────────────────────────
const pixFail02 = makePix(GRID_W, GRID_H);
const pixFix02 = makePix(GRID_W, GRID_H);
const DUSK_HIGH: RGB = [166, 156, 214];
const DUSK_LOW: RGB = [250, 204, 176];
const SUN_TINT: RGB = [255, 242, 224];
const AMBIENT_TINT: RGB = [206, 194, 240];
function paintPanel02(p: Pix, ambient: number): void {
const d = p.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(DUSK_HIGH, DUSK_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
const sunD = Math.hypot(x - SUN_X, y - SUN_Y);
let c = mix(sky, SUN_TINT, clamp(1 - (sunD - 16) / 90, 0, 1) * 0.7);
if (sunD < 16) { c = SUN_TINT; }
const dv = dens02.v[i];
if (dv > 0) {
const L = trans02[i] * (BASE_LIGHT + forwardPhase(x, y));
const cl: RGB = [
SUN_TINT[0] * L * 1.15 + AMBIENT_TINT[0] * ambient,
SUN_TINT[1] * L * 1.15 + AMBIENT_TINT[1] * ambient,
SUN_TINT[2] * L * 1.15 + AMBIENT_TINT[2] * ambient,
];
c = mix(c, cl, 1 - Math.exp(-dv * OPACITY));
}
const k = i * 4;
d[k] = clamp(c[0], 0, 255); d[k + 1] = clamp(c[1], 0, 255); d[k + 2] = clamp(c[2], 0, 255); d[k + 3] = 255;
}
}
}
function draw(): void {
buildDensity(sceneT);
buildLight();
paintPanel02(pixFail02, 0);
paintPanel02(pixFix02, AMBIENT_LIGHT);
blit(pixFail02, 0, 0, PANEL_W, PANEL_H);
blit(pixFix02, PANEL_W, 0, PANEL_W, PANEL_H);
seg(PANEL_W, 0, PANEL_W, H, 2, PAPER, 0.9, 0);
tag('주변광 없음', PANEL_W / 2, H - 22, CORAL_DEEP, 12, 'center');
tag('하늘 빛 하한 {a}'.replace('{a}', String(AMBIENT_LIGHT)), PANEL_W + PANEL_W / 2, H - 22, MINT_DEEP, 12, 'center');
}
function statusText(): string { return skyText(coverOf(dens02, 0.1), SUN_DEG_02, 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'해 질 녘 해를 등진 같은 구름 두 장이 나란히 있고, 왼쪽은 주변광 없이 해를 등진 쪽이 거의 까맣게, 오른쪽은 하늘 빛 하한을 더해 속은 어둡고 얇은 가장자리만 하얗게 빛난다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 · 해를 등진 구름의 은빛 테두리 — 측정 누적면적 18.733% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 칸에서 해 쪽으로 걸어 모은 짙기 합 S(자기 칸 반 걸음 포함)
function lightSum(gx, gy, steps) {
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
const dx = SUN_X - x;
const dy = SUN_Y - y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len * LIGHT_STEP_PX / CELL;
const uy = dy / len * LIGHT_STEP_PX / CELL;
let s = dens02.v[gy * GRID_W + gx] * 0.5;
for (let k = 1; k <= steps; k += 1) {
s += sampleField(dens02, gx + ux * k, gy + uy * k);
}
return s * LIGHT_STEP_FRAC;
}
03응결 높이에서 평평해지는 뭉게구름
날씨 앱의 오후 소나기 예보 카드에 어울린다. 공기 덩이 70개가 buoyancy 의 부력으로 오르다 CONDENSE_H 112px 를 넘은 것만 stampPuff 로 짙기를 남겨, 밑면은 10초 내내 110px 줄에 붙어 있고 꼭대기 근처 폭은 2초의 105px 에서 10초의 261px 로 퍼진다. 응결 높이 위에서는 MOIST_LAPSE 0.04 만큼만 식으므로 덩이가 더 오르다가, 위로 속도가 SPREAD_SPEED 14 보다 느려지면 가운데에서 먼 쪽으로 밀려난다.

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="바닥에서 오르는 공기 덩이 점들이 점선으로 표시한 응결 높이를 넘는 순간부터 퍼프를 남겨, 밑면이 평평한 뭉게구름이 자라고 오를 힘을 잃은 꼭대기는 옆으로 퍼진다"></canvas>
<p class="cl__status" data-role="status" aria-live="off">하늘을 읽는 중</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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-cream;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
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: #fff7e6;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return '오른쪽으로';
}
if (dir < 0) {
return '왼쪽으로';
}
return '잔잔함';
}
function skyText(cover, sunDeg, windDir) {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s, x, y, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 03 응결 높이에서 평평해지는 뭉게구름 ───────────────────────
// 옆에서 본 하늘. 높이 h = 땅(GROUND_Y)에서 잰 화면 px(위가 +). 온도는 화면 단위 상대값이다(실제 기온이 아니다)
const GROUND_Y = 272;
// 주변 공기 — 땅에서 ENV_LAPSE 씩 h 1px 마다 식고, 덮개 높이 CAP_H 위로는 더 식지 않는다(덮개를 넘은 덩이는 젖은 비율로 식기만 해 곧 부력을 잃고 멈춘다)
const ENV_LAPSE = 0.06;
const CAP_H = 170;
// 덩이 — 마른 동안은 DRY_LAPSE 씩, 응결 높이 CONDENSE_H 를 넘으면 MOIST_LAPSE 씩 식는다(물방울이 맺히며 열을 내놓아 덜 식는다)
const DRY_LAPSE = 0.08;
const MOIST_LAPSE = 0.04;
const CONDENSE_H = 112;
// 부력 = (덩이 온도 − 주변 온도) × BUOYANCY, 저항 = 속도 × DRAG (px/초²)
const BUOYANCY = 30;
const DRAG = 1.6;
// 오를 힘을 잃은 꼭대기(위로 속도 < SPREAD_SPEED)는 가운데에서 먼 쪽으로 SPREAD_ACCEL 만큼 밀려 옆으로 퍼진다
const SPREAD_SPEED = 14;
const SPREAD_ACCEL = 46;
// 퍼프 — 응결 높이 위 덩이가 반지름 PUFF_CELLS 칸 안에 초당 PUFF_RATE 만큼 짙기를 남긴다. 짙기는 초당 FADE_RATE 몫씩 옅어진다
const PUFF_CELLS = 4;
const PUFF_RATE = 3.5;
const FADE_RATE = 0.12;
const SINK_SPEED = 6;
const PARCEL_COUNT = 70;
const SOURCE_X = 240;
const GRID_W = 160;
const GRID_H = 100;
const CELL = W / GRID_W;
const SUN_DEG_03 = 62;
const LOOP_S = 12;
const START_T = 4.5;
const REST_T = 8;
const parcels = [];
const cloud03 = makeField(GRID_W, GRID_H);
let spawnRand = seeded(303);
function envTemp(h) { return -ENV_LAPSE * Math.min(h, CAP_H); }
// 덩이가 dh 만큼 오를 때 식는 양 — 응결 높이 아래는 마른 비율, 위는 젖은 비율
function coolBy(h0, dh) {
const h1 = h0 + dh;
const below = Math.max(0, Math.min(h1, CONDENSE_H) - Math.min(h0, CONDENSE_H));
return below * DRY_LAPSE + (dh - below) * MOIST_LAPSE;
}
function spawn(p, h) {
p.x = SOURCE_X + (spawnRand() - 0.5) * 120;
p.h = h;
p.vx = (spawnRand() - 0.5) * 6;
p.vh = 10 + spawnRand() * 10;
// 출발 온도 차 — 주변보다 2.6~3.4 만큼 따뜻하게(이번 높이에서 마른 비율로 식었을 몫을 뺀다)
p.temp = envTemp(0) + 2.6 + spawnRand() * 0.8 - coolBy(0, h);
p.age = 0;
p.seed = spawnRand() * 1000;
}
function reset() {
spawnRand = seeded(303);
cloud03.v.fill(0);
parcels.length = 0;
for (let i = 0; i < PARCEL_COUNT; i += 1) {
const p = { x: 0, h: 0, vx: 0, vh: 0, temp: 0, age: 0, seed: 0 };
spawn(p, (i / PARCEL_COUNT) * CONDENSE_H * 0.9);
parcels.push(p);
}
}
function buoyancy(p) { return (p.temp - envTemp(p.h)) * BUOYANCY; }
function stepParcel(p, dt) {
const a = buoyancy(p) - DRAG * p.vh;
p.vh += a * dt;
const dh = p.vh * dt;
p.temp -= dh > 0 ? coolBy(p.h, dh) : (dh * DRY_LAPSE);
p.h += dh;
let ax = Math.sin(sceneT * 1.7 + p.seed) * 4 - DRAG * p.vx;
if (p.h > CONDENSE_H && p.vh < SPREAD_SPEED) {
ax += (p.x >= SOURCE_X ? 1 : -1) * SPREAD_ACCEL;
}
p.vx += ax * dt;
p.x += p.vx * dt;
p.age += dt;
if (p.h < 0) {
p.h = 0;
p.vh = 0;
}
if (p.age > 9 || p.x < -20 || p.x > W + 20 || (p.h < 4 && p.vh <= 0 && p.age > 1)) {
spawn(p, 0);
}
}
// 응결 높이 위 덩이만 짙기를 남긴다 — 칸의 높이가 응결 높이 아래면 칠하지 않아 밑면이 평평하다
// 가라앉는 덩이(아래로 SINK_SPEED 보다 빠름)는 데워져 물방울이 마르므로 남기지 않는다 — 구름 옆에 아래로 늘어진 줄이 생기지 않게
function stampPuff(p, dt) {
if (p.h < CONDENSE_H || p.vh < -SINK_SPEED) {
return;
}
const cx = p.x / CELL;
const cy = (GROUND_Y - p.h) / CELL;
const baseRow = (GROUND_Y - CONDENSE_H) / CELL;
for (let oy = -PUFF_CELLS; oy <= PUFF_CELLS; oy += 1) {
const gy = Math.round(cy) + oy;
if (gy < 0 || gy >= GRID_H || gy > baseRow) {
continue;
}
for (let ox = -PUFF_CELLS; ox <= PUFF_CELLS; ox += 1) {
const gx = Math.round(cx) + ox;
if (gx < 0 || gx >= GRID_W) {
continue;
}
const r = Math.hypot(gx - cx, gy - cy) / PUFF_CELLS;
if (r >= 1) {
continue;
}
const i = gy * GRID_W + gx;
cloud03.v[i] = Math.min(1, cloud03.v[i] + PUFF_RATE * dt * (1 - r * r));
}
}
}
function update(dt) {
const keep = 1 - FADE_RATE * dt;
for (let i = 0; i < cloud03.v.length; i += 1) {
cloud03.v[i] *= keep;
}
for (const p of parcels) {
stepParcel(p, dt);
stampPuff(p, dt);
}
}
// 가장 낮은 구름 칸의 높이(px) — 짙기 > level 인 칸 가운데 땅에서 가장 낮은 줄
function cloudBaseH(level) {
for (let gy = GRID_H - 1; gy >= 0; gy -= 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
if (cloud03.v[gy * GRID_W + gx] > level) {
return GROUND_Y - (gy + 1) * CELL;
}
}
}
return -1;
}
// ── 03 화면 ──────────────────────────────────────────────────
const pix03 = makePix(GRID_W, GRID_H);
// 해 쪽(오른쪽 위) 칸으로 몇 칸 걸으며 짙기를 더해 아랫면·그늘을 어둡게
const SHADE_STEPS_03 = 6;
function draw() {
const d = pix03.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
let c = sky;
const v0 = cloud03.v[i];
if (v0 > 0.01) {
// 뭉게 결 — 여러 겹 노이즈 합이 천천히 위로 흘러 꼭대기가 끓어오르듯 보이게(짙기를 0.55~1.45 배)
const tex = layeredNoise(gx * CELL / 16, gy * CELL / 16 + sceneT * 0.35, 3, 31);
const v = Math.min(1.2, v0 * (0.55 + 0.9 * tex));
let s = 0;
for (let k = 1; k <= SHADE_STEPS_03; k += 1) {
const sx = gx + k;
const sy = gy - k;
if (sx >= GRID_W || sy < 0) {
break;
}
s += cloud03.v[sy * GRID_W + sx];
}
const lit = Math.exp(-0.32 * s);
const cl = mix(CLOUD_SHADE, CLOUD, 0.2 + 0.8 * lit * (0.7 + 0.5 * tex));
c = mix(sky, cl, 1 - Math.exp(-4.5 * v));
}
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
}
blit(pix03, 0, 0, W, H);
disc(424, 46, 20, BUTTER, 1);
// 공기 덩이 — 응결 높이 아래만 산호 점으로(아직 보이지 않는 공기). 위로 올라간 덩이는 구름 속에 묻힌다
for (const p of parcels) {
if (p.h < CONDENSE_H) {
disc(p.x, GROUND_Y - p.h, 3.2, CORAL_DEEP, 0.65);
}
}
const baseY = GROUND_Y - CONDENSE_H;
seg(0, baseY, W, baseY, 1.5, INK, 0.55, 5);
tag('응결 높이', 12, baseY + 14, INK, 11, 'left');
box(0, GROUND_Y, W, H - GROUND_Y, 0, GRASS, 1);
seg(0, GROUND_Y, W, GROUND_Y, 2, GRASS_SHADE, 0.8, 0);
}
function statusText() { return skyText(coverOf(cloud03, 0.15), SUN_DEG_03, 0); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ThermalBillowBase - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ThermalBillowBase.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ThermalBillowBase() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s: string, x: number, y: number, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 03 응결 높이에서 평평해지는 뭉게구름 ───────────────────────
// 옆에서 본 하늘. 높이 h = 땅(GROUND_Y)에서 잰 화면 px(위가 +). 온도는 화면 단위 상대값이다(실제 기온이 아니다)
const GROUND_Y = 272;
// 주변 공기 — 땅에서 ENV_LAPSE 씩 h 1px 마다 식고, 덮개 높이 CAP_H 위로는 더 식지 않는다(덮개를 넘은 덩이는 젖은 비율로 식기만 해 곧 부력을 잃고 멈춘다)
const ENV_LAPSE = 0.06;
const CAP_H = 170;
// 덩이 — 마른 동안은 DRY_LAPSE 씩, 응결 높이 CONDENSE_H 를 넘으면 MOIST_LAPSE 씩 식는다(물방울이 맺히며 열을 내놓아 덜 식는다)
const DRY_LAPSE = 0.08;
const MOIST_LAPSE = 0.04;
const CONDENSE_H = 112;
// 부력 = (덩이 온도 − 주변 온도) × BUOYANCY, 저항 = 속도 × DRAG (px/초²)
const BUOYANCY = 30;
const DRAG = 1.6;
// 오를 힘을 잃은 꼭대기(위로 속도 < SPREAD_SPEED)는 가운데에서 먼 쪽으로 SPREAD_ACCEL 만큼 밀려 옆으로 퍼진다
const SPREAD_SPEED = 14;
const SPREAD_ACCEL = 46;
// 퍼프 — 응결 높이 위 덩이가 반지름 PUFF_CELLS 칸 안에 초당 PUFF_RATE 만큼 짙기를 남긴다. 짙기는 초당 FADE_RATE 몫씩 옅어진다
const PUFF_CELLS = 4;
const PUFF_RATE = 3.5;
const FADE_RATE = 0.12;
const SINK_SPEED = 6;
const PARCEL_COUNT = 70;
const SOURCE_X = 240;
const GRID_W = 160;
const GRID_H = 100;
const CELL = W / GRID_W;
const SUN_DEG_03 = 62;
const LOOP_S = 12;
const START_T = 4.5;
const REST_T = 8;
type Parcel = { x: number; h: number; vx: number; vh: number; temp: number; age: number; seed: number };
const parcels: Parcel[] = [];
const cloud03 = makeField(GRID_W, GRID_H);
let spawnRand = seeded(303);
function envTemp(h: number): number { return -ENV_LAPSE * Math.min(h, CAP_H); }
// 덩이가 dh 만큼 오를 때 식는 양 — 응결 높이 아래는 마른 비율, 위는 젖은 비율
function coolBy(h0: number, dh: number): number {
const h1 = h0 + dh;
const below = Math.max(0, Math.min(h1, CONDENSE_H) - Math.min(h0, CONDENSE_H));
return below * DRY_LAPSE + (dh - below) * MOIST_LAPSE;
}
function spawn(p: Parcel, h: number): void {
p.x = SOURCE_X + (spawnRand() - 0.5) * 120;
p.h = h;
p.vx = (spawnRand() - 0.5) * 6;
p.vh = 10 + spawnRand() * 10;
// 출발 온도 차 — 주변보다 2.6~3.4 만큼 따뜻하게(이번 높이에서 마른 비율로 식었을 몫을 뺀다)
p.temp = envTemp(0) + 2.6 + spawnRand() * 0.8 - coolBy(0, h);
p.age = 0;
p.seed = spawnRand() * 1000;
}
function reset(): void {
spawnRand = seeded(303);
cloud03.v.fill(0);
parcels.length = 0;
for (let i = 0; i < PARCEL_COUNT; i += 1) {
const p: Parcel = { x: 0, h: 0, vx: 0, vh: 0, temp: 0, age: 0, seed: 0 };
spawn(p, (i / PARCEL_COUNT) * CONDENSE_H * 0.9);
parcels.push(p);
}
}
function buoyancy(p: Parcel): number { return (p.temp - envTemp(p.h)) * BUOYANCY; }
function stepParcel(p: Parcel, dt: number): void {
const a = buoyancy(p) - DRAG * p.vh;
p.vh += a * dt;
const dh = p.vh * dt;
p.temp -= dh > 0 ? coolBy(p.h, dh) : (dh * DRY_LAPSE);
p.h += dh;
let ax = Math.sin(sceneT * 1.7 + p.seed) * 4 - DRAG * p.vx;
if (p.h > CONDENSE_H && p.vh < SPREAD_SPEED) { ax += (p.x >= SOURCE_X ? 1 : -1) * SPREAD_ACCEL; }
p.vx += ax * dt;
p.x += p.vx * dt;
p.age += dt;
if (p.h < 0) { p.h = 0; p.vh = 0; }
if (p.age > 9 || p.x < -20 || p.x > W + 20 || (p.h < 4 && p.vh <= 0 && p.age > 1)) { spawn(p, 0); }
}
// 응결 높이 위 덩이만 짙기를 남긴다 — 칸의 높이가 응결 높이 아래면 칠하지 않아 밑면이 평평하다
// 가라앉는 덩이(아래로 SINK_SPEED 보다 빠름)는 데워져 물방울이 마르므로 남기지 않는다 — 구름 옆에 아래로 늘어진 줄이 생기지 않게
function stampPuff(p: Parcel, dt: number): void {
if (p.h < CONDENSE_H || p.vh < -SINK_SPEED) { return; }
const cx = p.x / CELL;
const cy = (GROUND_Y - p.h) / CELL;
const baseRow = (GROUND_Y - CONDENSE_H) / CELL;
for (let oy = -PUFF_CELLS; oy <= PUFF_CELLS; oy += 1) {
const gy = Math.round(cy) + oy;
if (gy < 0 || gy >= GRID_H || gy > baseRow) { continue; }
for (let ox = -PUFF_CELLS; ox <= PUFF_CELLS; ox += 1) {
const gx = Math.round(cx) + ox;
if (gx < 0 || gx >= GRID_W) { continue; }
const r = Math.hypot(gx - cx, gy - cy) / PUFF_CELLS;
if (r >= 1) { continue; }
const i = gy * GRID_W + gx;
cloud03.v[i] = Math.min(1, cloud03.v[i] + PUFF_RATE * dt * (1 - r * r));
}
}
}
function update(dt: number): void {
const keep = 1 - FADE_RATE * dt;
for (let i = 0; i < cloud03.v.length; i += 1) { cloud03.v[i] *= keep; }
for (const p of parcels) {
stepParcel(p, dt);
stampPuff(p, dt);
}
}
// 가장 낮은 구름 칸의 높이(px) — 짙기 > level 인 칸 가운데 땅에서 가장 낮은 줄
function cloudBaseH(level: number): number {
for (let gy = GRID_H - 1; gy >= 0; gy -= 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
if (cloud03.v[gy * GRID_W + gx] > level) { return GROUND_Y - (gy + 1) * CELL; }
}
}
return -1;
}
// ── 03 화면 ──────────────────────────────────────────────────
const pix03 = makePix(GRID_W, GRID_H);
// 해 쪽(오른쪽 위) 칸으로 몇 칸 걸으며 짙기를 더해 아랫면·그늘을 어둡게
const SHADE_STEPS_03 = 6;
function draw(): void {
const d = pix03.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
let c = sky;
const v0 = cloud03.v[i];
if (v0 > 0.01) {
// 뭉게 결 — 여러 겹 노이즈 합이 천천히 위로 흘러 꼭대기가 끓어오르듯 보이게(짙기를 0.55~1.45 배)
const tex = layeredNoise(gx * CELL / 16, gy * CELL / 16 + sceneT * 0.35, 3, 31);
const v = Math.min(1.2, v0 * (0.55 + 0.9 * tex));
let s = 0;
for (let k = 1; k <= SHADE_STEPS_03; k += 1) {
const sx = gx + k;
const sy = gy - k;
if (sx >= GRID_W || sy < 0) { break; }
s += cloud03.v[sy * GRID_W + sx];
}
const lit = Math.exp(-0.32 * s);
const cl = mix(CLOUD_SHADE, CLOUD, 0.2 + 0.8 * lit * (0.7 + 0.5 * tex));
c = mix(sky, cl, 1 - Math.exp(-4.5 * v));
}
const k = i * 4;
d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = 255;
}
}
blit(pix03, 0, 0, W, H);
disc(424, 46, 20, BUTTER, 1);
// 공기 덩이 — 응결 높이 아래만 산호 점으로(아직 보이지 않는 공기). 위로 올라간 덩이는 구름 속에 묻힌다
for (const p of parcels) {
if (p.h < CONDENSE_H) { disc(p.x, GROUND_Y - p.h, 3.2, CORAL_DEEP, 0.65); }
}
const baseY = GROUND_Y - CONDENSE_H;
seg(0, baseY, W, baseY, 1.5, INK, 0.55, 5);
tag('응결 높이', 12, baseY + 14, INK, 11, 'left');
box(0, GROUND_Y, W, H - GROUND_Y, 0, GRASS, 1);
seg(0, GROUND_Y, W, GROUND_Y, 2, GRASS_SHADE, 0.8, 0);
}
function statusText(): string { return skyText(coverOf(cloud03, 0.15), SUN_DEG_03, 0); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'바닥에서 오르는 공기 덩이 점들이 점선으로 표시한 응결 높이를 넘는 순간부터 퍼프를 남겨, 밑면이 평평한 뭉게구름이 자라고 오를 힘을 잃은 꼭대기는 옆으로 퍼진다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}03 · 응결 높이에서 평평해지는 뭉게구름 — 측정 누적면적 8.347% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function buoyancy(p) { return (p.temp - envTemp(p.h)) * BUOYANCY; }
function stepParcel(p, dt) {
const a = buoyancy(p) - DRAG * p.vh;
p.vh += a * dt;
const dh = p.vh * dt;
p.temp -= dh > 0 ? coolBy(p.h, dh) : (dh * DRY_LAPSE);
p.h += dh;
let ax = Math.sin(sceneT * 1.7 + p.seed) * 4 - DRAG * p.vx;
if (p.h > CONDENSE_H && p.vh < SPREAD_SPEED) {
ax += (p.x >= SOURCE_X ? 1 : -1) * SPREAD_ACCEL;
}
p.vx += ax * dt;
p.x += p.vx * dt;
p.age += dt;
if (p.h < 0) {
p.h = 0;
p.vh = 0;
}
if (p.age > 9 || p.x < -20 || p.x > W + 20 || (p.h < 4 && p.vh <= 0 && p.age > 1)) {
spawn(p, 0);
}
}
04해 높이만큼 비껴 지나는 언덕 위 구름 그림자
여행 소개 페이지의 들판 히어로에 어울린다. shadowSourceX 는 땅 점에서 해 쪽으로 구름층 높이까지 거슬러 올라간 자리를 x + (CLOUD_H − h) / tan(높이각) 로 구해, 해 높이 50° 에서 높이 34px 땅은 149.4px 오른쪽 구름의 그림자를, 126px 언덕은 72.2px 오른쪽 구름의 그림자를 받는다. sunFacing 은 비탈 법선과 해 방향의 내적으로 땅 밝기를 정해 해를 향한 비탈 평균 0.882, 등진 비탈 평균 0.515 로 갈린다.

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="언덕 들판 위 구름층이 바람에 흐르고, 오른쪽 위 해에서 비스듬히 내려온 점선 빛줄기를 따라 구름 그림자가 구름 바로 아래가 아니라 비껴 난 자리에 떨어지며 비탈마다 길이가 다르다"></canvas>
<p class="cl__status" data-role="status" aria-live="off">하늘을 읽는 중</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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-cream;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
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: #fff7e6;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return '오른쪽으로';
}
if (dir < 0) {
return '왼쪽으로';
}
return '잔잔함';
}
function skyText(cover, sunDeg, windDir) {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s, x, y, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 04 해 높이만큼 비껴 지나는 언덕 위 구름 그림자 ───────────────
// 옆에서 본 들판. 높이는 바닥 줄(BASE_Y)에서 위로 잰 px. 해는 오른쪽 위 — 빛은 오른쪽 위에서 왼쪽 아래로 내려온다
const BASE_Y = 292;
// 해 높이각 — 땅(수평)에서 잰 각. 작을수록 해가 낮다
const SUN_ELEV_DEG = 50;
// 구름층 높이(px)와 두께 절반
const CLOUD_H = 212;
const CLOUD_HALF = 34;
// 바람 — 구름 노이즈를 초당 WIND_PX 만큼 오른쪽으로 민다
const WIND_PX = 30;
// 그림자 진하기 = SHADOW_DEPTH × (구름 기둥 짙기를 부드럽게 0~1 로)
const SHADOW_DEPTH = 0.62;
const GRID_W = 240;
const GRID_H = 150;
const CELL = W / GRID_W;
const LOOP_S = 0;
const START_T = 3;
const REST_T = 3;
const cloud04 = makeField(GRID_W, GRID_H);
// 열마다 구름층을 세로로 평균낸 짙기(0~1)
const column04 = new Float32Array(GRID_W);
// 언덕 높이 h(x)(px) — 여러 겹 노이즈 합 한 줄
function terrainH(x) { return 34 + 92 * layeredNoise(x / 120, 3.5, 3, 41); }
// 비탈 기울기 h'(x) — 양쪽 1px 차
function terrainSlope(x) { return (terrainH(x + 1) - terrainH(x - 1)) / 2; }
function sunVec() { const e = toRad(SUN_ELEV_DEG); return [Math.cos(e), Math.sin(e)]; }
// 땅 점 (x, h) 에서 해 쪽으로 구름층 높이까지 거슬러 올라간 자리 — x + (CLOUD_H − h) / tan(높이각)
function shadowSourceX(x, h) { return x + (CLOUD_H - h) / Math.tan(toRad(SUN_ELEV_DEG)); }
// 비탈 법선과 해 방향의 내적 — 1 이면 해를 정면으로, 0 이하면 해를 등진 비탈
function sunFacing(x) {
const s = terrainSlope(x);
const v = sunVec();
return (-s * v[0] + v[1]) / Math.hypot(s, 1);
}
function cloudDensity04(x, hy, t) {
const profile = smooth01(1 - Math.abs(hy - CLOUD_H) / CLOUD_HALF);
if (profile <= 0) {
return 0;
}
const n = layeredNoise((x - t * WIND_PX) / 64, hy / 30, 4, 44);
return clamp((n * profile - 0.25) * 3.4, 0, 1);
}
function buildCloud(t) {
column04.fill(0);
let rows = 0;
for (let gy = 0; gy < GRID_H; gy += 1) {
const hy = BASE_Y - (gy + 0.5) * CELL;
const inBand = Math.abs(hy - CLOUD_H) < CLOUD_HALF;
if (inBand) {
rows += 1;
}
for (let gx = 0; gx < GRID_W; gx += 1) {
const v = inBand ? cloudDensity04((gx + 0.5) * CELL, hy, t) : 0;
cloud04.v[gy * GRID_W + gx] = v;
column04[gx] += v;
}
}
for (let gx = 0; gx < GRID_W; gx += 1) {
column04[gx] /= Math.max(1, rows);
}
}
// 화면 x 자리의 구름 기둥 짙기(열 사이 섞기, 화면 밖은 노이즈를 직접 읽는다)
function columnAt(x, t) {
const g = x / CELL - 0.5;
if (g >= 0 && g < GRID_W - 1) {
const i = Math.floor(g);
return lerp(column04[i], column04[i + 1], g - i);
}
let s = 0;
let n = 0;
for (let hy = CLOUD_H - CLOUD_HALF + CELL / 2; hy < CLOUD_H + CLOUD_HALF; hy += CELL) {
s += cloudDensity04(x, hy, t);
n += 1;
}
return s / Math.max(1, n);
}
// 땅 점 x 의 그림자 진하기 0~SHADOW_DEPTH
function shadowAt(x, t) {
const src = shadowSourceX(x, terrainH(x));
return SHADOW_DEPTH * smooth01(columnAt(src, t) * 2.4);
}
function reset() { }
function update(dt) { }
// ── 04 화면 ──────────────────────────────────────────────────
const pix04 = makePix(GRID_W, GRID_H);
const surf04 = new Float32Array(GRID_W);
const face04 = new Float32Array(GRID_W);
const shade04 = new Float32Array(GRID_W);
// 땅 겉면 띠 두께(px) — 이 아래 단면은 해 받는 정도와 상관없는 한 톤
const SURFACE_PX = 30;
// 빛줄기 하나 — 구름 자리 (x, 구름층 높이) 에서 해 반대쪽(왼쪽 아래)으로 땅에 닿을 때까지
function rayToGround(xc) {
const v = sunVec();
let x = xc;
let h = CLOUD_H;
for (let k = 0; k < 400; k += 1) {
if (h <= terrainH(x)) {
break;
}
x -= v[0] * 2;
h -= v[1] * 2;
}
return [x, BASE_Y - h];
}
function draw() {
buildCloud(sceneT);
for (let gx = 0; gx < GRID_W; gx += 1) {
const x = (gx + 0.5) * CELL;
surf04[gx] = terrainH(x);
face04[gx] = sunFacing(x);
shade04[gx] = shadowAt(x, sceneT);
}
const v = sunVec();
const d = pix04.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const hy = BASE_Y - (gy + 0.5) * CELL;
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
let c;
if (hy < surf04[gx]) {
// 땅 — 겉면 띠(위 SURFACE_PX)는 해를 향한 정도로 밝기, 더 깊은 단면은 한 톤 어두운 풀빛으로 내려간다.
// 구름 그림자는 겉면에서 가장 진하고 깊이 들어갈수록 옅게(단면 전체가 세로 줄무늬로 보이지 않게)
const depth = band(0, SURFACE_PX, surf04[gx] - hy);
const lit = 0.3 + 0.7 * Math.max(0, face04[gx]);
const grass = mix(mix(GRASS_SHADE, GRASS, lit), mix(GRASS_SHADE, GRASS, 0.42), depth);
c = mix(grass, [52, 82, 74], shade04[gx] * (1 - 0.85 * depth));
}
else {
c = sky;
const cv04 = cloud04.v[i];
if (cv04 > 0) {
let s = 0;
for (let k = 1; k <= 5; k += 1) {
s += sampleField(cloud04, gx + v[0] * k * 1.5, gy - v[1] * k * 1.5);
}
const cl = mix(CLOUD_SHADE, CLOUD, Math.exp(-0.55 * s));
c = mix(sky, cl, 1 - Math.exp(-3 * cv04));
}
}
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
}
blit(pix04, 0, 0, W, H);
// 해 — 높이각 방향, 들판 가운데에서 230px
const sx = 250 + v[0] * 230;
const sy = 270 - v[1] * 230;
disc(sx, sy, 16, BUTTER, 1);
// 가장 짙은 구름 기둥 두 곳에서 그림자 자리까지 점선 빛줄기
let best = -1;
let second = -1;
for (let gx = 20; gx < GRID_W - 20; gx += 1) {
if (best < 0 || column04[gx] > column04[best]) {
best = gx;
}
}
for (let gx = 20; gx < GRID_W - 20; gx += 1) {
if (Math.abs(gx - best) > 40 && (second < 0 || column04[gx] > column04[second])) {
second = gx;
}
}
for (const g of [best, second]) {
if (g < 0 || column04[g] < 0.15) {
continue;
}
const xc = (g + 0.5) * CELL;
const hit = rayToGround(xc);
seg(xc, BASE_Y - CLOUD_H, hit[0], hit[1], 1.5, INK, 0.6, 4);
disc(hit[0], hit[1], 3, INK, 0.7);
}
tag('해 높이 {d}°'.replace('{d}', String(SUN_ELEV_DEG)), sx, sy + 28, INK, 11, 'center');
}
function statusText() { return skyText(coverOf(cloud04, 0.1) * GRID_H / (2 * CLOUD_HALF / CELL), SUN_ELEV_DEG, 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// GroundShadowOffset - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./GroundShadowOffset.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GroundShadowOffset() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s: string, x: number, y: number, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 04 해 높이만큼 비껴 지나는 언덕 위 구름 그림자 ───────────────
// 옆에서 본 들판. 높이는 바닥 줄(BASE_Y)에서 위로 잰 px. 해는 오른쪽 위 — 빛은 오른쪽 위에서 왼쪽 아래로 내려온다
const BASE_Y = 292;
// 해 높이각 — 땅(수평)에서 잰 각. 작을수록 해가 낮다
const SUN_ELEV_DEG = 50;
// 구름층 높이(px)와 두께 절반
const CLOUD_H = 212;
const CLOUD_HALF = 34;
// 바람 — 구름 노이즈를 초당 WIND_PX 만큼 오른쪽으로 민다
const WIND_PX = 30;
// 그림자 진하기 = SHADOW_DEPTH × (구름 기둥 짙기를 부드럽게 0~1 로)
const SHADOW_DEPTH = 0.62;
const GRID_W = 240;
const GRID_H = 150;
const CELL = W / GRID_W;
const LOOP_S = 0;
const START_T = 3;
const REST_T = 3;
const cloud04 = makeField(GRID_W, GRID_H);
// 열마다 구름층을 세로로 평균낸 짙기(0~1)
const column04 = new Float32Array(GRID_W);
// 언덕 높이 h(x)(px) — 여러 겹 노이즈 합 한 줄
function terrainH(x: number): number { return 34 + 92 * layeredNoise(x / 120, 3.5, 3, 41); }
// 비탈 기울기 h'(x) — 양쪽 1px 차
function terrainSlope(x: number): number { return (terrainH(x + 1) - terrainH(x - 1)) / 2; }
function sunVec(): [number, number] { const e = toRad(SUN_ELEV_DEG); return [Math.cos(e), Math.sin(e)]; }
// 땅 점 (x, h) 에서 해 쪽으로 구름층 높이까지 거슬러 올라간 자리 — x + (CLOUD_H − h) / tan(높이각)
function shadowSourceX(x: number, h: number): number { return x + (CLOUD_H - h) / Math.tan(toRad(SUN_ELEV_DEG)); }
// 비탈 법선과 해 방향의 내적 — 1 이면 해를 정면으로, 0 이하면 해를 등진 비탈
function sunFacing(x: number): number {
const s = terrainSlope(x);
const v = sunVec();
return (-s * v[0] + v[1]) / Math.hypot(s, 1);
}
function cloudDensity04(x: number, hy: number, t: number): number {
const profile = smooth01(1 - Math.abs(hy - CLOUD_H) / CLOUD_HALF);
if (profile <= 0) { return 0; }
const n = layeredNoise((x - t * WIND_PX) / 64, hy / 30, 4, 44);
return clamp((n * profile - 0.25) * 3.4, 0, 1);
}
function buildCloud(t: number): void {
column04.fill(0);
let rows = 0;
for (let gy = 0; gy < GRID_H; gy += 1) {
const hy = BASE_Y - (gy + 0.5) * CELL;
const inBand = Math.abs(hy - CLOUD_H) < CLOUD_HALF;
if (inBand) { rows += 1; }
for (let gx = 0; gx < GRID_W; gx += 1) {
const v = inBand ? cloudDensity04((gx + 0.5) * CELL, hy, t) : 0;
cloud04.v[gy * GRID_W + gx] = v;
column04[gx] += v;
}
}
for (let gx = 0; gx < GRID_W; gx += 1) { column04[gx] /= Math.max(1, rows); }
}
// 화면 x 자리의 구름 기둥 짙기(열 사이 섞기, 화면 밖은 노이즈를 직접 읽는다)
function columnAt(x: number, t: number): number {
const g = x / CELL - 0.5;
if (g >= 0 && g < GRID_W - 1) {
const i = Math.floor(g);
return lerp(column04[i], column04[i + 1], g - i);
}
let s = 0;
let n = 0;
for (let hy = CLOUD_H - CLOUD_HALF + CELL / 2; hy < CLOUD_H + CLOUD_HALF; hy += CELL) { s += cloudDensity04(x, hy, t); n += 1; }
return s / Math.max(1, n);
}
// 땅 점 x 의 그림자 진하기 0~SHADOW_DEPTH
function shadowAt(x: number, t: number): number {
const src = shadowSourceX(x, terrainH(x));
return SHADOW_DEPTH * smooth01(columnAt(src, t) * 2.4);
}
function reset(): void { /* 상태가 없다 */ }
function update(dt: number): void { /* 시각만 흐른다 */ }
// ── 04 화면 ──────────────────────────────────────────────────
const pix04 = makePix(GRID_W, GRID_H);
const surf04 = new Float32Array(GRID_W);
const face04 = new Float32Array(GRID_W);
const shade04 = new Float32Array(GRID_W);
// 땅 겉면 띠 두께(px) — 이 아래 단면은 해 받는 정도와 상관없는 한 톤
const SURFACE_PX = 30;
// 빛줄기 하나 — 구름 자리 (x, 구름층 높이) 에서 해 반대쪽(왼쪽 아래)으로 땅에 닿을 때까지
function rayToGround(xc: number): [number, number] {
const v = sunVec();
let x = xc;
let h = CLOUD_H;
for (let k = 0; k < 400; k += 1) {
if (h <= terrainH(x)) { break; }
x -= v[0] * 2;
h -= v[1] * 2;
}
return [x, BASE_Y - h];
}
function draw(): void {
buildCloud(sceneT);
for (let gx = 0; gx < GRID_W; gx += 1) {
const x = (gx + 0.5) * CELL;
surf04[gx] = terrainH(x);
face04[gx] = sunFacing(x);
shade04[gx] = shadowAt(x, sceneT);
}
const v = sunVec();
const d = pix04.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const hy = BASE_Y - (gy + 0.5) * CELL;
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
let c: RGB;
if (hy < surf04[gx]) {
// 땅 — 겉면 띠(위 SURFACE_PX)는 해를 향한 정도로 밝기, 더 깊은 단면은 한 톤 어두운 풀빛으로 내려간다.
// 구름 그림자는 겉면에서 가장 진하고 깊이 들어갈수록 옅게(단면 전체가 세로 줄무늬로 보이지 않게)
const depth = band(0, SURFACE_PX, surf04[gx] - hy);
const lit = 0.3 + 0.7 * Math.max(0, face04[gx]);
const grass = mix(mix(GRASS_SHADE, GRASS, lit), mix(GRASS_SHADE, GRASS, 0.42), depth);
c = mix(grass, [52, 82, 74], shade04[gx] * (1 - 0.85 * depth));
} else {
c = sky;
const cv04 = cloud04.v[i];
if (cv04 > 0) {
let s = 0;
for (let k = 1; k <= 5; k += 1) { s += sampleField(cloud04, gx + v[0] * k * 1.5, gy - v[1] * k * 1.5); }
const cl = mix(CLOUD_SHADE, CLOUD, Math.exp(-0.55 * s));
c = mix(sky, cl, 1 - Math.exp(-3 * cv04));
}
}
const k = i * 4;
d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = 255;
}
}
blit(pix04, 0, 0, W, H);
// 해 — 높이각 방향, 들판 가운데에서 230px
const sx = 250 + v[0] * 230;
const sy = 270 - v[1] * 230;
disc(sx, sy, 16, BUTTER, 1);
// 가장 짙은 구름 기둥 두 곳에서 그림자 자리까지 점선 빛줄기
let best = -1;
let second = -1;
for (let gx = 20; gx < GRID_W - 20; gx += 1) {
if (best < 0 || column04[gx] > column04[best]) { best = gx; }
}
for (let gx = 20; gx < GRID_W - 20; gx += 1) {
if (Math.abs(gx - best) > 40 && (second < 0 || column04[gx] > column04[second])) { second = gx; }
}
for (const g of [best, second]) {
if (g < 0 || column04[g] < 0.15) { continue; }
const xc = (g + 0.5) * CELL;
const hit = rayToGround(xc);
seg(xc, BASE_Y - CLOUD_H, hit[0], hit[1], 1.5, INK, 0.6, 4);
disc(hit[0], hit[1], 3, INK, 0.7);
}
tag('해 높이 {d}°'.replace('{d}', String(SUN_ELEV_DEG)), sx, sy + 28, INK, 11, 'center');
}
function statusText(): string { return skyText(coverOf(cloud04, 0.1) * GRID_H / (2 * CLOUD_HALF / CELL), SUN_ELEV_DEG, 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'언덕 들판 위 구름층이 바람에 흐르고, 오른쪽 위 해에서 비스듬히 내려온 점선 빛줄기를 따라 구름 그림자가 구름 바로 아래가 아니라 비껴 난 자리에 떨어지며 비탈마다 길이가 다르다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}04 · 해 높이만큼 비껴 지나는 언덕 위 구름 그림자 — 측정 누적면적 17.765% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 땅 점 (x, h) 에서 해 쪽으로 구름층 높이까지 거슬러 올라간 자리 — x + (CLOUD_H − h) / tan(높이각)
function shadowSourceX(x, h) { return x + (CLOUD_H - h) / Math.tan(toRad(SUN_ELEV_DEG)); }
// 비탈 법선과 해 방향의 내적 — 1 이면 해를 정면으로, 0 이하면 해를 등진 비탈
function sunFacing(x) {
const s = terrainSlope(x);
const v = sunVec();
return (-s * v[0] + v[1]) / Math.hypot(s, 1);
}
05높이마다 다른 바람에 늘어나는 새털구름
명상 앱의 시작 화면 하늘에 어울린다. windAt 은 높이 비 hr 에 SHEAR 를 곱해 바닥 30px/초에서 꼭대기 48px/초(1.6배)까지 바람을 세게 하고, wispPhase 는 읽는 x 를 그 바람으로 밀면서 가로만 6배 늘여 읽는다. 16초마다 되감는 두 판을 반 주기 어긋나게 섞어 미는 거리가 끝없이 커지지 않게 했고, 위상 한가운데(8초)의 결은 25.6° 기울어 있다.

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="높은 하늘의 새털구름 결이 바람 방향으로 빗질한 듯 길게 늘어나고, 위쪽 결일수록 빨리 흘러 구름이 비스듬히 기울며, 왼쪽 화살표 길이가 높이별 바람 세기를 보여 준다"></canvas>
<p class="cl__status" data-role="status" aria-live="off">하늘을 읽는 중</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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-cream;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
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: #fff7e6;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return '오른쪽으로';
}
if (dir < 0) {
return '왼쪽으로';
}
return '잔잔함';
}
function skyText(cover, sunDeg, windDir) {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s, x, y, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 05 높이마다 다른 바람에 늘어나는 새털구름 ───────────────────
// 높이 비 hr = 1 − y / H (화면 아래 0, 위 1). 그 높이의 바람 = WIND_PX × (1 + SHEAR × hr) px/초 — 위로 갈수록 세다
const WIND_PX = 30;
const SHEAR = 0.6;
// 가로축만 STRETCH 배 늘여 읽는다 — 노이즈 덩어리가 바람 방향으로 길쭉해진다
const STRETCH = 6;
const NOISE_PX = 22;
// 두 위상 섞기 — 시계를 PHASE_S 초마다 되감는 판 둘을 반 주기 어긋나게 두고, 되감는 순간 무게가 0 인 쪽을 쓴다.
// 미는 거리가 끝없이 커져 결이 눕기만 하지 않게(한 판이 PHASE_S 동안만 밀린다)
const PHASE_S = 16;
const GRID_W = 200;
const GRID_H = 125;
const CELL = W / GRID_W;
const SUN_DEG_05 = 48;
const LOOP_S = 0;
const START_T = 6;
const REST_T = 8;
const wisp05 = makeField(GRID_W, GRID_H);
// 높이 비 hr 에서의 바람(px/초)
function windAt(hr) { return WIND_PX * (1 + SHEAR * hr); }
// 한 위상 판의 짙기 — 읽는 x 를 그 높이 바람으로 tp 초만큼 거슬러 민 뒤 가로만 STRETCH 배 늘여 읽는다
function wispPhase(x, y, tp, salt) {
const hr = 1 - y / H;
const u = (x - tp * windAt(hr)) / (NOISE_PX * STRETCH);
const v = y / NOISE_PX + salt;
return layeredNoise(u, v, 4, 55);
}
// 칸 (x, y) 의 새털구름 짙기 — 두 위상 판을 삼각 무게로 섞고, 높이 비 0.28~0.52 에서 차츰 나타나 그 위 높은 하늘에만 남긴다
function wispDensity(x, y, t) {
const f = t / PHASE_S - Math.floor(t / PHASE_S);
const g = (f + 0.5) - Math.floor(f + 0.5);
const wa = 1 - Math.abs(2 * f - 1);
const n = wispPhase(x, y, f * PHASE_S, 0) * wa + wispPhase(x, y, g * PHASE_S, 17.3) * (1 - wa);
const hr = 1 - y / H;
return band(0.52, 0.66, n) * band(0.28, 0.52, hr);
}
function buildWisps(t) {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
wisp05.v[gy * GRID_W + gx] = wispDensity((gx + 0.5) * CELL, (gy + 0.5) * CELL, t);
}
}
}
function reset() { }
function update(dt) { }
// ── 05 화면 ──────────────────────────────────────────────────
const pix05 = makePix(GRID_W, GRID_H);
const HIGH_BLUE = [92, 130, 206];
const WISP_SHADE = [176, 170, 220];
function windArrow(y, len) {
const x0 = 16;
seg(x0, y, x0 + len, y, 2.5, INK, 0.75, 0);
seg(x0 + len, y, x0 + len - 7, y - 5, 2.5, INK, 0.75, 0);
seg(x0 + len, y, x0 + len - 7, y + 5, 2.5, INK, 0.75, 0);
}
function draw() {
buildWisps(sceneT);
const d = pix05.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(HIGH_BLUE, SKY_LOW, Math.pow(gy / GRID_H, 1.3));
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const v = wisp05.v[i];
let c = sky;
if (v > 0) {
// 결의 아래쪽(아래 칸이 더 옅은 자리)은 라벤더 그늘
const below = gy + 1 < GRID_H ? wisp05.v[i + GRID_W] : 0;
const cl = mix(CLOUD, WISP_SHADE, clamp((v - below) * 3, 0, 1) * 0.7);
c = mix(sky, cl, v * 0.92);
}
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
}
blit(pix05, 0, 0, W, H);
// 높이별 바람 화살표 — 길이가 그 높이 바람 세기(px/초)에 비례
for (const hr of [0.85, 0.65, 0.45]) {
windArrow((1 - hr) * H, windAt(hr) * 0.9);
}
tag('위쪽 바람 {k}배'.replace('{k}', (1 + SHEAR).toFixed(1)), 14, H - 22, INK, 11, 'left');
}
function statusText() { return skyText(coverOf(wisp05, 0.2) / 0.6, SUN_DEG_05, 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ShearCirrusStreak - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ShearCirrusStreak.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ShearCirrusStreak() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s: string, x: number, y: number, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 05 높이마다 다른 바람에 늘어나는 새털구름 ───────────────────
// 높이 비 hr = 1 − y / H (화면 아래 0, 위 1). 그 높이의 바람 = WIND_PX × (1 + SHEAR × hr) px/초 — 위로 갈수록 세다
const WIND_PX = 30;
const SHEAR = 0.6;
// 가로축만 STRETCH 배 늘여 읽는다 — 노이즈 덩어리가 바람 방향으로 길쭉해진다
const STRETCH = 6;
const NOISE_PX = 22;
// 두 위상 섞기 — 시계를 PHASE_S 초마다 되감는 판 둘을 반 주기 어긋나게 두고, 되감는 순간 무게가 0 인 쪽을 쓴다.
// 미는 거리가 끝없이 커져 결이 눕기만 하지 않게(한 판이 PHASE_S 동안만 밀린다)
const PHASE_S = 16;
const GRID_W = 200;
const GRID_H = 125;
const CELL = W / GRID_W;
const SUN_DEG_05 = 48;
const LOOP_S = 0;
const START_T = 6;
const REST_T = 8;
const wisp05 = makeField(GRID_W, GRID_H);
// 높이 비 hr 에서의 바람(px/초)
function windAt(hr: number): number { return WIND_PX * (1 + SHEAR * hr); }
// 한 위상 판의 짙기 — 읽는 x 를 그 높이 바람으로 tp 초만큼 거슬러 민 뒤 가로만 STRETCH 배 늘여 읽는다
function wispPhase(x: number, y: number, tp: number, salt: number): number {
const hr = 1 - y / H;
const u = (x - tp * windAt(hr)) / (NOISE_PX * STRETCH);
const v = y / NOISE_PX + salt;
return layeredNoise(u, v, 4, 55);
}
// 칸 (x, y) 의 새털구름 짙기 — 두 위상 판을 삼각 무게로 섞고, 높이 비 0.28~0.52 에서 차츰 나타나 그 위 높은 하늘에만 남긴다
function wispDensity(x: number, y: number, t: number): number {
const f = t / PHASE_S - Math.floor(t / PHASE_S);
const g = (f + 0.5) - Math.floor(f + 0.5);
const wa = 1 - Math.abs(2 * f - 1);
const n = wispPhase(x, y, f * PHASE_S, 0) * wa + wispPhase(x, y, g * PHASE_S, 17.3) * (1 - wa);
const hr = 1 - y / H;
return band(0.52, 0.66, n) * band(0.28, 0.52, hr);
}
function buildWisps(t: number): void {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
wisp05.v[gy * GRID_W + gx] = wispDensity((gx + 0.5) * CELL, (gy + 0.5) * CELL, t);
}
}
}
function reset(): void { /* 상태가 없다 */ }
function update(dt: number): void { /* 시각만 흐른다 */ }
// ── 05 화면 ──────────────────────────────────────────────────
const pix05 = makePix(GRID_W, GRID_H);
const HIGH_BLUE: RGB = [92, 130, 206];
const WISP_SHADE: RGB = [176, 170, 220];
function windArrow(y: number, len: number): void {
const x0 = 16;
seg(x0, y, x0 + len, y, 2.5, INK, 0.75, 0);
seg(x0 + len, y, x0 + len - 7, y - 5, 2.5, INK, 0.75, 0);
seg(x0 + len, y, x0 + len - 7, y + 5, 2.5, INK, 0.75, 0);
}
function draw(): void {
buildWisps(sceneT);
const d = pix05.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(HIGH_BLUE, SKY_LOW, Math.pow(gy / GRID_H, 1.3));
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const v = wisp05.v[i];
let c = sky;
if (v > 0) {
// 결의 아래쪽(아래 칸이 더 옅은 자리)은 라벤더 그늘
const below = gy + 1 < GRID_H ? wisp05.v[i + GRID_W] : 0;
const cl = mix(CLOUD, WISP_SHADE, clamp((v - below) * 3, 0, 1) * 0.7);
c = mix(sky, cl, v * 0.92);
}
const k = i * 4;
d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = 255;
}
}
blit(pix05, 0, 0, W, H);
// 높이별 바람 화살표 — 길이가 그 높이 바람 세기(px/초)에 비례
for (const hr of [0.85, 0.65, 0.45]) { windArrow((1 - hr) * H, windAt(hr) * 0.9); }
tag('위쪽 바람 {k}배'.replace('{k}', (1 + SHEAR).toFixed(1)), 14, H - 22, INK, 11, 'left');
}
function statusText(): string { return skyText(coverOf(wisp05, 0.2) / 0.6, SUN_DEG_05, 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'높은 하늘의 새털구름 결이 바람 방향으로 빗질한 듯 길게 늘어나고, 위쪽 결일수록 빨리 흘러 구름이 비스듬히 기울며, 왼쪽 화살표 길이가 높이별 바람 세기를 보여 준다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}05 · 높이마다 다른 바람에 늘어나는 새털구름 — 측정 누적면적 29.586% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 높이 비 hr 에서의 바람(px/초)
function windAt(hr) { return WIND_PX * (1 + SHEAR * hr); }
// 한 위상 판의 짙기 — 읽는 x 를 그 높이 바람으로 tp 초만큼 거슬러 민 뒤 가로만 STRETCH 배 늘여 읽는다
function wispPhase(x, y, tp, salt) {
const hr = 1 - y / H;
const u = (x - tp * windAt(hr)) / (NOISE_PX * STRETCH);
const v = y / NOISE_PX + salt;
return layeredNoise(u, v, 4, 55);
}
// 칸 (x, y) 의 새털구름 짙기 — 두 위상 판을 삼각 무게로 섞고, 높이 비 0.28~0.52 에서 차츰 나타나 그 위 높은 하늘에만 남긴다
function wispDensity(x, y, t) {
const f = t / PHASE_S - Math.floor(t / PHASE_S);
const g = (f + 0.5) - Math.floor(f + 0.5);
const wa = 1 - Math.abs(2 * f - 1);
const n = wispPhase(x, y, f * PHASE_S, 0) * wa + wispPhase(x, y, g * PHASE_S, 17.3) * (1 - wa);
const hr = 1 - y / H;
return band(0.52, 0.66, n) * band(0.28, 0.52, hr);
}
06비행기 창밖으로 펼쳐지는 구름바다
항공권 검색 첫 화면의 배경에 어울린다. planeDistance 는 지평선 아래 화소의 기울기로 평면까지 거리를 CAM_HEIGHT 40 / 기울기로 구해, 화면 y 300 은 44, y 120 은 4000 만큼 떨어진 자리를 읽는다. 거리가 멀수록 노이즈 겹 수를 4에서 2로 줄이고 안개 몫을 1 − e^(−z × FOG) 로 섞어 y 120 근처는 0.988 까지 하늘색으로 녹는다.

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="비행기 창틀 너머로 아래 평면에 깔린 구름바다가 앞으로 흘러오고, 멀어질수록 하늘색이 섞여 지평선 쪽 구름이 부드럽게 흐려진다"></canvas>
<p class="cl__status" data-role="status" aria-live="off">하늘을 읽는 중</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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-cream;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
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: #fff7e6;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return '오른쪽으로';
}
if (dir < 0) {
return '왼쪽으로';
}
return '잔잔함';
}
function skyText(cover, sunDeg, windDir) {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s, x, y, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 06 비행기 창밖으로 펼쳐지는 구름바다 ─────────────────────────
// 눈은 구름 평면 위 CAM_HEIGHT 높이. 화면 y 가 지평선 HORIZON_Y 아래인 화소는 시선 기울기 s = (y − HORIZON_Y) / FOCAL_PX 로
// 평면까지 거리 z = CAM_HEIGHT / s 를 얻고, 평면 좌표 (X, Z) = ((x − 240) / FOCAL_PX × z, z + 지나온 거리) 의 노이즈를 읽는다
const HORIZON_Y = 118;
const FOCAL_PX = 200;
const CAM_HEIGHT = 40;
// 앞으로 나는 빠르기(평면 단위/초)
const FLY_SPEED = 90;
// 구름 높낮이 노이즈 한 덩이 크기(평면 단위)
const PUFF_SIZE = 48;
// 거리 안개 — 섞는 몫 = 1 − e^(−z × FOG)
const FOG = 0.0011;
// 밝기 — 해 쪽(먼 쪽)으로 LIGHT_PROBE 만큼 떨어진 자리보다 높으면 해를 받는 비탈
const LIGHT_PROBE = 10;
const GRID_W = 200;
const GRID_H = 125;
const CELL = W / GRID_W;
const SUN_DEG_06 = 18;
const LOOP_S = 0;
const START_T = 2;
const REST_T = 2;
// 칸마다 [거리 z, 구름 높이 n, 해 받는 정도] — 화면 쪽이 색으로 바꾼다
const seaZ = new Float32Array(GRID_W * GRID_H);
const seaN = new Float32Array(GRID_W * GRID_H);
const seaLit = new Float32Array(GRID_W * GRID_H);
// 화면 y 의 평면까지 거리(지평선 위면 −1)
function planeDistance(y) {
const s = (y - HORIZON_Y) / FOCAL_PX;
return s > 0 ? CAM_HEIGHT / s : -1;
}
// 먼 곳일수록 겹을 줄인다 — 한 화소에 덩이 여러 개가 들어가면 잔물결만 반짝인다
function octavesFor(z) { return z < 260 ? 4 : (z < 900 ? 3 : 2); }
function seaHeight(X, Z, oct) { return layeredNoise(X / PUFF_SIZE, Z / PUFF_SIZE, oct, 66); }
function fogMix(z) { return 1 - Math.exp(-z * FOG); }
function buildSea(t) {
const travel = t * FLY_SPEED;
for (let gy = 0; gy < GRID_H; gy += 1) {
const y = (gy + 0.5) * CELL;
const z = planeDistance(y);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
seaZ[i] = z;
if (z < 0) {
continue;
}
const X = ((gx + 0.5) * CELL - W / 2) / FOCAL_PX * z;
const oct = octavesFor(z);
const n = seaHeight(X, z + travel, oct);
seaN[i] = n;
seaLit[i] = clamp(0.5 + (n - seaHeight(X, z + travel + LIGHT_PROBE, oct)) * 6, 0, 1);
}
}
}
function seaCover() {
let a = 0;
let n = 0;
for (let i = 0; i < seaZ.length; i += 1) {
if (seaZ[i] < 0) {
continue;
}
n += 1;
if (seaN[i] > 0.36) {
a += 1;
}
}
return n ? a / n : 0;
}
function reset() { }
function update(dt) { }
// ── 06 화면 ──────────────────────────────────────────────────
const pix06 = makePix(GRID_W, GRID_H);
const HAZE = [214, 222, 246];
const SEA_DEEP = [128, 136, 196];
const WINDOW_FRAME = [232, 228, 238];
// 비행기 창틀 — 바깥 사각형에서 둥근 창 구멍을 뺀 면(꾸밈 한 겹, 움직이지 않는다)
function windowFrame() {
ctx.beginPath();
ctx.rect(0, 0, W, H);
ctx.roundRect(30, 18, W - 60, H - 36, 96);
ctx.fillStyle = rgba(WINDOW_FRAME, 1);
ctx.fill('evenodd');
ctx.beginPath();
ctx.roundRect(30, 18, W - 60, H - 36, 96);
ctx.lineWidth = 6;
ctx.strokeStyle = rgba(CLOUD_SHADE, 0.55);
ctx.stroke();
}
function draw() {
buildSea(sceneT);
const d = pix06.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const y = (gy + 0.5) * CELL;
const sky = mix(SKY_HIGH, HAZE, clamp(y / HORIZON_Y, 0, 1));
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
let c = sky;
const z = seaZ[i];
if (z >= 0) {
const n = seaN[i];
// 낮은 골은 보랏빛 그늘, 솟은 꼭대기는 흰빛 — 해를 받는 비탈은 버터빛을 조금
const top = band(0.3, 0.68, n);
let cl = mix(SEA_DEEP, CLOUD, 0.25 + 0.75 * top);
cl = mix(cl, BUTTER, seaLit[i] * top * 0.35);
c = mix(cl, HAZE, fogMix(z));
}
else {
const x = (gx + 0.5) * CELL;
c = mix(c, BUTTER, clamp(1 - Math.hypot(x - 300, y - HORIZON_Y) / 120, 0, 1) * 0.6);
}
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
}
blit(pix06, 0, 0, W, H);
windowFrame();
}
function statusText() { return skyText(seaCover(), SUN_DEG_06, 0); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SeaPlaneHorizon - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SeaPlaneHorizon.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SeaPlaneHorizon() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s: string, x: number, y: number, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 06 비행기 창밖으로 펼쳐지는 구름바다 ─────────────────────────
// 눈은 구름 평면 위 CAM_HEIGHT 높이. 화면 y 가 지평선 HORIZON_Y 아래인 화소는 시선 기울기 s = (y − HORIZON_Y) / FOCAL_PX 로
// 평면까지 거리 z = CAM_HEIGHT / s 를 얻고, 평면 좌표 (X, Z) = ((x − 240) / FOCAL_PX × z, z + 지나온 거리) 의 노이즈를 읽는다
const HORIZON_Y = 118;
const FOCAL_PX = 200;
const CAM_HEIGHT = 40;
// 앞으로 나는 빠르기(평면 단위/초)
const FLY_SPEED = 90;
// 구름 높낮이 노이즈 한 덩이 크기(평면 단위)
const PUFF_SIZE = 48;
// 거리 안개 — 섞는 몫 = 1 − e^(−z × FOG)
const FOG = 0.0011;
// 밝기 — 해 쪽(먼 쪽)으로 LIGHT_PROBE 만큼 떨어진 자리보다 높으면 해를 받는 비탈
const LIGHT_PROBE = 10;
const GRID_W = 200;
const GRID_H = 125;
const CELL = W / GRID_W;
const SUN_DEG_06 = 18;
const LOOP_S = 0;
const START_T = 2;
const REST_T = 2;
// 칸마다 [거리 z, 구름 높이 n, 해 받는 정도] — 화면 쪽이 색으로 바꾼다
const seaZ = new Float32Array(GRID_W * GRID_H);
const seaN = new Float32Array(GRID_W * GRID_H);
const seaLit = new Float32Array(GRID_W * GRID_H);
// 화면 y 의 평면까지 거리(지평선 위면 −1)
function planeDistance(y: number): number {
const s = (y - HORIZON_Y) / FOCAL_PX;
return s > 0 ? CAM_HEIGHT / s : -1;
}
// 먼 곳일수록 겹을 줄인다 — 한 화소에 덩이 여러 개가 들어가면 잔물결만 반짝인다
function octavesFor(z: number): number { return z < 260 ? 4 : (z < 900 ? 3 : 2); }
function seaHeight(X: number, Z: number, oct: number): number { return layeredNoise(X / PUFF_SIZE, Z / PUFF_SIZE, oct, 66); }
function fogMix(z: number): number { return 1 - Math.exp(-z * FOG); }
function buildSea(t: number): void {
const travel = t * FLY_SPEED;
for (let gy = 0; gy < GRID_H; gy += 1) {
const y = (gy + 0.5) * CELL;
const z = planeDistance(y);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
seaZ[i] = z;
if (z < 0) { continue; }
const X = ((gx + 0.5) * CELL - W / 2) / FOCAL_PX * z;
const oct = octavesFor(z);
const n = seaHeight(X, z + travel, oct);
seaN[i] = n;
seaLit[i] = clamp(0.5 + (n - seaHeight(X, z + travel + LIGHT_PROBE, oct)) * 6, 0, 1);
}
}
}
function seaCover(): number {
let a = 0;
let n = 0;
for (let i = 0; i < seaZ.length; i += 1) {
if (seaZ[i] < 0) { continue; }
n += 1;
if (seaN[i] > 0.36) { a += 1; }
}
return n ? a / n : 0;
}
function reset(): void { /* 상태가 없다 */ }
function update(dt: number): void { /* 시각만 흐른다 */ }
// ── 06 화면 ──────────────────────────────────────────────────
const pix06 = makePix(GRID_W, GRID_H);
const HAZE: RGB = [214, 222, 246];
const SEA_DEEP: RGB = [128, 136, 196];
const WINDOW_FRAME: RGB = [232, 228, 238];
// 비행기 창틀 — 바깥 사각형에서 둥근 창 구멍을 뺀 면(꾸밈 한 겹, 움직이지 않는다)
function windowFrame(): void {
ctx.beginPath();
ctx.rect(0, 0, W, H);
ctx.roundRect(30, 18, W - 60, H - 36, 96);
ctx.fillStyle = rgba(WINDOW_FRAME, 1);
ctx.fill('evenodd');
ctx.beginPath();
ctx.roundRect(30, 18, W - 60, H - 36, 96);
ctx.lineWidth = 6;
ctx.strokeStyle = rgba(CLOUD_SHADE, 0.55);
ctx.stroke();
}
function draw(): void {
buildSea(sceneT);
const d = pix06.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const y = (gy + 0.5) * CELL;
const sky = mix(SKY_HIGH, HAZE, clamp(y / HORIZON_Y, 0, 1));
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
let c = sky;
const z = seaZ[i];
if (z >= 0) {
const n = seaN[i];
// 낮은 골은 보랏빛 그늘, 솟은 꼭대기는 흰빛 — 해를 받는 비탈은 버터빛을 조금
const top = band(0.3, 0.68, n);
let cl = mix(SEA_DEEP, CLOUD, 0.25 + 0.75 * top);
cl = mix(cl, BUTTER, seaLit[i] * top * 0.35);
c = mix(cl, HAZE, fogMix(z));
} else {
const x = (gx + 0.5) * CELL;
c = mix(c, BUTTER, clamp(1 - Math.hypot(x - 300, y - HORIZON_Y) / 120, 0, 1) * 0.6);
}
const k = i * 4;
d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = 255;
}
}
blit(pix06, 0, 0, W, H);
windowFrame();
}
function statusText(): string { return skyText(seaCover(), SUN_DEG_06, 0); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'비행기 창틀 너머로 아래 평면에 깔린 구름바다가 앞으로 흘러오고, 멀어질수록 하늘색이 섞여 지평선 쪽 구름이 부드럽게 흐려진다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}06 · 비행기 창밖으로 펼쳐지는 구름바다 — 측정 누적면적 33.032% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function buildSea(t) {
const travel = t * FLY_SPEED;
for (let gy = 0; gy < GRID_H; gy += 1) {
const y = (gy + 0.5) * CELL;
const z = planeDistance(y);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
seaZ[i] = z;
if (z < 0) {
continue;
}
const X = ((gx + 0.5) * CELL - W / 2) / FOCAL_PX * z;
const oct = octavesFor(z);
const n = seaHeight(X, z + travel, oct);
seaN[i] = n;
seaLit[i] = clamp(0.5 + (n - seaHeight(X, z + travel + LIGHT_PROBE, oct)) * 6, 0, 1);
}
}
}
07이음매 없이 흐르는 여러 겹 구름 띠
웹사이트 헤더의 흐르는 배경에 어울린다. 네 겹의 윗선을 bandProfile 의 주기 노이즈(PERIOD 8칸)로 만들어 8칸을 흐르면 처음 값으로 돌아오고, 깊이 1 겹은 89.6px/초, 깊이 4 겹은 12.8px/초 로 느려지며 하늘색 몫은 0 에서 0.72 로 늘어난다. 한 바퀴는 가까운 겹이 5초, 먼 겹이 20초다.

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="깊이가 다른 구름 띠 네 겹이 먼 겹일수록 느리고 옅게 흐르며 이음매 없이 반복되고, 위쪽 작은 띠 두 줄 가운데 주기 처리를 하지 않은 쪽만 표시선 자리에서 툭 끊긴다"></canvas>
<p class="cl__status" data-role="status" aria-live="off">하늘을 읽는 중</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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-cream;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
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: #fff7e6;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return '오른쪽으로';
}
if (dir < 0) {
return '왼쪽으로';
}
return '잔잔함';
}
function skyText(cover, sunDeg, windDir) {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s, x, y, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 07 이음매 없이 흐르는 여러 겹 구름 띠 ───────────────────────
// 띠 하나의 윗선 = 주기 노이즈 한 줄. 격자 칸 PERIOD 개를 지나면 처음 값으로 돌아오므로, 흐른 거리를 PERIOD 로 나머지 처리해도 끊기지 않는다
const PERIOD = 8;
const LAYER_COUNT = 4;
// 겹 깊이 = 1(가까움)~LAYER_COUNT(멂). 흐르는 빠르기 = BASE_SPEED / 깊이 (격자 칸/초)
const BASE_SPEED = 1.6;
// 먼 겹일수록 하늘색을 더 섞는다 — 섞는 몫 = SKY_MIX × (깊이 − 1) / (LAYER_COUNT − 1)
const SKY_MIX = 0.72;
// 겹마다 [윗선 기준 y(px), 출렁임 폭(px), 격자 칸 하나의 화면 px] — 먼 겹이 위·작게
const LAYERS = [[226, 46, 56], [190, 40, 48], [150, 34, 40], [112, 30, 32]];
// 위쪽 시험 띠 두 줄 — 같은 노이즈를 주기 처리한 판과 하지 않은 판(STRIP_CELL_PX 칸 크기, 깊이 1 빠르기)
const STRIP_CELL_PX = 40;
const STRIP_SALT = 9.5;
const GRID_W = 240;
const GRID_H = 150;
const CELL = W / GRID_W;
const SUN_DEG_07 = 30;
const LOOP_S = 0;
const START_T = 3;
const REST_T = 3;
function layerSpeed(depth) { return BASE_SPEED / depth; }
// 깊이 depth 겹의 흐른 거리(격자 칸) — PERIOD 로 나머지 처리해 숫자가 끝없이 커지지 않게
function layerOffset(depth, t) {
const s = t * layerSpeed(depth);
return s - Math.floor(s / PERIOD) * PERIOD;
}
// 겹의 윗선 높이(0~1) — 주기 여러 겹 노이즈 합
function bandProfile(u, depth) { return periodicLayered(u, depth * 3.1, PERIOD, 4, 77); }
// 실패판 — 같은 자리를 주기 처리 없는 여러 겹 노이즈 합으로 읽는다(u 를 PERIOD 로 나머지 처리한 값을 그대로 넣는다)
function bandProfileNaive(u, depth) {
const m = u - Math.floor(u / PERIOD) * PERIOD;
return layeredNoise(m, depth * 3.1, 4, 77);
}
// 이음매 값 차 — u 가 PERIOD 바로 앞(PERIOD − 0.001)과 0 에서 읽은 값의 차
function seamJump(read, depth) {
return Math.abs(read(PERIOD - 0.001, depth) - read(0, depth));
}
// 화면 x 의 겹 윗선 y(px)
function layerTopY(x, li, t) {
const L = LAYERS[li];
const depth = li + 1;
return L[0] - L[1] * (bandProfile(x / L[2] + layerOffset(depth, t), depth) - 0.5) * 2;
}
function reset() { }
function update(dt) { }
// ── 07 화면 ──────────────────────────────────────────────────
const pix07 = makePix(GRID_W, GRID_H);
const tops07 = new Float32Array(GRID_W * LAYER_COUNT);
let cover07 = 0;
const BAND_SKY = [176, 200, 240];
const BAND_SHADE = [168, 160, 214];
// 시험 띠 한 줄 — 왼쪽 x0 부터 폭 w, 위 y0 높이 h. read 가 0~1 윗선을 준다
function drawStrip(y0, h, read, label, fg) {
const x0 = 12;
const w = W - 24;
box(x0, y0, w, h, 6, BAND_SKY, 0.95);
const off = layerOffset(1, sceneT);
ctx.beginPath();
ctx.moveTo(x0, y0 + h);
for (let x = 0; x <= w; x += 2) {
const v = read(x / STRIP_CELL_PX + off, STRIP_SALT);
ctx.lineTo(x0 + x, y0 + h - clamp((v - 0.2) / 0.6, 0, 1) * (h - 3));
}
ctx.lineTo(x0 + w, y0 + h);
ctx.closePath();
ctx.fillStyle = rgba(CLOUD, 1);
ctx.fill();
// 이음매 자리 — u 가 PERIOD 의 배수가 되는 화면 x
for (let k = 0; k < 4; k += 1) {
const x = (k * PERIOD - off) * STRIP_CELL_PX;
if (x < 0 || x > w) {
continue;
}
seg(x0 + x, y0 - 2, x0 + x, y0 + h + 2, 1.5, fg, 0.85, 3);
}
frameBox(x0, y0, w, h, 6, 1, INK, 0.25);
tag(label, x0 + 6, y0 + h / 2, fg, 10, 'left');
}
function draw() {
for (let gx = 0; gx < GRID_W; gx += 1) {
for (let li = 0; li < LAYER_COUNT; li += 1) {
tops07[li * GRID_W + gx] = layerTopY((gx + 0.5) * CELL, li, sceneT);
}
}
const d = pix07.img.data;
let covered = 0;
for (let gy = 0; gy < GRID_H; gy += 1) {
const y = (gy + 0.5) * CELL;
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
let c = sky;
for (let li = 0; li < LAYER_COUNT; li += 1) {
const top = tops07[li * GRID_W + gx];
if (y < top) {
continue;
}
const L = LAYERS[li];
const depth = li + 1;
// 윗선 가까이는 흰빛, 안쪽으로 들어갈수록 라벤더 그늘 + 안쪽 뭉게 무늬(같은 주기라 이것도 끊기지 않는다)
const tex = periodicLayered((gx + 0.5) * CELL / L[2] + layerOffset(depth, sceneT), y / L[2], PERIOD, 2, 80 + li);
let cl = mix(CLOUD, BAND_SHADE, band(2, 44, y - top) * 0.55 + (tex - 0.5) * 0.3);
cl = mix(cl, sky, SKY_MIX * (depth - 1) / (LAYER_COUNT - 1));
c = mix(sky, cl, band(-1, 2.5, y - top));
covered += 1;
break;
}
const k = (gy * GRID_W + gx) * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
}
cover07 = covered / (GRID_W * GRID_H);
blit(pix07, 0, 0, W, H);
drawStrip(44, 20, bandProfile, '주기 노이즈', MINT_DEEP);
drawStrip(70, 20, bandProfileNaive, '주기 없음', CORAL_DEEP);
}
function statusText() { return skyText(cover07, SUN_DEG_07, -1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SeamlessCloudBands - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SeamlessCloudBands.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SeamlessCloudBands() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s: string, x: number, y: number, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 07 이음매 없이 흐르는 여러 겹 구름 띠 ───────────────────────
// 띠 하나의 윗선 = 주기 노이즈 한 줄. 격자 칸 PERIOD 개를 지나면 처음 값으로 돌아오므로, 흐른 거리를 PERIOD 로 나머지 처리해도 끊기지 않는다
const PERIOD = 8;
const LAYER_COUNT = 4;
// 겹 깊이 = 1(가까움)~LAYER_COUNT(멂). 흐르는 빠르기 = BASE_SPEED / 깊이 (격자 칸/초)
const BASE_SPEED = 1.6;
// 먼 겹일수록 하늘색을 더 섞는다 — 섞는 몫 = SKY_MIX × (깊이 − 1) / (LAYER_COUNT − 1)
const SKY_MIX = 0.72;
// 겹마다 [윗선 기준 y(px), 출렁임 폭(px), 격자 칸 하나의 화면 px] — 먼 겹이 위·작게
const LAYERS: number[][] = [[226, 46, 56], [190, 40, 48], [150, 34, 40], [112, 30, 32]];
// 위쪽 시험 띠 두 줄 — 같은 노이즈를 주기 처리한 판과 하지 않은 판(STRIP_CELL_PX 칸 크기, 깊이 1 빠르기)
const STRIP_CELL_PX = 40;
const STRIP_SALT = 9.5;
const GRID_W = 240;
const GRID_H = 150;
const CELL = W / GRID_W;
const SUN_DEG_07 = 30;
const LOOP_S = 0;
const START_T = 3;
const REST_T = 3;
function layerSpeed(depth: number): number { return BASE_SPEED / depth; }
// 깊이 depth 겹의 흐른 거리(격자 칸) — PERIOD 로 나머지 처리해 숫자가 끝없이 커지지 않게
function layerOffset(depth: number, t: number): number {
const s = t * layerSpeed(depth);
return s - Math.floor(s / PERIOD) * PERIOD;
}
// 겹의 윗선 높이(0~1) — 주기 여러 겹 노이즈 합
function bandProfile(u: number, depth: number): number { return periodicLayered(u, depth * 3.1, PERIOD, 4, 77); }
// 실패판 — 같은 자리를 주기 처리 없는 여러 겹 노이즈 합으로 읽는다(u 를 PERIOD 로 나머지 처리한 값을 그대로 넣는다)
function bandProfileNaive(u: number, depth: number): number {
const m = u - Math.floor(u / PERIOD) * PERIOD;
return layeredNoise(m, depth * 3.1, 4, 77);
}
// 이음매 값 차 — u 가 PERIOD 바로 앞(PERIOD − 0.001)과 0 에서 읽은 값의 차
function seamJump(read: (u: number, depth: number) => number, depth: number): number {
return Math.abs(read(PERIOD - 0.001, depth) - read(0, depth));
}
// 화면 x 의 겹 윗선 y(px)
function layerTopY(x: number, li: number, t: number): number {
const L = LAYERS[li];
const depth = li + 1;
return L[0] - L[1] * (bandProfile(x / L[2] + layerOffset(depth, t), depth) - 0.5) * 2;
}
function reset(): void { /* 상태가 없다 */ }
function update(dt: number): void { /* 시각만 흐른다 */ }
// ── 07 화면 ──────────────────────────────────────────────────
const pix07 = makePix(GRID_W, GRID_H);
const tops07 = new Float32Array(GRID_W * LAYER_COUNT);
let cover07 = 0;
const BAND_SKY: RGB = [176, 200, 240];
const BAND_SHADE: RGB = [168, 160, 214];
// 시험 띠 한 줄 — 왼쪽 x0 부터 폭 w, 위 y0 높이 h. read 가 0~1 윗선을 준다
function drawStrip(y0: number, h: number, read: (u: number, depth: number) => number, label: string, fg: RGB): void {
const x0 = 12;
const w = W - 24;
box(x0, y0, w, h, 6, BAND_SKY, 0.95);
const off = layerOffset(1, sceneT);
ctx.beginPath();
ctx.moveTo(x0, y0 + h);
for (let x = 0; x <= w; x += 2) {
const v = read(x / STRIP_CELL_PX + off, STRIP_SALT);
ctx.lineTo(x0 + x, y0 + h - clamp((v - 0.2) / 0.6, 0, 1) * (h - 3));
}
ctx.lineTo(x0 + w, y0 + h);
ctx.closePath();
ctx.fillStyle = rgba(CLOUD, 1);
ctx.fill();
// 이음매 자리 — u 가 PERIOD 의 배수가 되는 화면 x
for (let k = 0; k < 4; k += 1) {
const x = (k * PERIOD - off) * STRIP_CELL_PX;
if (x < 0 || x > w) { continue; }
seg(x0 + x, y0 - 2, x0 + x, y0 + h + 2, 1.5, fg, 0.85, 3);
}
frameBox(x0, y0, w, h, 6, 1, INK, 0.25);
tag(label, x0 + 6, y0 + h / 2, fg, 10, 'left');
}
function draw(): void {
for (let gx = 0; gx < GRID_W; gx += 1) {
for (let li = 0; li < LAYER_COUNT; li += 1) { tops07[li * GRID_W + gx] = layerTopY((gx + 0.5) * CELL, li, sceneT); }
}
const d = pix07.img.data;
let covered = 0;
for (let gy = 0; gy < GRID_H; gy += 1) {
const y = (gy + 0.5) * CELL;
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
let c = sky;
for (let li = 0; li < LAYER_COUNT; li += 1) {
const top = tops07[li * GRID_W + gx];
if (y < top) { continue; }
const L = LAYERS[li];
const depth = li + 1;
// 윗선 가까이는 흰빛, 안쪽으로 들어갈수록 라벤더 그늘 + 안쪽 뭉게 무늬(같은 주기라 이것도 끊기지 않는다)
const tex = periodicLayered((gx + 0.5) * CELL / L[2] + layerOffset(depth, sceneT), y / L[2], PERIOD, 2, 80 + li);
let cl = mix(CLOUD, BAND_SHADE, band(2, 44, y - top) * 0.55 + (tex - 0.5) * 0.3);
cl = mix(cl, sky, SKY_MIX * (depth - 1) / (LAYER_COUNT - 1));
c = mix(sky, cl, band(-1, 2.5, y - top));
covered += 1;
break;
}
const k = (gy * GRID_W + gx) * 4;
d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = 255;
}
}
cover07 = covered / (GRID_W * GRID_H);
blit(pix07, 0, 0, W, H);
drawStrip(44, 20, bandProfile, '주기 노이즈', MINT_DEEP);
drawStrip(70, 20, bandProfileNaive, '주기 없음', CORAL_DEEP);
}
function statusText(): string { return skyText(cover07, SUN_DEG_07, -1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'깊이가 다른 구름 띠 네 겹이 먼 겹일수록 느리고 옅게 흐르며 이음매 없이 반복되고, 위쪽 작은 띠 두 줄 가운데 주기 처리를 하지 않은 쪽만 표시선 자리에서 툭 끊긴다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}07 · 이음매 없이 흐르는 여러 겹 구름 띠 — 측정 누적면적 43.224% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 깊이 depth 겹의 흐른 거리(격자 칸) — PERIOD 로 나머지 처리해 숫자가 끝없이 커지지 않게
function layerOffset(depth, t) {
const s = t * layerSpeed(depth);
return s - Math.floor(s / PERIOD) * PERIOD;
}
// 겹의 윗선 높이(0~1) — 주기 여러 겹 노이즈 합
function bandProfile(u, depth) { return periodicLayered(u, depth * 3.1, PERIOD, 4, 77); }
08마른 공기와 섞여 가장자리부터 흩어지는 구름
블로그 글 머리의 계절 전환 장면에 어울린다. stepGrid 는 짙기를 이웃 평균으로 번지게 하고 빈 이웃이 많은 바깥 칸일수록 증발을 크게 깎아, 마른 1초(0.5~1.5초) 동안 바깥 칸은 평균 0.334, 안쪽 칸은 0.156 줄어 바깥이 2.1배 빨리 마른다. 습도가 4.6~5.4초에 0.12 에서 0.92 로 오르면 가운데 무게가 큰 칸부터 차올라 5.6초에 무게 0.9 넘는 칸의 평균 짙기가 0.591, 0.2~0.5 칸은 0.093 이다.

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="마른 공기 속에서 구름 덩어리가 바깥 가장자리부터 조각조각 얇아지다가 다시 습해지면 가운데부터 차오르고, 오른쪽 아래 작은 창은 숨은 탭 뒤 긴 한 프레임을 그대로 받은 판이 한꺼번에 튀는 모습을 보여 준다"></canvas>
<p class="cl__status" data-role="status" aria-live="off">하늘을 읽는 중</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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-cream;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
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: #fff7e6;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return '오른쪽으로';
}
if (dir < 0) {
return '왼쪽으로';
}
return '잔잔함';
}
function skyText(cover, sunDeg, windDir) {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s, x, y, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 08 마른 공기와 섞여 가장자리부터 흩어지는 구름 ───────────────
// 짙기 격자 한 걸음(dt 초): 새 값 = 값 + DIFFUSE × dt × (상하좌우 이웃 평균 − 값)
// − EVAP_RATE × (1 − 습도) × (EDGE_BASE + 바깥 노출) × 마른 공기 섞임(dryMix) × dt + FILL_RATE × 습도 × 가운데 무게 × (1 − 값) × dt
// 바깥 노출 = 여덟 이웃 가운데 EMPTY_LEVEL 아래(빈 칸)인 몫. 걸음 뒤 줄어드는 칸이 CUT_LEVEL 아래면 0 으로 비운다
const DIFFUSE = 0.8;
const EVAP_RATE = 1.2;
const EDGE_BASE = 0.15;
const FILL_RATE = 0.9;
const EMPTY_LEVEL = 0.06;
const CUT_LEVEL = 0.03;
// 습도 시간표 — 한 바퀴 LOOP_S 초 가운데 앞 절반은 마름(DRY_HUMIDITY), 뒤 절반은 습함(WET_HUMIDITY)
const DRY_HUMIDITY = 0.12;
const WET_HUMIDITY = 0.92;
// 숨은 탭 시험 — 시연 SPIKE_AT 초에 HIDDEN_FRAME_S 초짜리 한 프레임이 들어온다. 수리판은 SPIKE_CAP_S(화면 쪽 DT_MAX 와 같은 0.05초)에서 자르고,
// 실패판은 그 1.2초를 한 걸음에 그대로 넣는다
const SPIKE_AT = 2;
const HIDDEN_FRAME_S = 1.2;
const SPIKE_CAP_S = 0.05;
const GRID_W = 160;
const GRID_H = 100;
const CELL = W / GRID_W;
const SUN_DEG_08 = 40;
const LOOP_S = 10;
const START_T = 3.8;
const REST_T = 4;
const fix08 = makeField(GRID_W, GRID_H);
const fail08 = makeField(GRID_W, GRID_H);
const next08 = new Float32Array(GRID_W * GRID_H);
// 처음 짙기(덩어리 셋 + 안쪽 결 노이즈 — 얇은 자리부터 먼저 뚫린다)와 가운데 무게(습해지면 이 무게만큼 차오른다, 덩어리 가운데로 갈수록 1)
const start08 = new Float32Array(GRID_W * GRID_H);
const core08 = new Float32Array(GRID_W * GRID_H);
const CORES = [[196, 160, 108], [316, 138, 84], [104, 186, 66]];
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
let dmin = 1e9;
for (const c of CORES) {
dmin = Math.min(dmin, Math.hypot(x - c[0], y - c[1]) - c[2]);
}
const n = layeredNoise(x / 26, y / 26, 3, 88);
const fine = layeredNoise(x / 11, y / 11, 2, 89);
const shape = 1 - band(-30, 8, dmin + (n - 0.5) * 50);
start08[gy * GRID_W + gx] = clamp(shape * (0.55 + 0.75 * fine), 0, 1);
core08[gy * GRID_W + gx] = 1 - band(-70, 6, dmin + (n - 0.5) * 30);
}
}
let lastT08 = 0;
// 섞여 드는 마른 공기 — 칸마다 0.4~1.6 배로 증발을 키우는 값 노이즈 소용돌이. 시각에 따라 오른쪽으로 흐른다
let mixT08 = 0;
function dryMix(gx, gy) { return 0.4 + 1.2 * valueNoise(gx / 6 - mixT08 * 0.6, gy / 6, 90); }
function humidityAt(t) { return lerp(DRY_HUMIDITY, WET_HUMIDITY, band(4.6, 5.4, t)); }
function stepGrid(f, dt, hum) {
const v = f.v;
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const l = gx > 0 ? v[i - 1] : 0;
const r = gx < GRID_W - 1 ? v[i + 1] : 0;
const u = gy > 0 ? v[i - GRID_W] : 0;
const dn = gy < GRID_H - 1 ? v[i + GRID_W] : 0;
const avg = (l + r + u + dn) / 4;
let empty = 0;
for (let oy = -1; oy <= 1; oy += 1) {
for (let ox = -1; ox <= 1; ox += 1) {
if (ox === 0 && oy === 0) {
continue;
}
const x = gx + ox;
const y = gy + oy;
const nb = (x < 0 || y < 0 || x >= GRID_W || y >= GRID_H) ? 0 : v[y * GRID_W + x];
if (nb < EMPTY_LEVEL) {
empty += 1;
}
}
}
let nv = v[i] + DIFFUSE * dt * (avg - v[i]);
nv -= EVAP_RATE * (1 - hum) * (EDGE_BASE + empty / 8) * dryMix(gx, gy) * dt;
nv += FILL_RATE * hum * core08[i] * (1 - v[i]) * dt;
next08[i] = (nv < CUT_LEVEL && nv < v[i]) ? 0 : clamp(nv, 0, 2);
}
}
v.set(next08);
}
function reset() {
fix08.v.set(start08);
fail08.v.set(start08);
lastT08 = 0;
}
function update(dt) {
const hum = humidityAt(sceneT);
mixT08 = sceneT;
if (lastT08 < SPIKE_AT && sceneT >= SPIKE_AT) {
stepGrid(fix08, Math.min(HIDDEN_FRAME_S, SPIKE_CAP_S), hum);
stepGrid(fail08, HIDDEN_FRAME_S, hum);
}
lastT08 = sceneT;
stepGrid(fix08, dt, hum);
stepGrid(fail08, dt, hum);
}
// 격자 지표 — [구름 양(짙기 > 0.1 몫), 1 을 넘은 칸 몫, 최댓값]
function gridStats(f) {
let a = 0;
let over = 0;
let mx = 0;
for (let i = 0; i < f.v.length; i += 1) {
const x = f.v[i];
if (x > 0.1) {
a += 1;
}
if (x > 1.0001) {
over += 1;
}
if (x > mx) {
mx = x;
}
}
return [a / f.v.length, over / f.v.length, mx];
}
// ── 08 화면 ──────────────────────────────────────────────────
const pixFix08 = makePix(GRID_W, GRID_H);
const pixFail08 = makePix(GRID_W, GRID_H);
const DRY_SKY_HIGH = [150, 176, 226];
const DRY_SKY_LOW = [222, 214, 240];
// 오른쪽 아래 작은 창 — 실패판(긴 한 프레임을 그대로 받은 격자)
const INSET_X = 300;
const INSET_Y = 186;
const INSET_W = 168;
const INSET_H = 105;
function paintGrid08(p, f) {
const d = p.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(DRY_SKY_HIGH, DRY_SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const v = f.v[i];
let c = sky;
if (v > 0) {
// 왼쪽 위(빛 쪽) 이웃이 더 짙으면 그늘, 옅으면 빛을 받는 쪽
const ul = gx > 0 && gy > 0 ? f.v[i - GRID_W - 1] : 0;
const shade = clamp((ul - v) * 4 + 0.35, 0, 1);
const cl = mix(CLOUD, CLOUD_SHADE, shade * 0.7);
c = mix(sky, cl, 1 - Math.exp(-3.4 * Math.min(v, 1)));
}
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
}
}
function draw() {
paintGrid08(pixFix08, fix08);
paintGrid08(pixFail08, fail08);
blit(pixFix08, 0, 0, W, H);
box(INSET_X - 4, INSET_Y - 4, INSET_W + 8, INSET_H + 8, 10, PAPER, 0.95);
ctx.save();
ctx.beginPath();
ctx.roundRect(INSET_X, INSET_Y, INSET_W, INSET_H, 7);
ctx.clip();
blit(pixFail08, INSET_X, INSET_Y, INSET_W, INSET_H);
ctx.restore();
tag('상한 없음', INSET_X + INSET_W / 2, INSET_Y + 14, CORAL_DEEP, 10, 'center');
tag('dt 상한 {s}초'.replace('{s}', String(SPIKE_CAP_S)), 14, H - 22, MINT_DEEP, 11, 'left');
const hum = humidityAt(sceneT);
tag((hum < 0.5 ? '마른 공기 {p}%' : '습한 공기 {p}%').replace('{p}', String(Math.round(hum * 100))), 14, H - 48, INK, 11, 'left');
}
function statusText() { return skyText(gridStats(fix08)[0], SUN_DEG_08, 0); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// EvaporateDryAir - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./EvaporateDryAir.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function EvaporateDryAir() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s: string, x: number, y: number, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 08 마른 공기와 섞여 가장자리부터 흩어지는 구름 ───────────────
// 짙기 격자 한 걸음(dt 초): 새 값 = 값 + DIFFUSE × dt × (상하좌우 이웃 평균 − 값)
// − EVAP_RATE × (1 − 습도) × (EDGE_BASE + 바깥 노출) × 마른 공기 섞임(dryMix) × dt + FILL_RATE × 습도 × 가운데 무게 × (1 − 값) × dt
// 바깥 노출 = 여덟 이웃 가운데 EMPTY_LEVEL 아래(빈 칸)인 몫. 걸음 뒤 줄어드는 칸이 CUT_LEVEL 아래면 0 으로 비운다
const DIFFUSE = 0.8;
const EVAP_RATE = 1.2;
const EDGE_BASE = 0.15;
const FILL_RATE = 0.9;
const EMPTY_LEVEL = 0.06;
const CUT_LEVEL = 0.03;
// 습도 시간표 — 한 바퀴 LOOP_S 초 가운데 앞 절반은 마름(DRY_HUMIDITY), 뒤 절반은 습함(WET_HUMIDITY)
const DRY_HUMIDITY = 0.12;
const WET_HUMIDITY = 0.92;
// 숨은 탭 시험 — 시연 SPIKE_AT 초에 HIDDEN_FRAME_S 초짜리 한 프레임이 들어온다. 수리판은 SPIKE_CAP_S(화면 쪽 DT_MAX 와 같은 0.05초)에서 자르고,
// 실패판은 그 1.2초를 한 걸음에 그대로 넣는다
const SPIKE_AT = 2;
const HIDDEN_FRAME_S = 1.2;
const SPIKE_CAP_S = 0.05;
const GRID_W = 160;
const GRID_H = 100;
const CELL = W / GRID_W;
const SUN_DEG_08 = 40;
const LOOP_S = 10;
const START_T = 3.8;
const REST_T = 4;
const fix08 = makeField(GRID_W, GRID_H);
const fail08 = makeField(GRID_W, GRID_H);
const next08 = new Float32Array(GRID_W * GRID_H);
// 처음 짙기(덩어리 셋 + 안쪽 결 노이즈 — 얇은 자리부터 먼저 뚫린다)와 가운데 무게(습해지면 이 무게만큼 차오른다, 덩어리 가운데로 갈수록 1)
const start08 = new Float32Array(GRID_W * GRID_H);
const core08 = new Float32Array(GRID_W * GRID_H);
const CORES: number[][] = [[196, 160, 108], [316, 138, 84], [104, 186, 66]];
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
let dmin = 1e9;
for (const c of CORES) { dmin = Math.min(dmin, Math.hypot(x - c[0], y - c[1]) - c[2]); }
const n = layeredNoise(x / 26, y / 26, 3, 88);
const fine = layeredNoise(x / 11, y / 11, 2, 89);
const shape = 1 - band(-30, 8, dmin + (n - 0.5) * 50);
start08[gy * GRID_W + gx] = clamp(shape * (0.55 + 0.75 * fine), 0, 1);
core08[gy * GRID_W + gx] = 1 - band(-70, 6, dmin + (n - 0.5) * 30);
}
}
let lastT08 = 0;
// 섞여 드는 마른 공기 — 칸마다 0.4~1.6 배로 증발을 키우는 값 노이즈 소용돌이. 시각에 따라 오른쪽으로 흐른다
let mixT08 = 0;
function dryMix(gx: number, gy: number): number { return 0.4 + 1.2 * valueNoise(gx / 6 - mixT08 * 0.6, gy / 6, 90); }
function humidityAt(t: number): number { return lerp(DRY_HUMIDITY, WET_HUMIDITY, band(4.6, 5.4, t)); }
function stepGrid(f: Field, dt: number, hum: number): void {
const v = f.v;
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const l = gx > 0 ? v[i - 1] : 0;
const r = gx < GRID_W - 1 ? v[i + 1] : 0;
const u = gy > 0 ? v[i - GRID_W] : 0;
const dn = gy < GRID_H - 1 ? v[i + GRID_W] : 0;
const avg = (l + r + u + dn) / 4;
let empty = 0;
for (let oy = -1; oy <= 1; oy += 1) {
for (let ox = -1; ox <= 1; ox += 1) {
if (ox === 0 && oy === 0) { continue; }
const x = gx + ox;
const y = gy + oy;
const nb = (x < 0 || y < 0 || x >= GRID_W || y >= GRID_H) ? 0 : v[y * GRID_W + x];
if (nb < EMPTY_LEVEL) { empty += 1; }
}
}
let nv = v[i] + DIFFUSE * dt * (avg - v[i]);
nv -= EVAP_RATE * (1 - hum) * (EDGE_BASE + empty / 8) * dryMix(gx, gy) * dt;
nv += FILL_RATE * hum * core08[i] * (1 - v[i]) * dt;
next08[i] = (nv < CUT_LEVEL && nv < v[i]) ? 0 : clamp(nv, 0, 2);
}
}
v.set(next08);
}
function reset(): void {
fix08.v.set(start08);
fail08.v.set(start08);
lastT08 = 0;
}
function update(dt: number): void {
const hum = humidityAt(sceneT);
mixT08 = sceneT;
if (lastT08 < SPIKE_AT && sceneT >= SPIKE_AT) {
stepGrid(fix08, Math.min(HIDDEN_FRAME_S, SPIKE_CAP_S), hum);
stepGrid(fail08, HIDDEN_FRAME_S, hum);
}
lastT08 = sceneT;
stepGrid(fix08, dt, hum);
stepGrid(fail08, dt, hum);
}
// 격자 지표 — [구름 양(짙기 > 0.1 몫), 1 을 넘은 칸 몫, 최댓값]
function gridStats(f: Field): [number, number, number] {
let a = 0;
let over = 0;
let mx = 0;
for (let i = 0; i < f.v.length; i += 1) {
const x = f.v[i];
if (x > 0.1) { a += 1; }
if (x > 1.0001) { over += 1; }
if (x > mx) { mx = x; }
}
return [a / f.v.length, over / f.v.length, mx];
}
// ── 08 화면 ──────────────────────────────────────────────────
const pixFix08 = makePix(GRID_W, GRID_H);
const pixFail08 = makePix(GRID_W, GRID_H);
const DRY_SKY_HIGH: RGB = [150, 176, 226];
const DRY_SKY_LOW: RGB = [222, 214, 240];
// 오른쪽 아래 작은 창 — 실패판(긴 한 프레임을 그대로 받은 격자)
const INSET_X = 300;
const INSET_Y = 186;
const INSET_W = 168;
const INSET_H = 105;
function paintGrid08(p: Pix, f: Field): void {
const d = p.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(DRY_SKY_HIGH, DRY_SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const v = f.v[i];
let c = sky;
if (v > 0) {
// 왼쪽 위(빛 쪽) 이웃이 더 짙으면 그늘, 옅으면 빛을 받는 쪽
const ul = gx > 0 && gy > 0 ? f.v[i - GRID_W - 1] : 0;
const shade = clamp((ul - v) * 4 + 0.35, 0, 1);
const cl = mix(CLOUD, CLOUD_SHADE, shade * 0.7);
c = mix(sky, cl, 1 - Math.exp(-3.4 * Math.min(v, 1)));
}
const k = i * 4;
d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = 255;
}
}
}
function draw(): void {
paintGrid08(pixFix08, fix08);
paintGrid08(pixFail08, fail08);
blit(pixFix08, 0, 0, W, H);
box(INSET_X - 4, INSET_Y - 4, INSET_W + 8, INSET_H + 8, 10, PAPER, 0.95);
ctx.save();
ctx.beginPath();
ctx.roundRect(INSET_X, INSET_Y, INSET_W, INSET_H, 7);
ctx.clip();
blit(pixFail08, INSET_X, INSET_Y, INSET_W, INSET_H);
ctx.restore();
tag('상한 없음', INSET_X + INSET_W / 2, INSET_Y + 14, CORAL_DEEP, 10, 'center');
tag('dt 상한 {s}초'.replace('{s}', String(SPIKE_CAP_S)), 14, H - 22, MINT_DEEP, 11, 'left');
const hum = humidityAt(sceneT);
tag((hum < 0.5 ? '마른 공기 {p}%' : '습한 공기 {p}%').replace('{p}', String(Math.round(hum * 100))), 14, H - 48, INK, 11, 'left');
}
function statusText(): string { return skyText(gridStats(fix08)[0], SUN_DEG_08, 0); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'마른 공기 속에서 구름 덩어리가 바깥 가장자리부터 조각조각 얇아지다가 다시 습해지면 가운데부터 차오르고, 오른쪽 아래 작은 창은 숨은 탭 뒤 긴 한 프레임을 그대로 받은 판이 한꺼번에 튀는 모습을 보여 준다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}08 · 마른 공기와 섞여 가장자리부터 흩어지는 구름 — 측정 누적면적 14.21% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const avg = (l + r + u + dn) / 4;
let empty = 0;
for (let oy = -1; oy <= 1; oy += 1) {
for (let ox = -1; ox <= 1; ox += 1) {
if (ox === 0 && oy === 0) {
continue;
}
const x = gx + ox;
const y = gy + oy;
const nb = (x < 0 || y < 0 || x >= GRID_W || y >= GRID_H) ? 0 : v[y * GRID_W + x];
if (nb < EMPTY_LEVEL) {
empty += 1;
}
}
}
let nv = v[i] + DIFFUSE * dt * (avg - v[i]);
nv -= EVAP_RATE * (1 - hum) * (EDGE_BASE + empty / 8) * dryMix(gx, gy) * dt;
nv += FILL_RATE * hum * core08[i] * (1 - v[i]) * dt;
next08[i] = (nv < CUT_LEVEL && nv < v[i]) ? 0 : clamp(nv, 0, 2);
}
}
v.set(next08);
}
09해돋이 구름 위 히어로 장면
포트폴리오 첫 화면의 히어로에 어울린다. 06 의 평면 위에서 sunBlock 이 해 쪽 5걸음(SUN_STEPS) 동안 빛줄기보다 솟은 구름 높이를 모으고 sunTransmit 이 e^(−ABSORB_09 × S / 22) 로 투과를 구한다. 해가 0° 일 때 꼭대기 위 20% 는 투과 0.841, 골짜기 아래 20% 는 0.339 로 꼭대기부터 빛이 닿고, 12° 에서는 0.996 과 0.967 로 거의 같아진다.

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="구름바다 위로 해가 떠오르며 높이 솟은 구름 꼭대기부터 금빛이 닿고 골짜기는 늦게 밝아지며, 지평선 가까이 먼 구름 띠가 천천히 흐른다"></canvas>
<p class="cl__status" data-role="status" aria-live="off">하늘을 읽는 중</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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-cream;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
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: #fff7e6;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return '오른쪽으로';
}
if (dir < 0) {
return '왼쪽으로';
}
return '잔잔함';
}
function skyText(cover, sunDeg, windDir) {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s, x, y, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 09 해돋이 구름 위 히어로 장면 ───────────────────────────────
// 06 의 아래 평면(화소마다 평면까지 거리) + 02 의 해 쪽 걸음(지수로 깎이는 투과) + 07 의 먼 겹 주기 띠를 한 화면에
const HORIZON_Y = 132;
const FOCAL_PX = 200;
const CAM_HEIGHT = 30;
const FLY_SPEED = 26;
const PUFF_SIZE = 56;
// 구름 꼭대기 높이 = 노이즈(0~1) × TOP_HEIGHT (평면 단위)
const TOP_HEIGHT = 22;
const FOG = 0.0012;
// 해 높이각(땅에서 잰 각) — 한 바퀴 LOOP_S 초 동안 SUN_START_DEG 에서 SUN_END_DEG 까지 오른다. 해는 정면(먼 쪽)
const SUN_START_DEG = -3;
const SUN_END_DEG = 12;
// 해 쪽 걸음 — 꼭대기 점에서 해 쪽으로 SUN_STEPS 걸음(한 걸음 SUN_STEP 평면 단위). 걸음마다 빛줄기 높이보다 솟은 구름 높이만큼 S 에 더한다
const SUN_STEPS = 5;
const SUN_STEP = 16;
// T = e^(−ABSORB_09 × S / TOP_HEIGHT)
const ABSORB_09 = 2.4;
// 하늘 빛 하한 — 해가 닿지 않은 골짜기도 까맣게 죽지 않게(02 의 수리와 같은 생각)
const AMBIENT_09 = 0.34;
// 먼 겹 띠 — 지평선 바로 위 주기 노이즈 두 겹(07 과 같은 주기 처리)
const FAR_PERIOD = 8;
const FAR_SPEED = 0.25;
const GRID_W = 200;
const GRID_H = 125;
const CELL = W / GRID_W;
const LOOP_S = 14;
const START_T = 6.5;
const REST_T = 8;
const seaZ09 = new Float32Array(GRID_W * GRID_H);
const seaH09 = new Float32Array(GRID_W * GRID_H);
const seaT09 = new Float32Array(GRID_W * GRID_H);
function sunElevDeg(t) { return lerp(SUN_START_DEG, SUN_END_DEG, smooth01(t / LOOP_S)); }
function planeDistance09(y) {
const s = (y - HORIZON_Y) / FOCAL_PX;
return s > 0 ? CAM_HEIGHT / s : -1;
}
function topHeight(X, Z, oct) { return layeredNoise(X / PUFF_SIZE, Z / PUFF_SIZE, oct, 99) * TOP_HEIGHT; }
// 꼭대기 점 (X, Z, h) 에서 해 쪽(+Z, 위로 tan 높이각)으로 걸어 모은 가림 S — 빛줄기보다 높이 솟은 구름 몫만 더한다
function sunBlock(X, Z, h, elevDeg) {
const rise = Math.tan(toRad(elevDeg)) * SUN_STEP;
let s = 0;
for (let k = 1; k <= SUN_STEPS; k += 1) {
const ray = h + rise * k;
const top = topHeight(X, Z + SUN_STEP * k, 2);
if (top > ray) {
s += top - ray;
}
}
return s;
}
function sunTransmit(X, Z, h, elevDeg) { return Math.exp(-ABSORB_09 * sunBlock(X, Z, h, elevDeg) / TOP_HEIGHT); }
function buildSea09(t) {
const travel = t * FLY_SPEED;
const e = sunElevDeg(t);
for (let gy = 0; gy < GRID_H; gy += 1) {
const z = planeDistance09((gy + 0.5) * CELL);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
seaZ09[i] = z;
if (z < 0) {
continue;
}
const X = ((gx + 0.5) * CELL - W / 2) / FOCAL_PX * z;
const h = topHeight(X, z + travel, z < 300 ? 4 : 3);
seaH09[i] = h;
seaT09[i] = sunTransmit(X, z + travel, h, e);
}
}
}
// 먼 겹 띠의 윗선 y(px) — 깊이 depth(1·2), 주기 노이즈라 흐른 거리를 FAR_PERIOD 로 나머지 처리해도 끊기지 않는다
function farBandTop(x, depth, t) {
const s = t * FAR_SPEED / depth;
const off = s - Math.floor(s / FAR_PERIOD) * FAR_PERIOD;
return HORIZON_Y - 4 - depth * 7 - 12 * periodicLayered(x / (70 / depth) + off, depth * 2.3, FAR_PERIOD, 3, 120);
}
function reset() { }
function update(dt) { }
// ── 09 화면 ──────────────────────────────────────────────────
const pix09 = makePix(GRID_W, GRID_H);
const farTop09 = new Float32Array(GRID_W * 2);
// 하늘 바탕색은 꾸밈 한 겹 — 해 높이에 따라 새벽 두 색에서 아침 두 색으로 섞기만 한다
const DAWN_TOP = [128, 126, 196];
const DAWN_LOW = [238, 184, 196];
const MORNING_TOP = [150, 182, 232];
const MORNING_LOW = [252, 218, 186];
const VALLEY = [112, 110, 170];
const CLOUD_DIM = [212, 208, 236];
const GOLD = [255, 214, 158];
const SUN_X_09 = 296;
let lit09 = 0;
let cover09 = 0;
function draw() {
buildSea09(sceneT);
const e = sunElevDeg(sceneT);
const dayK = band(-3, 12, e);
const top = mix(DAWN_TOP, MORNING_TOP, dayK);
const low = mix(DAWN_LOW, MORNING_LOW, dayK);
const sunY = HORIZON_Y - e * 7;
// 해가 떠 있는 몫 — 지평선 아래면 곧바로 오는 빛이 약하다
const sunUp = band(-2.5, 3, e);
for (let gx = 0; gx < GRID_W; gx += 1) {
for (let k = 0; k < 2; k += 1) {
farTop09[k * GRID_W + gx] = farBandTop((gx + 0.5) * CELL, k + 1, sceneT);
}
}
const d = pix09.img.data;
let litN = 0;
let seaN = 0;
let coverN = 0;
for (let gy = 0; gy < GRID_H; gy += 1) {
const y = (gy + 0.5) * CELL;
const sky = mix(top, low, clamp(y / HORIZON_Y, 0, 1));
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const x = (gx + 0.5) * CELL;
let c;
const z = seaZ09[i];
if (z < 0) {
const sd = Math.hypot(x - SUN_X_09, y - sunY);
c = mix(sky, GOLD, clamp(1 - (sd - 15) / 110, 0, 1) * 0.75);
if (sd < 15) {
c = [255, 236, 200];
}
// 먼 겹 띠 — 가까운 쪽(깊이 1)이 먼 쪽을 가린다. 먼 겹일수록 하늘색에 더 섞는다
for (let k = 0; k < 2; k += 1) {
if (y >= farTop09[k * GRID_W + gx]) {
const rimK = band(6, 0, y - farTop09[k * GRID_W + gx]) * sunUp;
const bandC = mix(mix(VALLEY, CLOUD_DIM, 0.45), GOLD, rimK * 0.8);
c = mix(bandC, sky, 0.35 + k * 0.25);
break;
}
}
}
else {
seaN += 1;
const hn = seaH09[i] / TOP_HEIGHT;
const lit = seaT09[i] * sunUp;
if (lit > 0.5) {
litN += 1;
}
if (hn > 0.3) {
coverN += 1;
}
const shadow = mix(VALLEY, CLOUD_DIM, band(0.25, 0.75, hn));
// 해가 닿지 않은 자리의 밝기 몫 = 0.2 + AMBIENT_09 × 2 (하늘 빛 하한을 0 으로 두면 0.2배로 거의 까맣게)
const k = 0.2 + AMBIENT_09 * 2;
const ambientC = [shadow[0] * k, shadow[1] * k, shadow[2] * k];
const sunC = mix(GOLD, CLOUD, 0.3 + 0.4 * hn);
c = mix(ambientC, sunC, lit);
c = mix(c, low, 1 - Math.exp(-z * FOG));
}
const k4 = i * 4;
d[k4] = c[0];
d[k4 + 1] = c[1];
d[k4 + 2] = c[2];
d[k4 + 3] = 255;
}
}
lit09 = seaN ? litN / seaN : 0;
cover09 = seaN ? coverN / seaN : 0;
blit(pix09, 0, 0, W, H);
tag('빛 닿은 구름 {p}%'.replace('{p}', String(Math.round(lit09 * 100))), 14, H - 22, INK, 11, 'left');
}
function statusText() { return skyText(cover09, sunElevDeg(sceneT), 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SunriseAboveSea - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SunriseAboveSea.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SunriseAboveSea() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 밝은 알약 위에 진한 글자
function tag(s: string, x: number, y: number, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 09 해돋이 구름 위 히어로 장면 ───────────────────────────────
// 06 의 아래 평면(화소마다 평면까지 거리) + 02 의 해 쪽 걸음(지수로 깎이는 투과) + 07 의 먼 겹 주기 띠를 한 화면에
const HORIZON_Y = 132;
const FOCAL_PX = 200;
const CAM_HEIGHT = 30;
const FLY_SPEED = 26;
const PUFF_SIZE = 56;
// 구름 꼭대기 높이 = 노이즈(0~1) × TOP_HEIGHT (평면 단위)
const TOP_HEIGHT = 22;
const FOG = 0.0012;
// 해 높이각(땅에서 잰 각) — 한 바퀴 LOOP_S 초 동안 SUN_START_DEG 에서 SUN_END_DEG 까지 오른다. 해는 정면(먼 쪽)
const SUN_START_DEG = -3;
const SUN_END_DEG = 12;
// 해 쪽 걸음 — 꼭대기 점에서 해 쪽으로 SUN_STEPS 걸음(한 걸음 SUN_STEP 평면 단위). 걸음마다 빛줄기 높이보다 솟은 구름 높이만큼 S 에 더한다
const SUN_STEPS = 5;
const SUN_STEP = 16;
// T = e^(−ABSORB_09 × S / TOP_HEIGHT)
const ABSORB_09 = 2.4;
// 하늘 빛 하한 — 해가 닿지 않은 골짜기도 까맣게 죽지 않게(02 의 수리와 같은 생각)
const AMBIENT_09 = 0.34;
// 먼 겹 띠 — 지평선 바로 위 주기 노이즈 두 겹(07 과 같은 주기 처리)
const FAR_PERIOD = 8;
const FAR_SPEED = 0.25;
const GRID_W = 200;
const GRID_H = 125;
const CELL = W / GRID_W;
const LOOP_S = 14;
const START_T = 6.5;
const REST_T = 8;
const seaZ09 = new Float32Array(GRID_W * GRID_H);
const seaH09 = new Float32Array(GRID_W * GRID_H);
const seaT09 = new Float32Array(GRID_W * GRID_H);
function sunElevDeg(t: number): number { return lerp(SUN_START_DEG, SUN_END_DEG, smooth01(t / LOOP_S)); }
function planeDistance09(y: number): number {
const s = (y - HORIZON_Y) / FOCAL_PX;
return s > 0 ? CAM_HEIGHT / s : -1;
}
function topHeight(X: number, Z: number, oct: number): number { return layeredNoise(X / PUFF_SIZE, Z / PUFF_SIZE, oct, 99) * TOP_HEIGHT; }
// 꼭대기 점 (X, Z, h) 에서 해 쪽(+Z, 위로 tan 높이각)으로 걸어 모은 가림 S — 빛줄기보다 높이 솟은 구름 몫만 더한다
function sunBlock(X: number, Z: number, h: number, elevDeg: number): number {
const rise = Math.tan(toRad(elevDeg)) * SUN_STEP;
let s = 0;
for (let k = 1; k <= SUN_STEPS; k += 1) {
const ray = h + rise * k;
const top = topHeight(X, Z + SUN_STEP * k, 2);
if (top > ray) { s += top - ray; }
}
return s;
}
function sunTransmit(X: number, Z: number, h: number, elevDeg: number): number { return Math.exp(-ABSORB_09 * sunBlock(X, Z, h, elevDeg) / TOP_HEIGHT); }
function buildSea09(t: number): void {
const travel = t * FLY_SPEED;
const e = sunElevDeg(t);
for (let gy = 0; gy < GRID_H; gy += 1) {
const z = planeDistance09((gy + 0.5) * CELL);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
seaZ09[i] = z;
if (z < 0) { continue; }
const X = ((gx + 0.5) * CELL - W / 2) / FOCAL_PX * z;
const h = topHeight(X, z + travel, z < 300 ? 4 : 3);
seaH09[i] = h;
seaT09[i] = sunTransmit(X, z + travel, h, e);
}
}
}
// 먼 겹 띠의 윗선 y(px) — 깊이 depth(1·2), 주기 노이즈라 흐른 거리를 FAR_PERIOD 로 나머지 처리해도 끊기지 않는다
function farBandTop(x: number, depth: number, t: number): number {
const s = t * FAR_SPEED / depth;
const off = s - Math.floor(s / FAR_PERIOD) * FAR_PERIOD;
return HORIZON_Y - 4 - depth * 7 - 12 * periodicLayered(x / (70 / depth) + off, depth * 2.3, FAR_PERIOD, 3, 120);
}
function reset(): void { /* 상태가 없다 — 해 높이와 구름은 시각으로만 정해진다 */ }
function update(dt: number): void { /* 시각만 흐른다 */ }
// ── 09 화면 ──────────────────────────────────────────────────
const pix09 = makePix(GRID_W, GRID_H);
const farTop09 = new Float32Array(GRID_W * 2);
// 하늘 바탕색은 꾸밈 한 겹 — 해 높이에 따라 새벽 두 색에서 아침 두 색으로 섞기만 한다
const DAWN_TOP: RGB = [128, 126, 196];
const DAWN_LOW: RGB = [238, 184, 196];
const MORNING_TOP: RGB = [150, 182, 232];
const MORNING_LOW: RGB = [252, 218, 186];
const VALLEY: RGB = [112, 110, 170];
const CLOUD_DIM: RGB = [212, 208, 236];
const GOLD: RGB = [255, 214, 158];
const SUN_X_09 = 296;
let lit09 = 0;
let cover09 = 0;
function draw(): void {
buildSea09(sceneT);
const e = sunElevDeg(sceneT);
const dayK = band(-3, 12, e);
const top = mix(DAWN_TOP, MORNING_TOP, dayK);
const low = mix(DAWN_LOW, MORNING_LOW, dayK);
const sunY = HORIZON_Y - e * 7;
// 해가 떠 있는 몫 — 지평선 아래면 곧바로 오는 빛이 약하다
const sunUp = band(-2.5, 3, e);
for (let gx = 0; gx < GRID_W; gx += 1) {
for (let k = 0; k < 2; k += 1) { farTop09[k * GRID_W + gx] = farBandTop((gx + 0.5) * CELL, k + 1, sceneT); }
}
const d = pix09.img.data;
let litN = 0;
let seaN = 0;
let coverN = 0;
for (let gy = 0; gy < GRID_H; gy += 1) {
const y = (gy + 0.5) * CELL;
const sky = mix(top, low, clamp(y / HORIZON_Y, 0, 1));
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const x = (gx + 0.5) * CELL;
let c: RGB;
const z = seaZ09[i];
if (z < 0) {
const sd = Math.hypot(x - SUN_X_09, y - sunY);
c = mix(sky, GOLD, clamp(1 - (sd - 15) / 110, 0, 1) * 0.75);
if (sd < 15) { c = [255, 236, 200]; }
// 먼 겹 띠 — 가까운 쪽(깊이 1)이 먼 쪽을 가린다. 먼 겹일수록 하늘색에 더 섞는다
for (let k = 0; k < 2; k += 1) {
if (y >= farTop09[k * GRID_W + gx]) {
const rimK = band(6, 0, y - farTop09[k * GRID_W + gx]) * sunUp;
const bandC = mix(mix(VALLEY, CLOUD_DIM, 0.45), GOLD, rimK * 0.8);
c = mix(bandC, sky, 0.35 + k * 0.25);
break;
}
}
} else {
seaN += 1;
const hn = seaH09[i] / TOP_HEIGHT;
const lit = seaT09[i] * sunUp;
if (lit > 0.5) { litN += 1; }
if (hn > 0.3) { coverN += 1; }
const shadow = mix(VALLEY, CLOUD_DIM, band(0.25, 0.75, hn));
// 해가 닿지 않은 자리의 밝기 몫 = 0.2 + AMBIENT_09 × 2 (하늘 빛 하한을 0 으로 두면 0.2배로 거의 까맣게)
const k = 0.2 + AMBIENT_09 * 2;
const ambientC: RGB = [shadow[0] * k, shadow[1] * k, shadow[2] * k];
const sunC = mix(GOLD, CLOUD, 0.3 + 0.4 * hn);
c = mix(ambientC, sunC, lit);
c = mix(c, low, 1 - Math.exp(-z * FOG));
}
const k4 = i * 4;
d[k4] = c[0]; d[k4 + 1] = c[1]; d[k4 + 2] = c[2]; d[k4 + 3] = 255;
}
}
lit09 = seaN ? litN / seaN : 0;
cover09 = seaN ? coverN / seaN : 0;
blit(pix09, 0, 0, W, H);
tag('빛 닿은 구름 {p}%'.replace('{p}', String(Math.round(lit09 * 100))), 14, H - 22, INK, 11, 'left');
}
function statusText(): string { return skyText(cover09, sunElevDeg(sceneT), 1); }
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 버튼을 누르면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'구름바다 위로 해가 떠오르며 높이 솟은 구름 꼭대기부터 금빛이 닿고 골짜기는 늦게 밝아지며, 지평선 가까이 먼 구름 띠가 천천히 흐른다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 · 해돋이 구름 위 히어로 장면 — 측정 누적면적 49.187% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 꼭대기 점 (X, Z, h) 에서 해 쪽(+Z, 위로 tan 높이각)으로 걸어 모은 가림 S — 빛줄기보다 높이 솟은 구름 몫만 더한다
function sunBlock(X, Z, h, elevDeg) {
const rise = Math.tan(toRad(elevDeg)) * SUN_STEP;
let s = 0;
for (let k = 1; k <= SUN_STEPS; k += 1) {
const ray = h + rise * k;
const top = topHeight(X, Z + SUN_STEP * k, 2);
if (top > ray) {
s += top - ray;
}
}
return s;
}
function sunTransmit(X, Z, h, elevDeg) { return Math.exp(-ABSORB_09 * sunBlock(X, Z, h, elevDeg) / TOP_HEIGHT); }
구름 그림자 그리기는 구름 자리에 무엇을 더해야 땅 위에 맞게 떨어질까?
구름 그림자는 구름 바로 아래에 두면 어긋난다. 04 는 땅 점 (x, h) 의 그림자가 x + (CLOUD_H − h) / tan(높이각) 자리의 구름에서 온다고 계산하므로, 구름 x 에 해 높이로 정해지는 거리를 더해야 그림자가 맞는 자리에 떨어진다.
높은 언덕일수록 구름에 가까운 그림자
같은 해 높이 50° 에서 높이 34px 땅은 구름과 149.4px 떨어진 그림자를 받고, 80px 는 110.8px, 126px 는 72.2px 로 줄어든다. 언덕 꼭대기가 구름층에 가까울수록 해 쪽으로 거슬러 오를 높이가 짧기 때문이다.
해를 땅에서 재는 각으로 둘 것
SUN_ELEV_DEG 는 땅에서 잰 높이각이다. 각이 작을수록 tan 이 작아져 나눗셈 결과인 비껴 나는 거리가 길어지므로, 머리 위에서 잰 각으로 넣으면 거리 계산이 어긋난다.
배경 위에 얹을 때
구름 배경 그리기에서는 04 처럼 계산 격자를 화면보다 작게 잡고 늘려 그리는 편이 가볍다. 여행 소개 페이지의 들판 히어로라면 언덕 높이 h(x) 를 페이지의 지형 그림에 맞추고, 상태 줄은 왼쪽 위, 일시정지는 오른쪽 위에 둔 자리를 그대로 쓰면 된다.
노이즈로 짙기를 만들고, 해 쪽으로 걸어 빛을 깎고, 바람으로 밀어 옮긴다 — 노이즈 한 장이 구름이 되는 순서
구름이 되는 길은 네 단계다. 짙기를 만들고, 해 쪽으로 걸어 빛을 깎고, 바람으로 밀고, 작은 격자에서 계산해 늘려 그린다.
| 단계 | 하는 일 | 쓰는 이름 | 쓰는 장면 |
|---|---|---|---|
| 1 짙기 만들기 | 여러 겹 노이즈를 겹마다 빈도 2배·세기 절반으로 합해 0~1 로 맞춤 | layeredNoise |
02~09 |
| 2 빛 깎기 | 해 쪽으로 걸으며 짙기를 합산해 지수로 깎음 | lightSum·sunBlock |
02·09 |
| 3 바람으로 밀기 | 읽는 좌표를 흐른 거리만큼 밀어 구름이 흐름 | windAt·layerOffset |
05·07 |
| 4 늘려 그리기 | 작은 격자 화소를 만들어 480×300 에 확대 | makePix·putPx·blit |
01~09 |
여러 겹 노이즈 합이 구름 결인 이유
한 겹 노이즈는 매끈한 언덕만 만든다. 겹마다 빈도를 2배, 세기를 절반으로 쌓고 세기 합으로 나누면 큰 덩이 위에 잔 결이 얹히면서도 값은 0~1 에 머문다.
빛을 합산해 지수로 깎는 이유
투과를 e^(−ABSORB × S) 로 두면 짙기 0.5 를 두 번 지난 빛과 1 을 한 번 지난 빛이 같다. 걸음 수를 정해 짙기를 합산하기만 하면 되므로, 칸마다 해 쪽으로 6걸음 걷는 것으로 구름 속은 어둡고 얇은 가장자리는 밝은 모양이 나온다.
작은 격자에서 계산해 늘려 그리는 이유
화소 계산은 칸 수에 비례한다. 장면마다 120×150에서 240×150 사이의 작은 격자에서 계산하고 drawImage 로 늘려 그리면, 구름의 부드러운 경계는 확대가 대신 만들어 준다.
구름이 깨지는 순간 — 이음매에서 툭 끊기는 반복·해를 등진 쪽이 까맣게 죽는 빛·숨은 탭 뒤 한꺼번에 튀어 오른 구름
이음매에서 끊기는 반복
07 의 위쪽 시험 띠 둘은 같은 u 를 서로 다른 노이즈에 넣은 것이다. 흐른 거리를 PERIOD 로 나머지 처리해도 주기 없는 노이즈는 u 가 8 에서 0 으로 돌아가는 자리에서 다른 격자값을 읽어, u 7.999 와 0 의 값 차(64개 높이 줄)가 평균 0.175, 최대 0.553 이 된다. 주기 노이즈는 평균·최대 모두 0 이고, 높이 17px 시험 띠에서 주기 없음은 이음매에서 5px 툭 끊기는 반면 주기 노이즈는 0px 로 끊김이 없다.
아래 seamJump 는 화면이 부르지 않는 시험·측정용 함수다.
// 실패판 — 같은 자리를 주기 처리 없는 여러 겹 노이즈 합으로 읽는다(u 를 PERIOD 로 나머지 처리한 값을 그대로 넣는다)
function bandProfileNaive(u, depth) {
const m = u - Math.floor(u / PERIOD) * PERIOD;
return layeredNoise(m, depth * 3.1, 4, 77);
}
// 이음매 값 차 — u 가 PERIOD 바로 앞(PERIOD − 0.001)과 0 에서 읽은 값의 차
function seamJump(read, depth) {
return Math.abs(read(PERIOD - 0.001, depth) - read(0, depth));
}
해를 등진 쪽이 까맣게 죽는 빛
02 의 왼쪽 판은 투과만으로 칠한 판이라 해를 등진 구름 속에서 빛이 지수로 줄어 바닥을 친다. 오른쪽 판은 paintPanel02 에 AMBIENT_LIGHT 0.6 을 넘겨 하늘 빛 하한을 더하며, 같은 짙기에서 구름 칸 최저 밝기가 0.029 에서 0.629 로 오르고 밝기 0.15 아래 칸이 80.4% 에서 0% 로 사라진다. 09 도 같은 수리를 AMBIENT_09 로 하며, 해 못 받은 골짜기 밝기 몫은 하한 0 이면 0.2, 있으면 0.88 이다.
const dv = dens02.v[i];
if (dv > 0) {
const L = trans02[i] * (BASE_LIGHT + forwardPhase(x, y));
const cl = [
SUN_TINT[0] * L * 1.15 + AMBIENT_TINT[0] * ambient,
SUN_TINT[1] * L * 1.15 + AMBIENT_TINT[1] * ambient,
SUN_TINT[2] * L * 1.15 + AMBIENT_TINT[2] * ambient,
];
c = mix(c, cl, 1 - Math.exp(-dv * OPACITY));
}
숨은 탭 뒤에 한꺼번에 튀어 오른 구름
08 은 시연 2초에 1.2초짜리 프레임이 들어오는 상황을 일부러 만든다. 수리판은 SPIKE_CAP_S 0.05초로 잘라 확산 몫 0.04 의 한 걸음만 가고, 실패판은 1.2초를 한 걸음에 써 확산 몫 0.96 이 되어 짙기가 0.2 넘게 바뀐 칸이 격자 전체 기준 0% 대 6.82% 가 된다. 그 뒤 실패판은 빈 칸이 늘어 증발이 빨라져 4초 22.4 대 17.4%, 5초 18.2 대 12.1% 로 벌어졌다가 습해진 6초 이후 26.7 대 26.6% 로 다시 붙는다.
function update(dt) {
const hum = humidityAt(sceneT);
mixT08 = sceneT;
if (lastT08 < SPIKE_AT && sceneT >= SPIKE_AT) {
stepGrid(fix08, Math.min(HIDDEN_FRAME_S, SPIKE_CAP_S), hum);
stepGrid(fail08, HIDDEN_FRAME_S, hum);
}
lastT08 = sceneT;
stepGrid(fix08, dt, hum);
stepGrid(fail08, dt, hum);
}
실제 탭이 숨으면 프레임 루프가 멈추고, 돌아올 때 lastTs 를 −1 로 비워 둔 채 DT_MAX 0.05초로 한 번 더 잘라 쓴다. SPIKE_CAP_S 와 DT_MAX 는 값이 0.05초로 같을 뿐 별개의 상수다. 압축 파일은 암호 n97f273v 로 열고 07·02·08 폴더의 index.html 을 일시정지 버튼으로 세워 두면 깨진 판과 고친 판이 갈라지는 자리를 눈으로 확인할 수 있다.
흡수를 높이면 아랫면은 얼마나 어두워질까 — 흡수율·해 높이·높이별 바람 차이를 바꿔 본 차이
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 빛을 더 많이 먹게 | 02 ABSORB 0.9 → 1.6 |
짙기 합 1 을 지난 빛이 41% 에서 20% 로 줄어 구름 속 투과가 평균 0.14 에서 0.04 로 떨어지고, 주변광 없는 판의 거의 까만 칸이 구름의 80% 에서 91% 로 늘며 얇은 가장자리 밝기도 0.88 에서 0.79 로 내려가 은빛 테두리가 가늘어진다 |
| 해를 낮게 | 04 SUN_ELEV_DEG 50 → 20 |
높이각의 탄젠트가 1.19 에서 0.36 으로 줄어 그림자가 구름에서 비껴 나는 거리가 72~149px 에서 236~489px 로 세 배 넘게 멀어지고, 해를 향한 비탈과 등진 비탈의 밝기 몫이 0.88·0.52 에서 0.53·0.12 로 벌어지며 해를 못 받는 땅이 0.2% 에서 15% 로 늘어난다 |
| 높이별 바람 차이를 크게 | 05 SHEAR 0.6 → 1.4 |
꼭대기 바람이 바닥의 1.6배에서 2.4배(초당 48px 에서 72px)가 되어 위상 한가운데(8초) 결 기울기가 25.6°에서 48.2°로 눕고, 높이 비 0.3 과 꼭대기 결의 어긋남이 100.8px 에서 235.2px 로 벌어진다 |
값을 바꾸기 전에 볼 것
02 의 ABSORB 는 지수 안에 들어가므로 1.6 으로 올리면 구름 속 투과가 곱셈으로 급히 떨어진다. 04 의 SUN_ELEV_DEG 는 땅에서 잰 각이라 20 으로 낮추면 tan 이 작아져 나눗셈 결과가 커진다. 05 의 SHEAR 는 높이 비에 곱해지므로 1.4 에서 꼭대기 바람이 바닥의 2.4배가 된다.
움직임 줄이기에서는 멈춘 하늘 한 장 — 지금 하늘을 읽어 주는 문장과 일시정지의 접근성
상태 문장과 일시정지
아홉 장면의 캔버스는 role="img" 와 장면 설명 aria-label 을 갖고, 상태 줄은 「구름 양 40% · 해 높이 50° · 바람 오른쪽으로」 꼴로 지금 하늘을 문장으로 알린다. 일시정지 버튼은 Space·Enter 로 누르면 aria-pressed 가 바뀌고 루프가 멈추며, 포커스는 3px 외곽선으로 보인다. 시연 중 상태 줄은 aria-live off 이고 첫 입력 뒤에 polite 로 바뀐다.
움직임 줄이기 설정
prefers-reduced-motion: reduce 이면 아홉 장면 모두 루프를 세우고 대표 순간 한 장만 그린다. 그 순간은 01 1.2초·02 2초·03 8초·04 3초·05 8초·06 2초·07 3초·08 4초·09 8초 이고, 흐름·부풀기·흩어짐은 없다. 설정을 풀면 다시 돌아가고, 규격은 MDN 의 prefers-reduced-motion 에 있다.
구름 애니메이션 FAQ — css 만으로 만들기·휴대폰 부하·배경으로 깔기
구름 생성 원리를 몰라도 css 만으로 같은 구름을 만들 수 있나요
그라디언트를 겹친 정지 구름은 css 만으로 되지만, 이 편의 장면은 칸마다 짙기와 빛을 계산하므로 캔버스와 js 가 맞습니다. 원리를 몰라도 ABSORB·SUN_ELEV_DEG·SHEAR 같은 상수만 바꿔 다른 하늘을 얻을 수 있습니다.
휴대폰에서도 돌려 볼 수 있나요
캔버스 내부 좌표는 480×300 으로 고정이고 devicePixelRatio 는 2 까지만 쓰며, 화소 계산은 120×150~240×150 의 작은 격자에서 한다. 그래도 무겁다면 격자 칸 수를 줄이는 것부터 해 보시면 됩니다.
페이지 배경으로 깔아도 되나요
06·07·09 는 헤더와 히어로 배경으로 쓰기 좋고, 탭이 숨으면 루프가 멈춥니다. 글자를 읽는 구간에는 구름 위에 반투명 판을 한 장 두어 글자 대비를 지키는 쪽이 좋습니다.









