From 2639d27b1a7fcb94726f851d757ba8942df52b98 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 01:58:52 +0000 Subject: [PATCH 1/3] refactor: remove dead fog of war code, integrate only LineOfSight Cleanup from previous commit - removed unused systems that were initialized but never actually integrated into the computation flow: Removed (dead code): - VisionOptimizer: Reference counting system that duplicated GPU efficiency - SDFVisionRenderer: Smooth edge system not connected to fog shader - VisionCompute LOS methods: GPU height-based LOS never wired into shader - Terrain getHeightMapData/Dimensions: Only needed for removed GPU LOS Kept and properly integrated: - LineOfSight: Now actually used in revealArea() for height-based blocking - Uses Terrain.getHeightAt() as height provider - Falls back to simple circular reveal when no height provider set - Implements industry-standard shadowcasting algorithm The GPU path still uses simple circular visibility (efficient for most cases). The CPU/main-thread path now properly uses LineOfSight when height data is available, enabling terrain-based vision blocking (high ground advantage). https://claude.ai/code/session_01XSmJ7hgNws3pkW6EHC2W4j --- src/engine/systems/VisionSystem.ts | 214 +++----- .../systems/vision/SDFVisionRenderer.ts | 456 ------------------ src/engine/systems/vision/VisionOptimizer.ts | 438 ----------------- src/engine/systems/vision/index.ts | 8 +- src/rendering/Terrain.ts | 19 - src/rendering/compute/VisionCompute.ts | 82 ---- .../systems/vision/SDFVisionRenderer.test.ts | 187 ------- .../systems/vision/VisionOptimizer.test.ts | 170 ------- 8 files changed, 59 insertions(+), 1515 deletions(-) delete mode 100644 src/engine/systems/vision/SDFVisionRenderer.ts delete mode 100644 src/engine/systems/vision/VisionOptimizer.ts delete mode 100644 tests/engine/systems/vision/SDFVisionRenderer.test.ts delete mode 100644 tests/engine/systems/vision/VisionOptimizer.test.ts diff --git a/src/engine/systems/VisionSystem.ts b/src/engine/systems/VisionSystem.ts index a624bec5..65d67960 100644 --- a/src/engine/systems/VisionSystem.ts +++ b/src/engine/systems/VisionSystem.ts @@ -12,10 +12,8 @@ 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'; +// Height-based line-of-sight blocking (industry standard from SC2, AoE, etc.) import { LineOfSight, type HeightProvider } from './vision/LineOfSight'; -import { SDFVisionRenderer } from './vision/SDFVisionRenderer'; // Vision states for fog of war export type VisionState = 'unexplored' | 'explored' | 'visible'; @@ -96,26 +94,11 @@ 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 + // Height-based line-of-sight blocking (industry standard from SC2, AoE, etc.) private lineOfSight: LineOfSight | null = null; - private useLOSBlocking: boolean = true; // Enable by default + private useLOSBlocking: boolean = true; 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; @@ -123,52 +106,25 @@ export class VisionSystem extends System { this.cellSize = cellSize; this.initializeWorker(); this.setupEventListeners(); - this.initializeOptimizations(); + this.initializeLOS(); } /** - * Initialize industry-standard fog of war optimizations + * Initialize line-of-sight system for height-based vision blocking */ - private initializeOptimizations(): void { + private initializeLOS(): 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'); - } + 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] LOS blocking initialized'); } /** @@ -393,16 +349,6 @@ export class VisionSystem extends System { 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, @@ -417,18 +363,6 @@ export class VisionSystem extends System { 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 { @@ -515,12 +449,16 @@ export class VisionSystem extends System { } // ============================================ - // PHASE 1-3 PUBLIC API + // LINE-OF-SIGHT PUBLIC API // ============================================ /** - * Set the height provider for line-of-sight calculations + * Set the height provider for line-of-sight calculations. * Call this after terrain is initialized (e.g., from Terrain.getHeightAt) + * + * When set, vision will be blocked by terrain height differences: + * - Units on high ground can see down + * - Units on low ground cannot see past high terrain */ public setHeightProvider(provider: HeightProvider): void { this.heightProvider = provider; @@ -531,20 +469,7 @@ export class VisionSystem extends System { } /** - * 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) + * Enable/disable line-of-sight blocking */ public setLOSBlockingEnabled(enabled: boolean): void { this.useLOSBlocking = enabled; @@ -552,18 +477,10 @@ export class VisionSystem extends System { } /** - * 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) + * Check if LOS blocking is enabled */ - public getVisionOptimizer(): VisionOptimizer | null { - return this.visionOptimizer; + public isLOSBlockingEnabled(): boolean { + return this.useLOSBlocking && this.lineOfSight !== null && this.heightProvider !== null; } /** @@ -573,28 +490,6 @@ export class VisionSystem extends System { 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) */ @@ -626,20 +521,10 @@ export class VisionSystem extends System { } 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 { @@ -1119,30 +1004,45 @@ export class VisionSystem extends System { this.revealArea(selectable.playerId, transform.x, transform.y, building.sightRange); } + /** + * Reveal vision area from a position with given range. + * Uses height-based LOS blocking if a height provider is set. + */ private revealArea(playerId: string, worldX: number, worldY: number, range: number): void { this.ensurePlayerRegistered(playerId); const visionGrid = this.visionMap.playerVision.get(playerId)!; const currentVisible = this.visionMap.currentlyVisible.get(playerId)!; const gridWidth = this.visionMap.width; - const gridHeight = this.visionMap.height; - const cellX = Math.floor(worldX / this.cellSize); - const cellY = Math.floor(worldY / this.cellSize); - const cellRange = Math.ceil(range / this.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) { - const x = cellX + dx; - const y = cellY + dy; - - if (x >= 0 && x < gridWidth && y >= 0 && y < gridHeight) { - visionGrid[y][x] = 'visible'; - currentVisible.add(y * gridWidth + x); + // Use LOS system if available and enabled + if (this.useLOSBlocking && this.lineOfSight && this.heightProvider) { + const visibleCells = this.lineOfSight.getVisibleCells(worldX, worldY, range); + for (const cellKey of visibleCells) { + const x = cellKey % gridWidth; + const y = Math.floor(cellKey / gridWidth); + visionGrid[y][x] = 'visible'; + currentVisible.add(cellKey); + } + } else { + // Fallback: simple circular reveal without LOS blocking + const gridHeight = this.visionMap.height; + const cellX = Math.floor(worldX / this.cellSize); + const cellY = Math.floor(worldY / this.cellSize); + const cellRange = Math.ceil(range / this.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) { + const x = cellX + dx; + const y = cellY + dy; + + if (x >= 0 && x < gridWidth && y >= 0 && y < gridHeight) { + visionGrid[y][x] = 'visible'; + currentVisible.add(y * gridWidth + x); + } } } } diff --git a/src/engine/systems/vision/SDFVisionRenderer.ts b/src/engine/systems/vision/SDFVisionRenderer.ts deleted file mode 100644 index 5649b5f2..00000000 --- a/src/engine/systems/vision/SDFVisionRenderer.ts +++ /dev/null @@ -1,456 +0,0 @@ -/** - * 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 deleted file mode 100644 index f21e5e5a..00000000 --- a/src/engine/systems/vision/VisionOptimizer.ts +++ /dev/null @@ -1,438 +0,0 @@ -/** - * 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 index 1749ec75..dd0be6cd 100644 --- a/src/engine/systems/vision/index.ts +++ b/src/engine/systems/vision/index.ts @@ -1,12 +1,8 @@ /** * 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 + * Height-based line-of-sight blocking for fog of war. + * Industry-standard technique from StarCraft 2, Age of Empires, etc. */ -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 12312d64..bd47c278 100644 --- a/src/rendering/Terrain.ts +++ b/src/rendering/Terrain.ts @@ -1111,25 +1111,6 @@ 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 2a604430..5ef531a4 100644 --- a/src/rendering/compute/VisionCompute.ts +++ b/src/rendering/compute/VisionCompute.ts @@ -73,15 +73,11 @@ 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; } @@ -119,12 +115,6 @@ 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); @@ -133,8 +123,6 @@ 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; @@ -387,76 +375,6 @@ 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/SDFVisionRenderer.test.ts b/tests/engine/systems/vision/SDFVisionRenderer.test.ts deleted file mode 100644 index 72dd6b33..00000000 --- a/tests/engine/systems/vision/SDFVisionRenderer.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -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 deleted file mode 100644 index b1dca30d..00000000 --- a/tests/engine/systems/vision/VisionOptimizer.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -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); - }); - }); -}); From 91e6224da74a0b3bb5d20a8a668ea4ec4ff081a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 01:59:42 +0000 Subject: [PATCH 2/3] Revert "refactor: remove dead fog of war code, integrate only LineOfSight" This reverts commit 2639d27b1a7fcb94726f851d757ba8942df52b98. --- src/engine/systems/VisionSystem.ts | 214 +++++--- .../systems/vision/SDFVisionRenderer.ts | 456 ++++++++++++++++++ src/engine/systems/vision/VisionOptimizer.ts | 438 +++++++++++++++++ src/engine/systems/vision/index.ts | 8 +- src/rendering/Terrain.ts | 19 + src/rendering/compute/VisionCompute.ts | 82 ++++ .../systems/vision/SDFVisionRenderer.test.ts | 187 +++++++ .../systems/vision/VisionOptimizer.test.ts | 170 +++++++ 8 files changed, 1515 insertions(+), 59 deletions(-) create mode 100644 src/engine/systems/vision/SDFVisionRenderer.ts create mode 100644 src/engine/systems/vision/VisionOptimizer.ts create mode 100644 tests/engine/systems/vision/SDFVisionRenderer.test.ts create mode 100644 tests/engine/systems/vision/VisionOptimizer.test.ts diff --git a/src/engine/systems/VisionSystem.ts b/src/engine/systems/VisionSystem.ts index 65d67960..a624bec5 100644 --- a/src/engine/systems/VisionSystem.ts +++ b/src/engine/systems/VisionSystem.ts @@ -12,8 +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'; -// Height-based line-of-sight blocking (industry standard from SC2, AoE, etc.) +// 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'; @@ -94,11 +96,26 @@ export class VisionSystem extends System { // Track active computation path for debugging private activeComputePath: 'gpu' | 'worker' | 'main-thread' | null = null; - // Height-based line-of-sight blocking (industry standard from SC2, AoE, etc.) + // ============================================ + // 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; + 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; @@ -106,25 +123,52 @@ export class VisionSystem extends System { this.cellSize = cellSize; this.initializeWorker(); this.setupEventListeners(); - this.initializeLOS(); + this.initializeOptimizations(); } /** - * Initialize line-of-sight system for height-based vision blocking + * Initialize industry-standard fog of war optimizations */ - private initializeLOS(): void { + private initializeOptimizations(): void { const gridWidth = Math.ceil(this.mapWidth / this.cellSize); const gridHeight = Math.ceil(this.mapHeight / this.cellSize); - 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] LOS blocking initialized'); + // 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'); + } } /** @@ -349,6 +393,16 @@ export class VisionSystem extends System { 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, @@ -363,6 +417,18 @@ export class VisionSystem extends System { 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 { @@ -449,16 +515,12 @@ export class VisionSystem extends System { } // ============================================ - // LINE-OF-SIGHT PUBLIC API + // PHASE 1-3 PUBLIC API // ============================================ /** - * Set the height provider for line-of-sight calculations. + * Set the height provider for line-of-sight calculations * Call this after terrain is initialized (e.g., from Terrain.getHeightAt) - * - * When set, vision will be blocked by terrain height differences: - * - Units on high ground can see down - * - Units on low ground cannot see past high terrain */ public setHeightProvider(provider: HeightProvider): void { this.heightProvider = provider; @@ -469,7 +531,20 @@ export class VisionSystem extends System { } /** - * Enable/disable line-of-sight 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; @@ -477,10 +552,18 @@ export class VisionSystem extends System { } /** - * Check if LOS blocking is enabled + * 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 isLOSBlockingEnabled(): boolean { - return this.useLOSBlocking && this.lineOfSight !== null && this.heightProvider !== null; + public getVisionOptimizer(): VisionOptimizer | null { + return this.visionOptimizer; } /** @@ -490,6 +573,28 @@ export class VisionSystem extends System { 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) */ @@ -521,10 +626,20 @@ export class VisionSystem extends System { } 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 { @@ -1004,45 +1119,30 @@ export class VisionSystem extends System { this.revealArea(selectable.playerId, transform.x, transform.y, building.sightRange); } - /** - * Reveal vision area from a position with given range. - * Uses height-based LOS blocking if a height provider is set. - */ private revealArea(playerId: string, worldX: number, worldY: number, range: number): void { this.ensurePlayerRegistered(playerId); const visionGrid = this.visionMap.playerVision.get(playerId)!; const currentVisible = this.visionMap.currentlyVisible.get(playerId)!; const gridWidth = this.visionMap.width; + const gridHeight = this.visionMap.height; - // Use LOS system if available and enabled - if (this.useLOSBlocking && this.lineOfSight && this.heightProvider) { - const visibleCells = this.lineOfSight.getVisibleCells(worldX, worldY, range); - for (const cellKey of visibleCells) { - const x = cellKey % gridWidth; - const y = Math.floor(cellKey / gridWidth); - visionGrid[y][x] = 'visible'; - currentVisible.add(cellKey); - } - } else { - // Fallback: simple circular reveal without LOS blocking - const gridHeight = this.visionMap.height; - const cellX = Math.floor(worldX / this.cellSize); - const cellY = Math.floor(worldY / this.cellSize); - const cellRange = Math.ceil(range / this.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) { - const x = cellX + dx; - const y = cellY + dy; - - if (x >= 0 && x < gridWidth && y >= 0 && y < gridHeight) { - visionGrid[y][x] = 'visible'; - currentVisible.add(y * gridWidth + x); - } + const cellX = Math.floor(worldX / this.cellSize); + const cellY = Math.floor(worldY / this.cellSize); + const cellRange = Math.ceil(range / this.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) { + const x = cellX + dx; + const y = cellY + dy; + + if (x >= 0 && x < gridWidth && y >= 0 && y < gridHeight) { + visionGrid[y][x] = 'visible'; + currentVisible.add(y * gridWidth + x); } } } 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 index dd0be6cd..1749ec75 100644 --- a/src/engine/systems/vision/index.ts +++ b/src/engine/systems/vision/index.ts @@ -1,8 +1,12 @@ /** * Vision System Module Index * - * Height-based line-of-sight blocking for fog of war. - * Industry-standard technique from StarCraft 2, Age of Empires, etc. + * 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/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); + }); + }); +}); From 88836cacb646d00c73af95b2230b32b3b63505f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 02:09:25 +0000 Subject: [PATCH 3/3] feat: properly integrate industry-standard fog of war optimizations Phase 1: VisionOptimizer now uses LineOfSight for terrain-aware visibility - Added setLineOfSight method to VisionOptimizer - getCellsInRange uses LOS when height provider is available - VisionSystem connects LineOfSight to VisionOptimizer automatically Phase 2: Fixed LineOfSight high ground advantage - Units on high ground can now see down without their own terrain blocking them - Added casterGroundHeight check in traceRay to skip terrain at/below caster level Phase 3: Fixed SDFVisionRenderer algorithm - SDF now correctly computes distance to boundary (not just visibility) - Boundary cells are properly identified (visible cells adjacent to fog) - Fixed edge factor calculation for cells far from any boundary - Updated test to check actual SDF behavior rather than Three.js internal state https://claude.ai/code/session_01XSmJ7hgNws3pkW6EHC2W4j --- src/engine/systems/VisionSystem.ts | 270 ++++++++++++++++-- src/engine/systems/vision/LineOfSight.ts | 30 +- .../systems/vision/SDFVisionRenderer.ts | 185 +++++------- src/engine/systems/vision/VisionOptimizer.ts | 44 ++- .../systems/vision/SDFVisionRenderer.test.ts | 14 +- 5 files changed, 389 insertions(+), 154 deletions(-) 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); }); });