diff --git a/src/engine/systems/VisionSystem.ts b/src/engine/systems/VisionSystem.ts index 41749fbb..a624bec5 100644 --- a/src/engine/systems/VisionSystem.ts +++ b/src/engine/systems/VisionSystem.ts @@ -12,6 +12,10 @@ import { WatchTower } from '@/data/maps/MapTypes'; import type { VisionCompute, VisionCaster } from '@/rendering/compute/VisionCompute'; import type { WebGPURenderer } from 'three/webgpu'; import { debugPathfinding } from '@/utils/debugLogger'; +// Industry-standard fog of war optimizations +import { VisionOptimizer } from './vision/VisionOptimizer'; +import { LineOfSight, type HeightProvider } from './vision/LineOfSight'; +import { SDFVisionRenderer } from './vision/SDFVisionRenderer'; // Vision states for fog of war export type VisionState = 'unexplored' | 'explored' | 'visible'; @@ -92,6 +96,26 @@ export class VisionSystem extends System { // Track active computation path for debugging private activeComputePath: 'gpu' | 'worker' | 'main-thread' | null = null; + // ============================================ + // INDUSTRY-STANDARD OPTIMIZATIONS + // ============================================ + + // Phase 1: Reference counting and cell boundary tracking + private visionOptimizer: VisionOptimizer | null = null; + private useOptimizedVision: boolean = true; // Enable by default + + // Phase 2: Height-based line-of-sight blocking + private lineOfSight: LineOfSight | null = null; + private useLOSBlocking: boolean = true; // Enable by default + private heightProvider: HeightProvider | null = null; + + // Phase 3: SDF-based edge rendering + private sdfRenderer: SDFVisionRenderer | null = null; + private useSDF: boolean = true; // Enable by default + + // Track entity cell positions for boundary detection + private entityCellPositions: Map = new Map(); + constructor(game: Game, mapWidth: number, mapHeight: number, cellSize: number = 2) { super(game); this.mapWidth = mapWidth; @@ -99,6 +123,52 @@ export class VisionSystem extends System { this.cellSize = cellSize; this.initializeWorker(); this.setupEventListeners(); + this.initializeOptimizations(); + } + + /** + * Initialize industry-standard fog of war optimizations + */ + private initializeOptimizations(): void { + const gridWidth = Math.ceil(this.mapWidth / this.cellSize); + const gridHeight = Math.ceil(this.mapHeight / this.cellSize); + + // Phase 1: Reference counting optimizer + if (this.useOptimizedVision) { + this.visionOptimizer = new VisionOptimizer({ + gridWidth, + gridHeight, + cellSize: this.cellSize, + mapWidth: this.mapWidth, + mapHeight: this.mapHeight, + }); + debugPathfinding.log('[VisionSystem] Phase 1: Reference counting enabled'); + } + + // Phase 2: Line-of-sight blocking + if (this.useLOSBlocking) { + this.lineOfSight = new LineOfSight({ + gridWidth, + gridHeight, + cellSize: this.cellSize, + mapWidth: this.mapWidth, + mapHeight: this.mapHeight, + losBlockingThreshold: 1.0, // 1 world unit height difference blocks LOS + }); + debugPathfinding.log('[VisionSystem] Phase 2: LOS blocking enabled'); + } + + // Phase 3: SDF renderer for smooth edges + if (this.useSDF) { + this.sdfRenderer = new SDFVisionRenderer({ + gridWidth, + gridHeight, + cellSize: this.cellSize, + maxDistance: 8, // 8 cells max SDF propagation + edgeSoftness: 0.3, + }); + debugPathfinding.log('[VisionSystem] Phase 3: SDF edge rendering enabled'); + } } /** @@ -318,6 +388,47 @@ export class VisionSystem extends System { cellSize: this.cellSize, }); } + + // Reinitialize optimization systems + const gridWidth = Math.ceil(this.mapWidth / this.cellSize); + const gridHeight = Math.ceil(this.mapHeight / this.cellSize); + + if (this.visionOptimizer) { + this.visionOptimizer.reinitialize({ + gridWidth, + gridHeight, + cellSize: this.cellSize, + mapWidth: this.mapWidth, + mapHeight: this.mapHeight, + }); + } + + if (this.lineOfSight) { + this.lineOfSight.reinitialize({ + gridWidth, + gridHeight, + cellSize: this.cellSize, + mapWidth: this.mapWidth, + mapHeight: this.mapHeight, + losBlockingThreshold: 1.0, + }); + // Re-attach height provider if available + if (this.heightProvider) { + this.lineOfSight.setHeightProvider(this.heightProvider); + } + } + + if (this.sdfRenderer) { + this.sdfRenderer.reinitialize({ + gridWidth, + gridHeight, + cellSize: this.cellSize, + maxDistance: 8, + edgeSoftness: 0.3, + }); + } + + this.entityCellPositions.clear(); } private initializeVisionMap(): void { @@ -403,6 +514,87 @@ export class VisionSystem extends System { return this.gpuVisionCompute; } + // ============================================ + // PHASE 1-3 PUBLIC API + // ============================================ + + /** + * Set the height provider for line-of-sight calculations + * Call this after terrain is initialized (e.g., from Terrain.getHeightAt) + */ + public setHeightProvider(provider: HeightProvider): void { + this.heightProvider = provider; + if (this.lineOfSight) { + this.lineOfSight.setHeightProvider(provider); + debugPathfinding.log('[VisionSystem] Height provider set for LOS blocking'); + } + } + + /** + * Enable/disable reference counting optimization (Phase 1) + */ + public setOptimizedVisionEnabled(enabled: boolean): void { + this.useOptimizedVision = enabled; + if (!enabled && this.visionOptimizer) { + this.visionOptimizer.dispose(); + this.visionOptimizer = null; + } else if (enabled && !this.visionOptimizer) { + this.initializeOptimizations(); + } + } + + /** + * Enable/disable line-of-sight blocking (Phase 2) + */ + public setLOSBlockingEnabled(enabled: boolean): void { + this.useLOSBlocking = enabled; + debugPathfinding.log(`[VisionSystem] LOS blocking ${enabled ? 'enabled' : 'disabled'}`); + } + + /** + * Enable/disable SDF edge rendering (Phase 3) + */ + public setSDFRenderingEnabled(enabled: boolean): void { + this.useSDF = enabled; + debugPathfinding.log(`[VisionSystem] SDF rendering ${enabled ? 'enabled' : 'disabled'}`); + } + + /** + * Get the vision optimizer instance (for debugging/stats) + */ + public getVisionOptimizer(): VisionOptimizer | null { + return this.visionOptimizer; + } + + /** + * Get the line-of-sight system instance + */ + public getLineOfSight(): LineOfSight | null { + return this.lineOfSight; + } + + /** + * Get the SDF renderer instance + */ + public getSDFRenderer(): SDFVisionRenderer | null { + return this.sdfRenderer; + } + + /** + * Check if optimizations are enabled + */ + public getOptimizationStatus(): { + referenceCountingEnabled: boolean; + losBlockingEnabled: boolean; + sdfRenderingEnabled: boolean; + } { + return { + referenceCountingEnabled: this.useOptimizedVision, + losBlockingEnabled: this.useLOSBlocking, + sdfRenderingEnabled: this.useSDF, + }; + } + /** * Get numeric player index for GPU (creates if not exists) */ @@ -433,6 +625,21 @@ export class VisionSystem extends System { this.gpuVisionCompute = null; } this.useGPUVision = false; + + // Clean up optimization systems + if (this.visionOptimizer) { + this.visionOptimizer.dispose(); + this.visionOptimizer = null; + } + if (this.lineOfSight) { + this.lineOfSight.dispose(); + this.lineOfSight = null; + } + if (this.sdfRenderer) { + this.sdfRenderer.dispose(); + this.sdfRenderer = null; + } + this.entityCellPositions.clear(); } public update(_deltaTime: number): void { diff --git a/src/engine/systems/vision/LineOfSight.ts b/src/engine/systems/vision/LineOfSight.ts new file mode 100644 index 00000000..541fc086 --- /dev/null +++ b/src/engine/systems/vision/LineOfSight.ts @@ -0,0 +1,499 @@ +/** + * LineOfSight - Terrain-aware line of sight blocking for fog of war + * + * Industry-standard LOS systems from StarCraft 2, Age of Empires, etc: + * 1. Height advantage: Units on high ground see down, low ground can't see up + * 2. Ray-based blocking: Trace rays from caster to cells, block at terrain + * 3. Shadowcasting: Efficient octant-based visibility calculation + * + * This module implements height-based LOS blocking using Bresenham's line algorithm + * with terrain height sampling along the ray. + */ + +import { debugPathfinding } from '@/utils/debugLogger'; + +export interface LOSConfig { + gridWidth: number; + gridHeight: number; + cellSize: number; + mapWidth: number; + mapHeight: number; + // Height difference threshold to block LOS (in world units) + // Terrain must be at least this much higher to block vision + losBlockingThreshold: number; +} + +export type HeightProvider = (worldX: number, worldY: number) => number; + +/** + * LineOfSight calculates terrain-aware visibility. + * + * Algorithm: For each cell in sight range, trace a ray from caster to cell. + * If any terrain along the ray is higher than the sight line, block vision. + * + * The sight line is calculated with a small upward offset to allow looking + * slightly over terrain (simulating unit eye height). + */ +export class LineOfSight { + private config: LOSConfig; + private getHeight: HeightProvider | null = null; + + // Cache for LOS calculations to avoid redundant traces + private losCache: Map = new Map(); + private losCacheVersion: number = 0; + + // Pre-computed values + private halfCellSize: number; + + constructor(config: LOSConfig) { + this.config = config; + this.halfCellSize = config.cellSize / 2; + debugPathfinding.log(`[LineOfSight] Initialized with threshold ${config.losBlockingThreshold}`); + } + + /** + * Set the height provider function (from Terrain) + */ + public setHeightProvider(provider: HeightProvider): void { + this.getHeight = provider; + this.invalidateCache(); + } + + /** + * Invalidate the LOS cache (call when terrain changes) + */ + public invalidateCache(): void { + this.losCacheVersion++; + this.losCache.clear(); + } + + /** + * Check if there's line of sight from caster position to target cell + * + * @param casterX Caster world X + * @param casterY Caster world Y (game coordinate = depth/north-south) + * @param casterHeight Height at caster position + * @param targetCellX Target cell X + * @param targetCellY Target cell Y + * @param sightRange Caster's sight range (for height advantage calculation) + * @returns true if LOS is clear, false if blocked + */ + public hasLineOfSight( + casterX: number, + casterY: number, + casterHeight: number, + targetCellX: number, + targetCellY: number, + sightRange: number + ): boolean { + if (!this.getHeight) { + // No height provider - assume clear LOS (fallback behavior) + return true; + } + + // Calculate target world position (cell center) + const targetX = (targetCellX + 0.5) * this.config.cellSize; + const targetY = (targetCellY + 0.5) * this.config.cellSize; + + // Check cache + const cacheKey = this.getCacheKey(casterX, casterY, targetCellX, targetCellY); + const cached = this.losCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + // Get target height + const targetHeight = this.getHeight(targetX, targetY); + + // Calculate sight line with eye height offset + // Caster "eye" is slightly above terrain to allow looking over small bumps + const eyeHeightOffset = 0.5; // Half unit above terrain + const casterEyeHeight = casterHeight + eyeHeightOffset; + + // Trace ray using Bresenham's algorithm + const result = this.traceRay( + casterX, + casterY, + casterEyeHeight, + targetX, + targetY, + targetHeight, + sightRange + ); + + // Cache result + this.losCache.set(cacheKey, result); + + return result; + } + + /** + * Trace a ray from caster to target, checking terrain heights along the way + */ + private traceRay( + startX: number, + startY: number, + startHeight: number, + endX: number, + endY: number, + endHeight: number, + _sightRange: number + ): boolean { + if (!this.getHeight) return true; + + const dx = endX - startX; + const dy = endY - startY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < this.config.cellSize) { + // Target is in same or adjacent cell - always visible + return true; + } + + // Number of samples along the ray (at least 1 per cell) + const numSamples = Math.max(4, Math.ceil(distance / this.config.cellSize)); + const stepX = dx / numSamples; + const stepY = dy / numSamples; + const heightStep = (endHeight - startHeight) / numSamples; + + // Sample terrain along the ray + for (let i = 1; i < numSamples; i++) { + const sampleX = startX + stepX * i; + const sampleY = startY + stepY * i; + const sightLineHeight = startHeight + heightStep * i; + + // Get terrain height at this sample point + const terrainHeight = this.getHeight(sampleX, sampleY); + + // 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) { + return false; // LOS blocked + } + } + + return true; // LOS clear + } + + /** + * 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 { + const visibleCells = new Set(); + + if (!this.getHeight) { + // No height provider - return all cells in range (fallback) + return this.getAllCellsInRange(casterX, casterY, sightRange); + } + + const casterCellX = Math.floor(casterX / this.config.cellSize); + const casterCellY = Math.floor(casterY / this.config.cellSize); + const cellRange = Math.ceil(sightRange / this.config.cellSize); + const cellRangeSq = cellRange * cellRange; + + // Get caster height + const casterHeight = this.getHeight(casterX, casterY); + + // Check all cells in sight range + for (let dy = -cellRange; dy <= cellRange; dy++) { + for (let dx = -cellRange; dx <= cellRange; dx++) { + const distSq = dx * dx + dy * dy; + if (distSq > cellRangeSq) continue; + + const targetCellX = casterCellX + dx; + const targetCellY = casterCellY + dy; + + // Bounds check + if ( + targetCellX < 0 || + targetCellX >= this.config.gridWidth || + targetCellY < 0 || + targetCellY >= this.config.gridHeight + ) { + continue; + } + + // Check LOS + if (this.hasLineOfSight( + casterX, + casterY, + casterHeight, + targetCellX, + targetCellY, + sightRange + )) { + visibleCells.add(targetCellY * this.config.gridWidth + targetCellX); + } + } + } + + return visibleCells; + } + + /** + * Get all cells in range (no LOS blocking - fallback) + */ + private getAllCellsInRange(casterX: number, casterY: number, sightRange: number): Set { + const visibleCells = new Set(); + const casterCellX = Math.floor(casterX / this.config.cellSize); + const casterCellY = Math.floor(casterY / this.config.cellSize); + const cellRange = Math.ceil(sightRange / this.config.cellSize); + const cellRangeSq = cellRange * cellRange; + + for (let dy = -cellRange; dy <= cellRange; dy++) { + for (let dx = -cellRange; dx <= cellRange; dx++) { + const distSq = dx * dx + dy * dy; + if (distSq > cellRangeSq) continue; + + const targetCellX = casterCellX + dx; + const targetCellY = casterCellY + dy; + + if ( + targetCellX >= 0 && + targetCellX < this.config.gridWidth && + targetCellY >= 0 && + targetCellY < this.config.gridHeight + ) { + visibleCells.add(targetCellY * this.config.gridWidth + targetCellX); + } + } + } + + return visibleCells; + } + + /** + * Optimized shadowcasting for high-performance LOS calculation + * Uses recursive shadowcasting in 8 octants (similar to roguelike FOV) + * + * This is more efficient than ray-per-cell for large sight ranges. + */ + public getVisibleCellsShadowcast( + casterX: number, + casterY: number, + sightRange: number + ): Set { + const visibleCells = new Set(); + + if (!this.getHeight) { + return this.getAllCellsInRange(casterX, casterY, sightRange); + } + + const casterCellX = Math.floor(casterX / this.config.cellSize); + const casterCellY = Math.floor(casterY / this.config.cellSize); + const casterHeight = this.getHeight(casterX, casterY); + const cellRange = Math.ceil(sightRange / this.config.cellSize); + + // Caster's cell is always visible + if ( + casterCellX >= 0 && + casterCellX < this.config.gridWidth && + casterCellY >= 0 && + casterCellY < this.config.gridHeight + ) { + visibleCells.add(casterCellY * this.config.gridWidth + casterCellX); + } + + // Process all 8 octants + for (let octant = 0; octant < 8; octant++) { + this.castLight( + visibleCells, + casterCellX, + casterCellY, + casterHeight, + cellRange, + 1, + 1.0, + 0.0, + octant + ); + } + + return visibleCells; + } + + /** + * Recursive shadowcasting for one octant + */ + private castLight( + visibleCells: Set, + casterCellX: number, + casterCellY: number, + casterHeight: number, + range: number, + row: number, + startSlope: number, + endSlope: number, + octant: number + ): void { + if (startSlope < endSlope || row > range) { + return; + } + + let newStartSlope = startSlope; + let blocked = false; + + for (let distance = row; distance <= range && !blocked; distance++) { + const dy = -distance; + + for (let dx = -distance; dx <= 0; dx++) { + // Map octant-relative coordinates to actual coordinates + const [mapDx, mapDy] = this.transformOctant(dx, dy, octant); + const targetCellX = casterCellX + mapDx; + const targetCellY = casterCellY + mapDy; + + // Bounds check + if ( + targetCellX < 0 || + targetCellX >= this.config.gridWidth || + targetCellY < 0 || + targetCellY >= this.config.gridHeight + ) { + continue; + } + + // Calculate slopes + const leftSlope = (dx - 0.5) / (dy + 0.5); + const rightSlope = (dx + 0.5) / (dy - 0.5); + + if (startSlope < rightSlope) { + continue; + } else if (endSlope > leftSlope) { + break; + } + + // Distance check (circular range) + const distSq = mapDx * mapDx + mapDy * mapDy; + if (distSq > range * range) { + continue; + } + + // Check terrain height blocking + const targetWorldX = (targetCellX + 0.5) * this.config.cellSize; + const targetWorldY = (targetCellY + 0.5) * this.config.cellSize; + const targetHeight = this.getHeight!(targetWorldX, targetWorldY); + + // Cell is visible if terrain height doesn't block + const eyeHeightOffset = 0.5; + const sightLineHeight = + casterHeight + + eyeHeightOffset - + (Math.sqrt(distSq) * this.config.cellSize * eyeHeightOffset) / range; + + const isBlocking = targetHeight > sightLineHeight + this.config.losBlockingThreshold; + + if (!isBlocking) { + visibleCells.add(targetCellY * this.config.gridWidth + targetCellX); + } + + if (blocked) { + // Previous cell was blocking + if (isBlocking) { + newStartSlope = rightSlope; + continue; + } else { + blocked = false; + startSlope = newStartSlope; + } + } else { + if (isBlocking && distance < range) { + // This cell blocks - recurse + blocked = true; + this.castLight( + visibleCells, + casterCellX, + casterCellY, + casterHeight, + range, + distance + 1, + startSlope, + leftSlope, + octant + ); + newStartSlope = rightSlope; + } + } + } + } + } + + /** + * Transform coordinates based on octant + */ + private transformOctant(dx: number, dy: number, octant: number): [number, number] { + switch (octant) { + case 0: + return [dx, dy]; + case 1: + return [dy, dx]; + case 2: + return [-dy, dx]; + case 3: + return [-dx, dy]; + case 4: + return [-dx, -dy]; + case 5: + return [-dy, -dx]; + case 6: + return [dy, -dx]; + case 7: + return [dx, -dy]; + default: + return [dx, dy]; + } + } + + /** + * Generate cache key for LOS lookup + */ + private getCacheKey( + casterX: number, + casterY: number, + targetCellX: number, + targetCellY: number + ): string { + // Round caster position to cell precision for cache efficiency + const casterCellX = Math.floor(casterX / this.config.cellSize); + const casterCellY = Math.floor(casterY / this.config.cellSize); + return `${this.losCacheVersion}:${casterCellX},${casterCellY}:${targetCellX},${targetCellY}`; + } + + /** + * 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 { + // This would check terrain features if we had that data + // For now, only terrain height is considered + return false; + } + + /** + * Update configuration + */ + public reinitialize(config: LOSConfig): void { + this.config = config; + this.halfCellSize = config.cellSize / 2; + this.invalidateCache(); + } + + /** + * Get config + */ + public getConfig(): LOSConfig { + return this.config; + } + + /** + * Dispose resources + */ + public dispose(): void { + this.losCache.clear(); + this.getHeight = null; + } +} diff --git a/src/engine/systems/vision/SDFVisionRenderer.ts b/src/engine/systems/vision/SDFVisionRenderer.ts new file mode 100644 index 00000000..5649b5f2 --- /dev/null +++ b/src/engine/systems/vision/SDFVisionRenderer.ts @@ -0,0 +1,456 @@ +/** + * SDFVisionRenderer - Signed Distance Field based fog of war edge rendering + * + * Industry technique for smooth fog edges: + * 1. Store distance to nearest visible cell instead of binary visible/not + * 2. Enables smooth edges without expensive blur + * 3. Can render soft shadows at fog boundaries + * 4. Used in modern RTS games for high-quality fog edges + * + * SDF Benefits: + * - Resolution-independent smooth edges + * - Efficient GPU sampling (no blur kernel) + * - Natural anti-aliasing at boundaries + * - Easy to animate (add noise to distance) + */ + +import * as THREE from 'three'; +import { debugShaders } from '@/utils/debugLogger'; + +export interface SDFVisionConfig { + gridWidth: number; + gridHeight: number; + cellSize: number; + // Max distance to propagate SDF (in cells) + maxDistance: number; + // Edge softness (0-1, how much to smooth edges) + edgeSoftness: number; +} + +/** + * SDFVisionRenderer generates a signed distance field from binary visibility data. + * + * The SDF stores the distance to the nearest visibility boundary: + * - Positive values: inside visible area, distance to edge + * - Negative values: inside fog, distance to visible edge + * - Zero: exactly on the boundary + * + * This enables smooth edge rendering without expensive per-pixel blur. + */ +export class SDFVisionRenderer { + private config: SDFVisionConfig; + + // SDF textures per player + private sdfTextures: Map = new Map(); + private sdfData: Map = new Map(); + + // Temporary buffers for distance transform + private tempBuffer1: Float32Array; + private tempBuffer2: Float32Array; + + constructor(config: SDFVisionConfig) { + this.config = config; + const size = config.gridWidth * config.gridHeight; + this.tempBuffer1 = new Float32Array(size); + this.tempBuffer2 = new Float32Array(size); + + debugShaders.log(`[SDFVisionRenderer] Initialized ${config.gridWidth}x${config.gridHeight}`); + } + + /** + * Get or create SDF texture for a player + */ + public getSDFTexture(playerId: string): THREE.DataTexture { + let tex = this.sdfTextures.get(playerId); + if (!tex) { + const data = new Float32Array(this.config.gridWidth * this.config.gridHeight); + this.sdfData.set(playerId, data); + + tex = new THREE.DataTexture( + data, + this.config.gridWidth, + this.config.gridHeight, + THREE.RedFormat, + THREE.FloatType + ); + tex.minFilter = THREE.LinearFilter; + tex.magFilter = THREE.LinearFilter; + tex.wrapS = THREE.ClampToEdgeWrapping; + tex.wrapT = THREE.ClampToEdgeWrapping; + tex.needsUpdate = true; + + this.sdfTextures.set(playerId, tex); + } + return tex; + } + + /** + * Update SDF from binary visibility data + * + * Uses a two-pass distance transform algorithm: + * 1. Forward pass: propagate distance left-to-right, top-to-bottom + * 2. Backward pass: propagate distance right-to-left, bottom-to-top + * + * @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 + } + + 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 + for (let i = 0; i < sdf.length; i++) { + sdf[i] = visibilityMask[i] > 0.5 ? 0 : maxDist * 2; + } + + // Forward pass (left-to-right, top-to-bottom) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + + if (x > 0) { + sdf[idx] = Math.min(sdf[idx], sdf[idx - 1] + 1); + } + if (y > 0) { + sdf[idx] = Math.min(sdf[idx], sdf[idx - width] + 1); + } + // Diagonal + if (x > 0 && y > 0) { + sdf[idx] = Math.min(sdf[idx], sdf[idx - width - 1] + 1.414); + } + if (x < width - 1 && y > 0) { + sdf[idx] = Math.min(sdf[idx], sdf[idx - width + 1] + 1.414); + } + } + } + + // Backward pass (right-to-left, bottom-to-top) + for (let y = height - 1; y >= 0; y--) { + for (let x = width - 1; x >= 0; x--) { + const idx = y * width + x; + + if (x < width - 1) { + sdf[idx] = Math.min(sdf[idx], sdf[idx + 1] + 1); + } + if (y < height - 1) { + sdf[idx] = Math.min(sdf[idx], sdf[idx + width] + 1); + } + // Diagonal + if (x < width - 1 && y < height - 1) { + sdf[idx] = Math.min(sdf[idx], sdf[idx + width + 1] + 1.414); + } + if (x > 0 && y < height - 1) { + sdf[idx] = Math.min(sdf[idx], sdf[idx + width - 1] + 1.414); + } + } + } + + // Convert to signed distance (negative inside fog, positive inside visible) + // And normalize to 0-1 range for texture + 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; + } + + // Update texture + const tex = this.sdfTextures.get(playerId); + if (tex) { + tex.needsUpdate = true; + } + } + + /** + * Generate pattern-based AA edges (League of Legends technique) + * + * LoL uses 16 unique transition patterns for edge anti-aliasing: + * Each edge cell is expanded to a 4x4 block with gray "anti-aliasing" pixels + * based on neighbor configuration. + * + * This method generates those patterns for upscaling from 128x128 to 512x512. + */ + public generateAAPatterns(): Map { + const patterns = new Map(); + + // 16 patterns based on 4-neighbor configuration (N, S, E, W) + // 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, + ], + // 0b0001: North neighbor visible + 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, + ], + // 0b0011: North and South visible + 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, + ], + // 0b0101: North and East visible + 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, + ], + // 0b0111: North, South, East visible + 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, + ], + // 0b1001: North and West visible + 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, + ], + // 0b1011: North, South, West visible + 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, + ], + // 0b1101: North, East, West visible + 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, + ], + // 0b1111: All neighbors visible + 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)) { + patterns.set(parseInt(key), new Uint8Array(pattern)); + } + + return patterns; + } + + /** + * Upscale visibility texture with pattern-based AA (LoL technique) + * + * @param inputMask Input visibility mask (gridWidth x gridHeight) + * @param scale Upscale factor (e.g., 4 for 128→512) + * @returns Upscaled texture data + */ + public upscaleWithPatterns(inputMask: Float32Array, scale: number = 4): Uint8Array { + const patterns = this.generateAAPatterns(); + const outWidth = this.config.gridWidth * scale; + const outHeight = this.config.gridHeight * scale; + const output = new Uint8Array(outWidth * outHeight); + + for (let y = 0; y < this.config.gridHeight; y++) { + for (let x = 0; x < this.config.gridWidth; x++) { + const idx = y * this.config.gridWidth + x; + const isVisible = inputMask[idx] > 0.5; + + if (!isVisible) { + // Fog cell - fill with zeros + for (let py = 0; py < scale; py++) { + for (let px = 0; px < scale; px++) { + const outX = x * scale + px; + const outY = y * scale + py; + output[outY * outWidth + outX] = 0; + } + } + continue; + } + + // Determine neighbor configuration + let config = 0; + + // North + if (y > 0 && inputMask[(y - 1) * this.config.gridWidth + x] > 0.5) { + config |= 0b0001; + } + // South + if (y < this.config.gridHeight - 1 && inputMask[(y + 1) * this.config.gridWidth + x] > 0.5) { + config |= 0b0010; + } + // East + if (x < this.config.gridWidth - 1 && inputMask[y * this.config.gridWidth + (x + 1)] > 0.5) { + config |= 0b0100; + } + // West + if (x > 0 && inputMask[y * this.config.gridWidth + (x - 1)] > 0.5) { + config |= 0b1000; + } + + // Get pattern + const pattern = patterns.get(config) || patterns.get(15)!; + + // Apply pattern + for (let py = 0; py < scale; py++) { + for (let px = 0; px < scale; px++) { + const outX = x * scale + px; + const outY = y * scale + py; + const patternIdx = py * scale + px; + output[outY * outWidth + outX] = pattern[patternIdx] || 255; + } + } + } + } + + return output; + } + + /** + * Create upscaled texture with pattern-based AA + */ + 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; + + const tex = new THREE.DataTexture( + upscaled, + outWidth, + outHeight, + THREE.RedFormat, + THREE.UnsignedByteType + ); + tex.minFilter = THREE.LinearFilter; + tex.magFilter = THREE.LinearFilter; + tex.wrapS = THREE.ClampToEdgeWrapping; + tex.wrapT = THREE.ClampToEdgeWrapping; + tex.needsUpdate = true; + + return tex; + } + + /** + * Get edge blend factor for smooth transitions + * Uses SDF to calculate how close to edge a point is + * + * @param x Grid X coordinate + * @param y Grid Y coordinate + * @param playerId Player ID + * @returns Edge factor 0-1 (0 = solid fog/visible, 1 = on edge) + */ + public getEdgeFactor(x: number, y: number, playerId: string): number { + const data = this.sdfData.get(playerId); + if (!data) return 0; + + const idx = y * this.config.gridWidth + x; + const sdfValue = data[idx]; + + // SDF value of 0.5 = on edge + // Distance from 0.5 determines how far from edge + 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); + + return edgeFactor; + } + + /** + * Reinitialize with new configuration + */ + public reinitialize(config: SDFVisionConfig): void { + this.config = config; + const size = config.gridWidth * config.gridHeight; + this.tempBuffer1 = new Float32Array(size); + this.tempBuffer2 = new Float32Array(size); + + // Clear existing textures + for (const tex of this.sdfTextures.values()) { + tex.dispose(); + } + this.sdfTextures.clear(); + this.sdfData.clear(); + } + + /** + * Dispose resources + */ + public dispose(): void { + for (const tex of this.sdfTextures.values()) { + tex.dispose(); + } + this.sdfTextures.clear(); + this.sdfData.clear(); + } +} diff --git a/src/engine/systems/vision/VisionOptimizer.ts b/src/engine/systems/vision/VisionOptimizer.ts new file mode 100644 index 00000000..f21e5e5a --- /dev/null +++ b/src/engine/systems/vision/VisionOptimizer.ts @@ -0,0 +1,438 @@ +/** + * VisionOptimizer - Reference counting and cell boundary tracking for fog of war + * + * Industry-standard optimizations used by StarCraft 2, League of Legends, etc: + * 1. Reference counting: Track how many units can see each cell + * 2. Cell boundary detection: Only update when units cross cell boundaries + * 3. Incremental updates: Only process cells affected by moved units + * + * Performance impact: O(casters × cells) → O(moved_units × affected_cells) + */ + +import { debugPathfinding } from '@/utils/debugLogger'; + +export interface VisionCasterState { + entityId: number; + playerId: string; + x: number; + y: number; + sightRange: number; + // Cell position (for boundary detection) + cellX: number; + cellY: number; + // Previously visible cells for this caster + visibleCells: Set; +} + +export interface CellReferenceCount { + // How many casters can see this cell per player + // Map + refCounts: Map; + // Last tick this cell was visible (for explored state persistence) + lastVisibleTick: Map; +} + +export interface VisionOptimizerConfig { + gridWidth: number; + gridHeight: number; + cellSize: number; + mapWidth: number; + mapHeight: number; +} + +/** + * VisionOptimizer tracks vision state efficiently using reference counting. + * + * Key insight from industry research: + * - Static units = zero vision processing + * - Moving units only update their affected cells + * - O(1) visibility check instead of O(casters) + */ +export class VisionOptimizer { + private config: VisionOptimizerConfig; + + // Caster tracking + private casters: Map = new Map(); + + // Reference counting per cell + // Key: cellY * gridWidth + cellX (numeric for performance) + private cellRefCounts: Map = new Map(); + + // Dirty cells that need GPU update this frame + private dirtyCells: Set = new Set(); + + // Players with dirty vision state + private dirtyPlayers: Set = new Set(); + + // Current game tick for explored state tracking + private currentTick: number = 0; + + // Pre-computed sight range to cell set mapping for common ranges + private sightRangeCache: Map> = new Map(); + + constructor(config: VisionOptimizerConfig) { + this.config = config; + this.precomputeSightRanges(); + debugPathfinding.log(`[VisionOptimizer] Initialized ${config.gridWidth}x${config.gridHeight} grid`); + } + + /** + * Pre-compute cell offsets for common sight ranges (optimization) + */ + private precomputeSightRanges(): void { + // Pre-compute for sight ranges 1-20 (covers most units) + for (let range = 1; range <= 20; range++) { + const cells = new Set<{ dx: number; dy: number }>(); + const cellRange = Math.ceil(range / this.config.cellSize); + const cellRangeSq = cellRange * cellRange; + + for (let dy = -cellRange; dy <= cellRange; dy++) { + for (let dx = -cellRange; dx <= cellRange; dx++) { + const distSq = dx * dx + dy * dy; + if (distSq <= cellRangeSq) { + cells.add({ dx, dy }); + } + } + } + this.sightRangeCache.set(range, cells); + } + } + + /** + * Get cells visible from a position with given sight range + */ + private getCellsInRange( + cellX: number, + cellY: number, + sightRange: number + ): Set { + const result = new Set(); + const cellRange = Math.ceil(sightRange / this.config.cellSize); + const cellRangeSq = cellRange * cellRange; + + // Use pre-computed offsets if available + const cached = this.sightRangeCache.get(Math.ceil(sightRange)); + if (cached) { + for (const { dx, dy } of cached) { + const x = cellX + dx; + const y = cellY + dy; + if (x >= 0 && x < this.config.gridWidth && y >= 0 && y < this.config.gridHeight) { + result.add(y * this.config.gridWidth + x); + } + } + } else { + // Fallback for unusual ranges + for (let dy = -cellRange; dy <= cellRange; dy++) { + for (let dx = -cellRange; dx <= cellRange; dx++) { + const distSq = dx * dx + dy * dy; + if (distSq <= cellRangeSq) { + const x = cellX + dx; + const y = cellY + dy; + if (x >= 0 && x < this.config.gridWidth && y >= 0 && y < this.config.gridHeight) { + result.add(y * this.config.gridWidth + x); + } + } + } + } + } + + return result; + } + + /** + * World position to cell position + */ + private worldToCell(worldX: number, worldY: number): { cellX: number; cellY: number } { + return { + cellX: Math.floor(worldX / this.config.cellSize), + cellY: Math.floor(worldY / this.config.cellSize), + }; + } + + /** + * Register or update a vision caster (unit/building) + * Returns true if the caster crossed a cell boundary (vision needs update) + */ + public updateCaster( + entityId: number, + playerId: string, + worldX: number, + worldY: number, + sightRange: number + ): boolean { + const { cellX, cellY } = this.worldToCell(worldX, worldY); + + const existing = this.casters.get(entityId); + if (existing) { + // Check if caster crossed cell boundary + if (existing.cellX === cellX && existing.cellY === cellY) { + // Same cell - update position but no vision recalc needed + existing.x = worldX; + existing.y = worldY; + return false; + } + + // Crossed boundary - need to update vision + this.decrementCasterVision(existing); + + // Update caster state + existing.x = worldX; + existing.y = worldY; + existing.cellX = cellX; + existing.cellY = cellY; + existing.sightRange = sightRange; + + this.incrementCasterVision(existing); + this.dirtyPlayers.add(playerId); + return true; + } else { + // New caster + const newCaster: VisionCasterState = { + entityId, + playerId, + x: worldX, + y: worldY, + sightRange, + cellX, + cellY, + visibleCells: new Set(), + }; + + this.casters.set(entityId, newCaster); + this.incrementCasterVision(newCaster); + this.dirtyPlayers.add(playerId); + return true; + } + } + + /** + * Remove a caster (unit died or building destroyed) + */ + public removeCaster(entityId: number): void { + const caster = this.casters.get(entityId); + if (!caster) return; + + this.decrementCasterVision(caster); + this.dirtyPlayers.add(caster.playerId); + this.casters.delete(entityId); + } + + /** + * Increment reference counts for cells visible by this caster + */ + private incrementCasterVision(caster: VisionCasterState): void { + const visibleCells = this.getCellsInRange(caster.cellX, caster.cellY, caster.sightRange); + + for (const cellKey of visibleCells) { + let cellRef = this.cellRefCounts.get(cellKey); + if (!cellRef) { + cellRef = { + refCounts: new Map(), + lastVisibleTick: new Map(), + }; + this.cellRefCounts.set(cellKey, cellRef); + } + + const currentCount = cellRef.refCounts.get(caster.playerId) || 0; + cellRef.refCounts.set(caster.playerId, currentCount + 1); + cellRef.lastVisibleTick.set(caster.playerId, this.currentTick); + + this.dirtyCells.add(cellKey); + caster.visibleCells.add(cellKey); + } + } + + /** + * Decrement reference counts for cells visible by this caster + */ + private decrementCasterVision(caster: VisionCasterState): void { + for (const cellKey of caster.visibleCells) { + const cellRef = this.cellRefCounts.get(cellKey); + if (!cellRef) continue; + + const currentCount = cellRef.refCounts.get(caster.playerId) || 0; + if (currentCount <= 1) { + cellRef.refCounts.delete(caster.playerId); + } else { + cellRef.refCounts.set(caster.playerId, currentCount - 1); + } + + this.dirtyCells.add(cellKey); + } + + caster.visibleCells.clear(); + } + + /** + * Check if a cell is currently visible to a player + * O(1) lookup thanks to reference counting + */ + public isCellVisible(cellX: number, cellY: number, playerId: string): boolean { + const cellKey = cellY * this.config.gridWidth + cellX; + const cellRef = this.cellRefCounts.get(cellKey); + if (!cellRef) return false; + + return (cellRef.refCounts.get(playerId) || 0) > 0; + } + + /** + * Check if a cell was ever explored by a player + */ + public isCellExplored(cellX: number, cellY: number, playerId: string): boolean { + const cellKey = cellY * this.config.gridWidth + cellX; + const cellRef = this.cellRefCounts.get(cellKey); + if (!cellRef) return false; + + return cellRef.lastVisibleTick.has(playerId); + } + + /** + * Get visibility state for a cell + */ + public getCellVisionState( + cellX: number, + cellY: number, + playerId: string + ): 'unexplored' | 'explored' | 'visible' { + if (this.isCellVisible(cellX, cellY, playerId)) return 'visible'; + if (this.isCellExplored(cellX, cellY, playerId)) return 'explored'; + return 'unexplored'; + } + + /** + * Get all dirty cells since last clear + */ + public getDirtyCells(): Set { + return this.dirtyCells; + } + + /** + * Get players with dirty vision state + */ + public getDirtyPlayers(): Set { + return this.dirtyPlayers; + } + + /** + * Clear dirty state after GPU update + */ + public clearDirtyState(): void { + this.dirtyCells.clear(); + this.dirtyPlayers.clear(); + } + + /** + * Set current tick for explored state tracking + */ + public setCurrentTick(tick: number): void { + this.currentTick = tick; + } + + /** + * 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): { + cells: Array<{ x: number; y: number; visible: number; explored: number }>; + isDirty: boolean; + } { + const isDirty = this.dirtyPlayers.has(playerId); + + if (incremental && !isDirty) { + return { cells: [], isDirty: false }; + } + + const cells: Array<{ x: number; y: number; visible: number; explored: number }> = []; + + if (incremental) { + // Only return dirty cells + for (const cellKey of this.dirtyCells) { + const x = cellKey % this.config.gridWidth; + const y = Math.floor(cellKey / this.config.gridWidth); + const state = this.getCellVisionState(x, y, playerId); + + cells.push({ + x, + y, + visible: state === 'visible' ? 1 : 0, + explored: state !== 'unexplored' ? 1 : 0, + }); + } + } else { + // Return full grid + for (let y = 0; y < this.config.gridHeight; y++) { + for (let x = 0; x < this.config.gridWidth; x++) { + const state = this.getCellVisionState(x, y, playerId); + cells.push({ + x, + y, + visible: state === 'visible' ? 1 : 0, + explored: state !== 'unexplored' ? 1 : 0, + }); + } + } + } + + return { cells, isDirty }; + } + + /** + * Get full visibility mask as Float32Array for GPU texture upload + * Format: 0 = unexplored, 0.5 = explored, 1.0 = visible + */ + public getVisibilityMask(playerId: string): Float32Array { + const mask = new Float32Array(this.config.gridWidth * this.config.gridHeight); + + for (let y = 0; y < this.config.gridHeight; y++) { + for (let x = 0; x < this.config.gridWidth; x++) { + const idx = y * this.config.gridWidth + x; + const state = this.getCellVisionState(x, y, playerId); + + mask[idx] = state === 'visible' ? 1.0 : state === 'explored' ? 0.5 : 0.0; + } + } + + return mask; + } + + /** + * Get statistics for debugging + */ + public getStats(): { + totalCasters: number; + totalTrackedCells: number; + dirtyCells: number; + dirtyPlayers: number; + } { + return { + totalCasters: this.casters.size, + totalTrackedCells: this.cellRefCounts.size, + dirtyCells: this.dirtyCells.size, + dirtyPlayers: this.dirtyPlayers.size, + }; + } + + /** + * Reinitialize with new config + */ + public reinitialize(config: VisionOptimizerConfig): void { + this.config = config; + this.casters.clear(); + this.cellRefCounts.clear(); + this.dirtyCells.clear(); + this.dirtyPlayers.clear(); + this.sightRangeCache.clear(); + this.precomputeSightRanges(); + } + + /** + * Dispose resources + */ + public dispose(): void { + this.casters.clear(); + this.cellRefCounts.clear(); + this.dirtyCells.clear(); + this.dirtyPlayers.clear(); + this.sightRangeCache.clear(); + } +} diff --git a/src/engine/systems/vision/index.ts b/src/engine/systems/vision/index.ts new file mode 100644 index 00000000..1749ec75 --- /dev/null +++ b/src/engine/systems/vision/index.ts @@ -0,0 +1,12 @@ +/** + * Vision System Module Index + * + * Industry-standard fog of war implementation: + * - VisionOptimizer: Reference counting and cell boundary tracking + * - LineOfSight: Height-based LOS blocking + * - SDFVisionRenderer: Signed distance field for smooth edges + */ + +export { VisionOptimizer, type VisionCasterState, type CellReferenceCount, type VisionOptimizerConfig } from './VisionOptimizer'; +export { LineOfSight, type LOSConfig, type HeightProvider } from './LineOfSight'; +export { SDFVisionRenderer, type SDFVisionConfig } from './SDFVisionRenderer'; diff --git a/src/rendering/Terrain.ts b/src/rendering/Terrain.ts index bd47c278..12312d64 100644 --- a/src/rendering/Terrain.ts +++ b/src/rendering/Terrain.ts @@ -1111,6 +1111,25 @@ export class Terrain { return this.mapData.height * this.cellSize; } + /** + * Get the raw heightmap data for vision LOS calculations + * Returns a copy of the heightmap array (gridWidth x gridHeight) + */ + public getHeightMapData(): Float32Array { + return this.heightMap.slice(); + } + + /** + * Get heightmap dimensions + */ + public getHeightMapDimensions(): { width: number; height: number; cellSize: number } { + return { + width: this.gridWidth, + height: this.gridHeight, + cellSize: this.cellSize, + }; + } + /** * Smooth the heightmap using Gaussian-like averaging * Preserves large features while smoothing rough transitions diff --git a/src/rendering/compute/VisionCompute.ts b/src/rendering/compute/VisionCompute.ts index 5ef531a4..2a604430 100644 --- a/src/rendering/compute/VisionCompute.ts +++ b/src/rendering/compute/VisionCompute.ts @@ -73,11 +73,15 @@ export interface VisionCaster { y: number; sightRange: number; playerId: number; + // Optional height for LOS calculations + height?: number; } export interface VisionComputeConfig { mapWidth: number; mapHeight: number; + // LOS blocking threshold (height difference to block vision) + losBlockingThreshold?: number; cellSize: number; } @@ -115,6 +119,12 @@ export class VisionCompute { // Compute shader nodes per player private computeNodes: Map = new Map(); + // Height texture for LOS blocking (Phase 2) + private heightTexture: THREE.DataTexture | null = null; + private heightData: Float32Array | null = null; + private useLOSBlocking = true; + private losBlockingThreshold = 1.0; + // Uniforms private uGridWidth = uniform(0); private uGridHeight = uniform(0); @@ -123,6 +133,8 @@ export class VisionCompute { private uTargetPlayerId = uniform(0); private uTemporalBlend = uniform(TEMPORAL_BLEND_SPEED); private uDeltaTime = uniform(0.016); // ~60fps default + private uLOSThreshold = uniform(1.0); // Height difference to block LOS + private uUseLOS = uniform(1.0); // 1.0 = enabled, 0.0 = disabled // State tracking private gpuComputeAvailable = false; @@ -375,6 +387,76 @@ export class VisionCompute { this.uTemporalBlend.value = clamp(speed, 0.01, 1.0); } + // ============================================ + // PHASE 2: HEIGHT-BASED LOS BLOCKING + // ============================================ + + /** + * Set height data for LOS blocking + * Heights should be a Float32Array of size gridWidth * gridHeight + * with terrain heights at each cell + */ + public setHeightData(heights: Float32Array): void { + if (heights.length !== this.gridWidth * this.gridHeight) { + debugShaders.warn('[VisionCompute] Height data size mismatch'); + return; + } + + this.heightData = heights; + + // Create or update height texture + if (!this.heightTexture) { + this.heightTexture = new THREE.DataTexture( + heights, + this.gridWidth, + this.gridHeight, + THREE.RedFormat, + THREE.FloatType + ); + this.heightTexture.minFilter = THREE.LinearFilter; + this.heightTexture.magFilter = THREE.LinearFilter; + this.heightTexture.wrapS = THREE.ClampToEdgeWrapping; + this.heightTexture.wrapT = THREE.ClampToEdgeWrapping; + } else { + // Update existing texture data + this.heightTexture.image.data = heights; + } + this.heightTexture.needsUpdate = true; + + debugShaders.log('[VisionCompute] Height data set for LOS blocking'); + } + + /** + * Enable/disable LOS blocking + */ + public setLOSBlockingEnabled(enabled: boolean): void { + this.useLOSBlocking = enabled; + this.uUseLOS.value = enabled ? 1.0 : 0.0; + debugShaders.log(`[VisionCompute] LOS blocking ${enabled ? 'enabled' : 'disabled'}`); + } + + /** + * Set LOS blocking threshold (height difference required to block vision) + */ + public setLOSBlockingThreshold(threshold: number): void { + this.losBlockingThreshold = threshold; + this.uLOSThreshold.value = threshold; + } + + /** + * Get height texture for shader access + */ + public getHeightTexture(): THREE.DataTexture | null { + return this.heightTexture; + } + + /** + * Check if LOS blocking is enabled and has height data + */ + public isLOSBlockingActive(): boolean { + return this.useLOSBlocking && this.heightData !== null; + } + /** * Update vision with new caster data */ diff --git a/tests/engine/systems/vision/LineOfSight.test.ts b/tests/engine/systems/vision/LineOfSight.test.ts new file mode 100644 index 00000000..b2403f1d --- /dev/null +++ b/tests/engine/systems/vision/LineOfSight.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { LineOfSight } from '@/engine/systems/vision/LineOfSight'; + +describe('LineOfSight', () => { + let los: LineOfSight; + + const defaultConfig = { + gridWidth: 64, + gridHeight: 64, + cellSize: 2, + mapWidth: 128, + mapHeight: 128, + losBlockingThreshold: 1.0, + }; + + // Simple flat terrain height provider + const flatHeightProvider = () => 0; + + // Terrain with a hill in the center + const hillHeightProvider = (x: number, y: number) => { + const centerX = 64; + const centerY = 64; + const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); + if (dist < 16) { + return 5; // 5 unit high hill + } + return 0; + }; + + beforeEach(() => { + los = new LineOfSight(defaultConfig); + }); + + describe('without height provider', () => { + it('should allow LOS to all cells in range', () => { + const visible = los.getVisibleCells(32, 32, 10); + + // Should have cells visible (no blocking) + expect(visible.size).toBeGreaterThan(0); + }); + }); + + describe('with flat terrain', () => { + beforeEach(() => { + los.setHeightProvider(flatHeightProvider); + }); + + it('should allow LOS to cells in range', () => { + const visible = los.getVisibleCells(32, 32, 10); + + // Should have all cells in circular range visible + expect(visible.size).toBeGreaterThan(0); + }); + + it('should respect sight range', () => { + const visible = los.getVisibleCells(64, 64, 8); + + // Convert to cell coordinates + const centerCellX = Math.floor(64 / defaultConfig.cellSize); + const centerCellY = Math.floor(64 / defaultConfig.cellSize); + + // All visible cells should be within range + for (const cellKey of visible) { + const cellX = cellKey % defaultConfig.gridWidth; + const cellY = Math.floor(cellKey / defaultConfig.gridWidth); + const dx = cellX - centerCellX; + const dy = cellY - centerCellY; + const distSq = dx * dx + dy * dy; + const maxDistSq = (8 / defaultConfig.cellSize + 1) ** 2; // Account for rounding + expect(distSq).toBeLessThanOrEqual(maxDistSq); + } + }); + }); + + describe('with terrain blocking', () => { + beforeEach(() => { + los.setHeightProvider(hillHeightProvider); + }); + + it('should block LOS through high terrain', () => { + // Caster at (32, 32), target at (96, 96), hill in between at (64, 64) + const hasLOS = los.hasLineOfSight( + 32, 32, // caster position + 0, // caster height (flat ground) + 48, 48, // target cell (on other side of hill) + 20 // sight range + ); + + // LOS should be blocked by the hill + expect(hasLOS).toBe(false); + }); + + it('should allow LOS to cells not blocked by terrain', () => { + // Caster at (32, 32), target at (40, 32), no hill in between + const hasLOS = los.hasLineOfSight( + 32, 32, // caster position + 0, // caster height + 20, 16, // target cell (same side as caster) + 10 // sight range + ); + + expect(hasLOS).toBe(true); + }); + + it('should allow LOS from high ground to low ground', () => { + // Caster on the hill looking down + const hasLOS = los.hasLineOfSight( + 64, 64, // caster on hill + 5, // caster height (on top of hill) + 16, 16, // target on flat ground + 30 // sight range + ); + + expect(hasLOS).toBe(true); + }); + }); + + describe('shadowcasting', () => { + beforeEach(() => { + los.setHeightProvider(flatHeightProvider); + }); + + it('should return visible cells using shadowcast algorithm', () => { + const visible = los.getVisibleCellsShadowcast(64, 64, 10); + + // Should have cells visible + expect(visible.size).toBeGreaterThan(0); + + // Center cell should be visible + const centerCellX = Math.floor(64 / defaultConfig.cellSize); + const centerCellY = Math.floor(64 / defaultConfig.cellSize); + const centerKey = centerCellY * defaultConfig.gridWidth + centerCellX; + expect(visible.has(centerKey)).toBe(true); + }); + }); + + describe('cache', () => { + beforeEach(() => { + los.setHeightProvider(flatHeightProvider); + }); + + it('should cache LOS results', () => { + // First call + los.hasLineOfSight(32, 32, 0, 16, 16, 10); + + // Second call should use cache (same result) + const result = los.hasLineOfSight(32, 32, 0, 16, 16, 10); + + expect(result).toBe(true); + }); + + it('should invalidate cache when provider changes', () => { + los.hasLineOfSight(32, 32, 0, 16, 16, 10); + + // Changing height provider should invalidate cache + los.setHeightProvider(hillHeightProvider); + + // This is a new computation, not from cache + const result = los.hasLineOfSight(32, 32, 0, 16, 16, 10); + + // Result should be computed fresh + expect(typeof result).toBe('boolean'); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + los.setHeightProvider(flatHeightProvider); + }); + + it('should handle cells at grid boundary', () => { + const visible = los.getVisibleCells(4, 4, 10); + + // All visible cells should be within bounds + for (const cellKey of visible) { + const cellX = cellKey % defaultConfig.gridWidth; + const cellY = Math.floor(cellKey / defaultConfig.gridWidth); + expect(cellX).toBeGreaterThanOrEqual(0); + expect(cellX).toBeLessThan(defaultConfig.gridWidth); + expect(cellY).toBeGreaterThanOrEqual(0); + expect(cellY).toBeLessThan(defaultConfig.gridHeight); + } + }); + + it('should handle very small sight range', () => { + const visible = los.getVisibleCells(64, 64, 1); + + // Should still have at least the center cell + expect(visible.size).toBeGreaterThanOrEqual(1); + }); + + it('should handle large sight range', () => { + const visible = los.getVisibleCells(64, 64, 50); + + // Should have many cells visible + expect(visible.size).toBeGreaterThan(100); + }); + }); + + describe('reinitialize', () => { + it('should accept new configuration', () => { + const newConfig = { + ...defaultConfig, + gridWidth: 32, + gridHeight: 32, + losBlockingThreshold: 2.0, + }; + + los.reinitialize(newConfig); + + expect(los.getConfig().gridWidth).toBe(32); + expect(los.getConfig().losBlockingThreshold).toBe(2.0); + }); + }); +}); diff --git a/tests/engine/systems/vision/SDFVisionRenderer.test.ts b/tests/engine/systems/vision/SDFVisionRenderer.test.ts new file mode 100644 index 00000000..72dd6b33 --- /dev/null +++ b/tests/engine/systems/vision/SDFVisionRenderer.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SDFVisionRenderer } from '@/engine/systems/vision/SDFVisionRenderer'; + +describe('SDFVisionRenderer', () => { + let sdfRenderer: SDFVisionRenderer; + + const defaultConfig = { + gridWidth: 16, + gridHeight: 16, + cellSize: 2, + maxDistance: 8, + edgeSoftness: 0.3, + }; + + beforeEach(() => { + sdfRenderer = new SDFVisionRenderer(defaultConfig); + }); + + describe('SDF generation', () => { + it('should create SDF texture for player', () => { + const texture = sdfRenderer.getSDFTexture('player1'); + + expect(texture).toBeDefined(); + expect(texture.image.width).toBe(defaultConfig.gridWidth); + expect(texture.image.height).toBe(defaultConfig.gridHeight); + }); + + it('should update SDF from visibility mask', () => { + const mask = new Float32Array(defaultConfig.gridWidth * defaultConfig.gridHeight); + + // Create a visibility pattern (center visible) + for (let y = 6; y < 10; y++) { + for (let x = 6; x < 10; x++) { + mask[y * defaultConfig.gridWidth + x] = 1.0; + } + } + + sdfRenderer.updateSDF('player1', mask); + + const texture = sdfRenderer.getSDFTexture('player1'); + expect(texture.needsUpdate).toBe(true); + }); + }); + + describe('edge factor calculation', () => { + it('should return 0 for solid areas away from edge', () => { + const mask = new Float32Array(defaultConfig.gridWidth * defaultConfig.gridHeight); + mask.fill(1.0); // All visible + + sdfRenderer.updateSDF('player1', mask); + + // Center cell should have low edge factor (away from edges) + const edgeFactor = sdfRenderer.getEdgeFactor(8, 8, 'player1'); + expect(edgeFactor).toBeLessThan(0.5); + }); + + it('should return 0 for unexplored player', () => { + const edgeFactor = sdfRenderer.getEdgeFactor(8, 8, 'unknown_player'); + expect(edgeFactor).toBe(0); + }); + }); + + describe('pattern-based AA', () => { + it('should generate 16 AA patterns', () => { + const patterns = sdfRenderer.generateAAPatterns(); + + expect(patterns.size).toBe(16); + + // Each pattern should be 4x4 = 16 values + for (const [, pattern] of patterns) { + expect(pattern.length).toBe(16); + } + }); + + it('should generate correct isolated visible cell pattern', () => { + const patterns = sdfRenderer.generateAAPatterns(); + const isolatedPattern = patterns.get(0); // 0b0000 = no neighbors + + expect(isolatedPattern).toBeDefined(); + + // Corners should be dimmer (128) + expect(isolatedPattern![0]).toBe(128); + expect(isolatedPattern![3]).toBe(128); + expect(isolatedPattern![12]).toBe(128); + expect(isolatedPattern![15]).toBe(128); + + // Center should be bright (255) + expect(isolatedPattern![5]).toBe(255); + expect(isolatedPattern![6]).toBe(255); + expect(isolatedPattern![9]).toBe(255); + expect(isolatedPattern![10]).toBe(255); + }); + + it('should generate correct all-neighbors-visible pattern', () => { + const patterns = sdfRenderer.generateAAPatterns(); + const allNeighborsPattern = patterns.get(15); // 0b1111 = all neighbors + + expect(allNeighborsPattern).toBeDefined(); + + // All values should be 255 (fully visible) + for (const value of allNeighborsPattern!) { + expect(value).toBe(255); + } + }); + }); + + describe('upscaling', () => { + it('should upscale visibility mask with patterns', () => { + const mask = new Float32Array(defaultConfig.gridWidth * defaultConfig.gridHeight); + + // Create simple pattern + for (let y = 0; y < 8; y++) { + for (let x = 0; x < 8; x++) { + mask[y * defaultConfig.gridWidth + x] = 1.0; + } + } + + const scale = 4; + const upscaled = sdfRenderer.upscaleWithPatterns(mask, scale); + + const expectedWidth = defaultConfig.gridWidth * scale; + const expectedHeight = defaultConfig.gridHeight * scale; + + expect(upscaled.length).toBe(expectedWidth * expectedHeight); + }); + + it('should create zero values for fog areas', () => { + const mask = new Float32Array(defaultConfig.gridWidth * defaultConfig.gridHeight); + // All zeros (fog) + + const upscaled = sdfRenderer.upscaleWithPatterns(mask, 4); + + // All values should be 0 + for (const value of upscaled) { + expect(value).toBe(0); + } + }); + + it('should create upscaled texture', () => { + const mask = new Float32Array(defaultConfig.gridWidth * defaultConfig.gridHeight); + mask.fill(1.0); + + const texture = sdfRenderer.createUpscaledTexture('player1', mask, 4); + + expect(texture.image.width).toBe(defaultConfig.gridWidth * 4); + expect(texture.image.height).toBe(defaultConfig.gridHeight * 4); + }); + }); + + describe('reinitialize', () => { + it('should clear textures on reinitialize', () => { + sdfRenderer.getSDFTexture('player1'); + sdfRenderer.getSDFTexture('player2'); + + sdfRenderer.reinitialize(defaultConfig); + + // Old textures should be disposed, new ones created on demand + // This tests that reinitialize doesn't throw + const newTexture = sdfRenderer.getSDFTexture('player1'); + expect(newTexture).toBeDefined(); + }); + + it('should update grid dimensions on reinitialize', () => { + const newConfig = { + ...defaultConfig, + gridWidth: 32, + gridHeight: 32, + }; + + sdfRenderer.reinitialize(newConfig); + + const texture = sdfRenderer.getSDFTexture('player1'); + expect(texture.image.width).toBe(32); + expect(texture.image.height).toBe(32); + }); + }); + + describe('dispose', () => { + it('should clean up resources', () => { + sdfRenderer.getSDFTexture('player1'); + sdfRenderer.getSDFTexture('player2'); + + // Should not throw + sdfRenderer.dispose(); + }); + }); +}); diff --git a/tests/engine/systems/vision/VisionOptimizer.test.ts b/tests/engine/systems/vision/VisionOptimizer.test.ts new file mode 100644 index 00000000..b1dca30d --- /dev/null +++ b/tests/engine/systems/vision/VisionOptimizer.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { VisionOptimizer } from '@/engine/systems/vision/VisionOptimizer'; + +describe('VisionOptimizer', () => { + let optimizer: VisionOptimizer; + + const defaultConfig = { + gridWidth: 64, + gridHeight: 64, + cellSize: 2, + mapWidth: 128, + mapHeight: 128, + }; + + beforeEach(() => { + optimizer = new VisionOptimizer(defaultConfig); + }); + + describe('reference counting', () => { + it('should track caster visibility correctly', () => { + // Add a caster at world position (32, 32) with sight range 8 + optimizer.updateCaster(1, 'player1', 32, 32, 8); + + // Cell at (16, 16) should be visible (center of caster in grid coords) + expect(optimizer.isCellVisible(16, 16, 'player1')).toBe(true); + }); + + it('should increment reference count when multiple casters see same cell', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + optimizer.updateCaster(2, 'player1', 36, 32, 8); + + // Cell at (16, 16) should be visible to player1 + expect(optimizer.isCellVisible(16, 16, 'player1')).toBe(true); + }); + + it('should decrement reference count when caster is removed', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + optimizer.removeCaster(1); + + // Cell should no longer be visible but should be explored + expect(optimizer.isCellVisible(16, 16, 'player1')).toBe(false); + expect(optimizer.isCellExplored(16, 16, 'player1')).toBe(true); + }); + + it('should maintain visibility when one of multiple casters is removed', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + optimizer.updateCaster(2, 'player1', 32, 32, 8); // Same position + optimizer.removeCaster(1); + + // Cell should still be visible due to second caster + expect(optimizer.isCellVisible(16, 16, 'player1')).toBe(true); + }); + }); + + describe('cell boundary tracking', () => { + it('should return false when caster moves within same cell', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + + // Move within same cell (cellSize = 2, so 32-33 is same cell) + const crossedBoundary = optimizer.updateCaster(1, 'player1', 33, 32, 8); + + expect(crossedBoundary).toBe(false); + }); + + it('should return true when caster crosses cell boundary', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + + // Move to different cell (cellSize = 2, so 34 is new cell) + const crossedBoundary = optimizer.updateCaster(1, 'player1', 34, 32, 8); + + expect(crossedBoundary).toBe(true); + }); + + it('should mark dirty cells when caster moves', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + optimizer.clearDirtyState(); + + optimizer.updateCaster(1, 'player1', 40, 32, 8); + + expect(optimizer.getDirtyCells().size).toBeGreaterThan(0); + }); + }); + + describe('multi-player support', () => { + it('should track visibility separately per player', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + optimizer.updateCaster(2, 'player2', 96, 96, 8); + + // Player1 should see their area but not player2's + expect(optimizer.isCellVisible(16, 16, 'player1')).toBe(true); + expect(optimizer.isCellVisible(48, 48, 'player1')).toBe(false); + + // Player2 should see their area but not player1's + expect(optimizer.isCellVisible(48, 48, 'player2')).toBe(true); + expect(optimizer.isCellVisible(16, 16, 'player2')).toBe(false); + }); + + it('should track dirty players independently', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + optimizer.clearDirtyState(); + + optimizer.updateCaster(2, 'player2', 96, 96, 8); + + expect(optimizer.getDirtyPlayers().has('player2')).toBe(true); + expect(optimizer.getDirtyPlayers().has('player1')).toBe(false); + }); + }); + + describe('vision state', () => { + it('should return unexplored for unvisited cells', () => { + expect(optimizer.getCellVisionState(32, 32, 'player1')).toBe('unexplored'); + }); + + it('should return visible for currently visible cells', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + + expect(optimizer.getCellVisionState(16, 16, 'player1')).toBe('visible'); + }); + + it('should return explored for previously visible cells', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + optimizer.removeCaster(1); + + expect(optimizer.getCellVisionState(16, 16, 'player1')).toBe('explored'); + }); + }); + + describe('visibility mask generation', () => { + it('should generate correct mask values', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 4); + + const mask = optimizer.getVisibilityMask('player1'); + + // Check that mask has correct size + expect(mask.length).toBe(defaultConfig.gridWidth * defaultConfig.gridHeight); + + // Check that some cells are visible (value = 1.0) + const centerIdx = 16 * defaultConfig.gridWidth + 16; + expect(mask[centerIdx]).toBe(1.0); + }); + }); + + describe('stats', () => { + it('should track caster count', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + optimizer.updateCaster(2, 'player1', 64, 64, 8); + + const stats = optimizer.getStats(); + expect(stats.totalCasters).toBe(2); + }); + + it('should track dirty state', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + + const stats = optimizer.getStats(); + expect(stats.dirtyCells).toBeGreaterThan(0); + expect(stats.dirtyPlayers).toBe(1); + }); + }); + + describe('reinitialize', () => { + it('should clear all state on reinitialize', () => { + optimizer.updateCaster(1, 'player1', 32, 32, 8); + optimizer.reinitialize(defaultConfig); + + expect(optimizer.getStats().totalCasters).toBe(0); + expect(optimizer.isCellVisible(16, 16, 'player1')).toBe(false); + }); + }); +});