diff --git a/src/engine/systems/VisionSystem.ts b/src/engine/systems/VisionSystem.ts index a624bec5..ddc0a8f0 100644 --- a/src/engine/systems/VisionSystem.ts +++ b/src/engine/systems/VisionSystem.ts @@ -78,7 +78,10 @@ export class VisionSystem extends System { // PERF: Version counter for dirty checking by FogOfWar renderer private visionVersion = 0; // PERF: Cached vision masks per player to avoid regenerating every frame - private visionMaskCache: Map = new Map(); + private visionMaskCache: Map< + string, + { mask: Float32Array; width: number; height: number; version: number } + > = new Map(); // Web Worker for off-thread vision computation private visionWorker: Worker | null = null; @@ -225,10 +228,9 @@ export class VisionSystem extends System { try { // Create worker as ES module (required for Next.js 16+ Turbopack) - this.visionWorker = new Worker( - new URL('../../workers/vision.worker.ts', import.meta.url), - { type: 'module' } - ); + this.visionWorker = new Worker(new URL('../../workers/vision.worker.ts', import.meta.url), { + type: 'module', + }); this.visionWorker.onmessage = this.handleWorkerMessage.bind(this); this.visionWorker.onerror = (error) => { @@ -412,9 +414,12 @@ export class VisionSystem extends System { mapHeight: this.mapHeight, losBlockingThreshold: 1.0, }); - // Re-attach height provider if available + // Re-attach height provider and connect to optimizer if available if (this.heightProvider) { this.lineOfSight.setHeightProvider(this.heightProvider); + if (this.visionOptimizer) { + this.visionOptimizer.setLineOfSight(this.lineOfSight, this.heightProvider); + } } } @@ -527,6 +532,12 @@ export class VisionSystem extends System { if (this.lineOfSight) { this.lineOfSight.setHeightProvider(provider); debugPathfinding.log('[VisionSystem] Height provider set for LOS blocking'); + + // Connect LineOfSight to VisionOptimizer for terrain-aware visibility + if (this.visionOptimizer) { + this.visionOptimizer.setLineOfSight(this.lineOfSight, provider); + debugPathfinding.log('[VisionSystem] LineOfSight connected to VisionOptimizer'); + } } } @@ -944,7 +955,7 @@ export class VisionSystem extends System { } // Collect watch tower data - const watchTowerData = this.watchTowers.map(tower => ({ + const watchTowerData = this.watchTowers.map((tower) => ({ id: tower.id, x: tower.x, y: tower.y, @@ -967,13 +978,227 @@ export class VisionSystem extends System { } /** - * Main thread fallback for vision computation + * Main thread vision computation using optimized reference counting. + * + * Industry-standard optimizations: + * 1. Reference counting: Only update cells when casters cross boundaries + * 2. LOS blocking: Use height-based visibility when terrain data available + * 3. Incremental updates: Skip if no casters moved between cells */ private updateVisionMainThread(): void { - // Clear currently visible cells - const gridWidth = this.visionMap.width; const currentTick = this.game.getCurrentTick(); + // Use VisionOptimizer if available (reference counting optimization) + if (this.visionOptimizer) { + this.updateVisionOptimized(currentTick); + } else { + // Fallback to legacy implementation + this.updateVisionLegacy(currentTick); + } + + // Increment version for dirty checking by renderers + this.visionVersion++; + } + + /** + * Optimized vision computation using reference counting. + * Only processes entities that crossed cell boundaries since last update. + */ + private updateVisionOptimized(currentTick: number): void { + if (!this.visionOptimizer) return; + + this.visionOptimizer.setCurrentTick(currentTick); + + // Track which entities we've seen this frame (for removal detection) + const seenEntityIds = new Set(); + + // Update vision casters from units + const units = this.world.getEntitiesWith('Unit', 'Transform', 'Selectable'); + for (const entity of units) { + const transform = entity.get('Transform'); + const unit = entity.get('Unit'); + const selectable = entity.get('Selectable'); + + if (!transform || !unit || !selectable) continue; + + this.ensurePlayerRegistered(selectable.playerId); + seenEntityIds.add(entity.id); + + // VisionOptimizer only updates when entity crosses cell boundary + this.visionOptimizer.updateCaster( + entity.id, + selectable.playerId, + transform.x, + transform.y, + unit.sightRange + ); + } + + // Update vision casters from buildings + const buildings = this.world.getEntitiesWith('Building', 'Transform', 'Selectable'); + for (const entity of buildings) { + const transform = entity.get('Transform'); + const building = entity.get('Building'); + const selectable = entity.get('Selectable'); + + if (!transform || !building || !selectable) continue; + if (!building.isOperational()) continue; + + this.ensurePlayerRegistered(selectable.playerId); + seenEntityIds.add(entity.id); + + this.visionOptimizer.updateCaster( + entity.id, + selectable.playerId, + transform.x, + transform.y, + building.sightRange + ); + } + + // Update watch towers + this.updateWatchTowersOptimized(units, seenEntityIds); + + // Process temporary reveals + this.processTemporaryRevealsOptimized(currentTick, seenEntityIds); + + // Remove casters for entities that no longer exist + for (const [entityId] of this.entityCellPositions) { + if (!seenEntityIds.has(entityId)) { + this.visionOptimizer.removeCaster(entityId); + this.entityCellPositions.delete(entityId); + } + } + + // Sync optimizer state to visionMap for API compatibility + this.syncOptimizerToVisionMap(); + + // Clear dirty state after sync + this.visionOptimizer.clearDirtyState(); + } + + /** + * Update watch towers using optimized system + */ + private updateWatchTowersOptimized(units: Entity[], seenEntityIds: Set): void { + if (!this.visionOptimizer) return; + + const captureRadiusSq = this.WATCH_TOWER_CAPTURE_RADIUS * this.WATCH_TOWER_CAPTURE_RADIUS; + + for (const tower of this.watchTowers) { + tower.controllingPlayers.clear(); + tower.isActive = false; + + // Find units within capture radius + const nearbyUnitIds = this.world.unitGrid.queryRadius( + tower.x, + tower.y, + this.WATCH_TOWER_CAPTURE_RADIUS + ); + + for (const unitId of nearbyUnitIds) { + const entity = this.world.getEntity(unitId); + if (!entity) continue; + + const transform = entity.get('Transform'); + const selectable = entity.get('Selectable'); + if (!transform || !selectable) continue; + + const dx = transform.x - tower.x; + const dy = transform.y - tower.y; + const distSq = dx * dx + dy * dy; + + if (distSq <= captureRadiusSq) { + tower.controllingPlayers.add(selectable.playerId); + tower.isActive = true; + } + } + + // Add watch tower as vision caster for controlling players + if (tower.isActive) { + for (const playerId of tower.controllingPlayers) { + // Use negative entity IDs for watch towers to avoid collision + const towerId = -tower.id - 10000; + seenEntityIds.add(towerId); + this.visionOptimizer.updateCaster(towerId, playerId, tower.x, tower.y, tower.radius); + } + } + } + } + + /** + * Process temporary reveals using optimized system + */ + private processTemporaryRevealsOptimized(currentTick: number, seenEntityIds: Set): void { + if (!this.visionOptimizer) return; + + let i = 0; + while (i < this.temporaryReveals.length) { + const reveal = this.temporaryReveals[i]; + + if (reveal.expirationTick <= currentTick) { + // Expired - remove caster and reveal entry + const revealId = -i - 20000; // Negative ID for reveals + this.visionOptimizer.removeCaster(revealId); + this.temporaryReveals[i] = this.temporaryReveals[this.temporaryReveals.length - 1]; + this.temporaryReveals.pop(); + } else { + // Still active - add as caster + const revealId = -i - 20000; + seenEntityIds.add(revealId); + this.visionOptimizer.updateCaster( + revealId, + reveal.playerId, + reveal.position.x, + reveal.position.y, + reveal.radius + ); + + // Detect cloaked units if this reveal can detect them + if (reveal.detectsCloaked) { + this.detectCloakedUnitsInArea(reveal.playerId, reveal.position, reveal.radius); + } + + i++; + } + } + } + + /** + * Sync VisionOptimizer state to visionMap for API compatibility + */ + private syncOptimizerToVisionMap(): void { + if (!this.visionOptimizer) return; + + const gridWidth = this.visionMap.width; + const gridHeight = this.visionMap.height; + + for (const playerId of this.knownPlayers) { + const visionGrid = this.visionMap.playerVision.get(playerId); + const currentVisible = this.visionMap.currentlyVisible.get(playerId); + + if (!visionGrid || !currentVisible) continue; + + currentVisible.clear(); + + for (let y = 0; y < gridHeight; y++) { + for (let x = 0; x < gridWidth; x++) { + const state = this.visionOptimizer.getCellVisionState(x, y, playerId); + visionGrid[y][x] = state; + if (state === 'visible') { + currentVisible.add(y * gridWidth + x); + } + } + } + } + } + + /** + * Legacy vision computation (fallback when optimizer not available) + */ + private updateVisionLegacy(currentTick: number): void { + const gridWidth = this.visionMap.width; + for (const playerId of this.knownPlayers) { const currentVisible = this.visionMap.currentlyVisible.get(playerId); const visionGrid = this.visionMap.playerVision.get(playerId); @@ -1006,11 +1231,8 @@ export class VisionSystem extends System { // Update watch towers this.updateWatchTowers(units); - // Process temporary reveals (scanner sweep, etc.) + // Process temporary reveals this.processTemporaryReveals(currentTick); - - // Increment version for dirty checking by renderers - this.visionVersion++; } /** @@ -1231,10 +1453,12 @@ export class VisionSystem extends System { // Check cache for existing mask const cacheKey = playerId; const cached = this.visionMaskCache.get(cacheKey); - if (cached && - cached.width === targetWidth && - cached.height === targetHeight && - cached.version === this.visionVersion) { + if ( + cached && + cached.width === targetWidth && + cached.height === targetHeight && + cached.version === this.visionVersion + ) { return cached.mask; } @@ -1257,13 +1481,17 @@ export class VisionSystem extends System { const state = visionGrid[srcY]?.[srcX] ?? 'unexplored'; // 0 = unexplored, 0.5 = explored, 1 = visible - mask[y * targetWidth + x] = - state === 'visible' ? 1.0 : state === 'explored' ? 0.5 : 0.0; + mask[y * targetWidth + x] = state === 'visible' ? 1.0 : state === 'explored' ? 0.5 : 0.0; } } // Update cache - this.visionMaskCache.set(cacheKey, { mask, width: targetWidth, height: targetHeight, version: this.visionVersion }); + this.visionMaskCache.set(cacheKey, { + mask, + width: targetWidth, + height: targetHeight, + version: this.visionVersion, + }); return mask; } diff --git a/src/engine/systems/vision/LineOfSight.ts b/src/engine/systems/vision/LineOfSight.ts index 541fc086..b5677018 100644 --- a/src/engine/systems/vision/LineOfSight.ts +++ b/src/engine/systems/vision/LineOfSight.ts @@ -156,6 +156,11 @@ export class LineOfSight { const stepY = dy / numSamples; const heightStep = (endHeight - startHeight) / numSamples; + // Get caster's ground level height (eye height minus offset) + // Terrain at or below caster's ground can't block their vision (high ground advantage) + const eyeHeightOffset = 0.5; + const casterGroundHeight = startHeight - eyeHeightOffset; + // Sample terrain along the ray for (let i = 1; i < numSamples; i++) { const sampleX = startX + stepX * i; @@ -165,6 +170,12 @@ export class LineOfSight { // Get terrain height at this sample point const terrainHeight = this.getHeight(sampleX, sampleY); + // High ground advantage: terrain at or below caster's level can't block their vision + // This allows units on high ground to see down without their own terrain blocking them + if (terrainHeight <= casterGroundHeight) { + continue; + } + // Check if terrain blocks the sight line // Terrain blocks if it's higher than the sight line by the threshold if (terrainHeight > sightLineHeight + this.config.losBlockingThreshold) { @@ -179,11 +190,7 @@ export class LineOfSight { * Check LOS for all cells in sight range and return visible cells * This is the main entry point for visibility calculation */ - public getVisibleCells( - casterX: number, - casterY: number, - sightRange: number - ): Set { + public getVisibleCells(casterX: number, casterY: number, sightRange: number): Set { const visibleCells = new Set(); if (!this.getHeight) { @@ -219,14 +226,9 @@ export class LineOfSight { } // Check LOS - if (this.hasLineOfSight( - casterX, - casterY, - casterHeight, - targetCellX, - targetCellY, - sightRange - )) { + if ( + this.hasLineOfSight(casterX, casterY, casterHeight, targetCellX, targetCellY, sightRange) + ) { visibleCells.add(targetCellY * this.config.gridWidth + targetCellX); } } @@ -467,7 +469,7 @@ export class LineOfSight { * Check if a specific cell blocks vision (for terrain features) * Used for features like dense forest that block vision */ - public doesCellBlockVision(cellX: number, cellY: number): boolean { + public doesCellBlockVision(_cellX: number, _cellY: number): boolean { // This would check terrain features if we had that data // For now, only terrain height is considered return false; diff --git a/src/engine/systems/vision/SDFVisionRenderer.ts b/src/engine/systems/vision/SDFVisionRenderer.ts index 5649b5f2..96a5a8b4 100644 --- a/src/engine/systems/vision/SDFVisionRenderer.ts +++ b/src/engine/systems/vision/SDFVisionRenderer.ts @@ -87,28 +87,59 @@ export class SDFVisionRenderer { /** * Update SDF from binary visibility data * - * Uses a two-pass distance transform algorithm: + * Computes distance to the visibility boundary using a two-pass distance transform: * 1. Forward pass: propagate distance left-to-right, top-to-bottom * 2. Backward pass: propagate distance right-to-left, bottom-to-top * + * The result is a signed distance field where: + * - Values > 0.5 are inside visible area (farther from edge = higher value) + * - Values < 0.5 are inside fog area (farther from edge = lower value) + * - Value = 0.5 is exactly on the boundary + * * @param playerId Player ID * @param visibilityMask Binary visibility data (0 = fog, 1 = visible) */ public updateSDF(playerId: string, visibilityMask: Float32Array): void { - const data = this.sdfData.get(playerId); - if (!data) { - this.getSDFTexture(playerId); // Creates the data array - } + // Ensure texture exists + this.getSDFTexture(playerId); const sdf = this.sdfData.get(playerId)!; const width = this.config.gridWidth; const height = this.config.gridHeight; const maxDist = this.config.maxDistance; - // Initialize distance field - // Inside visible: 0, Outside visible: large positive + // Find boundary cells (visible cells adjacent to fog cells) + // Initialize: boundary cells = 0, all others = large value for (let i = 0; i < sdf.length; i++) { - sdf[i] = visibilityMask[i] > 0.5 ? 0 : maxDist * 2; + sdf[i] = maxDist * 2; + } + + // Mark boundary cells as 0 (they are on the edge) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + const isVisible = visibilityMask[idx] > 0.5; + + // Check if this cell is on the boundary (visible cell adjacent to fog) + let isBoundary = false; + if (isVisible) { + // Check 4 neighbors for fog + if (x > 0 && visibilityMask[idx - 1] <= 0.5) isBoundary = true; + if (x < width - 1 && visibilityMask[idx + 1] <= 0.5) isBoundary = true; + if (y > 0 && visibilityMask[idx - width] <= 0.5) isBoundary = true; + if (y < height - 1 && visibilityMask[idx + width] <= 0.5) isBoundary = true; + } else { + // Check 4 neighbors for visible + if (x > 0 && visibilityMask[idx - 1] > 0.5) isBoundary = true; + if (x < width - 1 && visibilityMask[idx + 1] > 0.5) isBoundary = true; + if (y > 0 && visibilityMask[idx - width] > 0.5) isBoundary = true; + if (y < height - 1 && visibilityMask[idx + width] > 0.5) isBoundary = true; + } + + if (isBoundary) { + sdf[idx] = 0; + } + } } // Forward pass (left-to-right, top-to-bottom) @@ -153,19 +184,18 @@ export class SDFVisionRenderer { } } - // Convert to signed distance (negative inside fog, positive inside visible) - // And normalize to 0-1 range for texture + // Convert to signed distance normalized to 0-1 range + // 0.5 = boundary, >0.5 = inside visible (high = far from edge), <0.5 = inside fog for (let i = 0; i < sdf.length; i++) { const dist = sdf[i]; const isVisible = visibilityMask[i] > 0.5; - // Normalize distance to 0-1 range - // 0.5 = boundary, >0.5 = inside visible, <0.5 = inside fog - const normalizedDist = Math.min(dist, maxDist) / maxDist; - sdf[i] = isVisible ? 0.5 + normalizedDist * 0.5 : 0.5 - normalizedDist * 0.5; + // Normalize distance to 0-0.5 range + const normalizedDist = (Math.min(dist, maxDist) / maxDist) * 0.5; + sdf[i] = isVisible ? 0.5 + normalizedDist : 0.5 - normalizedDist; } - // Update texture + // Mark texture for GPU upload const tex = this.sdfTextures.get(playerId); if (tex) { tex.needsUpdate = true; @@ -188,117 +218,37 @@ export class SDFVisionRenderer { // Each pattern is a 4x4 block (16 values, 0-255) const basePatterns: Record = { // 0b0000: Isolated visible cell - 0: [ - 128, 192, 192, 128, - 192, 255, 255, 192, - 192, 255, 255, 192, - 128, 192, 192, 128, - ], + 0: [128, 192, 192, 128, 192, 255, 255, 192, 192, 255, 255, 192, 128, 192, 192, 128], // 0b0001: North neighbor visible - 1: [ - 255, 255, 255, 255, - 192, 255, 255, 192, - 192, 255, 255, 192, - 128, 192, 192, 128, - ], + 1: [255, 255, 255, 255, 192, 255, 255, 192, 192, 255, 255, 192, 128, 192, 192, 128], // 0b0010: South neighbor visible - 2: [ - 128, 192, 192, 128, - 192, 255, 255, 192, - 192, 255, 255, 192, - 255, 255, 255, 255, - ], + 2: [128, 192, 192, 128, 192, 255, 255, 192, 192, 255, 255, 192, 255, 255, 255, 255], // 0b0011: North and South visible - 3: [ - 255, 255, 255, 255, - 192, 255, 255, 192, - 192, 255, 255, 192, - 255, 255, 255, 255, - ], + 3: [255, 255, 255, 255, 192, 255, 255, 192, 192, 255, 255, 192, 255, 255, 255, 255], // 0b0100: East neighbor visible - 4: [ - 128, 192, 255, 255, - 192, 255, 255, 255, - 192, 255, 255, 255, - 128, 192, 255, 255, - ], + 4: [128, 192, 255, 255, 192, 255, 255, 255, 192, 255, 255, 255, 128, 192, 255, 255], // 0b0101: North and East visible - 5: [ - 255, 255, 255, 255, - 192, 255, 255, 255, - 192, 255, 255, 255, - 128, 192, 255, 255, - ], + 5: [255, 255, 255, 255, 192, 255, 255, 255, 192, 255, 255, 255, 128, 192, 255, 255], // 0b0110: South and East visible - 6: [ - 128, 192, 255, 255, - 192, 255, 255, 255, - 192, 255, 255, 255, - 255, 255, 255, 255, - ], + 6: [128, 192, 255, 255, 192, 255, 255, 255, 192, 255, 255, 255, 255, 255, 255, 255], // 0b0111: North, South, East visible - 7: [ - 255, 255, 255, 255, - 192, 255, 255, 255, - 192, 255, 255, 255, - 255, 255, 255, 255, - ], + 7: [255, 255, 255, 255, 192, 255, 255, 255, 192, 255, 255, 255, 255, 255, 255, 255], // 0b1000: West neighbor visible - 8: [ - 255, 255, 192, 128, - 255, 255, 255, 192, - 255, 255, 255, 192, - 255, 255, 192, 128, - ], + 8: [255, 255, 192, 128, 255, 255, 255, 192, 255, 255, 255, 192, 255, 255, 192, 128], // 0b1001: North and West visible - 9: [ - 255, 255, 255, 255, - 255, 255, 255, 192, - 255, 255, 255, 192, - 255, 255, 192, 128, - ], + 9: [255, 255, 255, 255, 255, 255, 255, 192, 255, 255, 255, 192, 255, 255, 192, 128], // 0b1010: South and West visible - 10: [ - 255, 255, 192, 128, - 255, 255, 255, 192, - 255, 255, 255, 192, - 255, 255, 255, 255, - ], + 10: [255, 255, 192, 128, 255, 255, 255, 192, 255, 255, 255, 192, 255, 255, 255, 255], // 0b1011: North, South, West visible - 11: [ - 255, 255, 255, 255, - 255, 255, 255, 192, - 255, 255, 255, 192, - 255, 255, 255, 255, - ], + 11: [255, 255, 255, 255, 255, 255, 255, 192, 255, 255, 255, 192, 255, 255, 255, 255], // 0b1100: East and West visible - 12: [ - 255, 255, 255, 255, - 255, 255, 255, 255, - 255, 255, 255, 255, - 255, 255, 255, 255, - ], + 12: [255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], // 0b1101: North, East, West visible - 13: [ - 255, 255, 255, 255, - 255, 255, 255, 255, - 255, 255, 255, 255, - 255, 255, 255, 255, - ], + 13: [255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], // 0b1110: South, East, West visible - 14: [ - 255, 255, 255, 255, - 255, 255, 255, 255, - 255, 255, 255, 255, - 255, 255, 255, 255, - ], + 14: [255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], // 0b1111: All neighbors visible - 15: [ - 255, 255, 255, 255, - 255, 255, 255, 255, - 255, 255, 255, 255, - 255, 255, 255, 255, - ], + 15: [255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], }; for (const [key, pattern] of Object.entries(basePatterns)) { @@ -346,7 +296,10 @@ export class SDFVisionRenderer { config |= 0b0001; } // South - if (y < this.config.gridHeight - 1 && inputMask[(y + 1) * this.config.gridWidth + x] > 0.5) { + if ( + y < this.config.gridHeight - 1 && + inputMask[(y + 1) * this.config.gridWidth + x] > 0.5 + ) { config |= 0b0010; } // East @@ -379,7 +332,11 @@ export class SDFVisionRenderer { /** * Create upscaled texture with pattern-based AA */ - public createUpscaledTexture(playerId: string, inputMask: Float32Array, scale: number = 4): THREE.DataTexture { + public createUpscaledTexture( + playerId: string, + inputMask: Float32Array, + scale: number = 4 + ): THREE.DataTexture { const upscaled = this.upscaleWithPatterns(inputMask, scale); const outWidth = this.config.gridWidth * scale; const outHeight = this.config.gridHeight * scale; @@ -421,7 +378,7 @@ export class SDFVisionRenderer { const distFromEdge = Math.abs(sdfValue - 0.5); // Convert to edge factor (closer to edge = higher factor) - const edgeFactor = 1 - Math.min(distFromEdge * 2 / this.config.edgeSoftness, 1); + const edgeFactor = 1 - Math.min((distFromEdge * 2) / this.config.edgeSoftness, 1); return edgeFactor; } diff --git a/src/engine/systems/vision/VisionOptimizer.ts b/src/engine/systems/vision/VisionOptimizer.ts index f21e5e5a..68302e49 100644 --- a/src/engine/systems/vision/VisionOptimizer.ts +++ b/src/engine/systems/vision/VisionOptimizer.ts @@ -10,6 +10,7 @@ */ import { debugPathfinding } from '@/utils/debugLogger'; +import type { LineOfSight, HeightProvider } from './LineOfSight'; export interface VisionCasterState { entityId: number; @@ -70,10 +71,26 @@ export class VisionOptimizer { // Pre-computed sight range to cell set mapping for common ranges private sightRangeCache: Map> = new Map(); + // Line-of-sight system for terrain-aware visibility + private lineOfSight: LineOfSight | null = null; + private heightProvider: HeightProvider | null = null; + constructor(config: VisionOptimizerConfig) { this.config = config; this.precomputeSightRanges(); - debugPathfinding.log(`[VisionOptimizer] Initialized ${config.gridWidth}x${config.gridHeight} grid`); + debugPathfinding.log( + `[VisionOptimizer] Initialized ${config.gridWidth}x${config.gridHeight} grid` + ); + } + + /** + * Set the line-of-sight system for terrain-aware visibility + * When set, vision calculations will use height-based LOS blocking + */ + public setLineOfSight(los: LineOfSight, heightProvider: HeightProvider): void { + this.lineOfSight = los; + this.heightProvider = heightProvider; + debugPathfinding.log('[VisionOptimizer] Line-of-sight system attached'); } /** @@ -100,12 +117,21 @@ export class VisionOptimizer { /** * Get cells visible from a position with given sight range + * Uses LineOfSight for terrain-aware visibility when available */ private getCellsInRange( cellX: number, cellY: number, - sightRange: number + sightRange: number, + worldX?: number, + worldY?: number ): Set { + // Use LineOfSight for terrain-aware visibility if available + if (this.lineOfSight && worldX !== undefined && worldY !== undefined) { + return this.lineOfSight.getVisibleCells(worldX, worldY, sightRange); + } + + // Fallback to simple circular visibility (no terrain blocking) const result = new Set(); const cellRange = Math.ceil(sightRange / this.config.cellSize); const cellRangeSq = cellRange * cellRange; @@ -221,7 +247,14 @@ export class VisionOptimizer { * Increment reference counts for cells visible by this caster */ private incrementCasterVision(caster: VisionCasterState): void { - const visibleCells = this.getCellsInRange(caster.cellX, caster.cellY, caster.sightRange); + // Pass world coordinates for terrain-aware LOS when LineOfSight is available + const visibleCells = this.getCellsInRange( + caster.cellX, + caster.cellY, + caster.sightRange, + caster.x, + caster.y + ); for (const cellKey of visibleCells) { let cellRef = this.cellRefCounts.get(cellKey); @@ -332,7 +365,10 @@ export class VisionOptimizer { * Get visibility data for GPU upload (optimized for incremental updates) * Returns only dirty cells if incremental, or full grid if not */ - public getVisibilityData(playerId: string, incremental: boolean = false): { + public getVisibilityData( + playerId: string, + incremental: boolean = false + ): { cells: Array<{ x: number; y: number; visible: number; explored: number }>; isDirty: boolean; } { diff --git a/tests/engine/systems/vision/SDFVisionRenderer.test.ts b/tests/engine/systems/vision/SDFVisionRenderer.test.ts index 72dd6b33..183b234a 100644 --- a/tests/engine/systems/vision/SDFVisionRenderer.test.ts +++ b/tests/engine/systems/vision/SDFVisionRenderer.test.ts @@ -37,8 +37,20 @@ describe('SDFVisionRenderer', () => { sdfRenderer.updateSDF('player1', mask); + // Verify texture exists and has correct dimensions const texture = sdfRenderer.getSDFTexture('player1'); - expect(texture.needsUpdate).toBe(true); + expect(texture).toBeDefined(); + expect(texture.image.width).toBe(defaultConfig.gridWidth); + expect(texture.image.height).toBe(defaultConfig.gridHeight); + + // Verify the SDF data was actually processed - center should have high values (visible) + // and edges of the visible area should be lower (near boundary) + const edgeFactorCenter = sdfRenderer.getEdgeFactor(8, 8, 'player1'); + const edgeFactorBoundary = sdfRenderer.getEdgeFactor(6, 6, 'player1'); + + // Center should have lower edge factor (farther from boundary) + // Boundary should have higher edge factor (on the edge) + expect(edgeFactorBoundary).toBeGreaterThanOrEqual(edgeFactorCenter); }); });