Merge branch 'fix/lighting-visual-artifacts' into main: resolve wall black-hole self-occlusion, clear-color seam, cave torch emitters, and vertical UV shift
This commit is contained in:
commit
8a9970aa31
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6052,42 +6052,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,
|
||||
})
|
||||
}
|
||||
|
|
@ -6096,49 +6113,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],
|
||||
|
|
@ -6229,7 +6267,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
|
||||
|
|
@ -6244,6 +6285,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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -6299,7 +6341,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 }
|
||||
}
|
||||
|
|
@ -6345,6 +6390,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) {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue