07Cave cross-section — a cavity carved by noise
Two fbm curves draw a ceiling and a floor, clip keeps only the cavity between them, and stalactite spots are chosen where the noise value peaks to finish the cave cross-section.
Best forExploration, game, and dark-fantasy landings
fbmquadraticCurveToclip

<main class="stage">
<div class="tcc" id="tcc">
<p class="tcc__hint">
<svg class="tcc__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m32-88a32 32 0 1 0-32 32a32 32 0 0 0 32-32"/></g></svg>
<span>Change the seed and a different cave is carved</span>
</p>
<div class="tcc__field" id="field">
<canvas class="tcc__cv" id="cv"></canvas>
</div>
<div class="tcc__bar">
<label class="tcc__dial">Seed
<input type="number" id="seed" min="1" max="9999" step="1" value="7" aria-label="Cave seed">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 동굴 단면 — 위아래 두 fbm 곡선으로 천장·바닥을 그리고 clip 으로 공동만 남긴 뒤
// 종유석·석순을 노이즈 값이 높은 지점에 배치한다. 무대는 잉크(어두운 바위).
// 파스텔 — mix() 로 옅게 섞은 로컬 변수만 쓴다. 리터럴 hex 0개(BD26).
$terrain-lilac: $subject-lilac;
$terrain-mint: mix($subject-mint, $stage-paper, 42%);
$duration: $dur-loop;
$easing: $ease-out;
$color: $terrain-lilac;
@include stage($stage-ink);
.tcc {
// 바위 몸통(위→아래) · 공동 채움 · 공동 안쪽 아래 채광(위→아래)
--rock-top: #{mix($subject-ink, $terrain-lilac, 78%)};
--rock-bottom: #{mix($subject-ink, $terrain-mint, 84%)};
--cav-top: #{$terrain-lilac};
--cav-bottom: #{mix($terrain-lilac, $subject-ink, 38%)};
--glow-top: #{$terrain-mint};
--glow-bottom: #{mix($terrain-mint, $subject-ink, 30%)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tcc__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-cream;
}
.tcc__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-cream; }
.tcc__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .16);
}
.tcc__cv { display: block; width: 100%; height: 100%; }
.tcc__bar { display: flex; align-items: center; gap: $sp-3; }
.tcc__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-cream;
}
.tcc__dial input {
width: 72px; margin: 0;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-sm;
background: rgba($subject-cream, .12); color: $subject-cream;
font-weight: 700; font-size: 12px; font-family: inherit;
}
.tcc__dial input:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
@media (max-width: 340px) {
.tcc { gap: $sp-1; }
.tcc__field { height: 100px; }
.tcc__dial input { width: 56px; }
.tcc__hint { font-size: 10px; }
}
// reduce — 루프 없이 시드의 정적 한 장만 남는다. 커서 신호가 없으므로 클래스만 걸어 둔다(다른 항목과 형식 일치)
@media (prefers-reduced-motion: reduce) {
.tcc.is-still .tcc__field { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tcc {
--rock-top: rgb(22.9960784314%, 19.9215686275%, 29.9529411765%);
--rock-bottom: rgb(18.8592941176%, 21.4550588235%, 22.2192941176%);
--cav-top: #b9a0ff;
--cav-bottom: rgb(33.1607843137%, 28.7058823529%, 44.3215686275%);
--glow-top: rgb(70.5176470588%, 92.9176470588%, 85.3411764706%);
--glow-bottom: rgb(27.4690196078%, 33.3654901961%, 32.7396078431%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.tcc__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: #fff7e6;
}
.tcc__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #fff7e6;
}
.tcc__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.16);
}
.tcc__cv {
display: block;
width: 100%;
height: 100%;
}
.tcc__bar {
display: flex;
align-items: center;
gap: 12px;
}
.tcc__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #fff7e6;
}
.tcc__dial input {
width: 72px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: 12px;
font-family: inherit;
}
.tcc__dial input:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tcc {
gap: 4px;
}
.tcc__field {
height: 100px;
}
.tcc__dial input {
width: 56px;
}
.tcc__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tcc.is-still .tcc__field {
cursor: default;
}
}// 동굴 단면 — 위아래 두 fbm 곡선으로 천장과 바닥을 그리고 clip 으로 그 사이 공동만 남긴다.
// 천장 곡선은 fbm, 바닥 곡선도 fbm(다른 오프셋) — 두 곡선 사이가 노이즈로 파낸 공동(cavity)이 된다.
// 종유석(천장에서 아래로)·석순(바닥에서 위로)은 노이즈 값이 높은 지점에 골라 배치한다:
// x 칸마다 vnoise 를 하나 더 읽어 문턱을 넘으면 그 길이로 밑뿌리를 내리는 식이다(좌표는 격자로 양자화).
// 천장·바닥 곡선 둘 다 옥타브 위상을 시간에 흘려 동굴이 살아 움직이게 한다(브리프 명시).
var root = document.getElementById('tcc');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var seedIn = document.getElementById('seed');
// 결정적 난수 — 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);
// Seed → PERM(순서표)·VAL(격자점 난수값) 재생성 — Seed UI 가 이걸 다시 부른다
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); } // 스무스스텝 — 격자 경계에서 각이 안 지게
// 1차원 값 노이즈 — 격자점마다 난수를 하나 두고 그 사이를 fade 로 부드럽게 잇는다
function vnoise1(x) {
var xi = Math.floor(x);
var xf = x - xi;
var a = VAL[PERM[xi & 255]], b = VAL[PERM[(xi + 1) & 255]];
return a + (b - a) * fade(xf);
}
var OCT = 4;
// fbm — 주파수 2배·진폭 절반으로 4겹. 옥타브마다 자기 위상을 따로 흘려보낸다
function fbm1(x, sec, flow) {
var sum = 0, amp = 0.5, norm = 0, f = 1;
for (var i = 0; i < OCT; i++) {
var phase = sec * flow * (i + 1);
sum += vnoise1(x * f + phase) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
var CEIL_SCALE = 0.020, CEIL_FLOW = 9.0, CEIL_OFFSET = 17.3;
var FLOOR_SCALE = 0.017, FLOOR_FLOW = 7.5, FLOOR_OFFSET = 203.8;
var STAL_FLOW = 6.0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var raf = 0;
var css = getComputedStyle(root);
function tok(name) { return css.getPropertyValue(name).trim(); }
var C_ROCK_TOP = tok('--rock-top'), C_ROCK_BOTTOM = tok('--rock-bottom');
var C_CAV_TOP = tok('--cav-top'), C_CAV_BOTTOM = tok('--cav-bottom');
var C_GLOW_TOP = tok('--glow-top'), C_GLOW_BOTTOM = tok('--glow-bottom');
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 ceilY(x, sec) {
var n = fbm1(x * CEIL_SCALE + CEIL_OFFSET, sec, CEIL_FLOW);
n = n * 1.7 - 0.35; // 값 노이즈는 가운데로 몰린다 — 대비를 한 번 펴 준다
if (n < 0.06) n = 0.06; else if (n > 0.62) n = 0.62;
return H * (0.14 + n * 0.34);
}
function floorY(x, sec) {
var n = fbm1(x * FLOOR_SCALE + FLOOR_OFFSET, sec, FLOOR_FLOW);
n = n * 1.7 - 0.35;
if (n < 0.30) n = 0.30; else if (n > 0.94) n = 0.94;
return H * (0.44 + n * 0.40);
}
// 종유석 — x 칸마다 노이즈를 하나 더 읽어 문턱 위일 때만 내린다(좌표는 4px 격자로 양자화).
// 옆면은 quadraticCurveTo 로 안으로 휘게(밑뿌리가 좁아지는 물방울 곡선) — 진짜 종유석 단면처럼
function drawStalactites(sec) {
ctx.fillStyle = C_ROCK_BOTTOM;
var cell = 4;
for (var gx = 2; gx < W; gx += cell) {
var s = vnoise1(gx * 0.055 + 71.7 + sec * STAL_FLOW * 0.35);
if (s > 0.62) {
var len = (s - 0.62) / 0.38; // 0..1 — 문턱을 얼마나 넘었나
var top = ceilY(gx, sec);
var h = 4 + len * H * 0.26;
var w = 3 + Math.round(len * 3);
var g = ctx.createLinearGradient(0, top, 0, top + h);
g.addColorStop(0, C_ROCK_TOP);
g.addColorStop(1, C_GLOW_BOTTOM);
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(gx - w, top);
ctx.quadraticCurveTo(gx - w * 0.15, top + h * 0.55, gx, top + h);
ctx.quadraticCurveTo(gx + w * 0.15, top + h * 0.55, gx + w, top);
ctx.closePath();
ctx.fill();
}
}
// 석순(stalagmite) — 바닥에서 위로, 다른 노이즈 창으로 자리를 골라 대칭을 피한다
ctx.fillStyle = C_ROCK_TOP;
for (var fx = 6; fx < W; fx += cell) {
var s2 = vnoise1(fx * 0.055 + 141.9 + sec * STAL_FLOW * 0.3);
if (s2 > 0.66) {
var len2 = (s2 - 0.66) / 0.34;
var fy = floorY(fx, sec);
var h2 = 3 + len2 * H * 0.20;
var w2 = 3 + Math.round(len2 * 3);
var g2 = ctx.createLinearGradient(0, fy, 0, fy - h2);
g2.addColorStop(0, C_ROCK_TOP);
g2.addColorStop(1, C_GLOW_BOTTOM);
ctx.fillStyle = g2;
ctx.beginPath();
ctx.moveTo(fx - w2, fy);
ctx.quadraticCurveTo(fx - w2 * 0.15, fy - h2 * 0.55, fx, fy - h2);
ctx.quadraticCurveTo(fx + w2 * 0.15, fy - h2 * 0.55, fx + w2, fy);
ctx.closePath();
ctx.fill();
}
}
}
function paint(sec) {
ctx.clearRect(0, 0, W, H);
// 1) 바위 몸통 — 화면 전체를 어두운 암석 톤으로 깐다
var rock = ctx.createLinearGradient(0, 0, 0, H);
rock.addColorStop(0, C_ROCK_TOP);
rock.addColorStop(1, C_ROCK_BOTTOM);
ctx.fillStyle = rock;
ctx.fillRect(0, 0, W, H);
// 2) 공동 — 천장·바닥 두 fbm 곡선 사이만 clip 으로 남긴다
ctx.save();
ctx.beginPath();
ctx.moveTo(0, ceilY(0, sec));
for (var x = 2; x <= W; x += 2) ctx.lineTo(x, ceilY(x, sec));
for (var x2 = W; x2 >= 0; x2 -= 2) ctx.lineTo(x2, floorY(x2, sec));
ctx.closePath();
ctx.clip();
var cav = ctx.createLinearGradient(0, 0, 0, H);
cav.addColorStop(0, C_CAV_TOP);
cav.addColorStop(1, C_CAV_BOTTOM);
ctx.fillStyle = cav;
ctx.fillRect(0, 0, W, H);
// 공동 안쪽 빛 — 아래에서 올라오는 민트 채광
var glow = ctx.createLinearGradient(0, H * 0.45, 0, H);
glow.addColorStop(0, C_GLOW_TOP);
glow.addColorStop(1, C_GLOW_BOTTOM);
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
// 3) 종유석·석순 — 공동 안에서만 그린다(clip 이 걸린 상태)
drawStalactites(sec);
ctx.restore();
}
function frame(now) {
paint(now / 1000);
raf = requestAnimationFrame(frame);
}
function onSeed() {
var v = Math.max(1, Math.min(9999, Math.floor(Number(seedIn.value)) || 1));
buildTables(v);
if (reduce) paint(0);
}
seedIn.addEventListener('input', onSeed);
seedIn.addEventListener('change', onSeed);
window.addEventListener('resize', function () { fit(); if (reduce) paint(0); });
buildTables(Number(seedIn.value) || 7);
fit();
if (reduce) {
// reduce — 루프를 시작하지 않고 시드가 정한 동굴 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
paint(0);
} else {
raf = requestAnimationFrame(frame);
}// CaveCrossSection — vanilla 판(run/309/demo/07-terrain-cave-cross-section/index.html) 의 React 포트.
// 위아래 두 fbm 곡선으로 천장·바닥을 그리고 clip 으로 공동만 남긴 뒤,
// 종유석·석순을 노이즈 값이 높은 지점에 배치한다. 천장·바닥 둘 다 옥타브 위상을 시간에 흘린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CaveCrossSection.module.scss";
export type CaveCrossSectionProps = {
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 OCT = 4;
const CEIL_SCALE = 0.020, CEIL_FLOW = 9.0, CEIL_OFFSET = 17.3;
const FLOOR_SCALE = 0.017, FLOOR_FLOW = 7.5, FLOOR_OFFSET = 203.8;
const STAL_FLOW = 6.0;
// 결정적 난수 — 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);
}
export default function CaveCrossSection({
duration = "2s",
easing = "ease-out",
color = "#b9a0ff",
}: CaveCrossSectionProps) {
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 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 vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 시드 → PERM(순서표)·VAL(격자점 난수값) 재생성 — 시드 UI 가 이걸 다시 부른다
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();
}
// 1차원 값 노이즈 — 격자점마다 난수를 하나 두고 그 사이를 fade 로 부드럽게 잇는다
function vnoise1(x: number): number {
const perm = permRef.current, val = valRef.current;
const xi = Math.floor(x);
const xf = x - xi;
const a = val[perm[xi & 255]], b = val[perm[(xi + 1) & 255]];
return a + (b - a) * fade(xf);
}
// fbm — 주파수 2배·진폭 절반으로 4겹. 옥타브마다 자기 위상을 따로 흘려보낸다
function fbm1(x: number, sec: number, flow: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < OCT; i++) {
const phase = sec * flow * (i + 1);
sum += vnoise1(x * f + phase) * 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 ceilY(x: number, sec: number): number {
const H = sizeRef.current.h;
let n = fbm1(x * CEIL_SCALE + CEIL_OFFSET, sec, CEIL_FLOW);
n = n * 1.7 - 0.35;
if (n < 0.06) n = 0.06; else if (n > 0.62) n = 0.62;
return H * (0.14 + n * 0.34);
}
function floorY(x: number, sec: number): number {
const H = sizeRef.current.h;
let n = fbm1(x * FLOOR_SCALE + FLOOR_OFFSET, sec, FLOOR_FLOW);
n = n * 1.7 - 0.35;
if (n < 0.30) n = 0.30; else if (n > 0.94) n = 0.94;
return H * (0.44 + n * 0.40);
}
function tok(name: string): string {
const root = rootRef.current;
if (!root) return "#000";
return getComputedStyle(root).getPropertyValue(name).trim();
}
// 종유석 — x 칸마다 노이즈를 하나 더 읽어 문턱 위일 때만 내린다(좌표는 4px 격자로 양자화).
// 옆면은 quadraticCurveTo 로 안으로 휘게(밑뿌리가 좁아지는 물방울 곡선) — 진짜 종유석 단면처럼
function drawStalactites(ctx: CanvasRenderingContext2D, sec: number): void {
const W = sizeRef.current.w, H = sizeRef.current.h;
const cell = 4;
const rockTop = tok("--rock-top"), glowBottom = tok("--glow-bottom");
for (let gx = 2; gx < W; gx += cell) {
const s = vnoise1(gx * 0.055 + 71.7 + sec * STAL_FLOW * 0.35);
if (s > 0.62) {
const len = (s - 0.62) / 0.38;
const top = ceilY(gx, sec);
const h = 4 + len * H * 0.26;
const w = 3 + Math.round(len * 3);
const g = ctx.createLinearGradient(0, top, 0, top + h);
g.addColorStop(0, rockTop);
g.addColorStop(1, glowBottom);
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(gx - w, top);
ctx.quadraticCurveTo(gx - w * 0.15, top + h * 0.55, gx, top + h);
ctx.quadraticCurveTo(gx + w * 0.15, top + h * 0.55, gx + w, top);
ctx.closePath();
ctx.fill();
}
}
// 석순(stalagmite) — 바닥에서 위로, 다른 노이즈 창으로 자리를 골라 대칭을 피한다
for (let fx = 6; fx < W; fx += cell) {
const s2 = vnoise1(fx * 0.055 + 141.9 + sec * STAL_FLOW * 0.3);
if (s2 > 0.66) {
const len2 = (s2 - 0.66) / 0.34;
const fy = floorY(fx, sec);
const h2 = 3 + len2 * H * 0.20;
const w2 = 3 + Math.round(len2 * 3);
const g2 = ctx.createLinearGradient(0, fy, 0, fy - h2);
g2.addColorStop(0, rockTop);
g2.addColorStop(1, glowBottom);
ctx.fillStyle = g2;
ctx.beginPath();
ctx.moveTo(fx - w2, fy);
ctx.quadraticCurveTo(fx - w2 * 0.15, fy - h2 * 0.55, fx, fy - h2);
ctx.quadraticCurveTo(fx + w2 * 0.15, fy - h2 * 0.55, fx + w2, fy);
ctx.closePath();
ctx.fill();
}
}
}
function paint(sec: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
ctx.clearRect(0, 0, W, H);
// 1) 바위 몸통 — 화면 전체를 어두운 암석 톤으로 깐다
const rock = ctx.createLinearGradient(0, 0, 0, H);
rock.addColorStop(0, tok("--rock-top"));
rock.addColorStop(1, tok("--rock-bottom"));
ctx.fillStyle = rock;
ctx.fillRect(0, 0, W, H);
// 2) 공동 — 천장·바닥 두 fbm 곡선 사이만 clip 으로 남긴다
ctx.save();
ctx.beginPath();
ctx.moveTo(0, ceilY(0, sec));
for (let x = 2; x <= W; x += 2) ctx.lineTo(x, ceilY(x, sec));
for (let x2 = W; x2 >= 0; x2 -= 2) ctx.lineTo(x2, floorY(x2, sec));
ctx.closePath();
ctx.clip();
const cav = ctx.createLinearGradient(0, 0, 0, H);
cav.addColorStop(0, tok("--cav-top"));
cav.addColorStop(1, tok("--cav-bottom"));
ctx.fillStyle = cav;
ctx.fillRect(0, 0, W, H);
// 공동 안쪽 빛 — 아래에서 올라오는 민트 채광
const glow = ctx.createLinearGradient(0, H * 0.45, 0, H);
glow.addColorStop(0, tok("--glow-top"));
glow.addColorStop(1, tok("--glow-bottom"));
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
// 3) 종유석·석순 — 공동 안에서만 그린다(clip 이 걸린 상태)
drawStalactites(ctx, sec);
ctx.restore();
}
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="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m32-88a32 32 0 1 0-32 32a32 32 0 0 0 32-32"/></g></svg>
<span>시드를 바꾸면 다른 동굴이 뚫립니다</span>
</p>
<div className={styles.field} ref={fieldRef}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</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>
</div>
</div>
</main>
);
}07 Cave cross-section — a cavity carved by noise — Measured area 31.048% · 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


