fix(lighting): resolve wall black-hole self-occlusion, clear-color seam, torch emitters, and vertical UV shift

- LightGrid Radial Ray-Marching (src/game/engine/light-grid.ts):
  * Only count line-of-sight blockers (COLLIDE_VISIBLE | COLLIDE_DOOR) in updateObstacles
    so floor rocks, stairs, and walkable blockers do not create dark spots.
  * Trace radial rays from emitter to target cell (1 <= s < steps) so the emitter's
    own cell is never self-occluded when hugging a wall, wall front-faces receive full
    illumination, and authentic shadows are cast behind walls.

- Ground-Plane Vertical Elevation Compensation (src/render/renderer.ts):
  * Encode vertical sprite elevation (elevationY) in top quad vertices while strictly
    preserving the 10-float vertex stride (FLOATS_PER_VERTEX = 10) so upright walls,
    objects, characters, and monsters sample the lightmap at their ground footprint.

- Scene Lighting & Clear Color Parity (src/scene/act-scene.ts):
  * Set canvas clear color to pure pitch black [0, 0, 0] when lighting is enabled,
    eliminating #08080a diamond void bands around cave boundaries.
  * Scale light radii by SUB_TILES_PER_LIGHT_CELL (8 sub-tiles per cell: player torch 88).
  * Register Objects.txt Lit0..2 emitters (TO, T1, T2, FB, B2, WP, SH, etc.) for cave torches.
  * Map all spell skillIds (including Glacial Spike '55') for dynamic projectile lighting.
This commit is contained in:
troytt 2026-09-24 09:14:35 +00:00
parent f424a92a1d
commit 536a712816
4 changed files with 173 additions and 91 deletions

View File

