From 7d9729e594aa99531e251c460583116581248f29 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 22 Aug 2026 09:58:21 -0600 Subject: [PATCH] Improve sprite occlusion handling in Wolfenstein view Replace all-or-nothing per-sprite depth checks with per-column occlusion testing. This allows walls to correctly hide only the parts of sprites (like dead guards) that are behind them, rather than hiding the entire sprite if any part is occluded. Implementation details: - Walk outward from the sprite's center column to determine the visible span based on the wall depth buffer. - Use Phaser's `setCrop` to clip the sprite to the visible columns, which is more performant than redrawing per-column like walls. - Add `OCCLUSION_EPS` tolerance to prevent flickering when sprites are positioned exactly against walls due to float precision issues. --- src/games/wolfenstein/WolfensteinView.js | 58 ++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js index 2696273..3334b78 100644 --- a/src/games/wolfenstein/WolfensteinView.js +++ b/src/games/wolfenstein/WolfensteinView.js @@ -15,8 +15,11 @@ // // Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser // Images, positioned via the same camera-space projection math as the walls, -// occluded per-sprite (not per-pixel) against the wall depth buffer — an -// approximation, acceptable for this MVP. +// occluded per-column (not per-pixel) against the wall depth buffer built by +// _drawWalls — cheap enough to stay a single quad per sprite (via +// Image.setCrop, not a redraw-per-column like the walls themselves), while +// still letting a wall corner hide just the part of a sprite behind it +// instead of an all-or-nothing per-sprite test (see _drawSprites). import { castColumns } from './WolfensteinRaycaster.js'; import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js'; @@ -44,6 +47,14 @@ const GUARD_FRAME = { // How long each half of the 2-frame walk cycle (idle/walk pose) holds, ms. const WALK_CYCLE_MS = 300; +// Occlusion tolerance, in world units, for sprite-vs-wall depth comparisons +// (both the initial per-sprite gate and the per-column walk in +// _drawSprites) — without slack, a sprite sitting exactly against a wall +// (its own depth equal to the wall's, e.g. a body that died mid-collision +// against it) would flicker in and out as float error tips the compare +// either way. +const OCCLUSION_EPS = 0.15; + // Death sequence timing, ms: fall pose -> hold on the ground -> fade out. // Angle-independent (no directional fall/ground art), same as the shoot pose. const DEATH_FALL_MS = 350; @@ -244,11 +255,15 @@ export default class WolfensteinView { live.add(s.key); if (s._depth <= 0.05) { this._hideSprite(s.key); continue; } const screenColF = (NUM_COLUMNS / 2) * (1 + s._tx / s._depth); - const col = Math.floor(screenColF); - if (col < 0 || col >= NUM_COLUMNS || s._depth > this.depthBuffer[col] + 0.15) { + const centerCol = Math.floor(screenColF); + if (centerCol < 0 || centerCol >= NUM_COLUMNS || s._depth > this.depthBuffer[centerCol] + OCCLUSION_EPS) { this._hideSprite(s.key); continue; } const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale; + const centerX = screenColF * this.colWidth; + const leftPx = centerX - spriteSize / 2; + const rightPx = centerX + spriteSize / 2; + const img = this._ensureSprite(s.key, s.tex); if (s.guardFrame != null && this.guardTexture) { img.setTexture('wolfenstein-guard-sheet', s.guardFrame); @@ -257,10 +272,43 @@ export default class WolfensteinView { img.setTexture(s.tex); img.setFlipX(false); } - img.setPosition(screenColF * this.colWidth, VIEW_H / 2); + img.setPosition(centerX, VIEW_H / 2); img.setDisplaySize(spriteSize, spriteSize); img.setDepth(10 + Math.max(0, 100 - s._depth)); img.setAlpha(s.alpha ?? 1); + + // A sprite wide enough to span several columns can have a wall corner + // cut across it — the depth-buffer test above only proved its CENTER + // column is unoccluded, so a wide sprite (most visibly a dead guard's + // body, sitting still against a wall for seconds) could otherwise + // render fully on top of a wall that should hide part of it. Walk + // outward from the known-visible center column and stop at the first + // occluded neighbor on each side, then crop the image to just that + // visible span — cheaper than a per-pixel/per-column redraw (unlike + // the wall pass, which already draws per column) while still + // resolving the common single-corner case correctly. + const colStart = Math.max(0, Math.floor(leftPx / this.colWidth)); + const colEnd = Math.min(NUM_COLUMNS - 1, Math.floor((rightPx - 1e-6) / this.colWidth)); + let visLeftCol = centerCol, visRightCol = centerCol; + while (visLeftCol > colStart && s._depth <= this.depthBuffer[visLeftCol - 1] + OCCLUSION_EPS) visLeftCol--; + while (visRightCol < colEnd && s._depth <= this.depthBuffer[visRightCol + 1] + OCCLUSION_EPS) visRightCol++; + + if (visLeftCol === colStart && visRightCol === colEnd) { + img.setCrop(); // fully visible — clear any crop left over from a previous frame + } else { + const visLeftPx = Math.max(leftPx, visLeftCol * this.colWidth); + const visRightPx = Math.min(rightPx, (visRightCol + 1) * this.colWidth); + const fracLeft = Math.max(0, Math.min(1, (visLeftPx - leftPx) / spriteSize)); + const fracRight = Math.max(0, Math.min(1, (visRightPx - leftPx) / spriteSize)); + const fw = img.frame.width, fh = img.frame.height; + // setCrop's x/width are in the frame's own (unflipped) texture + // space — Phaser accounts for setFlipX internally when mapping that + // rect to screen UVs, so a flipped sprite's screen-left visible + // fraction corresponds to the frame's RIGHT edge, not its left. + const cropX = (s.guardFlip ? 1 - fracRight : fracLeft) * fw; + const cropW = Math.max(1, (fracRight - fracLeft) * fw); + img.setCrop(cropX, 0, cropW, fh); + } img.setVisible(true); } for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);