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.
|
* 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
|
* @param collision - scene collision grid
|
||||||
*/
|
*/
|
||||||
|
|
@ -149,47 +151,41 @@ export class LightGrid {
|
||||||
const cellIdx = cy * LIGHT_GRID_SIZE + cx
|
const cellIdx = cy * LIGHT_GRID_SIZE + cx
|
||||||
|
|
||||||
if (subX < 0 || subY < 0 || subX >= gridW || subY >= gridH) {
|
if (subX < 0 || subY < 0 || subX >= gridW || subY >= gridH) {
|
||||||
// Out of map bounds is completely opaque obstacle
|
// Out of map bounds attenuates rays passing through void
|
||||||
this.obstacle[cellIdx] = 16
|
this.obstacle[cellIdx] = 6
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let obstacleSum = 0
|
let obstacleSum = 0
|
||||||
if (masks !== undefined) {
|
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) {
|
for (let dy = 2; dy <= 5; dy += 2) {
|
||||||
const sy = subY + dy
|
const sy = subY + dy
|
||||||
if (sy < 0 || sy >= gridH) { obstacleSum += 8; continue }
|
if (sy < 0 || sy >= gridH) { obstacleSum += 2; continue }
|
||||||
const rowOffset = sy * gridW
|
const rowOffset = sy * gridW
|
||||||
for (let dx = 2; dx <= 5; dx += 2) {
|
for (let dx = 2; dx <= 5; dx += 2) {
|
||||||
const sx = subX + dx
|
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
|
const m = masks[rowOffset + sx] ?? 0
|
||||||
if ((m & (COLLIDE_VISIBLE | COLLIDE_DOOR)) !== 0) {
|
if ((m & (COLLIDE_VISIBLE | COLLIDE_DOOR)) !== 0) {
|
||||||
obstacleSum += 4
|
obstacleSum += 1
|
||||||
} else if ((m & (COLLIDE_BLANK | COLLIDE_WALL)) !== 0) {
|
|
||||||
obstacleSum += 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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)
|
* - 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
|
* - Color Blending: Intensity-weighted average
|
||||||
*
|
*
|
||||||
* @param light - light emitter parameters
|
* @param light - light emitter parameters
|
||||||
|
|
@ -229,10 +225,10 @@ export class LightGrid {
|
||||||
|
|
||||||
for (let cx = minCellX; cx <= maxCellX; cx += 1) {
|
for (let cx = minCellX; cx <= maxCellX; cx += 1) {
|
||||||
const cellIdx = rowOffset + cx
|
const cellIdx = rowOffset + cx
|
||||||
const obstacleFactor = this.obstacle[cellIdx]!
|
const cellObstacle = this.obstacle[cellIdx]!
|
||||||
|
|
||||||
// If obstacle factor >= 16 (0x10), light is completely blocked (0x6fb597ea)
|
// Explicit hard-block sentinel (>= 16) blocks light immediately
|
||||||
if (obstacleFactor >= 16) continue
|
if (cellObstacle >= 16) continue
|
||||||
|
|
||||||
const cellCenterSubX = cx * SUB_TILES_PER_LIGHT_CELL + (SUB_TILES_PER_LIGHT_CELL >> 1)
|
const cellCenterSubX = cx * SUB_TILES_PER_LIGHT_CELL + (SUB_TILES_PER_LIGHT_CELL >> 1)
|
||||||
const dSubX = cellCenterSubX - relSubX
|
const dSubX = cellCenterSubX - relSubX
|
||||||
|
|
@ -245,8 +241,32 @@ export class LightGrid {
|
||||||
const attenuated = ((effectiveRadius - dist) * intensitySlope) >> 16
|
const attenuated = ((effectiveRadius - dist) * intensitySlope) >> 16
|
||||||
if (attenuated <= 0) continue
|
if (attenuated <= 0) continue
|
||||||
|
|
||||||
// Obstacle attenuation: ((8 - obstacleFactor) * attenuated) >> 3
|
// Radial ray-marching occlusion from emitter (centerCellX, centerCellY) to (cx, cy):
|
||||||
const finalIntensity = Math.max(0, ((8 - obstacleFactor) * attenuated) >> 3)
|
// 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
|
if (finalIntensity <= 0) continue
|
||||||
|
|
||||||
// Accumulate and blend into cell (0x6fb58a91)
|
// Accumulate and blend into cell (0x6fb58a91)
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,12 @@ export interface DrawOptions {
|
||||||
readonly height?: number
|
readonly height?: number
|
||||||
/** Blend mode for rendering: 'normal' (alpha blend) or 'additive' (translucent spell glow). */
|
/** Blend mode for rendering: 'normal' (alpha blend) or 'additive' (translucent spell glow). */
|
||||||
readonly blendMode?: 'normal' | 'additive'
|
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. */
|
/** Dynamic scene lighting configuration for Glide-style illumination. */
|
||||||
|
|
@ -160,14 +166,14 @@ void main() {
|
||||||
v_uv = a_uv;
|
v_uv = a_uv;
|
||||||
v_tint = a_tint;
|
v_tint = a_tint;
|
||||||
v_unit = int(a_unit);
|
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
|
// x_scene = (subX - subY) * 16.0 + originX
|
||||||
// y_scene = (subX + subY) * 8.0 + originY
|
// y_scene = (subX + subY) * 8.0 + originY - liftY
|
||||||
// => subX = ((x_scene - originX)/16.0 + (y_scene - originY)/8.0) * 0.5
|
vec2 d = vec2(a_position.x, a_position.y + liftY) - u_sceneOrigin;
|
||||||
// => subY = ((y_scene - originY)/8.0 - (x_scene - originX)/16.0) * 0.5
|
|
||||||
vec2 d = a_position - u_sceneOrigin;
|
|
||||||
float subX = (d.x * (1.0 / 16.0) + d.y * (1.0 / 8.0)) * 0.5;
|
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;
|
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
|
// Light grid spans 48 cells * 8 sub-tiles = 384 sub-tiles
|
||||||
|
|
@ -1016,6 +1022,8 @@ export class SpriteRenderer {
|
||||||
const tint = options.tint
|
const tint = options.tint
|
||||||
const flipped = options.flipX === true
|
const flipped = options.flipX === true
|
||||||
const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1
|
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(
|
this.quad(
|
||||||
x0, y0, x1, y1, x2, y2, x3, y3,
|
x0, y0, x1, y1, x2, y2, x3, y3,
|
||||||
flipped ? u1 : u0, v0,
|
flipped ? u1 : u0, v0,
|
||||||
|
|
@ -1026,6 +1034,7 @@ export class SpriteRenderer {
|
||||||
tint === undefined ? 1 : tint[3],
|
tint === undefined ? 1 : tint[3],
|
||||||
unit,
|
unit,
|
||||||
paletteRow,
|
paletteRow,
|
||||||
|
topPalette,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1130,6 +1139,7 @@ export class SpriteRenderer {
|
||||||
r: number, g: number, b: number, a: number,
|
r: number, g: number, b: number, a: number,
|
||||||
unit: number,
|
unit: number,
|
||||||
paletteRow: number,
|
paletteRow: number,
|
||||||
|
topPalette = paletteRow,
|
||||||
): void {
|
): void {
|
||||||
const needed = (this.quadCount + 1) * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
|
const needed = (this.quadCount + 1) * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
|
||||||
if (needed > this.vertices.length) this.grow(needed)
|
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
|
let at = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
|
||||||
// Corner 0: top-left.
|
// Corner 0: top-left.
|
||||||
v[at] = x0; v[at + 1] = y0; v[at + 2] = u0; v[at + 3] = v0
|
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
|
at += FLOATS_PER_VERTEX
|
||||||
// Corner 1: top-right.
|
// Corner 1: top-right.
|
||||||
v[at] = x1; v[at + 1] = y1; v[at + 2] = u1; v[at + 3] = v0
|
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
|
at += FLOATS_PER_VERTEX
|
||||||
// Corner 2: bottom-left.
|
// Corner 2: bottom-left.
|
||||||
v[at] = x2; v[at + 1] = y2; v[at + 2] = u0; v[at + 3] = v1
|
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 camX = engine.world.player.x
|
||||||
const camY = engine.world.player.y - 16
|
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) {
|
if (lightingEnabled) {
|
||||||
const pSub = playerSubTile()
|
const pSub = playerSubTile()
|
||||||
lightGrid.centerOnSubTile(pSub.x, pSub.y)
|
lightGrid.centerOnSubTile(pSub.x, pSub.y)
|
||||||
lightGrid.clear()
|
lightGrid.clear()
|
||||||
lightGrid.updateObstacles(runtime.grid)
|
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
|
const flicker = (Math.sin(engine.world.tick * 0.35) * 2) | 0
|
||||||
lightGrid.addLight({
|
lightGrid.addLight({
|
||||||
subTileX: pSub.x,
|
subTileX: pSub.x,
|
||||||
subTileY: pSub.y,
|
subTileY: pSub.y,
|
||||||
radius: 55 + flicker,
|
radius: 88 + flicker,
|
||||||
intensity: 255,
|
intensity: 255,
|
||||||
red: 255,
|
red: 255,
|
||||||
green: 255,
|
green: 246,
|
||||||
blue: 255,
|
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) {
|
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
|
if (!mType) continue
|
||||||
const mData = getMissileTxtData(mType)
|
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 dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||||
const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||||
const shotSubX = Math.round((dy + dx) / 2)
|
const shotSubX = Math.round((dy + dx) / 2)
|
||||||
const shotSubY = Math.round((dy - dx) / 2)
|
const shotSubY = Math.round((dy - dx) / 2)
|
||||||
|
const isCold = mData.blue > mData.red
|
||||||
lightGrid.addLight({
|
lightGrid.addLight({
|
||||||
subTileX: shotSubX,
|
subTileX: shotSubX,
|
||||||
subTileY: shotSubY,
|
subTileY: shotSubY,
|
||||||
radius: mData.light * 5,
|
radius: Math.max(48, lightUnits * 8),
|
||||||
intensity: 255,
|
intensity: 255,
|
||||||
red: mData.red,
|
red: isCold ? Math.max(120, mData.red) : mData.red,
|
||||||
green: mData.green,
|
green: isCold ? Math.max(175, mData.green) : mData.green,
|
||||||
blue: mData.blue,
|
blue: mData.blue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -6096,49 +6113,70 @@ if (typeof window !== 'undefined') {
|
||||||
// Active explosion lights
|
// Active explosion lights
|
||||||
for (const exp of engine.explosions) {
|
for (const exp of engine.explosions) {
|
||||||
const expData = getMissileTxtData(exp.missileType)
|
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 dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||||
const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||||
const expSubX = Math.round((dy + dx) / 2)
|
const expSubX = Math.round((dy + dx) / 2)
|
||||||
const expSubY = 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({
|
lightGrid.addLight({
|
||||||
subTileX: expSubX,
|
subTileX: expSubX,
|
||||||
subTileY: expSubY,
|
subTileY: expSubY,
|
||||||
radius,
|
radius: lightUnits * 8,
|
||||||
intensity: 255,
|
intensity: 255,
|
||||||
red: expData?.red ?? 255,
|
red: isColdExp ? 130 : (expData?.red ?? 255),
|
||||||
green: expData?.green ?? 160,
|
green: isColdExp ? 185 : (expData?.green ?? 160),
|
||||||
blue: expData?.blue ?? 50,
|
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) {
|
if (runtime.objectDrawables) {
|
||||||
for (const obj of 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 dx = obj.x - engine.world.player.x
|
||||||
const dy = obj.y - engine.world.player.y
|
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 odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||||
const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
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({
|
lightGrid.addLight({
|
||||||
subTileX: Math.round((ody + odx) / 2),
|
subTileX: objSubX,
|
||||||
subTileY: Math.round((ody - odx) / 2),
|
subTileY: objSubY,
|
||||||
radius: 35,
|
radius: (isTorchOrFire ? 56 : isWaypoint ? 52 : 44) + objFlicker,
|
||||||
intensity: 220,
|
intensity: isTorchOrFire ? 235 : 210,
|
||||||
red: 255,
|
red: isWaypoint ? 120 : 255,
|
||||||
green: 180,
|
green: isWaypoint ? 150 : (isTorchOrFire ? 220 : 210),
|
||||||
blue: 80,
|
blue: isWaypoint ? 255 : (isTorchOrFire ? 150 : 180),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ambNorm = environment.currentIntensity / 255
|
// Maintain a subtle warm-stone minimum ambient baseline in zero-intensity indoor caves
|
||||||
const ambR = (environment.currentRed / 255) * ambNorm
|
// so distant cave walls retain authentic silhouette depth against the [0, 0, 0] clear color.
|
||||||
const ambG = (environment.currentGreen / 255) * ambNorm
|
const rawIntensity = environment.currentIntensity
|
||||||
const ambB = (environment.currentBlue / 255) * ambNorm
|
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({
|
renderer.setLighting({
|
||||||
ambient: [ambR, ambG, ambB],
|
ambient: [ambR, ambG, ambB],
|
||||||
|
|
@ -6229,7 +6267,10 @@ if (typeof window !== 'undefined') {
|
||||||
if (page === null || page === undefined) {
|
if (page === null || page === undefined) {
|
||||||
skippedDraws += 1
|
skippedDraws += 1
|
||||||
} else {
|
} 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
|
wallIdx += 1
|
||||||
|
|
@ -6244,6 +6285,7 @@ if (typeof window !== 'undefined') {
|
||||||
renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, {
|
renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, {
|
||||||
atlas: page,
|
atlas: page,
|
||||||
blendMode: nextObj!.blendMode ?? 'normal',
|
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.anchorY
|
||||||
: engine.world.player.y - frame.height + FEET_HEIGHT / 2
|
: 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.characterFrames += 1
|
||||||
state.playerDrawRect = { x: drawX, y: drawY, w: frame.width, h: frame.height }
|
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, {
|
renderer.draw(frame, drawX, drawY, {
|
||||||
atlas: art.handle,
|
atlas: art.handle,
|
||||||
blendMode: baseBlendMode,
|
blendMode: baseBlendMode,
|
||||||
|
elevationY: Math.max(0, frame.height - 16),
|
||||||
...(isChilled ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } : {}),
|
...(isChilled ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } : {}),
|
||||||
})
|
})
|
||||||
if (art.glowGroups !== undefined) {
|
if (art.glowGroups !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -313,11 +313,10 @@ describe('Scene Lighting & Environment Integration', () => {
|
||||||
expect(env.currentIntensity).toBe(64)
|
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()
|
const grid = new LightGrid()
|
||||||
grid.centerOnSubTile(200, 200)
|
grid.centerOnSubTile(200, 200)
|
||||||
|
|
||||||
// Mock collision grid with a solid wall
|
|
||||||
const collision: CollisionGrid = {
|
const collision: CollisionGrid = {
|
||||||
cellsX: 80,
|
cellsX: 80,
|
||||||
cellsY: 80,
|
cellsY: 80,
|
||||||
|
|
@ -328,51 +327,58 @@ describe('Scene Lighting & Environment Integration', () => {
|
||||||
collisionMasks: new Uint16Array(80 * 5 * 80 * 5),
|
collisionMasks: new Uint16Array(80 * 5 * 80 * 5),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set obstacle mask on center cell (24, 24)
|
// Place a 2-cell thick sight-blocking wall at cells (25, 24) and (26, 24),
|
||||||
// Center sub-tile is 200. Grid origin is 200 - 192 = 8.
|
// and also touch the player's own cell (24, 24) to test hugging a wall
|
||||||
// Center cell (cx = 24, cy = 24) starts at subTile (8 + 24*8, 8 + 24*8) = (200, 200).
|
for (const cx of [24, 25, 26]) {
|
||||||
const cellStartSubX = grid.originSubX + 24 * 8
|
const cellStartSubX = grid.originSubX + cx * 8
|
||||||
const cellStartSubY = grid.originSubY + 24 * 8
|
const cellStartSubY = grid.originSubY + 24 * 8
|
||||||
|
for (let dy = 2; dy <= 5; dy += 2) {
|
||||||
for (let dy = 2; dy <= 5; dy += 2) {
|
for (let dx = 2; dx <= 5; dx += 2) {
|
||||||
for (let dx = 2; dx <= 5; dx += 2) {
|
const idx = (cellStartSubY + dy) * collision.gridWidth + (cellStartSubX + dx)
|
||||||
const idx = (cellStartSubY + dy) * collision.gridWidth + (cellStartSubX + dx)
|
collision.collisionMasks![idx] = COLLIDE_WALL | COLLIDE_VISIBLE
|
||||||
collision.collisionMasks![idx] = COLLIDE_WALL | COLLIDE_VISIBLE
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update obstacles
|
|
||||||
grid.updateObstacles(collision)
|
grid.updateObstacles(collision)
|
||||||
|
|
||||||
// Center cell should have obstacle factor 16 (4 sample points * 4 = 16)
|
|
||||||
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
|
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({
|
grid.addLight({
|
||||||
subTileX: 200,
|
subTileX: 200,
|
||||||
subTileY: 200,
|
subTileY: 200,
|
||||||
radius: 40,
|
radius: 48,
|
||||||
intensity: 255,
|
intensity: 255,
|
||||||
red: 255,
|
red: 255,
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Because obstacle factor is 16 (0x10), light is completely blocked per 0x6fb597ea
|
// 1. Player's own cell (24, 24) is NEVER self-occluded when hugging a wall
|
||||||
expect(grid.intensity[centerIdx]).toBe(0)
|
expect(grid.intensity[centerIdx]).toBeGreaterThan(200)
|
||||||
|
|
||||||
// An unblocked adjacent cell (24, 25) with obstacle 0 receives light
|
// 2. Front face of the wall (25, 24) receives bright illumination (no black blotches on walls)
|
||||||
const unblockedIdx = 25 * LIGHT_GRID_SIZE + 24
|
expect(grid.intensity[wallFrontIdx]).toBeGreaterThan(140)
|
||||||
expect(grid.obstacle[unblockedIdx]).toBe(0)
|
|
||||||
expect(grid.intensity[unblockedIdx]).toBeGreaterThan(150)
|
// 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
|
// Texture buffer export produces valid RGBA8 values
|
||||||
const buf = grid.exportTextureBuffer()
|
const buf = grid.exportTextureBuffer()
|
||||||
expect(buf.length).toBe(48 * 48 * 4)
|
expect(buf.length).toBe(48 * 48 * 4)
|
||||||
const unblockedAlpha = buf[unblockedIdx * 4 + 3]
|
const frontAlpha = buf[wallFrontIdx * 4 + 3]
|
||||||
expect(unblockedAlpha).toBe(grid.intensity[unblockedIdx])
|
expect(frontAlpha).toBe(grid.intensity[wallFrontIdx])
|
||||||
const unblockedRed = buf[unblockedIdx * 4]
|
|
||||||
expect(unblockedRed).toBe((grid.red[unblockedIdx] * unblockedAlpha) >> 8)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue