08River erosion — steepest-descent flow line
Starting points repeatedly step to the lowest neighboring cell on a 2D fbm heightfield (steepest descent) and lineTo traces the resulting river path, which forks into a different valley for each seed.
Best forGeography, environment, and data-storytelling illustration
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>Change the seed and the water forks into a different valley</span>
</p>
<div class="tre__field" id="field" role="img" aria-label="Erosion map with rivers flowing down a banded heightfield">
<canvas class="tre__cv" id="cv"></canvas>
<b class="tre__badge" id="badge">7 springs</b>
</div>
<div class="tre__bar">
<label class="tre__dial">Seed
<input type="number" id="seed" min="1" max="9999" step="1" value="7" aria-label="Erosion seed">
</label>
<b class="tre__val" id="flowOut">Tracing 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단으로 지형을 칠한다.
// ② 시작점(springs)마다 이웃 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. Seed 하나 → 같은 테이블 ────────────────────────────
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; // springs(시작점) 수
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);
}
// springs 배치 — 격자 위 균등한 칸에 오프셋을 줘 시작점이 겹치지 않게. 시드가 바뀌면
// 지형 자체가 달라지므로 같은 배치로도 완전히 다른 골짜기로 흘러간다
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();
// 발원지 표식 — springs(샘) 점
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 = 'Tracing ' + 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 ' + SPRINGS + ' sites';
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 River erosion — steepest-descent flow line — Measured area 51.864% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Perlin Noise Animations, No Libraries, Pure Math
The post this demo lives in
9 Perlin Noise Animations, No Libraries, Pure MathPerlin noise animation smooths raw randomness into values that connect with their neighbours, and out of that one functi See all demos in this post
If you like this effect - similar demos
Creative UI09 Hero grove — a fractal forest growing over the brand lineL-System Fractal Tree Generator: 9 Demos
Creative UI03 Standing wave jump-rope harmonicsWave Interference Simulation: 9 Canvas Scenes
Creative UI01 The basic strike — one layer of midpoint displacement halving a segment9 Lightning Effect Background Scenes, No WebGL