キャンバス 海 アニメーション シーン9選 — 重ねた正弦波で波の場を計算する
海 アニメーションは写真やGIFを繰り返し再生する画面ではなく、格子のマスごとに波の高さをリアルタイムで計算し直して描く画面です。重ねた正弦波で波の場を計算するという原理ひとつの上に、この9つのシーンは水色・泡・反射光・渦へと枝分かれしていきます。
01 ocean-gerstner-sum
02 ocean-depth-attenuate
03 ocean-crest-foam
04 ocean-specular-glitter
05 ocean-foam-particles
06 ocean-refract-caustic
07 ocean-rip-current
08 ocean-sweep-surge
09 ocean-horizon-haze
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 正弦波合成の水面 — ガーストナー波の場
- 02 深さによる減衰 — 浅瀬の水色断面
- 03 波頂の泡 — 頂上の白い筋
- 04 鏡面ハイライト — 太陽のグリッター
- 05 泡のパーティクル — グリッドセル由来の破片
- 06 水面屈折 — ゆらぐ水紋
- 07 波の渦 — 離岸流の渦パッチ
- 08 津波スイープ — 押し寄せる波の壁
- 09 水平線のかすみ — 遠景を霞ませる
なぜこの順番か
最初のシーンが本体です。波長・方向・振幅の異なる正弦波6つを合成して水面グリッドをひとつ計算し、残り8つはすべてそのグリッドの上に派生値をもう一段重ねた結果です。水色を水深として読めば02、高さが閾値を超えるマスだけ選べば03、勾配と太陽ベクトルを内積すれば04になります。05は03の泡を座標ではなくグリッドセルで追跡し、06は同じ勾配を屈折オフセットとして使い、07・08はそのグリッド自体に局所回転と全体エンベロープを重ねて渦と押し寄せる波を作ります。09は最後に距離に応じて振幅と色を抑え、遠景を霞に沈めます。
本体はひとつ、派生は8つ
01のhgt配列(マスごとに計算した高さ)とfieldcalc関数は、9項目のほぼすべてでそのまま使い回されます。変わるのはその高さを読んで何を描くかだけです — 色なら水面、閾値なら泡、勾配なら反射光になります。
近景から遠景へ — 計算が積み上がる順番
01〜06は画面全体が同じ視点から見た水面で、07〜08はその水面の上に局所的な出来事(渦・押し寄せる波)を重ね、09は最後にカメラを引いて水平線まで収めます。素材を選ぶときもこの順番をそのまま辿ると、計算をひとつ覚えるたびに次のシーンが自然に解けていきます。
01正弦波合成の水面 — ガーストナー波の場
波長・方向・振幅の異なる正弦波6つを重ねて水面グリッドに合成し、波頂が前にずれるガーストナーのずれまで入れた、リゾート・クルーズLPのヒーロー向け基本シーンです(実測: 累積面積60.795%、23/23フレームで動きあり)。

