08河川侵食 — 最急降下の流線
2次元fbm高さマップ上で開始点ごとに隣接セルの最も低い方へ1マスずつ進む最急降下を繰り返し、lineTo で川筋を描画。シードが変わると別の谷へ分岐します。
使いどころ地理・環境・データストーリーテリングの挿絵
fbmlineTorequestAnimationFrame

<main class="stage">
<div class="tre" id="tre">
<p class="tre__hint">
<svg class="tre__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M112 56a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M105.46,210.4l-67-144.87a8,8,0,0,1,8.93-11.25L215.75,86A8,8,0,0,1,219,99.62l-107.52,47.12,15.4,67.6a8,8,0,0,1-15.53,4.6Z" opacity=".2"/><path d="M30.93,48.81a16,16,0,0,1,19.4-6.36l154.72,61.76a16,16,0,0,1-.77,29.84l-107.5,47.12L74.4,248.16a16,16,0,0,1-29.51-5.95Z"/></g></svg>
<span>シードを変えると水路が別の谷へ分かれます</span>
</p>
<div class="tre__field" id="field" role="img" aria-label="高度帯の地形を川が流れ下る浸食マップ">
<canvas class="tre__cv" id="cv"></canvas>
<b class="tre__badge" id="badge">水源 7か所</b>
</div>
<div class="tre__bar">
<label class="tre__dial">シード
<input type="number" id="seed" min="1" max="9999" step="1" value="7" aria-label="浸食シード">
</label>
<b class="tre__val" id="flowOut">追跡 0%</b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 강줄기 침식 — 2D fbm 높이맵 위 최급경사 추적(steepest descent). 각 시작점에서 이웃 8칸 중
// 값이 가장 낮은 칸으로 한 칸씩 내려가며 lineTo 로 강줄기를 긋는다. 시드마다 계곡의 모양이
// 달라져 물길이 다른 골짜기로 갈라진다. CSS 는 무대·판·다이얼만 — 움직임은 rAF 루프에 있다.
// 파스텔 — mix() 로 옅게 섞은 로컬 변수만 쓴다. 리터럴 hex 0개(BD26).
$terrain-sky: mix($subject-sky, $stage-paper, 34%);
$terrain-mint: mix($subject-mint, $stage-paper, 42%);
$terrain-blue: mix($subject-blue, $stage-paper, 34%);
$terrain-peach: mix($subject-orange-deep, $stage-paper, 30%);
$duration: $dur-loop;
$easing: $ease-out;
$color: $terrain-blue;
@include stage($stage-yellow);
.tre {
// 저지(밝은) → 고지(짙은) 순의 고도띠 6단 + 강줄기·시작점 색 — JS 가 커스텀 프로퍼티에서 읽는다
--c0: #{$stage-paper};
--c1: #{$terrain-sky};
--c2: #{$terrain-mint};
--c3: #{mix($terrain-mint, $subject-ink, 26%)};
--c4: #{mix($terrain-blue, $subject-ink, 38%)};
--c5: #{mix($subject-ink, $terrain-blue, 52%)};
--river: #{$subject-blue-deep};
--spring: #{$terrain-peach};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tre__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;
}
.tre__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.tre__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.tre__cv { display: block; width: 100%; height: 100%; }
.tre__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.tre__bar { display: flex; align-items: center; gap: $sp-3; flex-wrap: wrap; }
.tre__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.tre__dial input {
width: 72px; margin: 0;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-sm;
background: rgba($subject-ink, .08); color: $subject-ink;
font-weight: 700; font-size: 12px; font-family: inherit;
}
.tre__dial input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.tre__val { color: $subject-orange-deep; font-size: 11px; font-weight: 700; }
@media (max-width: 340px) {
.tre { gap: $sp-1; }
.tre__field { height: 100px; }
.tre__dial input { width: 56px; }
.tre__hint { font-size: 10px; }
.tre__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — 루프 없이 시드의 정적 한 장만 남는다(probe 가 이 프레임으로 시드 판정)
@media (prefers-reduced-motion: reduce) {
.tre.is-still .tre__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tre {
--c0: #ffffff;
--c1: rgb(80.6666666667%, 92.6666666667%, 100%);
--c2: rgb(70.5176470588%, 92.9176470588%, 85.3411764706%);
--c3: rgb(25.0090980392%, 29.9625098039%, 29.7338039216%);
--c4: rgb(33.0534901961%, 35.4654117647%, 43.8655686275%);
--c5: rgb(39.3781960784%, 42.7344313725%, 52.7259607843%);
--river: #0a4fe8;
--spring: rgb(93.6470588235%, 75.6470588235%, 70%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.tre__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;
}
.tre__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.tre__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.tre__cv {
display: block;
width: 100%;
height: 100%;
}
.tre__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.tre__bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.tre__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.tre__dial input {
width: 72px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 12px;
font-family: inherit;
}
.tre__dial input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.tre__val {
color: #c93000;
font-size: 11px;
font-weight: 700;
}
@media (max-width: 340px) {
.tre {
gap: 4px;
}
.tre__field {
height: 100px;
}
.tre__dial input {
width: 56px;
}
.tre__hint {
font-size: 10px;
}
.tre__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.tre.is-still .tre__field {
cursor: default !important;
}
}// 강줄기 침식 — 2D fbm 높이맵 위 최급경사(steepest descent) 추적.
// ① 격자(46×28)마다 fbm 높이를 구해 고도띠 6단으로 지형을 칠한다.
// ② 시작점(水源)마다 이웃 8칸 중 값이 가장 낮은 칸으로 한 칸씩 내려가는 추적을 반복해
// 그 경로를 lineTo 로 이어 강줄기를 긋는다 — 계곡을 따라 굽이치는 건 노이즈가 아니라
// "어디가 더 낮은가"라는 계산의 결과다(077 기준선: 계산 한 겹).
// ③ 시간에는 노이즈 샘플링 창(z)을 천천히 흘려 산이 다시 태어나면 물길도 같이 다시 판다.
// 물길은 매 프레임 처음부터 다시 추적한다 — 캐시 없이, 침식이 살아 움직이는 모습을 그대로.
var root = document.getElementById('tre');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var seedIn = document.getElementById('seed');
var badge = document.getElementById('badge');
var flowOut = document.getElementById('flowOut');
var GW = 46, GH = 28; // 높이맵 격자 — 추적은 이 칸 단위로 일어난다
var heights = new Float32Array(GW * GH); // 한 프레임 동안 쓰는 높이 버퍼(추적·채색 함께)
// ── 결정적 난수 — mulberry32. シード 하나 → 같은 테이블 ────────────────────────────
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var PERM = new Uint8Array(512);
var VAL = new Float32Array(256);
function buildTables(seed) {
var rng = mulberry32(seed >>> 0);
var p = [], i, j, t;
for (i = 0; i < 256; i++) p[i] = i;
for (i = 255; i > 0; i--) { j = (rng() * (i + 1)) | 0; t = p[i]; p[i] = p[j]; p[j] = t; }
for (i = 0; i < 512; i++) PERM[i] = p[i & 255];
for (i = 0; i < 256; i++) VAL[i] = rng();
}
function fade(t) { return t * t * (3 - 2 * t); } // 스무스스텝 — 격자 경계에서 각이 안 지게
// 3차원 값 노이즈(x·y·z — z 가 시간축). 트리플린어 보간
function vnoise(x, y, z) {
var xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
var xf = x - xi, yf = y - yi, zf = z - zi;
xi &= 255; yi &= 255; zi &= 255;
var u = fade(xf), v = fade(yf), w = fade(zf);
var a = PERM[xi] + yi, b = PERM[xi + 1] + yi;
var aa = PERM[a] + zi, ab = PERM[a + 1] + zi;
var ba = PERM[b] + zi, bb = PERM[b + 1] + zi;
var c000 = VAL[PERM[aa]], c100 = VAL[PERM[ba]];
var c010 = VAL[PERM[ab]], c110 = VAL[PERM[bb]];
var c001 = VAL[PERM[aa + 1]], c101 = VAL[PERM[ba + 1]];
var c011 = VAL[PERM[ab + 1]], c111 = VAL[PERM[bb + 1]];
var x00 = c000 + (c100 - c000) * u, x10 = c010 + (c110 - c010) * u;
var x01 = c001 + (c101 - c001) * u, x11 = c011 + (c111 - c011) * u;
var y0 = x00 + (x10 - x00) * v, y1 = x01 + (x11 - x01) * v;
return y0 + (y1 - y0) * w;
}
// fbm — 옥타브 적층. z 가 흐르면 산·골짜기가 통째로 천천히 다시 태어난다
function fbm(x, y, z) {
var sum = 0, amp = 0.5, norm = 0, f = 1;
for (var i = 0; i < 5; i++) {
sum += vnoise(x * f, y * f, z * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 색 — 고도띠 6단을 CSS 커스텀 프로퍼티(파스텔 토큰)에서 읽어 온다 ────────────────
var css = getComputedStyle(root);
function rgbOf(name) {
var v = css.getPropertyValue(name).trim().replace('#', '');
if (v.length === 3) v = v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
}
var BANDS = [rgbOf('--c0'), rgbOf('--c1'), rgbOf('--c2'), rgbOf('--c3'), rgbOf('--c4'), rgbOf('--c5')];
var RIVER = rgbOf('--river');
var SPRING = rgbOf('--spring');
var SCALE = 0.075; // 격자 간격(노이즈 공간) — 산·골짜기의 굵기
var FLOW = 0.7; // z(시간축) 흐름 — 산이 다시 태어나는 빠르기(고도띠가 눈에 띄게 넘실댄다)
var SPRINGS = 7; // 水源(시작점) 수
var MAX_STEP = 90; // 추적 최대 걸음 — 무한 루프 방지(분지 사이클·평지에서 멈춘다)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var raf = 0;
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);
}
// 水源 배치 — 격자 위 균등한 칸에 오프셋을 줘 시작점이 겹치지 않게. 시드가 바뀌면
// 지형 자체가 달라지므로 같은 배치로도 완전히 다른 골짜기로 흘러간다
function springCell(k) {
var cols = Math.ceil(SPRINGS / 2);
var col = k % cols, row = (k / cols) | 0;
var cx = Math.round(((col + 0.55) / cols) * (GW - 3)) + 1;
var cy = Math.round((row === 0 ? 0.18 : 0.62) * (GH - 3)) + 1;
if (cx < 1) cx = 1; if (cx > GW - 2) cx = GW - 2;
if (cy < 1) cy = 1; if (cy > GH - 2) cy = GH - 2;
return cx + cy * GW;
}
// 최급경사 추적 — (x,y) 에서 이웃 8칸 중 가장 낮은 칸으로 한 칸. 더 낮은 이웃이 없으면
// (호수·분지) 추적을 멈춘다. 경로를 배열로 돌려준다
function trace(ix) {
var path = [ix];
var x = ix % GW, y = (ix / GW) | 0;
for (var s = 0; s < MAX_STEP; s++) {
var best = -1, bestH = heights[y * GW + x];
for (var dy = -1; dy <= 1; dy++) {
var ny = y + dy; if (ny < 0 || ny >= GH) continue;
for (var dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
var nx = x + dx; if (nx < 0 || nx >= GW) continue;
var h = heights[ny * GW + nx];
if (h < bestH) { bestH = h; best = ny * GW + nx; }
}
}
if (best < 0) break; // 분지 도달 — 강이 호수로 닫는다
x = best % GW; y = (best / GW) | 0;
path.push(best);
if (y === GH - 1 || y === 0) break; // 지도 밖(바다)으로 나갔다
}
return path;
}
function paint(sec) {
var z = sec * FLOW;
// ① 높이맵 + 고도띠 채색 — 격자 칸을 화면 크기의 직사각형으로 넉넉히 그린다
var cw = W / GW + 1, ch = H / GH + 1;
var hi = 0, maxSteps = 0, stepSum = 0;
for (var gy = 0; gy < GH; gy++) {
for (var gx = 0; gx < GW; gx++) {
var n = fbm(gx * SCALE + 13.7, gy * SCALE + 71.3, z);
n = n * 1.5 - 0.25; // 값 노이즈는 가운데로 몰린다 — 대비를 펴 준다
if (n < 0) n = 0; else if (n > 1) n = 1;
heights[hi++] = n;
// 표시 색은 굵은 6단 고도띠로 고르되(BANDS), 색 사이 위치를 FINE(18) 단계로 잘게
// 쪼개 경계를 자주 넘나들게 한다 — 같은 색 팔레트를 그대로 두고 화면이 살아 움직이게
// 만드는 계산(파스텔 톤은 그대로, 경계만 촘촘해진다)
var bandF = n * 5;
var lo = bandF | 0; if (lo > 4) lo = 4;
var hi2 = lo + 1; if (hi2 > 5) hi2 = 5;
var frac = bandF - lo;
var FINE = 18;
var step = (frac * FINE) | 0;
var mixT = step / FINE;
var cl = BANDS[lo], ch2 = BANDS[hi2];
var r = cl[0] + (ch2[0] - cl[0]) * mixT;
var g = cl[1] + (ch2[1] - cl[1]) * mixT;
var bch = cl[2] + (ch2[2] - cl[2]) * mixT;
ctx.fillStyle = 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (bch | 0) + ')';
ctx.fillRect(gx * W / GW - 0.5, gy * H / GH - 0.5, cw, ch);
}
}
// ② 최급경사 추적 — 수원마다 경로를 구해 lineTo 로 강줄기를 긋는다.
// 하류로 갈수록 선이 굵어진다(유량이 모인다) — 발원지는 가늘게, 하류는 굵게
for (var k = 0; k < SPRINGS; k++) {
var path = trace(springCell(k));
if (path.length < 2) continue;
if (path.length > maxSteps) maxSteps = path.length;
stepSum += path.length;
var jx = (path[0] % GW) + 0.5, jy = ((path[0] / GW) | 0) + 0.5;
ctx.beginPath();
ctx.moveTo(jx * (W / GW), jy * (H / GH));
for (var p = 1; p < path.length; p++) {
var px = (path[p] % GW) + 0.5, py = ((path[p] / GW) | 0) + 0.5;
ctx.lineTo(px * (W / GW), py * (H / GH));
}
var wOut = 1.6 + path.length / MAX_STEP * 3.4; // 1.6px → 5px
ctx.lineWidth = wOut;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(' + RIVER[0] + ',' + RIVER[1] + ',' + RIVER[2] + ',0.92)';
ctx.stroke();
// 발원지 표식 — 水源(샘) 점
ctx.beginPath();
ctx.arc(jx * (W / GW), jy * (H / GH), 2.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgb(' + SPRING[0] + ',' + SPRING[1] + ',' + SPRING[2] + ')';
ctx.fill();
}
// 상태 라벨 — 추적이 얼마나 내려갔는지(침식 진행)
var avg = stepSum / SPRINGS;
flowOut.textContent = '追跡 ' + Math.round(avg / MAX_STEP * 100) + '%';
}
function frame(now) {
paint(now / 1000);
raf = requestAnimationFrame(frame);
}
function applySeed(v) {
var s = Math.max(1, Math.min(9999, Math.floor(v) || 1));
seedIn.value = String(s);
badge.textContent = '水源 ' + SPRINGS + 'か所';
buildTables(s);
if (reduce) paint(0);
}
seedIn.addEventListener('input', function () { applySeed(Number(seedIn.value)); });
seedIn.addEventListener('change', function () { applySeed(Number(seedIn.value)); });
window.addEventListener('resize', function () { fit(); if (reduce) paint(0); });
fit();
applySeed(Number(seedIn.value));
if (reduce) {
// reduce — 루프를 시작하지 않고 시드가 정한 물길 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
paint(0);
} else {
raf = requestAnimationFrame(frame);
}// RiverErosion — vanilla 판(run/309/demo/08-terrain-river-erosion/index.html) 의 React 포트.
// 2D fbm 높이맵 위에서 시작점(수원)마다 이웃 8칸 중 가장 낮은 칸으로 한 칸씩 내려가는
// 최급경사(steepest descent) 추적을 반복해 lineTo 로 강줄기를 긋는다. 시드가 바뀌면
// 지형이 달라져 물길이 다른 계곡으로 갈라진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RiverErosion.module.scss";
export type RiverErosionProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type InputEvt = { target: { value: string } };
const GW = 46, GH = 28;
const SCALE = 0.075, FLOW = 0.7, SPRINGS = 7, MAX_STEP = 90;
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function fade(t: number): number {
return t * t * (3 - 2 * t);
}
function rgbOf(v: string): [number, number, number] {
let s = v.trim().replace("#", "");
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)];
}
export default function RiverErosion({
duration = "2s",
easing = "ease-out",
color = "#0a4fe8",
}: RiverErosionProps) {
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 permRef = useRef(new Uint8Array(512)) as Box<Uint8Array>;
const valRef = useRef(new Float32Array(256)) as Box<Float32Array>;
const heightsRef = useRef(new Float32Array(GW * GH)) as Box<Float32Array>;
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const [seed, setSeed] = useState(7);
const [still, setStill] = useState(false);
const [flowPct, setFlowPct] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function buildTables(sd: number): void {
const rng = mulberry32(sd >>> 0);
const p: number[] = [];
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
const t = p[i]; p[i] = p[j]; p[j] = t;
}
const perm = permRef.current, val = valRef.current;
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
for (let i = 0; i < 256; i++) val[i] = rng();
}
function vnoise(x: number, y: number, z: number): number {
const perm = permRef.current, val = valRef.current;
let xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
const xf = x - xi, yf = y - yi, zf = z - zi;
xi &= 255; yi &= 255; zi &= 255;
const u = fade(xf), v = fade(yf), w = fade(zf);
const a = perm[xi] + yi, b = perm[xi + 1] + yi;
const aa = perm[a] + zi, ab = perm[a + 1] + zi;
const ba = perm[b] + zi, bb = perm[b + 1] + zi;
const c000 = val[perm[aa]], c100 = val[perm[ba]];
const c010 = val[perm[ab]], c110 = val[perm[bb]];
const c001 = val[perm[aa + 1]], c101 = val[perm[ba + 1]];
const c011 = val[perm[ab + 1]], c111 = val[perm[bb + 1]];
const x00 = c000 + (c100 - c000) * u, x10 = c010 + (c110 - c010) * u;
const x01 = c001 + (c101 - c001) * u, x11 = c011 + (c111 - c011) * u;
const y0 = x00 + (x10 - x00) * v, y1 = x01 + (x11 - x01) * v;
return y0 + (y1 - y0) * w;
}
function fbm(x: number, y: number, z: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < 5; i++) {
sum += vnoise(x * f, y * f, z * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
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);
}
function springCell(k: number): number {
const cols = Math.ceil(SPRINGS / 2);
const col = k % cols, row = (k / cols) | 0;
let cx = Math.round(((col + 0.55) / cols) * (GW - 3)) + 1;
let cy = Math.round((row === 0 ? 0.18 : 0.62) * (GH - 3)) + 1;
if (cx < 1) cx = 1; if (cx > GW - 2) cx = GW - 2;
if (cy < 1) cy = 1; if (cy > GH - 2) cy = GH - 2;
return cx + cy * GW;
}
function trace(ix: number): number[] {
const heights = heightsRef.current;
const path = [ix];
let x = ix % GW, y = (ix / GW) | 0;
for (let s = 0; s < MAX_STEP; s++) {
let best = -1, bestH = heights[y * GW + x];
for (let dy = -1; dy <= 1; dy++) {
const ny = y + dy; if (ny < 0 || ny >= GH) continue;
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx; if (nx < 0 || nx >= GW) continue;
const h = heights[ny * GW + nx];
if (h < bestH) { bestH = h; best = ny * GW + nx; }
}
}
if (best < 0) break;
x = best % GW; y = (best / GW) | 0;
path.push(best);
if (y === GH - 1 || y === 0) break;
}
return path;
}
function tok(name: string): string {
const root = rootRef.current;
if (!root) return "#000";
return getComputedStyle(root).getPropertyValue(name).trim();
}
function paint(sec: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const heights = heightsRef.current;
const BANDS = ["--c0", "--c1", "--c2", "--c3", "--c4", "--c5"].map((n) => rgbOf(tok(n)));
const RIVER = rgbOf(tok("--river"));
const SPRING = rgbOf(tok("--spring"));
const z = sec * FLOW;
const cw = W / GW + 1, ch = H / GH + 1;
let hi = 0, stepSum = 0;
for (let gy = 0; gy < GH; gy++) {
for (let gx = 0; gx < GW; gx++) {
let n = fbm(gx * SCALE + 13.7, gy * SCALE + 71.3, z);
n = n * 1.5 - 0.25;
if (n < 0) n = 0; else if (n > 1) n = 1;
heights[hi++] = n;
const bandF = n * 5;
let lo = bandF | 0; if (lo > 4) lo = 4;
let hi2 = lo + 1; if (hi2 > 5) hi2 = 5;
const frac = bandF - lo;
const FINE = 18;
const step = (frac * FINE) | 0;
const mixT = step / FINE;
const cl = BANDS[lo], ch2 = BANDS[hi2];
const r = cl[0] + (ch2[0] - cl[0]) * mixT;
const g = cl[1] + (ch2[1] - cl[1]) * mixT;
const bch = cl[2] + (ch2[2] - cl[2]) * mixT;
ctx.fillStyle = "rgb(" + (r | 0) + "," + (g | 0) + "," + (bch | 0) + ")";
ctx.fillRect(gx * W / GW - 0.5, gy * H / GH - 0.5, cw, ch);
}
}
for (let k = 0; k < SPRINGS; k++) {
const path = trace(springCell(k));
if (path.length < 2) continue;
stepSum += path.length;
const jx = (path[0] % GW) + 0.5, jy = ((path[0] / GW) | 0) + 0.5;
ctx.beginPath();
ctx.moveTo(jx * (W / GW), jy * (H / GH));
for (let p = 1; p < path.length; p++) {
const px = (path[p] % GW) + 0.5, py = ((path[p] / GW) | 0) + 0.5;
ctx.lineTo(px * (W / GW), py * (H / GH));
}
const wOut = 1.6 + (path.length / MAX_STEP) * 3.4;
ctx.lineWidth = wOut;
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(" + RIVER[0] + "," + RIVER[1] + "," + RIVER[2] + ",0.92)";
ctx.stroke();
ctx.beginPath();
ctx.arc(jx * (W / GW), jy * (H / GH), 2.6, 0, Math.PI * 2);
ctx.fillStyle = "rgb(" + SPRING[0] + "," + SPRING[1] + "," + SPRING[2] + ")";
ctx.fill();
}
setFlowPct(Math.round((stepSum / SPRINGS / MAX_STEP) * 100));
}
function frame(now: number): void {
paint(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function onSeed(e: InputEvt): void {
const v = Math.max(1, Math.min(9999, Math.floor(Number(e.target.value)) || 1));
setSeed(v);
buildTables(v);
if (reduced()) paint(0);
}
function onResize(): void {
fit();
if (reduced()) paint(0);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
buildTables(seed);
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
setStill(true);
paint(0);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cls = [styles.root, still ? 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="M112 56a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M105.46,210.4l-67-144.87a8,8,0,0,1,8.93-11.25L215.75,86A8,8,0,0,1,219,99.62l-107.52,47.12,15.4,67.6a8,8,0,0,1-15.53,4.6Z" opacity=".2"/><path d="M30.93,48.81a16,16,0,0,1,19.4-6.36l154.72,61.76a16,16,0,0,1-.77,29.84l-107.5,47.12L74.4,248.16a16,16,0,0,1-29.51-5.95Z"/></g></svg>
<span>시드를 바꾸면 물길이 다른 계곡으로 갈라집니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="img" aria-label="고도띠 지형 위로 강줄기가 흘러 내려가는 침식 지도">
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge}>수원 {SPRINGS}곳</b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>시드
<input type="number" min={1} max={9999} step={1} value={seed} aria-label="침식 시드" onChange={onSeed} />
</label>
<b className={styles.val}>추적 진행 {flowPct}%</b>
</div>
</div>
</main>
);
}08 河川侵食 — 最急降下の流線 — 計測 累積面積 51.864% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パーリンノイズ地形9選 — ライブラリなし、計算だけで
このデモが属する記事
パーリンノイズ地形9選 — ライブラリなし、計算だけでパーリンノイズとは、隣り合う値がなめらかにつながる乱数のことで、山・雲・海・星空といった自然の形を数式だけで描き出す元になる素材です。この9種をノイズ用ライブラリを一切使わずCanvas 2Dに直接実装しました。実測では08の河川侵食が累積 この記事のデモをすべて見る


