The canvas below renders a simple scene: colored shapes floating above a reflective floor. The floor uses SSR — for every floor pixel, a ray is marched upward through the color buffer until it hits a shape, and that shape's color is sampled as the reflection.
<!-- HTML layout - controls row + canvas + status line -->
<div class="controls">
<label>Roughness: <input type="range" id="roughness" min="0" max="10" value="0"></label>
<label>Steps: <input type="range" id="steps" min="4" max="64" value="32"></label>
<button id="toggleAnim" type="button">Animate</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<canvas id="c" width="480" height="360"></canvas>
<div class="status" id="status"></div>
/* Dark-theme styles for the SSR demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #111; color: #eee; }
.controls { display: flex; gap: .6rem; flex-wrap: wrap; align-items: center; padding: .5rem; background: #1a1a2e; }
label { font-size: .82rem; color: #ccc; display: flex; gap: .35rem; align-items: center; }
input[type=range] { width: 80px; accent-color: #4fc3f7; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #4fc3f7;
background: #4fc3f7; color: #000; border-radius: 6px; cursor: pointer; }
button.ghost { background: transparent; color: #4fc3f7; }
canvas { display: block; width: 100%; }
.status { font-size: .8rem; color: #888; padding: .3rem .5rem; min-height: 1.4em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Screen-Space Reflections demo: the floor reflects the shapes above
// by ray-marching upward through the color + depth buffers.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Floor starts at 55% of canvas height
const FLOOR_Y = Math.floor(H * 0.55);
// Colored circles that float in the scene above the floor
const SHAPES_INIT = [
{ x: 120, y: 130, r: 38, color: '#e63946', vx: 0.5, vy: 0 },
{ x: 260, y: 100, r: 30, color: '#2a9d8f', vx: -0.4, vy: 0 },
{ x: 380, y: 145, r: 44, color: '#e9c46a', vx: 0.3, vy: 0 }
];
let shapes = SHAPES_INIT.map(s => ({ ...s }));
let animating = false;
let roughness = 0;
let stepCount = 32;
// Off-screen canvas acts as the color buffer; depth is a Float32Array
const colorBuf = document.createElement('canvas');
colorBuf.width = W; colorBuf.height = H;
const colorCtx = colorBuf.getContext('2d');
const depthBuf = new Float32Array(W * H);
// Render the sky gradient and shapes into the color buffer, fill depth
function drawScene() {
colorCtx.clearRect(0, 0, W, H);
// Sky gradient background
const grad = colorCtx.createLinearGradient(0, 0, 0, FLOOR_Y);
grad.addColorStop(0, '#0d0d2b');
grad.addColorStop(1, '#1a1a3e');
colorCtx.fillStyle = grad;
colorCtx.fillRect(0, 0, W, FLOOR_Y);
// Draw each circle into the color buffer
for (const s of shapes) {
colorCtx.beginPath();
colorCtx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
colorCtx.fillStyle = s.color;
colorCtx.fill();
}
// Build a simple depth buffer - 1 inside shapes, 0 elsewhere
depthBuf.fill(0);
for (const s of shapes) {
const x0 = Math.max(0, Math.floor(s.x - s.r));
const x1 = Math.min(W - 1, Math.ceil(s.x + s.r));
const y0 = Math.max(0, Math.floor(s.y - s.r));
const y1 = Math.min(FLOOR_Y - 1, Math.ceil(s.y + s.r));
for (let py = y0; py <= y1; py++) {
for (let px = x0; px <= x1; px++) {
const dx = px - s.x, dy = py - s.y;
if (dx * dx + dy * dy <= s.r * s.r) {
// Depth value proportional to vertical position
depthBuf[py * W + px] = 1.0 - py / FLOOR_Y;
}
}
}
}
}
// March a ray upward from floor pixel (fx,fy); return hit coords or null
function ssrMarchColor(fx, fy) {
// Ray direction is straight up (dx=0, dy=-1) for a flat mirror
const dx = 0;
const dy = -1;
let cx = fx, cy = fy;
const stepLen = FLOOR_Y / stepCount;
for (let i = 0; i < stepCount; i++) {
cx += dx * stepLen;
cy += dy * stepLen;
if (cx < 0 || cx >= W || cy < 0 || cy >= FLOOR_Y) return null;
const ix = Math.round(cx), iy = Math.round(cy);
if (depthBuf[iy * W + ix] > 0) {
// Ray hit a shape pixel - return the screen coords
return { x: ix, y: iy };
}
}
return null;
}
// Main render function - compose scene + SSR reflections
function render() {
drawScene();
ctx.clearRect(0, 0, W, H);
// Blit the pre-rendered color buffer onto the main canvas
ctx.drawImage(colorBuf, 0, 0);
// Fill floor area with dark gradient before SSR overlay
const floorGrad = ctx.createLinearGradient(0, FLOOR_Y, 0, H);
floorGrad.addColorStop(0, '#151530');
floorGrad.addColorStop(1, '#0a0a20');
ctx.fillStyle = floorGrad;
ctx.fillRect(0, FLOOR_Y, W, H - FLOOR_Y);
// For every floor pixel, march the ray and sample the color buffer
const imgData = ctx.getImageData(0, FLOOR_Y, W, H - FLOOR_Y);
const data = imgData.data;
const colorData = colorCtx.getImageData(0, 0, W, H).data;
for (let fy = FLOOR_Y; fy < H; fy++) {
for (let fx = 0; fx < W; fx++) {
const hit = ssrMarchColor(fx, fy);
if (hit) {
const si = (hit.y * W + hit.x) * 4;
const di = ((fy - FLOOR_Y) * W + fx) * 4;
// Reflections fade with distance from the floor line
const fade = 1 - (fy - FLOOR_Y) / (H - FLOOR_Y);
const alpha = fade * 0.85;
data[di] = colorData[si];
data[di + 1] = colorData[si + 1];
data[di + 2] = colorData[si + 2];
data[di + 3] = Math.round(alpha * 255);
}
}
}
// Apply CSS blur to simulate surface roughness
if (roughness > 0) {
ctx.save();
ctx.filter = `blur(${roughness}px)`;
const tmpC = document.createElement('canvas');
tmpC.width = W; tmpC.height = H - FLOOR_Y;
const tmpCtx = tmpC.getContext('2d');
tmpCtx.putImageData(imgData, 0, 0);
ctx.clearRect(0, FLOOR_Y, W, H - FLOOR_Y);
ctx.drawImage(tmpC, 0, FLOOR_Y);
ctx.restore();
} else {
ctx.putImageData(imgData, 0, FLOOR_Y);
}
// Draw a faint horizon line between scene and floor
ctx.strokeStyle = 'rgba(79,195,247,0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, FLOOR_Y);
ctx.lineTo(W, FLOOR_Y);
ctx.stroke();
updateStatus();
}
function updateStatus() {
const onScreen = shapes.filter(s => s.x + s.r > 0 && s.x - s.r < W).length;
const offScreen = shapes.length - onScreen;
const s = document.getElementById('status');
if (offScreen > 0) {
s.textContent = i18nFmt('%n% shape(s) off-screen — their reflections vanish (SSR limit).', { n: offScreen });
} else {
s.textContent = i18nFmt('Marching with %k% steps · roughness blur %r%px.', { k: stepCount, r: roughness });
}
}
// Move shapes on each frame for the animate mode
let rafId = null;
function tick() {
for (const s of shapes) {
s.x += s.vx;
if (s.x - s.r > W + 20) s.x = -s.r - 20;
if (s.x + s.r < -20) s.x = W + s.r + 20;
}
render();
rafId = requestAnimationFrame(tick);
}
document.getElementById('roughness').addEventListener('input', e => {
roughness = Number(e.target.value);
render();
});
document.getElementById('steps').addEventListener('input', e => {
stepCount = Number(e.target.value);
render();
});
document.getElementById('toggleAnim').addEventListener('click', () => {
const btn = document.getElementById('toggleAnim');
if (animating) {
cancelAnimationFrame(rafId); rafId = null;
animating = false;
btn.textContent = 'Animate';
} else {
animating = true;
btn.textContent = 'Stop';
tick();
}
});
document.getElementById('resetBtn').addEventListener('click', () => {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; animating = false; }
document.getElementById('toggleAnim').textContent = 'Animate';
shapes = SHAPES_INIT.map(s => ({ ...s }));
roughness = 0; stepCount = 32;
document.getElementById('roughness').value = 0;
document.getElementById('steps').value = 32;
render();
});
render();
<!-- HTML layout - controls row + canvas + status line -->
<div class="controls">
<label>Roughness: <input type="range" id="roughness" min="0" max="10" value="0"></label>
<label>Steps: <input type="range" id="steps" min="4" max="64" value="32"></label>
<button id="toggleAnim" type="button">Animate</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<canvas id="c" width="480" height="360"></canvas>
<div class="status" id="status"></div>
/* Dark-theme styles for the SSR demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #111; color: #eee; }
.controls { display: flex; gap: .6rem; flex-wrap: wrap; align-items: center; padding: .5rem; background: #1a1a2e; }
label { font-size: .82rem; color: #ccc; display: flex; gap: .35rem; align-items: center; }
input[type=range] { width: 80px; accent-color: #4fc3f7; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #4fc3f7;
background: #4fc3f7; color: #000; border-radius: 6px; cursor: pointer; }
button.ghost { background: transparent; color: #4fc3f7; }
canvas { display: block; width: 100%; }
.status { font-size: .8rem; color: #888; padding: .3rem .5rem; min-height: 1.4em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Screen-Space Reflections demo: the floor reflects the shapes above
// by ray-marching upward through the color + depth buffers.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Floor starts at 55% of canvas height
const FLOOR_Y = Math.floor(H * 0.55);
// Colored circles that float in the scene above the floor
const SHAPES_INIT = [
{ x: 120, y: 130, r: 38, color: '#e63946', vx: 0.5, vy: 0 },
{ x: 260, y: 100, r: 30, color: '#2a9d8f', vx: -0.4, vy: 0 },
{ x: 380, y: 145, r: 44, color: '#e9c46a', vx: 0.3, vy: 0 }
];
let shapes = SHAPES_INIT.map(s => ({ ...s }));
let animating = false;
let roughness = 0;
let stepCount = 32;
// Off-screen canvas acts as the color buffer; depth is a Float32Array
const colorBuf = document.createElement('canvas');
colorBuf.width = W; colorBuf.height = H;
const colorCtx = colorBuf.getContext('2d');
const depthBuf = new Float32Array(W * H);
// Render the sky gradient and shapes into the color buffer, fill depth
function drawScene() {
colorCtx.clearRect(0, 0, W, H);
// Sky gradient background
const grad = colorCtx.createLinearGradient(0, 0, 0, FLOOR_Y);
grad.addColorStop(0, '#0d0d2b');
grad.addColorStop(1, '#1a1a3e');
colorCtx.fillStyle = grad;
colorCtx.fillRect(0, 0, W, FLOOR_Y);
// Draw each circle into the color buffer
for (const s of shapes) {
colorCtx.beginPath();
colorCtx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
colorCtx.fillStyle = s.color;
colorCtx.fill();
}
// Build a simple depth buffer - 1 inside shapes, 0 elsewhere
depthBuf.fill(0);
for (const s of shapes) {
const x0 = Math.max(0, Math.floor(s.x - s.r));
const x1 = Math.min(W - 1, Math.ceil(s.x + s.r));
const y0 = Math.max(0, Math.floor(s.y - s.r));
const y1 = Math.min(FLOOR_Y - 1, Math.ceil(s.y + s.r));
for (let py = y0; py <= y1; py++) {
for (let px = x0; px <= x1; px++) {
const dx = px - s.x, dy = py - s.y;
if (dx * dx + dy * dy <= s.r * s.r) {
// Depth value proportional to vertical position
depthBuf[py * W + px] = 1.0 - py / FLOOR_Y;
}
}
}
}
}
// March a ray upward from floor pixel (fx,fy); return hit coords or null
function ssrMarchColor(fx, fy) {
// Ray direction is straight up (dx=0, dy=-1) for a flat mirror
const dx = 0;
const dy = -1;
let cx = fx, cy = fy;
const stepLen = FLOOR_Y / stepCount;
for (let i = 0; i < stepCount; i++) {
cx += dx * stepLen;
cy += dy * stepLen;
if (cx < 0 || cx >= W || cy < 0 || cy >= FLOOR_Y) return null;
const ix = Math.round(cx), iy = Math.round(cy);
if (depthBuf[iy * W + ix] > 0) {
// Ray hit a shape pixel - return the screen coords
return { x: ix, y: iy };
}
}
return null;
}
// Main render function - compose scene + SSR reflections
function render() {
drawScene();
ctx.clearRect(0, 0, W, H);
// Blit the pre-rendered color buffer onto the main canvas
ctx.drawImage(colorBuf, 0, 0);
// Fill floor area with dark gradient before SSR overlay
const floorGrad = ctx.createLinearGradient(0, FLOOR_Y, 0, H);
floorGrad.addColorStop(0, '#151530');
floorGrad.addColorStop(1, '#0a0a20');
ctx.fillStyle = floorGrad;
ctx.fillRect(0, FLOOR_Y, W, H - FLOOR_Y);
// For every floor pixel, march the ray and sample the color buffer
const imgData = ctx.getImageData(0, FLOOR_Y, W, H - FLOOR_Y);
const data = imgData.data;
const colorData = colorCtx.getImageData(0, 0, W, H).data;
for (let fy = FLOOR_Y; fy < H; fy++) {
for (let fx = 0; fx < W; fx++) {
const hit = ssrMarchColor(fx, fy);
if (hit) {
const si = (hit.y * W + hit.x) * 4;
const di = ((fy - FLOOR_Y) * W + fx) * 4;
// Reflections fade with distance from the floor line
const fade = 1 - (fy - FLOOR_Y) / (H - FLOOR_Y);
const alpha = fade * 0.85;
data[di] = colorData[si];
data[di + 1] = colorData[si + 1];
data[di + 2] = colorData[si + 2];
data[di + 3] = Math.round(alpha * 255);
}
}
}
// Apply CSS blur to simulate surface roughness
if (roughness > 0) {
ctx.save();
ctx.filter = `blur(${roughness}px)`;
const tmpC = document.createElement('canvas');
tmpC.width = W; tmpC.height = H - FLOOR_Y;
const tmpCtx = tmpC.getContext('2d');
tmpCtx.putImageData(imgData, 0, 0);
ctx.clearRect(0, FLOOR_Y, W, H - FLOOR_Y);
ctx.drawImage(tmpC, 0, FLOOR_Y);
ctx.restore();
} else {
ctx.putImageData(imgData, 0, FLOOR_Y);
}
// Draw a faint horizon line between scene and floor
ctx.strokeStyle = 'rgba(79,195,247,0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, FLOOR_Y);
ctx.lineTo(W, FLOOR_Y);
ctx.stroke();
updateStatus();
}
function updateStatus() {
const onScreen = shapes.filter(s => s.x + s.r > 0 && s.x - s.r < W).length;
const offScreen = shapes.length - onScreen;
const s = document.getElementById('status');
if (offScreen > 0) {
s.textContent = i18nFmt('%n% shape(s) off-screen — their reflections vanish (SSR limit).', { n: offScreen });
} else {
s.textContent = i18nFmt('Marching with %k% steps · roughness blur %r%px.', { k: stepCount, r: roughness });
}
}
// Move shapes on each frame for the animate mode
let rafId = null;
function tick() {
for (const s of shapes) {
s.x += s.vx;
if (s.x - s.r > W + 20) s.x = -s.r - 20;
if (s.x + s.r < -20) s.x = W + s.r + 20;
}
render();
rafId = requestAnimationFrame(tick);
}
document.getElementById('roughness').addEventListener('input', e => {
roughness = Number(e.target.value);
render();
});
document.getElementById('steps').addEventListener('input', e => {
stepCount = Number(e.target.value);
render();
});
document.getElementById('toggleAnim').addEventListener('click', () => {
const btn = document.getElementById('toggleAnim');
if (animating) {
cancelAnimationFrame(rafId); rafId = null;
animating = false;
btn.textContent = 'Animate';
} else {
animating = true;
btn.textContent = 'Stop';
tick();
}
});
document.getElementById('resetBtn').addEventListener('click', () => {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; animating = false; }
document.getElementById('toggleAnim').textContent = 'Animate';
shapes = SHAPES_INIT.map(s => ({ ...s }));
roughness = 0; stepCount = 32;
document.getElementById('roughness').value = 0;
document.getElementById('steps').value = 32;
render();
});
render();
Comments
Loading comments...