The canvas below renders a checkerboard floor receding into the distance — the classic aliasing stress-test. Toggle the switch to turn mipmapping on and off and watch what happens to the distant squares.
<!-- Mipmapping demo: a receding checkerboard floor, mips on vs off -->
<div class="controls">
<label class="toggle-label">
<span class="label-text">Mipmapping</span>
<span class="switch">
<input type="checkbox" id="mip-toggle" checked>
<span class="slider"></span>
</span>
</label>
<span id="mip-level-display" class="level-badge"></span>
</div>
<canvas id="canvas" width="560" height="320" title="Checkerboard floor receding into the distance"></canvas>
<p class="hint">Toggle mipmapping above. <b>Off</b>: the full-resolution texture is sampled at every distance — distant squares collapse into shimmering noise. <b>On</b>: the GPU picks the pre-blurred mip level that best matches the pixel's footprint on the floor.</p>
/* Layout and toggle switch styles for the mipmapping demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #111; color: #eee; padding: .6rem; }
.controls { display: flex; align-items: center; gap: 1rem; margin-bottom: .6rem; flex-wrap: wrap; }
.label-text { font-size: .95rem; font-weight: 600; }
.switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; background: #555; border-radius: 24px; transition: .25s; cursor: pointer; }
.slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px;
background: #fff; border-radius: 50%; transition: .25s; }
input:checked + .slider { background: #3b82f6; }
input:checked + .slider::before { transform: translateX(20px); }
.level-badge { font-size: .8rem; background: #1e293b; border: 1px solid #334155;
border-radius: 6px; padding: .15rem .55rem; min-width: 7rem; text-align: center; }
canvas { display: block; width: 100%; max-width: 560px; border-radius: 8px; image-rendering: pixelated; }
.hint { font-size: .82rem; color: #aaa; margin-top: .5rem; line-height: 1.5; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Checkerboard floor renderer — demonstrates mipmap level selection
const TEX_SIZE = 128; // base texture side length in texels
const MIP_LEVELS = 8; // total mip levels including base (log2(128)+1 = 8)
// Build the base 128x128 checkerboard texture in RGBA
function buildBaseTexture(size) {
const data = new Uint8Array(size * size * 4);
const sq = size / 8; // checkerboard square size in texels
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const cell = (Math.floor(x / sq) + Math.floor(y / sq)) % 2;
const v = cell === 0 ? 240 : 30;
data[idx] = v; data[idx+1] = v; data[idx+2] = v; data[idx+3] = 255;
}
}
return { data, size };
}
// Build the full mip chain by successive 2x2 box-filter downsamples
function buildMipChain(base) {
const chain = [base];
let prev = base;
while (prev.size > 1) {
const s = prev.size >> 1;
const d = new Uint8Array(s * s * 4);
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const idx = (y * s + x) * 4;
const p = (y * 2 * prev.size + x * 2) * 4;
for (let c = 0; c < 4; c++) {
// 2x2 box filter average
d[idx+c] = (prev.data[p+c] + prev.data[p+4+c] +
prev.data[p + prev.size*4 + c] + prev.data[p + prev.size*4+4+c]) >> 2;
}
}
}
chain.push({ data: d, size: s });
prev = { data: d, size: s };
}
return chain;
}
// Nearest-texel sample from a given mip level
function sampleNearest(mip, u, v) {
const s = mip.size;
const tx = Math.min(Math.floor(u * s), s - 1);
const ty = Math.min(Math.floor(v * s), s - 1);
const p = (ty * s + tx) * 4;
return [mip.data[p], mip.data[p+1], mip.data[p+2]];
}
const mips = buildMipChain(buildBaseTexture(TEX_SIZE));
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const imgData = ctx.createImageData(W, H);
const toggle = document.getElementById('mip-toggle');
const badge = document.getElementById('mip-level-display');
// Floor projection: for each screen row below the horizon,
// project the pixel onto the world XZ plane and compute the mip level.
const CAM_Y = 1.6; // camera height above floor
const CAM_Z = 2.0; // camera forward depth offset
const FOV = 0.65; // field-of-view scale factor
const HORIZON = Math.floor(H * 0.40); // screen row of the horizon
function render(useMips) {
let maxLevel = 0;
for (let py = HORIZON; py < H; py++) {
const relY = (py - HORIZON) / (H - HORIZON); // relative depth below horizon (0 at horizon, 1 at bottom)
const dist = CAM_Y / (relY * FOV + 0.001); // approximate world-space distance to floor at this row
const tileSpan = dist / TEX_SIZE; // how many screen pixels span one texel at this distance
const rawLevel = Math.log2(Math.max(tileSpan, 0.5));
const mipLevel = useMips ? Math.min(Math.max(Math.round(rawLevel), 0), mips.length - 1) : 0;
if (mipLevel > maxLevel) maxLevel = mipLevel;
for (let px = 0; px < W; px++) {
const ndcX = (px / W - 0.5) * 2.0;
const worldX = CAM_Z + dist * ndcX * FOV;
const worldZ = dist;
const u = ((worldX % 1.0) + 1.0) % 1.0;
const v = ((worldZ % 1.0) + 1.0) % 1.0;
const [r, g, b] = sampleNearest(mips[mipLevel], u, v);
const i = (py * W + px) * 4;
imgData.data[i] = r; imgData.data[i+1] = g; imgData.data[i+2] = b; imgData.data[i+3] = 255;
}
}
// Paint a simple gradient sky above the horizon
for (let py = 0; py < HORIZON; py++) {
const grad = Math.floor(30 + (py / HORIZON) * 40);
for (let px = 0; px < W; px++) {
const i = (py * W + px) * 4;
imgData.data[i] = grad; imgData.data[i+1] = grad + 20; imgData.data[i+2] = grad + 50; imgData.data[i+3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
if (useMips) {
badge.textContent = i18nFmt('Mip level in use: %level%', { level: maxLevel });
} else {
badge.textContent = 'Mips OFF — full resolution';
}
}
function redraw() { render(toggle.checked); }
toggle.addEventListener('change', redraw);
redraw();
<!-- Mipmapping demo: a receding checkerboard floor, mips on vs off -->
<div class="controls">
<label class="toggle-label">
<span class="label-text">Mipmapping</span>
<span class="switch">
<input type="checkbox" id="mip-toggle" checked>
<span class="slider"></span>
</span>
</label>
<span id="mip-level-display" class="level-badge"></span>
</div>
<canvas id="canvas" width="560" height="320" title="Checkerboard floor receding into the distance"></canvas>
<p class="hint">Toggle mipmapping above. <b>Off</b>: the full-resolution texture is sampled at every distance — distant squares collapse into shimmering noise. <b>On</b>: the GPU picks the pre-blurred mip level that best matches the pixel's footprint on the floor.</p>
/* Layout and toggle switch styles for the mipmapping demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #111; color: #eee; padding: .6rem; }
.controls { display: flex; align-items: center; gap: 1rem; margin-bottom: .6rem; flex-wrap: wrap; }
.label-text { font-size: .95rem; font-weight: 600; }
.switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; background: #555; border-radius: 24px; transition: .25s; cursor: pointer; }
.slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px;
background: #fff; border-radius: 50%; transition: .25s; }
input:checked + .slider { background: #3b82f6; }
input:checked + .slider::before { transform: translateX(20px); }
.level-badge { font-size: .8rem; background: #1e293b; border: 1px solid #334155;
border-radius: 6px; padding: .15rem .55rem; min-width: 7rem; text-align: center; }
canvas { display: block; width: 100%; max-width: 560px; border-radius: 8px; image-rendering: pixelated; }
.hint { font-size: .82rem; color: #aaa; margin-top: .5rem; line-height: 1.5; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Checkerboard floor renderer — demonstrates mipmap level selection
const TEX_SIZE = 128; // base texture side length in texels
const MIP_LEVELS = 8; // total mip levels including base (log2(128)+1 = 8)
// Build the base 128x128 checkerboard texture in RGBA
function buildBaseTexture(size) {
const data = new Uint8Array(size * size * 4);
const sq = size / 8; // checkerboard square size in texels
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const cell = (Math.floor(x / sq) + Math.floor(y / sq)) % 2;
const v = cell === 0 ? 240 : 30;
data[idx] = v; data[idx+1] = v; data[idx+2] = v; data[idx+3] = 255;
}
}
return { data, size };
}
// Build the full mip chain by successive 2x2 box-filter downsamples
function buildMipChain(base) {
const chain = [base];
let prev = base;
while (prev.size > 1) {
const s = prev.size >> 1;
const d = new Uint8Array(s * s * 4);
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const idx = (y * s + x) * 4;
const p = (y * 2 * prev.size + x * 2) * 4;
for (let c = 0; c < 4; c++) {
// 2x2 box filter average
d[idx+c] = (prev.data[p+c] + prev.data[p+4+c] +
prev.data[p + prev.size*4 + c] + prev.data[p + prev.size*4+4+c]) >> 2;
}
}
}
chain.push({ data: d, size: s });
prev = { data: d, size: s };
}
return chain;
}
// Nearest-texel sample from a given mip level
function sampleNearest(mip, u, v) {
const s = mip.size;
const tx = Math.min(Math.floor(u * s), s - 1);
const ty = Math.min(Math.floor(v * s), s - 1);
const p = (ty * s + tx) * 4;
return [mip.data[p], mip.data[p+1], mip.data[p+2]];
}
const mips = buildMipChain(buildBaseTexture(TEX_SIZE));
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const imgData = ctx.createImageData(W, H);
const toggle = document.getElementById('mip-toggle');
const badge = document.getElementById('mip-level-display');
// Floor projection: for each screen row below the horizon,
// project the pixel onto the world XZ plane and compute the mip level.
const CAM_Y = 1.6; // camera height above floor
const CAM_Z = 2.0; // camera forward depth offset
const FOV = 0.65; // field-of-view scale factor
const HORIZON = Math.floor(H * 0.40); // screen row of the horizon
function render(useMips) {
let maxLevel = 0;
for (let py = HORIZON; py < H; py++) {
const relY = (py - HORIZON) / (H - HORIZON); // relative depth below horizon (0 at horizon, 1 at bottom)
const dist = CAM_Y / (relY * FOV + 0.001); // approximate world-space distance to floor at this row
const tileSpan = dist / TEX_SIZE; // how many screen pixels span one texel at this distance
const rawLevel = Math.log2(Math.max(tileSpan, 0.5));
const mipLevel = useMips ? Math.min(Math.max(Math.round(rawLevel), 0), mips.length - 1) : 0;
if (mipLevel > maxLevel) maxLevel = mipLevel;
for (let px = 0; px < W; px++) {
const ndcX = (px / W - 0.5) * 2.0;
const worldX = CAM_Z + dist * ndcX * FOV;
const worldZ = dist;
const u = ((worldX % 1.0) + 1.0) % 1.0;
const v = ((worldZ % 1.0) + 1.0) % 1.0;
const [r, g, b] = sampleNearest(mips[mipLevel], u, v);
const i = (py * W + px) * 4;
imgData.data[i] = r; imgData.data[i+1] = g; imgData.data[i+2] = b; imgData.data[i+3] = 255;
}
}
// Paint a simple gradient sky above the horizon
for (let py = 0; py < HORIZON; py++) {
const grad = Math.floor(30 + (py / HORIZON) * 40);
for (let px = 0; px < W; px++) {
const i = (py * W + px) * 4;
imgData.data[i] = grad; imgData.data[i+1] = grad + 20; imgData.data[i+2] = grad + 50; imgData.data[i+3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
if (useMips) {
badge.textContent = i18nFmt('Mip level in use: %level%', { level: maxLevel });
} else {
badge.textContent = 'Mips OFF — full resolution';
}
}
function redraw() { render(toggle.checked); }
toggle.addEventListener('change', redraw);
redraw();
With mips off, the renderer samples the full-resolution texture for every pixel regardless of distance. Far away, dozens of high-contrast texels collapse into one pixel and you get the characteristic shimmer. With mips on, the renderer picks a pre-blurred level where roughly one texel maps to one pixel — the floor stays smooth all the way to the horizon.
Comments
Loading comments...