Below is a triangle spinning slowly on a pixel grid. Each frame, the algorithm scans from the topmost vertex to the bottommost, one row at a time, and fills the pixels between the left and right edge crossings.
<!-- Main layout — controls above the pixel canvas -->
<div class="controls">
<button id="btn-play" type="button">Play</button>
<button id="btn-step" type="button">Step</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<label class="speed-label">Speed:
<input id="speed" type="range" min="1" max="10" value="4">
</label>
</div>
<canvas id="canvas" width="320" height="280" title="Scanline rasterization of a spinning triangle"></canvas>
<div id="info" class="info">Press Step or Play to start filling.</div>
/* Minimal style for the scanline rasterization demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; display: flex; flex-direction: column; align-items: center; gap: .5rem; padding: .5rem; }
.controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; justify-content: center; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.speed-label { font-size: .82rem; color: #555; display: flex; align-items: center; gap: .35rem; }
#speed { width: 80px; cursor: pointer; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; display: block; background: #f5f8fb; }
.info { font-size: .85rem; color: #444; min-height: 1.3em; text-align: center; }
// Scanline rasterization demo: spin a triangle and fill it row by row
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const btnPlay = document.getElementById('btn-play');
const btnStep = document.getElementById('btn-step');
const btnReset = document.getElementById('btn-reset');
const speedSlider = document.getElementById('speed');
const info = document.getElementById('info');
// Grid dimensions — each cell is one logical pixel
const COLS = 16, ROWS = 14;
const CW = canvas.width / COLS, CH = canvas.height / ROWS;
// Animation state
let angle = 0;
let scanY = null; // current scanline row being filled (null = show full fill)
let filled = []; // filled pixel cells for this frame
let playing = false;
let animId = null;
let lastTime = 0;
let tickAcc = 0;
// Equilateral triangle centered on the canvas, rotating with angle a
function triVertices(a) {
const cx = canvas.width / 2, cy = canvas.height / 2;
const rx = 100, ry = 90;
const offsets = [0, 2.094, 4.189]; // 0, 2π/3, 4π/3 — vertices of equilateral triangle
return offsets.map(d => ({
x: cx + rx * Math.cos(a + d),
y: cy + ry * Math.sin(a + d)
}));
}
// Rasterize triangle verts → array of {row, col} pixel cells
function rasterize(verts) {
// Sort vertices top-to-bottom
const v = [...verts].sort((a, b) => a.y - b.y);
const [v0, v1, v2] = v;
const result = [];
// Interpolate x on edge (ya,xa)→(yb,xb) at row y
function edgeX(ya, yb, xa, xb, y) {
if (yb === ya) return xa;
return xa + (xb - xa) * (y - ya) / (yb - ya);
}
const yMin = Math.ceil(v0.y / CH), yMax = Math.floor(v2.y / CH);
for (let row = yMin; row <= yMax; row++) {
const py = row * CH + CH / 2; // pixel center y
let xLeft, xRight;
if (py <= v1.y) {
// Upper half: long edge left vs short top edge right
xLeft = edgeX(v0.y, v2.y, v0.x, v2.x, py);
xRight = edgeX(v0.y, v1.y, v0.x, v1.x, py);
} else {
// Lower half: long edge left vs short bottom edge right
xLeft = edgeX(v0.y, v2.y, v0.x, v2.x, py);
xRight = edgeX(v1.y, v2.y, v1.x, v2.x, py);
}
if (xLeft > xRight) { const t = xLeft; xLeft = xRight; xRight = t; }
const colMin = Math.ceil(xLeft / CW), colMax = Math.floor(xRight / CW);
for (let col = colMin; col <= colMax; col++) {
result.push({ row, col });
}
}
return result;
}
// Render the canvas
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const verts = triVertices(angle);
const allPixels = rasterize(verts);
// Filled pixels (blue)
const visiblePixels = scanY === null ? allPixels : allPixels.filter(p => p.row < scanY);
ctx.fillStyle = '#4a90d9';
for (const { row, col } of visiblePixels) {
ctx.fillRect(col * CW + 1, row * CH + 1, CW - 2, CH - 2);
}
// Current scanline pixels (red)
if (scanY !== null && scanY <= Math.floor(triVertices(angle)[2].y / CH)) {
ctx.fillStyle = '#e63946';
const rowPixels = allPixels.filter(p => p.row === scanY);
for (const { col } of rowPixels) {
ctx.fillRect(col * CW + 1, scanY * CH + 1, CW - 2, CH - 2);
}
}
// Pixel grid
ctx.strokeStyle = '#dde4ec';
ctx.lineWidth = 0.5;
for (let r = 0; r <= ROWS; r++) { ctx.beginPath(); ctx.moveTo(0, r * CH); ctx.lineTo(canvas.width, r * CH); ctx.stroke(); }
for (let c = 0; c <= COLS; c++) { ctx.beginPath(); ctx.moveTo(c * CW, 0); ctx.lineTo(c * CW, canvas.height); ctx.stroke(); }
// Triangle outline
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(verts[0].x, verts[0].y);
ctx.lineTo(verts[1].x, verts[1].y);
ctx.lineTo(verts[2].x, verts[2].y);
ctx.closePath();
ctx.stroke();
// Active scanline indicator (red dashed)
if (scanY !== null) {
const sy = scanY * CH + CH / 2;
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(0, sy); ctx.lineTo(canvas.width, sy); ctx.stroke();
ctx.setLineDash([]);
}
}
// Reset scan to the first row of the current triangle
function resetScan() {
const verts = triVertices(angle);
const sorted = [...verts].sort((a, b) => a.y - b.y);
scanY = Math.ceil(sorted[0].y / CH);
filled = [];
}
function getMaxScanY() {
const verts = triVertices(angle);
return Math.floor([...verts].sort((a, b) => a.y - b.y)[2].y / CH);
}
// Advance one scanline
function step() {
if (scanY === null) resetScan();
const maxY = getMaxScanY();
if (scanY > maxY) {
info.textContent = fmt('Done — %rows% scanlines filled.', { rows: maxY - Math.ceil(triVertices(angle).sort((a,b)=>a.y-b.y)[0].y/CH) + 1 });
scanY = null;
return;
}
info.textContent = fmt('Filling scanline %row% of %maxRow%…', { row: scanY, maxRow: maxY });
scanY++;
draw();
}
// Animation loop — rotate and re-rasterize
function tick(ts) {
if (!playing) return;
const dt = ts - lastTime; lastTime = ts;
const speed = parseInt(speedSlider.value, 10);
tickAcc += dt;
const interval = 300 / speed; // ms between frames at this speed setting
if (tickAcc >= interval) {
tickAcc -= interval;
// Advance rotation angle
angle += 0.03 * speed;
resetScan();
draw();
}
animId = requestAnimationFrame(tick);
}
btnPlay.addEventListener('click', () => {
playing = !playing;
btnPlay.textContent = playing ? 'Pause' : 'Play';
if (playing) {
lastTime = performance.now(); tickAcc = 0;
animId = requestAnimationFrame(tick);
} else {
cancelAnimationFrame(animId);
}
});
btnStep.addEventListener('click', () => {
if (playing) { playing = false; btnPlay.textContent = 'Play'; cancelAnimationFrame(animId); }
step();
});
btnReset.addEventListener('click', () => {
playing = false; btnPlay.textContent = 'Play'; cancelAnimationFrame(animId);
angle = 0; scanY = null; filled = [];
info.textContent = 'Press Step or Play to start filling.';
draw();
});
// Initial draw
draw();
info.textContent = 'Press Step or Play to start filling.';
<!-- Main layout — controls above the pixel canvas -->
<div class="controls">
<button id="btn-play" type="button">Play</button>
<button id="btn-step" type="button">Step</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<label class="speed-label">Speed:
<input id="speed" type="range" min="1" max="10" value="4">
</label>
</div>
<canvas id="canvas" width="320" height="280" title="Scanline rasterization of a spinning triangle"></canvas>
<div id="info" class="info">Press Step or Play to start filling.</div>
/* Minimal style for the scanline rasterization demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; display: flex; flex-direction: column; align-items: center; gap: .5rem; padding: .5rem; }
.controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; justify-content: center; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.speed-label { font-size: .82rem; color: #555; display: flex; align-items: center; gap: .35rem; }
#speed { width: 80px; cursor: pointer; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; display: block; background: #f5f8fb; }
.info { font-size: .85rem; color: #444; min-height: 1.3em; text-align: center; }
// Scanline rasterization demo: spin a triangle and fill it row by row
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const btnPlay = document.getElementById('btn-play');
const btnStep = document.getElementById('btn-step');
const btnReset = document.getElementById('btn-reset');
const speedSlider = document.getElementById('speed');
const info = document.getElementById('info');
// Grid dimensions — each cell is one logical pixel
const COLS = 16, ROWS = 14;
const CW = canvas.width / COLS, CH = canvas.height / ROWS;
// Animation state
let angle = 0;
let scanY = null; // current scanline row being filled (null = show full fill)
let filled = []; // filled pixel cells for this frame
let playing = false;
let animId = null;
let lastTime = 0;
let tickAcc = 0;
// Equilateral triangle centered on the canvas, rotating with angle a
function triVertices(a) {
const cx = canvas.width / 2, cy = canvas.height / 2;
const rx = 100, ry = 90;
const offsets = [0, 2.094, 4.189]; // 0, 2π/3, 4π/3 — vertices of equilateral triangle
return offsets.map(d => ({
x: cx + rx * Math.cos(a + d),
y: cy + ry * Math.sin(a + d)
}));
}
// Rasterize triangle verts → array of {row, col} pixel cells
function rasterize(verts) {
// Sort vertices top-to-bottom
const v = [...verts].sort((a, b) => a.y - b.y);
const [v0, v1, v2] = v;
const result = [];
// Interpolate x on edge (ya,xa)→(yb,xb) at row y
function edgeX(ya, yb, xa, xb, y) {
if (yb === ya) return xa;
return xa + (xb - xa) * (y - ya) / (yb - ya);
}
const yMin = Math.ceil(v0.y / CH), yMax = Math.floor(v2.y / CH);
for (let row = yMin; row <= yMax; row++) {
const py = row * CH + CH / 2; // pixel center y
let xLeft, xRight;
if (py <= v1.y) {
// Upper half: long edge left vs short top edge right
xLeft = edgeX(v0.y, v2.y, v0.x, v2.x, py);
xRight = edgeX(v0.y, v1.y, v0.x, v1.x, py);
} else {
// Lower half: long edge left vs short bottom edge right
xLeft = edgeX(v0.y, v2.y, v0.x, v2.x, py);
xRight = edgeX(v1.y, v2.y, v1.x, v2.x, py);
}
if (xLeft > xRight) { const t = xLeft; xLeft = xRight; xRight = t; }
const colMin = Math.ceil(xLeft / CW), colMax = Math.floor(xRight / CW);
for (let col = colMin; col <= colMax; col++) {
result.push({ row, col });
}
}
return result;
}
// Render the canvas
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const verts = triVertices(angle);
const allPixels = rasterize(verts);
// Filled pixels (blue)
const visiblePixels = scanY === null ? allPixels : allPixels.filter(p => p.row < scanY);
ctx.fillStyle = '#4a90d9';
for (const { row, col } of visiblePixels) {
ctx.fillRect(col * CW + 1, row * CH + 1, CW - 2, CH - 2);
}
// Current scanline pixels (red)
if (scanY !== null && scanY <= Math.floor(triVertices(angle)[2].y / CH)) {
ctx.fillStyle = '#e63946';
const rowPixels = allPixels.filter(p => p.row === scanY);
for (const { col } of rowPixels) {
ctx.fillRect(col * CW + 1, scanY * CH + 1, CW - 2, CH - 2);
}
}
// Pixel grid
ctx.strokeStyle = '#dde4ec';
ctx.lineWidth = 0.5;
for (let r = 0; r <= ROWS; r++) { ctx.beginPath(); ctx.moveTo(0, r * CH); ctx.lineTo(canvas.width, r * CH); ctx.stroke(); }
for (let c = 0; c <= COLS; c++) { ctx.beginPath(); ctx.moveTo(c * CW, 0); ctx.lineTo(c * CW, canvas.height); ctx.stroke(); }
// Triangle outline
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(verts[0].x, verts[0].y);
ctx.lineTo(verts[1].x, verts[1].y);
ctx.lineTo(verts[2].x, verts[2].y);
ctx.closePath();
ctx.stroke();
// Active scanline indicator (red dashed)
if (scanY !== null) {
const sy = scanY * CH + CH / 2;
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(0, sy); ctx.lineTo(canvas.width, sy); ctx.stroke();
ctx.setLineDash([]);
}
}
// Reset scan to the first row of the current triangle
function resetScan() {
const verts = triVertices(angle);
const sorted = [...verts].sort((a, b) => a.y - b.y);
scanY = Math.ceil(sorted[0].y / CH);
filled = [];
}
function getMaxScanY() {
const verts = triVertices(angle);
return Math.floor([...verts].sort((a, b) => a.y - b.y)[2].y / CH);
}
// Advance one scanline
function step() {
if (scanY === null) resetScan();
const maxY = getMaxScanY();
if (scanY > maxY) {
info.textContent = fmt('Done — %rows% scanlines filled.', { rows: maxY - Math.ceil(triVertices(angle).sort((a,b)=>a.y-b.y)[0].y/CH) + 1 });
scanY = null;
return;
}
info.textContent = fmt('Filling scanline %row% of %maxRow%…', { row: scanY, maxRow: maxY });
scanY++;
draw();
}
// Animation loop — rotate and re-rasterize
function tick(ts) {
if (!playing) return;
const dt = ts - lastTime; lastTime = ts;
const speed = parseInt(speedSlider.value, 10);
tickAcc += dt;
const interval = 300 / speed; // ms between frames at this speed setting
if (tickAcc >= interval) {
tickAcc -= interval;
// Advance rotation angle
angle += 0.03 * speed;
resetScan();
draw();
}
animId = requestAnimationFrame(tick);
}
btnPlay.addEventListener('click', () => {
playing = !playing;
btnPlay.textContent = playing ? 'Pause' : 'Play';
if (playing) {
lastTime = performance.now(); tickAcc = 0;
animId = requestAnimationFrame(tick);
} else {
cancelAnimationFrame(animId);
}
});
btnStep.addEventListener('click', () => {
if (playing) { playing = false; btnPlay.textContent = 'Play'; cancelAnimationFrame(animId); }
step();
});
btnReset.addEventListener('click', () => {
playing = false; btnPlay.textContent = 'Play'; cancelAnimationFrame(animId);
angle = 0; scanY = null; filled = [];
info.textContent = 'Press Step or Play to start filling.';
draw();
});
// Initial draw
draw();
info.textContent = 'Press Step or Play to start filling.';
Comments
Loading comments...