The canvas below renders two overlapping colored quads in a simple software rasterizer. Use the toggle to enable or disable the depth buffer.
<!-- Demo controls and canvas -->
<div class="controls">
<label class="toggle-label">
<input type="checkbox" id="depthToggle" checked />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span id="toggleText">Depth buffer ON</span>
</label>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<canvas id="canvas" width="400" height="280"></canvas>
<div id="info" class="info">Toggle the depth buffer to see the difference.</div>
<div class="legend">
<span class="leg-item"><span class="leg-swatch green"></span> Green quad (near, z=0.3)</span>
<span class="leg-item"><span class="leg-swatch red"></span> Red quad (far, z=0.8)</span>
</div>
/* Styles for the z-buffer demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #f7f9fb; }
.controls { display: flex; align-items: center; gap: 1rem; margin-bottom: .7rem; flex-wrap: wrap; }
.toggle-label { display: flex; align-items: center; gap: .5rem; cursor: pointer; font-weight: 600; font-size: .95rem; }
.toggle-track { width: 44px; height: 24px; border-radius: 12px; background: #aaa; position: relative; transition: background .2s; flex-shrink: 0; }
input[type=checkbox]:checked + .toggle-track { background: #1d6fa5; }
.toggle-thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left .2s; box-shadow: 0 1px 3px #0003; }
input[type=checkbox]:checked + .toggle-track .toggle-thumb { left: 23px; }
input[type=checkbox] { display: none; }
canvas { display: block; width: 100%; max-width: 400px; border-radius: 10px; border: 1px solid #cdd9e3; background: #1a1a2e; }
.info { font-size: .88rem; color: #555; margin-top: .5rem; min-height: 1.3em; }
.legend { display: flex; gap: 1.2rem; margin-top: .5rem; font-size: .85rem; }
.leg-item { display: flex; align-items: center; gap: .35rem; }
.leg-swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
.leg-swatch.green { background: #4ade80; }
.leg-swatch.red { background: #f87171; }
button.ghost { font: 600 13px system-ui, sans-serif; padding: .35rem .8rem; border: 1px solid #1d3557; background: #fff; color: #1d3557; border-radius: 8px; cursor: pointer; }
// Software rasterizer with optional per-pixel depth test
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
// Two overlapping quads. Red is far (z=0.8), green is near (z=0.3).
// Each vertex has x, y screen coords and a z depth value (0=near, 1=far).
// Lower z = closer to camera.
const QUADS = [
{
verts: [ {x:60,y:180,z:0.8}, {x:260,y:180,z:0.8}, {x:220,y:60,z:0.8}, {x:100,y:60,z:0.8} ],
color: [248, 113, 113]
},
{
verts: [ {x:140,y:230,z:0.3}, {x:340,y:230,z:0.3}, {x:300,y:90,z:0.3}, {x:180,y:90,z:0.3} ],
color: [74, 222, 128]
}
];
// Allocate z-buffer (one float per pixel, initialized to Infinity = nothing drawn yet).
let zBuffer = new Float32Array(W * H);
let pixelData;
function resetBuffers() {
// Reset z-buffer to Infinity and fill canvas with dark background.
zBuffer.fill(Infinity);
const imageData = ctx.createImageData(W, H);
pixelData = imageData.data;
// Fill background color (dark navy).
for (let i = 0; i < pixelData.length; i += 4) {
pixelData[i] = 26;
pixelData[i + 1] = 26;
pixelData[i + 2] = 46;
pixelData[i + 3] = 255;
}
return imageData;
}
// Edge function: positive means point is on the left side of the edge AB.
function edgeFn(ax, ay, bx, by, px, py) {
return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
}
// Bounding box of a triangle: only test pixels inside it.
function boundingBox(verts) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const v of verts) {
if (v.x < minX) minX = v.x;
if (v.y < minY) minY = v.y;
if (v.x > maxX) maxX = v.x;
if (v.y > maxY) maxY = v.y;
}
return {
minX: Math.max(0, Math.floor(minX)),
minY: Math.max(0, Math.floor(minY)),
maxX: Math.min(W - 1, Math.ceil(maxX)),
maxY: Math.min(H - 1, Math.ceil(maxY))
};
}
// Rasterize a quad (split into 2 triangles). For each pixel in the bounding box,
// test if it is inside the triangle and perform the depth test.
function rasterizeQuad(quad, imageData, useDepth) {
const v = quad.verts;
const [r, g, b] = quad.color;
// Split quad into two triangles.
const tris = [
[v[0], v[1], v[2]],
[v[0], v[2], v[3]]
];
for (const [a, b2, c] of tris) {
const box = boundingBox([a, b2, c]);
const area = edgeFn(a.x, a.y, b2.x, b2.y, c.x, c.y);
if (Math.abs(area) < 0.0001) continue;
for (let py = box.minY; py <= box.maxY; py++) {
for (let px = box.minX; px <= box.maxX; px++) {
// Barycentric weights — if all same sign as area, pixel is inside the triangle.
const w0 = edgeFn(a.x, a.y, b2.x, b2.y, px + 0.5, py + 0.5);
const w1 = edgeFn(b2.x, b2.y, c.x, c.y, px + 0.5, py + 0.5);
const w2 = edgeFn(c.x, c.y, a.x, a.y, px + 0.5, py + 0.5);
const inside = (area > 0)
? (w0 >= 0 && w1 >= 0 && w2 >= 0)
: (w0 <= 0 && w1 <= 0 && w2 <= 0);
if (!inside) continue;
// Interpolate depth using barycentric coordinates.
const bA = w1 / area;
const bB = w2 / area;
const bC = w0 / area;
const z = bA * a.z + bB * b2.z + bC * c.z;
const idx = py * W + px;
if (useDepth && z >= zBuffer[idx]) continue;
if (useDepth) zBuffer[idx] = z;
const p = idx * 4;
imageData.data[p] = r;
imageData.data[p + 1] = g;
imageData.data[p + 2] = b;
imageData.data[p + 3] = 255;
}
}
}
}
function draw() {
const useDepth = document.getElementById('depthToggle').checked;
const imageData = resetBuffers();
for (const quad of QUADS) {
rasterizeQuad(quad, imageData, useDepth);
}
ctx.putImageData(imageData, 0, 0);
const info = document.getElementById('info');
if (useDepth) {
info.textContent = 'Depth buffer ON: green (z=0.3) correctly covers red (z=0.8) in the overlap.';
} else {
info.textContent = 'Depth buffer OFF: draw order decides — red is drawn first, so green paints over it everywhere (correct by accident here, but order is arbitrary).';
}
document.getElementById('toggleText').textContent = useDepth
? 'Depth buffer ON'
: 'Depth buffer OFF';
}
document.getElementById('depthToggle').addEventListener('change', draw);
document.getElementById('resetBtn').addEventListener('click', () => {
document.getElementById('depthToggle').checked = true;
draw();
});
draw();
<!-- Demo controls and canvas -->
<div class="controls">
<label class="toggle-label">
<input type="checkbox" id="depthToggle" checked />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span id="toggleText">Depth buffer ON</span>
</label>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<canvas id="canvas" width="400" height="280"></canvas>
<div id="info" class="info">Toggle the depth buffer to see the difference.</div>
<div class="legend">
<span class="leg-item"><span class="leg-swatch green"></span> Green quad (near, z=0.3)</span>
<span class="leg-item"><span class="leg-swatch red"></span> Red quad (far, z=0.8)</span>
</div>
/* Styles for the z-buffer demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #f7f9fb; }
.controls { display: flex; align-items: center; gap: 1rem; margin-bottom: .7rem; flex-wrap: wrap; }
.toggle-label { display: flex; align-items: center; gap: .5rem; cursor: pointer; font-weight: 600; font-size: .95rem; }
.toggle-track { width: 44px; height: 24px; border-radius: 12px; background: #aaa; position: relative; transition: background .2s; flex-shrink: 0; }
input[type=checkbox]:checked + .toggle-track { background: #1d6fa5; }
.toggle-thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left .2s; box-shadow: 0 1px 3px #0003; }
input[type=checkbox]:checked + .toggle-track .toggle-thumb { left: 23px; }
input[type=checkbox] { display: none; }
canvas { display: block; width: 100%; max-width: 400px; border-radius: 10px; border: 1px solid #cdd9e3; background: #1a1a2e; }
.info { font-size: .88rem; color: #555; margin-top: .5rem; min-height: 1.3em; }
.legend { display: flex; gap: 1.2rem; margin-top: .5rem; font-size: .85rem; }
.leg-item { display: flex; align-items: center; gap: .35rem; }
.leg-swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
.leg-swatch.green { background: #4ade80; }
.leg-swatch.red { background: #f87171; }
button.ghost { font: 600 13px system-ui, sans-serif; padding: .35rem .8rem; border: 1px solid #1d3557; background: #fff; color: #1d3557; border-radius: 8px; cursor: pointer; }
// Software rasterizer with optional per-pixel depth test
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
// Two overlapping quads. Red is far (z=0.8), green is near (z=0.3).
// Each vertex has x, y screen coords and a z depth value (0=near, 1=far).
// Lower z = closer to camera.
const QUADS = [
{
verts: [ {x:60,y:180,z:0.8}, {x:260,y:180,z:0.8}, {x:220,y:60,z:0.8}, {x:100,y:60,z:0.8} ],
color: [248, 113, 113]
},
{
verts: [ {x:140,y:230,z:0.3}, {x:340,y:230,z:0.3}, {x:300,y:90,z:0.3}, {x:180,y:90,z:0.3} ],
color: [74, 222, 128]
}
];
// Allocate z-buffer (one float per pixel, initialized to Infinity = nothing drawn yet).
let zBuffer = new Float32Array(W * H);
let pixelData;
function resetBuffers() {
// Reset z-buffer to Infinity and fill canvas with dark background.
zBuffer.fill(Infinity);
const imageData = ctx.createImageData(W, H);
pixelData = imageData.data;
// Fill background color (dark navy).
for (let i = 0; i < pixelData.length; i += 4) {
pixelData[i] = 26;
pixelData[i + 1] = 26;
pixelData[i + 2] = 46;
pixelData[i + 3] = 255;
}
return imageData;
}
// Edge function: positive means point is on the left side of the edge AB.
function edgeFn(ax, ay, bx, by, px, py) {
return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
}
// Bounding box of a triangle: only test pixels inside it.
function boundingBox(verts) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const v of verts) {
if (v.x < minX) minX = v.x;
if (v.y < minY) minY = v.y;
if (v.x > maxX) maxX = v.x;
if (v.y > maxY) maxY = v.y;
}
return {
minX: Math.max(0, Math.floor(minX)),
minY: Math.max(0, Math.floor(minY)),
maxX: Math.min(W - 1, Math.ceil(maxX)),
maxY: Math.min(H - 1, Math.ceil(maxY))
};
}
// Rasterize a quad (split into 2 triangles). For each pixel in the bounding box,
// test if it is inside the triangle and perform the depth test.
function rasterizeQuad(quad, imageData, useDepth) {
const v = quad.verts;
const [r, g, b] = quad.color;
// Split quad into two triangles.
const tris = [
[v[0], v[1], v[2]],
[v[0], v[2], v[3]]
];
for (const [a, b2, c] of tris) {
const box = boundingBox([a, b2, c]);
const area = edgeFn(a.x, a.y, b2.x, b2.y, c.x, c.y);
if (Math.abs(area) < 0.0001) continue;
for (let py = box.minY; py <= box.maxY; py++) {
for (let px = box.minX; px <= box.maxX; px++) {
// Barycentric weights — if all same sign as area, pixel is inside the triangle.
const w0 = edgeFn(a.x, a.y, b2.x, b2.y, px + 0.5, py + 0.5);
const w1 = edgeFn(b2.x, b2.y, c.x, c.y, px + 0.5, py + 0.5);
const w2 = edgeFn(c.x, c.y, a.x, a.y, px + 0.5, py + 0.5);
const inside = (area > 0)
? (w0 >= 0 && w1 >= 0 && w2 >= 0)
: (w0 <= 0 && w1 <= 0 && w2 <= 0);
if (!inside) continue;
// Interpolate depth using barycentric coordinates.
const bA = w1 / area;
const bB = w2 / area;
const bC = w0 / area;
const z = bA * a.z + bB * b2.z + bC * c.z;
const idx = py * W + px;
if (useDepth && z >= zBuffer[idx]) continue;
if (useDepth) zBuffer[idx] = z;
const p = idx * 4;
imageData.data[p] = r;
imageData.data[p + 1] = g;
imageData.data[p + 2] = b;
imageData.data[p + 3] = 255;
}
}
}
}
function draw() {
const useDepth = document.getElementById('depthToggle').checked;
const imageData = resetBuffers();
for (const quad of QUADS) {
rasterizeQuad(quad, imageData, useDepth);
}
ctx.putImageData(imageData, 0, 0);
const info = document.getElementById('info');
if (useDepth) {
info.textContent = 'Depth buffer ON: green (z=0.3) correctly covers red (z=0.8) in the overlap.';
} else {
info.textContent = 'Depth buffer OFF: draw order decides — red is drawn first, so green paints over it everywhere (correct by accident here, but order is arbitrary).';
}
document.getElementById('toggleText').textContent = useDepth
? 'Depth buffer ON'
: 'Depth buffer OFF';
}
document.getElementById('depthToggle').addEventListener('change', draw);
document.getElementById('resetBtn').addEventListener('click', () => {
document.getElementById('depthToggle').checked = true;
draw();
});
draw();
Comments
Loading comments...