@ -133,7 +133,9 @@ export class LightGrid {
/**
* Populate cell obstacle factors from map collision masks.
* Tests bits 0x0002 (COLLIDE_VISIBLE), 0x0800 (COLLIDE_DOOR), 0x0020 (COLLIDE_BLANK), 0x0001 (COLLIDE_WALL).
* Only line-of-sight blockers (COLLIDE_VISIBLE 0x0002 | COLLIDE_DOOR 0x0800)
* block light transmission; walk-only blockers (COLLIDE_WALL 0x0001 such as
* low rocks, puddles, barrels, and floor debris) remain fully illuminated.
*
* @param collision - scene collision grid
*/
@ -149,47 +151,41 @@ export class LightGrid {
const cellIdx = cy * LIGHT_GRID_SIZE + cx
if (subX < 0 || subY < 0 || subX >= gridW || subY >= gridH) {
// Out of map bounds is completely opaque obstacle
this.obstacle[cellIdx] = 16
// Out of map bounds attenuates rays passing through void
this.obstacle[cellIdx] = 6
continue
}
let obstacleSum = 0
if (masks !== undefined) {
// Sample the 4 center sub-tiles of the 8x8 cell block
// Sample the 4 center sub-tiles of the 8x8 cell block for sight-blocking walls
for (let dy = 2; dy <= 5; dy += 2) {
const sy = subY + dy
if (sy < 0 || sy >= gridH) { obstacleSum += 8; continue }
if (sy < 0 || sy >= gridH) { obstacleSum += 2; continue }
const rowOffset = sy * gridW
for (let dx = 2; dx <= 5; dx += 2) {
const sx = subX + dx
if (sx < 0 || sx >= gridW) { obstacleSum += 8; continue }
if (sx < 0 || sx >= gridW) { obstacleSum += 2; continue }
const m = masks[rowOffset + sx] ?? 0
if ((m & (COLLIDE_VISIBLE | COLLIDE_DOOR)) !== 0) {
obstacleSum += 4
} else if ((m & (COLLIDE_BLANK | COLLIDE_WALL)) !== 0) {
obstacleSum += 2
obstacleSum += 1
}
}
}
} else {
// Fallback if collision masks not present
const blocked = collision.blocked
const bIdx = subY * gridW + subX
if (blocked[bIdx] === 1) {
obstacleSum = 16
}
}
this.obstacle[cellIdx] = Math.min(16, obstacleSum)
this.obstacle[cellIdx] = Math.min(8, obstacleSum)
}
}
}
/**
* Apply one light source to the 48x48 grid using authentic 1.13c mathematics:
* Apply one light source to the 48x48 grid using authentic 1.13c mathematics
* with radial ray-marching occlusion:
* - Falloff: Attenuated = max(0, ((R - dist) * IntensitySlope) >> 16)
* - Obstacle: FinalIntensity = ((8 - ObstacleFactor) * Attenuated) >> 3
* - Radial Occlusion: Accumulates line-of-sight obstacles along intermediate cells
* between the emitter and target cell so wall front-faces and the emitter's own
* cell are brightly illuminated while casting authentic shadows behind walls.
* - Color Blending: Intensity-weighted average
*
* @param light - light emitter parameters
@ -229,10 +225,10 @@ export class LightGrid {
for (let cx = minCellX; cx <= maxCellX; cx += 1) {
const cellIdx = rowOffset + cx
const obstacleFactor = this.obstacle[cellIdx]!
const cellObstacle = this.obstacle[cellIdx]!
// If obstacle factor >= 16 (0x10), light is completely blocked (0x6fb597ea)
if (obstacleFactor >= 16) continue
// Explicit hard-block sentinel (>= 16) blocks light immediately
if (cellObstacle >= 16) continue
const cellCenterSubX = cx * SUB_TILES_PER_LIGHT_CELL + (SUB_TILES_PER_LIGHT_CELL >> 1)
const dSubX = cellCenterSubX - relSubX
@ -245,8 +241,32 @@ export class LightGrid {
const attenuated = ((effectiveRadius - dist) * intensitySlope) >> 16
if (attenuated <= 0) continue
// Obstacle attenuation: ((8 - obstacleFactor) * attenuated) >> 3
const finalIntensity = Math.max(0, ((8 - obstacleFactor) * attenuated) >> 3)
// Radial ray-marching occlusion from emitter (centerCellX, centerCellY) to (cx, cy):
// 1. The emitter's own cell (steps === 0) is never self-occluded.
// 2. Intermediate cells (1 <= s < steps) accumulate line-of-sight obstacle density
// to cast shadows BEHIND walls, while the front face of a wall (s === steps)
// only receives a gentle grazing term (cellObstacle >> 2) so walls facing the
// player are brightly illuminated without black blotches.
const dCellX = cx - centerCellX
const dCellY = cy - centerCellY
const steps = Math.max(Math.abs(dCellX), Math.abs(dCellY))
let effectiveObstacle = 0
if (steps > 0) {
let rayObstacle = 0
for (let s = 1; s < steps; s += 1) {
const rx = centerCellX + Math.round((dCellX * s) / steps)
const ry = centerCellY + Math.round((dCellY * s) / steps)
if (rx >= 0 && rx < LIGHT_GRID_SIZE && ry >= 0 && ry < LIGHT_GRID_SIZE) {
rayObstacle += this.obstacle[ry * LIGHT_GRID_SIZE + rx]!
if (rayObstacle >= 8) break
}
}
effectiveObstacle = Math.min(8, rayObstacle + (cellObstacle >> 2))
}
if (effectiveObstacle >= 8) continue
// Obstacle attenuation: ((8 - effectiveObstacle) * attenuated) >> 3
const finalIntensity = Math.max(0, ((8 - effectiveObstacle) * attenuated) >> 3)
if (finalIntensity <= 0) continue
// Accumulate and blend into cell (0x6fb58a91)

View File

@ -86,6 +86,12 @@ export interface DrawOptions {
readonly height?: number
/** Blend mode for rendering: 'normal' (alpha blend) or 'additive' (translucent spell glow). */
readonly blendMode?: 'normal' | 'additive'
/**
* Optional vertical screen height (in scene pixels) of the sprite's top edge above its ground base.
* Compensates isometric lightmap UV projection for upright geometry (walls, pillars, actors)
* while preserving the 10-float vertex stride invariant.
*/
readonly elevationY?: number
}
/** Dynamic scene lighting configuration for Glide-style illumination. */
@ -160,14 +166,14 @@ void main() {
v_uv = a_uv;
v_tint = a_tint;
v_unit = int(a_unit);
v_palette = int(a_palette);
float liftY = floor((a_palette + 1.5) * (1.0 / 16.0));
float rawPalette = a_palette - liftY * 16.0;
v_palette = int(round(rawPalette));
// Inverse isometric projection to sub-tiles:
// Inverse isometric projection to ground-plane sub-tiles (compensating for vertical height liftY):
// x_scene = (subX - subY) * 16.0 + originX
// y_scene = (subX + subY) * 8.0 + originY
// => subX = ((x_scene - originX)/16.0 + (y_scene - originY)/8.0) * 0.5
// => subY = ((y_scene - originY)/8.0 - (x_scene - originX)/16.0) * 0.5
vec2 d = a_position - u_sceneOrigin;
// y_scene = (subX + subY) * 8.0 + originY - liftY
vec2 d = vec2(a_position.x, a_position.y + liftY) - u_sceneOrigin;
float subX = (d.x * (1.0 / 16.0) + d.y * (1.0 / 8.0)) * 0.5;
float subY = (d.y * (1.0 / 8.0) - d.x * (1.0 / 16.0)) * 0.5;
// Light grid spans 48 cells * 8 sub-tiles = 384 sub-tiles
@ -1016,6 +1022,8 @@ export class SpriteRenderer {
const tint = options.tint
const flipped = options.flipX === true
const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1
const elevationY = options.elevationY !== undefined && options.elevationY > 0 ? Math.round(options.elevationY) : 0
const topPalette = elevationY > 0 ? paletteRow + elevationY * 16 : paletteRow
this.quad(
x0, y0, x1, y1, x2, y2, x3, y3,
flipped ? u1 : u0, v0,
@ -1026,6 +1034,7 @@ export class SpriteRenderer {
tint === undefined ? 1 : tint[3],
unit,
paletteRow,
topPalette,
)
}
@ -1130,6 +1139,7 @@ export class SpriteRenderer {
r: number, g: number, b: number, a: number,
unit: number,
paletteRow: number,
topPalette = paletteRow,
): void {
const needed = (this.quadCount + 1) * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
if (needed > this.vertices.length) this.grow(needed)
@ -1137,11 +1147,11 @@ export class SpriteRenderer {
let at = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
// Corner 0: top-left.
v[at] = x0; v[at + 1] = y0; v[at + 2] = u0; v[at + 3] = v0
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = topPalette
at += FLOATS_PER_VERTEX
// Corner 1: top-right.
v[at] = x1; v[at + 1] = y1; v[at + 2] = u1; v[at + 3] = v0
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = topPalette
at += FLOATS_PER_VERTEX
// Corner 2: bottom-left.
v[at] = x2; v[at + 1] = y2; v[at + 2] = u0; v[at + 3] = v1

View File

@ -5775,42 +5775,59 @@ if (typeof window !== 'undefined') {
}
const camX = engine.world.player.x
const camY = engine.world.player.y - 16
renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, [0.03, 0.03, 0.04])
renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, lightingEnabled ? [0, 0, 0] : [0.03, 0.03, 0.04])
if (lightingEnabled) {
const pSub = playerSubTile()
lightGrid.centerOnSubTile(pSub.x, pSub.y)
lightGrid.clear()
lightGrid.updateObstacles(runtime.grid)
// Authentic player torch light: base radius 55 sub-tiles (~11 tiles), intensity 255, white
// Authentic 1.13c player torch light: base radius 11 cells * 8 sub-tiles = 88 sub-tiles
const flicker = (Math.sin(engine.world.tick * 0.35) * 2) | 0
lightGrid.addLight({
subTileX: pSub.x,
subTileY: pSub.y,
radius: 55 + flicker,
radius: 88 + flicker,
intensity: 255,
red: 255,
green: 255,
blue: 255,
green: 246,
blue: 228,
})
// Active missile lights
// Active missile lights (with skillId fallback for all spells including Glacial Spike '55')
const skillToMissile: Readonly<Record<string, string>> = {
'36': 'firebolt',
'38': 'chargedbolt',
'39': 'icebolt',
'45': 'iceblast',
'47': 'fireball',
'49': 'lightning',
'53': 'chainlightning',
'55': 'glacialspike',
'56': 'meteor',
'64': 'frozenorb',
'67': 'teeth',
'84': 'bonespear',
'93': 'bonespirit',
}
for (const shot of engine.projectiles) {
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
const mType = shot.missileType ?? (shot.skillId ? skillToMissile[String(shot.skillId)] : undefined)
if (!mType) continue
const mData = getMissileTxtData(mType)
if (mData && mData.light > 0) {
const lightUnits = mData.light > 0 ? mData.light : (mData.trans === 1 ? 6 : 0)
if (lightUnits > 0) {
const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
const shotSubX = Math.round((dy + dx) / 2)
const shotSubY = Math.round((dy - dx) / 2)
const isCold = mData.blue > mData.red
lightGrid.addLight({
subTileX: shotSubX,
subTileY: shotSubY,
radius: mData.light * 5,
radius: Math.max(48, lightUnits * 8),
intensity: 255,
red: mData.red,
green: mData.green,
red: isCold ? Math.max(120, mData.red) : mData.red,
green: isCold ? Math.max(175, mData.green) : mData.green,
blue: mData.blue,
})
}
@ -5819,49 +5836,70 @@ if (typeof window !== 'undefined') {
// Active explosion lights
for (const exp of engine.explosions) {
const expData = getMissileTxtData(exp.missileType)
const radius = (expData && expData.light > 0 ? expData.light : 8) * 5
const lightUnits = expData && expData.light > 0 ? expData.light : 8
const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
const expSubX = Math.round((dy + dx) / 2)
const expSubY = Math.round((dy - dx) / 2)
const isColdExp = exp.missileType.includes('ice') || exp.missileType.includes('freeze') || exp.missileType.includes('blizzard') || exp.missileType.includes('orb')
lightGrid.addLight({
subTileX: expSubX,
subTileY: expSubY,
radius,
radius: lightUnits * 8,
intensity: 255,
red: expData?.red ?? 255,
green: expData?.green ?? 160,
blue: expData?.blue ?? 50,
red: isColdExp ? 130 : (expData?.red ?? 255),
green: isColdExp ? 185 : (expData?.green ?? 160),
blue: isColdExp ? 255 : (expData?.blue ?? 50),
})
}
// Environmental additive objects (torches, flames, etc.)
// Environmental static/animated lights from Objects.txt Lit0..2 (standing torches TO, braziers FB/B2, waypoints WP, shrines SH, beams)
if (runtime.objectDrawables) {
for (const obj of runtime.objectDrawables) {
if (obj.blendMode === 'additive' || (obj.token && ADDITIVE_OBJECT_TOKENS.has(obj.token.toUpperCase()))) {
const tok = (obj.token ?? '').trim().toUpperCase()
const isTorchOrFire =
tok === 'TO' || tok === 'T1' || tok === 'T2' || tok === 'E1' || tok === 'E2' ||
tok === 'V1' || tok === 'V2' || tok === 'JT' || tok === 'UX' || tok === 'FB' ||
tok === 'B1' || tok === 'B2' || tok === 'B3' || tok === 'CF' || tok === 'SS' ||
tok === 'F8' || tok === '1Y' || tok === 'FL'
const isWaypoint =
tok === 'WP' || tok === 'WI' || tok === 'WM' || tok === 'WN' ||
tok === 'W7' || tok === 'WZ' || tok === 'WV' || tok === 'AY'
const isShrineOrBeam =
tok === 'SH' || tok === 'S0' || tok === 'HA' || tok === '1G' ||
obj.blendMode === 'additive' || (tok !== '' && ADDITIVE_OBJECT_TOKENS.has(tok))
if (isTorchOrFire || isWaypoint || isShrineOrBeam) {
const dx = obj.x - engine.world.player.x
const dy = obj.y - engine.world.player.y
if (dx * dx + dy * dy < 1200 * 1200) {
if (dx * dx + dy * dy < 1500 * 1500) {
const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
const objSubX = Math.round((ody + odx) / 2)
const objSubY = Math.round((ody - odx) / 2)
const objFlicker = isTorchOrFire ? ((Math.sin(engine.world.tick * 0.45 + obj.x * 0.05) * 2) | 0) : 0
lightGrid.addLight({
subTileX: Math.round((ody + odx) / 2),
subTileY: Math.round((ody - odx) / 2),
radius: 35,
intensity: 220,
red: 255,
green: 180,
blue: 80,
subTileX: objSubX,
subTileY: objSubY,
radius: (isTorchOrFire ? 56 : isWaypoint ? 52 : 44) + objFlicker,
intensity: isTorchOrFire ? 235 : 210,
red: isWaypoint ? 120 : 255,
green: isWaypoint ? 150 : (isTorchOrFire ? 220 : 210),
blue: isWaypoint ? 255 : (isTorchOrFire ? 150 : 180),
})
}
}
}
}
const ambNorm = environment.currentIntensity / 255
const ambR = (environment.currentRed / 255) * ambNorm
const ambG = (environment.currentGreen / 255) * ambNorm
const ambB = (environment.currentBlue / 255) * ambNorm
// Maintain a subtle warm-stone minimum ambient baseline in zero-intensity indoor caves
// so distant cave walls retain authentic silhouette depth against the [0, 0, 0] clear color.
const rawIntensity = environment.currentIntensity
const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 14
const ambNorm = effectiveAmbIntensity / 255
const ambR = ((rawIntensity > 0 ? environment.currentRed : 225) / 255) * ambNorm
const ambG = ((rawIntensity > 0 ? environment.currentGreen : 215) / 255) * ambNorm
const ambB = ((rawIntensity > 0 ? environment.currentBlue : 205) / 255) * ambNorm
renderer.setLighting({
ambient: [ambR, ambG, ambB],
@ -5952,7 +5990,10 @@ if (typeof window !== 'undefined') {
if (page === null || page === undefined) {
skippedDraws += 1
} else {
renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { atlas: page })
renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, {
atlas: page,
elevationY: Math.max(0, nextWall!.frame.height - 40),
})
}
}
wallIdx += 1
@ -5967,6 +6008,7 @@ if (typeof window !== 'undefined') {
renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, {
atlas: page,
blendMode: nextObj!.blendMode ?? 'normal',
elevationY: Math.max(0, nextObj!.frame.height - 16),
})
}
}
@ -6022,7 +6064,10 @@ if (typeof window !== 'undefined') {
? engine.world.player.y + frame.anchorY
: engine.world.player.y - frame.height + FEET_HEIGHT / 2
renderer.draw(frame, drawX, drawY, { atlas: character.handle })
renderer.draw(frame, drawX, drawY, {
atlas: character.handle,
elevationY: Math.max(0, frame.height - 16),
})
state.characterFrames += 1
state.playerDrawRect = { x: drawX, y: drawY, w: frame.width, h: frame.height }
}
@ -6068,6 +6113,7 @@ if (typeof window !== 'undefined') {
renderer.draw(frame, drawX, drawY, {
atlas: art.handle,
blendMode: baseBlendMode,
elevationY: Math.max(0, frame.height - 16),
...(isChilled ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } : {}),
})
if (art.glowGroups !== undefined) {

View File

@ -313,11 +313,10 @@ describe('Scene Lighting & Environment Integration', () => {
expect(env.currentIntensity).toBe(64)
})
it('attenuates light passing through obstacles in LightGrid', () => {
it('illuminates emitter cell and wall front-faces while casting shadows behind walls via radial ray-marching', () => {
const grid = new LightGrid()
grid.centerOnSubTile(200, 200)
// Mock collision grid with a solid wall
const collision: CollisionGrid = {
cellsX: 80,
cellsY: 80,
@ -328,51 +327,58 @@ describe('Scene Lighting & Environment Integration', () => {
collisionMasks: new Uint16Array(80 * 5 * 80 * 5),
}
// Set obstacle mask on center cell (24, 24)
// Center sub-tile is 200. Grid origin is 200 - 192 = 8.
// Center cell (cx = 24, cy = 24) starts at subTile (8 + 24*8, 8 + 24*8) = (200, 200).
const cellStartSubX = grid.originSubX + 24 * 8
const cellStartSubY = grid.originSubY + 24 * 8
for (let dy = 2; dy <= 5; dy += 2) {
for (let dx = 2; dx <= 5; dx += 2) {
const idx = (cellStartSubY + dy) * collision.gridWidth + (cellStartSubX + dx)
collision.collisionMasks![idx] = COLLIDE_WALL | COLLIDE_VISIBLE
// Place a 2-cell thick sight-blocking wall at cells (25, 24) and (26, 24),
// and also touch the player's own cell (24, 24) to test hugging a wall
for (const cx of [24, 25, 26]) {
const cellStartSubX = grid.originSubX + cx * 8
const cellStartSubY = grid.originSubY + 24 * 8
for (let dy = 2; dy <= 5; dy += 2) {
for (let dx = 2; dx <= 5; dx += 2) {
const idx = (cellStartSubY + dy) * collision.gridWidth + (cellStartSubX + dx)
collision.collisionMasks![idx] = COLLIDE_WALL | COLLIDE_VISIBLE
}
}
}
// Update obstacles
grid.updateObstacles(collision)
// Center cell should have obstacle factor 16 (4 sample points * 4 = 16)
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
expect(grid.obstacle[centerIdx]).toBe(16)
const wallFrontIdx = 24 * LIGHT_GRID_SIZE + 25
const wallSecondIdx = 24 * LIGHT_GRID_SIZE + 26
const behindWallIdx = 24 * LIGHT_GRID_SIZE + 27
// Add light at center
expect(grid.obstacle[centerIdx]).toBe(4)
expect(grid.obstacle[wallFrontIdx]).toBe(4)
expect(grid.obstacle[wallSecondIdx]).toBe(4)
expect(grid.obstacle[behindWallIdx]).toBe(0)
// Add player torch at center (200, 200)
grid.addLight({
subTileX: 200,
subTileY: 200,
radius: 40,
radius: 48,
intensity: 255,
red: 255,
green: 255,
blue: 255,
})
// Because obstacle factor is 16 (0x10), light is completely blocked per 0x6fb597ea
expect(grid.intensity[centerIdx]).toBe(0)
// 1. Player's own cell (24, 24) is NEVER self-occluded when hugging a wall
expect(grid.intensity[centerIdx]).toBeGreaterThan(200)
// An unblocked adjacent cell (24, 25) with obstacle 0 receives light
const unblockedIdx = 25 * LIGHT_GRID_SIZE + 24
expect(grid.obstacle[unblockedIdx]).toBe(0)
expect(grid.intensity[unblockedIdx]).toBeGreaterThan(150)
// 2. Front face of the wall (25, 24) receives bright illumination (no black blotches on walls)
expect(grid.intensity[wallFrontIdx]).toBeGreaterThan(140)
// 3. Second layer of the wall (26, 24) is in penumbra (attenuated by wallFront)
expect(grid.intensity[wallSecondIdx]).toBeLessThan(grid.intensity[wallFrontIdx]!)
// 4. Floor behind the 2-cell thick wall (27, 24) is completely in shadow (rayObstacle = 4 + 4 = 8)
expect(grid.intensity[behindWallIdx]).toBe(0)
// Texture buffer export produces valid RGBA8 values
const buf = grid.exportTextureBuffer()
expect(buf.length).toBe(48 * 48 * 4)
const unblockedAlpha = buf[unblockedIdx * 4 + 3]
expect(unblockedAlpha).toBe(grid.intensity[unblockedIdx])
const unblockedRed = buf[unblockedIdx * 4]
expect(unblockedRed).toBe((grid.red[unblockedIdx] * unblockedAlpha) >> 8)
const frontAlpha = buf[wallFrontIdx * 4 + 3]
expect(frontAlpha).toBe(grid.intensity[wallFrontIdx])
})
})