The canvas below draws a rolling cross-section of terrain generated by stacking layers of Perlin noise. Use the controls to change the number of octaves, the persistence (how much each layer contributes), and the base frequency.
<!-- Perlin noise landscape demo — controls + canvas -->
<div class="controls">
<label>Octaves: <span id="oct-val">4</span>
<input type="range" id="octaves" min="1" max="8" value="4">
</label>
<label>Persistence: <span id="per-val">0.50</span>
<input type="range" id="persistence" min="10" max="90" value="50">
</label>
<label>Frequency: <span id="freq-val">3</span>
<input type="range" id="frequency" min="1" max="10" value="3">
</label>
<button id="reseed" type="button">New seed</button>
</div>
<canvas id="terrain" width="600" height="220"></canvas>
<p class="caption" id="caption">Adjust the sliders to reshape the landscape.</p>
/* Layout and theme for the Perlin noise demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f7fa; color: #222; }
.controls {
display: flex; flex-wrap: wrap; gap: .6rem 1.2rem;
align-items: center; padding: .6rem .4rem .4rem;
}
label {
display: flex; align-items: center; gap: .4rem;
font-size: .85rem; font-weight: 600; color: #334;
}
input[type=range] { width: 90px; accent-color: #3d6fa8; cursor: pointer; }
#reseed {
font: 600 13px system-ui, sans-serif; padding: .35rem .8rem;
border: 1px solid #3d6fa8; background: #3d6fa8; color: #fff;
border-radius: 7px; cursor: pointer;
}
#reseed:hover { background: #2d5a8e; }
canvas {
display: block; width: 100%; max-width: 600px;
border-radius: 8px; border: 1px solid #d0d8e4;
}
.caption {
font-size: .8rem; color: #556; margin: .4rem 0 0;
min-height: 1.3em; line-height: 1.4;
}
/* Perlin noise landscape — 1-D fBm stacking octaves */
/* Build a shuffled permutation table from a seed (LCG shuffle). */
function buildPerm(seed) {
const p = Array.from({length: 256}, (_, i) => i);
let s = seed | 0;
for (let i = 255; i > 0; i--) {
s = (s * 1664525 + 1013904223) & 0xffffffff;
const j = ((s >>> 16) & 0xff) % (i + 1);
[p[i], p[j]] = [p[j], p[i]];
}
return [...p, ...p];
}
/* Quintic fade function — zero first and second derivative at endpoints. */
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
/* 1-D gradient — returns +x or -x based on the lowest hash bit. */
function grad(hash, x) {
return (hash & 1) === 0 ? x : -x;
}
/* Single-octave 1-D Perlin noise in [-1, 1]. */
function noise1d(perm, x) {
const X = Math.floor(x) & 255;
x -= Math.floor(x);
const u = fade(x);
return lerp(grad(perm[X], x), grad(perm[X + 1], x - 1), u);
}
/* Fractional Brownian Motion — sum octaves with geometrically shrinking amplitude. */
function fbm(perm, x, octaves, persistence, freq) {
let val = 0, amp = 1, max = 0, f = freq;
for (let k = 0; k < octaves; k++) {
val += noise1d(perm, x * f) * amp;
max += amp;
amp *= persistence;
f *= 2;
}
return val / max;
}
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Current permutation table (re-seeded on button click). */
let perm = buildPerm(Math.random() * 65536 | 0);
const canvas = document.getElementById('terrain');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
function getParams() {
return {
octaves: +document.getElementById('octaves').value,
persistence: +document.getElementById('persistence').value / 100,
freq: +document.getElementById('frequency').value,
};
}
/* Draw the terrain cross-section onto the canvas. */
function draw() {
const { octaves, persistence, freq } = getParams();
ctx.clearRect(0, 0, W, H);
/* Sky background gradient. */
const sky = ctx.createLinearGradient(0, 0, 0, H * 0.6);
sky.addColorStop(0, '#b8d4f0');
sky.addColorStop(1, '#ddeeff');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
/* Trace the heightmap silhouette. */
ctx.beginPath();
ctx.moveTo(0, H);
for (let px = 0; px <= W; px++) {
const n = fbm(perm, px / W, octaves, persistence, freq);
/* Map noise [-1,1] to canvas y — centre + scaled offset. */
const y = H * 0.35 - n * H * 0.28;
if (px === 0) ctx.lineTo(0, y); else ctx.lineTo(px, y);
}
ctx.lineTo(W, H);
ctx.closePath();
/* Fill terrain with a green-to-dark gradient. */
const land = ctx.createLinearGradient(0, H * 0.1, 0, H);
land.addColorStop(0, '#6aaa64');
land.addColorStop(0.4, '#4a7c45');
land.addColorStop(1, '#2d4a2a');
ctx.fillStyle = land;
ctx.fill();
/* Live caption showing current parameters. */
document.getElementById('caption').textContent =
fmt('%octaves% octaves · persistence %persistence% · frequency %freq% — each extra octave adds finer detail.', { octaves, persistence: persistence.toFixed(2), freq });
/* Sync slider value displays. */
document.getElementById('oct-val').textContent = octaves;
document.getElementById('per-val').textContent = persistence.toFixed(2);
document.getElementById('freq-val').textContent = freq;
}
/* Wire sliders and reseed button. */
['octaves', 'persistence', 'frequency'].forEach(id => {
document.getElementById(id).addEventListener('input', draw);
});
document.getElementById('reseed').addEventListener('click', () => {
perm = buildPerm(Math.random() * 65536 | 0);
draw();
});
draw();
<!-- Perlin noise landscape demo — controls + canvas -->
<div class="controls">
<label>Octaves: <span id="oct-val">4</span>
<input type="range" id="octaves" min="1" max="8" value="4">
</label>
<label>Persistence: <span id="per-val">0.50</span>
<input type="range" id="persistence" min="10" max="90" value="50">
</label>
<label>Frequency: <span id="freq-val">3</span>
<input type="range" id="frequency" min="1" max="10" value="3">
</label>
<button id="reseed" type="button">New seed</button>
</div>
<canvas id="terrain" width="600" height="220"></canvas>
<p class="caption" id="caption">Adjust the sliders to reshape the landscape.</p>
/* Layout and theme for the Perlin noise demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f7fa; color: #222; }
.controls {
display: flex; flex-wrap: wrap; gap: .6rem 1.2rem;
align-items: center; padding: .6rem .4rem .4rem;
}
label {
display: flex; align-items: center; gap: .4rem;
font-size: .85rem; font-weight: 600; color: #334;
}
input[type=range] { width: 90px; accent-color: #3d6fa8; cursor: pointer; }
#reseed {
font: 600 13px system-ui, sans-serif; padding: .35rem .8rem;
border: 1px solid #3d6fa8; background: #3d6fa8; color: #fff;
border-radius: 7px; cursor: pointer;
}
#reseed:hover { background: #2d5a8e; }
canvas {
display: block; width: 100%; max-width: 600px;
border-radius: 8px; border: 1px solid #d0d8e4;
}
.caption {
font-size: .8rem; color: #556; margin: .4rem 0 0;
min-height: 1.3em; line-height: 1.4;
}
/* Perlin noise landscape — 1-D fBm stacking octaves */
/* Build a shuffled permutation table from a seed (LCG shuffle). */
function buildPerm(seed) {
const p = Array.from({length: 256}, (_, i) => i);
let s = seed | 0;
for (let i = 255; i > 0; i--) {
s = (s * 1664525 + 1013904223) & 0xffffffff;
const j = ((s >>> 16) & 0xff) % (i + 1);
[p[i], p[j]] = [p[j], p[i]];
}
return [...p, ...p];
}
/* Quintic fade function — zero first and second derivative at endpoints. */
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
/* 1-D gradient — returns +x or -x based on the lowest hash bit. */
function grad(hash, x) {
return (hash & 1) === 0 ? x : -x;
}
/* Single-octave 1-D Perlin noise in [-1, 1]. */
function noise1d(perm, x) {
const X = Math.floor(x) & 255;
x -= Math.floor(x);
const u = fade(x);
return lerp(grad(perm[X], x), grad(perm[X + 1], x - 1), u);
}
/* Fractional Brownian Motion — sum octaves with geometrically shrinking amplitude. */
function fbm(perm, x, octaves, persistence, freq) {
let val = 0, amp = 1, max = 0, f = freq;
for (let k = 0; k < octaves; k++) {
val += noise1d(perm, x * f) * amp;
max += amp;
amp *= persistence;
f *= 2;
}
return val / max;
}
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Current permutation table (re-seeded on button click). */
let perm = buildPerm(Math.random() * 65536 | 0);
const canvas = document.getElementById('terrain');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
function getParams() {
return {
octaves: +document.getElementById('octaves').value,
persistence: +document.getElementById('persistence').value / 100,
freq: +document.getElementById('frequency').value,
};
}
/* Draw the terrain cross-section onto the canvas. */
function draw() {
const { octaves, persistence, freq } = getParams();
ctx.clearRect(0, 0, W, H);
/* Sky background gradient. */
const sky = ctx.createLinearGradient(0, 0, 0, H * 0.6);
sky.addColorStop(0, '#b8d4f0');
sky.addColorStop(1, '#ddeeff');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
/* Trace the heightmap silhouette. */
ctx.beginPath();
ctx.moveTo(0, H);
for (let px = 0; px <= W; px++) {
const n = fbm(perm, px / W, octaves, persistence, freq);
/* Map noise [-1,1] to canvas y — centre + scaled offset. */
const y = H * 0.35 - n * H * 0.28;
if (px === 0) ctx.lineTo(0, y); else ctx.lineTo(px, y);
}
ctx.lineTo(W, H);
ctx.closePath();
/* Fill terrain with a green-to-dark gradient. */
const land = ctx.createLinearGradient(0, H * 0.1, 0, H);
land.addColorStop(0, '#6aaa64');
land.addColorStop(0.4, '#4a7c45');
land.addColorStop(1, '#2d4a2a');
ctx.fillStyle = land;
ctx.fill();
/* Live caption showing current parameters. */
document.getElementById('caption').textContent =
fmt('%octaves% octaves · persistence %persistence% · frequency %freq% — each extra octave adds finer detail.', { octaves, persistence: persistence.toFixed(2), freq });
/* Sync slider value displays. */
document.getElementById('oct-val').textContent = octaves;
document.getElementById('per-val').textContent = persistence.toFixed(2);
document.getElementById('freq-val').textContent = freq;
}
/* Wire sliders and reseed button. */
['octaves', 'persistence', 'frequency'].forEach(id => {
document.getElementById(id).addEventListener('input', draw);
});
document.getElementById('reseed').addEventListener('click', () => {
perm = buildPerm(Math.random() * 65536 | 0);
draw();
});
draw();
Watch what happens as you add octaves: the first octave gives broad hills; the second adds medium ridges; the third carves out fine gullies. Each layer doubles the frequency and multiplies the amplitude by the persistence value. That exponential relationship is called fractional Brownian motion (fBm), and it is the reason both coastlines and mountain ranges obey the same statistical self-similarity at every scale.
Comments
Loading comments...