<main class="stage">
<div class="gs" id="gs">
<p class="gs__hint">
<svg class="gs__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>正弦波6つを合成して水面グリッドを計算します — ドラッグすると風が波を押します</span>
</p>
<div class="gs__field" id="field" role="application" aria-label="正弦波合成水面の実験パネル" tabindex="0">
<canvas class="gs__cv" id="cv"></canvas>
<b class="gs__badge" aria-hidden="true"><span class="gs__badgeNum" id="waveVal">波6</span><span class="gs__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 정현파 합성 수면 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 두지 않는다(077 방식). 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$gs-crest: mix($subject-mint, $stage-paper, 72%); // 마루 — 민트
$gs-crest2: mix($subject-sky, $stage-paper, 66%); // 마루 보조(하늘 민트)
$gs-trough: mix($subject-blue-deep, $stage-paper, 52%); // 골 — 진한 파랑
$gs-trough2: mix($subject-blue, $stage-paper, 58%); // 골 보조(파랑)
$duration: $dur-loop;
$easing: $ease-spring;
$color: $gs-crest;
@include stage($stage-paper);
.gs {
--crest: #{$gs-crest};
--crest-2: #{$gs-crest2};
--trough: #{$gs-trough};
--trough-2: #{$gs-trough2};
--ink: #{$subject-ink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gs__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.gs__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.gs__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $gs-trough2;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.gs__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.gs__cv { display: block; width: 100%; height: 100%; }
// 파 개수·바람 세기를 화면에도 적어 둔다 — frame() 이 갱신한다
.gs__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.gs__badgeNum { font-size: 13px; color: $stage-yellow; }
.gs__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.gs { gap: $sp-1; }
.gs__field { height: 124px; }
.gs__badge { font-size: 10px; padding: 0 $sp-1; }
.gs__badgeNum { font-size: 11px; }
}
// reduce — 루프가 아예 안 돈다. 파도가 살아 있는 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.gs.is-still .gs__field { cursor: default !important; }
.gs.is-still .gs__badge { opacity: .92 !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);
}
.gs {
--crest: rgb(49.4588235294%, 87.8588235294%, 74.8705882353%);
--crest-2: rgb(62.4705882353%, 85.7647058824%, 100%);
--trough: rgb(50.0392156863%, 64.1098039216%, 95.3098039216%);
--trough-2: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
--ink: #17141a;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gs__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.gs__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.gs__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.gs__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.gs__cv {
display: block;
width: 100%;
height: 100%;
}
.gs__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.gs__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.gs__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.gs {
gap: 4px;
}
.gs__field {
height: 124px;
}
.gs__badge {
font-size: 10px;
padding: 0 4px;
}
.gs__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.gs.is-still .gs__field {
cursor: default !important;
}
.gs.is-still .gs__badge {
opacity: 0.92 !important;
}
}// 정현파 합성 수면 — Gerstner 근사 파도장. 파장·방향·진폭이 다른 정현파 여섯을
// 저해상 수면 격자(COLS×ROWS) 위에 겹쳐 합하고, 파도가 높을 때 물꼭대기가 앞으로
// 처지는 Gerstner 수평 처짐(dispX = Q·amp·cos)까지 넣어 꼭대기가 뾰족해지는 근사를 만든다.
// 렌더는 격자 해상도 ImageData(STEP=3 버퍼) → putImageData → drawImage 확대.
// 바람은 자동 모드(시드 고정 리사주 순환)와 포인터 실측(pointermove 이동 벡터 → lerp) 두 몸.
var root = document.getElementById('gs');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var waveVal = document.getElementById('waveVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var CREST = [v('--crest'), v('--crest-2')]; // 마루(높은 곳) 색 램프 — 민트 계열
var TROUGH = [v('--trough'), v('--trough-2')]; // 골(낮은 곳) 색 램프 — 파랑 계열
var INK = v('--ink');
var COLS = 110; // 수면 격자 가로 칸 수 — 세로는 판 비율. 칸 수 1만 이내로 60fps 예산
var STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수
var Q = 0.42; // Gerstner 처짐 계수 — 여섯 파의 Q·amp 합이 1을 넘지 않게 잡는다
// 결정적 난수 mulberry32(7) — 시드가 같으면 파도 구성이 항상 같다
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
// 여섯 정현파 — 파장·방향·진폭·위상 전부 시드에서 파생.
// 파장은 긴 것부터 절반씩(잔물결 계층), 진폭도 파장에 비례해 줄어든다.
var WAVES = [];
(function () {
var lam = 34; // 첫 파의 파장(격자 칸 수)
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2; // 파도가 나아가는 방향
WAVES.push({
lam: lam, // 파장
freq: (Math.PI * 2) / lam, // 공간 주파수(도메인 단위)
dirX: Math.cos(ang), dirY: Math.sin(ang), // 진행 방향 단위벡터
amp: 0.58 / (i * 0.62 + 1), // 진폭 — 첫 파가 가장 크다 (총진폭 ↑ → 화면 전체가 흐른다)
spd: 0.55 + rng() * 0.25, // 속도 산수
ph: rng() * Math.PI * 2 // 위상
});
lam *= 0.55; // 다음 파는 파장이 절반보다 조금 길게
}
})();
// ω — 파장 짧을수록 빠르게(ω ∝ sqrt(1/λ) 스케일, 보기 좋게 조정)
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null, disp = null; // 격자 높이·수평 처짐(Float32Array, 프레임마다 재사용)
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0;
var wind = 1.0, windT = 1.0; // 風 세기(실효 진폭에 곱한다) 현재·목표
var windAng = 0.5, windAngT = 0.5; // 風 방향(라디안) 현재·목표
var lastPX = -1, lastPY = -1, lastPT = 0;
function hex2rgb(s) {
// sass 가 mix() 값을 rgb(r%, g%, b%) 로 내보낸다 — 헥스와 둘 다 읽는다
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var C0 = hex2rgb(CREST[0]), C1 = hex2rgb(CREST[1]);
var T0 = hex2rgb(TROUGH[0]), T1 = hex2rgb(TROUGH[1]);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(40, Math.round(COLS * H / W));
var n = COLS * ROWS;
hgt = new Float32Array(n);
disp = new Float32Array(n);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 파도장 높이 합성 — height(x,y,t) = Σ amp·sin(dot(k·dir,(x,y)) + t·ω + φ)
// Gerstner 처짐 dispX = Q·amp·cos(같은 위상)·dirX 도 같은 루프에서 쌓는다.
// 風 세기는 실효 진폭에 곱하고, 風 방향은 각 파의 방향에 살짝 실린다.
function fieldcalc(t, wstr, wang) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var h = 0, d = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
// 風 방향 반영 — 방향 벡터를 風 각도 쪽으로 살짝 기울인다
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
var a = WV.amp * wstr;
h += a * Math.sin(ph);
d += Q * a * Math.cos(ph) * dx;
}
hgt[i] = h;
disp[i] = d;
}
}
}
// 렌더 — 격자 해상도 ImageData(STEP=3 업스케일 버퍼). 높이→색 램프(마루 민트·골 파랑).
// 색이 픽셀 단위로 흘러야 누적면적이 크게 잡히므로, 램프를 부드럽게 두 단계 섞는다.
function render() {
var o = out.data;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
for (var x = 0; x < COLS; x++) {
var i = row + x;
var h = hgt[i]; // 대략 -0.75 ~ +0.75
var u = h * 1.24 + 0.5; // 0..1 로 정규화(클램프) — 마루·골이 파렛트 끝까지 닿게
if (u < 0) u = 0; else if (u > 1) u = 1;
// 램프 — 골(T) → 마루(C). u 가 절반 넘으면 C0→C1 로, 이하면 T0→T1 로
var r, g, b;
if (u > 0.5) {
var k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
var k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
// Gerstner 처짐을 밝기로도 살짝 — 꼭대기 처진 칸이 더 밝다
var sp = disp[i] * 46;
r += sp; g += sp; b += sp;
// STEP×STEP 블록으로 펴 올린다 — 쓸 주소를 직접 계산한다(순차 누적 인덱스는 py 행 어긋남의 원인)
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
// 자동 風 — 시드 고정 리사주로 방향·세기가 천천히 순환. 포인터 없는 headless 렌더에서도 화면이 산다
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
fieldcalc(nowSec, wind, windAng);
render();
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
// 정지판 — 루프 없이 시드가 정한 한 시각의 파도장을 계산해 물결이 살아 있는 정적 한 장
function still() {
fieldcalc(7.3, 1.35, 0.5);
render();
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
if (lastPX >= 0 && t > lastPT) {
// 이동 벡터 실측 → 風 방향·세기 목표 갱신(lerp 완화는 frame 에서)
var vx = p[0] - lastPX, vy = p[1] - lastPY;
var sp2 = Math.sqrt(vx * vx + vy * vy);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(vy, vx) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true; // 떠나면 다음 텅에 자동 순환으로 돌아간다
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
windT = Math.max(0.35, Math.min(2.1, windT + d[0] * 0.18));
windAngT = windAngT + d[1] * 0.1;
if (reduce) { fieldcalc(nowSec || 3.1, windT, windAngT); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
waveVal.textContent = '波 ' + NW;
if (reduce) {
// reduce — 루프를 시작하지 않고 파도가 살아 있는 정적 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// GerstnerSum — vanilla 판(run/313/demo/01-ocean-gerstner-sum/index.html) 의 React 포트.
// 파장·방향·진폭이 다른 정현파 여섯을 수면 격자에 합성하고 Gerstner 수평 처짐까지 넣어
// 저해상 격자를 STEP=3 업스케일 버퍼로 펴 올린다. 바람은 자동 리사주 순환 + 포인터 실측.
// 움직임은 캔버스 rAF 루프가 전부이므로 @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./GerstnerSum.module.scss";
export type GerstnerSumProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
const COLS = 110; // 수면 격자 가로 칸 수
const STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
const NW = 6; // 겹칠 정현파 개수
const Q = 0.42; // Gerstner 처짐 계수
// 결정적 난수 mulberry32(7)
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
// 여섯 정현파 구성 — 파장·방향·진폭·위상 전부 시드(7)에서 파생
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
// ω ∝ sqrt(1/λ) — 파장 짧을수록 빠르게
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
// sass 가 mix() 값을 rgb(r%, g%, b%) 로 내보낸다 — 헥스와 둘 다 읽는다
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function GerstnerSum({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#8fdac2", // vanilla $color: $gs-crest (근사값)
}: GerstnerSumProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
disp: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
lastPX: number; lastPY: number; lastPT: number;
c0: [number, number, number] | null;
c1: [number, number, number] | null;
t0: [number, number, number] | null;
t1: [number, number, number] | null;
};
const stateRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null as Float32Array | null,
disp: null as Float32Array | null,
out: null as ImageData | null,
bctx: null as CanvasRenderingContext2D | null,
buf: null as HTMLCanvasElement | null,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
lastPX: -1, lastPY: -1, lastPT: 0,
c0: null as [number, number, number] | null,
c1: null as [number, number, number] | null,
t0: null as [number, number, number] | null,
t1: null as [number, number, number] | null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stateRef.current;
st.rows = Math.max(40, Math.round(COLS * r.height / r.width));
const n = COLS * st.rows;
st.hgt = new Float32Array(n);
st.disp = new Float32Array(n);
st.rw = COLS * STEP;
st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
// 파도장 높이 합성 + Gerstner 처짐
function fieldcalc(t: number, wstr: number, wang: number): void {
const st = stateRef.current;
const WV = wavesRef.current;
if (!st.hgt || !st.disp || !WV) return;
let i = 0;
for (let y = 0; y < st.rows; y++) {
for (let x = 0; x < COLS; x++, i++) {
let h = 0, d = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 + t * wv.om + wv.ph;
const a = wv.amp * wstr;
h += a * Math.sin(ph);
d += Q * a * Math.cos(ph) * dx;
}
st.hgt[i] = h;
st.disp[i] = d;
}
}
}
function render(): void {
const st = stateRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt || !st.disp) return;
if (!st.c0 || !st.c1 || !st.t0 || !st.t1) return;
const o = st.out.data;
const C0 = st.c0, C1 = st.c1, T0 = st.t0, T1 = st.t1;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
for (let x = 0; x < COLS; x++) {
const i = row + x;
let u = st.hgt[i] * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
let r: number, g: number, b: number;
if (u > 0.5) {
const k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
const k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
const sp = st.disp[i] * 46;
r += sp; g += sp; b += sp;
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stateRef.current;
const sec = now / 1000;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
fieldcalc(sec, st.wind, st.windAng);
render();
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stateRef.current;
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stateRef.current.auto = true;
stateRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
fieldcalc(7.3, 1.35, 0.5);
render();
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stateRef.current;
st.c0 = hex2rgb(tok("--crest"));
st.c1 = hex2rgb(tok("--crest-2"));
st.t0 = hex2rgb(tok("--trough"));
st.t1 = hex2rgb(tok("--trough-2"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프 없이 시드가 정한 한 시각의 파도장을 담는다
stillRef.current = true;
fieldcalc(7.3, 1.35, 0.5);
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>정현파 여섯을 합성해 수면 격자를 계산합니다 — 그리면 바람이 파도를 밉니다</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum}>파 6</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}01 · 正弦波合成の水面 — ガーストナー波の場 — 計測 累積面積 60.795% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
高さはサイン、ずれはコサインでまとめて計算する
function fieldcalc(t, wstr, wang) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var h = 0, d = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
var a = WV.amp * wstr;
h += a * Math.sin(ph); // 高さ — 正弦波6つの合計
d += Q * a * Math.cos(ph) * dx; // ガーストナー水平ずれ
}
hgt[i] = h;
disp[i] = d;
}
}
}
測定してみるとコサイン項を抜くと水が上下にしか揺れずプールのような印象になりますが、入れると波頂が進行方向に寄って波らしくなります — ポインタで移動ベクトルを実測して風向き・強さの目標を変え、矢印キーでも同じ値を調整できます。
02深さによる減衰 — 浅瀬の水色断面
水面グリッドの高さを水深として読み、ピクセルごとに水色を指数減衰で混ぜる、プール・ダイビング・旅行予約の背景向けシーンです(実測: 累積面積54.177%、強度平均54.2で9本中最高)。

<main class="stage">
<div class="da" id="da">
<p class="da__hint">
<svg class="da__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>水面下のピクセルごとに水深で指数減衰させ、浅瀬の断面を計算します — マウスで水色を揺らせます</span>
</p>
<div class="da__field" id="field" role="application" aria-label="深さ減衰の断面お試しパネル" tabindex="0">
<canvas class="da__cv" id="cv"></canvas>
<b class="da__badge" aria-hidden="true"><span class="da__badgeNum" id="attenVal">水深 x0.06</span><span class="da__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 깊이별 감쇠 — 1D 수면 곡선 아래 픽셀을 수심 지수 감쇠로 물들인다. 움직임은 전부 캔버스 rAF 안에 있다.
// @keyframes 를 두지 않는다(077 방식). 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$da-c0: mix($subject-mint, $stage-paper, 72%); // 얕음 — 민트
$da-c1: mix($subject-sky, $stage-paper, 66%); // 얕음 보조 — 하늘 민트
$da-t1: mix($subject-blue, $stage-paper, 58%); // 깊음 보조 — 파랑
$da-t0: mix($subject-blue-deep, $stage-paper, 52%); // 깊음 — 진한 파랑
$da-sand: mix($subject-cream, $stage-paper, 88%); // 바닥 모래 — 크림
$da-sky: mix($subject-sky, $stage-paper, 22%); // 수면 위 배경 — 옅은 하늘
$duration: $dur-loop;
$easing: $ease-spring;
$color: $da-c0;
@include stage($stage-paper);
.da {
--c0: #{$da-c0};
--c1: #{$da-c1};
--t1: #{$da-t1};
--t0: #{$da-t0};
--sand: #{$da-sand};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.da__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.da__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.da__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $da-sky;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.da__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.da__cv { display: block; width: 100%; height: 100%; }
.da__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.da__badgeNum { font-size: 13px; color: $stage-yellow; }
.da__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.da { gap: $sp-1; }
.da__field { height: 124px; }
.da__badge { font-size: 10px; padding: 0 $sp-1; }
.da__badgeNum { font-size: 11px; }
.da__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 파도가 살아 있는 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.da.is-still .da__field { cursor: default !important; }
.da.is-still .da__badge { opacity: .92 !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);
}
.da {
--c0: rgb(49.4588235294%, 87.8588235294%, 74.8705882353%);
--c1: rgb(62.4705882353%, 85.7647058824%, 100%);
--t1: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
--t0: rgb(50.0392156863%, 64.1098039216%, 95.3098039216%);
--sand: rgb(100%, 97.2392156863%, 91.3725490196%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.da__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.da__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.da__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(87.4901960784%, 95.2549019608%, 100%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.da__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.da__cv {
display: block;
width: 100%;
height: 100%;
}
.da__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.da__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.da__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.da {
gap: 4px;
}
.da__field {
height: 124px;
}
.da__badge {
font-size: 10px;
padding: 0 4px;
}
.da__badgeNum {
font-size: 11px;
}
.da__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.da.is-still .da__field {
cursor: default !important;
}
.da.is-still .da__badge {
opacity: 0.92 !important;
}
}// 깊이별 감쇠 — 얕은 물빛 단면. 01 과 같은 뿌리(정현파 6개 mulberry32(7))를 1D 로 읽어
// 수면선 높이를 구하고, 그 선 아래 픽셀마다 수심 d = y - surfaceY 에 exp(-감쇠계수*d) 를
// 적용해 민트(얕음)→라일락 하늘빛→파랑→진한 파랑(깊음) 4단 램프로 섞는다. 바닥 20px 은 크림 모래.
// 감쇠계수 자체도 바람이 세질수록 커져(물이 흐려져) 얕은 색이 더 빨리 사라진다.
var root = document.getElementById('da');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var attenVal = document.getElementById('attenVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var C0 = v('--c0'), C1 = v('--c1'); // 얕음 — 민트 → 하늘
var T1 = v('--t1'), T0 = v('--t0'); // 깊음 — 파랑 → 진한 파랑
var SAND = v('--sand');
var COLS = 160; // 1D 수면 곡선 표본 수 — 가로 해상도
var STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀 → RW = 480
var NW = 6; // 겹칠 정현파 개수(공통 물리)
var SAND_PX = 20; // 바닥 모래 두께(버퍼 기준 px)
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var AMP_SUM = 0;
for (var w2 = 0; w2 < NW; w2++) AMP_SUM += WAVES[w2].amp;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var surf = null; // 1D 수면 높이(버퍼 px, Float32Array)
var buf = null, bctx = null, out = null;
var RW = 480, RH = 160;
var raf = 0, auto = true, nowSec = 0;
var wind = 1.0, windT = 1.0;
var windAng = 0.5, windAngT = 0.5;
var lastPX = -1, lastPY = -1, lastPT = 0;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var RC0 = hex2rgb(C0), RC1 = hex2rgb(C1);
var RT1 = hex2rgb(T1), RT0 = hex2rgb(T0);
var RSAND = hex2rgb(SAND);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
surf = new Float32Array(COLS);
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 1D 수면 곡선 — height(x,t) = Σ amp·sin(x·dirX·freq·0.62 + t·ω + φ). y 항은 0 고정(단면이라 깊이 축이 없다)
function surfcalc(t, wstr, wang) {
for (var x = 0; x < COLS; x++) {
var h = 0;
for (var w3 = 0; w3 < NW; w3++) {
var WV = WAVES[w3];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var ph = (x * dx) * WV.freq * 0.62 + t * WV.om + WV.ph;
h += WV.amp * wstr * Math.sin(ph);
}
surf[x] = h;
}
}
// 렌더 — 수면선 위는 투명(카드 배경이 하늘), 아래는 수심 d 에 지수 감쇠로 4단 램프.
// attenK 는 바람이 세질수록 커진다(흐려진 물이 색을 더 빨리 삼킨다).
function render(attenK) {
var o = out.data;
var baseline = RH * 0.4; // 수면 중심선(버퍼 px)
var ampPx = RH * 0.34; // 수면 진폭(버퍼 px) — surf 값(대략 -0.75..0.75) 스케일
for (var cx = 0; cx < COLS; cx++) {
var surfaceY = baseline + surf[cx] * ampPx;
if (surfaceY < 0) surfaceY = 0; else if (surfaceY > RH - 2) surfaceY = RH - 2;
for (var sx = 0; sx < STEP; sx++) {
var px = cx * STEP + sx;
for (var y = 0; y < RH; y++) {
var base = (y * RW + px) * 4;
if (y < surfaceY) {
o[base] = 0; o[base + 1] = 0; o[base + 2] = 0; o[base + 3] = 0; // 물 위 — 투명(카드 배경이 하늘)
continue;
}
if (y >= RH - SAND_PX) {
o[base] = RSAND[0]; o[base + 1] = RSAND[1]; o[base + 2] = RSAND[2]; o[base + 3] = 255;
continue;
}
var d = y - surfaceY;
var u = 1 - Math.exp(-attenK * d); // 0(얕음)..1(깊음), 클램프
if (u < 0) u = 0; else if (u > 1) u = 1;
// 4단 램프 — C0(민트,얕음) → C1(하늘) → T1(파랑) → T0(진한 파랑,깊음). 전 구간을 다 쓴다.
var r, g, b;
if (u < 1 / 3) {
var k = u * 3;
r = RC0[0] + (RC1[0] - RC0[0]) * k; g = RC0[1] + (RC1[1] - RC0[1]) * k; b = RC0[2] + (RC1[2] - RC0[2]) * k;
} else if (u < 2 / 3) {
var k2 = (u - 1 / 3) * 3;
r = RC1[0] + (RT1[0] - RC1[0]) * k2; g = RC1[1] + (RT1[1] - RC1[1]) * k2; b = RC1[2] + (RT1[2] - RC1[2]) * k2;
} else {
var k3 = (u - 2 / 3) * 3;
r = RT1[0] + (RT0[0] - RT1[0]) * k3; g = RT1[1] + (RT0[1] - RT1[1]) * k3; b = RT1[2] + (RT0[2] - RT1[2]) * k3;
}
o[base] = r; o[base + 1] = g; o[base + 2] = b; o[base + 3] = 255;
}
}
}
bctx.putImageData(out, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
surfcalc(nowSec, wind, windAng);
var attenK = 0.045 + (wind / (AMP_SUM * 3)) * 0.03; // 바람이 세질수록 감쇠가 커진다
render(attenK);
attenVal.textContent = '水深 x' + attenK.toFixed(3);
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
function still() {
surfcalc(7.3, 1.35, 0.5);
render(0.06);
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
if (lastPX >= 0 && t > lastPT) {
var vx = p[0] - lastPX, vy = p[1] - lastPY;
var sp2 = Math.sqrt(vx * vx + vy * vy);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(vy, vx) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
windT = Math.max(0.35, Math.min(2.1, windT + d[0] * 0.18));
windAngT = windAngT + d[1] * 0.1;
if (reduce) {
surfcalc(nowSec || 3.1, windT, windAngT);
render(0.045 + (windT / (AMP_SUM * 3)) * 0.03);
}
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// DepthAttenuate — vanilla 판(run/313/demo/02-ocean-depth-attenuate/index.html) 의 React 포트.
// 1D 수면 곡선(정현파 6개, 시드 7)을 시간 팬으로 읽고 그 아래 픽셀마다 수심 d 에
// exp(-감쇠계수·d) 를 적용해 민트(얕음)→진한 파랑(깊음) 4단 램프로 섞는다. 바닥은 크림 모래.
// 바람은 자동 리사주 순환 + 포인터 실측. 움직임은 캔버스 rAF 루프가 전부이므로 @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DepthAttenuate.module.scss";
export type DepthAttenuateProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
const COLS = 160; // 1D 수면 곡선 표본 수 — 가로 해상도
const STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀 → RW = 480
const NW = 6; // 겹칠 정현파 개수
const RW = 480;
const RH = 160;
const SAND_PX = 20; // 바닥 모래 두께(버퍼 기준 px)
// 결정적 난수 mulberry32(7)
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
// sass 가 mix() 값을 rgb(r%, g%, b%) 로 내보낸다 — 헥스와 둘 다 읽는다
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function DepthAttenuate({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#8fdac2", // vanilla $color: $da-c0 (근사값)
}: DepthAttenuateProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const attenValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
const surfRef = useRef(new Float32Array(COLS)) as Box<Float32Array>;
type St = {
buf: HTMLCanvasElement | null;
bctx: CanvasRenderingContext2D | null;
out: ImageData | null;
ampSum: number;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
lastPX: number; lastPY: number; lastPT: number;
c0: [number, number, number] | null;
c1: [number, number, number] | null;
t0: [number, number, number] | null;
t1: [number, number, number] | null;
sand: [number, number, number] | null;
};
const stRef = useRef({
buf: null,
bctx: null,
out: null,
ampSum: 0,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
lastPX: -1, lastPY: -1, lastPT: 0,
c0: null, c1: null, t0: null, t1: null, sand: null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stRef.current;
const buf = document.createElement("canvas");
buf.width = RW; buf.height = RH;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(RW, RH);
}
// 1D 수면 곡선 — height(x,t) = Σ amp·sin(x·dirX·freq·0.62 + t·ω + φ)
function surfcalc(t: number, wstr: number, wang: number): void {
const WV = wavesRef.current;
const surf = surfRef.current;
if (!WV) return;
for (let x = 0; x < COLS; x++) {
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const ph = (x * dx) * wv.freq * 0.62 + t * wv.om + wv.ph;
h += wv.amp * wstr * Math.sin(ph);
}
surf[x] = h;
}
}
// 렌더 — 수면선 위는 투명, 아래는 수심 지수 감쇠 4단 램프, 바닥 20px 모래
function render(attenK: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
const surf = surfRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.c0 || !st.c1 || !st.t0 || !st.t1 || !st.sand) return;
const o = st.out.data;
const RC0 = st.c0, RC1 = st.c1, RT0 = st.t0, RT1 = st.t1, RSAND = st.sand;
const baseline = RH * 0.4;
const ampPx = RH * 0.34;
for (let cx = 0; cx < COLS; cx++) {
let surfaceY = baseline + surf[cx] * ampPx;
if (surfaceY < 0) surfaceY = 0; else if (surfaceY > RH - 2) surfaceY = RH - 2;
for (let sx = 0; sx < STEP; sx++) {
const px = cx * STEP + sx;
for (let y = 0; y < RH; y++) {
const base = (y * RW + px) * 4;
if (y < surfaceY) {
o[base] = 0; o[base + 1] = 0; o[base + 2] = 0; o[base + 3] = 0;
continue;
}
if (y >= RH - SAND_PX) {
o[base] = RSAND[0]; o[base + 1] = RSAND[1]; o[base + 2] = RSAND[2]; o[base + 3] = 255;
continue;
}
const d = y - surfaceY;
let u = 1 - Math.exp(-attenK * d);
if (u < 0) u = 0; else if (u > 1) u = 1;
let r: number, g: number, b: number;
if (u < 1 / 3) {
const k = u * 3;
r = RC0[0] + (RC1[0] - RC0[0]) * k; g = RC0[1] + (RC1[1] - RC0[1]) * k; b = RC0[2] + (RC1[2] - RC0[2]) * k;
} else if (u < 2 / 3) {
const k2 = (u - 1 / 3) * 3;
r = RC1[0] + (RT1[0] - RC1[0]) * k2; g = RC1[1] + (RT1[1] - RC1[1]) * k2; b = RC1[2] + (RT1[2] - RC1[2]) * k2;
} else {
const k3 = (u - 2 / 3) * 3;
r = RT1[0] + (RT0[0] - RT1[0]) * k3; g = RT1[1] + (RT0[1] - RT1[1]) * k3; b = RT1[2] + (RT0[2] - RT1[2]) * k3;
}
o[base] = r; o[base + 1] = g; o[base + 2] = b; o[base + 3] = 255;
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.clearRect(0, 0, sizeRef.current.w, sizeRef.current.h);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stRef.current;
const sec = now / 1000;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
surfcalc(sec, st.wind, st.windAng);
const attenK = 0.045 + (st.wind / (st.ampSum * 3)) * 0.03;
render(attenK);
if (attenValRef.current) attenValRef.current.textContent = "수심 x" + attenK.toFixed(3);
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stRef.current;
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stRef.current.auto = true;
stRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
surfcalc(7.3, 1.35, 0.5);
render(0.06);
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
let sum = 0;
for (let i = 0; i < NW; i++) sum += wavesRef.current[i].amp;
stRef.current.ampSum = sum;
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stRef.current;
st.c0 = hex2rgb(tok("--c0"));
st.c1 = hex2rgb(tok("--c1"));
st.t0 = hex2rgb(tok("--t0"));
st.t1 = hex2rgb(tok("--t1"));
st.sand = hex2rgb(tok("--sand"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
surfcalc(7.3, 1.35, 0.5);
render(0.06);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>수면 아래 픽셀마다 수심을 지수로 감쇠시켜 얕은 바다 단면을 계산합니다 — 마우스로 물빛을 흔드세요</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={attenValRef}>수심 x0.060</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}02 · 深さによる減衰 — 浅瀬の水色断面 — 計測 累積面積 54.177% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
線形ではなく指数で水色を吸い込ませる理由
function render(attenK) {
var o = out.data;
var baseline = RH * 0.4; // 水面の中心線(バッファpx)
var ampPx = RH * 0.34; // 水面振幅(バッファpx)
for (var cx = 0; cx < COLS; cx++) {
var surfaceY = baseline + surf[cx] * ampPx;
if (surfaceY < 0) surfaceY = 0; else if (surfaceY > RH - 2) surfaceY = RH - 2;
for (var sx = 0; sx < STEP; sx++) {
var px = cx * STEP + sx;
for (var y = 0; y < RH; y++) {
var base = (y * RW + px) * 4;
if (y < surfaceY) {
o[base] = 0; o[base + 1] = 0; o[base + 2] = 0; o[base + 3] = 0;
continue;
}
var d = y - surfaceY;
var u = 1 - Math.exp(-attenK * d); // 0(浅い)..1(深い)
if (u < 0) u = 0; else if (u > 1) u = 1;
}
}
}
}
指数減衰は浅い数ピクセルで色が急に変わり、深くなるほど変化がゆるやかになる、実際に水が光を吸い込む曲線と同じ形です — 水面より上は透明にしておき背景カードが空の役をし、風が強まるほどattenKが大きくなって色をより速く吸い込みます。
03波頂の泡 — 頂上の白い筋
合成高さが振幅の閾値を超えるマスだけクリーム色に塗って砕ける白い波頂を示す、サーフィン・マリンスポーツ向けシーンです(実測: 累積面積60.08%、フレーム内最大面積22.649%で9本中2番目に大きい)。

<main class="stage">
<div class="cf" id="cf">
<p class="cf__hint">
<svg class="cf__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>合成高さが振幅の閾値を超えるセルだけクリーム色に砕けます — 風が強まるほど白い波頂が増えます</span>
</p>
<div class="cf__field" id="field" role="application" aria-label="波頂の泡お試しパネル" tabindex="0">
<canvas class="cf__cv" id="cv"></canvas>
<b class="cf__badge" aria-hidden="true"><span class="cf__badgeNum" id="thrVal">閾値 72%</span><span class="cf__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 크레스트 거품 — 억제된 바탕 램프 위에 문턱을 넘은 칸만 크림 whitecap. 움직임은 전부 캔버스 rAF 안에 있다.
// @keyframes 를 두지 않는다(077 방식). 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$cf-base0: mix($subject-blue, $stage-paper, 40%); // 바탕 마루쪽 — 파랑(억제)
$cf-base1: mix($subject-blue-deep, $stage-paper, 46%); // 바탕 중간 — 파랑
$cf-deep: mix($subject-blue-deep, $stage-paper, 62%); // 가장 깊은 골 — 진한 파랑
$cf-foam: mix($subject-cream, $stage-paper, 92%); // whitecap — 크림
$cf-foam2: $subject-cream; // whitecap 꼭대기 — 밝은 크림
$duration: $dur-loop;
$easing: $ease-spring;
$color: $cf-foam;
@include stage($stage-paper);
.cf {
--base0: #{$cf-base0};
--base1: #{$cf-base1};
--deep: #{$cf-deep};
--foam: #{$cf-foam};
--foam2: #{$cf-foam2};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cf__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.cf__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.cf__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $cf-deep;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.cf__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.cf__cv { display: block; width: 100%; height: 100%; }
.cf__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.cf__badgeNum { font-size: 13px; color: $stage-yellow; }
.cf__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.cf { gap: $sp-1; }
.cf__field { height: 124px; }
.cf__badge { font-size: 10px; padding: 0 $sp-1; }
.cf__badgeNum { font-size: 11px; }
.cf__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 거품이 박힌 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.cf.is-still .cf__field { cursor: default !important; }
.cf.is-still .cf__badge { opacity: .92 !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);
}
.cf {
--base0: rgb(67.3725490196%, 77.0980392157%, 98.5882352941%);
--base1: rgb(55.8039215686%, 68.2509803922%, 95.8509803922%);
--deep: rgb(40.431372549%, 57.2078431373%, 94.4078431373%);
--foam: rgb(100%, 97.1137254902%, 90.9803921569%);
--foam2: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cf__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.cf__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.cf__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(40.431372549%, 57.2078431373%, 94.4078431373%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.cf__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.cf__cv {
display: block;
width: 100%;
height: 100%;
}
.cf__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.cf__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.cf__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.cf {
gap: 4px;
}
.cf__field {
height: 124px;
}
.cf__badge {
font-size: 10px;
padding: 0 4px;
}
.cf__badgeNum {
font-size: 11px;
}
.cf__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf.is-still .cf__field {
cursor: default !important;
}
.cf.is-still .cf__badge {
opacity: 0.92 !important;
}
}// 크레스트 泡 — 01 과 같은 2D 파도장(정현파 6개 mulberry32(7) + Gerstner 처짐)이지만
// 바탕 램프를 낮게 억제해(거의 단색 파랑 — 거품이 주인공) 물밑에 묻어두고,
// 합성높이 > 실효진폭×閾値 인 칸만 smoothstep 가장자리로 크림색 whitecap 을 얹는다.
// 문턱은 風 세기에 반비례해 0.82 → 0.55 로 낮아진다 — 바람이 세질수록 거품이 늘어난다.
var root = document.getElementById('cf');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var thrVal = document.getElementById('thrVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var BASE0 = v('--base0'), BASE1 = v('--base1'); // 억제된 바탕 램프 — 파랑 (泡 아닌 물)
var DEEP = v('--deep'); // 가장 낮은 골 — 진한 파랑
var FOAM = v('--foam'); // whitecap — 크림
var FOAM2 = v('--foam2'); // whitecap 꼭대기 — 밝은 크림
var COLS = 110; // 수면 격자 가로 칸 수(01 과 동일)
var STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수
var Q = 0.42; // Gerstner 처짐 계수
var FOAM_EDGE = 0.16; // smoothstep 가장자리 폭(閾値 비율 단위)
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null;
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0;
var wind = 1.0, windT = 1.0;
var windAng = 0.5, windAngT = 0.5;
var lastPX = -1, lastPY = -1, lastPT = 0;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var RB0 = hex2rgb(BASE0), RB1 = hex2rgb(BASE1);
var RDEEP = hex2rgb(DEEP);
var RF = hex2rgb(FOAM), RF2 = hex2rgb(FOAM2);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(40, Math.round(COLS * H / W));
hgt = new Float32Array(COLS * ROWS);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 파도장 높이 합성 — height(x,y,t) = Σ amp·sin(dot(k·dir,(x,y)) + t·ω + φ) (01 과 같은 뿌리)
function fieldcalc(t, wstr, wang) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var h = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
h += WV.amp * wstr * Math.sin(ph);
}
hgt[i] = h;
}
}
}
function smoothstep(a, b, x) {
var t = (x - a) / (b - a);
if (t < 0) t = 0; else if (t > 1) t = 1;
return t * t * (3 - 2 * t);
}
// 렌더 — 억제된 바탕(골 DEEP → BASE0 → BASE1, 마루 직전까지만) 위에
// 문턱을 넘은 칸을 크림 whitecap 으로. 가장자리는 smoothstep 로 부드럽게, 꼭대기는 FOAM2 로 더 밝게.
function render(thr) {
var o = out.data;
// 실효 진폭 — 현재 바람까지 반영한 이 파도장의 실제 스케일. 문턱은 이 배율로 작동한다.
var eff = 0;
for (var i = 0; i < NW; i++) eff += WAVES[i].amp;
eff *= wind;
var cut = eff * thr;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
for (var x = 0; x < COLS; x++) {
var idx = row + x;
var h = hgt[idx];
// 억제된 바탕 램프 — h 를 0.55 배로 눌러 물색이 거의 단색에 가깝게(거품이 주인공)
var u = h * 0.55 * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
var r, g, b;
if (u > 0.5) {
var k = (u - 0.5) * 2;
r = RB1[0] * (1 - k) + RB0[0] * k;
g = RB1[1] * (1 - k) + RB0[1] * k;
b = RB1[2] * (1 - k) + RB0[2] * k;
} else {
var k2 = u * 2;
r = RDEEP[0] * (1 - k2) + RB0[0] * k2;
g = RDEEP[1] * (1 - k2) + RB0[1] * k2;
b = RDEEP[2] * (1 - k2) + RB0[2] * k2;
}
// whitecap — h 가 cut 을 넘으면 크림. 가장자리 smoothstep(폭 FOAM_EDGE·eff), 꼭대기는 FOAM2 로 두 단
var f = smoothstep(cut - FOAM_EDGE * eff, cut + FOAM_EDGE * eff, h);
if (f > 0) {
var top = smoothstep(cut + FOAM_EDGE * eff * 2.2, cut + eff * 0.42, h);
var fr = RF[0] * (1 - top) + RF2[0] * top;
var fg = RF[1] * (1 - top) + RF2[1] * top;
var fb = RF[2] * (1 - top) + RF2[2] * top;
r = r * (1 - f) + fr * f;
g = g * (1 - f) + fg * f;
b = b * (1 - f) + fb * f;
}
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
// 문턱은 바람에 반비례 — 0.82(약한 風) → 0.55(강한 風)
function thresholdFor(w) {
return 0.82 - 0.27 * Math.max(0, Math.min(1, (w - 0.35) / (2.1 - 0.35)));
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
fieldcalc(nowSec, wind, windAng);
var thr = thresholdFor(wind);
render(thr);
thrVal.textContent = '閾値 ' + Math.round(thr * 100) + '%';
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
function still() {
fieldcalc(7.3, 1.35, 0.5);
render(thresholdFor(1.35));
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
if (lastPX >= 0 && t > lastPT) {
var vx = p[0] - lastPX, vy = p[1] - lastPY;
var sp2 = Math.sqrt(vx * vx + vy * vy);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(vy, vx) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
windT = Math.max(0.35, Math.min(2.1, windT + d[0] * 0.18));
windAngT = windAngT + d[1] * 0.1;
if (reduce) {
fieldcalc(nowSec || 3.1, windT, windAngT);
render(thresholdFor(windT));
}
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// CrestFoam — vanilla 판(run/313/demo/03-ocean-crest-foam/index.html) 의 React 포트.
// 01 과 같은 2D 파도장(정현파 6개, 시드 7)을 억제된 파랑 바탕 위에 그리고,
// 합성높이 > 실효진폭×문턱 인 칸만 smoothstep 가장자리로 크림 whitecap 을 얹는다.
// 문턱은 바람 세기에 반비례(0.82→0.55). 움직임은 캔버스 rAF 루프가 전부이므로 @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CrestFoam.module.scss";
export type CrestFoamProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
type RGB = [number, number, number];
const COLS = 110; // 수면 격자 가로 칸 수
const STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
const NW = 6; // 겹칠 정현파 개수
const FOAM_EDGE = 0.16; // smoothstep 가장자리 폭(문턱 비율 단위)
// 결정적 난수 mulberry32(7)
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): RGB {
// sass 가 mix() 값을 rgb(r%, g%, b%) 로 내보낸다 — 헥스와 둘 다 읽는다
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function smoothstep(a: number, b: number, x: number): number {
let t = (x - a) / (b - a);
if (t < 0) t = 0; else if (t > 1) t = 1;
return t * t * (3 - 2 * t);
}
// 문턱은 바람에 반비례 — 0.82(약한 바람) → 0.55(강한 바람)
function thresholdFor(w: number): number {
return 0.82 - 0.27 * Math.max(0, Math.min(1, (w - 0.35) / (2.1 - 0.35)));
}
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
ampSum: number;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
lastPX: number; lastPY: number; lastPT: number;
rb0: RGB | null; rb1: RGB | null; rdeep: RGB | null;
rf: RGB | null; rf2: RGB | null;
};
export default function CrestFoam({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#fff7e6", // vanilla $color: $cf-foam (근사값)
}: CrestFoamProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const thrValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
const stRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null,
out: null,
bctx: null,
buf: null,
ampSum: 0,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
lastPX: -1, lastPY: -1, lastPT: 0,
rb0: null, rb1: null, rdeep: null, rf: null, rf2: null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stRef.current;
st.rows = Math.max(40, Math.round(COLS * r.height / r.width));
st.hgt = new Float32Array(COLS * st.rows);
st.rw = COLS * STEP;
st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
// 파도장 높이 합성 — height(x,y,t) = Σ amp·sin(dot(k·dir,(x,y)) + t·ω + φ)
function fieldcalc(t: number, wstr: number, wang: number): void {
const st = stRef.current;
const WV = wavesRef.current;
if (!st.hgt || !WV) return;
let i = 0;
for (let y = 0; y < st.rows; y++) {
for (let x = 0; x < COLS; x++, i++) {
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 + t * wv.om + wv.ph;
h += wv.amp * wstr * Math.sin(ph);
}
st.hgt[i] = h;
}
}
}
// 렌더 — 억제된 바탕 램프 위에 문턱을 넘은 칸만 크림 whitecap(smoothstep 가장자리)
function render(thr: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt) return;
if (!st.rb0 || !st.rb1 || !st.rdeep || !st.rf || !st.rf2) return;
const o = st.out.data;
const RB0 = st.rb0, RB1 = st.rb1, RDEEP = st.rdeep, RF = st.rf, RF2 = st.rf2;
const eff = st.ampSum * st.wind; // 실효 진폭 — 현재 바람까지 반영한 파도장 스케일
const cut = eff * thr;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
for (let x = 0; x < COLS; x++) {
const idx = row + x;
const h = st.hgt[idx];
let u = h * 0.55 * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
let r: number, g: number, b: number;
if (u > 0.5) {
const k = (u - 0.5) * 2;
r = RB1[0] * (1 - k) + RB0[0] * k;
g = RB1[1] * (1 - k) + RB0[1] * k;
b = RB1[2] * (1 - k) + RB0[2] * k;
} else {
const k2 = u * 2;
r = RDEEP[0] * (1 - k2) + RB0[0] * k2;
g = RDEEP[1] * (1 - k2) + RB0[1] * k2;
b = RDEEP[2] * (1 - k2) + RB0[2] * k2;
}
const f = smoothstep(cut - FOAM_EDGE * eff, cut + FOAM_EDGE * eff, h);
if (f > 0) {
const top = smoothstep(cut + FOAM_EDGE * eff * 2.2, cut + eff * 0.42, h);
const fr = RF[0] * (1 - top) + RF2[0] * top;
const fg = RF[1] * (1 - top) + RF2[1] * top;
const fb = RF[2] * (1 - top) + RF2[2] * top;
r = r * (1 - f) + fr * f;
g = g * (1 - f) + fg * f;
b = b * (1 - f) + fb * f;
}
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stRef.current;
const sec = now / 1000;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
fieldcalc(sec, st.wind, st.windAng);
const thr = thresholdFor(st.wind);
render(thr);
if (thrValRef.current) thrValRef.current.textContent = "문턱 " + Math.round(thr * 100) + "%";
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stRef.current;
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stRef.current.auto = true;
stRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
fieldcalc(7.3, 1.35, 0.5);
render(thresholdFor(1.35));
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
let sum = 0;
for (let i = 0; i < NW; i++) sum += wavesRef.current[i].amp;
stRef.current.ampSum = sum;
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stRef.current;
st.rb0 = hex2rgb(tok("--base0"));
st.rb1 = hex2rgb(tok("--base1"));
st.rdeep = hex2rgb(tok("--deep"));
st.rf = hex2rgb(tok("--foam"));
st.rf2 = hex2rgb(tok("--foam2"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
fieldcalc(7.3, 1.35, 0.5);
render(thresholdFor(1.35));
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>합성 높이가 진폭의 문턱을 넘는 칸만 크림색으로 부서뜨립니다 — 바람이 세질수록 흰 꼭대기가 늘어요</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={thrValRef}>문턱 72%</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}03 · 波頂の泡 — 頂上の白い筋 — 計測 累積面積 60.08% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
閾値が風に反比例するように組んだ関数
// whitecap — h が cut を超えたらクリーム色。縁はsmoothstep、頂点はFOAM2で二段階
var f = smoothstep(cut - FOAM_EDGE * eff, cut + FOAM_EDGE * eff, h);
if (f > 0) {
var top = smoothstep(cut + FOAM_EDGE * eff * 2.2, cut + eff * 0.42, h);
var fr = RF[0] * (1 - top) + RF2[0] * top;
var fg = RF[1] * (1 - top) + RF2[1] * top;
var fb = RF[2] * (1 - top) + RF2[2] * top;
r = r * (1 - f) + fr * f;
g = g * (1 - f) + fg * f;
b = b * (1 - f) + fb * f;
}
// 閾値は風に反比例 — 0.82(弱風) → 0.55(強風)
function thresholdFor(w) {
return 0.82 - 0.27 * Math.max(0, Math.min(1, (w - 0.35) / (2.1 - 0.35)));
}
縁をsmoothstepで滑らかにしないと閾値の境目が階段状に割れ、頂点専用色(FOAM2)がないと白が一本調子に広がって砕けた感じが出ません。
04鏡面ハイライト — 太陽のグリッター
水面グリッドの勾配と太陽方向の内積を2乗して瞬間の光斑だけ打つ、日の出・夜のビーチキャンペーン向けシーンです(実測: 累積面積41.906%、強度平均43.0)。

<main class="stage">
<div class="sg" id="sg">
<p class="sg__hint">
<svg class="sg__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>水面の勾配と太陽方向の内積をべき乗して、きらめきだけを描きます — ドラッグで太陽を動かせます</span>
</p>
<div class="sg__field" id="field" role="application" aria-label="鏡面ハイライトお試しパネル" tabindex="0">
<canvas class="sg__cv" id="cv"></canvas>
<b class="sg__badge" aria-hidden="true"><span class="sg__badgeNum" id="shinyVal">光沢 48</span><span class="sg__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 반사 하이라이트 — 수면 법선과 태양 방향의 내적을 shininess 로 제곱해 문턱 넘는 칸만 glint 로 찍는다.
// 움직임은 전부 캔버스 rAF 안에 있다. @keyframes 를 두지 않는다(077 방식).
// 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$sg-water0: mix($subject-blue-deep, $stage-paper, 58%); // 바탕 물색 — 진한 파랑
$sg-water1: mix($subject-blue, $stage-paper, 64%); // 바탕 물색 — 파랑(밝은 쪽)
$sg-glint: mix($subject-cream, $stage-paper, 90%); // 반짝이 — 크림
$sg-glint2: $subject-cream; // 반짝이 정점 — 밝은 크림
$duration: $dur-loop;
$easing: $ease-spring;
$color: $sg-glint2;
@include stage($stage-paper);
.sg {
--water0: #{$sg-water0};
--water1: #{$sg-water1};
--glint: #{$sg-glint};
--glint2: #{$sg-glint2};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sg__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.sg__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.sg__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $sg-water0;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.sg__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.sg__cv { display: block; width: 100%; height: 100%; }
.sg__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.sg__badgeNum { font-size: 13px; color: $stage-yellow; }
.sg__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.sg { gap: $sp-1; }
.sg__field { height: 124px; }
.sg__badge { font-size: 10px; padding: 0 $sp-1; }
.sg__badgeNum { font-size: 11px; }
.sg__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 태양 반짝이가 박힌 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.sg.is-still .sg__field { cursor: default !important; }
.sg.is-still .sg__badge { opacity: .92 !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);
}
.sg {
--water0: rgb(44.2745098039%, 59.968627451%, 94.768627451%);
--water1: rgb(47.7960784314%, 63.3568627451%, 97.7411764706%);
--glint: rgb(100%, 97.1764705882%, 91.1764705882%);
--glint2: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sg__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.sg__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.sg__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(44.2745098039%, 59.968627451%, 94.768627451%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.sg__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sg__cv {
display: block;
width: 100%;
height: 100%;
}
.sg__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.sg__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.sg__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.sg {
gap: 4px;
}
.sg__field {
height: 124px;
}
.sg__badge {
font-size: 10px;
padding: 0 4px;
}
.sg__badgeNum {
font-size: 11px;
}
.sg__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-still .sg__field {
cursor: default !important;
}
.sg.is-still .sg__badge {
opacity: 0.92 !important;
}
}// 반사 하이라이트 — 01 과 같은 뿌리(정현파 6개 mulberry32(7) + Gerstner 처짐)의 2D 파도장에서
// 중앙차분으로 법선(nx, ny, 1)을 근사하고, 태양(광원) 방향 L 과의 내적 dot(N,L) 을 shininess 로
// 제곱해(pow) 스페큘러 세기를 얻는다. 세기가 문턱을 넘는 칸만 반짝이(glint)로 찍어
// "찰나의 빛무리"를 만든다 — 램프 전체를 밝히는 것과 다르다. 태양 방향은 포인터 드래그로 조절.
var root = document.getElementById('sg');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var shinyVal = document.getElementById('shinyVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var WATER0 = v('--water0'), WATER1 = v('--water1'); // 바탕 물색 — 억제된 파랑 램프
var GLINT = v('--glint'), GLINT2 = v('--glint2'); // 반짝이 — 크림 → 밝은 크림
var COLS = 110; // 수면 격자 가로 칸 수(01 과 동일)
var STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수
var SHINY = 48; // 스페큘러 지수(shininess) — 클수록 반짝이가 더 좁고 날카롭다
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null; // 격자 높이(Float32Array)
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0;
var wind = 1.0, windT = 1.0;
var windAng = 0.5, windAngT = 0.5;
var lastPX = -1, lastPY = -1, lastPT = 0;
// 태양(광원) 방향 — 단위벡터(lx, ly, lz). 자동 모드에서는 천천히 원을 그리고, 드래그 중엔 포인터를 따라간다.
var sunAng = 0.9, sunAngT = 0.9;
var sunZ = 0.62, sunZT = 0.62; // z 성분(하늘 쪽 기울기) — 클수록 광원이 수직에 가깝다(반짝이가 넓게 퍼짐)
var dragging = false;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var RW0 = hex2rgb(WATER0), RW1 = hex2rgb(WATER1);
var RG = hex2rgb(GLINT), RG2 = hex2rgb(GLINT2);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(40, Math.round(COLS * H / W));
hgt = new Float32Array(COLS * ROWS);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 파도장 높이 합성 — 01 과 같은 뿌리
function fieldcalc(t, wstr, wang) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var h = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
h += WV.amp * wstr * Math.sin(ph);
}
hgt[i] = h;
}
}
}
// 렌더 — 바탕은 억제된 물색 램프, 그 위에 중앙차분 법선과 태양 벡터의 내적을 shininess 로 제곱해
// 閾値 넘는 칸만 glint 로 찍는다. 경계 칸(x/y 끝)은 차분을 안쪽으로만 잡아 배열 밖을 읽지 않는다.
function render() {
var o = out.data;
var lx = Math.cos(sunAng) * (1 - sunZ), ly = Math.sin(sunAng) * (1 - sunZ), lz = sunZ + 0.3;
var llen = Math.sqrt(lx * lx + ly * ly + lz * lz);
lx /= llen; ly /= llen; lz /= llen;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
var y0 = y > 0 ? y - 1 : y, y1 = y < ROWS - 1 ? y + 1 : y;
for (var x = 0; x < COLS; x++) {
var idx = row + x;
var x0 = x > 0 ? x - 1 : x, x1 = x < COLS - 1 ? x + 1 : x;
// 중앙차분 기울기 — 법선(nx, ny, nz) 근사. 분모(x1-x0, y1-y0)가 0 이 되는 경계는 위에서 안쪽 클램프로 방지.
var gx = (hgt[row + x1] - hgt[row + x0]) / Math.max(1, x1 - x0);
var gy = (hgt[y1 * COLS + x] - hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
var nx = -gx * 5.2, ny = -gy * 5.2, nz = 1;
var nlen = Math.sqrt(nx * nx + ny * ny + nz * nz);
nx /= nlen; ny /= nlen; nz /= nlen;
var d = nx * lx + ny * ly + nz * lz;
if (d < 0) d = 0;
var spec = Math.pow(d, SHINY); // dot(N,L)^shininess — 좁고 날카로운 반짝이
// 바탕 물색 — 높이를 낮은 대비로만 섞어 캔버스가 완전 단색이 되지 않게
var u = hgt[idx] * 0.6 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
var r = RW0[0] + (RW1[0] - RW0[0]) * u;
var g = RW0[1] + (RW1[1] - RW0[1]) * u;
var b = RW0[2] + (RW1[2] - RW0[2]) * u;
if (spec > 0.02) {
var f = spec > 1 ? 1 : spec;
var top = f > 0.6 ? (f - 0.6) / 0.4 : 0;
var gr = RG[0] + (RG2[0] - RG[0]) * top;
var gg = RG[1] + (RG2[1] - RG[1]) * top;
var gb = RG[2] + (RG2[2] - RG[2]) * top;
r = r * (1 - f) + gr * f;
g = g * (1 - f) + gg * f;
b = b * (1 - f) + gb * f;
}
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
if (!dragging) {
sunAngT = nowSec * 0.18; // 자동 모드 — 태양이 천천히 원을 그린다
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
sunAng += (sunAngT - sunAng) * 0.06;
sunZ += (sunZT - sunZ) * 0.06;
fieldcalc(nowSec, wind, windAng);
render();
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
// 정지판 — 루프 없이 시드가 정한 한 시각의 파도장 + 태양 위치를 계산해 반짝이가 박힌 정적 한 장
function still() {
fieldcalc(7.3, 1.35, 0.5);
sunAng = 0.9; sunZ = 0.62;
render();
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointerdown', function (e) {
dragging = true;
field.setPointerCapture(e.pointerId);
var p = localXY(e);
sunAngT = Math.atan2(p[1] - H / 2, p[0] - W / 2);
sunZT = Math.max(0.1, Math.min(0.95, 1 - Math.sqrt((p[0] - W / 2) * (p[0] - W / 2) + (p[1] - H / 2) * (p[1] - H / 2)) / (W * 0.6)));
});
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
if (dragging) {
sunAngT = Math.atan2(p[1] - H / 2, p[0] - W / 2);
sunZT = Math.max(0.1, Math.min(0.95, 1 - Math.sqrt((p[0] - W / 2) * (p[0] - W / 2) + (p[1] - H / 2) * (p[1] - H / 2)) / (W * 0.6)));
}
if (lastPX >= 0 && t > lastPT) {
var vx = p[0] - lastPX, vy = p[1] - lastPY;
var sp2 = Math.sqrt(vx * vx + vy * vy);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(vy, vx) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerup', function () { dragging = false; });
field.addEventListener('pointerleave', function () {
auto = true;
dragging = false;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
dragging = true;
sunAngT = sunAngT + d[0] * 0.2;
sunZT = Math.max(0.1, Math.min(0.95, sunZT + d[1] * 0.08));
if (reduce) { fieldcalc(nowSec || 3.1, windT, windAngT); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
shinyVal.textContent = '光沢 ' + SHINY;
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// SpecularGlitter — vanilla 판(run/313/demo/04-ocean-specular-glitter/index.html) 의 React 포트.
// 01 과 같은 뿌리(정현파 6개, 시드 7)의 2D 파도장에서 중앙차분으로 법선을 근사하고
// 태양(광원) 방향과의 내적을 shininess 로 제곱해 문턱 넘는 칸만 glint 로 찍는다.
// 태양 방향은 자동 순환 + 포인터 드래그. 움직임은 캔버스 rAF 루프가 전부이므로 @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SpecularGlitter.module.scss";
export type SpecularGlitterProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerLikeEvent = { clientX: number; clientY: number; pointerId: number };
const COLS = 110;
const STEP = 3;
const NW = 6;
const SHINY = 48;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function SpecularGlitter({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: SpecularGlitterProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const shinyValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
auto: boolean; dragging: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
sunAng: number; sunAngT: number;
sunZ: number; sunZT: number;
lastPX: number; lastPY: number; lastPT: number;
water0: [number, number, number] | null;
water1: [number, number, number] | null;
glint: [number, number, number] | null;
glint2: [number, number, number] | null;
};
const stRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null, out: null, bctx: null, buf: null,
auto: true, dragging: false,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
sunAng: 0.9, sunAngT: 0.9,
sunZ: 0.62, sunZT: 0.62,
lastPX: -1, lastPY: -1, lastPT: 0,
water0: null, water1: null, glint: null, glint2: null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stRef.current;
st.rows = Math.max(40, Math.round(COLS * r.height / r.width));
st.hgt = new Float32Array(COLS * st.rows);
st.rw = COLS * STEP; st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
function fieldcalc(t: number, wstr: number, wang: number): void {
const st = stRef.current;
const WV = wavesRef.current;
if (!st.hgt || !WV) return;
let i = 0;
for (let y = 0; y < st.rows; y++) {
for (let x = 0; x < COLS; x++, i++) {
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 + t * wv.om + wv.ph;
h += wv.amp * wstr * Math.sin(ph);
}
st.hgt[i] = h;
}
}
}
// 중앙차분 법선 · 태양 내적 제곱 · 문턱 넘는 칸만 glint
function render(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt) return;
if (!st.water0 || !st.water1 || !st.glint || !st.glint2) return;
const o = st.out.data;
const RW0 = st.water0, RW1 = st.water1, RG = st.glint, RG2 = st.glint2;
let lx = Math.cos(st.sunAng) * (1 - st.sunZ), ly = Math.sin(st.sunAng) * (1 - st.sunZ), lz = st.sunZ + 0.3;
const llen = Math.sqrt(lx * lx + ly * ly + lz * lz);
lx /= llen; ly /= llen; lz /= llen;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
const y0 = y > 0 ? y - 1 : y, y1 = y < st.rows - 1 ? y + 1 : y;
for (let x = 0; x < COLS; x++) {
const idx = row + x;
const x0 = x > 0 ? x - 1 : x, x1 = x < COLS - 1 ? x + 1 : x;
const gx = (st.hgt[row + x1] - st.hgt[row + x0]) / Math.max(1, x1 - x0);
const gy = (st.hgt[y1 * COLS + x] - st.hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
let nx = -gx * 5.2, ny = -gy * 5.2, nz = 1;
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz);
nx /= nlen; ny /= nlen; nz /= nlen;
let d = nx * lx + ny * ly + nz * lz;
if (d < 0) d = 0;
const spec = Math.pow(d, SHINY);
let u = st.hgt[idx] * 0.6 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
let r = RW0[0] + (RW1[0] - RW0[0]) * u;
let g = RW0[1] + (RW1[1] - RW0[1]) * u;
let b = RW0[2] + (RW1[2] - RW0[2]) * u;
if (spec > 0.02) {
const f = spec > 1 ? 1 : spec;
const top = f > 0.6 ? (f - 0.6) / 0.4 : 0;
const gr = RG[0] + (RG2[0] - RG[0]) * top;
const gg = RG[1] + (RG2[1] - RG[1]) * top;
const gb = RG[2] + (RG2[2] - RG[2]) * top;
r = r * (1 - f) + gr * f;
g = g * (1 - f) + gg * f;
b = b * (1 - f) + gb * f;
}
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stRef.current;
const sec = now / 1000;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
if (!st.dragging) st.sunAngT = sec * 0.18;
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
st.sunAng += (st.sunAngT - st.sunAng) * 0.06;
st.sunZ += (st.sunZT - st.sunZ) * 0.06;
fieldcalc(sec, st.wind, st.windAng);
render();
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function localXY(e: PointerLikeEvent): [number, number] {
const field = fieldRef.current;
if (!field) return [0, 0];
const r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function aimSun(px: number, py: number): void {
const st = stRef.current;
const w = sizeRef.current.w, h = sizeRef.current.h;
st.sunAngT = Math.atan2(py - h / 2, px - w / 2);
const dist = Math.sqrt((px - w / 2) * (px - w / 2) + (py - h / 2) * (py - h / 2));
st.sunZT = Math.max(0.1, Math.min(0.95, 1 - dist / (w * 0.6)));
}
function onPointerDown(e: PointerLikeEvent & { currentTarget: HTMLDivElement }): void {
const st = stRef.current;
st.dragging = true;
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* noop */ }
const [px, py] = localXY(e);
aimSun(px, py);
}
function onPointerMove(e: PointerLikeEvent): void {
const st = stRef.current;
const [px, py] = localXY(e);
if (st.dragging) aimSun(px, py);
const t = performance.now();
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerUp(): void {
stRef.current.dragging = false;
}
function onPointerLeave(): void {
const st = stRef.current;
st.auto = true;
st.dragging = false;
st.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
fieldcalc(7.3, 1.35, 0.5);
render();
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stRef.current;
st.water0 = hex2rgb(tok("--water0"));
st.water1 = hex2rgb(tok("--water1"));
st.glint = hex2rgb(tok("--glint"));
st.glint2 = hex2rgb(tok("--glint2"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
fieldcalc(7.3, 1.35, 0.5);
st.sunAng = 0.9; st.sunZ = 0.62;
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>수면 기울기와 태양 방향의 내적을 제곱해 반짝이만 찍습니다 — 드래그로 태양을 옮기세요</span>
</p>
<div
className={styles.field}
ref={fieldRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerLeave}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={shinyValRef}>광택 {SHINY}</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}04 · 鏡面ハイライト — 太陽のグリッター — 計測 累積面積 41.906% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
法線内積を2乗してきらめきを絞る計算
var lx = Math.cos(sunAng) * (1 - sunZ), ly = Math.sin(sunAng) * (1 - sunZ), lz = sunZ + 0.3;
var llen = Math.sqrt(lx * lx + ly * ly + lz * lz);
lx /= llen; ly /= llen; lz /= llen;
// 中心差分の勾配 — 法線(nx, ny, nz)の近似
var gx = (hgt[row + x1] - hgt[row + x0]) / Math.max(1, x1 - x0);
var gy = (hgt[y1 * COLS + x] - hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
var nx = -gx * 5.2, ny = -gy * 5.2, nz = 1;
var nlen = Math.sqrt(nx * nx + ny * ny + nz * nz);
nx /= nlen; ny /= nlen; nz /= nlen;
var d = nx * lx + ny * ly + nz * lz;
if (d < 0) d = 0;
var spec = Math.pow(d, SHINY); // dot(N,L)^shininess — 狭く鋭いきらめき
内積の値をそのまま使うと画面が白く滲みますが、2乗(SHINY)を強めるほど光が点に集まります。ポインタの位置が太陽の方位・高さ(sunZ)になり、太陽を画面内で直接動かせます。
05泡のパーティクル — グリッドセル由来の破片
波頂から離れた泡を座標ではなく生まれたグリッドマスで寿命・位置を計算して散らす、夏イベント・ビーチフェス向けシーンです(実測: 累積面積60.21%、強度平均21.0)。

<main class="stage">
<div class="fp" id="fp">
<p class="fp__hint">
<svg class="fp__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>格子セルごとに泡の密度を計算します — 座標を引き直さず、生まれたセルで寿命を数えます。強い風を当ててみてください</span>
</p>
<div class="fp__field fp__grid-cell" id="field" role="application" aria-label="泡パーティクルお試しパネル" tabindex="0">
<canvas class="fp__cv" id="cv"></canvas>
<b class="fp__badge" aria-hidden="true"><span class="fp__badgeNum" id="foamVal">泡 0</span><span class="fp__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 물거품 파티클(격자 셀 기반) — 크레스트에서 발생 → advect(흐름 이동) → 지수 감쇠하는 오일러 밀도장.
// 움직임은 전부 캔버스 rAF 안에 있다. @keyframes 를 두지 않는다(077 방식).
// 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$fp-sea0: mix($subject-blue-deep, $stage-paper, 54%); // 바탕 바다색 — 진한 파랑
$fp-sea1: mix($subject-sky, $stage-paper, 62%); // 바탕 바다색 — 밝은 하늘 민트
$fp-foam0: mix($subject-cream, $stage-paper, 70%); // 거품 밀도 낮음 — 옅은 크림
$fp-foam1: $subject-cream; // 거품 밀도 높음 — 진한 크림
$duration: $dur-loop;
$easing: $ease-spring;
$color: $fp-foam1;
@include stage($stage-paper);
.fp {
--sea0: #{$fp-sea0};
--sea1: #{$fp-sea1};
--foam0: #{$fp-foam0};
--foam1: #{$fp-foam1};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fp__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.fp__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.fp__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $fp-sea0;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.fp__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.fp__cv { display: block; width: 100%; height: 100%; }
.fp__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.fp__badgeNum { font-size: 13px; color: $stage-yellow; }
.fp__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.fp { gap: $sp-1; }
.fp__field { height: 124px; }
.fp__badge { font-size: 10px; padding: 0 $sp-1; }
.fp__badgeNum { font-size: 11px; }
.fp__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 거품이 이미 자란 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.fp.is-still .fp__field { cursor: default !important; }
.fp.is-still .fp__badge { opacity: .92 !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);
}
.fp {
--sea0: rgb(48.1176470588%, 62.7294117647%, 95.1294117647%);
--sea1: rgb(64.7450980392%, 86.6274509804%, 100%);
--foam0: rgb(100%, 97.8039215686%, 93.137254902%);
--foam1: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fp__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.fp__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.fp__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(48.1176470588%, 62.7294117647%, 95.1294117647%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.fp__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fp__cv {
display: block;
width: 100%;
height: 100%;
}
.fp__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.fp__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.fp__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.fp {
gap: 4px;
}
.fp__field {
height: 124px;
}
.fp__badge {
font-size: 10px;
padding: 0 4px;
}
.fp__badgeNum {
font-size: 11px;
}
.fp__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fp.is-still .fp__field {
cursor: default !important;
}
.fp.is-still .fp__badge {
opacity: 0.92 !important;
}
}// 물거품 파티클(격자 셀 기반) — 01 과 같은 뿌리(정현파 6개 mulberry32(7))의 2D 파도장에서
// 크레스트(閾値 넘는 칸)를 오일러 관점으로 스캔한다. 개별 파티클 좌표를 매 프레임 새로 뽑지 않고,
// "이 거품이 어느 격자 셀(grid-cell)에서 태어났는가"(cellId)로 밀도(density[cellId])와 수명(life[cellId])을
// 저장해 크레스트에서 발생 → 성장 → 파도 위상을 따라 흐름 이동 → 소멸하는 오일러 필드를 만든다.
// 포인터로 "風"을 불면 발생량(spawn rate)이 커진다.
var root = document.getElementById('fp');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var foamVal = document.getElementById('foamVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var SEA0 = v('--sea0'), SEA1 = v('--sea1'); // 바탕 바다색 램프
var FOAM0 = v('--foam0'), FOAM1 = v('--foam1'); // 泡 밀도 낮음→높음 램프(크림 계열)
var COLS = 90; // 오일러 격자 가로 칸 수 — 저해상 셀(파티클 좌표 아님)
var STEP = 4; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수(공통 물리, 01 과 동일 뿌리)
var LIFE_MAX = 26; // 泡 한 점의 최대 수명(프레임 단위 근사 — 시간 dt 로 감쇠)
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var rngSpawn = mulberry32(19); // 발생 판정 전용 — 파도장 시드와 분리(같은 시드를 재사용하면 상관이 생긴다)
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null; // 파도장 높이(Float32Array)
var dens = null; // 셀별 泡 밀도(Float32Array, 0..1) — 오일러 필드 본체
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0, lastT = 0;
var wind = 1.0, windT = 1.0;
var windAng = 0.5, windAngT = 0.5;
var lastPX = -1, lastPY = -1, lastPT = 0;
var totalFoam = 0;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var RS0 = hex2rgb(SEA0), RS1 = hex2rgb(SEA1);
var RF0 = hex2rgb(FOAM0), RF1 = hex2rgb(FOAM1);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(30, Math.round(COLS * H / W));
var n = COLS * ROWS;
hgt = new Float32Array(n);
dens = new Float32Array(n);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 파도장 높이 합성 — 01 과 같은 뿌리
function fieldcalc(t, wstr, wang) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var h = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
h += WV.amp * wstr * Math.sin(ph);
}
hgt[i] = h;
}
}
}
// 泡 오일러 스텝 — 셀마다: (1) 크레스트면 발생 확률로 밀도를 채운다(spawn),
// (2) 파도 위상 방향으로 밀도를 옆 셀에 조금 옮긴다(advect — 흐름 이동, 개별 좌표 추적 아님),
// (3) 전체를 지수 감쇠시켜 수명이 다한 거품이 자연히 사라지게 한다.
var densNext = null;
function foamStep(dt, wstr, wang) {
if (!densNext || densNext.length !== dens.length) densNext = new Float32Array(dens.length);
var eff = 0;
for (var i = 0; i < NW; i++) eff += WAVES[i].amp;
eff *= wstr;
var cut = eff * 0.74; // 크레스트 閾値(03 과 같은 비율대)
var spawnRate = 0.10 + Math.min(0.55, (wstr - 0.6) * 0.28); // 바람이 셀수록 발생량 ↑
var decay = Math.exp(-dt * (1000 / LIFE_MAX) * 0.06); // 수명 감쇠(지수)
var flowX = Math.cos(windAng) * 0.9, flowY = Math.sin(windAng) * 0.9;
var total = 0;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
for (var x = 0; x < COLS; x++) {
var idx = row + x;
// advect — 위상 흐름 반대 방향(이전 위치)에서 밀도를 끌어와 이 셀에 놓는다(semi-Lagrangian 근사)
var sx = x - flowX, sy = y - flowY;
var ix = Math.round(sx), iy = Math.round(sy);
if (ix < 0) ix = 0; else if (ix >= COLS) ix = COLS - 1;
if (iy < 0) iy = 0; else if (iy >= ROWS) iy = ROWS - 1;
var carried = dens[iy * COLS + ix] * decay;
// spawn — 크레스트 셀만 결정적 난수로 발생 판정(같은 셀·같은 프레임은 같은 판정 — 시드 고정)
if (hgt[idx] > cut) {
var r = rngSpawn();
if (r < spawnRate) carried = Math.min(1, carried + 0.55 + r * 0.4);
}
densNext[idx] = carried;
total += carried;
}
}
var tmp = dens; dens = densNext; densNext = tmp;
totalFoam = total;
}
// 렌더 — 바탕 바다색(높이 낮은 대비) 위에 泡 밀도를 크림 램프로 얹는다. 밀도가 진할수록 불투명.
function render() {
var o = out.data;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
for (var x = 0; x < COLS; x++) {
var idx = row + x;
var u = hgt[idx] * 0.7 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
var r = RS0[0] + (RS1[0] - RS0[0]) * u;
var g = RS0[1] + (RS1[1] - RS0[1]) * u;
var b = RS0[2] + (RS1[2] - RS0[2]) * u;
var f = dens[idx];
if (f > 0.02) {
if (f > 1) f = 1;
var fr = RF0[0] + (RF1[0] - RF0[0]) * f;
var fg = RF0[1] + (RF1[1] - RF0[1]) * f;
var fb = RF0[2] + (RF1[2] - RF0[2]) * f;
r = r * (1 - f) + fr * f;
g = g * (1 - f) + fg * f;
b = b * (1 - f) + fb * f;
}
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx2 = 0; sx2 < STEP; sx2++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
var dt = lastT ? (now - lastT) : 16.7;
lastT = now;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
fieldcalc(nowSec, wind, windAng);
foamStep(dt, wind, windAng);
render();
foamVal.textContent = '泡 ' + Math.round(totalFoam);
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
// 정지판 — 발생·감쇠를 몇 스텝 미리 돌려 거품이 이미 자란 정적 한 장을 만든다(빈 바다가 아니다)
function still() {
fieldcalc(7.3, 1.35, 0.5);
windAng = 0.5;
for (var k = 0; k < 40; k++) foamStep(33, 1.35, 0.5);
render();
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
if (lastPX >= 0 && t > lastPT) {
var vx = p[0] - lastPX, vy = p[1] - lastPY;
var sp2 = Math.sqrt(vx * vx + vy * vy);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(vy, vx) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
windT = Math.max(0.35, Math.min(2.1, windT + d[0] * 0.18));
windAngT = windAngT + d[1] * 0.1;
if (reduce) { fieldcalc(nowSec || 3.1, windT, windAngT); foamStep(33, windT, windAngT); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// FoamParticles — vanilla 판(run/313/demo/05-ocean-foam-particles/index.html) 의 React 포트.
// 01 과 같은 뿌리(정현파 6개, 시드 7)의 2D 파도장에서 크레스트를 오일러 관점으로 스캔해
// 셀별 거품 밀도(density)를 저장한다 — 개별 파티클 좌표를 매 프레임 새로 뽑지 않는다.
// 발생(spawn, 결정적 난수) → advect(파도 위상 흐름 이동) → 지수 감쇠(수명)를 한 스텝으로 처리.
// 움직임은 캔버스 rAF 루프가 전부이므로 @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./FoamParticles.module.scss";
export type FoamParticlesProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
const COLS = 90;
const STEP = 4;
const NW = 6;
const LIFE_MAX = 26;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function FoamParticles({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: FoamParticlesProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const foamValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const lastTRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
const rngSpawnRef = useRef<(() => number) | null>(null);
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
dens: Float32Array | null;
densNext: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
lastPX: number; lastPY: number; lastPT: number;
totalFoam: number;
sea0: [number, number, number] | null;
sea1: [number, number, number] | null;
foam0: [number, number, number] | null;
foam1: [number, number, number] | null;
};
const stRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null, dens: null, densNext: null, out: null, bctx: null, buf: null,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
lastPX: -1, lastPY: -1, lastPT: 0,
totalFoam: 0,
sea0: null, sea1: null, foam0: null, foam1: null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stRef.current;
st.rows = Math.max(30, Math.round(COLS * r.height / r.width));
const n = COLS * st.rows;
st.hgt = new Float32Array(n);
st.dens = new Float32Array(n);
st.densNext = new Float32Array(n);
st.rw = COLS * STEP; st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
function fieldcalc(t: number, wstr: number, wang: number): void {
const st = stRef.current;
const WV = wavesRef.current;
if (!st.hgt || !WV) return;
let i = 0;
for (let y = 0; y < st.rows; y++) {
for (let x = 0; x < COLS; x++, i++) {
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 + t * wv.om + wv.ph;
h += wv.amp * wstr * Math.sin(ph);
}
st.hgt[i] = h;
}
}
}
// 거품 오일러 스텝 — 발생(크레스트 문턱, 결정적 난수) → advect(위상 흐름 이동) → 지수 감쇠
function foamStep(dt: number, wstr: number, wang: number): void {
const st = stRef.current;
const WV = wavesRef.current;
const rngSpawn = rngSpawnRef.current;
if (!st.hgt || !st.dens || !st.densNext || !WV || !rngSpawn) return;
let eff = 0;
for (let i = 0; i < NW; i++) eff += WV[i].amp;
eff *= wstr;
const cut = eff * 0.74;
const spawnRate = 0.10 + Math.min(0.55, (wstr - 0.6) * 0.28);
const decay = Math.exp(-dt * (1000 / LIFE_MAX) * 0.06);
const flowX = Math.cos(wang) * 0.9, flowY = Math.sin(wang) * 0.9;
let total = 0;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
for (let x = 0; x < COLS; x++) {
const idx = row + x;
const sx = x - flowX, sy = y - flowY;
let ix = Math.round(sx), iy = Math.round(sy);
if (ix < 0) ix = 0; else if (ix >= COLS) ix = COLS - 1;
if (iy < 0) iy = 0; else if (iy >= st.rows) iy = st.rows - 1;
let carried = st.dens[iy * COLS + ix] * decay;
if (st.hgt[idx] > cut) {
const r = rngSpawn();
if (r < spawnRate) carried = Math.min(1, carried + 0.55 + r * 0.4);
}
st.densNext[idx] = carried;
total += carried;
}
}
const tmp = st.dens; st.dens = st.densNext; st.densNext = tmp;
st.totalFoam = total;
}
function render(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt || !st.dens) return;
if (!st.sea0 || !st.sea1 || !st.foam0 || !st.foam1) return;
const o = st.out.data;
const RS0 = st.sea0, RS1 = st.sea1, RF0 = st.foam0, RF1 = st.foam1;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
for (let x = 0; x < COLS; x++) {
const idx = row + x;
let u = st.hgt[idx] * 0.7 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
let r = RS0[0] + (RS1[0] - RS0[0]) * u;
let g = RS0[1] + (RS1[1] - RS0[1]) * u;
let b = RS0[2] + (RS1[2] - RS0[2]) * u;
let f = st.dens[idx];
if (f > 0.02) {
if (f > 1) f = 1;
const fr = RF0[0] + (RF1[0] - RF0[0]) * f;
const fg = RF0[1] + (RF1[1] - RF0[1]) * f;
const fb = RF0[2] + (RF1[2] - RF0[2]) * f;
r = r * (1 - f) + fr * f;
g = g * (1 - f) + fg * f;
b = b * (1 - f) + fb * f;
}
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx2 = 0; sx2 < STEP; sx2++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stRef.current;
const sec = now / 1000;
const dt = lastTRef.current ? now - lastTRef.current : 16.7;
lastTRef.current = now;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
fieldcalc(sec, st.wind, st.windAng);
foamStep(dt, st.wind, st.windAng);
render();
if (foamValRef.current) foamValRef.current.textContent = "거품 " + Math.round(st.totalFoam);
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stRef.current;
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stRef.current.auto = true;
stRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
const st = stRef.current;
fieldcalc(7.3, 1.35, 0.5);
st.windAng = 0.5;
for (let k = 0; k < 40; k++) foamStep(33, 1.35, 0.5);
render();
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
rngSpawnRef.current = mulberry32(19);
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stRef.current;
st.sea0 = hex2rgb(tok("--sea0"));
st.sea1 = hex2rgb(tok("--sea1"));
st.foam0 = hex2rgb(tok("--foam0"));
st.foam1 = hex2rgb(tok("--foam1"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
fieldcalc(7.3, 1.35, 0.5);
st.windAng = 0.5;
for (let k = 0; k < 40; k++) foamStep(33, 1.35, 0.5);
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>격자 셀마다 거품 밀도를 계산합니다 — 좌표를 다시 뽑지 않고 태어난 칸에서 수명을 셉니다. 바람을 세게 불어보세요</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={foamValRef}>거품 0</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}05 · 泡のパーティクル — グリッドセル由来の破片 — 計測 累積面積 60.21% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
座標乱数の代わりにグリッドセルで泡を運ぶアドベクション
// advect — 位相の流れと逆方向(直前の位置)から密度を引いてきてこのセルに置く
var sx = x - flowX, sy = y - flowY;
var ix = Math.round(sx), iy = Math.round(sy);
if (ix < 0) ix = 0; else if (ix >= COLS) ix = COLS - 1;
if (iy < 0) iy = 0; else if (iy >= ROWS) iy = ROWS - 1;
var carried = dens[iy * COLS + ix] * decay;
// spawn — 波頂セルだけ決定的な乱数で発生判定(同じセル・同じフレームは同じ判定)
if (hgt[idx] > cut) {
var r = rngSpawn();
if (r < spawnRate) carried = Math.min(1, carried + 0.55 + r * 0.4);
}
densNext[idx] = carried;
座標を乱数で引き直すと風が弱まった瞬間に泡が消えてしまいますが、グリッドセルに紐づけて減衰(decay)に沿って薄れさせると自然に馴染みます。
06水面屈折 — ゆらぐ水紋
下絵(グリッド・文字)のピクセルを保持し、水面グリッドの勾配を読み取り位置のオフセットに変えて元絵を引き寄せる、水族館・スパ・製品のウォータールック向け屈折近似シーンです(実測: 累積面積41.565%、強度平均22.9)。

<main class="stage">
<div class="rc" id="rc">
<p class="rc__hint">
<svg class="rc__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>床のチェッカー模様を水面の勾配ぶん引き寄せて屈折を近似します — 明るいコースティクスは勾配の発散から計算します</span>
</p>
<div class="rc__field" id="field" role="application" aria-label="水面屈折近似お試しパネル" tabindex="0">
<canvas class="rc__cv" id="cv"></canvas>
<b class="rc__badge" aria-hidden="true"><span class="rc__badgeNum" id="depthVal">屈折 x34</span><span class="rc__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 수면 굴절 근사 — 바닥 체커를 수면 기울기만큼 당겨 오는 굴절 + 기울기 발산 코스틱.
// 움직임은 전부 캔버스 rAF 안에 있다. @keyframes 를 두지 않는다(077 방식).
// 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$rc-tile0: mix($subject-cream, $stage-paper, 62%); // 바닥 체커 밝은 칸 — 크림
$rc-tile1: mix($subject-sky, $stage-paper, 34%); // 바닥 체커 어두운 칸 — 청록
$rc-line: mix($subject-blue, $stage-paper, 46%); // 바닥 격자선 — 파랑
$rc-caustic: mix($subject-mint, $stage-paper, 84%); // 코스틱 밝기 — 민트
$duration: $dur-loop;
$easing: $ease-spring;
$color: $rc-caustic;
@include stage($stage-paper);
.rc {
--tile0: #{$rc-tile0};
--tile1: #{$rc-tile1};
--line: #{$rc-line};
--caustic: #{$rc-caustic};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.rc__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.rc__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.rc__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $rc-tile1;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.rc__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.rc__cv { display: block; width: 100%; height: 100%; }
.rc__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
box-shadow: 0 0 calc(4px + var(--glint, .5) * 6px) var(--caustic);
}
.rc__badgeNum { font-size: 13px; color: $stage-yellow; }
.rc__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.rc { gap: $sp-1; }
.rc__field { height: 124px; }
.rc__badge { font-size: 10px; padding: 0 $sp-1; }
.rc__badgeNum { font-size: 11px; }
.rc__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 굴절·코스틱이 살아 있는 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.rc.is-still .rc__field { cursor: default !important; }
.rc.is-still .rc__badge { opacity: .92 !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);
}
.rc {
--tile0: rgb(100%, 98.0549019608%, 93.9215686275%);
--tile1: rgb(80.6666666667%, 92.6666666667%, 100%);
--line: rgb(62.4784313725%, 73.662745098%, 98.3764705882%);
--caustic: rgb(41.0352941176%, 85.8352941176%, 70.6823529412%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.rc__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.rc__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.rc__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(80.6666666667%, 92.6666666667%, 100%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.rc__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rc__cv {
display: block;
width: 100%;
height: 100%;
}
.rc__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.rc__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.rc__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.rc {
gap: 4px;
}
.rc__field {
height: 124px;
}
.rc__badge {
font-size: 10px;
padding: 0 4px;
}
.rc__badgeNum {
font-size: 11px;
}
.rc__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc.is-still .rc__field {
cursor: default !important;
}
.rc.is-still .rc__badge {
opacity: 0.92 !important;
}
}// 수면 굴절 근사 — 01 과 같은 뿌리(정현파 6개 mulberry32(7))의 2D 파도장에서 픽셀마다 기울기(gx,gy)를
// 구해 "빛이 그만큼 꺾여 온다"는 굴절 근사로 바닥 무늬(체커+격자선) 샘플 좌표를 오프셋한다.
// 077-07(fx-fluid-surface)의 스넬 굴절과 달리 여기선 밑그림이 정적 체커판이고, 코스틱(집광 밝기)은
// 별도 스텝으로 만들지 않고 기울기 발산(∂gx/∂x + ∂gy/∂y)의 절대값으로 근사해 밝기에 얹는다
// (빛다발이 모이는 자리 = 기울기가 급하게 수렴/발산하는 자리라는 물리적 직관의 저해상 근사).
var root = document.getElementById('rc');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var depthVal = document.getElementById('depthVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var TILE0 = v('--tile0'), TILE1 = v('--tile1'); // 바닥 체커 두 색(모래 계열)
var LINE = v('--line'); // 바닥 격자선
var CAUSTIC = v('--caustic'); // 코스틱 밝기(민트)
var COLS = 110; // 수면 격자 가로 칸 수(01 과 동일)
var STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수(공통 물리, 01 과 동일 뿌리)
var REFRACT = 34; // 굴절 오프셋 배율(버퍼 px) — 기울기 1 당 이만큼 옆 픽셀을 당겨 온다
var TILE_PX = 11; // 바닥 체커 한 칸 크기(버퍼 px) — 칸이 작을수록 굴절로 움직이는 경계가 많아진다
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null; // 파도장 높이(Float32Array, 격자 해상도)
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0;
var wind = 1.0, windT = 1.0;
var windAng = 0.5, windAngT = 0.5;
var lastPX = -1, lastPY = -1, lastPT = 0;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var RT0 = hex2rgb(TILE0), RT1 = hex2rgb(TILE1);
var RL = hex2rgb(LINE), RC = hex2rgb(CAUSTIC);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(40, Math.round(COLS * H / W));
hgt = new Float32Array(COLS * ROWS);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH); // getImageData 없이 밑그림을 직접 계산한다
}
// 파도장 높이 합성 — 01 과 같은 뿌리
function fieldcalc(t, wstr, wang) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var h = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
h += WV.amp * wstr * Math.sin(ph);
}
hgt[i] = h;
}
}
}
// 바닥 무늬 색 — 체커 + 옅은 격자선. 굴절로 당겨온 "원본 좌표"에서 이 함수를 호출해 색을 얻는다.
// 좌표는 버퍼 px 단위(bx, by) — 화면 밖으로 나가면 감싸기(모듈로)로 무한 바닥처럼 보이게 한다.
function tileColor(bx, by) {
var wx = ((bx % RW) + RW) % RW;
var wy = ((by % RH) + RH) % RH;
var cx = Math.floor(wx / TILE_PX), cy = Math.floor(wy / TILE_PX);
var checker = (cx + cy) % 2 === 0;
var lx = wx - cx * TILE_PX, ly = wy - cy * TILE_PX;
var onLine = lx < 1.4 || ly < 1.4;
if (onLine) return RL;
return checker ? RT0 : RT1;
}
// 렌더 — 픽셀마다 격자 기울기(중앙차분)를 REFRACT 배로 키워 바닥 샘플 좌표를 오프셋(굴절 근사),
// 기울기 발산(수렴/확산이 급한 자리)의 절대값을 코스틱 밝기로 얹는다.
function render() {
var o = out.data;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
var y0 = y > 0 ? y - 1 : y, y1 = y < ROWS - 1 ? y + 1 : y;
for (var x = 0; x < COLS; x++) {
var idx = row + x;
var x0 = x > 0 ? x - 1 : x, x1 = x < COLS - 1 ? x + 1 : x;
var gx = (hgt[row + x1] - hgt[row + x0]) / Math.max(1, x1 - x0);
var gy = (hgt[y1 * COLS + x] - hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
// 발산 근사 — 이웃 기울기와의 차이(2차 차분에 준하는 값)를 코스틱 강도로 쓴다
var gxr = x1 < COLS - 1 ? (hgt[row + Math.min(COLS - 1, x1 + 1)] - hgt[row + x1]) : gx;
var gyd = y1 < ROWS - 1 ? (hgt[Math.min(ROWS - 1, y1 + 1) * COLS + x] - hgt[y1 * COLS + x]) : gy;
var div = Math.abs((gxr - gx) + (gyd - gy)) * 90;
if (div > 1) div = 1;
var offx = gx * REFRACT, offy = gy * REFRACT;
var bx0 = x * STEP + STEP / 2, by0 = y * STEP + STEP / 2;
var c = tileColor(bx0 + offx, by0 + offy);
var r = c[0], g = c[1], b = c[2];
// 코스틱 — 발산이 큰 자리를 밝은 민트로 가산
if (div > 0.06) {
var f = (div - 0.06) / 0.94;
r = r + (RC[0] - r) * f * 0.85;
g = g + (RC[1] - g) * f * 0.85;
b = b + (RC[2] - b) * f * 0.85;
}
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
// 중앙 코스틱 밝기를 getImageData 로 되읽어 배지 발광에 되먹인다
var samp = bctx.getImageData(Math.floor(RW / 2), Math.floor(RH / 2), 1, 1).data;
field.style.setProperty('--glint', (samp[0] / 255).toFixed(2));
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
fieldcalc(nowSec, wind, windAng);
render();
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
// 정지판 — 루프 없이 시드가 정한 한 시각의 파도장을 계산해 굴절·코스틱이 살아 있는 정적 한 장
function still() {
fieldcalc(7.3, 1.35, 0.5);
render();
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
if (lastPX >= 0 && t > lastPT) {
var vx = p[0] - lastPX, vy = p[1] - lastPY;
var sp2 = Math.sqrt(vx * vx + vy * vy);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(vy, vx) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
windT = Math.max(0.35, Math.min(2.1, windT + d[0] * 0.18));
windAngT = windAngT + d[1] * 0.1;
if (reduce) { fieldcalc(nowSec || 3.1, windT, windAngT); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
depthVal.textContent = '屈折 x' + REFRACT;
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// RefractCaustic — vanilla 판(run/313/demo/06-ocean-refract-caustic/index.html) 의 React 포트.
// 01 과 같은 뿌리(정현파 6개, 시드 7)의 2D 파도장에서 픽셀마다 중앙차분 기울기를 구해
// 바닥 체커 무늬의 샘플 좌표를 그만큼 오프셋하는 굴절 근사 + 기울기 발산(2차 차분)의
// 절대값을 코스틱 밝기로 얹는다. 움직임은 캔버스 rAF 루프가 전부이므로 @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RefractCaustic.module.scss";
export type RefractCausticProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
const COLS = 110;
const STEP = 3;
const NW = 6;
const REFRACT = 34;
const TILE_PX = 11;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function RefractCaustic({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#4cd4a6",
}: RefractCausticProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const depthValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
lastPX: number; lastPY: number; lastPT: number;
tile0: [number, number, number] | null;
tile1: [number, number, number] | null;
line: [number, number, number] | null;
caustic: [number, number, number] | null;
};
const stRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null, out: null, bctx: null, buf: null,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
lastPX: -1, lastPY: -1, lastPT: 0,
tile0: null, tile1: null, line: null, caustic: null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stRef.current;
st.rows = Math.max(40, Math.round(COLS * r.height / r.width));
st.hgt = new Float32Array(COLS * st.rows);
st.rw = COLS * STEP; st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
function fieldcalc(t: number, wstr: number, wang: number): void {
const st = stRef.current;
const WV = wavesRef.current;
if (!st.hgt || !WV) return;
let i = 0;
for (let y = 0; y < st.rows; y++) {
for (let x = 0; x < COLS; x++, i++) {
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 + t * wv.om + wv.ph;
h += wv.amp * wstr * Math.sin(ph);
}
st.hgt[i] = h;
}
}
}
// 바닥 무늬 색 — 체커 + 옅은 격자선. 굴절 오프셋된 좌표(버퍼 px)에서 호출한다. 감싸기로 무한 바닥처럼.
function tileColor(bx: number, by: number, rw: number, rh: number, tile0: [number, number, number], tile1: [number, number, number], line: [number, number, number]): [number, number, number] {
const wx = ((bx % rw) + rw) % rw;
const wy = ((by % rh) + rh) % rh;
const cx = Math.floor(wx / TILE_PX), cy = Math.floor(wy / TILE_PX);
const checker = (cx + cy) % 2 === 0;
const lx = wx - cx * TILE_PX, ly = wy - cy * TILE_PX;
if (lx < 1.4 || ly < 1.4) return line;
return checker ? tile0 : tile1;
}
function render(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt) return;
if (!st.tile0 || !st.tile1 || !st.line || !st.caustic) return;
const o = st.out.data;
const RT0 = st.tile0, RT1 = st.tile1, RL = st.line, RC = st.caustic;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
const y0 = y > 0 ? y - 1 : y, y1 = y < st.rows - 1 ? y + 1 : y;
for (let x = 0; x < COLS; x++) {
const x0 = x > 0 ? x - 1 : x, x1 = x < COLS - 1 ? x + 1 : x;
const gx = (st.hgt[row + x1] - st.hgt[row + x0]) / Math.max(1, x1 - x0);
const gy = (st.hgt[y1 * COLS + x] - st.hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
const gxr = x1 < COLS - 1 ? (st.hgt[row + Math.min(COLS - 1, x1 + 1)] - st.hgt[row + x1]) : gx;
const gyd = y1 < st.rows - 1 ? (st.hgt[Math.min(st.rows - 1, y1 + 1) * COLS + x] - st.hgt[y1 * COLS + x]) : gy;
let div = Math.abs((gxr - gx) + (gyd - gy)) * 90;
if (div > 1) div = 1;
const offx = gx * REFRACT, offy = gy * REFRACT;
const bx0 = x * STEP + STEP / 2, by0 = y * STEP + STEP / 2;
const c = tileColor(bx0 + offx, by0 + offy, st.rw, st.rh, RT0, RT1, RL);
let r = c[0], g = c[1], b = c[2];
if (div > 0.06) {
const f = (div - 0.06) / 0.94;
r = r + (RC[0] - r) * f * 0.85;
g = g + (RC[1] - g) * f * 0.85;
b = b + (RC[2] - b) * f * 0.85;
}
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stRef.current;
const sec = now / 1000;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
fieldcalc(sec, st.wind, st.windAng);
render();
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stRef.current;
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stRef.current.auto = true;
stRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
fieldcalc(7.3, 1.35, 0.5);
render();
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stRef.current;
st.tile0 = hex2rgb(tok("--tile0"));
st.tile1 = hex2rgb(tok("--tile1"));
st.line = hex2rgb(tok("--line"));
st.caustic = hex2rgb(tok("--caustic"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
fieldcalc(7.3, 1.35, 0.5);
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>바닥 체커 무늬를 수면 기울기만큼 당겨 와 굴절을 근사합니다 — 밝은 코스틱은 기울기 발산으로 계산해요</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={depthValRef}>굴절 x{REFRACT}</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}06 · 水面屈折 — ゆらぐ水紋 — 計測 累積面積 41.565% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
勾配の発散でコースティック強度を測る計算
var gx = (hgt[row + x1] - hgt[row + x0]) / Math.max(1, x1 - x0);
var gy = (hgt[y1 * COLS + x] - hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
// 発散近似 — 隣接勾配との差をコースティック強度として使う
var gxr = x1 < COLS - 1 ? (hgt[row + Math.min(COLS - 1, x1 + 1)] - hgt[row + x1]) : gx;
var gyd = y1 < ROWS - 1 ? (hgt[Math.min(ROWS - 1, y1 + 1) * COLS + x] - hgt[y1 * COLS + x]) : gy;
var div = Math.abs((gxr - gx) + (gyd - gy)) * 90;
if (div > 1) div = 1;
var offx = gx * REFRACT, offy = gy * REFRACT;
var bx0 = x * STEP + STEP / 2, by0 = y * STEP + STEP / 2;
var c = tileColor(bx0 + offx, by0 + offy); // オフセット分だけ引き寄せた床の色
屈折オフセット倍率(REFRACT)を上げるほど床の模様が強くねじれますが、大きくしすぎると座標が飛んでタイルが途切れます。勾配の発散が大きいマスにはミント色を加算し、光が集まるコースティックまで重ねています。
07波の渦 — 離岸流の渦パッチ
波の場に局所回転を重ねて波が束になり沖へ抜け出す離岸流を表現する、海洋安全・教育コンテンツ向けシーンです(実測: 累積面積60.735%、フレーム内最大面積25.676%で9本中最大)。

<main class="stage">
<div class="rp" id="rp">
<p class="rp__hint">
<svg class="rp__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>波の場に局所回転を重ねて渦を作ります — マウスを乗せると渦の中心がそこへ移動します</span>
</p>
<div class="rp__field" id="field" role="application" aria-label="波の渦お試しパネル" tabindex="0">
<canvas class="rp__cv" id="cv"></canvas>
<b class="rp__badge" aria-hidden="true"><span class="rp__badgeNum" id="swirlVal">回転 1.0</span><span class="rp__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 파도 소용돌이 — 파도장 위에 국소 회전(와류)을 얹는 이안류 패치.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 를 두지 않는다(077 방식).
// 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$rp-crest: mix($subject-mint, $stage-paper, 72%);
$rp-crest2: mix($subject-sky, $stage-paper, 66%);
$rp-trough: mix($subject-blue-deep, $stage-paper, 52%);
$rp-trough2: mix($subject-blue, $stage-paper, 58%);
$rp-foam: mix($subject-cream, $stage-paper, 88%); // 와류 중심 거품 — 밝은 크림
$duration: $dur-loop;
$easing: $ease-spring;
$color: $rp-foam;
@include stage($stage-paper);
.rp {
--crest: #{$rp-crest};
--crest-2: #{$rp-crest2};
--trough: #{$rp-trough};
--trough-2: #{$rp-trough2};
--foam: #{$rp-foam};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.rp__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.rp__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.rp__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $rp-trough2;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.rp__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.rp__cv { display: block; width: 100%; height: 100%; }
// 와류 세기·바람 세기를 화면에도 적어 둔다 — frame() 이 갱신한다
.rp__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.rp__badgeNum { font-size: 13px; color: $stage-yellow; }
.rp__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.rp { gap: $sp-1; }
.rp__field { height: 124px; }
.rp__badge { font-size: 10px; padding: 0 $sp-1; }
.rp__badgeNum { font-size: 11px; }
.rp__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 소용돌이가 살아 있는 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.rp.is-still .rp__field { cursor: default !important; }
.rp.is-still .rp__badge { opacity: .92 !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);
}
.rp {
--crest: rgb(49.4588235294%, 87.8588235294%, 74.8705882353%);
--crest-2: rgb(62.4705882353%, 85.7647058824%, 100%);
--trough: rgb(50.0392156863%, 64.1098039216%, 95.3098039216%);
--trough-2: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
--foam: rgb(100%, 97.2392156863%, 91.3725490196%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.rp__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.rp__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.rp__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.rp__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rp__cv {
display: block;
width: 100%;
height: 100%;
}
.rp__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.rp__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.rp__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.rp {
gap: 4px;
}
.rp__field {
height: 124px;
}
.rp__badge {
font-size: 10px;
padding: 0 4px;
}
.rp__badgeNum {
font-size: 11px;
}
.rp__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.rp.is-still .rp__field {
cursor: default !important;
}
.rp.is-still .rp__badge {
opacity: 0.92 !important;
}
}// 파도 소용돌이(이안류 패치) — 01 과 같은 뿌리(정현파 6개 mulberry32(7))의 파도장 위에
// 국소 와류를 한 겹 얹는다. 回転 유도체 접근: 각 셀의 좌표를 와류 중심 기준 극좌표로 바꿔
// 위상에 -각도(回転)와 반경 비례 감쇠(ramp)를 더한다. 위상이 비틀리면 정현파의 마루·골이
// 와류 주변에서 나선처럼 감기며 "파도가 뭉쳐 바깥으로 빠져나가는" 이안류의 인상을 준다.
// 와류 중심은 자동 모드에서 리사주로 천천히 옮겨 다니고, 포인터가 있으면 그 위치를 따라간다.
// 와류 주변 밝기(泡 농도)는 반경 가우시안으로 부풀려 중심이 물빛보다 밝게 뜨게 한다.
var root = document.getElementById('rp');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var swirlVal = document.getElementById('swirlVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var CREST = [v('--crest'), v('--crest-2')]; // 마루 색 램프 — 민트 계열
var TROUGH = [v('--trough'), v('--trough-2')]; // 골 색 램프 — 파랑 계열
var FOAM = v('--foam'); // 와류 중심 泡 — 밝은 크림
var COLS = 110; // 파도장 가로 칸 수(01 과 동일)
var STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수(공통 물리)
var SWIRL = 2.6; // 와류 세기 — 위상을 이만큼 비튼다(라디안 스케일)
var VORTEX_R = 30;// 와류 반경(격자 칸 수) — 이 안에서 회전이 감쇠하며 살아 있다
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null; // 파도장 높이(위상 비틀림 반영)
var foam = null; // 와류 泡 농도(0..1)
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0;
var wind = 1.0, windT = 1.0; // 風 세기(실효 진폭)
var windAng = 0.5, windAngT = 0.5; // 風 방향
var vx = 0.5, vy = 0.42; // 와류 중심(판 비율 0..1) 현재
var vxT = 0.5, vyT = 0.42; // 목표(포인터 또는 자동 리사주)
var swirl = 1.0, swirlT = 1.0; // 와류 세기 배율
var lastPX = -1, lastPY = -1, lastPT = 0;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var C0 = hex2rgb(CREST[0]), C1 = hex2rgb(CREST[1]);
var T0 = hex2rgb(TROUGH[0]), T1 = hex2rgb(TROUGH[1]);
var FC = hex2rgb(FOAM);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(40, Math.round(COLS * H / W));
var n = COLS * ROWS;
hgt = new Float32Array(n);
foam = new Float32Array(n);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 파도장 + 와류 — height(x,y,t) = Σ amp·sin(위상 + 비틀림(각도·반경))
// 비틀림 twist = SWIRL·세기·(1 - r/R)·θ … θ 는 중심 기준 방위각, r 은 반경.
// 반경이 R 을 넘으면 twist=0 — 와류가 국소 패치로만 살게.
function fieldcalc(t, wstr, wang, cx, cy, sw) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var dxv = x - cx, dyv = y - cy;
var r = Math.sqrt(dxv * dxv + dyv * dyv);
var ang = Math.atan2(dyv, dxv);
var fall = r < VORTEX_R ? 1 - r / VORTEX_R : 0;
fall *= fall; // 감쇠를 가속 — 중심만 또렷하게
var twist = SWIRL * sw * fall * ang;
var h = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
h += WV.amp * wstr * Math.sin(ph + twist);
}
hgt[i] = h;
// 泡 농도 — 와류 중심 가우시안 + 파도 마루 겹침. 진폭이 뭉치는 자리가 밝다
var foamv = fall * 0.85 + Math.max(0, h) * 0.5;
if (foamv > 1) foamv = 1;
foam[i] = foamv;
}
}
}
// 렌더 — 높이→색 램프(골 파랑 → 마루 민트) 뒤 泡 농도만큼 밝은 크림으로 당긴다.
// 와류가 옮겨 다니면 밝은 패치가 파도를 헤치고 다니는 게 눈에 띄어 누적면적이 크게 잡힌다.
function render() {
var o = out.data;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
for (var x = 0; x < COLS; x++) {
var i = row + x;
var h = hgt[i];
var u = h * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
var r, g, b;
if (u > 0.5) {
var k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
var k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
var f = foam[i];
if (f > 0.02) {
r = r + (FC[0] - r) * f * 0.8;
g = g + (FC[1] - g) * f * 0.8;
b = b + (FC[2] - b) * f * 0.8;
}
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
// 와류 중심이 판 안을 천천히 배회 — 두 주파수 리사주로 예측 불가능하게
vxT = 0.5 + Math.sin(th * 0.37 + 1.2) * 0.3;
vyT = 0.44 + Math.sin(th * 0.53 + 2.1) * 0.26;
swirlT = 1.0 + Math.sin(th * 0.7) * 0.35;
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
vx += (vxT - vx) * 0.045;
vy += (vyT - vy) * 0.045;
swirl += (swirlT - swirl) * 0.04;
fieldcalc(nowSec, wind, windAng, vx * COLS, vy * ROWS, swirl);
render();
swirlVal.textContent = '回転 ' + swirl.toFixed(1);
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
// 정지판 — 루프 없이 시드가 정한 한 시각의 와류를 계산해 소용돌이가 살아 있는 정적 한 장
function still() {
fieldcalc(7.3, 1.35, 0.5, 0.46 * COLS, 0.42 * ROWS, 1.2);
render();
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
// 포인터 = 와류 중심 목표. 이동 벡터는 바람으로도 실측(01 방식)
vxT = Math.max(0.04, Math.min(0.96, p[0] / W));
vyT = Math.max(0.04, Math.min(0.96, p[1] / H));
if (lastPX >= 0 && t > lastPT) {
var dxp = p[0] - lastPX, dyp = p[1] - lastPY;
var sp2 = Math.sqrt(dxp * dxp + dyp * dyp);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(dyp, dxp) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
vxT = Math.max(0.05, Math.min(0.95, vxT + d[0] * 0.06));
vyT = Math.max(0.05, Math.min(0.95, vyT + d[1] * 0.06));
swirlT = Math.max(0.3, Math.min(1.9, swirlT + (d[1] < 0 ? 0.12 : -0.12)));
if (reduce) { fieldcalc(nowSec || 3.1, windT, windAngT, vxT * COLS, vyT * ROWS, swirlT); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// RipCurrent — vanilla 판(run/313/demo/07-ocean-rip-current/index.html) 의 React 포트.
// 01(GerstnerSum)과 같은 뿌리(정현파 6개 mulberry32(7))의 파도장에 국소 와류(회전 유도체)를
// 한 겹 얹는다 — 셀 좌표를 와류 중심 기준 극좌표로 바꿔 위상에 각도·반경 감쇠를 더한다.
// 와류 중심은 자동 리사주로 배회하거나 포인터를 따라간다. 움직임은 rAF 루프가 전부.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RipCurrent.module.scss";
export type RipCurrentProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
const COLS = 110;
const STEP = 3;
const NW = 6;
const SWIRL = 2.6;
const VORTEX_R = 30;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function RipCurrent({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4ead2",
}: RipCurrentProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const swirlValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
foam: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
vx: number; vy: number; vxT: number; vyT: number;
swirl: number; swirlT: number;
lastPX: number; lastPY: number; lastPT: number;
c0: [number, number, number] | null;
c1: [number, number, number] | null;
t0: [number, number, number] | null;
t1: [number, number, number] | null;
fc: [number, number, number] | null;
};
const stateRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null as Float32Array | null,
foam: null as Float32Array | null,
out: null as ImageData | null,
bctx: null as CanvasRenderingContext2D | null,
buf: null as HTMLCanvasElement | null,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
vx: 0.5, vy: 0.42, vxT: 0.5, vyT: 0.42,
swirl: 1.0, swirlT: 1.0,
lastPX: -1, lastPY: -1, lastPT: 0,
c0: null as [number, number, number] | null,
c1: null as [number, number, number] | null,
t0: null as [number, number, number] | null,
t1: null as [number, number, number] | null,
fc: null as [number, number, number] | null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stateRef.current;
st.rows = Math.max(40, Math.round(COLS * r.height / r.width));
const n = COLS * st.rows;
st.hgt = new Float32Array(n);
st.foam = new Float32Array(n);
st.rw = COLS * STEP;
st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
function fieldcalc(t: number, wstr: number, wang: number, cx: number, cy: number, sw: number): void {
const st = stateRef.current;
const WV = wavesRef.current;
if (!st.hgt || !st.foam || !WV) return;
let i = 0;
for (let y = 0; y < st.rows; y++) {
for (let x = 0; x < COLS; x++, i++) {
const dxv = x - cx, dyv = y - cy;
const r = Math.sqrt(dxv * dxv + dyv * dyv);
const ang = Math.atan2(dyv, dxv);
let fall = r < VORTEX_R ? 1 - r / VORTEX_R : 0;
fall *= fall;
const twist = SWIRL * sw * fall * ang;
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 + t * wv.om + wv.ph;
h += wv.amp * wstr * Math.sin(ph + twist);
}
st.hgt[i] = h;
let foamv = fall * 0.85 + Math.max(0, h) * 0.5;
if (foamv > 1) foamv = 1;
st.foam[i] = foamv;
}
}
}
function render(): void {
const st = stateRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt || !st.foam) return;
if (!st.c0 || !st.c1 || !st.t0 || !st.t1 || !st.fc) return;
const o = st.out.data;
const C0 = st.c0, C1 = st.c1, T0 = st.t0, T1 = st.t1, FC = st.fc;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
for (let x = 0; x < COLS; x++) {
const i = row + x;
let u = st.hgt[i] * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
let r: number, g: number, b: number;
if (u > 0.5) {
const k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
const k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
const f = st.foam[i];
if (f > 0.02) {
r = r + (FC[0] - r) * f * 0.8;
g = g + (FC[1] - g) * f * 0.8;
b = b + (FC[2] - b) * f * 0.8;
}
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stateRef.current;
const sec = now / 1000;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
st.vxT = 0.5 + Math.sin(th * 0.37 + 1.2) * 0.3;
st.vyT = 0.44 + Math.sin(th * 0.53 + 2.1) * 0.26;
st.swirlT = 1.0 + Math.sin(th * 0.7) * 0.35;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
st.vx += (st.vxT - st.vx) * 0.045;
st.vy += (st.vyT - st.vy) * 0.045;
st.swirl += (st.swirlT - st.swirl) * 0.04;
fieldcalc(sec, st.wind, st.windAng, st.vx * COLS, st.vy * st.rows, st.swirl);
render();
if (swirlValRef.current) swirlValRef.current.textContent = "회전 " + st.swirl.toFixed(1);
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stateRef.current;
st.vxT = Math.max(0.04, Math.min(0.96, px / sizeRef.current.w));
st.vyT = Math.max(0.04, Math.min(0.96, py / sizeRef.current.h));
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stateRef.current.auto = true;
stateRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
const st = stateRef.current;
fieldcalc(7.3, 1.35, 0.5, 0.46 * COLS, 0.42 * st.rows, 1.2);
render();
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stateRef.current;
st.c0 = hex2rgb(tok("--crest"));
st.c1 = hex2rgb(tok("--crest-2"));
st.t0 = hex2rgb(tok("--trough"));
st.t1 = hex2rgb(tok("--trough-2"));
st.fc = hex2rgb(tok("--foam"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
fieldcalc(7.3, 1.35, 0.5, 0.46 * COLS, 0.42 * st.rows, 1.2);
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>파도장에 국소 회전을 한 겹 얹어 소용돌이를 만듭니다 — 마우스를 대면 회전 중심이 그리로 갑니다</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={swirlValRef}>회전 1.0</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}07 · 波の渦 — 離岸流の渦パッチ — 計測 累積面積 60.735% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
距離減衰を2乗して回転を中心に閉じ込める計算
function fieldcalc(t, wstr, wang, cx, cy, sw) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var dxv = x - cx, dyv = y - cy;
var r = Math.sqrt(dxv * dxv + dyv * dyv);
var ang = Math.atan2(dyv, dxv);
var fall = r < VORTEX_R ? 1 - r / VORTEX_R : 0;
fall *= fall; // 減衰を加速 — 中心だけくっきりと
var twist = SWIRL * sw * fall * ang;
// この回転(twist)を波の位相に加え、渦の近くだけ波がねじれるようにする
}
}
}
減衰を1回だけ掛けると渦の縁まで波が広くねじれて不自然ですが、2乗すると中心の数マスだけくっきり回ります。ポインタ座標がそのまま渦の中心(cx, cy)になるので、画面のどこへでも引き回せます。
08津波スイープ — 押し寄せる波の壁
波の場の振幅を距離・時間の関数で局所的に膨らませ、ひと塊りの水が水平線へ押し寄せるサージを作る、災害・ドキュメンタリ風オープニング向けシーンです(実測: 累積面積60.026%、強度平均37.9)。

<main class="stage">
<div class="ss" id="ss">
<p class="ss__hint">
<svg class="ss__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>長波長のサージ波が水面を通過し水位を押し上げます — マウスを素早く動かすとスイープが強くなります</span>
</p>
<div class="ss__field" id="field" role="application" aria-label="津波スイープお試しパネル" tabindex="0">
<canvas class="ss__cv" id="cv"></canvas>
<b class="ss__badge" aria-hidden="true"><span class="ss__badgeNum" id="surgeVal">スイープ 1.0</span><span class="ss__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 해일 스윕 — 파도장에 거리·시간 함수의 국소 진폭 엔벨로프를 얹어 한 덩어리의 물이
// 밀려왔다 빠지는 서지를 그린다. 움직임은 전부 캔버스 rAF 루프 안에 있다.
// @keyframes 를 두지 않는다(077 방식). 색은 전부 토큰 또는 mix() 로컬 변수(BD26).
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$ss-crest: mix($subject-mint, $stage-paper, 72%);
$ss-crest2: mix($subject-sky, $stage-paper, 66%);
$ss-trough: mix($subject-blue-deep, $stage-paper, 52%);
$ss-trough2: mix($subject-blue, $stage-paper, 58%);
$ss-sink: mix($subject-blue-deep, black, 78%); // 썰물 — 가라앉은 깊은 남색
$duration: $dur-loop;
$easing: $ease-spring;
$color: $ss-crest;
@include stage($stage-paper);
.ss {
--crest: #{$ss-crest};
--crest-2: #{$ss-crest2};
--trough: #{$ss-trough};
--trough-2: #{$ss-trough2};
--sink: #{$ss-sink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ss__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.ss__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.ss__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $ss-trough2;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.ss__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.ss__cv { display: block; width: 100%; height: 100%; }
// 스윕 세기·바람 세기를 화면에도 적어 둔다 — frame() 이 갱신한다
.ss__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ss__badgeNum { font-size: 13px; color: $stage-yellow; }
.ss__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ss { gap: $sp-1; }
.ss__field { height: 124px; }
.ss__badge { font-size: 10px; padding: 0 $sp-1; }
.ss__badgeNum { font-size: 11px; }
.ss__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 서지가 살아 있는 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.ss.is-still .ss__field { cursor: default !important; }
.ss.is-still .ss__badge { opacity: .92 !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);
}
.ss {
--crest: rgb(49.4588235294%, 87.8588235294%, 74.8705882353%);
--crest-2: rgb(62.4705882353%, 85.7647058824%, 100%);
--trough: rgb(50.0392156863%, 64.1098039216%, 95.3098039216%);
--trough-2: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
--sink: rgb(3.0588235294%, 24.1647058824%, 70.9647058824%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ss__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.ss__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.ss__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.ss__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ss__cv {
display: block;
width: 100%;
height: 100%;
}
.ss__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ss__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ss__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ss {
gap: 4px;
}
.ss__field {
height: 124px;
}
.ss__badge {
font-size: 10px;
padding: 0 4px;
}
.ss__badgeNum {
font-size: 11px;
}
.ss__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ss.is-still .ss__field {
cursor: default !important;
}
.ss.is-still .ss__badge {
opacity: 0.92 !important;
}
}// 해일 スイープ — 01 과 같은 뿌리(정현파 6개 mulberry32(7))의 파도장에 거리·시간의 함수로
// 국소 진폭 엔벨로프를 얹는다. 엔벨로프 E(y,t) = 서지 파형(느린 정현파 하나)을 세로 거리로
// 밀어 올려 한 덩어리의 물이 아래에서 위(水平線 쪽)로 밀려가게 한다. 엔벨로프가 1보다 크면
// 그 구간 파도의 진폭이 부풀고(밀려오는 물), 지나간 뒤 1보다 낮게 꺼지면 수면이 가라앉는
// 썰물(드로백)까지 잡힌다. 파도 색 램프도 엔벨로프를 따라 밝아지고 어두워져 "물벽"이 읽힌다.
// スイープ 속도·진폭은 風 세기(자동 리사주 + 포인터 실측)와 연동된다.
var root = document.getElementById('ss');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var surgeVal = document.getElementById('surgeVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var CREST = [v('--crest'), v('--crest-2')]; // スイープ 마루(밀려오는 벽) — 민트
var TROUGH = [v('--trough'), v('--trough-2')]; // 골 — 파랑
var SINK = v('--sink'); // 썰물(가라앉은 수면) — 깊은 남색
var COLS = 110; // 파도장 가로 칸 수(01 과 동일)
var STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수(공통 물리)
var SURGE_LAM = 0.9; // 서지 엔벨로프의 파장(판 높이 비율) — 화면보다 길다
var SURGE_GAIN = 1.9;// 엔벨로프 최대 부풀림 — 진폭이 최대 2.9배까지
var SURGE_OM = 0.5; // 서지 각속도(×風 세기) — 한 스윕이 약 12초에 화면을 지난다
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null; // 파도장 높이(엔벨로프 부풀림 반영)
var env = null; // スイープ 엔벨로프(행별 0.2~2.9)
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0;
var wind = 1.0, windT = 1.0;
var windAng = 0.5, windAngT = 0.5;
var surge = 1.0; // 표시용 スイープ 세기(엔벨로프 최대값)
var lastPX = -1, lastPY = -1, lastPT = 0;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var C0 = hex2rgb(CREST[0]), C1 = hex2rgb(CREST[1]);
var T0 = hex2rgb(TROUGH[0]), T1 = hex2rgb(TROUGH[1]);
var SK = hex2rgb(SINK);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(40, Math.round(COLS * H / W));
var n = COLS * ROWS;
hgt = new Float32Array(n);
env = new Float32Array(ROWS);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 파도장 + 서지 엔벨로프 — 행(세로 위치)마다 엔벨로프를 먼저 계산:
// E(y) = 1 + SURGE_GAIN·max(0, sin((y/ROWS·2π/SURGE_LAM) - t·ω·wind))² … 밀려오는 물
// 그리고 지나간 자리를 1 아래로 살짝 눌러 썰물(드로백)을 만든다.
// 행의 엔벨로프가 그 행 파도 전체의 진폭과 밝기를 함께 부풀린다 — 물이 덩어리로 움직인다.
function fieldcalc(t, wstr, wang) {
// 엔벨로프 — 아래(근접)에서 위(水平線)로 이동하는 느린 파형. 바람이 세면 빠르고 높다
var om = SURGE_OM * (0.55 + wind * 0.75);
var i = 0;
for (var y = 0; y < ROWS; y++) {
var yn = y / ROWS;
var sw = Math.sin(yn * Math.PI * 2 / SURGE_LAM - t * om + 0.7);
var crest = sw > 0 ? sw * sw : 0; // 밀려오는 물벽(양의 반파만)
var dip = sw < 0 ? -sw * sw * 0.42 : 0; // 지나간 뒤 가라앉는 썰물
var e = 1 + crest * SURGE_GAIN * (0.55 + wind * 0.5) - dip;
env[y] = e;
for (var x = 0; x < COLS; x++, i++) {
var h = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
h += WV.amp * wstr * Math.sin(ph);
}
// 엔벨로프가 진폭을 부풀리고 바깥으로도 살짝 민다 — 물이 위로 볼록해진다
hgt[i] = h * e + (e - 1) * 0.34;
}
}
}
// 렌더 — 높이→색 램프. 엔벨로프 > 1(밀려옴)이면 마루 민트로, < 1(썰물)이면 골 색을
// 깊은 남색(SINK)으로 더 끌어내려 수면이 잠기는 인상을 준다.
function render() {
var o = out.data;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
var e = env[y];
for (var x = 0; x < COLS; x++) {
var i = row + x;
var h = hgt[i];
var u = h * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
var r, g, b;
if (u > 0.5) {
var k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
var k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
// 엔벨로프 컬러 — 밀려오는 벽은 밝게, 빠지는 썰물은 SINK 로 가라앉는다
if (e > 1) {
var up = Math.min(1, (e - 1) * 0.55);
r += (C0[0] - r) * up * 0.4;
g += (C0[1] - g) * up * 0.4;
b += (C0[2] - b) * up * 0.4;
} else {
var dn = Math.min(1, (1 - e) * 0.85);
r += (SK[0] - r) * dn;
g += (SK[1] - g) * dn;
b += (SK[2] - b) * dn;
}
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
fieldcalc(nowSec, wind, windAng);
render();
// 표시용 スイープ 세기 — 현재 엔벨로프 최대값
var mx = 0;
for (var y = 0; y < ROWS; y++) if (env[y] > mx) mx = env[y];
surge = mx;
surgeVal.textContent = 'スイープ ' + surge.toFixed(1);
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
// 정지판 — 루프 없이 시드가 정한 한 시각의 서지가 살아 있는 정적 한 장
function still() {
fieldcalc(7.3, 1.35, 0.5);
render();
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
if (lastPX >= 0 && t > lastPT) {
var vx = p[0] - lastPX, vy = p[1] - lastPY;
var sp2 = Math.sqrt(vx * vx + vy * vy);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(vy, vx) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
windT = Math.max(0.35, Math.min(2.1, windT - d[1] * 0.18));
windAngT = windAngT + d[0] * 0.1;
if (reduce) { fieldcalc(nowSec || 3.1, windT, windAngT); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// SweepSurge — vanilla 판(run/313/demo/08-ocean-sweep-surge/index.html) 의 React 포트.
// 01(GerstnerSum)과 같은 뿌리(정현파 6개 mulberry32(7))의 파도장에 행마다 이동하는
// 서지 엔벨로프(느린 정현파 하나)를 얹어 물벽이 밀려왔다 빠지는 것을 시뮬레이션한다.
// 엔벨로프는 진폭·밝기를 함께 부풀리고, 지나간 자리는 SINK 색으로 가라앉는다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SweepSurge.module.scss";
export type SweepSurgeProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
const COLS = 110;
const STEP = 3;
const NW = 6;
const SURGE_LAM = 0.9;
const SURGE_GAIN = 1.9;
const SURGE_OM = 0.5;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function SweepSurge({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#8fdac2",
}: SweepSurgeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const surgeValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
env: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
lastPX: number; lastPY: number; lastPT: number;
c0: [number, number, number] | null;
c1: [number, number, number] | null;
t0: [number, number, number] | null;
t1: [number, number, number] | null;
sk: [number, number, number] | null;
};
const stateRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null as Float32Array | null,
env: null as Float32Array | null,
out: null as ImageData | null,
bctx: null as CanvasRenderingContext2D | null,
buf: null as HTMLCanvasElement | null,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
lastPX: -1, lastPY: -1, lastPT: 0,
c0: null as [number, number, number] | null,
c1: null as [number, number, number] | null,
t0: null as [number, number, number] | null,
t1: null as [number, number, number] | null,
sk: null as [number, number, number] | null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stateRef.current;
st.rows = Math.max(40, Math.round(COLS * r.height / r.width));
const n = COLS * st.rows;
st.hgt = new Float32Array(n);
st.env = new Float32Array(st.rows);
st.rw = COLS * STEP;
st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
function fieldcalc(t: number, wstr: number, wang: number): void {
const st = stateRef.current;
const WV = wavesRef.current;
if (!st.hgt || !st.env || !WV) return;
const om = SURGE_OM * (0.55 + st.wind * 0.75);
let i = 0;
for (let y = 0; y < st.rows; y++) {
const yn = y / st.rows;
const sw = Math.sin((yn * Math.PI * 2) / SURGE_LAM - t * om + 0.7);
const crest = sw > 0 ? sw * sw : 0;
const dip = sw < 0 ? -sw * sw * 0.42 : 0;
const e = 1 + crest * SURGE_GAIN * (0.55 + wstr * 0.5) - dip;
st.env[y] = e;
for (let x = 0; x < COLS; x++, i++) {
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 + t * wv.om + wv.ph;
h += wv.amp * wstr * Math.sin(ph);
}
st.hgt[i] = h * e + (e - 1) * 0.34;
}
}
}
function render(): void {
const st = stateRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt || !st.env) return;
if (!st.c0 || !st.c1 || !st.t0 || !st.t1 || !st.sk) return;
const o = st.out.data;
const C0 = st.c0, C1 = st.c1, T0 = st.t0, T1 = st.t1, SK = st.sk;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
const e = st.env[y];
for (let x = 0; x < COLS; x++) {
const i = row + x;
let u = st.hgt[i] * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
let r: number, g: number, b: number;
if (u > 0.5) {
const k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
const k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
if (e > 1) {
const up = Math.min(1, (e - 1) * 0.55);
r += (C0[0] - r) * up * 0.4;
g += (C0[1] - g) * up * 0.4;
b += (C0[2] - b) * up * 0.4;
} else {
const dn = Math.min(1, (1 - e) * 0.85);
r += (SK[0] - r) * dn;
g += (SK[1] - g) * dn;
b += (SK[2] - b) * dn;
}
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stateRef.current;
const sec = now / 1000;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
fieldcalc(sec, st.wind, st.windAng);
render();
let mx = 0;
if (st.env) for (let y = 0; y < st.rows; y++) if (st.env[y] > mx) mx = st.env[y];
if (surgeValRef.current) surgeValRef.current.textContent = "스윕 " + mx.toFixed(1);
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stateRef.current;
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stateRef.current.auto = true;
stateRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
fieldcalc(7.3, 1.35, 0.5);
render();
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stateRef.current;
st.c0 = hex2rgb(tok("--crest"));
st.c1 = hex2rgb(tok("--crest-2"));
st.t0 = hex2rgb(tok("--trough"));
st.t1 = hex2rgb(tok("--trough-2"));
st.sk = hex2rgb(tok("--sink"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
fieldcalc(7.3, 1.35, 0.5);
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>긴 파장의 해일 파형이 수면을 타고 지나가며 수위를 밀어올립니다 — 마우스를 빠르게 그으면 스윕이 더 세게 옵니다</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={surgeValRef}>스윕 1.0</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}08 · 津波スイープ — 押し寄せる波の壁 — 計測 累積面積 60.026% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
波頂は膨らませ波谷はあまり沈めない非対称エンベロープ
// エンベロープ — 下(手前)から上(水平線)へ移動するゆっくりした波形
var om = SURGE_OM * (0.55 + wind * 0.75);
var yn = y / ROWS;
var sw = Math.sin(yn * Math.PI * 2 / SURGE_LAM - t * om + 0.7);
var crest = sw > 0 ? sw * sw : 0; // 押し寄せる水の壁(正の半波のみ)
var dip = sw < 0 ? -sw * sw * 0.42 : 0; // 通り過ぎた後に沈む引き波
var e = 1 + crest * SURGE_GAIN * (0.55 + wind * 0.5) - dip;
env[y] = e;
// エンベロープが振幅を膨らませ、外側にも少し押す — 水が上に膨らむ
hgt[i] = h * e + (e - 1) * 0.34;
波頂と波谷に同じ係数を掛けると水が同じ幅で上下するだけの呼吸に留まりますが、波谷の沈み込みを0.42倍に抑えると片方へ押し寄せる力が感じられ、風が強まるほどサージ周期(om)も速くなります。
09水平線のかすみ — 遠景を霞ませる
ピクセルの水平線からの距離に比例して水色を空色と混ぜ、振幅も抑えて遠景を霞ませる、旅行・瞑想・ムードアプリ向けシーンです(実測: 累積面積34.095% — 画面の半分が空なので低いのが正常です)。

<main class="stage">
<div class="hh" id="hh">
<p class="hh__hint">
<svg class="hh__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>水平線から遠いほど水色を空色に混ぜ振幅も抑えて空気遠近法を作ります — マウスで水平線を上下に動かせます</span>
</p>
<div class="hh__field" id="field" role="application" aria-label="水平線のかすみお試しパネル" tabindex="0">
<canvas class="hh__cv" id="cv"></canvas>
<b class="hh__badge" aria-hidden="true"><span class="hh__badgeNum" id="horizonVal">水平線 34%</span><span class="hh__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 수평선 안개 — 원근 압축(수평선 근처는 잦은 주파수·낮은 진폭) + 거리 기반 안개 블렌드로
// 공기원근을 만든다. 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 를 두지 않는다
// (077 방식). 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$hh-crest: mix($subject-mint, $stage-paper, 72%);
$hh-crest2: mix($subject-sky, $stage-paper, 66%);
$hh-trough: mix($subject-blue-deep, $stage-paper, 52%);
$hh-trough2: mix($subject-blue, $stage-paper, 58%);
$hh-sky: mix($subject-lilac, $stage-paper, 58%); // 하늘 위쪽 — 옅은 라일락
$hh-sky2: mix($subject-sky, $stage-paper, 22%); // 하늘 수평선 쪽 — 밝은 하늘(안개 색)
$duration: $dur-loop;
$easing: $ease-spring;
$color: $hh-sky2;
@include stage($stage-paper);
.hh {
--crest: #{$hh-crest};
--crest-2: #{$hh-crest2};
--trough: #{$hh-trough};
--trough-2: #{$hh-trough2};
--sky: #{$hh-sky};
--sky-2: #{$hh-sky2};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.hh__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.hh__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.hh__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $hh-sky2;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.hh__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.hh__cv { display: block; width: 100%; height: 100%; }
// 수평선 위치·바람 세기를 화면에도 적어 둔다 — frame() 이 갱신한다
.hh__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.hh__badgeNum { font-size: 13px; color: $stage-yellow; }
.hh__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.hh { gap: $sp-1; }
.hh__field { height: 124px; }
.hh__badge { font-size: 10px; padding: 0 $sp-1; }
.hh__badgeNum { font-size: 11px; }
.hh__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 안개·파도가 살아 있는 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.hh.is-still .hh__field { cursor: default !important; }
.hh.is-still .hh__badge { opacity: .92 !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);
}
.hh {
--crest: rgb(49.4588235294%, 87.8588235294%, 74.8705882353%);
--crest-2: rgb(62.4705882353%, 85.7647058824%, 100%);
--trough: rgb(50.0392156863%, 64.1098039216%, 95.3098039216%);
--trough-2: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
--sky: rgb(84.0784313725%, 78.3921568627%, 100%);
--sky-2: rgb(87.4901960784%, 95.2549019608%, 100%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.hh__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.hh__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.hh__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(87.4901960784%, 95.2549019608%, 100%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.hh__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.hh__cv {
display: block;
width: 100%;
height: 100%;
}
.hh__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.hh__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.hh__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.hh {
gap: 4px;
}
.hh__field {
height: 124px;
}
.hh__badge {
font-size: 10px;
padding: 0 4px;
}
.hh__badgeNum {
font-size: 11px;
}
.hh__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.hh.is-still .hh__field {
cursor: default !important;
}
.hh.is-still .hh__badge {
opacity: 0.92 !important;
}
}// 水平線 안개 — 01 과 같은 뿌리(정현파 6개 mulberry32(7))의 파도장에 두 겹을 얹는다.
// ① 원근 감쇠 — 수평선에 가까운 행일수록 공간 주파수를 높이고(잦은 잔물결) 진폭을 낮춰
// "멀리 있는 파도는 잘게 부서져 보인다"는 원근 압축을 흉내낸다.
// ② 안개 블렌드(공기원근) — 픽셀의 수평선까지 남은 거리에 비례해 물색을 하늘색과 섞는다.
// 가까울수록(화면 아래) 안개가 거의 없고, 수평선에 닿으면 거의 하늘색 한 가지로 뭉갠다.
// 水平線 위쪽은 하늘(고정 그라디언트 두 톤), 水平線 자체는 포인터로 위아래 이동한다.
var root = document.getElementById('hh');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var horizonVal = document.getElementById('horizonVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var CREST = [v('--crest'), v('--crest-2')]; // 마루 — 민트 계열
var TROUGH = [v('--trough'), v('--trough-2')]; // 골 — 파랑 계열
var SKY = [v('--sky'), v('--sky-2')]; // 하늘 — 위 옅은 라일락 → 水平線 쪽 밝은 하늘
var COLS = 110; // 파도장 가로 칸 수(01 과 동일)
var STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수(공통 물리)
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null; // 파도장 높이(원근 압축 반영)
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0;
var wind = 1.0, windT = 1.0;
var windAng = 0.5, windAngT = 0.5;
var horizon = 0.34, horizonT = 0.34; // 水平線 위치(판 세로 비율, 0=위 1=아래)
var lastPX = -1, lastPY = -1, lastPT = 0;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var C0 = hex2rgb(CREST[0]), C1 = hex2rgb(CREST[1]);
var T0 = hex2rgb(TROUGH[0]), T1 = hex2rgb(TROUGH[1]);
var SK0 = hex2rgb(SKY[0]), SK1 = hex2rgb(SKY[1]);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(40, Math.round(COLS * H / W));
var n = COLS * ROWS;
hgt = new Float32Array(n);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 파도장 — 행마다 "수평선까지 남은 거리"로 원근 압축을 건다.
// horizonRow 보다 위(작은 y)는 바다가 아니라 하늘이므로 계산을 건너뛴다.
// dist = 1(맨 아래·가장 가까움) .. 0(水平線 바로 아래·가장 멂)
// 공간 주파수는 (1 + (1-dist)*2.4)배로 빨라지고, 진폭은 (0.15 + dist*0.85)배로 준다.
function fieldcalc(t, wstr, wang, horizonRow) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
if (y < horizonRow) { i += COLS; continue; }
var dist = ROWS > horizonRow ? (y - horizonRow) / (ROWS - horizonRow) : 1;
if (dist > 1) dist = 1; else if (dist < 0) dist = 0;
var freqMul = 1 + (1 - dist) * 2.4;
var ampMul = 0.15 + dist * 0.85;
for (var x = 0; x < COLS; x++, i++) {
var h = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 * freqMul + t * WV.om + WV.ph;
h += WV.amp * wstr * ampMul * Math.sin(ph);
}
hgt[i] = h;
}
}
}
// 렌더 — 水平線 위는 하늘 그라디언트, 아래는 파도 색 램프 뒤 거리 기반 안개(하늘색 블렌드)를 얹는다.
// 안개 강도 fog = (1-dist)^1.6 — 水平線 바로 아래가 거의 하늘색, 화면 아래는 안개가 거의 없다.
function render(horizonRow) {
var o = out.data;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
if (y < horizonRow) {
// 하늘 — 위(짙은 라일락)에서 水平線(밝은 하늘)으로 램프
var sk = horizonRow > 0 ? y / horizonRow : 0;
var sr = SK0[0] + (SK1[0] - SK0[0]) * sk;
var sg = SK0[1] + (SK1[1] - SK0[1]) * sk;
var sb = SK0[2] + (SK1[2] - SK0[2]) * sk;
for (var x2 = 0; x2 < COLS; x2++) {
for (var sy2 = 0; sy2 < STEP; sy2++) {
var base2 = ((y * STEP + sy2) * RW + x2 * STEP) * 4;
for (var sx2 = 0; sx2 < STEP; sx2++, base2 += 4) {
o[base2] = sr; o[base2 + 1] = sg; o[base2 + 2] = sb; o[base2 + 3] = 255;
}
}
}
continue;
}
var dist = ROWS > horizonRow ? (y - horizonRow) / (ROWS - horizonRow) : 1;
if (dist > 1) dist = 1; else if (dist < 0) dist = 0;
var fog = Math.pow(1 - dist, 1.6);
for (var x = 0; x < COLS; x++) {
var i = row + x;
var h = hgt[i];
var u = h * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
var r, g, b;
if (u > 0.5) {
var k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
var k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
// 안개 — 水平線 색(SK1, 하늘의 水平線 쪽 톤)과 섞는다
r += (SK1[0] - r) * fog;
g += (SK1[1] - g) * fog;
b += (SK1[2] - b) * fog;
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
horizonT = 0.34 + Math.sin(th * 0.22) * 0.14; // 수평선도 천천히 오르내려 화면이 산다
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
horizon += (horizonT - horizon) * 0.05;
var hr = Math.round(horizon * ROWS);
fieldcalc(nowSec, wind, windAng, hr);
render(hr);
horizonVal.textContent = '水平線 ' + Math.round(horizon * 100) + '%';
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
// 정지판 — 루프 없이 시드가 정한 한 시각의 안개·파도가 살아 있는 정적 한 장
function still() {
var hr = Math.round(0.34 * ROWS);
fieldcalc(7.3, 1.35, 0.5, hr);
render(hr);
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
// 포인터 세로 위치 = 水平線 목표(위쪽 65%까지만 — 바다가 항상 화면 아래를 채우게)
horizonT = Math.max(0.06, Math.min(0.68, p[1] / H));
if (lastPX >= 0 && t > lastPT) {
var vx = p[0] - lastPX, vy = p[1] - lastPY;
var sp2 = Math.sqrt(vx * vx + vy * vy);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(vy, vx) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
horizonT = Math.max(0.06, Math.min(0.68, horizonT + d[1] * 0.05));
windT = Math.max(0.35, Math.min(2.1, windT + d[0] * 0.15));
if (reduce) { var hr = Math.round(horizonT * ROWS); fieldcalc(nowSec || 3.1, windT, windAngT, hr); render(hr); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// HorizonHaze — vanilla 판(run/313/demo/09-ocean-horizon-haze/index.html) 의 React 포트.
// 01(GerstnerSum)과 같은 뿌리(정현파 6개 mulberry32(7))의 파도장에 원근 압축(수평선에
// 가까울수록 잦은 주파수·낮은 진폭)과 거리 기반 안개 블렌드(공기원근)를 얹는다.
// 수평선 위는 하늘 그라디언트, 수평선 위치는 포인터 세로 좌표로 조절한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./HorizonHaze.module.scss";
export type HorizonHazeProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
const COLS = 110;
const STEP = 3;
const NW = 6;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function HorizonHaze({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#bcd9f2",
}: HorizonHazeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const horizonValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
horizon: number; horizonT: number;
lastPX: number; lastPY: number; lastPT: number;
c0: [number, number, number] | null;
c1: [number, number, number] | null;
t0: [number, number, number] | null;
t1: [number, number, number] | null;
sk0: [number, number, number] | null;
sk1: [number, number, number] | null;
};
const stateRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null as Float32Array | null,
out: null as ImageData | null,
bctx: null as CanvasRenderingContext2D | null,
buf: null as HTMLCanvasElement | null,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
horizon: 0.34, horizonT: 0.34,
lastPX: -1, lastPY: -1, lastPT: 0,
c0: null as [number, number, number] | null,
c1: null as [number, number, number] | null,
t0: null as [number, number, number] | null,
t1: null as [number, number, number] | null,
sk0: null as [number, number, number] | null,
sk1: null as [number, number, number] | null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stateRef.current;
st.rows = Math.max(40, Math.round(COLS * r.height / r.width));
const n = COLS * st.rows;
st.hgt = new Float32Array(n);
st.rw = COLS * STEP;
st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
function fieldcalc(t: number, wstr: number, wang: number, horizonRow: number): void {
const st = stateRef.current;
const WV = wavesRef.current;
if (!st.hgt || !WV) return;
let i = 0;
for (let y = 0; y < st.rows; y++) {
if (y < horizonRow) { i += COLS; continue; }
let dist = st.rows > horizonRow ? (y - horizonRow) / (st.rows - horizonRow) : 1;
if (dist > 1) dist = 1; else if (dist < 0) dist = 0;
const freqMul = 1 + (1 - dist) * 2.4;
const ampMul = 0.15 + dist * 0.85;
for (let x = 0; x < COLS; x++, i++) {
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 * freqMul + t * wv.om + wv.ph;
h += wv.amp * wstr * ampMul * Math.sin(ph);
}
st.hgt[i] = h;
}
}
}
function render(horizonRow: number): void {
const st = stateRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt) return;
if (!st.c0 || !st.c1 || !st.t0 || !st.t1 || !st.sk0 || !st.sk1) return;
const o = st.out.data;
const C0 = st.c0, C1 = st.c1, T0 = st.t0, T1 = st.t1, SK0 = st.sk0, SK1 = st.sk1;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
if (y < horizonRow) {
const sk = horizonRow > 0 ? y / horizonRow : 0;
const sr = SK0[0] + (SK1[0] - SK0[0]) * sk;
const sg = SK0[1] + (SK1[1] - SK0[1]) * sk;
const sb = SK0[2] + (SK1[2] - SK0[2]) * sk;
for (let x2 = 0; x2 < COLS; x2++) {
for (let sy2 = 0; sy2 < STEP; sy2++) {
let base2 = ((y * STEP + sy2) * st.rw + x2 * STEP) * 4;
for (let sx2 = 0; sx2 < STEP; sx2++, base2 += 4) {
o[base2] = sr; o[base2 + 1] = sg; o[base2 + 2] = sb; o[base2 + 3] = 255;
}
}
}
continue;
}
let dist = st.rows > horizonRow ? (y - horizonRow) / (st.rows - horizonRow) : 1;
if (dist > 1) dist = 1; else if (dist < 0) dist = 0;
const fog = Math.pow(1 - dist, 1.6);
for (let x = 0; x < COLS; x++) {
const i = row + x;
let u = st.hgt[i] * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
let r: number, g: number, b: number;
if (u > 0.5) {
const k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
const k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
r += (SK1[0] - r) * fog;
g += (SK1[1] - g) * fog;
b += (SK1[2] - b) * fog;
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stateRef.current;
const sec = now / 1000;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
st.horizonT = 0.34 + Math.sin(th * 0.22) * 0.14;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
st.horizon += (st.horizonT - st.horizon) * 0.05;
const hr = Math.round(st.horizon * st.rows);
fieldcalc(sec, st.wind, st.windAng, hr);
render(hr);
if (horizonValRef.current) horizonValRef.current.textContent = "수평선 " + Math.round(st.horizon * 100) + "%";
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stateRef.current;
st.horizonT = Math.max(0.06, Math.min(0.68, py / sizeRef.current.h));
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stateRef.current.auto = true;
stateRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
const st = stateRef.current;
const hr = Math.round(0.34 * st.rows);
fieldcalc(7.3, 1.35, 0.5, hr);
render(hr);
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stateRef.current;
st.c0 = hex2rgb(tok("--crest"));
st.c1 = hex2rgb(tok("--crest-2"));
st.t0 = hex2rgb(tok("--trough"));
st.t1 = hex2rgb(tok("--trough-2"));
st.sk0 = hex2rgb(tok("--sky"));
st.sk1 = hex2rgb(tok("--sky-2"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
const hr = Math.round(0.34 * st.rows);
fieldcalc(7.3, 1.35, 0.5, hr);
render(hr);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>수평선에서 멀수록 물색을 하늘빛과 섞고 파도 진폭도 눌러 공기원근을 만듭니다 — 마우스로 수평선을 위아래로 옮기세요</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={horizonValRef}>수평선 34%</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}09 · 水平線のかすみ — 遠景を霞ませる — 計測 累積面積 34.095% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
距離ひとつで周波数・振幅・色をまとめて抑える計算
function fieldcalc(t, wstr, wang, horizonRow) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
if (y < horizonRow) { i += COLS; continue; } // 水平線より上は空 — 計算せず飛ばす
var dist = ROWS > horizonRow ? (y - horizonRow) / (ROWS - horizonRow) : 1;
if (dist > 1) dist = 1; else if (dist < 0) dist = 0;
var freqMul = 1 + (1 - dist) * 2.4; // 近いほど波長が狭く見える
var ampMul = 0.15 + dist * 0.85; // 遠いほど振幅を抑えて霞に沈める
for (var x = 0; x < COLS; x++, i++) {
// 本計算は01と同じで、freqMul・ampMulだけ掛けて入れる
}
}
}
無理に波の面積を増やそうとすると水平線より上(空)にまで波を描き込む不自然なシーンになりますが、振幅の抑制と色の混合を同じ距離値でまとめると遠景がひとりでに沈み、ポインタの縦位置が水平線の高さ(horizonRow)の目標になります。
正弦波を重ねるとどうやって海になるのか?
正弦波をひとつだけ描くと規則正しい縞模様にしかならず、海には見えません。海らしく見せるには波長・方向・速度がそれぞれ違う正弦波を何本も同じグリッドの上に重ねて足し、波頂と波谷を不規則にずらして自然な表面を作る必要があります。市販の海 アニメーション素材の多くも仕組みは同じで、規則的な1本の波を隠すために複数の波をレイヤーとして重ねています。01のWAVES配列は6本の波を作りながら、波長は34から始めて毎回0.55倍ずつ短くし、振幅はインデックスが大きくなるほど0.58/(i*0.62+1)で小さくしていきます — 大きな波が全体の形を決め、小さな波がその上に細波を乗せる構造です。実際の波の重なりをもっと物理的に近づけるにはサイン関数そのものを傾けるガーストナーのずれが要り、この波 アニメーション 作り方では各波の方向ベクトル(dirX)にコサイン項を掛けて、波頂付近の水が進行方向へわずかに寄るようにしています — 頂点が尖り谷がなだらかな実際の波の非対称さを近似する値です。
サインとコサインを対にする理由
高さはMath.sin(ph)で計算しますが、水平方向のずれはMath.cos(ph)で計算します。サインとコサインは位相が90度ずれているため、高さが最も高い地点(サインが最大)ではコサインがほぼ0になり、高さが中間の地点でコサインが最大になります。このずれのおかげで波頂は上に盛り上がりながら前へ寄り、波谷は横にだけ広がるので、単純なサイン波の重ね合わせよりずっと本物の波らしい形になります。
波長が短くなるほど振幅も落とす理由
波長を縮め続けるだけだと、短い波が画面を細かい縞模様で埋め尽くしてしまいます。01でインデックスが大きくなるほど(波長が短くなるほど)振幅も一緒に落としているのはこのためです — 実際の海でも短い波(さざ波)は浅く、長い波(うねり)は深く動きます。この比率を逆にすると画面がノイズのようにチラつき、比率を開きすぎるとさざ波が見えなくなって表面が単調になります。
6本で足りるのか、もっと必要か
波の本数を増やすほど表面は複雑になりますが、ピクセルごとに回る内側のループもその分増えて、フレームあたりの計算量は本数に比例して増えていきます。01は6本で形と速度のバランスを取っており、09のように画面の大半が霞に沈むシーンでは本数を増やしても目に見える違いが小さいためそのまま使い回しています。波の本数はこのあとのバリエーション節で最初に触れる調整点でもあります。
どこで壊れるか — 15.2%から60.8%まで直した4か所
01を最初にレンダリングしたとき、累積面積はわずか15.2%しか出ませんでした。コードはエラーもなく動き、画面には波も見えていたのに、測定値を見て初めてほとんどのフレームで画面がほぼ静止していたことに気づきました。原因はひとつではなく4か所に重なっており、そのうちひとつを直して初めて次が見えてくる、という順番で剥がれていきました。
1つ目 — STEPブロックを連番インデックスで埋めた箇所
低解像度グリッド(縦横STEP マスずつまとめたブロック)を実際のバッファへ引き伸ばして描く際、最初はループを回る順番のままカウンタをひとつずつ増やしてアドレスを取っていました。グリッドマスの並び順と、バッファの実際の行・列の並び順がずれていることに気づいていなかったのです。画面の下側へ行くほど波が裂けてずれて見える現象として現れましたが、縦方向に数行ずれるだけでも見た目には大きく跳ねます。
2つ目 — sassがrgbをパーセントで吐く箇所
2つ目は色が出ない問題でした。sassのmix()関数が色をrgb(52.9%, 78.1%, 69%)のようにパーセント単位でコンパイルしていたのに対し、色を配列に変換するhex2rgb関数は16進文字列しか読めないよう書かれていました。マッチしなくても例外を投げず黙って黒(0,0,0)を返す作りだったため、コンソールにはエラーが一切出ず画面だけが真っ黒に死んでいました。正規表現でパーセント形式も認識し、値に2.55を掛ける変換を入れて初めて色が生き返りました。
3つ目 — 空間周波数にすでに入っている2πを位相でもう一度掛けた箇所
3つ目は波長の計算でした。空間周波数をfreq = 2π/λとして定義しておきながら、位相を計算する箇所で習慣的に係数をもう一度掛けてしまっていたのです。結果として実際の波長がグリッドの領域よりずっと長くなり、画面の中では波がほとんど平らな傾斜にしか見えませんでした。領域の大きさと波長の比率を合わせるスケール係数(0.62)を位相計算に掛け込んで初めて、グリッドの中に複数の波頂と波谷が収まります。
4つ目 — 色ランプの両端が一度も現れなかった箇所
面積を最も大きく引き上げたのは4つ目でした。合成した高さはおおよそ-0.75から0.75の間に収まりますが、この値をそのまま0〜1の色ランプ座標に移すと実際には0.15〜0.85の区間にしか値が集まらず、ランプの両端の色(最も明るい波頂の色と最も深い波谷の色)が画面に一度も現れていませんでした。色が中間トーンにばかり固まっていたので波が動いても明暗差が小さく、測定スクリプトが「変化なし」と判定したフレームが大量に残っていたのです。正規化のスケールをh * 1.24 + 0.5に広げてクランプが両端まで届くように直したところ、累積面積は60.795%まで上がりました。この修理の経験をそのまま持ち越すなら — アニメーションが「遅い」「動かない」という印象は、フレーム数ではなく値の範囲が色ランプの端に届いていないことが原因である場合がよくあります。
修理の順番と根拠はこの記事のzipの中にもまとめてあり、解凍するときに入力するパスワードはjy92b5caです。
3つのバリエーションとアクセシビリティ
同じ波の場の計算の上で、値を3つ変えるだけでまったく違うムードになります。
| バリエーション | 変える値 | 結果 |
|---|---|---|
| 波の本数を減らす | NWを6から3へ |
波がまばらになり、静かな湖に近い表面になる |
| 波頂の閾値を緩める | thresholdForの上限を0.82から0.55へ |
泡が点ではなく帯状に広く広がる |
| パレットの入れ替え | $ocean-mintをライラック系へ |
昼の海から夕焼けの海のムードへ |
3つのバリエーション
波の本数を減らすと計算コストも同時に下がるので、モバイル向けに軽くしたいときはまずこの値を疑うのが手っ取り早い調整です。閾値の変更はthresholdFor関数の返り値の範囲だけをいじれば済み、レイアウトや他の項目には影響しません。パレットの入れ替えはdemo-tokensのSCSS変数を差し替えるだけで、01〜09のすべてのシーンに一括で反映されます。
アクセシビリティ
動く画面である以上、基本として3つを押さえました。prefers-reduced-motion: reduceが検知されるとループを止め、still()関数で計算した静止フレーム1枚だけを描き、波のある画面であることは見せつつ動きはなくします。キャンバスにはtabindexを与え、フォーカスが入ると矢印キーで風の強さと向きを調整できるようにして、マウスがなくても操作が途切れません。キャンバス要素にはaria-labelでシーンを説明し、スクリーンリーダーの利用者にもこのキャンバス 海 アニメーションが何を描く画面なのかが伝わるようにしています。
FAQ
波 アニメーション 作り方でグリッドの解像度はどこまで下げても大丈夫ですか?
この9項目はすべて低解像度グリッドで計算してからdrawImageで拡大して描く方式を使っています。グリッドを実際のピクセル数と同じ密度で組むと計算量が急激に増えますが、波のように大きな塊で動く表面はグリッドを粗く取ってSTEPブロック単位で拡大しても目に違和感が出ません。どこまで粗くできるかは画面サイズと波長の長さによって変わるので、拡大倍率を2倍・4倍と変えながら目で確認するのが安全です。
動く波の背景をページのヒーローに置いても性能に問題は出ませんか?
キャンバスひとつでレンダリングしライブラリを使わないため重さ自体は軽いですが、ページの最上部に置く場合はprefers-reduced-motionを必ず尊重し、タブがバックグラウンドに回ったときにrequestAnimationFrameループを止める処理を加えるのが望ましいです。この9つのシーンに標準で入っているのはprefers-reduced-motionへの対応だけで、タブがバックグラウンドに回ったときの停止はブラウザのrequestAnimationFrameスロットリングに任せています。実サービスに移すときはvisibilitychangeで明示的にループを止める処理を足すのが安全です。
9項目をReactプロジェクトにそのまま移せますか?
移せます。各項目はキャンバスひとつとrequestAnimationFrameループ、いくつかの純粋関数だけで構成されているので、useEffectの中でキャンバスのrefを取り、同じ計算関数をそのまま持っていけば動きます。zipの中にはバニラ版と並んでReactに移植した版も入っており、コンポーネント構成の参考にできます。








