The ground gets cast: per-pixel floor and ceiling, both paintable

True floor-casting replaces the gradients and the projected seam lines:
every screen row lies at one depth, walked with a constant world-space
step and sampled from floor.png underfoot and ceiling.png overhead —
each maze cell wearing exactly one tile, so the artist's border IS the
cell seam. Distance shade and fog fold into the same per-pixel pass,
pixel buffers cache per texture (re-read when a painted file lands),
and the whole frame still clears 60fps with room to spare. Flagstones
and dark slabs ship as the starter files beside the other masonry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 13:05:46 -04:00
co-authored by Claude Fable 5
parent b2a6809a92
commit 0f01f8c356
5 changed files with 95 additions and 41 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

+1 -1
View File
@@ -94,7 +94,7 @@
wherever a file is missing.
</p>
<div class="grid">
{#each ["wall", "rim", "stone", "door", "firewall", "warp"] as name (name)}
{#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as name (name)}
<figure>
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
<figcaption>{name}</figcaption>
+66 -40
View File
@@ -58,57 +58,83 @@
// so a missing or still-loading file never leaves a wall naked.
const textures = materialTextures();
// Floor-casting samples per PIXEL, so the two ground-plane textures are
// cached as raw pixel buffers — re-extracted whenever a painted file
// finishes loading and swaps the entry.
const pixelCache = new WeakMap<object, ImageData>();
function pixelsOf(src: CanvasImageSource & { width: number; height: number }): ImageData {
let px = pixelCache.get(src);
if (!px) {
const t = document.createElement("canvas");
t.width = src.width;
t.height = src.height;
const c = t.getContext("2d")!;
c.drawImage(src, 0, 0);
px = c.getImageData(0, 0, t.width, t.height);
pixelCache.set(src, px);
}
return px;
}
let frame: ImageData | null = null;
function draw(time: number) {
const ctx = canvas?.getContext("2d");
if (!ctx) return;
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
const W = width, H = height, half = H / 2;
// Sky and floor: torchlight fading to the dark of the maze.
const sky = ctx.createLinearGradient(0, 0, 0, half);
sky.addColorStop(0, "#0d0c12");
sky.addColorStop(1, "#2a2620");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, half);
const floor = ctx.createLinearGradient(0, half, 0, H);
floor.addColorStop(0, "#2b251c");
floor.addColorStop(1, "#463c2c");
ctx.fillStyle = floor;
ctx.fillRect(0, half, W, H - half);
// The floor's tile seams: grid lines projected onto the ground plane,
// drawn before the walls so the walls occlude what stands behind them.
// The ground and the vault overhead, cast per pixel: each screen row
// below (or above) the horizon lies at one fixed depth, so the row is
// walked with a constant world-space step and sampled from the floor
// or ceiling texture — every maze cell wearing one full tile of it.
const fx = (W / 2) / Math.tan(FOV / 2);
const cosF = Math.cos(facing), sinF = Math.sin(facing);
const toCam = (wx: number, wy: number) => {
const rx = wx - x, ry = wy - y;
return { fwd: rx * cosF + ry * sinF, side: -rx * sinF + ry * cosF };
};
ctx.lineWidth = 1;
const seg = (ax: number, ay: number, bx: number, by: number) => {
let a = toCam(ax, ay), b = toCam(bx, by);
const NEAR = 0.12;
if (a.fwd < NEAR && b.fwd < NEAR) return;
if (a.fwd < NEAR || b.fwd < NEAR) {
const t = (NEAR - a.fwd) / (b.fwd - a.fwd);
const cut = { fwd: NEAR, side: a.side + (b.side - a.side) * t };
if (a.fwd < NEAR) a = cut; else b = cut;
if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H);
const buf = frame.data;
const fl = pixelsOf(textures.floor!);
const ce = pixelsOf(textures.ceiling!);
const FOG = 13;
for (let row = 0; row < H; row++) {
const below = row > half;
const dz = below ? row - half : half - row;
if (dz < 1) {
// The horizon sliver: fog it flat.
for (let col = 0; col < W; col++) {
const o = (row * W + col) * 4;
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9; buf[o + 3] = 255;
}
const mid = (a.fwd + b.fwd) / 2;
if (mid > 11) return;
ctx.strokeStyle = `rgba(18,14,10,${Math.min(0.75, 1.0 / (1 + mid * 0.35))})`;
ctx.beginPath();
ctx.moveTo(W / 2 + (a.side / a.fwd) * fx, half + (H / 2) / a.fwd);
ctx.lineTo(W / 2 + (b.side / b.fwd) * fx, half + (H / 2) / b.fwd);
ctx.stroke();
};
const R = 11;
for (let gx = Math.floor(x) - R; gx <= Math.floor(x) + R; gx++) {
for (let gy = Math.floor(y) - R; gy <= Math.floor(y) + R; gy++) {
seg(gx, gy, gx + 1, gy);
seg(gx, gy, gx, gy + 1);
continue;
}
const d = (H / 2) / dz;
const tex = below ? fl : ce;
const tw = tex.width, th = tex.height;
const tp = tex.data;
const shade = Math.max(0, Math.min(1, 1.25 / (1 + d * 0.45)) * (1 - d / FOG));
// World position at column 0 and its per-column step, both at depth d.
const sideStep = d / fx;
const side0 = -(W / 2) * sideStep;
let wx = x + d * cosF - side0 * sinF;
let wy = y + d * sinF + side0 * cosF;
const stepX = -sideStep * sinF;
const stepY = sideStep * cosF;
for (let col = 0; col < W; col++) {
const o = (row * W + col) * 4;
if (shade <= 0.02) {
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9;
} else {
let u = wx % 1; if (u < 0) u += 1;
let v = wy % 1; if (v < 0) v += 1;
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
buf[o] = tp[ti]! * shade;
buf[o + 1] = tp[ti + 1]! * shade;
buf[o + 2] = tp[ti + 2]! * shade;
}
buf[o + 3] = 255;
wx += stepX;
wy += stepY;
}
}
ctx.putImageData(frame, 0, 0);
// Walls, one ray per column; remember each column's depth for sprites.
const zbuf = new Float64Array(W);
+28
View File
@@ -79,6 +79,34 @@ export function proceduralTextures(): Record<string, HTMLCanvasElement> {
c.fillRect(h2(i, 1) * TEX, TEX - h2(i, 7) * TEX, w, h2(i, 7) * TEX);
}
}),
floor: bake((c) => {
// Flagstones: a 2x2 of worn slabs per cell, the mortar border carrying
// the tile seam so every cell reads as one square of the maze.
c.fillStyle = "#3a3226";
c.fillRect(0, 0, TEX, TEX);
for (let r = 0; r < 2; r++) {
for (let q = 0; q < 2; q++) {
const jit = 0.8 + 0.3 * h2(r * 3 + 1, q * 7 + 2);
c.fillStyle = `rgb(${74 * jit | 0},${64 * jit | 0},${48 * jit | 0})`;
c.fillRect(q * (TEX / 2) + 2, r * (TEX / 2) + 2, TEX / 2 - 4, TEX / 2 - 4);
}
}
c.strokeStyle = "rgba(16,12,8,0.9)";
c.lineWidth = 3;
c.strokeRect(0.5, 0.5, TEX - 1, TEX - 1);
}),
ceiling: bake((c) => {
// Heavy dark slabs overhead, barely catching the torchlight.
c.fillStyle = "#161310";
c.fillRect(0, 0, TEX, TEX);
for (let r = 0; r < 2; r++) {
for (let q = 0; q < 2; q++) {
const jit = 0.75 + 0.3 * h2(r * 5 + 3, q * 11 + 4);
c.fillStyle = `rgb(${34 * jit | 0},${30 * jit | 0},${26 * jit | 0})`;
c.fillRect(q * (TEX / 2) + 1, r * (TEX / 2) + 1, TEX / 2 - 2, TEX / 2 - 2);
}
}
}),
warp: bake((c) => {
const grad = c.createLinearGradient(0, 0, TEX, TEX);
grad.addColorStop(0, "#2c1a4e");