diablo2-web/tests/scene-lighting.test.ts

437 lines
14 KiB
TypeScript

/**
* Integration tests for Diablo II v1.13c Dynamic Lighting & Environment System.
*
* Verifies:
* 1. Single-pass draw call batching with dynamic lighting enabled (drawCalls === 1).
* 2. Exact 10-float vertex stride invariant (FLOATS_PER_VERTEX === 10).
* 3. Lazy 48x48 RGBA8 lightmap texture allocation upon first setLighting() invocation.
* 4. Additive blend mode bypass (missiles & glowing spell overlays stay radiant in darkness).
* 5. Environment ambient calculations for outdoor, indoor, and Act IV levels.
* 6. LightGrid obstacle attenuation & Alpha Max Plus Beta Min falloff.
*/
import { describe, it, expect } from 'vitest'
import {
SpriteRenderer,
FLOATS_PER_VERTEX,
type LightingConfig,
} from '../src/render/renderer.ts'
import { LightGrid, LIGHT_GRID_SIZE } from '../src/game/engine/light-grid.ts'
import { Environment } from '../src/game/engine/environment.ts'
import { getLevelLighting } from '../src/game/levels-meta.ts'
import { COLLIDE_VISIBLE, COLLIDE_WALL, type CollisionGrid } from '../src/game/d2map.ts'
interface MockResource {
id: number
type: string
}
function createMockCanvas(): {
canvas: any
stats: {
createdTextures: Set<MockResource>
glDrawCalls: number
}
} {
let nextId = 1
const stats = {
createdTextures: new Set<MockResource>(),
glDrawCalls: 0,
}
const gl: any = {
VERTEX_SHADER: 35633,
FRAGMENT_SHADER: 35632,
COMPILE_STATUS: 35713,
LINK_STATUS: 35714,
MAX_TEXTURE_SIZE: 3379,
MAX_TEXTURE_IMAGE_UNITS: 34930,
TEXTURE_2D: 3553,
RGBA: 6408,
RGBA8: 32856,
R8: 33321,
RED: 6403,
UNSIGNED_BYTE: 5121,
UNSIGNED_INT: 5125,
FLOAT: 5126,
ARRAY_BUFFER: 34962,
ELEMENT_ARRAY_BUFFER: 34963,
DYNAMIC_DRAW: 35048,
STATIC_DRAW: 35044,
TRIANGLES: 4,
TEXTURE_MIN_FILTER: 10241,
TEXTURE_MAG_FILTER: 10240,
TEXTURE_WRAP_S: 10242,
TEXTURE_WRAP_T: 10243,
CLAMP_TO_EDGE: 33071,
NEAREST: 9728,
LINEAR: 9729,
UNPACK_ALIGNMENT: 3317,
BLEND: 3042,
SRC_ALPHA: 770,
ONE: 1,
ONE_MINUS_SRC_ALPHA: 771,
DEPTH_TEST: 2929,
COLOR_BUFFER_BIT: 16384,
TEXTURE0: 33984,
drawingBufferWidth: 1280,
drawingBufferHeight: 720,
isContextLost: () => false,
getParameter: (param: number) => {
if (param === 34930) return 16 // MAX_TEXTURE_IMAGE_UNITS
if (param === 3379) return 8192 // MAX_TEXTURE_SIZE
return 16
},
createProgram: () => ({ id: nextId++, type: 'program' }),
deleteProgram: () => {},
attachShader: () => {},
detachShader: () => {},
linkProgram: () => {},
getProgramParameter: () => true,
getProgramInfoLog: () => '',
useProgram: () => {},
createShader: (type: number) => ({ id: nextId++, type: type === 35633 ? 'vertexShader' : 'fragmentShader' }),
shaderSource: () => {},
compileShader: () => {},
getShaderParameter: () => true,
getShaderInfoLog: () => '',
deleteShader: () => {},
createVertexArray: () => ({ id: nextId++, type: 'vao' }),
deleteVertexArray: () => {},
bindVertexArray: () => {},
createBuffer: () => ({ id: nextId++, type: 'buffer' }),
deleteBuffer: () => {},
bindBuffer: () => {},
bufferData: () => {},
bufferSubData: () => {},
getAttribLocation: (_p: any, name: string) => {
const map: Record<string, number> = {
a_position: 0,
a_uv: 1,
a_tint: 2,
a_unit: 3,
a_paletteRow: 4,
}
return map[name] ?? -1
},
enableVertexAttribArray: () => {},
vertexAttribPointer: () => {},
vertexAttribIPointer: () => {},
createTexture: () => {
const res: MockResource = { id: nextId++, type: 'texture' }
stats.createdTextures.add(res)
return res
},
deleteTexture: (t: MockResource) => { stats.createdTextures.delete(t) },
bindTexture: () => {},
texParameteri: () => {},
texImage2D: () => {},
texStorage2D: () => {},
texSubImage2D: () => {},
pixelStorei: () => {},
getUniformLocation: (_p: any, name: string) => ({ name, id: nextId++ }),
uniform2f: () => {},
uniform4f: () => {},
uniform1f: () => {},
uniform1i: () => {},
uniform1iv: () => {},
enable: () => {},
disable: () => {},
blendFunc: () => {},
viewport: () => {},
clearColor: () => {},
clear: () => {},
activeTexture: () => {},
drawArrays: () => { stats.glDrawCalls += 1 },
drawElements: () => { stats.glDrawCalls += 1 },
}
const canvas: any = {
getContext: (type: string) => (type === 'webgl2' ? gl : null),
addEventListener: () => {},
removeEventListener: () => {},
width: 1280,
height: 720,
drawingBufferWidth: 1280,
drawingBufferHeight: 720,
}
return { canvas, stats }
}
describe('Scene Lighting & Environment Integration', () => {
it('maintains strict 10-float vertex stride invariant', () => {
expect(FLOATS_PER_VERTEX).toBe(10)
})
it('allocates lightmap texture lazily on first setLighting call', () => {
const { canvas } = createMockCanvas()
const renderer = new SpriteRenderer(canvas)
// Non-lighting setup has 3 default textures (atlas, white, palette)
const initialTextures = (renderer as any).allocatedTextures.size
expect(initialTextures).toBe(3)
// Clear and draw unlit quad
renderer.begin({ x: 0, y: 0, zoom: 1 })
renderer.drawSolid(0, 0, 100, 100, [1, 1, 1, 1])
renderer.flush()
expect((renderer as any).allocatedTextures.size).toBe(3)
// Set lighting
const lightGrid = new LightGrid()
lightGrid.centerOnSubTile(100, 100)
renderer.setLighting({
ambient: [0.2, 0.2, 0.2],
originSubX: lightGrid.originSubX,
originSubY: lightGrid.originSubY,
sceneOriginX: 0,
sceneOriginY: 0,
lightmap: lightGrid.exportTextureBuffer(),
})
// Lightmap texture is now allocated (3 + 1 = 4)
expect((renderer as any).allocatedTextures.size).toBe(4)
renderer.dispose()
})
it('maintains single-pass draw call batching when dynamic lighting is active', () => {
const { canvas, stats } = createMockCanvas()
const renderer = new SpriteRenderer(canvas)
const lightGrid = new LightGrid()
lightGrid.centerOnSubTile(200, 200)
lightGrid.addLight({
subTileX: 200,
subTileY: 200,
radius: 55,
intensity: 255,
red: 255,
green: 255,
blue: 255,
})
renderer.begin({ x: 100, y: 100, zoom: 1 }, [0.03, 0.03, 0.04])
renderer.setLighting({
ambient: [0.15, 0.15, 0.18],
originSubX: lightGrid.originSubX,
originSubY: lightGrid.originSubY,
sceneOriginX: 0,
sceneOriginY: 0,
lightmap: lightGrid.exportTextureBuffer(),
})
// Draw world floor tiles and entities
for (let i = 0; i < 50; i += 1) {
renderer.drawSolid(i * 10, i * 5, 80, 40, [0.8, 0.8, 0.8, 1])
}
// Flush batch
renderer.flush()
// All 50 quads rendered in exactly 1 single draw call
expect(renderer.drawCalls).toBe(1)
expect(stats.glDrawCalls).toBe(1)
expect(renderer.quadsSubmitted).toBe(50)
renderer.dispose()
})
it('supports unshaded UI rendering by setting lighting to null before minimap', () => {
const { canvas, stats } = createMockCanvas()
const renderer = new SpriteRenderer(canvas)
const lightGrid = new LightGrid()
lightGrid.centerOnSubTile(200, 200)
renderer.begin({ x: 100, y: 100, zoom: 1 })
// 1. World pass with dynamic lighting
renderer.setLighting({
ambient: [0.1, 0.1, 0.1],
originSubX: lightGrid.originSubX,
originSubY: lightGrid.originSubY,
sceneOriginX: 0,
sceneOriginY: 0,
lightmap: lightGrid.exportTextureBuffer(),
})
renderer.drawSolid(0, 0, 50, 50, [1, 1, 1, 1])
// 2. Automap / Minimap pass without lighting
renderer.setLighting(null)
renderer.drawSolid(10, 10, 20, 20, [0, 1, 0, 1])
renderer.flush()
// Total draw calls: 1 for lit world + 1 for unshaded automap = 2
expect(renderer.drawCalls).toBe(2)
expect(stats.glDrawCalls).toBe(2)
renderer.dispose()
})
it('evaluates authentic 1.13c ambient lighting for indoor and outdoor levels', () => {
const env = new Environment()
// Level 1: Rogue Encampment (Act 1 outdoor town)
const townLight = env.tick(1, 1)
expect(townLight.isInside).toBe(false)
expect(townLight.intensity).toBeGreaterThan(0)
// Level 37: Catacombs Level 4 (Act 1 indoor dungeon, Andariel lair)
const cataLight = env.tick(37, 1)
expect(cataLight.isInside).toBe(true)
const cataMeta = getLevelLighting(37)
expect(cataMeta).toBeDefined()
expect(cataLight.intensity).toBe(cataMeta!.intensity)
expect(cataLight.red).toBe(cataMeta!.red)
expect(cataLight.green).toBe(cataMeta!.green)
expect(cataLight.blue).toBe(cataMeta!.blue)
// Level 103: The Pandemonium Fortress (Act 4 town)
// Lerps towards target intensity 128
for (let t = 0; t < 200; t += 1) {
env.tick(103, 4)
}
expect(env.currentIntensity).toBe(128)
// Level 104: Outer Steppes (Act 4 wilderness)
// Advance several ticks to let lerp converge to target 64
for (let t = 0; t < 200; t += 1) {
env.tick(104, 4)
}
expect(env.currentIntensity).toBe(64)
})
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)
const collision: CollisionGrid = {
cellsX: 80,
cellsY: 80,
originX: 0,
originY: 0,
gridWidth: 80 * 5,
blocked: new Uint8Array(80 * 5 * 80 * 5),
collisionMasks: new Uint16Array(80 * 5 * 80 * 5),
}
// 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
}
}
}
grid.updateObstacles(collision)
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
const wallFrontIdx = 24 * LIGHT_GRID_SIZE + 25
const wallSecondIdx = 24 * LIGHT_GRID_SIZE + 26
const behindWallIdx = 24 * LIGHT_GRID_SIZE + 27
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: 48,
intensity: 255,
red: 255,
green: 255,
blue: 255,
})
// 1. Player's own cell (24, 24) is NEVER self-occluded when hugging a wall
expect(grid.intensity[centerIdx]).toBeGreaterThan(200)
// 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 frontAlpha = buf[wallFrontIdx * 4 + 3]
expect(frontAlpha).toBe(grid.intensity[wallFrontIdx])
})
it('supports interactive scene lighting presets and #lighting selector in acts.html', async () => {
const { LIGHTING_PRESETS, normalizeLightingPreset } = await import('../src/scene/act-scene.ts')
const fs = await import('node:fs')
expect(normalizeLightingPreset(undefined)).toBe('auto')
expect(normalizeLightingPreset('1')).toBe('auto')
expect(normalizeLightingPreset('0')).toBe('off')
expect(normalizeLightingPreset('off')).toBe('off')
expect(normalizeLightingPreset('noon')).toBe('noon')
expect(normalizeLightingPreset('night')).toBe('night')
expect(normalizeLightingPreset('cave')).toBe('cave')
expect(normalizeLightingPreset('hell')).toBe('hell')
expect(normalizeLightingPreset('eclipse')).toBe('eclipse')
expect(LIGHTING_PRESETS.noon.overrideAmbient?.intensity).toBe(255)
expect(LIGHTING_PRESETS.cave.overrideAmbient?.intensity).toBe(5)
expect(LIGHTING_PRESETS.off.enabled).toBe(false)
const actsHtml = fs.readFileSync('acts.html', 'utf8')
expect(actsHtml).toContain('<select id="lighting">')
for (const preset of Object.keys(LIGHTING_PRESETS)) {
expect(actsHtml).toContain(`value="${preset}"`)
}
}, 15000)
it('keeps cave player torch (radius=20 sub-tiles) and wall torches (radius=13 sub-tiles) localized without saturating outer viewport cells', () => {
const grid = new LightGrid()
grid.centerOnSubTile(200, 200)
// Add calibrated cave player torch at center (200, 200), radius=20 sub-tiles (~240px horizontal radius)
grid.addLight({
subTileX: 200,
subTileY: 200,
radius: 20,
intensity: 242,
red: 255,
green: 234,
blue: 196,
})
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
// 1 cell away (dx=+1, dy=-1 => 8 sub-tiles each => dist=11, 160px horizontally): illuminated
const nearIdx = 23 * LIGHT_GRID_SIZE + 25
// 3 cells away (dx=+3, dy=-3 => 24 sub-tiles each => dist=32, 480px horizontally, well within 640px half-screen): completely dark!
const outerScreenIdx = 21 * LIGHT_GRID_SIZE + 27
expect(grid.intensity[centerIdx]).toBeGreaterThan(220)
expect(grid.intensity[nearIdx]).toBeGreaterThan(80)
expect(grid.intensity[outerScreenIdx]).toBe(0)
})
})