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.
This commit is contained in:
Brian Fertig 2026-08-22 09:58:21 -06:00
parent a6f6b647da
commit 7d9729e594
1 changed files with 53 additions and 5 deletions

View File

@ -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);