From 0f01f8c3569edf2aa7a7ba6553ba86cb7ef59fa1 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 13:05:46 -0400 Subject: [PATCH] The ground gets cast: per-pixel floor and ceiling, both paintable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/web/public/textures/ceiling.png | Bin 0 -> 351 bytes packages/web/public/textures/floor.png | Bin 0 -> 380 bytes packages/web/src/TokenGallery.svelte | 2 +- packages/web/src/fpv/FirstPerson.svelte | 106 ++++++++++++++--------- packages/web/src/fpv/textures.ts | 28 ++++++ 5 files changed, 95 insertions(+), 41 deletions(-) create mode 100644 packages/web/public/textures/ceiling.png create mode 100644 packages/web/public/textures/floor.png diff --git a/packages/web/public/textures/ceiling.png b/packages/web/public/textures/ceiling.png new file mode 100644 index 0000000000000000000000000000000000000000..56751800d4205fab33dacafe021ed53f562da27a GIT binary patch literal 351 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEU=;CmaSW+od^71F*C7J|m+5jn zYsDQu{I9=stm2}d;&fY%)l&|f>u{F3Q8sg?`Q7~M=9|@S*Dals)3WXA@BcAfy#mwT zGd^ex=e5aw4Dnl-K6Gxl>&g)Q z_}+RBd`zkQx2ei4cQ0%Su)$3} zd#m7~yo|M1>#o|rPyEi#!0`WnuIJv_z+hqng%2|W!;)V4$IyBc4CpKb*uq; zo8|`p<~Sg-@Xh~l=4o#aDRCckJ13Vm^{1%A&ID`L2HnCt;kXQbcCUJWJX=L)YPCM| zCe>GW-SM*Hc`f4e#3HZ%K735nvU)~n(;9|TNAH}g-~XMRf#LuEy*l&10K
- {#each ["wall", "rim", "stone", "door", "firewall", "warp"] as name (name)} + {#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as name (name)}
{`${name}
{name}
diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 3a91382..6f425d7 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -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(); + 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; + } + continue; } - 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); + 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); diff --git a/packages/web/src/fpv/textures.ts b/packages/web/src/fpv/textures.ts index 9d42b87..7bebed6 100644 --- a/packages/web/src/fpv/textures.ts +++ b/packages/web/src/fpv/textures.ts @@ -79,6 +79,34 @@ export function proceduralTextures(): Record { 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");