01Elevation profile — fbm ridge silhouette
A 1D value noise summed across 4 octaves (fbm) draws one ridge silhouette line, and the octave phase drifts slowly over time so the ridge keeps re-carving itself.
Best forOutdoor and hiking landing hero backgrounds
fbmlineTorequestAnimationFrame

<main class="stage">
<div class="tep" id="tep">
<p class="tep__hint">
<svg class="tep__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M144 52a20 20 0 1 1 20 20a20 20 0 0 1-20-20m49.44 55.92a8 8 0 0 0-13.77 0l-33 55.75l-21-35.67H50.35L8 200h240Z" opacity=".2"/><path d="M164 80a28 28 0 1 0-28-28a28 28 0 0 0 28 28m0-40a12 12 0 1 1-12 12a12 12 0 0 1 12-12m90.88 155.92l-54.56-92.08A15.87 15.87 0 0 0 186.55 96a15.85 15.85 0 0 0-13.76 7.84L146.63 148l-44.84-76.1a16 16 0 0 0-27.58 0L1.11 195.94A8 8 0 0 0 8 208h240a8 8 0 0 0 6.88-12.08M88 80l23.57 40H64.43ZM22 192l33-56h66l33 56Zm150.57 0l-16.66-28.28L186.55 112L234 192Z"/></g></svg>
<span>Change the seed and the ridge is carved anew from scratch</span>
</p>
<div class="tep__field" id="field">
<canvas class="tep__cv" id="cv"></canvas>
</div>
<div class="tep__bar">
<label class="tep__dial">Seed
<input type="number" id="seed" min="1" max="9999" step="1" value="7" aria-label="Terrain seed">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 지형 단면 — 1차원 값 노이즈를 옥타브 4겹(fbm)으로 겹쳐 산 능선 실루엣 한 줄을 lineTo 로 그린다.
// 3D 회전·원근 투영은 쓰지 않는다(307-02 heightfield-terrain 과 다른 축) — 순수 2D 단면.
// 옥타브마다 독립된 위상(phase)을 시간에 흘려 능선이 조금씩 다시 깎이는 것처럼 보이게 한다.
// 파스텔 — mix() 로 옅게 섞은 로컬 변수만 쓴다. 리터럴 hex 0개(BD26).
$terrain-sky: mix($subject-sky, $stage-paper, 42%);
$terrain-peach: mix($subject-orange-deep, $stage-paper, 30%);
$terrain-lilac: $subject-lilac;
$terrain-mint: mix($subject-mint, $stage-paper, 42%);
$duration: $dur-loop;
$easing: $ease-out;
$color: $terrain-mint;
@include stage($stage-orange);
.tep {
// 하늘 그라디언트(위→지평선) + 원경·근경 능선 각각의 그라디언트(위→아래로 한 단계 진하게)
--sky-top: #{$terrain-sky};
--sky-bottom: #{$terrain-peach};
--far-top: #{$terrain-lilac};
--far-bottom: #{mix($terrain-lilac, $subject-ink, 24%)};
--near-top: #{$terrain-mint};
--near-bottom: #{mix($terrain-mint, $subject-ink, 32%)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tep__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;
}
.tep__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.tep__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);
}
.tep__cv { display: block; width: 100%; height: 100%; }
.tep__bar { display: flex; align-items: center; gap: $sp-3; }
.tep__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.tep__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;
}
.tep__dial input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
@media (max-width: 340px) {
.tep { gap: $sp-1; }
.tep__field { height: 100px; }
.tep__dial input { width: 56px; }
.tep__hint { font-size: 10px; }
}
// reduce — 루프 없이 시드의 정적 한 장만 남는다. 커서 신호가 없으므로 클래스만 걸어 둔다(다른 항목과 형식 일치)
@media (prefers-reduced-motion: reduce) {
.tep.is-still .tep__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tep {
--sky-top: rgb(76.1176470588%, 90.9411764706%, 100%);
--sky-bottom: rgb(93.6470588235%, 75.6470588235%, 70%);
--far-top: #b9a0ff;
--far-bottom: rgb(24.2666666667%, 21.0196078431%, 31.7490196078%);
--near-top: rgb(70.5176470588%, 92.9176470588%, 85.3411764706%);
--near-bottom: rgb(28.6989803922%, 35.0669803922%, 34.2425098039%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.tep__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;
}
.tep__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.tep__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);
}
.tep__cv {
display: block;
width: 100%;
height: 100%;
}
.tep__bar {
display: flex;
align-items: center;
gap: 12px;
}
.tep__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.tep__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;
}
.tep__dial input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tep {
gap: 4px;
}
.tep__field {
height: 100px;
}
.tep__dial input {
width: 56px;
}
.tep__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tep.is-still .tep__field {
cursor: default;
}
}// Elevation profile — 1차원 값 노이즈를 옥타브 4겹(fbm)으로 겹쳐 산 능선 실루엣 한 줄을 lineTo 로 그린다.
// 3D 회전·원근 투영은 안 쓴다 — 가로 위치마다 fbm(x) 값을 높이로 읽는 순수 2D 단면이다.
// 옥타브마다 독립된 위상(phase)을 시간에 흘려보내 능선이 조금씩 다시 깎이는 것처럼 보이게 한다.
// 원경·근경 두 겹을 겹쳐 그려 입체감을 준다(파라미터만 다른 같은 계열).
var root = document.getElementById('tep');
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; // 옥타브 4겹
// fbm — 주파수 2배·진폭 절반으로 4겹 쌓는다. 옥타브마다 자기 위상을 따로 흘려보내
// (i+1) 배로 밀기 때문에 고주파 옥타브일수록 더 빨리 밀리고 — 능선이 부위별로 다시 깎인다
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;
}
// 원경(far)·근경(near) — 같은 fbm1 을 스케일·흐름·오프셋만 다르게 줘서 두 겹을 겹친다
var FAR_SCALE = 0.011, FAR_FLOW = 8.5, FAR_OFFSET = 13.7;
var NEAR_SCALE = 0.021, NEAR_FLOW = 12.0, NEAR_OFFSET = 241.3;
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_SKY_TOP = tok('--sky-top'), C_SKY_BOTTOM = tok('--sky-bottom');
var C_FAR_TOP = tok('--far-top'), C_FAR_BOTTOM = tok('--far-bottom');
var C_NEAR_TOP = tok('--near-top'), C_NEAR_BOTTOM = tok('--near-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 ridgeY(x, scale, offset, base, amp, sec, flow) {
var n = fbm1(x * scale + offset, sec, flow);
n = n * 1.6 - 0.3; // 값 노이즈는 가운데로 몰린다 — 대비를 한 번 펴 준다
if (n < 0) n = 0; else if (n > 1) n = 1;
return base - n * amp;
}
// 능선 실루엣 하나 — 닫힌 다각형(아래는 캔버스 바닥까지)으로 lineTo, 그라디언트 파스텔로 채운다
function drawRidge(sec, scale, offset, base, amp, flow, topColor, bottomColor) {
var step = 2;
ctx.beginPath();
ctx.moveTo(0, H);
for (var x = 0; x <= W; x += step) {
var xx = x > W ? W : x;
ctx.lineTo(xx, ridgeY(xx, scale, offset, base, amp, sec, flow));
}
ctx.lineTo(W, H);
ctx.closePath();
var g = ctx.createLinearGradient(0, base - amp, 0, H);
g.addColorStop(0, topColor);
g.addColorStop(1, bottomColor);
ctx.fillStyle = g;
ctx.fill();
}
function paint(sec) {
ctx.clearRect(0, 0, W, H);
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, C_SKY_TOP);
sky.addColorStop(1, C_SKY_BOTTOM);
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
drawRidge(sec, FAR_SCALE, FAR_OFFSET, H * 0.50, H * 0.38, FAR_FLOW, C_FAR_TOP, C_FAR_BOTTOM);
drawRidge(sec, NEAR_SCALE, NEAR_OFFSET, H * 0.80, H * 0.56, NEAR_FLOW, C_NEAR_TOP, C_NEAR_BOTTOM);
}
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);
}// ElevationProfile — vanilla 판(run/309/demo/01-terrain-elevation-profile/index.html) 의 React 포트.
// 1차원 값 노이즈를 옥타브 4겹(fbm) 으로 겹쳐 산 능선 실루엣 한 줄을 lineTo 로 그린다.
// 3D 회전·원근 투영은 안 쓴다 — 가로 위치마다 fbm(x) 값을 높이로 읽는 순수 2D 단면이다.
// 옥타브마다 독립된 위상을 시간에 흘려 능선이 조금씩 다시 깎이는 것처럼 보이게 한다.
// 원경·근경 두 겹을 겹쳐 그려 입체감을 준다. 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ElevationProfile.module.scss";
export type ElevationProfileProps = {
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; // 옥타브 4겹
const FAR_SCALE = 0.011, FAR_FLOW = 8.5, FAR_OFFSET = 13.7;
const NEAR_SCALE = 0.021, NEAR_FLOW = 12.0, NEAR_OFFSET = 241.3;
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
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;
};
}
function fade(t: number): number {
return t * t * (3 - 2 * t); // 스무스스텝 — 격자 경계에서 각이 안 지게
}
// 1차원 값 노이즈 테이블 — 시드가 바뀌면 다시 짠다
class ValueNoise1 {
perm: Uint8Array;
val: Float32Array;
constructor(seed: number) {
const rng = mulberry32(seed >>> 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;
}
this.perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];
this.val = new Float32Array(256);
for (let i = 0; i < 256; i++) this.val[i] = rng();
}
at(x: number): number {
const xi = Math.floor(x);
const xf = x - xi;
const a = this.val[this.perm[xi & 255]], b = this.val[this.perm[(xi + 1) & 255]];
return a + (b - a) * fade(xf);
}
}
// fbm — 주파수 2배·진폭 절반으로 4겹. 옥타브마다 자기 위상을 따로 흘려보내
// (i+1) 배로 밀기 때문에 고주파 옥타브일수록 더 빨리 밀리고 — 능선이 부위별로 다시 깎인다
function fbm1(n: ValueNoise1, 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 += n.at(x * f + phase) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
export default function ElevationProfile({
duration = "2s", // vanilla $duration: $dur-loop
easing = "ease-out", // vanilla $easing: $ease-out
color = "#8fdac2", // vanilla $color: $terrain-mint (근사값)
}: ElevationProfileProps) {
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 seedRef = useRef<HTMLInputElement | null>(null);
const noiseRef = useRef<ValueNoise1 | 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 vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function buildTables(seed: number): void {
noiseRef.current = new ValueNoise1(seed);
}
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 ridgeY(x: number, scale: number, offset: number, base: number, amp: number, sec: number, flow: number): number {
const n = noiseRef.current;
if (!n) return base;
let v = fbm1(n, x * scale + offset, sec, flow);
v = v * 1.6 - 0.3; // 값 노이즈는 가운데로 몰린다 — 대비를 한 번 펴 준다
if (v < 0) v = 0; else if (v > 1) v = 1;
return base - v * amp;
}
// 능선 실루엣 하나 — 닫힌 다각형(아래는 캔버스 바닥까지)으로 lineTo, 그라디언트 파스텔로 채운다
function drawRidge(
ctx: CanvasRenderingContext2D, H: number, W: number,
sec: number, scale: number, offset: number, base: number, amp: number, flow: number,
topColor: string, bottomColor: string,
): void {
const step = 2;
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W; x += step) {
const xx = x > W ? W : x;
ctx.lineTo(xx, ridgeY(xx, scale, offset, base, amp, sec, flow));
}
ctx.lineTo(W, H);
ctx.closePath();
const g = ctx.createLinearGradient(0, base - amp, 0, H);
g.addColorStop(0, topColor);
g.addColorStop(1, bottomColor);
ctx.fillStyle = g;
ctx.fill();
}
function paint(sec: number): void {
const ctx = ctxRef.current, root = rootRef.current;
if (!ctx || !root) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
ctx.clearRect(0, 0, W, H);
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, tok("--sky-top"));
sky.addColorStop(1, tok("--sky-bottom"));
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
drawRidge(ctx, H, W, sec, FAR_SCALE, FAR_OFFSET, H * 0.50, H * 0.38, FAR_FLOW, tok("--far-top"), tok("--far-bottom"));
drawRidge(ctx, H, W, sec, NEAR_SCALE, NEAR_OFFSET, H * 0.80, H * 0.56, NEAR_FLOW, tok("--near-top"), tok("--near-bottom"));
}
function frame(now: number): void {
paint(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function onSeedInput(event: InputEvt): void {
const v = Math.max(1, Math.min(9999, Math.floor(Number(event.target.value)) || 1));
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");
if (!ctxRef.current) return;
buildTables(Number(seedRef.current?.value) || 7);
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않고 시드가 정한 능선 한 장만 그린다
stillRef.current = true;
paint(0);
} 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="M144 52a20 20 0 1 1 20 20a20 20 0 0 1-20-20m49.44 55.92a8 8 0 0 0-13.77 0l-33 55.75l-21-35.67H50.35L8 200h240Z" opacity=".2"/><path d="M164 80a28 28 0 1 0-28-28a28 28 0 0 0 28 28m0-40a12 12 0 1 1-12 12a12 12 0 0 1 12-12m90.88 155.92l-54.56-92.08A15.87 15.87 0 0 0 186.55 96a15.85 15.85 0 0 0-13.76 7.84L146.63 148l-44.84-76.1a16 16 0 0 0-27.58 0L1.11 195.94A8 8 0 0 0 8 208h240a8 8 0 0 0 6.88-12.08M88 80l23.57 40H64.43ZM22 192l33-56h66l33 56Zm150.57 0l-16.66-28.28L186.55 112L234 192Z"/></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" ref={seedRef} min={1} max={9999} step={1} defaultValue={7} aria-label="지형 시드" onChange={onSeedInput} />
</label>
</div>
</div>
</main>
);
}01 Elevation profile — fbm ridge silhouette — Measured area 31.644% · 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 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
Creative UI02 Square-wave synthesis — a few odd-harmonic circles building a sharp waveFourier Epicycles, 9 Ways (No Library)