diff --git a/docs/architecture/rendering.md b/docs/architecture/rendering.md index 2d0f9f52..2122fa3d 100644 --- a/docs/architecture/rendering.md +++ b/docs/architecture/rendering.md @@ -7,6 +7,7 @@ VOIDSTRIKE uses Three.js with WebGPU renderer and TSL (Three.js Shading Language ## Current Implementation ### Renderer + - **WebGPU Renderer** with WebGL2 fallback - **TSL (Three.js Shading Language)** for shader authoring - Post-processing pipeline using `three/webgpu` PostProcessing class @@ -14,19 +15,19 @@ VOIDSTRIKE uses Three.js with WebGPU renderer and TSL (Three.js Shading Language ### Post-Processing Effects (Implemented) -| Effect | Status | Description | -|--------|--------|-------------| -| **GTAO** | ✅ Implemented | Ground Truth Ambient Occlusion for contact shadows | -| **Bloom** | ✅ Implemented | HDR glow effect with threshold/strength/radius controls | -| **FXAA** | ✅ Implemented | Fast Approximate Anti-Aliasing | -| **TRAA** | ✅ Implemented | Temporal Reprojection Anti-Aliasing with MRT velocity buffers | -| **SSR** | ✅ Implemented | Screen Space Reflections for metallic surfaces | -| **FSR 1.0 EASU** | ✅ Implemented | FidelityFX Super Resolution upscaling with Lanczos2 kernel | -| **RCAS Sharpening** | ✅ Implemented | Robust Contrast-Adaptive Sharpening | -| **Vignette** | ✅ Implemented | Cinematic edge darkening | -| **Color Grading** | ✅ Implemented | Exposure, saturation, contrast with ACES Filmic tone mapping | -| **Volumetric Fog** | ✅ Implemented | Raymarched atmospheric scattering with quality presets | -| **Fog of War** | ✅ Implemented | Classic RTS-style vision with soft edges, desaturation, animated clouds | +| Effect | Status | Description | +| ------------------- | -------------- | ----------------------------------------------------------------------- | +| **GTAO** | ✅ Implemented | Ground Truth Ambient Occlusion for contact shadows | +| **Bloom** | ✅ Implemented | HDR glow effect with threshold/strength/radius controls | +| **FXAA** | ✅ Implemented | Fast Approximate Anti-Aliasing | +| **TRAA** | ✅ Implemented | Temporal Reprojection Anti-Aliasing with MRT velocity buffers | +| **SSR** | ✅ Implemented | Screen Space Reflections for metallic surfaces | +| **FSR 1.0 EASU** | ✅ Implemented | FidelityFX Super Resolution upscaling with Lanczos2 kernel | +| **RCAS Sharpening** | ✅ Implemented | Robust Contrast-Adaptive Sharpening | +| **Vignette** | ✅ Implemented | Cinematic edge darkening | +| **Color Grading** | ✅ Implemented | Exposure, saturation, contrast with ACES Filmic tone mapping | +| **Volumetric Fog** | ✅ Implemented | Raymarched atmospheric scattering with quality presets | +| **Fog of War** | ✅ Implemented | Classic RTS-style vision with soft edges, desaturation, animated clouds | ### Post-Processing Architecture (Modular Design) @@ -60,6 +61,7 @@ src/rendering/tsl/ ``` **Design Principles:** + - `PostProcessing.ts` owns pipeline orchestration and public API - `EffectPasses.ts` contains pure functions for creating individual effects - `TemporalManager.ts` handles quarter-resolution temporal reprojection @@ -68,6 +70,7 @@ src/rendering/tsl/ ### Anti-Aliasing Details #### TRAA (Temporal Reprojection Anti-Aliasing) + - Uses **per-instance velocity** via MRT for proper motion vectors - Optional RCAS sharpening to counter temporal blur - TRAANode handles camera jitter internally (Halton sequence) @@ -76,28 +79,30 @@ src/rendering/tsl/ **Per-Instance Velocity (AAA Optimization):** Three.js's built-in VelocityNode doesn't work for InstancedMesh (only tracks per-object, not per-instance). Our solution: -| Renderer | Velocity | Cost | -|----------|----------|------| -| UnitRenderer | Full per-instance | ~5-10% overhead | -| BuildingRenderer | Zero (static) | None | -| ResourceRenderer | Zero (static) | None | +| Renderer | Velocity | Cost | +| ---------------- | ----------------- | --------------- | +| UnitRenderer | Full per-instance | ~5-10% overhead | +| BuildingRenderer | Zero (static) | None | +| ResourceRenderer | Zero (static) | None | **Key Insight:** Floating-point precision differences between code paths caused micro-jitter. By storing BOTH current and previous instance matrices as attributes and reading them identically, we eliminate precision issues. See: [GitHub Issue #31892](https://github.com/mrdoob/three.js/issues/31892) **Implementation:** + ```typescript // UnitRenderer: Full velocity tracking -setupInstancedVelocity(mesh); // Add 8 vec4 attributes (curr + prev matrices) -swapInstanceMatrices(mesh); // At frame START: prev = curr -commitInstanceMatrices(mesh); // After updates: curr = mesh.instanceMatrix +setupInstancedVelocity(mesh); // Add 8 vec4 attributes (curr + prev matrices) +swapInstanceMatrices(mesh); // At frame START: prev = curr +commitInstanceMatrices(mesh); // After updates: curr = mesh.instanceMatrix // BuildingRenderer/ResourceRenderer: No velocity (static objects) // Velocity node returns zero for meshes without attributes ``` **Velocity Node Architecture:** + ``` createInstancedVelocityNode() ├── Read currInstanceMatrix0-3 (current frame transforms) @@ -108,6 +113,7 @@ createInstancedVelocityNode() ``` #### SSR (Screen Space Reflections) + - Real-time reflections on metallic surfaces - Uses MRT to output view-space normals - Configurable parameters: @@ -124,6 +130,7 @@ renderPipeline.applyConfig({ ssrEnabled: true }); **Note:** SSR uses MRT for normals which works correctly with InstancedMesh (unlike velocity). #### FSR 1.0 EASU Upscaling + - Proper FSR 1.0 (FidelityFX Super Resolution) implementation - 12-tap edge-adaptive filter with Lanczos2 kernel - Directional filtering: filters ALONG edges, not across them @@ -131,6 +138,7 @@ renderPipeline.applyConfig({ ssrEnabled: true }); - Configurable render scale (50%-100%) **Algorithm:** + 1. Sample 12 texels in cross pattern around target pixel 2. Compute luminance gradients for edge direction detection 3. Apply Lanczos2-weighted directional filtering based on edge orientation @@ -142,11 +150,13 @@ The internal render target uses `LinearSRGBColorSpace` because the post-processi **Critical: Dual-Pipeline Color Space Fix:** When rendering the internal pipeline to `internalRenderTarget`, the renderer's `outputColorSpace` must be temporarily set to `LinearSRGBColorSpace`. This is because: + 1. ACES tone mapping already outputs display-referred (gamma-corrected) SDR values 2. If `outputColorSpace = SRGBColorSpace` (the default), Three.js applies ANOTHER gamma conversion 3. This caused washed out colors (double gamma correction) The fix in `PostProcessing.render()`: + ```typescript // Save original color space const originalColorSpace = this.renderer.outputColorSpace; @@ -158,6 +168,7 @@ this.renderer.outputColorSpace = originalColorSpace; ``` **Tone Mapping Architecture:** + - `renderer.toneMapping = ACESFilmicToneMapping` (Three.js renderer) - `renderer.toneMappingExposure = 1.0` - PostProcessing also applies ACES Filmic in color grading pass @@ -188,28 +199,33 @@ The render pipeline uses a dual-pipeline architecture like AAA games: ``` **Implementation:** + 1. Set renderer to render resolution 2. Create internal PostProcessing (all buffers at render res) 3. Restore renderer to display resolution 4. Create display PostProcessing (EASU only) **Render Order:** + 1. Temporarily set renderer to render resolution 2. Render internal pipeline (scene + effects + TAA) 3. Restore renderer to display resolution 4. Render display pipeline (EASU upscale to canvas) This solves: + - **WebGPU depth copy errors** - All depths match (no cross-resolution copies) - **Camera shake with FSR** - TAA jitter at render resolution - **Proper temporal accumulation** - History buffer at correct resolution **SSR/SSGI Normal Texture Fix:** + - Pass raw encoded normal texture from MRT (has `.sample()` method) - Do NOT use `colorToDirection()` before passing - returns Fn node without `.sample()` - SSR/SSGI handle decoding internally during ray marching **Volumetric Fog Input Type Fix (January 2026):** + - Volumetric fog must handle both texture nodes AND Fn nodes as input - When SSGI/SSR are enabled (ultra preset), previous effects transform `outputNode` from a texture node to a Fn node via `.mul()` and `.add()` operations - Fn nodes do NOT have `.sample()` method - only texture nodes do @@ -220,6 +236,7 @@ This solves: ### Effect Pipeline Order **Internal Pipeline (render resolution):** + 1. **Scene Pass** - Render scene with MRT (output, normals, velocity) 2. **SSGI** - Global illumination + AO (if enabled) 3. **GTAO** - Ambient occlusion (if SSGI disabled) @@ -231,6 +248,7 @@ This solves: 9. **TAA/FXAA** - Anti-aliasing **Display Pipeline (display resolution):** + 1. **EASU** - Edge-adaptive upscaling from internal output --- @@ -244,6 +262,7 @@ This solves: Fully GPU-accelerated vision and fog of war computation using Three.js r182 compute shaders. **Architecture (GPU-Only Path - Option A):** + ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ VisionCompute │───→│ StorageTexture │───→│ FogOfWar │ @@ -254,6 +273,7 @@ Fully GPU-accelerated vision and fog of war computation using Three.js r182 comp ``` **Key Features:** + - **No CPU readback**: FogOfWar shader samples StorageTexture directly - **Storage buffer**: Caster positions packed as `vec4(x, y, sightRange, playerId)` - **Storage texture**: RGBA per cell (R=explored, G=visible) per player @@ -261,11 +281,13 @@ Fully GPU-accelerated vision and fog of war computation using Three.js r182 comp - **Workgroup size**: 64 threads per workgroup **Benefits:** + - 1000+ vision casters at 60Hz (vs. hundreds at 2Hz with worker) - Zero CPU↔GPU texture upload overhead - Version tracking for dirty checking **Usage:** + ```typescript // In VisionSystem this.gpuVisionCompute = new VisionCompute(renderer, { @@ -288,17 +310,20 @@ fogOfWar.setGPUVisionCompute(gpuVisionCompute); Quarter-resolution rendering with history reprojection reduces GPU cost by 75%. **GTAO Temporal Reprojection:** + - Render GTAO at quarter resolution (width/2 × height/2) - Reproject previous frame's AO using velocity buffer - Blend: 90% reprojected + 10% new quarter-res sample - Depth-based rejection prevents disocclusion ghosting **SSR Temporal Reprojection:** + - Render SSR at quarter resolution - Neighborhood clamping (3×3) reduces ghosting - Blend: 85% reprojected + 15% new (lower for reflection parallax) **Configuration:** + ```typescript renderPipeline.applyConfig({ temporalAOEnabled: true, @@ -315,6 +340,7 @@ renderPipeline.applyConfig({ The culling system provides unified frustum culling for all entities (units and buildings) with proper sphere-frustum intersection. **Architecture:** + ``` ┌─────────────────┐ ┌────────────────────────┐ ┌─────────────────────┐ │ CullingService │───→│ UnifiedCullingCompute │───→│ GPUIndirectRenderer │ @@ -328,12 +354,14 @@ The culling system provides unified frustum culling for all entities (units and ``` **File Structure:** + - `CullingService.ts` - High-level interface for renderers - `GPUEntityBuffer.ts` - Unified transform/metadata storage for units + buildings - `UnifiedCullingCompute.ts` - GPU/CPU frustum culling with sphere intersection - `GPUIndirectRenderer.ts` - Indirect draw manager **Key Features:** + 1. **Unified Entity Buffer** - Single buffer handles both units and buildings 2. **Proper Sphere-Frustum Intersection** - Fixes disappearing objects at close zoom 3. **Category-Aware Culling** - Separate indirect args for units vs buildings @@ -341,6 +369,7 @@ The culling system provides unified frustum culling for all entities (units and 5. **GPU/CPU Fallback** - WebGPU when available, CPU fallback for WebGL **GPUEntityBuffer:** + - Slot allocation with category (Unit/Building) support - Transform buffer with velocity tracking (prev/curr matrices) - Metadata buffer: `vec4(entityId, packedTypeAndCategory, playerId, boundingRadius)` @@ -349,6 +378,7 @@ The culling system provides unified frustum culling for all entities (units and - Quarantine system for safe GPU buffer reclamation **UnifiedCullingCompute (r182 Patterns):** + - **Two-pass compute**: Reset pass + Cull pass for deterministic results - **Sphere-Frustum Intersection**: `if (distance_to_plane < -radius)` instead of point test - TSL `Fn().compute()` shader with WORKGROUP_SIZE=64 @@ -360,6 +390,7 @@ The culling system provides unified frustum culling for all entities (units and - CPU fallback using `THREE.Frustum.intersectsSphere()` **CullingService (High-Level API):** + ```typescript // Create service (typically in UnitRenderer.enableGPUDrivenRendering()) const cullingService = new CullingService({ maxEntities: 8192 }); @@ -382,6 +413,7 @@ if (cullingService.isVisible(entityId)) { ``` **Renderer Integration:** + - `UnitRenderer` creates and owns the CullingService - `BuildingRenderer` receives the shared CullingService via `setCullingService()` - Both renderers use `isVisible()` for frustum culling instead of point-in-frustum tests @@ -389,6 +421,7 @@ if (cullingService.isVisible(entityId)) { ### GPU-Driven Indirect Draw **GPUIndirectRenderer (r174+ API):** + - One IndirectMesh per (unitType, LOD) pair - **r174+ indirect draw**: `geometry.setIndirect(IndirectStorageBufferAttribute)` - **Per-mesh offset**: `geometry.drawIndirectOffset` for shared buffer @@ -398,6 +431,7 @@ if (cullingService.isVisible(entityId)) { - Single drawIndexedIndirect call per unit type/LOD **Usage:** + ```typescript // Enable GPU-driven mode (UnitRenderer) unitRenderer.enableGPUDrivenRendering(); @@ -412,11 +446,66 @@ console.log(stats.visibleEntities, stats.totalIndirectDrawCalls); ``` **WebGPU Indirect Draw Status (2026):** + - `geometry.setIndirect()` - Standardized API in Three.js r174+ - `drawIndirectOffset` - Per-geometry byte offset for shared buffers - `multiDrawIndexedIndirect()` - Still experimental via `chromium-experimental-multi-draw-indirect` - Our implementation uses single shared buffer with per-geometry offsets +### LOD Hysteresis + +**Problem:** Without hysteresis, entities near LOD distance boundaries flicker rapidly between detail levels as the camera makes small movements. + +**Solution:** LOD hysteresis adds a "dead zone" around distance thresholds to prevent flickering: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ LOD HYSTERESIS │ +│ │ +│ LOD0 (High) │ LOD0→1 @ 55 LOD1→0 @ 45 │ LOD1 │ +│ ───────────────[───────]───────────────[───────]───────────────│ +│ 45 50 50 55 │ +│ │ │ │ │ │ +│ │ 10% hysteresis │ 10% hysteresis │ +│ │ │ │ +│ └── Switch DOWN here ───┘── Switch UP here │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Thresholds with 10% hysteresis (default):** +| Transition | Standard | With Hysteresis | +|------------|----------|-----------------| +| LOD0 → LOD1 | 50 | 55 (switch UP at 50 _ 1.10) | +| LOD1 → LOD0 | 50 | 45 (switch DOWN at 50 _ 0.90) | +| LOD1 → LOD2 | 120 | 132 (switch UP at 120 _ 1.10) | +| LOD2 → LOD1 | 120 | 108 (switch DOWN at 120 _ 0.90) | + +**Implementation:** + +- `AssetManager.getBestLODWithHysteresis()` - Central hysteresis calculation +- `UnitRenderer.entityCurrentLOD` - Tracks current LOD per entity +- `BuildingRenderer.entityCurrentLOD` - Same for buildings +- `UnifiedCullingCompute.previousLODAssignments` - CPU fallback path tracking + +**Configuration:** + +```typescript +// Graphics settings (uiStore.ts) +lodEnabled: true, +lodDistance0: 50, // LOD0 threshold (world units) +lodDistance1: 120, // LOD1 threshold (world units) +lodHysteresis: 0.1, // 10% hysteresis margin + +// LODConfig interface (UnifiedCullingCompute.ts) +interface LODConfig { + LOD0_MAX: number; + LOD1_MAX: number; + hysteresis?: number; // Default 0.1 (10%) +} +``` + +**Note:** GPU-driven rendering path (WebGPU compute shader) does not currently support hysteresis due to the complexity of per-entity LOD state tracking in GPU memory. The standard CPU-side rendering paths fully support hysteresis. + --- ## Planned Enhancements @@ -424,13 +513,16 @@ console.log(stats.visibleEntities, stats.totalIndirectDrawCalls); ### High Priority (Easy to Add) #### 1. Motion Blur + **Status:** Now possible with custom velocity implementation! **Benefits:** + - Cinematic feel for fast-moving units/projectiles - Per-pixel blur based on motion vectors **Implementation:** + ```typescript import { motionBlur } from 'three/tsl'; // Use our custom velocity from MRT @@ -441,6 +533,7 @@ const blurredNode = motionBlur(outputNode, scenePassVelocity); **Note:** Our custom per-instance velocity now enables proper motion blur with InstancedMesh objects. #### 2. Depth of Field (Bokeh DoF) + ```typescript import { dof } from 'three/tsl'; // dof(node, viewZNode, focusDistance, focalLength, bokehScale) @@ -448,11 +541,13 @@ const dofNode = dof(outputNode, scenePassDepth, 10, 50, 2.0); ``` **Use Cases:** + - Auto-focus on selected units - Dramatic cutscene moments - Menu/pause screen background blur #### 3. Film Grain & Chromatic Aberration + ```typescript import { film, chromaticAberration } from 'three/tsl'; const grainNode = film(outputNode, intensity, grayscale); @@ -460,6 +555,7 @@ const caNode = chromaticAberration(outputNode, offset); ``` #### 4. Anamorphic Lens Flares + ```typescript import { anamorphic, lensflare } from 'three/tsl'; const flareNode = anamorphic(outputNode, threshold, scale, samples); @@ -470,9 +566,11 @@ const flareNode = anamorphic(outputNode, threshold, scale, samples); ### Medium Priority (More Work, Big Impact) #### 5. Screen Space Global Illumination (SSGI) + [Anderson Mancini's SSGI demo](https://ssgi-webgpu-demo.vercel.app/) shows this working with Three.js WebGPU + TSL. **Features:** + - Real-time color bleeding - Dynamic emissive lighting - Realistic light bounces @@ -480,9 +578,11 @@ const flareNode = anamorphic(outputNode, threshold, scale, samples); **Impact:** Buildings casting colored light, explosions illuminating surroundings - would set VOIDSTRIKE apart from any browser game. #### 6. Volumetric Lighting / God Rays + [Official Three.js example](https://threejs.org/examples/webgpu_volume_lighting.html) available. **Features:** + - Atmospheric light shafts through fog/dust - Compatible with native lights and shadows - Post-processing based @@ -490,17 +590,21 @@ const flareNode = anamorphic(outputNode, threshold, scale, samples); **Impact:** Perfect for battlefield atmosphere, smoke, dust effects. #### 7. PCSS Soft Shadows + Percentage-Closer Soft Shadows with variable penumbra. **Features:** + - Soft shadow edges based on distance from caster - More realistic than hard shadows - Vogel disk sampling for quality #### 8. Atmospheric Scattering + Based on [Epic's Sebastian Hillaire paper](https://discourse.threejs.org/t/volumetric-lighting-in-webgpu/87959). **Features:** + - Realistic sky rendering - Proper light scattering - Day/night cycle support @@ -510,22 +614,27 @@ Based on [Epic's Sebastian Hillaire paper](https://discourse.threejs.org/t/volum ### Cutting-Edge (Research Required) #### 9. Temporal Super Resolution (TSR/DLSS-like) + Use MRT velocity data for frame interpolation and temporal upscaling beyond EASU. **Potential:** True industry-first for browsers - neural-network-free temporal upscaling using motion vectors for reconstruction. #### 10. GPU Particle Systems with Collision + Compute shader particles that interact with depth buffer. **Features:** + - Debris, smoke, sparks - Scene collision via depth testing - Thousands of particles at 60fps #### 11. Deferred Decals + Project damage marks, tire tracks, blast craters onto any surface. **Features:** + - No texture modification needed - Screen-space projection - Dynamic application @@ -552,6 +661,7 @@ const DESIRED_LIMITS = { ``` **How it works:** + 1. Query the GPU adapter's maximum supported limits 2. Request the minimum of desired and adapter-supported limits 3. Create a custom `WebGPUBackend` with `requiredLimits` @@ -561,6 +671,7 @@ const DESIRED_LIMITS = { Most modern GPUs (Vulkan/D3D12/Metal backends) support 16+ vertex buffers. **Fallback Behavior:** + - If limits can't be raised, `setupInstancedVelocity()` checks attribute count - Velocity setup skipped if it would exceed device limit - TAA uses depth-only reprojection (slight ghosting on fast objects) @@ -571,6 +682,7 @@ Most modern GPUs (Vulkan/D3D12/Metal backends) support 16+ vertex buffers. **Additional Safety:** `AssetManager.ts` also cleans excess attributes from AI-generated models: The `cleanupModelAttributes()` function removes: + - Extra UV layers (`uv1`, `uv2`, `texcoord_1`) - Extra color layers (`color_0`, `_color_1`) - Morph targets @@ -586,6 +698,7 @@ The `cleanupModelAttributes()` function removes: **Total with velocity:** 13-14 attributes (within 16 limit) ### TSL Type Definitions + Some TSL exports aren't in `@types/three` yet. Workaround: ```typescript @@ -595,11 +708,13 @@ const cameraProjectionMatrix = (TSL as any).cameraProjectionMatrix; ``` ### MRT Requirements + - Requires `antialias: false` on WebGPURenderer - Custom velocity node handles InstancedMesh correctly - Normals output for SSR works with all materials ### Performance Considerations + - **GTAO:** ~1-2ms per frame - disable for low-end devices - **EASU:** 75% render scale provides ~1.8x performance boost - **TAA:** ~0.5-1ms overhead with custom velocity - excellent quality/cost ratio @@ -719,6 +834,7 @@ Edit `public/config/graphics-presets.json` to add new presets: ### Implementation Details **Store Integration** (`uiStore.ts`): + - `currentGraphicsPreset: GraphicsPresetName` - Tracks active preset - `graphicsPresetsConfig: GraphicsPresetsConfig | null` - Loaded presets JSON - `loadGraphicsPresets()` - Fetches presets from JSON file @@ -726,6 +842,7 @@ Edit `public/config/graphics-presets.json` to add new presets: - `detectCurrentPreset()` - Checks if current settings match any preset **UI Integration** (`GraphicsOptionsPanel.tsx`): + - Preset selector buttons at top of panel - "Modified" badge when custom - Description text below buttons @@ -735,31 +852,33 @@ Edit `public/config/graphics-presets.json` to add new presets: Each preset controls all `GraphicsSettings` values: -| Category | Settings | -|----------|----------| -| **Core** | postProcessingEnabled | -| **Shadows** | shadowsEnabled, shadowQuality, shadowDistance | -| **Ambient Occlusion** | ssaoEnabled, ssaoRadius, ssaoIntensity | -| **Bloom** | bloomEnabled, bloomStrength, bloomThreshold, bloomRadius | -| **Anti-Aliasing** | antiAliasingMode, taaSharpeningEnabled, taaSharpeningIntensity | -| **Reflections** | ssrEnabled, ssrOpacity, ssrMaxRoughness | -| **Global Illumination** | ssgiEnabled, ssgiRadius, ssgiIntensity | -| **Resolution** | resolutionMode, resolutionScale, maxPixelRatio | -| **Frame Rate** | maxFPS (Off/60/120/144) | -| **Upscaling** | upscalingMode, renderScale, easuSharpness | -| **Fog** | fogEnabled, fogDensity, volumetricFogEnabled, volumetricFogQuality | -| **Lighting** | shadowFill, dynamicLightsEnabled, maxDynamicLights | -| **Effects** | emissiveDecorationsEnabled, particlesEnabled, particleDensity | -| **Color** | toneMappingExposure, saturation, contrast | -| **Vignette** | vignetteEnabled, vignetteIntensity | -| **Environment** | environmentMapEnabled | +| Category | Settings | +| ----------------------- | ------------------------------------------------------------------ | +| **Core** | postProcessingEnabled | +| **Shadows** | shadowsEnabled, shadowQuality, shadowDistance | +| **Ambient Occlusion** | ssaoEnabled, ssaoRadius, ssaoIntensity | +| **Bloom** | bloomEnabled, bloomStrength, bloomThreshold, bloomRadius | +| **Anti-Aliasing** | antiAliasingMode, taaSharpeningEnabled, taaSharpeningIntensity | +| **Reflections** | ssrEnabled, ssrOpacity, ssrMaxRoughness | +| **Global Illumination** | ssgiEnabled, ssgiRadius, ssgiIntensity | +| **Resolution** | resolutionMode, resolutionScale, maxPixelRatio | +| **Frame Rate** | maxFPS (Off/60/120/144) | +| **Upscaling** | upscalingMode, renderScale, easuSharpness | +| **Fog** | fogEnabled, fogDensity, volumetricFogEnabled, volumetricFogQuality | +| **Lighting** | shadowFill, dynamicLightsEnabled, maxDynamicLights | +| **Effects** | emissiveDecorationsEnabled, particlesEnabled, particleDensity | +| **Color** | toneMappingExposure, saturation, contrast | +| **Vignette** | vignetteEnabled, vignetteIntensity | +| **Environment** | environmentMapEnabled | --- ## Graphics Settings Audit (January 2025) ### Connected Settings + All working and wired up: + - Post-processing master toggle - Tone mapping (exposure, saturation, contrast) - Shadows (enabled, quality, distance) @@ -781,13 +900,14 @@ All working and wired up: - Shadow fill ### Disconnected Settings (Not Wired) + These exist in `uiStore.ts` but have no effect: -| Setting | In Store | Has UI | Wired to Code | -|---------|----------|--------|---------------| -| `outlineEnabled` | ✅ | ❌ | ❌ | -| `outlineStrength` | ✅ | ❌ | ❌ | -| `taaHistoryBlendRate` | ✅ | ❌ | ❌ (comment says "kept for UI compatibility") | +| Setting | In Store | Has UI | Wired to Code | +| --------------------- | -------- | ------ | --------------------------------------------- | +| `outlineEnabled` | ✅ | ❌ | ❌ | +| `outlineStrength` | ✅ | ❌ | ❌ | +| `taaHistoryBlendRate` | ✅ | ❌ | ❌ (comment says "kept for UI compatibility") | **Recommendation:** Either implement these features or remove from store to avoid confusion. @@ -800,6 +920,7 @@ These exist in `uiStore.ts` but have no effect: **Status:** Fully implemented and integrated into the PostProcessing pipeline (January 2025). **Implementation Details:** + - Raymarched volumetric fog with Henyey-Greenstein phase function for light scattering - Quality presets: Low (16 steps), Medium (32), High (64), Ultra (128) - Configurable density and scattering intensity @@ -807,6 +928,7 @@ These exist in `uiStore.ts` but have no effect: - Integrated into post-processing between Bloom and Color Grading **Files:** + - `src/rendering/tsl/VolumetricFog.ts` - TSL implementation - `src/rendering/tsl/PostProcessing.ts` - Pipeline orchestration (RenderPipeline class) - `src/rendering/tsl/effects/EffectPasses.ts` - Individual effect creation functions (SSGI, GTAO, SSR, Bloom, etc.) @@ -836,6 +958,7 @@ The fog of war system has been completely redesigned from a simple overlay mesh 7. **Quality Presets** - Low (1 sample, 1 octave), Medium (5 samples, 2 octaves), High (9 samples, 3 octaves), Ultra (13 samples, 3+ octaves) **Architecture:** + ``` VisionSystem (game logic) ↓ caster positions @@ -849,12 +972,14 @@ Bloom → Volumetric Fog → Color Grading → TAA ``` **Vision Texture Format (RGBA):** + - **R channel:** Explored flag (0 or 1) - persists once seen - **G channel:** Visible flag (0 or 1) - current frame binary - **B channel:** Velocity (0.5 = no change, <0.5 = hiding, >0.5 = revealing) - **A channel:** Smooth visibility (0-1, temporally filtered for transitions) **Files:** + - `src/rendering/tsl/effects/EffectPasses.ts` - `createFogOfWarPass()` function - `src/rendering/compute/VisionCompute.ts` - GPU compute with temporal smoothing - `src/rendering/tsl/FogOfWar.ts` - Vision texture provider (removed mesh) @@ -862,6 +987,7 @@ Bloom → Volumetric Fog → Color Grading → TAA - `src/store/uiStore.ts` - Quality settings (edge blur, desaturation, cloud speed, etc.) **Performance:** + - GPU path: Zero CPU overhead, StorageTexture direct sampling - CPU fallback: 50ms throttling with temporal smoothing - Post-process cost: ~0.5-1ms depending on quality preset @@ -919,14 +1045,15 @@ const volumetricFog = Fn(({ sceneColor, depth, lightPos, fogDensity }) => { #### Performance Impact -| Quality | Steps | Performance Hit | Visual Quality | -|---------|-------|-----------------|----------------| -| Low | 16 | ~1-2ms | Basic depth fog | -| Medium | 32 | ~2-4ms | Good volume feel | -| High | 64 | ~4-8ms | Cinematic quality | -| Ultra | 128 | ~8-15ms | Film-quality scattering | +| Quality | Steps | Performance Hit | Visual Quality | +| ------- | ----- | --------------- | ----------------------- | +| Low | 16 | ~1-2ms | Basic depth fog | +| Medium | 32 | ~2-4ms | Good volume feel | +| High | 64 | ~4-8ms | Cinematic quality | +| Ultra | 128 | ~8-15ms | Film-quality scattering | **Optimization Strategies:** + 1. **Half-resolution rendering** - Render fog at 50% res, bilateral upsample 2. **Temporal reprojection** - Spread samples across frames 3. **Frustum-aligned volumes** - Only raymarch visible area @@ -934,12 +1061,12 @@ const volumetricFog = Fn(({ sceneColor, depth, lightPos, fogDensity }) => { #### Use Cases for VOIDSTRIKE -| Effect | Implementation | Performance | -|--------|----------------|-------------| +| Effect | Implementation | Performance | +| ------------------------- | ------------------------------------------- | --------------------- | | Production building smoke | Local density volumes at building positions | Low cost if localized | -| Plasma geyser gas | Animated noise-based density | Medium cost | -| Battlefield dust/haze | Full-screen low-density fog | Medium cost | -| Explosion smoke clouds | Temporary high-density spheres | Low (transient) | +| Plasma geyser gas | Animated noise-based density | Medium cost | +| Battlefield dust/haze | Full-screen low-density fog | Medium cost | +| Explosion smoke clouds | Temporary high-density spheres | Low (transient) | #### Proposed UI (Graphics Panel → Atmosphere Section) @@ -961,6 +1088,7 @@ const volumetricFog = Fn(({ sceneColor, depth, lightPos, fogDensity }) => { **Status:** Full implementation with centralized manager and animation support (January 2025). **Features:** + - `emissiveDecorationsEnabled` - Toggle emissive glow on/off - `emissiveIntensityMultiplier` - Control glow intensity (0.5x - 2.0x) - **Pulsing animation** - Per-biome pulse speed and amplitude @@ -974,13 +1102,17 @@ const volumetricFog = Fn(({ sceneColor, depth, lightPos, fogDensity }) => { // Supports both individual meshes and InstancedMesh batches // For individual decorations (with optional light attachment): -emissiveManager.registerDecoration(mesh, { - emissive: '#00ff88', - emissiveIntensity: 2.0, - pulseSpeed: 0.5, - pulseAmplitude: 0.2, - attachLight: { color: '#00ff88', intensity: 1.5, distance: 8 } -}, position); +emissiveManager.registerDecoration( + mesh, + { + emissive: '#00ff88', + emissiveIntensity: 2.0, + pulseSpeed: 0.5, + pulseAmplitude: 0.2, + attachLight: { color: '#00ff88', intensity: 1.5, distance: 8 }, + }, + position +); // For InstancedMesh (shared material, uniform animation): emissiveManager.registerInstancedDecoration(crystalMesh, { @@ -996,13 +1128,14 @@ emissiveManager.update(deltaTime); **Biome-Specific Crystal Effects:** -| Biome | Color | Pulse Speed | Amplitude | -|-------|-------|-------------|-----------| -| Frozen | Ice blue (#204060) | 0.3 (subtle) | 0.15 | -| Void | Purple (#4020a0) | 0.5 (ethereal) | 0.25 | -| Volcanic | Orange (#802010) | 0.8 (flickering) | 0.3 | +| Biome | Color | Pulse Speed | Amplitude | +| -------- | ------------------ | ---------------- | --------- | +| Frozen | Ice blue (#204060) | 0.3 (subtle) | 0.15 | +| Void | Purple (#4020a0) | 0.5 (ethereal) | 0.25 | +| Volcanic | Orange (#802010) | 0.8 (flickering) | 0.3 | **Files:** + - `src/rendering/EmissiveDecorationManager.ts` - Manager with animation and light support - `src/rendering/GroundDetail.ts` - CrystalField exposes InstancedMesh for registration - `src/rendering/EnvironmentManager.ts` - Creates manager, registers decorations @@ -1017,20 +1150,25 @@ To add emissive behavior to new decoration types: const alienTower = new THREE.Mesh(geometry, material); // 2. Register with the manager -emissiveDecorationManager.registerDecoration(alienTower, { - emissive: '#ff4400', - emissiveIntensity: 3.0, - pulseSpeed: 0.5, - pulseAmplitude: 0.3, - attachLight: { - color: '#ff4400', - intensity: 2.0, - distance: 10, +emissiveDecorationManager.registerDecoration( + alienTower, + { + emissive: '#ff4400', + emissiveIntensity: 3.0, + pulseSpeed: 0.5, + pulseAmplitude: 0.3, + attachLight: { + color: '#ff4400', + intensity: 2.0, + distance: 10, + }, }, -}, alienTower.position); + alienTower.position +); ``` **Design Notes:** + - Individual decorations can have attached point lights via LightPool - InstancedMesh decorations share one material, so per-instance lights aren't supported - Use DecorationLightManager for clustered/distance-sorted decoration lights at scale @@ -1044,17 +1182,20 @@ emissiveDecorationManager.registerDecoration(alienTower, { #### Solutions **Option A: Hemisphere Light Boost (Already Have)** + ```typescript // Current setup (EnvironmentManager.ts:121-126) this.hemiLight = new THREE.HemisphereLight( - skyColor, // From above + skyColor, // From above groundColor, // From below (THIS IS FILL LIGHT) - 0.5 // Intensity + 0.5 // Intensity ); ``` + **Quick fix:** Increase intensity to 0.7-0.8, use brighter ground color. **Option B: Secondary Ambient from Below** + ```typescript // Add second ambient light with upward bias const groundAmbient = new THREE.AmbientLight(0x404050, 0.3); @@ -1064,6 +1205,7 @@ groundFill.position.set(0, -1, 0); // From below ``` **Option C: Per-Material Ambient Boost** + ```typescript // In decoration material setup (InstancedDecorations.ts) if (instancedMaterial instanceof THREE.MeshStandardMaterial) { @@ -1073,6 +1215,7 @@ if (instancedMaterial instanceof THREE.MeshStandardMaterial) { ``` **Option D: TSL Custom Fill Light Node** + ```typescript // In post-processing, add fill light based on surface orientation const fillLight = Fn(({ color, normal }) => { @@ -1083,6 +1226,7 @@ const fillLight = Fn(({ color, normal }) => { ``` #### Recommended Approach + 1. **Immediate:** Increase `envMapIntensity` on rock materials from 0 to 0.2-0.3 2. **Quick win:** Boost hemisphere light ground color brightness 3. **Long-term:** Add UI slider for "Shadow Fill" that controls ground ambient @@ -1138,6 +1282,7 @@ const fillLight = Fn(({ color, normal }) => { ``` **Implementation:** + ```typescript // AssetManager.ts - Apply rendering hints when loading function applyRenderingHints(mesh: THREE.Mesh, hints: RenderingHints) { @@ -1157,21 +1302,25 @@ function applyRenderingHints(mesh: THREE.Mesh, hints: RenderingHints) { ### 5. Lighting System Recommendations #### Current State + 5 static lights (ambient, key, fill, back, hemisphere) - traditional 3-point + additions. #### Recommended Improvements **Tier 1: Easy Wins (Low Effort)** + - [ ] Increase hemisphere ground color brightness (+20% for shadow fill) - [ ] Re-enable partial envMapIntensity on decorations (0.2-0.3) - [ ] Add UI exposure slider range expansion (0.5-2.5 instead of 0.5-2.0) **Tier 2: Moderate Effort** + - [x] **Light Pool System** - Reusable point/spot lights for effects ✅ Implemented - [x] **Emissive decoration manager** - Crystals/towers that glow ✅ Implemented with animation - [ ] **Per-biome light color presets** - More dramatic biome lighting **Tier 3: Advanced (High Impact)** + - [ ] **Clustered lighting** - Efficient many-lights for WebGPU - [ ] **Light probes** - Baked indirect lighting for performance - [ ] **Dynamic time-of-day** - Moving sun, changing shadows @@ -1204,8 +1353,14 @@ class LightPool { } } - spawn(id: string, position: THREE.Vector3, color: THREE.Color, intensity: number, duration: number): void { - const light = this.pool.find(l => !l.visible); + spawn( + id: string, + position: THREE.Vector3, + color: THREE.Color, + intensity: number, + duration: number + ): void { + const light = this.pool.find((l) => !l.visible); if (!light) return; // Pool exhausted light.position.copy(position); @@ -1265,6 +1420,7 @@ lightPool.spawn('laser_hit', impactPos, new THREE.Color(0x00ffff), 3.0, 100); ### Overview VOIDSTRIKE features a world-class battle effects system with: + - **Three.js 3D effects**: Projectile trails, explosions, impact decals, ground effects - **Phaser 2D overlay**: Damage numbers, screen effects, kill streaks - **Proper depth ordering**: Ground effects are now correctly occluded by units @@ -1305,27 +1461,28 @@ Hybrid water shader combining Three.js WaterMesh reflection techniques with RTS- wave displacement. Designed to eliminate the "big color gradients moving back and forth" issue from the previous procedural approach. -| Feature | Description | -|---------|-------------| +| Feature | Description | +| ------------------------- | ---------------------------------------------------------------- | | **Texture-Based Normals** | 4-sample animated normal map (generated procedurally at startup) | -| **Fixed Depth Coloring** | Distance-based color variation instead of wave-height mixing | -| **Clean Fresnel** | Schlick approximation for angle-dependent reflectivity | -| **Subtle Gerstner Waves** | 3 overlapping waves for gentle vertex displacement | -| **Stable Sky Reflection** | Consistent sky color blending, no oscillating gradients | -| **Subtle Caustics** | Low-intensity underwater light shimmer | -| **Lava Support** | Volcanic biomes render as animated lava with glow | +| **Fixed Depth Coloring** | Distance-based color variation instead of wave-height mixing | +| **Clean Fresnel** | Schlick approximation for angle-dependent reflectivity | +| **Subtle Gerstner Waves** | 3 overlapping waves for gentle vertex displacement | +| **Stable Sky Reflection** | Consistent sky color blending, no oscillating gradients | +| **Subtle Caustics** | Low-intensity underwater light shimmer | +| **Lava Support** | Volcanic biomes render as animated lava with glow | **Key Differences from Previous OceanWater:** -| Aspect | OceanWater (Old) | OceanWater (New) | -|--------|------------------|------------------------| -| Normal generation | Procedural sin/cos | 4-sample texture animation | -| Color mixing | Wave-height based (gradient issue) | Distance-based (stable) | -| Reflectivity | 0.35 | 0.25 (less reflective) | -| Wave height | 0.12 | 0.08 (subtler) | -| Fresnel power | 3.5 | 3.0 (softer) | +| Aspect | OceanWater (Old) | OceanWater (New) | +| ----------------- | ---------------------------------- | -------------------------- | +| Normal generation | Procedural sin/cos | 4-sample texture animation | +| Color mixing | Wave-height based (gradient issue) | Distance-based (stable) | +| Reflectivity | 0.35 | 0.25 (less reflective) | +| Wave height | 0.12 | 0.08 (subtler) | +| Fresnel power | 3.5 | 3.0 (softer) | **Normal Map Generation:** + ```typescript // Procedurally generated at startup (512x512) // Multiple sine waves with irrational ratios prevent tiling @@ -1335,15 +1492,17 @@ const wave2 = Math.sin(u * 23.1 - v * 17.9) * 0.2; ``` **Gerstner Wave Configuration (RTS-Scale):** + ```typescript const waves = [ { direction: (1.0, 0.15), steepness: 0.035, wavelength: 47.0 }, { direction: (0.2, 1.0), steepness: 0.028, wavelength: 31.0 }, - { direction: (0.7, 0.7), steepness: 0.020, wavelength: 19.0 }, + { direction: (0.7, 0.7), steepness: 0.02, wavelength: 19.0 }, ]; ``` **Configurable Parameters:** + - Wave height and speed - Water colors (shallow/deep/sky) - Reflectivity (0-1) @@ -1358,16 +1517,17 @@ const waves = [ TSL-animated water surfaces for water terrain features: -| Feature | Description | -|---------|-------------| -| **Flood-Fill Regions** | Groups connected water cells into efficient batched meshes | -| **Depth-Aware Materials** | Separate materials for shallow vs deep water | -| **Animated Waves** | Multi-layer sine wave animation | -| **Fresnel Effect** | Angle-dependent reflectivity | -| **Caustic Highlights** | Light pattern simulation on water surface | -| **Ripple Effects** | High-frequency surface detail animation | +| Feature | Description | +| ------------------------- | ---------------------------------------------------------- | +| **Flood-Fill Regions** | Groups connected water cells into efficient batched meshes | +| **Depth-Aware Materials** | Separate materials for shallow vs deep water | +| **Animated Waves** | Multi-layer sine wave animation | +| **Fresnel Effect** | Angle-dependent reflectivity | +| **Caustic Highlights** | Light pattern simulation on water surface | +| **Ripple Effects** | High-frequency surface detail animation | **Terrain Integration:** + - Renders at `elevation * HEIGHT_SCALE + 0.15` (offset above terrain) - Distinguishes `water_shallow` (0.65 opacity) and `water_deep` (0.8 opacity) - Supports both game and editor coordinate systems @@ -1377,37 +1537,38 @@ TSL-animated water surfaces for water terrain features: **File:** `src/editor/rendering3d/EditorTerrain.ts` The map editor renders water with proper orientation: + - WaterMesh added as child of terrain mesh - Counter-rotation applied to compensate for terrain rotation: `waterMesh.group.rotation.x = Math.PI / 2` ### Biome Water Configuration -| Biome | hasWater | Water Level | Water Color | Notes | -|-------|----------|-------------|-------------|-------| -| Grassland | ✓ | -0.5 | 0x3080c0 | Standard ocean | -| Desert | ✗ | -1 | - | Disabled | -| Frozen | ✗ | -1 | - | Disabled (artifact issues) | -| Volcanic | ✓ | -0.3 | 0xff4010 | Renders as lava | -| Void | ✗ | -1 | - | Disabled | -| Jungle | ✗ | -1 | - | Disabled | -| Ocean | ✓ | 0.5 | 0x1060a0 | Deep ocean for naval | +| Biome | hasWater | Water Level | Water Color | Notes | +| --------- | -------- | ----------- | ----------- | -------------------------- | +| Grassland | ✓ | -0.5 | 0x3080c0 | Standard ocean | +| Desert | ✗ | -1 | - | Disabled | +| Frozen | ✗ | -1 | - | Disabled (artifact issues) | +| Volcanic | ✓ | -0.3 | 0xff4010 | Renders as lava | +| Void | ✗ | -1 | - | Disabled | +| Jungle | ✗ | -1 | - | Disabled | +| Ocean | ✓ | 0.5 | 0x1060a0 | Deep ocean for naval | --- ### Render Order -| Order | Layer | Description | -|-------|-------|-------------| -| 0-9 | Terrain | Ground geometry | -| 10-19 | Ground Decals | Scorch marks, impact craters | +| Order | Layer | Description | +| ----- | -------------- | ----------------------------------------- | +| 0-9 | Terrain | Ground geometry | +| 10-19 | Ground Decals | Scorch marks, impact craters | | 20-29 | Ground Effects | Hit rings, shockwaves (`depthTest: true`) | -| 30-39 | Unit Shadows | Ground shadows | -| 40-59 | Ground Units | Marines, tanks, buildings | -| 60-69 | Projectiles | Tracers, plasma bolts, trails | -| 70-79 | Air Units | Wraiths, carriers | -| 80-89 | Air Effects | Hit effects at flying unit height | -| 90-99 | Glow Effects | Additive bloom-interacting effects | -| 100+ | UI | Damage numbers, indicators | +| 30-39 | Unit Shadows | Ground shadows | +| 40-59 | Ground Units | Marines, tanks, buildings | +| 60-69 | Projectiles | Tracers, plasma bolts, trails | +| 70-79 | Air Units | Wraiths, carriers | +| 80-89 | Air Effects | Hit effects at flying unit height | +| 90-99 | Glow Effects | Additive bloom-interacting effects | +| 100+ | UI | Damage numbers, indicators | ### Projectile System @@ -1419,6 +1580,7 @@ The map editor renders water with proper orientation: | Zerg | Acid green | Dark green | Bright green | **Features:** + - Ribbon trail geometry that follows projectile path - Glow sprites with bloom interaction - Muzzle flash at attack origin @@ -1426,20 +1588,21 @@ The map editor renders water with proper orientation: ### Particle Types -| Type | Use Case | Features | -|------|----------|----------| -| FIRE | Explosions | Animated sprite, rises, orange→black | -| SMOKE | Aftermath | Large soft sprites, slow rise, fades | -| SPARK | Impacts | Small bright dots, arcs with gravity | -| DEBRIS | Destruction | Tumbling geometry, bounces on ground | -| ENERGY | Psionic | Pulsing, blue/purple | -| PLASMA | Acid | Dripping, green glow | -| DUST | Movement | Ground cloud, soft edges | -| ELECTRICITY | Shields | Rapid pulse, branching | +| Type | Use Case | Features | +| ----------- | ----------- | ------------------------------------ | +| FIRE | Explosions | Animated sprite, rises, orange→black | +| SMOKE | Aftermath | Large soft sprites, slow rise, fades | +| SPARK | Impacts | Small bright dots, arcs with gravity | +| DEBRIS | Destruction | Tumbling geometry, bounces on ground | +| ENERGY | Psionic | Pulsing, blue/purple | +| PLASMA | Acid | Dripping, green glow | +| DUST | Movement | Ground cloud, soft edges | +| ELECTRICITY | Shields | Rapid pulse, branching | ### Damage Numbers (Phaser 2D) **Consolidation Logic:** + - Max one damage number per entity - Hits within 500ms consolidate into existing number - Total damage accumulates, number grows with intensity @@ -1456,16 +1619,17 @@ The map editor renders water with proper orientation: ### Screen Effects (Phaser 2D) -| Effect | Trigger | Description | -|--------|---------|-------------| -| Chromatic Aberration | Heavy damage | RGB channel separation at screen edges | -| Directional Indicators | Damage received | Arrow pointing toward damage source | -| Kill Streak | 3/5/10/15/25 kills | "TRIPLE KILL", "RAMPAGE", etc. | -| Screen Cracks | Health < 30% | Fracture lines from screen edges | -| Explosion Rings | Building destroyed | Expanding white circles | -| Screen Flash | Major events | Brief color flash | +| Effect | Trigger | Description | +| ---------------------- | ------------------ | -------------------------------------- | +| Chromatic Aberration | Heavy damage | RGB channel separation at screen edges | +| Directional Indicators | Damage received | Arrow pointing toward damage source | +| Kill Streak | 3/5/10/15/25 kills | "TRIPLE KILL", "RAMPAGE", etc. | +| Screen Cracks | Health < 30% | Fracture lines from screen edges | +| Explosion Rings | Building destroyed | Expanding white circles | +| Screen Flash | Major events | Brief color flash | **Kill Streak Thresholds:** + 1. 3 kills: "TRIPLE KILL" (orange) 2. 5 kills: "KILLING SPREE" (red-orange) 3. 10 kills: "RAMPAGE" (red) @@ -1482,13 +1646,13 @@ The map editor renders water with proper orientation: ### Files -| File | Purpose | -|------|---------| -| `src/rendering/effects/BattleEffectsRenderer.ts` | Core 3D battle effects | -| `src/rendering/effects/AdvancedParticleSystem.ts` | GPU particle system | -| `src/phaser/systems/DamageNumberSystem.ts` | Phaser damage numbers | -| `src/phaser/systems/ScreenEffectsSystem.ts` | Phaser screen effects | -| `src/engine/systems/CombatSystem.ts` | Emits `damage:dealt` event | +| File | Purpose | +| ------------------------------------------------- | -------------------------- | +| `src/rendering/effects/BattleEffectsRenderer.ts` | Core 3D battle effects | +| `src/rendering/effects/AdvancedParticleSystem.ts` | GPU particle system | +| `src/phaser/systems/DamageNumberSystem.ts` | Phaser damage numbers | +| `src/phaser/systems/ScreenEffectsSystem.ts` | Phaser screen effects | +| `src/engine/systems/CombatSystem.ts` | Emits `damage:dealt` event | ### Event Flow @@ -1552,29 +1716,31 @@ VOIDSTRIKE features a comprehensive overlay system for strategic information dis #### Strategic Overlays (3D) -| Overlay | Type | Update | Description | -|---------|------|--------|-------------| -| **Terrain** | Static | Cached | Walkability by terrain type (green=walk, red=blocked) | -| **Elevation** | Static | Cached | Elevation zones (yellow=high, blue=mid, green=low) | -| **Threat** | Dynamic | 500ms | Enemy attack ranges as red heatmap | -| **Navmesh** | Static | Progressive | Pathfinding connectivity (green=connected, magenta=disconnected ramps) | -| **Buildable** | Dynamic | On request | Building placement grid (green=buildable, red=blocked, gray=occupied) | +| Overlay | Type | Update | Description | +| ------------- | ------- | ----------- | ---------------------------------------------------------------------- | +| **Terrain** | Static | Cached | Walkability by terrain type (green=walk, red=blocked) | +| **Elevation** | Static | Cached | Elevation zones (yellow=high, blue=mid, green=low) | +| **Threat** | Dynamic | 500ms | Enemy attack ranges as red heatmap | +| **Navmesh** | Static | Progressive | Pathfinding connectivity (green=connected, magenta=disconnected ramps) | +| **Buildable** | Dynamic | On request | Building placement grid (green=buildable, red=blocked, gray=occupied) | #### Tactical Overlays (2D - RTS Style) -| Overlay | Activation | Description | -|---------|------------|-------------| -| **Attack Range** | Hold R | Red circles showing selected units' weapon range | -| **Vision Range** | Hold V | Blue circles showing selected units' sight range | -| **Resource** | Toggle | Highlights resource nodes with remaining amounts | -| **Tactical View** | Backtick (`) | Grid overlay with "TACTICAL" label | +| Overlay | Activation | Description | +| ----------------- | ------------ | ------------------------------------------------ | +| **Attack Range** | Hold R | Red circles showing selected units' weapon range | +| **Vision Range** | Hold V | Blue circles showing selected units' sight range | +| **Resource** | Toggle | Highlights resource nodes with remaining amounts | +| **Tactical View** | Backtick (`) | Grid overlay with "TACTICAL" label | ### Performance Optimizations #### Navmesh Progressive Computation + **Problem**: Original navmesh overlay froze game for 30+ seconds (65,536 pathfinding queries synchronously). **Solution**: + ```typescript // Process in batches with yielding const BATCH_SIZE = 1024; @@ -1588,7 +1754,7 @@ const processBatch = async (): Promise => { this.navmeshTexture.needsUpdate = true; // Yield to main thread - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); await processBatch(); }; ``` @@ -1596,14 +1762,17 @@ const processBatch = async (): Promise => { **Result**: Overlay appears progressively in <2 seconds, game remains responsive. #### Threat Overlay Worker Offloading + **Problem**: Threat overlay caused frame drops every 200ms with many units. **Solution**: + - Increased update interval from 200ms to 500ms - Moved computation to `overlay.worker.ts` - Only updates when threat overlay is visible #### IndexedDB Caching + Static overlays (terrain, elevation, navmesh) are computed once and cached: ```typescript @@ -1621,40 +1790,42 @@ await setOverlayCache(mapHash, 'navmesh', textureData, width, height); ### Keyboard Shortcuts -| Key | Overlay | Mode | -|-----|---------|------| -| `` ` `` | Tactical view | Toggle | -| `O` | Cycle strategic overlays | Toggle | -| `R` | Attack range | Hold | -| `V` | Vision range | Hold | +| Key | Overlay | Mode | +| ------- | ------------------------ | ------ | +| `` ` `` | Tactical view | Toggle | +| `O` | Cycle strategic overlays | Toggle | +| `R` | Attack range | Hold | +| `V` | Vision range | Hold | ### Color Coding Reference #### Navmesh Overlay -| Color | Meaning | -|-------|---------| -| Green | Connected to reference point (pathable) | -| Cyan/Green | Connected ramp | -| Yellow/Orange | On navmesh but disconnected | -| Magenta | **Disconnected ramp (critical issue!)** | -| Red | Should be walkable but not on navmesh | -| Dark Gray | Correctly unwalkable | + +| Color | Meaning | +| ------------- | --------------------------------------- | +| Green | Connected to reference point (pathable) | +| Cyan/Green | Connected ramp | +| Yellow/Orange | On navmesh but disconnected | +| Magenta | **Disconnected ramp (critical issue!)** | +| Red | Should be walkable but not on navmesh | +| Dark Gray | Correctly unwalkable | #### Threat Overlay -| Intensity | Meaning | -|-----------|---------| -| Transparent | No threat | -| Light Red | Light threat (1-2 units) | -| Bright Red | Heavy threat (multiple units/buildings) | + +| Intensity | Meaning | +| ----------- | --------------------------------------- | +| Transparent | No threat | +| Light Red | Light threat (1-2 units) | +| Bright Red | Heavy threat (multiple units/buildings) | ### Files -| File | Purpose | -|------|---------| -| `src/rendering/tsl/GameOverlay.ts` | TSL-based 3D strategic overlays | -| `src/rendering/OverlayManager.ts` | Unified overlay manager (alternative) | -| `src/phaser/scenes/OverlayScene.ts` | Phaser 2D tactical overlays | -| `src/workers/overlay.worker.ts` | Overlay computation worker | -| `src/workers/pathfinding.worker.ts` | Batch pathfinding queries | -| `src/utils/overlayCache.ts` | IndexedDB caching | -| `src/store/uiStore.ts` | Overlay state management | +| File | Purpose | +| ----------------------------------- | ------------------------------------- | +| `src/rendering/tsl/GameOverlay.ts` | TSL-based 3D strategic overlays | +| `src/rendering/OverlayManager.ts` | Unified overlay manager (alternative) | +| `src/phaser/scenes/OverlayScene.ts` | Phaser 2D tactical overlays | +| `src/workers/overlay.worker.ts` | Overlay computation worker | +| `src/workers/pathfinding.worker.ts` | Batch pathfinding queries | +| `src/utils/overlayCache.ts` | IndexedDB caching | +| `src/store/uiStore.ts` | Overlay state management | diff --git a/src/assets/AssetManager.ts b/src/assets/AssetManager.ts index faccdbfb..0ce55bbf 100644 --- a/src/assets/AssetManager.ts +++ b/src/assets/AssetManager.ts @@ -65,11 +65,21 @@ export type LODLevel = 0 | 1 | 2; /** Default distance thresholds for LOD switching (in world units) */ export const DEFAULT_LOD_DISTANCES = { - LOD0_MAX: 50, // Use LOD0 (highest detail) within 50 units - LOD1_MAX: 120, // Use LOD1 (medium detail) between 50-120 units + LOD0_MAX: 50, // Use LOD0 (highest detail) within 50 units + LOD1_MAX: 120, // Use LOD1 (medium detail) between 50-120 units // Beyond 120 units, use LOD2 (lowest detail) } as const; +/** + * Default hysteresis margin for LOD transitions (as fraction of distance threshold). + * Prevents LOD flickering when camera hovers near distance boundaries. + * + * With 10% hysteresis: + * - LOD0→LOD1 at 55 units (50 * 1.10), LOD1→LOD0 at 45 units (50 * 0.90) + * - LOD1→LOD2 at 132 units (120 * 1.10), LOD2→LOD1 at 108 units (120 * 0.90) + */ +export const DEFAULT_LOD_HYSTERESIS = 0.1; + // Asset definition types export interface AssetDefinition { id: string; @@ -134,19 +144,19 @@ export type VehicleEffectCondition = 'always' | 'moving' | 'idle' | 'attacking' /** Types of vehicle effects that can be attached */ export type VehicleEffectType = - | 'engine_exhaust' // Fire + smoke for engines - | 'thruster' // Blue/energy thruster glow - | 'smoke_trail' // Trailing smoke - | 'dust_cloud' // Ground dust behind wheels/tracks - | 'afterburner' // Intense engine fire - | 'hover_dust' // Dust from hovering/landing - | 'sparks'; // Mechanical sparks + | 'engine_exhaust' // Fire + smoke for engines + | 'thruster' // Blue/energy thruster glow + | 'smoke_trail' // Trailing smoke + | 'dust_cloud' // Ground dust behind wheels/tracks + | 'afterburner' // Intense engine fire + | 'hover_dust' // Dust from hovering/landing + | 'sparks'; // Mechanical sparks /** Attachment point for an effect (local coordinates relative to unit) */ export interface EffectAttachment { - x: number; // Local offset X (right/left) - y: number; // Local offset Y (up/down) - z: number; // Local offset Z (front/back) + x: number; // Local offset X (right/left) + y: number; // Local offset Y (up/down) + z: number; // Local offset Z (front/back) scale?: number; // Size multiplier for this attachment (default: 1.0) } @@ -154,9 +164,9 @@ export interface EffectAttachment { export interface VehicleEffectDefinition { type: VehicleEffectType; attachments: EffectAttachment[]; - emitRate: number; // Particles per second per attachment + emitRate: number; // Particles per second per attachment conditions: VehicleEffectCondition[]; - speedScale?: boolean; // Scale emit rate with movement speed + speedScale?: boolean; // Scale emit rate with movement speed } /** Container for all effects on a unit */ @@ -319,7 +329,7 @@ function cleanupVertexAttributes( const essentialAttributes = new Set([ 'position', 'normal', - 'uv', // Only first UV set + 'uv', // Only first UV set 'tangent', ]); @@ -433,7 +443,12 @@ function cleanupModelAttributes(model: THREE.Object3D, assetId: string): void { * @param assetId - Optional asset ID for caching Y offset * @param scaleMultiplier - Optional additional scale multiplier applied after height normalization */ -function normalizeModel(root: THREE.Object3D, targetHeight: number, assetId?: string, scaleMultiplier: number = 1.0): void { +function normalizeModel( + root: THREE.Object3D, + targetHeight: number, + assetId?: string, + scaleMultiplier: number = 1.0 +): void { // Update world matrices first root.updateMatrixWorld(true); @@ -449,14 +464,18 @@ function normalizeModel(root: THREE.Object3D, targetHeight: number, assetId?: st const size = box.getSize(new THREE.Vector3()); // Log model info for debugging - debugAssets.log(`[AssetManager] Model bounds: size=(${size.x.toFixed(2)}, ${size.y.toFixed(2)}, ${size.z.toFixed(2)}), min.y=${box.min.y.toFixed(2)}`); + debugAssets.log( + `[AssetManager] Model bounds: size=(${size.x.toFixed(2)}, ${size.y.toFixed(2)}, ${size.z.toFixed(2)}), min.y=${box.min.y.toFixed(2)}` + ); // Scale to target height if model has height, then apply scale multiplier if (size.y > 0.001) { const heightScale = targetHeight / size.y; const finalScale = heightScale * scaleMultiplier; root.scale.setScalar(finalScale); - debugAssets.log(`[AssetManager] Applied scale: ${finalScale.toFixed(4)} (height: ${heightScale.toFixed(4)} × multiplier: ${scaleMultiplier.toFixed(2)}) to achieve height ${(targetHeight * scaleMultiplier).toFixed(2)}`); + debugAssets.log( + `[AssetManager] Applied scale: ${finalScale.toFixed(4)} (height: ${heightScale.toFixed(4)} × multiplier: ${scaleMultiplier.toFixed(2)}) to achieve height ${(targetHeight * scaleMultiplier).toFixed(2)}` + ); } // Update matrices after scaling @@ -473,7 +492,9 @@ function normalizeModel(root: THREE.Object3D, targetHeight: number, assetId?: st depth: finalSize.z, height: finalSize.y, }); - debugAssets.log(`[AssetManager] Stored bounding dimensions for ${assetId}: ${finalSize.x.toFixed(2)} x ${finalSize.z.toFixed(2)} x ${finalSize.y.toFixed(2)}`); + debugAssets.log( + `[AssetManager] Stored bounding dimensions for ${assetId}: ${finalSize.x.toFixed(2)} x ${finalSize.z.toFixed(2)} x ${finalSize.y.toFixed(2)}` + ); } // Ground the model (set bottom at y=0) - minY anchor @@ -484,7 +505,9 @@ function normalizeModel(root: THREE.Object3D, targetHeight: number, assetId?: st // Store the Y offset for instanced rendering if (assetId) { modelYOffsets.set(assetId, root.position.y); - debugAssets.log(`[AssetManager] Stored Y offset for ${assetId}: ${root.position.y.toFixed(4)}`); + debugAssets.log( + `[AssetManager] Stored Y offset for ${assetId}: ${root.position.y.toFixed(4)}` + ); } } } @@ -513,12 +536,16 @@ function applyScaleAndGround(root: THREE.Object3D, assetId: string, scale: numbe const size = box.getSize(new THREE.Vector3()); // Log model info for debugging - debugAssets.log(`[AssetManager] ${assetId} original bounds: size=(${size.x.toFixed(2)}, ${size.y.toFixed(2)}, ${size.z.toFixed(2)}), min.y=${box.min.y.toFixed(2)}`); + debugAssets.log( + `[AssetManager] ${assetId} original bounds: size=(${size.x.toFixed(2)}, ${size.y.toFixed(2)}, ${size.z.toFixed(2)}), min.y=${box.min.y.toFixed(2)}` + ); // Apply scale multiplier if (scale !== 1.0) { root.scale.setScalar(scale); - debugAssets.log(`[AssetManager] ${assetId} applied scale: ${scale.toFixed(2)} -> final size: ${(size.y * scale).toFixed(2)}`); + debugAssets.log( + `[AssetManager] ${assetId} applied scale: ${scale.toFixed(2)} -> final size: ${(size.y * scale).toFixed(2)}` + ); } // Update matrices after scaling @@ -530,7 +557,9 @@ function applyScaleAndGround(root: THREE.Object3D, assetId: string, scale: numbe // Ground the model (set bottom at y=0) - minY anchor if (isFinite(box.min.y)) { root.position.y = -box.min.y; - debugAssets.log(`[AssetManager] ${assetId} grounded: position.y = ${root.position.y.toFixed(4)}`); + debugAssets.log( + `[AssetManager] ${assetId} grounded: position.y = ${root.position.y.toFixed(4)}` + ); // Store the Y offset for instanced rendering modelYOffsets.set(assetId, root.position.y); @@ -561,7 +590,11 @@ function deepCloneGeometry(source: THREE.BufferGeometry): THREE.BufferGeometry { if (source.index) { const srcIndex = source.index; const newIndexArray = srcIndex.array.slice(0); - const newIndex = new THREE.BufferAttribute(newIndexArray, srcIndex.itemSize, srcIndex.normalized); + const newIndex = new THREE.BufferAttribute( + newIndexArray, + srcIndex.itemSize, + srcIndex.normalized + ); newIndex.needsUpdate = true; cloned.setIndex(newIndex); } @@ -570,7 +603,7 @@ function deepCloneGeometry(source: THREE.BufferGeometry): THREE.BufferGeometry { if (source.morphAttributes) { for (const name of Object.keys(source.morphAttributes)) { const srcMorphArray = source.morphAttributes[name]; - cloned.morphAttributes[name] = srcMorphArray.map(srcAttr => { + cloned.morphAttributes[name] = srcMorphArray.map((srcAttr) => { const newArray = srcAttr.array.slice(0); const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); newAttr.needsUpdate = true; @@ -627,14 +660,14 @@ function deepCloneModelHierarchy(node: THREE.Object3D): THREE.Object3D { const clonedGeometry = deepCloneGeometry(node.geometry); // Clone material (shallow is fine, materials don't cause GPU buffer issues) const clonedMaterial = Array.isArray(node.material) - ? node.material.map(m => m.clone()) + ? node.material.map((m) => m.clone()) : node.material.clone(); clonedNode = new THREE.Mesh(clonedGeometry, clonedMaterial); } else if (node instanceof THREE.SkinnedMesh) { // SkinnedMesh needs special handling - deep clone geometry const clonedGeometry = deepCloneGeometry(node.geometry); const clonedMaterial = Array.isArray(node.material) - ? node.material.map(m => m.clone()) + ? node.material.map((m) => m.clone()) : node.material.clone(); // Create new SkinnedMesh with cloned geometry clonedNode = new THREE.SkinnedMesh(clonedGeometry, clonedMaterial); @@ -642,13 +675,13 @@ function deepCloneModelHierarchy(node: THREE.Object3D): THREE.Object3D { } else if (node instanceof THREE.Points) { const clonedGeometry = deepCloneGeometry(node.geometry); const clonedMaterial = Array.isArray(node.material) - ? node.material.map(m => m.clone()) + ? node.material.map((m) => m.clone()) : node.material.clone(); clonedNode = new THREE.Points(clonedGeometry, clonedMaterial); } else if (node instanceof THREE.Line) { const clonedGeometry = deepCloneGeometry(node.geometry); const clonedMaterial = Array.isArray(node.material) - ? node.material.map(m => m.clone()) + ? node.material.map((m) => m.clone()) : node.material.clone(); clonedNode = new THREE.Line(clonedGeometry, clonedMaterial); } else { @@ -798,7 +831,10 @@ export const Materials = { }; // Clone a material with a different color (for player colors) -export function colorMaterial(baseMaterial: THREE.MeshStandardMaterial, color: number): THREE.MeshStandardMaterial { +export function colorMaterial( + baseMaterial: THREE.MeshStandardMaterial, + color: number +): THREE.MeshStandardMaterial { const mat = baseMaterial.clone(); mat.color.setHex(color); return mat; @@ -842,7 +878,9 @@ export class AssetManager { wrapper.add(cloned); wrapper.updateMatrixWorld(true); - debugAssets.log(`[AssetManager] Custom ${unitId}: ${meshCount} meshes, inner pos.y=${cloned.position.y.toFixed(3)}, scale=${cloned.scale.x.toFixed(4)}`); + debugAssets.log( + `[AssetManager] Custom ${unitId}: ${meshCount} meshes, inner pos.y=${cloned.position.y.toFixed(3)}, scale=${cloned.scale.x.toFixed(4)}` + ); return wrapper; } @@ -859,7 +897,9 @@ export class AssetManager { const scaleMultiplier = assetScaleMultipliers.get(unitId); if (scaleMultiplier !== undefined && scaleMultiplier !== 1.0) { result.scale.multiplyScalar(scaleMultiplier); - debugAssets.log(`[AssetManager] Procedural ${unitId}: applied scale multiplier ${scaleMultiplier}`); + debugAssets.log( + `[AssetManager] Procedural ${unitId}: applied scale multiplier ${scaleMultiplier}` + ); } // Apply player color if provided @@ -909,7 +949,9 @@ export class AssetManager { wrapper.add(cloned); wrapper.updateMatrixWorld(true); - debugAssets.log(`[AssetManager] Custom building ${buildingId}: ${meshCount} meshes, inner pos.y=${cloned.position.y.toFixed(3)}, scale=${cloned.scale.x.toFixed(4)}`); + debugAssets.log( + `[AssetManager] Custom building ${buildingId}: ${meshCount} meshes, inner pos.y=${cloned.position.y.toFixed(3)}, scale=${cloned.scale.x.toFixed(4)}` + ); return wrapper; } @@ -926,7 +968,9 @@ export class AssetManager { const scaleMultiplier = assetScaleMultipliers.get(buildingId); if (scaleMultiplier !== undefined && scaleMultiplier !== 1.0) { result.scale.multiplyScalar(scaleMultiplier); - debugAssets.log(`[AssetManager] Procedural building ${buildingId}: applied scale multiplier ${scaleMultiplier}`); + debugAssets.log( + `[AssetManager] Procedural building ${buildingId}: applied scale multiplier ${scaleMultiplier}` + ); } if (playerColor !== undefined) { @@ -1128,7 +1172,10 @@ export class AssetManager { // Log animation names and store them per threejs-builder skill if (hasAnimations) { - debugAssets.log(`[AssetManager] ${assetId} animations:`, gltf.animations.map(a => a.name)); + debugAssets.log( + `[AssetManager] ${assetId} animations:`, + gltf.animations.map((a) => a.name) + ); assetAnimations.set(assetId, gltf.animations); } @@ -1466,6 +1513,89 @@ export class AssetManager { return 0; } + /** + * Get the best available LOD level with hysteresis to prevent flickering. + * + * Hysteresis adds a "dead zone" around LOD boundaries: + * - Switching to a lower-detail LOD requires crossing (threshold * (1 + hysteresis)) + * - Switching to a higher-detail LOD requires crossing (threshold * (1 - hysteresis)) + * + * @param assetId - The asset identifier + * @param distance - Current distance from camera + * @param currentLOD - The entity's current LOD level (null if unknown) + * @param lodDistances - Distance thresholds for LOD levels + * @param hysteresis - Hysteresis margin as fraction (0.1 = 10%) + */ + static getBestLODWithHysteresis( + assetId: string, + distance: number, + currentLOD: LODLevel | null, + lodDistances: { LOD0_MAX: number; LOD1_MAX: number } = DEFAULT_LOD_DISTANCES, + hysteresis: number = DEFAULT_LOD_HYSTERESIS + ): LODLevel { + // If no current LOD known, use standard selection (no hysteresis on first assignment) + if (currentLOD === null) { + return this.getBestLODForDistance(assetId, distance, lodDistances); + } + + // Calculate hysteresis-adjusted thresholds + // To switch UP (to lower detail): use threshold * (1 + hysteresis) + // To switch DOWN (to higher detail): use threshold * (1 - hysteresis) + const lod0UpThreshold = lodDistances.LOD0_MAX * (1 + hysteresis); // 55 @ 10% + const lod0DownThreshold = lodDistances.LOD0_MAX * (1 - hysteresis); // 45 @ 10% + const lod1UpThreshold = lodDistances.LOD1_MAX * (1 + hysteresis); // 132 @ 10% + const lod1DownThreshold = lodDistances.LOD1_MAX * (1 - hysteresis); // 108 @ 10% + + let idealLOD: LODLevel; + + // Apply hysteresis based on current LOD + if (currentLOD === 0) { + // Currently at LOD0, only switch to LOD1 if we cross the UP threshold + if (distance > lod1UpThreshold) { + idealLOD = 2; + } else if (distance > lod0UpThreshold) { + idealLOD = 1; + } else { + idealLOD = 0; + } + } else if (currentLOD === 1) { + // Currently at LOD1 + if (distance > lod1UpThreshold) { + idealLOD = 2; // Switch up to LOD2 + } else if (distance < lod0DownThreshold) { + idealLOD = 0; // Switch down to LOD0 + } else { + idealLOD = 1; // Stay at LOD1 + } + } else { + // Currently at LOD2, only switch to lower LODs if we cross DOWN thresholds + if (distance < lod0DownThreshold) { + idealLOD = 0; + } else if (distance < lod1DownThreshold) { + idealLOD = 1; + } else { + idealLOD = 2; + } + } + + // Check if ideal LOD is available, fall back if not + const levels = availableLODLevels.get(assetId); + if (!levels) return 0; + + if (levels.has(idealLOD)) return idealLOD; + + // Fall back to closest available LOD + if (idealLOD === 2) { + if (levels.has(1)) return 1; + return 0; + } else if (idealLOD === 1) { + if (levels.has(2)) return 2; + return 0; + } + + return 0; + } + /** * Get the original model at a specific LOD level (NOT cloned). * Returns the cached original model - caller must NOT modify it. @@ -1478,7 +1608,12 @@ export class AssetManager { case 1: return customAssetsLOD1.get(assetId) ?? customAssets.get(assetId) ?? null; case 2: - return customAssetsLOD2.get(assetId) ?? customAssetsLOD1.get(assetId) ?? customAssets.get(assetId) ?? null; + return ( + customAssetsLOD2.get(assetId) ?? + customAssetsLOD1.get(assetId) ?? + customAssets.get(assetId) ?? + null + ); default: return null; } @@ -1595,21 +1730,25 @@ export class AssetManager { */ static preloadCommonAssets(): void { // Units - ['fabricator', 'trooper', 'breacher', 'medic', 'devastator', 'valkyrie', 'specter'].forEach(id => { - this.getUnitMesh(id); - }); + ['fabricator', 'trooper', 'breacher', 'medic', 'devastator', 'valkyrie', 'specter'].forEach( + (id) => { + this.getUnitMesh(id); + } + ); // Buildings - ['headquarters', 'supply_cache', 'infantry_bay', 'extractor', 'forge', 'hangar'].forEach(id => { - this.getBuildingMesh(id); - }); + ['headquarters', 'supply_cache', 'infantry_bay', 'extractor', 'forge', 'hangar'].forEach( + (id) => { + this.getBuildingMesh(id); + } + ); // Resources this.getResourceMesh('minerals'); this.getResourceMesh('plasma'); // Projectiles - ['bullet', 'missile', 'laser'].forEach(id => { + ['bullet', 'missile', 'laser'].forEach((id) => { this.getProjectileMesh(id); }); } @@ -1749,7 +1888,12 @@ export class AssetManager { await this.loadConfig(); // Build model list from config or use hardcoded defaults - const customModels: Array<{ path: string; assetId: string; targetHeight?: number; scale?: number }> = []; + const customModels: Array<{ + path: string; + assetId: string; + targetHeight?: number; + scale?: number; + }> = []; if (!assetsConfig) { debugAssets.warn('[AssetManager] assets.json not found, using procedural meshes only'); @@ -1858,7 +2002,12 @@ export class AssetManager { * Load a single model with all its LOD levels * Uses worker for network I/O - no HEAD requests needed (worker handles 404s gracefully) */ - const loadModelWithLODs = async (model: { path: string; assetId: string; targetHeight?: number; scale?: number }): Promise => { + const loadModelWithLODs = async (model: { + path: string; + assetId: string; + targetHeight?: number; + scale?: number; + }): Promise => { try { const lod0Path = model.path; const lod1Path = lod0Path.replace('_LOD0.glb', '_LOD1.glb'); @@ -1869,28 +2018,49 @@ export class AssetManager { // Load LOD0 first (required) - this will throw if file doesn't exist try { - await this.loadGLTF(lod0Path, model.assetId, { targetHeight: model.targetHeight, scale: model.scale }); + await this.loadGLTF(lod0Path, model.assetId, { + targetHeight: model.targetHeight, + scale: model.scale, + }); lodLevels.add(0); } catch { // LOD0 doesn't exist - use procedural mesh - debugAssets.log(`[AssetManager] No custom model found at ${lod0Path}, using procedural mesh`); + debugAssets.log( + `[AssetManager] No custom model found at ${lod0Path}, using procedural mesh` + ); return false; } // Load LOD1 and LOD2 in parallel (optional, silent fail) // No HEAD requests - worker fetch returns null for missing files await Promise.all([ - this.loadGLTFForLOD(lod1Path, model.assetId, 1, { targetHeight: model.targetHeight, scale: model.scale }) - .then(() => { lodLevels.add(1); }) - .catch(() => { /* LOD1 not available */ }), - this.loadGLTFForLOD(lod2Path, model.assetId, 2, { targetHeight: model.targetHeight, scale: model.scale }) - .then(() => { lodLevels.add(2); }) - .catch(() => { /* LOD2 not available */ }), + this.loadGLTFForLOD(lod1Path, model.assetId, 1, { + targetHeight: model.targetHeight, + scale: model.scale, + }) + .then(() => { + lodLevels.add(1); + }) + .catch(() => { + /* LOD1 not available */ + }), + this.loadGLTFForLOD(lod2Path, model.assetId, 2, { + targetHeight: model.targetHeight, + scale: model.scale, + }) + .then(() => { + lodLevels.add(2); + }) + .catch(() => { + /* LOD2 not available */ + }), ]); // Store available LOD levels for this asset availableLODLevels.set(model.assetId, lodLevels); - debugAssets.log(`[AssetManager] ✓ Loaded ${model.assetId} with LOD levels: [${Array.from(lodLevels).join(', ')}]`); + debugAssets.log( + `[AssetManager] ✓ Loaded ${model.assetId} with LOD levels: [${Array.from(lodLevels).join(', ')}]` + ); return true; } catch (error) { debugAssets.log(`[AssetManager] Could not load ${model.path}:`, error); @@ -1922,7 +2092,9 @@ export class AssetManager { } } const loadTime = performance.now() - startTime; - debugAssets.log(`[AssetManager] Parallel loading completed in ${loadTime.toFixed(0)}ms (${CONCURRENCY_LIMIT} concurrent)`) + debugAssets.log( + `[AssetManager] Parallel loading completed in ${loadTime.toFixed(0)}ms (${CONCURRENCY_LIMIT} concurrent)` + ); // Log LOD summary let lodSummary = '[AssetManager] LOD Summary: '; @@ -1939,7 +2111,9 @@ export class AssetManager { // Notify all listeners that models have been loaded if (loadedCount > 0) { - debugAssets.log(`[AssetManager] Notifying ${onModelsLoadedCallbacks.length} listeners to refresh meshes`); + debugAssets.log( + `[AssetManager] Notifying ${onModelsLoadedCallbacks.length} listeners to refresh meshes` + ); for (const callback of onModelsLoadedCallbacks) { try { callback(); @@ -2023,11 +2197,7 @@ export class ProceduralGenerator { const height = 1 + Math.random() * 1.5; const geo = new THREE.ConeGeometry(0.3 + Math.random() * 0.2, height, 6); const mesh = new THREE.Mesh(geo, Materials.resources.mineral); - mesh.position.set( - (Math.random() - 0.5) * 1.5, - height / 2, - (Math.random() - 0.5) * 1.5 - ); + mesh.position.set((Math.random() - 0.5) * 1.5, height / 2, (Math.random() - 0.5) * 1.5); mesh.rotation.set( (Math.random() - 0.5) * 0.3, Math.random() * Math.PI * 2, @@ -2732,11 +2902,7 @@ export class ProceduralGenerator { const lightGeo = new THREE.SphereGeometry(0.15, 8, 8); const lightMat = new THREE.MeshBasicMaterial({ color: 0x40ff40 }); const light = new THREE.Mesh(lightGeo, lightMat); - light.position.set( - (i % 2 === 0 ? -1.5 : 1.5), - 2.1, - (i < 2 ? -1.5 : 1.5) - ); + light.position.set(i % 2 === 0 ? -1.5 : 1.5, 2.1, i < 2 ? -1.5 : 1.5); group.add(light); } @@ -2826,7 +2992,7 @@ export class ProceduralGenerator { const launcherGeo = new THREE.CylinderGeometry(0.15, 0.15, 1, 8); const launcher = new THREE.Mesh(launcherGeo, Materials.dominion.accent); launcher.userData.isAccent = true; - launcher.position.set(0.2, 1.7, (i === 0 ? 0.3 : -0.3)); + launcher.position.set(0.2, 1.7, i === 0 ? 0.3 : -0.3); launcher.rotation.x = Math.PI / 6; launcher.castShadow = true; group.add(launcher); diff --git a/src/rendering/BuildingRenderer.ts b/src/rendering/BuildingRenderer.ts index 5ce2cb9d..672df176 100644 --- a/src/rendering/BuildingRenderer.ts +++ b/src/rendering/BuildingRenderer.ts @@ -261,6 +261,10 @@ export class BuildingRenderer { frameQueued: number; }> = []; + // LOD hysteresis: Track current LOD per entity to prevent flickering + // Key = entityId, Value = current LOD level + private entityCurrentLOD: Map = new Map(); + // Fallback elevation heights when terrain isn't available private static readonly ELEVATION_HEIGHTS = BUILDING_RENDERER.ELEVATION_HEIGHTS; @@ -704,17 +708,28 @@ export class BuildingRenderer { // PERFORMANCE: Try to use instanced rendering for completed static buildings if (this.canUseInstancing(building, health, selectable)) { - // Calculate LOD level based on distance from camera + // Calculate LOD level based on distance from camera with hysteresis let lodLevel: LODLevel = 0; const settings = useUIStore.getState().graphicsSettings; if (settings.lodEnabled && this.camera) { const dx = transform.x - this.camera.position.x; const dz = transform.y - this.camera.position.z; const distanceToCamera = Math.sqrt(dx * dx + dz * dz); - lodLevel = AssetManager.getBestLODForDistance(building.buildingId, distanceToCamera, { - LOD0_MAX: settings.lodDistance0, - LOD1_MAX: settings.lodDistance1, - }); + + // Get current LOD for hysteresis (null on first frame) + const currentLOD = this.entityCurrentLOD.get(entity.id) ?? null; + + // Select LOD with hysteresis to prevent flickering at distance boundaries + lodLevel = AssetManager.getBestLODWithHysteresis( + building.buildingId, + distanceToCamera, + currentLOD, + { LOD0_MAX: settings.lodDistance0, LOD1_MAX: settings.lodDistance1 }, + settings.lodHysteresis ?? 0.1 + ); + + // Store new LOD for next frame's hysteresis calculation + this.entityCurrentLOD.set(entity.id, lodLevel); } const group = this.getOrCreateInstancedGroup(building.buildingId, ownerId, lodLevel); @@ -1214,6 +1229,8 @@ export class BuildingRenderer { meshData.clippingPlane = null; this.disposeGroup(meshData.group); this.buildingMeshes.delete(entityId); + // Clean up LOD hysteresis tracking + this.entityCurrentLOD.delete(entityId); } } } @@ -2734,6 +2751,9 @@ export class BuildingRenderer { scheduleGeometryDisposal(group.mesh.geometry, group.mesh.material); } this.instancedGroups.clear(); + + // Clear LOD hysteresis tracking + this.entityCurrentLOD.clear(); } /** diff --git a/src/rendering/UnitRenderer.ts b/src/rendering/UnitRenderer.ts index 71a2a051..5cb5670f 100644 --- a/src/rendering/UnitRenderer.ts +++ b/src/rendering/UnitRenderer.ts @@ -11,7 +11,12 @@ import { Terrain } from './Terrain'; import { getPlayerColor, isSpectatorMode } from '@/store/gameSetupStore'; import { useUIStore } from '@/store/uiStore'; import { debugAnimation, debugAssets, debugMesh, debugPerformance } from '@/utils/debugLogger'; -import { setupInstancedVelocity, swapInstanceMatrices, commitInstanceMatrices, disposeInstancedVelocity } from './tsl/InstancedVelocity'; +import { + setupInstancedVelocity, + swapInstanceMatrices, + commitInstanceMatrices, + disposeInstancedVelocity, +} from './tsl/InstancedVelocity'; import { UNIT_RENDERER, UNIT_SELECTION_RING, @@ -68,7 +73,11 @@ function cloneGeometryForGPU(source: THREE.BufferGeometry): THREE.BufferGeometry if (source.index) { const srcIndex = source.index; const newIndexArray = srcIndex.array.slice(0); - const newIndex = new THREE.BufferAttribute(newIndexArray, srcIndex.itemSize, srcIndex.normalized); + const newIndex = new THREE.BufferAttribute( + newIndexArray, + srcIndex.itemSize, + srcIndex.normalized + ); newIndex.needsUpdate = true; cloned.setIndex(newIndex); } @@ -77,7 +86,7 @@ function cloneGeometryForGPU(source: THREE.BufferGeometry): THREE.BufferGeometry if (source.morphAttributes) { for (const name of Object.keys(source.morphAttributes)) { const srcMorphArray = source.morphAttributes[name]; - cloned.morphAttributes[name] = srcMorphArray.map(srcAttr => { + cloned.morphAttributes[name] = srcMorphArray.map((srcAttr) => { const newArray = srcAttr.array.slice(0); const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); newAttr.needsUpdate = true; @@ -148,9 +157,9 @@ interface AnimatedUnitMesh { // Per-unit overlay data - now tracks instance indices instead of individual meshes // Selection rings and team markers use instanced rendering for ~150 fewer draw calls interface UnitOverlay { - healthBar: THREE.Group; // Health bars kept individual due to dynamic width + healthBar: THREE.Group; // Health bars kept individual due to dynamic width lastHealth: number; - playerId: string; // Track player for team marker color grouping + playerId: string; // Track player for team marker color grouping // PERF: Cached terrain height to avoid recalculation every frame cachedTerrainHeight: number; lastX: number; @@ -160,7 +169,7 @@ interface UnitOverlay { // Instanced overlay group for team markers interface InstancedOverlayGroup { mesh: THREE.InstancedMesh; - entityIds: number[]; // Maps instance index to entity ID + entityIds: number[]; // Maps instance index to entity ID positions: THREE.Vector3[]; // Cached positions for matrix updates maxInstances: number; } @@ -227,7 +236,9 @@ export class UnitRenderer { private camera: THREE.Camera | null = null; // PERF: Shared smooth rotation interpolation - private readonly smoothRotation: SmoothRotation = new SmoothRotation(UNIT_RENDERER.ROTATION_SMOOTH_FACTOR); + private readonly smoothRotation: SmoothRotation = new SmoothRotation( + UNIT_RENDERER.ROTATION_SMOOTH_FACTOR + ); // PERF: Cached sorted entity list to avoid spread+sort every frame private cachedSortedEntities: IEntity[] = []; @@ -244,7 +255,16 @@ export class UnitRenderer { // Track unit type geometries registered with GPU indirect renderer private gpuRegisteredUnitTypes: Set = new Set(); - constructor(scene: THREE.Scene, world: IWorldProvider, visionSystem?: VisionSystem, terrain?: Terrain) { + // LOD hysteresis: Track current LOD per entity to prevent flickering + // Key = entityId, Value = current LOD level + private entityCurrentLOD: Map = new Map(); + + constructor( + scene: THREE.Scene, + world: IWorldProvider, + visionSystem?: VisionSystem, + terrain?: Terrain + ) { this.scene = scene; this.world = world; this.visionSystem = visionSystem ?? null; @@ -277,7 +297,7 @@ export class UnitRenderer { }); // Load custom GLB models (async, runs in background) - AssetManager.loadCustomModels().catch(err => { + AssetManager.loadCustomModels().catch((err) => { debugAssets.warn('[UnitRenderer] Error loading custom models:', err); }); } @@ -385,7 +405,9 @@ export class UnitRenderer { debugPerformance.log('[UnitRenderer] GPU indirect renderer initialized successfully'); debugPerformance.log('[UnitRenderer] GPU-driven rendering pipeline READY:'); debugPerformance.log(' - GPU Entity Buffer: INITIALIZED'); - debugPerformance.log(' - GPU Culling Compute: ' + (this.gpuCullingInitialized ? 'READY' : 'PENDING')); + debugPerformance.log( + ' - GPU Culling Compute: ' + (this.gpuCullingInitialized ? 'READY' : 'PENDING') + ); debugPerformance.log(' - GPU Indirect Draw: ENABLED'); debugPerformance.log('[GPU Indirect Renderer] INITIALIZED - indirect draw calls enabled'); } catch (e) { @@ -397,7 +419,12 @@ export class UnitRenderer { /** * Register a unit type geometry with the GPU indirect renderer */ - private registerUnitTypeForGPU(unitType: string, lodLevel: number, geometry: THREE.BufferGeometry, material?: THREE.Material): void { + private registerUnitTypeForGPU( + unitType: string, + lodLevel: number, + geometry: THREE.BufferGeometry, + material?: THREE.Material + ): void { if (!this.gpuIndirectRenderer || !this.cullingService) return; const key = `${unitType}_${lodLevel}`; @@ -407,7 +434,9 @@ export class UnitRenderer { this.gpuIndirectRenderer.registerUnitType(typeIndex, lodLevel, geometry, material); this.gpuRegisteredUnitTypes.add(key); - debugPerformance.log(`[UnitRenderer] Registered unit type ${unitType} LOD${lodLevel} for GPU indirect rendering`); + debugPerformance.log( + `[UnitRenderer] Registered unit type ${unitType} LOD${lodLevel} for GPU indirect rendering` + ); } /** @@ -494,7 +523,8 @@ export class UnitRenderer { visibleCount: cullingStats?.visibleEntities ?? 0, totalIndirectDrawCalls: this.gpuIndirectRenderer?.getTotalVisibleCount() ?? 0, isUsingGPUCulling: cullingStats?.isUsingGPU ?? false, - gpuCullTimeMs: this.cullingService?.getCullingCompute().getGPUCullingStats().lastCullTimeMs ?? 0, + gpuCullTimeMs: + this.cullingService?.getCullingCompute().getGPUCullingStats().lastCullTimeMs ?? 0, quarantinedSlots: cullingStats?.quarantinedSlots ?? 0, }; } @@ -578,7 +608,6 @@ export class UnitRenderer { this.cullingService?.getCullingCompute().setLODConfig(lodConfig); } - /** * Smoothly interpolate rotation with proper angle wrapping. * Uses exponential smoothing for frame-rate independent smooth rotation. @@ -607,7 +636,11 @@ export class UnitRenderer { * Get or create an animated mesh for a specific unit entity. * Uses the data-driven AnimationController for state machine-based animation. */ - private getOrCreateAnimatedUnit(entityId: number, unitType: string, playerId: string): AnimatedUnitMesh { + private getOrCreateAnimatedUnit( + entityId: number, + unitType: string, + playerId: string + ): AnimatedUnitMesh { let animUnit = this.animatedUnits.get(entityId); if (!animUnit) { @@ -660,7 +693,7 @@ export class UnitRenderer { // Log animation state for debugging debugAnimation.log( `[UnitRenderer] ${unitType}: Created AnimationController with ${clips.length} clips, ` + - `${animConfig.layers.length} layers, initial state: ${controller.getCurrentState()}` + `${animConfig.layers.length} layers, initial state: ${controller.getCurrentState()}` ); animUnit = { @@ -688,7 +721,15 @@ export class UnitRenderer { const tracksToRemove: number[] = []; // Common root bone names in character rigs (case-insensitive matching) - const rootBoneNames = ['root', 'rootbone', 'root_bone', 'armature', 'hips', 'mixamorig:hips', 'pelvis']; + const rootBoneNames = [ + 'root', + 'rootbone', + 'root_bone', + 'armature', + 'hips', + 'mixamorig:hips', + 'pelvis', + ]; for (let i = 0; i < clip.tracks.length; i++) { const track = clip.tracks[i]; @@ -702,7 +743,9 @@ export class UnitRenderer { const boneName = trackName.split('.')[0].toLowerCase(); // Only remove if it's explicitly a root bone - const isRootBone = rootBoneNames.some(root => boneName === root || boneName === `mixamorig:${root}`); + const isRootBone = rootBoneNames.some( + (root) => boneName === root || boneName === `mixamorig:${root}` + ); if (isRootBone) { tracksToRemove.push(i); @@ -712,14 +755,20 @@ export class UnitRenderer { // Remove tracks in reverse order to maintain correct indices for (let i = tracksToRemove.length - 1; i >= 0; i--) { const removedTrack = clip.tracks.splice(tracksToRemove[i], 1)[0]; - debugAnimation.log(`[UnitRenderer] Removed root motion track: ${removedTrack.name} from ${clip.name}`); + debugAnimation.log( + `[UnitRenderer] Removed root motion track: ${removedTrack.name} from ${clip.name}` + ); } } /** * Get or create an instanced mesh group for a unit type + player combo at a specific LOD level */ - private getOrCreateInstancedGroup(unitType: string, playerId: string, lodLevel: LODLevel = 0): InstancedUnitGroup { + private getOrCreateInstancedGroup( + unitType: string, + playerId: string, + lodLevel: LODLevel = 0 + ): InstancedUnitGroup { const key = `${unitType}_${playerId}_LOD${lodLevel}`; let group = this.instancedGroups.get(key); @@ -728,8 +777,9 @@ export class UnitRenderer { // Get the base mesh from AssetManager at the requested LOD level // Falls back to next best LOD if requested level isn't available - const baseMesh = AssetManager.getModelAtLOD(unitType, lodLevel) - ?? AssetManager.getUnitMesh(unitType, playerColor); + const baseMesh = + AssetManager.getModelAtLOD(unitType, lodLevel) ?? + AssetManager.getUnitMesh(unitType, playerColor); // Update world matrices to get accurate world positions baseMesh.updateMatrixWorld(true); @@ -769,11 +819,7 @@ export class UnitRenderer { } // Create instanced mesh - const instancedMesh = new THREE.InstancedMesh( - geometry, - material!, - MAX_INSTANCES_PER_TYPE - ); + const instancedMesh = new THREE.InstancedMesh(geometry, material!, MAX_INSTANCES_PER_TYPE); instancedMesh.count = 0; // Start with no visible instances instancedMesh.castShadow = true; instancedMesh.receiveShadow = true; @@ -802,7 +848,9 @@ export class UnitRenderer { // Log rotation as Euler for easier debugging const rotEuler = new THREE.Euler().setFromQuaternion(meshWorldRotation); - debugAssets.log(`[UnitRenderer] Created instanced group for ${unitType} LOD${lodLevel}: yOffset=${meshWorldY.toFixed(3)}, rotation=(${(rotEuler.x * 180/Math.PI).toFixed(1)}°, ${(rotEuler.y * 180/Math.PI).toFixed(1)}°, ${(rotEuler.z * 180/Math.PI).toFixed(1)}°), scale=${meshWorldScale.toFixed(3)}`); + debugAssets.log( + `[UnitRenderer] Created instanced group for ${unitType} LOD${lodLevel}: yOffset=${meshWorldY.toFixed(3)}, rotation=(${((rotEuler.x * 180) / Math.PI).toFixed(1)}°, ${((rotEuler.y * 180) / Math.PI).toFixed(1)}°, ${((rotEuler.z * 180) / Math.PI).toFixed(1)}°), scale=${meshWorldScale.toFixed(3)}` + ); this.instancedGroups.set(key, group); @@ -858,7 +906,11 @@ export class UnitRenderer { opacity: UNIT_TEAM_MARKER.OPACITY, side: THREE.DoubleSide, }); - const mesh = new THREE.InstancedMesh(this.teamMarkerGeometry, material, MAX_OVERLAY_INSTANCES); + const mesh = new THREE.InstancedMesh( + this.teamMarkerGeometry, + material, + MAX_OVERLAY_INSTANCES + ); mesh.count = 0; mesh.frustumCulled = false; // NOTE: Don't set mesh.rotation here - rotation is applied per-instance to avoid @@ -894,7 +946,7 @@ export class UnitRenderer { return overlay.cachedTerrainHeight; } - public update(deltaTime: number = 1/60): void { + public update(deltaTime: number = 1 / 60): void { const updateStart = performance.now(); this.frameCount++; @@ -910,7 +962,6 @@ export class UnitRenderer { // PERF: Only re-sort when entity count changes (add/remove) to avoid O(n log n) every frame const rawEntities = this.world.getEntitiesWith('Transform', 'Unit'); - if (rawEntities.length !== this.cachedEntityCount) { // Rebuild cache - entity count changed (add/remove occurred) this.cachedSortedEntities.length = 0; @@ -957,18 +1008,20 @@ export class UnitRenderer { const cullingMode = stats.isUsingGPUCulling ? 'GPU' : 'CPU'; debugPerformance.log( `[Unified Culling] ${stats.isUsingGPUCulling ? 'GPU ACTIVE' : 'CPU FALLBACK'} - ` + - `Entities: ${stats.managedEntities}/${stats.visibleCount} total/visible, ` + - `UnitTypes: ${stats.registeredUnitTypes}, ` + - `Culling: ${cullingMode} (${stats.gpuCullTimeMs.toFixed(2)}ms), ` + - `Indirect: ${stats.indirectReady ? 'ON' : 'OFF'}, ` + - `Quarantined: ${stats.quarantinedSlots}` + `Entities: ${stats.managedEntities}/${stats.visibleCount} total/visible, ` + + `UnitTypes: ${stats.registeredUnitTypes}, ` + + `Culling: ${cullingMode} (${stats.gpuCullTimeMs.toFixed(2)}ms), ` + + `Indirect: ${stats.indirectReady ? 'ON' : 'OFF'}, ` + + `Quarantined: ${stats.quarantinedSlots}` ); // Validate mesh geometries periodically to detect any disposed buffers if (this.gpuIndirectRenderer) { const invalidCount = this.gpuIndirectRenderer.validateAllMeshes(); if (invalidCount > 0) { - debugPerformance.warn(`[GPU Rendering] Found ${invalidCount} invalid meshes - hiding to prevent crashes`); + debugPerformance.warn( + `[GPU Rendering] Found ${invalidCount} invalid meshes - hiding to prevent crashes` + ); } } } @@ -1079,7 +1132,7 @@ export class UnitRenderer { animUnit.controller.update(deltaTime * animSpeedMultiplier); } else { // Use instanced rendering for non-animated units - // Calculate distance from camera for LOD selection + // Calculate distance from camera for LOD selection with hysteresis let lodLevel: LODLevel = 0; const settings = useUIStore.getState().graphicsSettings; if (settings.lodEnabled && this.camera) { @@ -1087,20 +1140,20 @@ export class UnitRenderer { const dz = transform.y - this.camera.position.z; // transform.y is world Z const distanceToCamera = Math.sqrt(dx * dx + dz * dz); - // Select LOD level based on distance thresholds - if (distanceToCamera <= settings.lodDistance0) { - lodLevel = 0; - } else if (distanceToCamera <= settings.lodDistance1) { - lodLevel = 1; - } else { - lodLevel = 2; - } + // Get current LOD for hysteresis (null on first frame) + const currentLOD = this.entityCurrentLOD.get(entity.id) ?? null; + + // Select LOD with hysteresis to prevent flickering at distance boundaries + lodLevel = AssetManager.getBestLODWithHysteresis( + unit.unitId, + distanceToCamera, + currentLOD, + { LOD0_MAX: settings.lodDistance0, LOD1_MAX: settings.lodDistance1 }, + settings.lodHysteresis ?? 0.1 + ); - // Fall back to best available LOD if requested level isn't loaded - lodLevel = AssetManager.getBestLODForDistance(unit.unitId, distanceToCamera, { - LOD0_MAX: settings.lodDistance0, - LOD1_MAX: settings.lodDistance1, - }); + // Store new LOD for next frame's hysteresis calculation + this.entityCurrentLOD.set(entity.id, lodLevel); } const group = this.getOrCreateInstancedGroup(unit.unitId, ownerId, lodLevel); @@ -1114,15 +1167,25 @@ export class UnitRenderer { // baseRotation is the model's full base rotation (X, Y, Z from assets.json config). // We multiply: unit facing (Y rotation) × base rotation to get final orientation. // modelScale is the normalization scale from AssetManager (to achieve target height). - this.transformUtils.tempPosition.set(transform.x, unitHeight + group.yOffset, transform.y); + this.transformUtils.tempPosition.set( + transform.x, + unitHeight + group.yOffset, + transform.y + ); // Create quaternion from unit's facing direction (Y rotation only) with smooth interpolation // smoothRotation already calculated above for GPU buffer this.transformUtils.tempEuler.set(0, smoothRotation, 0); this.transformUtils.tempFacingQuat.setFromEuler(this.transformUtils.tempEuler); // Combine: facing rotation × base rotation (order matters for proper orientation) - this.transformUtils.tempQuaternion.copy(this.transformUtils.tempFacingQuat).multiply(group.baseRotation); + this.transformUtils.tempQuaternion + .copy(this.transformUtils.tempFacingQuat) + .multiply(group.baseRotation); this.transformUtils.tempScale.setScalar(group.modelScale); - this.transformUtils.tempMatrix.compose(this.transformUtils.tempPosition, this.transformUtils.tempQuaternion, this.transformUtils.tempScale); + this.transformUtils.tempMatrix.compose( + this.transformUtils.tempPosition, + this.transformUtils.tempQuaternion, + this.transformUtils.tempScale + ); group.mesh.setMatrixAt(instanceIndex, this.transformUtils.tempMatrix); group.mesh.count++; @@ -1155,7 +1218,11 @@ export class UnitRenderer { overlay.healthBar.visible = healthPercent < 1; if (overlay.healthBar.visible) { // Position health bar above the unit model - overlay.healthBar.position.set(transform.x, unitHeight + modelHeight + UNIT_HEALTH_BAR.Y_OFFSET, transform.y); + overlay.healthBar.position.set( + transform.x, + unitHeight + modelHeight + UNIT_HEALTH_BAR.Y_OFFSET, + transform.y + ); // Only update health bar visuals if health changed if (Math.abs(overlay.lastHealth - healthPercent) > 0.01) { this.healthBarRenderer.updateHealthBar(overlay.healthBar, health); @@ -1172,7 +1239,12 @@ export class UnitRenderer { const idx = group.mesh.count; group.entityIds[idx] = entityId; // Team markers are flat on ground - use shared transform utils - this.transformUtils.composeGroundOverlay(data.position.x, data.position.y, data.position.z, 1); + this.transformUtils.composeGroundOverlay( + data.position.x, + data.position.y, + data.position.z, + 1 + ); group.mesh.setMatrixAt(idx, this.transformUtils.tempMatrix); group.mesh.count++; } @@ -1210,7 +1282,9 @@ export class UnitRenderer { const materials = group.mesh.material; this.queueGeometryForDisposal(group.mesh.geometry, materials); this.instancedGroups.delete(key); - debugPerformance.log(`[UnitRenderer] Cleaned up inactive mesh: ${key} (inactive for ${framesInactive} frames)`); + debugPerformance.log( + `[UnitRenderer] Cleaned up inactive mesh: ${key} (inactive for ${framesInactive} frames)` + ); } } @@ -1229,6 +1303,8 @@ export class UnitRenderer { for (const entityId of this.smoothRotation.keys()) { if (!this.entityIdTracker.has(entityId)) { this.smoothRotation.remove(entityId); + // Clean up LOD hysteresis tracking + this.entityCurrentLOD.delete(entityId); // Clean up culling service registration this.removeEntityFromCulling(entityId); } @@ -1256,7 +1332,9 @@ export class UnitRenderer { const updateElapsed = performance.now() - updateStart; if (updateElapsed > 16) { - debugPerformance.warn(`[UnitRenderer] UPDATE: ${entities.length} entities took ${updateElapsed.toFixed(1)}ms`); + debugPerformance.warn( + `[UnitRenderer] UPDATE: ${entities.length} entities took ${updateElapsed.toFixed(1)}ms` + ); } } @@ -1396,9 +1474,10 @@ export class UnitRenderer { } this.teamMarkerGroups.clear(); - // Clear rotation tracking + // Clear rotation and LOD tracking this.smoothRotation.clear(); this.visibleUnits.clear(); + this.entityCurrentLOD.clear(); // Dispose GPU-driven rendering resources this.cullingService?.dispose(); diff --git a/src/rendering/compute/UnifiedCullingCompute.ts b/src/rendering/compute/UnifiedCullingCompute.ts index 371eca04..bc845dcd 100644 --- a/src/rendering/compute/UnifiedCullingCompute.ts +++ b/src/rendering/compute/UnifiedCullingCompute.ts @@ -32,7 +32,12 @@ import { import { StorageInstancedBufferAttribute } from 'three/webgpu'; -import { GPUEntityBuffer, EntitySlot, EntityCategory, createIndirectArgsBuffer } from './GPUEntityBuffer'; +import { + GPUEntityBuffer, + EntitySlot, + EntityCategory, + createIndirectArgsBuffer, +} from './GPUEntityBuffer'; import { debugShaders } from '@/utils/debugLogger'; import { DEFAULT_LOD_DISTANCES } from '@/assets/AssetManager'; @@ -41,6 +46,7 @@ export interface LODConfig { LOD0_MAX: number; // Distance for highest detail LOD1_MAX: number; // Distance for medium detail // Beyond LOD1_MAX = LOD2 (lowest detail) + hysteresis?: number; // Hysteresis margin as fraction (0.1 = 10%) to prevent flickering } const DEFAULT_LOD_CONFIG: LODConfig = { @@ -80,6 +86,9 @@ export class UnifiedCullingCompute { private cachedVisibleSlots: EntitySlot[] = []; private cachedLODAssignments: Map = new Map(); + // LOD hysteresis: stores previous LOD per entity to prevent flickering + private previousLODAssignments: Map = new Map(); + // GPU compute structures private gpuComputeAvailable = false; private useCPUFallback = true; @@ -154,7 +163,11 @@ export class UnifiedCullingCompute { // Create storage buffers for input data this.transformStorageAttribute = new StorageInstancedBufferAttribute(transformData, 16); - this.transformStorageBuffer = storage(this.transformStorageAttribute, 'mat4', MAX_GPU_ENTITIES); + this.transformStorageBuffer = storage( + this.transformStorageAttribute, + 'mat4', + MAX_GPU_ENTITIES + ); this.metadataStorageAttribute = new StorageInstancedBufferAttribute(metadataData, 4); this.metadataStorageBuffer = storage(this.metadataStorageAttribute, 'vec4', MAX_GPU_ENTITIES); @@ -269,8 +282,8 @@ export class UnifiedCullingCompute { // Read metadata: vec4(entityId, packedTypeAndCategory, playerId, boundingRadius) const metadata = metadataBuffer.element(entityIndex); const packedTypeAndCategory = int(metadata.y); - const typeIndex = packedTypeAndCategory.bitAnd(int(0xFFFF)); - const category = packedTypeAndCategory.shiftRight(int(16)).bitAnd(int(0xFFFF)); + const typeIndex = packedTypeAndCategory.bitAnd(int(0xffff)); + const category = packedTypeAndCategory.shiftRight(int(16)).bitAnd(int(0xffff)); const playerId = int(metadata.z); const radius = metadata.w; @@ -282,27 +295,39 @@ export class UnifiedCullingCompute { // Test each plane const p0 = frustumPlanes.element(int(0)); const d0 = p0.x.mul(posX).add(p0.y.mul(posY)).add(p0.z.mul(posZ)).add(p0.w); - If(d0.lessThan(radius.negate()), () => { visible.assign(0); }); + If(d0.lessThan(radius.negate()), () => { + visible.assign(0); + }); const p1 = frustumPlanes.element(int(1)); const d1 = p1.x.mul(posX).add(p1.y.mul(posY)).add(p1.z.mul(posZ)).add(p1.w); - If(d1.lessThan(radius.negate()), () => { visible.assign(0); }); + If(d1.lessThan(radius.negate()), () => { + visible.assign(0); + }); const p2 = frustumPlanes.element(int(2)); const d2 = p2.x.mul(posX).add(p2.y.mul(posY)).add(p2.z.mul(posZ)).add(p2.w); - If(d2.lessThan(radius.negate()), () => { visible.assign(0); }); + If(d2.lessThan(radius.negate()), () => { + visible.assign(0); + }); const p3 = frustumPlanes.element(int(3)); const d3 = p3.x.mul(posX).add(p3.y.mul(posY)).add(p3.z.mul(posZ)).add(p3.w); - If(d3.lessThan(radius.negate()), () => { visible.assign(0); }); + If(d3.lessThan(radius.negate()), () => { + visible.assign(0); + }); const p4 = frustumPlanes.element(int(4)); const d4 = p4.x.mul(posX).add(p4.y.mul(posY)).add(p4.z.mul(posZ)).add(p4.w); - If(d4.lessThan(radius.negate()), () => { visible.assign(0); }); + If(d4.lessThan(radius.negate()), () => { + visible.assign(0); + }); const p5 = frustumPlanes.element(int(5)); const d5 = p5.x.mul(posX).add(p5.y.mul(posY)).add(p5.z.mul(posZ)).add(p5.w); - If(d5.lessThan(radius.negate()), () => { visible.assign(0); }); + If(d5.lessThan(radius.negate()), () => { + visible.assign(0); + }); // If visible, calculate LOD and update output buffers If(visible.greaterThan(0), () => { @@ -323,7 +348,9 @@ export class UnifiedCullingCompute { }); // Calculate index into indirect args buffer - const indirectIndex = typeIndex.mul(maxLODs).mul(maxPlayers) + const indirectIndex = typeIndex + .mul(maxLODs) + .mul(maxPlayers) .add(lod.mul(maxPlayers)) .add(playerId); const instanceCountOffset = indirectIndex.mul(int(5)).add(int(1)); @@ -350,10 +377,7 @@ export class UnifiedCullingCompute { * Update frustum from camera */ updateFrustum(camera: THREE.Camera): void { - this.frustumMatrix.multiplyMatrices( - camera.projectionMatrix, - camera.matrixWorldInverse - ); + this.frustumMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); this.frustum.setFromProjectionMatrix(this.frustumMatrix); // Update GPU uniforms @@ -384,6 +408,22 @@ export class UnifiedCullingCompute { this.uLOD1MaxSq.value = config.LOD1_MAX * config.LOD1_MAX; } + /** + * Remove LOD hysteresis tracking for an entity (call when entity is unregistered) + */ + removeEntityLODTracking(entityId: number): void { + this.previousLODAssignments.delete(entityId); + this.cachedLODAssignments.delete(entityId); + } + + /** + * Clear all LOD hysteresis tracking (call on scene reset) + */ + clearLODTracking(): void { + this.previousLODAssignments.clear(); + this.cachedLODAssignments.clear(); + } + /** * Perform GPU culling * @@ -392,7 +432,12 @@ export class UnifiedCullingCompute { * 2. Cull: Tests each entity with sphere-frustum intersection */ cullGPU(entityBuffer: GPUEntityBuffer, camera: THREE.Camera): void { - if (!this.renderer || !this.cullingComputeNode || !this.resetComputeNode || this.useCPUFallback) { + if ( + !this.renderer || + !this.cullingComputeNode || + !this.resetComputeNode || + this.useCPUFallback + ) { return; } @@ -453,23 +498,67 @@ export class UnifiedCullingCompute { continue; } - // LOD selection + // LOD selection with hysteresis const dx = x - cameraPosition.x; const dy = y - cameraPosition.y; const dz = z - cameraPosition.z; - const distanceSq = dx * dx + dy * dy + dz * dz; + const distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Get previous LOD for hysteresis (undefined on first frame) + const previousLOD = this.previousLODAssignments.get(slot.entityId); + const hysteresis = this.lodConfig.hysteresis ?? 0.1; let lod: number; - if (distanceSq <= this.lodConfig.LOD0_MAX * this.lodConfig.LOD0_MAX) { - lod = 0; - } else if (distanceSq <= this.lodConfig.LOD1_MAX * this.lodConfig.LOD1_MAX) { - lod = 1; + + if (previousLOD === undefined) { + // First time seeing this entity, use standard thresholds + if (distance <= this.lodConfig.LOD0_MAX) { + lod = 0; + } else if (distance <= this.lodConfig.LOD1_MAX) { + lod = 1; + } else { + lod = 2; + } } else { - lod = 2; + // Apply hysteresis based on current LOD + const lod0UpThreshold = this.lodConfig.LOD0_MAX * (1 + hysteresis); + const lod0DownThreshold = this.lodConfig.LOD0_MAX * (1 - hysteresis); + const lod1UpThreshold = this.lodConfig.LOD1_MAX * (1 + hysteresis); + const lod1DownThreshold = this.lodConfig.LOD1_MAX * (1 - hysteresis); + + if (previousLOD === 0) { + // Currently at LOD0, only switch up if past threshold + margin + if (distance > lod1UpThreshold) { + lod = 2; + } else if (distance > lod0UpThreshold) { + lod = 1; + } else { + lod = 0; + } + } else if (previousLOD === 1) { + // Currently at LOD1 + if (distance > lod1UpThreshold) { + lod = 2; + } else if (distance < lod0DownThreshold) { + lod = 0; + } else { + lod = 1; + } + } else { + // Currently at LOD2, only switch down if past threshold - margin + if (distance < lod0DownThreshold) { + lod = 0; + } else if (distance < lod1DownThreshold) { + lod = 1; + } else { + lod = 2; + } + } } this.cachedVisibleSlots.push(slot); this.cachedLODAssignments.set(slot.entityId, lod); + this.previousLODAssignments.set(slot.entityId, lod); } return { @@ -630,6 +719,7 @@ export class UnifiedCullingCompute { dispose(): void { this.cachedVisibleSlots.length = 0; this.cachedLODAssignments.clear(); + this.previousLODAssignments.clear(); this.resetComputeNode = null; this.cullingComputeNode = null; this.transformStorageAttribute = null; diff --git a/src/rendering/services/CullingService.ts b/src/rendering/services/CullingService.ts index 12d7ca2a..3b118507 100644 --- a/src/rendering/services/CullingService.ts +++ b/src/rendering/services/CullingService.ts @@ -155,6 +155,8 @@ export class CullingService { this.entityBuffer.freeSlot(entityId); this.visibilityMap.delete(entityId); this.lodMap.delete(entityId); + // Clean up LOD hysteresis tracking in compute + this.cullingCompute.removeEntityLODTracking(entityId); } /** @@ -363,9 +365,10 @@ export class CullingService { * Get all visible entities of a category */ *getVisibleEntities(category?: EntityCategory): IterableIterator { - const slots = category !== undefined - ? this.entityBuffer.getSlotsByCategory(category) - : this.entityBuffer.getAllocatedSlots(); + const slots = + category !== undefined + ? this.entityBuffer.getSlotsByCategory(category) + : this.entityBuffer.getAllocatedSlots(); for (const slot of slots) { if (this.visibilityMap.get(slot.entityId)) { diff --git a/src/store/uiStore.ts b/src/store/uiStore.ts index 21d984fd..651d520e 100644 --- a/src/store/uiStore.ts +++ b/src/store/uiStore.ts @@ -205,6 +205,7 @@ export interface GraphicsSettings { lodDistance0: number; // Distance threshold for LOD0 (highest detail) lodDistance1: number; // Distance threshold for LOD1 (medium detail) // Beyond lodDistance1, LOD2 (lowest detail) is used + lodHysteresis: number; // Hysteresis margin (0.1 = 10%) to prevent LOD flickering at boundaries // Water waterEnabled: boolean; @@ -662,6 +663,7 @@ export const useUIStore = create((set, get) => ({ lodEnabled: true, lodDistance0: 50, // Use LOD0 (highest detail) within 50 units from camera lodDistance1: 120, // Use LOD1 (medium detail) between 50-120 units, LOD2 beyond + lodHysteresis: 0.1, // 10% hysteresis to prevent LOD flickering // Water waterEnabled: true,