The canvas below renders a bumpy heightfield top-down. Each pixel's brightness normally reflects only its height. Toggle Ambient Occlusion to add the AO pass: the algorithm fires sample rays around each pixel and darkens it proportionally to how many neighbors are higher — simulating how a valley can barely see the sky.
<!-- Controls and canvas for the ambient occlusion heightfield demo -->
<div class="controls">
<label class="toggle-label">
<input type="checkbox" id="aoToggle" checked>
<span>Ambient Occlusion</span>
</label>
<label class="slider-label" title="How strongly the AO darkens occluded areas">
Strength: <input type="range" id="strengthSlider" min="0.2" max="2.0" step="0.1" value="1.0">
<span id="strengthVal">1.0</span>
</label>
<label class="slider-label" title="Number of sample rays per pixel — more samples = smoother but slower">
Samples: <input type="range" id="samplesSlider" min="4" max="32" step="4" value="16">
<span id="samplesVal">16</span>
</label>
</div>
<canvas id="canvas"></canvas>
<p class="hint">Toggle Ambient Occlusion to see how valleys and creases darken. Peaks that see the full sky stay bright; crevices that see almost none go dark.</p>
/* Styles for the AO demo — dark background to make the AO effect visible */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: .6rem; }
.controls {
display: flex; flex-wrap: wrap; gap: .5rem .9rem; align-items: center;
margin-bottom: .5rem; font-size: .85rem;
}
.toggle-label { display: flex; align-items: center; gap: .4rem; cursor: pointer; font-weight: 600; color: #a8d8ea; }
.toggle-label input { width: 1.1rem; height: 1.1rem; accent-color: #a8d8ea; cursor: pointer; }
.slider-label { display: flex; align-items: center; gap: .35rem; color: #ccc; }
.slider-label input[type=range] { width: 90px; accent-color: #a8d8ea; cursor: pointer; }
.slider-label span { min-width: 1.8rem; text-align: right; font-variant-numeric: tabular-nums; color: #a8d8ea; }
canvas { display: block; width: 100%; max-width: 520px; height: 320px; border-radius: 8px; image-rendering: pixelated; }
.hint { font-size: .78rem; color: #888; margin-top: .45rem; line-height: 1.4; }
// Ambient Occlusion heightfield demo — top-down view of a procedural terrain
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Canvas resolution (logical pixels — CSS stretches it to fill the container)
const W = 130, H = 80;
canvas.width = W;
canvas.height = H;
// Generate a bumpy heightfield using summed sinusoids at multiple frequencies
// Multiple octaves give large hills and small crinkles, like real terrain
function buildHeightfield() {
const h = new Float32Array(W * H);
// Each octave contributes waves at a different frequency and amplitude
const octaves = [
{ freq: 0.05, amp: 1.0 },
{ freq: 0.12, amp: 0.45 },
{ freq: 0.25, amp: 0.22 },
{ freq: 0.55, amp: 0.10 }
];
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
let v = 0;
for (const o of octaves) {
// Two overlapping sine/cosine pairs per octave break up obvious repetition
v += Math.sin(x * o.freq + 1.3) * Math.cos(y * o.freq * 1.1 + 0.7) * o.amp;
v += Math.sin(x * o.freq * 1.4 + 2.1) * Math.cos(y * o.freq * 0.9 + 1.5) * o.amp * 0.6;
}
h[y * W + x] = v;
}
}
// Normalize all heights to the 0-1 range so colors span the full brightness
let mn = Infinity, mx = -Infinity;
for (const v of h) { if (v < mn) mn = v; if (v > mx) mx = v; }
for (let i = 0; i < h.length; i++) h[i] = (h[i] - mn) / (mx - mn);
return h;
}
const HEIGHT = buildHeightfield();
// Compute per-pixel ambient occlusion by sampling neighbors in a ring
// If a neighbor is higher than the current pixel, it blocks the sky from that direction
function computeAO(field, W, H, numSamples, radius) {
const ao = new Float32Array(W * H);
// Pre-compute sample directions evenly spaced around the full circle
const offsets = [];
for (let i = 0; i < numSamples; i++) {
const angle = (i / numSamples) * 2 * Math.PI;
const r = radius * (0.4 + 0.6 * (i / numSamples));
offsets.push([Math.cos(angle) * r, Math.sin(angle) * r]);
}
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const selfH = field[y * W + x];
let occluded = 0;
for (const [dx, dy] of offsets) {
const nx = Math.round(x + dx);
const ny = Math.round(y + dy);
if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
// Neighbor is higher — it occludes the sky from this direction
if (field[ny * W + nx] > selfH) occluded++;
}
ao[y * W + x] = occluded / offsets.length;
}
}
return ao;
}
// Render the heightfield with optional AO darkening
function render() {
const useAO = document.getElementById('aoToggle').checked;
const strength = parseFloat(document.getElementById('strengthSlider').value);
const numSamples = parseInt(document.getElementById('samplesSlider').value, 10);
const radius = 8;
const ao = useAO ? computeAO(HEIGHT, W, H, numSamples, radius) : null;
const img = ctx.createImageData(W, H);
for (let i = 0; i < W * H; i++) {
const h = HEIGHT[i];
// Map height to a blue-toned gray base color
const base = 40 + h * 180;
let r = base, g = base * 0.85 + 20, b = base * 0.7 + 40;
if (useAO) {
// Multiply base color by (1 - occlusion * strength) to darken occluded spots
const factor = 1.0 - ao[i] * strength * 0.75;
const f = Math.max(0, Math.min(1, factor));
r *= f; g *= f; b *= f;
}
img.data[i * 4 + 0] = Math.round(r);
img.data[i * 4 + 1] = Math.round(g);
img.data[i * 4 + 2] = Math.round(b);
img.data[i * 4 + 3] = 255;
}
ctx.putImageData(img, 0, 0);
}
// Wire up controls and do the first render
const strengthSlider = document.getElementById('strengthSlider');
const samplesSlider = document.getElementById('samplesSlider');
const strengthVal = document.getElementById('strengthVal');
const samplesVal = document.getElementById('samplesVal');
strengthSlider.addEventListener('input', () => {
strengthVal.textContent = parseFloat(strengthSlider.value).toFixed(1);
render();
});
samplesSlider.addEventListener('input', () => {
samplesVal.textContent = samplesSlider.value;
render();
});
document.getElementById('aoToggle').addEventListener('change', render);
render();
<!-- Controls and canvas for the ambient occlusion heightfield demo -->
<div class="controls">
<label class="toggle-label">
<input type="checkbox" id="aoToggle" checked>
<span>Ambient Occlusion</span>
</label>
<label class="slider-label" title="How strongly the AO darkens occluded areas">
Strength: <input type="range" id="strengthSlider" min="0.2" max="2.0" step="0.1" value="1.0">
<span id="strengthVal">1.0</span>
</label>
<label class="slider-label" title="Number of sample rays per pixel — more samples = smoother but slower">
Samples: <input type="range" id="samplesSlider" min="4" max="32" step="4" value="16">
<span id="samplesVal">16</span>
</label>
</div>
<canvas id="canvas"></canvas>
<p class="hint">Toggle Ambient Occlusion to see how valleys and creases darken. Peaks that see the full sky stay bright; crevices that see almost none go dark.</p>
/* Styles for the AO demo — dark background to make the AO effect visible */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: .6rem; }
.controls {
display: flex; flex-wrap: wrap; gap: .5rem .9rem; align-items: center;
margin-bottom: .5rem; font-size: .85rem;
}
.toggle-label { display: flex; align-items: center; gap: .4rem; cursor: pointer; font-weight: 600; color: #a8d8ea; }
.toggle-label input { width: 1.1rem; height: 1.1rem; accent-color: #a8d8ea; cursor: pointer; }
.slider-label { display: flex; align-items: center; gap: .35rem; color: #ccc; }
.slider-label input[type=range] { width: 90px; accent-color: #a8d8ea; cursor: pointer; }
.slider-label span { min-width: 1.8rem; text-align: right; font-variant-numeric: tabular-nums; color: #a8d8ea; }
canvas { display: block; width: 100%; max-width: 520px; height: 320px; border-radius: 8px; image-rendering: pixelated; }
.hint { font-size: .78rem; color: #888; margin-top: .45rem; line-height: 1.4; }
// Ambient Occlusion heightfield demo — top-down view of a procedural terrain
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Canvas resolution (logical pixels — CSS stretches it to fill the container)
const W = 130, H = 80;
canvas.width = W;
canvas.height = H;
// Generate a bumpy heightfield using summed sinusoids at multiple frequencies
// Multiple octaves give large hills and small crinkles, like real terrain
function buildHeightfield() {
const h = new Float32Array(W * H);
// Each octave contributes waves at a different frequency and amplitude
const octaves = [
{ freq: 0.05, amp: 1.0 },
{ freq: 0.12, amp: 0.45 },
{ freq: 0.25, amp: 0.22 },
{ freq: 0.55, amp: 0.10 }
];
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
let v = 0;
for (const o of octaves) {
// Two overlapping sine/cosine pairs per octave break up obvious repetition
v += Math.sin(x * o.freq + 1.3) * Math.cos(y * o.freq * 1.1 + 0.7) * o.amp;
v += Math.sin(x * o.freq * 1.4 + 2.1) * Math.cos(y * o.freq * 0.9 + 1.5) * o.amp * 0.6;
}
h[y * W + x] = v;
}
}
// Normalize all heights to the 0-1 range so colors span the full brightness
let mn = Infinity, mx = -Infinity;
for (const v of h) { if (v < mn) mn = v; if (v > mx) mx = v; }
for (let i = 0; i < h.length; i++) h[i] = (h[i] - mn) / (mx - mn);
return h;
}
const HEIGHT = buildHeightfield();
// Compute per-pixel ambient occlusion by sampling neighbors in a ring
// If a neighbor is higher than the current pixel, it blocks the sky from that direction
function computeAO(field, W, H, numSamples, radius) {
const ao = new Float32Array(W * H);
// Pre-compute sample directions evenly spaced around the full circle
const offsets = [];
for (let i = 0; i < numSamples; i++) {
const angle = (i / numSamples) * 2 * Math.PI;
const r = radius * (0.4 + 0.6 * (i / numSamples));
offsets.push([Math.cos(angle) * r, Math.sin(angle) * r]);
}
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const selfH = field[y * W + x];
let occluded = 0;
for (const [dx, dy] of offsets) {
const nx = Math.round(x + dx);
const ny = Math.round(y + dy);
if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
// Neighbor is higher — it occludes the sky from this direction
if (field[ny * W + nx] > selfH) occluded++;
}
ao[y * W + x] = occluded / offsets.length;
}
}
return ao;
}
// Render the heightfield with optional AO darkening
function render() {
const useAO = document.getElementById('aoToggle').checked;
const strength = parseFloat(document.getElementById('strengthSlider').value);
const numSamples = parseInt(document.getElementById('samplesSlider').value, 10);
const radius = 8;
const ao = useAO ? computeAO(HEIGHT, W, H, numSamples, radius) : null;
const img = ctx.createImageData(W, H);
for (let i = 0; i < W * H; i++) {
const h = HEIGHT[i];
// Map height to a blue-toned gray base color
const base = 40 + h * 180;
let r = base, g = base * 0.85 + 20, b = base * 0.7 + 40;
if (useAO) {
// Multiply base color by (1 - occlusion * strength) to darken occluded spots
const factor = 1.0 - ao[i] * strength * 0.75;
const f = Math.max(0, Math.min(1, factor));
r *= f; g *= f; b *= f;
}
img.data[i * 4 + 0] = Math.round(r);
img.data[i * 4 + 1] = Math.round(g);
img.data[i * 4 + 2] = Math.round(b);
img.data[i * 4 + 3] = 255;
}
ctx.putImageData(img, 0, 0);
}
// Wire up controls and do the first render
const strengthSlider = document.getElementById('strengthSlider');
const samplesSlider = document.getElementById('samplesSlider');
const strengthVal = document.getElementById('strengthVal');
const samplesVal = document.getElementById('samplesVal');
strengthSlider.addEventListener('input', () => {
strengthVal.textContent = parseFloat(strengthSlider.value).toFixed(1);
render();
});
samplesSlider.addEventListener('input', () => {
samplesVal.textContent = samplesSlider.value;
render();
});
document.getElementById('aoToggle').addEventListener('change', render);
render();
Notice how peaks and ridges stay bright while valleys and tight crevices go dark. The Strength slider controls how aggressively the darkening is applied; the Samples count trades quality for speed. Even with very few samples the effect is convincing because it only needs to approximate an integral — a rough Monte Carlo estimate is already visually compelling.
Comments
Loading comments...