Tree Dissolve Transparency
Added: March 27, 2026 (PR #1101)Location:
packages/shared/src/systems/shared/world/DissolveAnimation.ts
Overview
The tree dissolve transparency system provides visual feedback for resource depletion and respawn. Depleted trees become ~70% transparent instantly using screen-door dithering, then animate back to full opacity over 0.3 seconds on respawn.Key Features
- Instant Depletion: Trees become transparent immediately when depleted
- Smooth Respawn: 0.3-second fade-in animation when tree respawns
- Opaque Rendering: Uses screen-door dithering to stay in opaque render pass (no transparency sorting overhead)
- LOD Preservation: Dissolve state carries over during LOD transitions to prevent visual pops
- Dual Backend: Supports both InstancedMesh and BatchedMesh rendering paths
Architecture
DissolveAnimation Module
File:packages/shared/src/systems/shared/world/DissolveAnimation.ts
Shared state machine used by both GLBTreeInstancer and GLBTreeBatchedInstancer to keep dissolve logic synchronized.
DissolveAnim Interface
Core Functions
startDissolve(anims, entityId, direction, instant, applyFn)
Start or instantly apply a dissolve animation.
Parameters:
anims: Map<string, DissolveAnim>- Animation map to manageentityId: string- Entity to dissolvedirection: 1 | -1- 1 for depletion, -1 for respawninstant: boolean- If true, skip animation and apply immediatelyapplyFn: (entityId: string, value: number) => void- Callback to write dissolve value to rendering backend
- If
instant=true, applies target value immediately and removes from animation map - If
instant=false, starts animation from current progress (or 0/DISSOLVE_MAX based on direction) - If animation already in progress, continues from current progress to avoid visual pop
tickDissolveAnims(anims, deltaTime, applyFn)
Advance all active dissolve animations by deltaTime and apply values.
Parameters:
anims: Map<string, DissolveAnim>- Animation map to tickdeltaTime: number- Time elapsed since last tick (seconds)applyFn: (entityId: string, value: number) => void- Callback to write dissolve value to rendering backend
- Advances each animation’s progress by
(direction * deltaTime) / DISSOLVE_DURATION - Clamps progress to
[0, DISSOLVE_MAX]range - Removes completed animations from map
- Uses module-level
_completedarray to avoid per-frame allocation
Configuration
File:packages/shared/src/systems/shared/world/GPUMaterials.ts
Implementation Details
Encoding Strategy
InstancedMesh
Uses dedicatedinstanceDissolve float attribute per instance:
BatchedMesh
Encodes dissolve in the blue channel of per-instance batch colors:Shader Integration
File:packages/shared/src/systems/shared/world/GPUMaterials.ts
The dissolve effect uses Bayer 4×4 screen-door dithering in the alphaTestNode:
- Dithering uses Bayer 4×4 pattern (same as distance fade)
- Fragments are discarded via
alphaTestNode, not alpha blending - Trees stay in opaque render pass with full early-Z benefits
- No transparency sorting overhead
LOD Transition Handling
Dissolve state is preserved when trees transition between LOD levels:Initial Dissolve State
Trees that spawn already depleted have dissolve applied atomically during instance creation:API Reference
TreeGLBVisualStrategy
onDepleted(ctx: ResourceVisualContext): Promise<boolean>
Called when a tree is depleted (cut down).
Behavior:
- Starts instant dissolve animation (direction=1, instant=true)
- Sets
proxy.userData.depleted = true - Sets
proxy.userData.interactable = false - Always returns
true(dissolve handles all depletion visuals)
onRespawn(ctx: ResourceVisualContext): Promise<void>
Called when a tree respawns.
Behavior:
- Starts reverse dissolve animation (direction=-1, instant=false)
- Sets
proxy.userData.depleted = false - Sets
proxy.userData.interactable = true - Animation runs over DISSOLVE_DURATION seconds
update(_ctx: ResourceVisualContext, deltaTime: number): void
Called every frame to tick dissolve animations.
Behavior:
- Calls
updateGLBTreeInstancer(deltaTime)andupdateGLBTreeBatchedInstancer(deltaTime) - Both instancers tick their dissolve animations via
tickDissolveAnims() - Dissolve ticks run AFTER LOD transitions to ensure entities are in correct pools
Performance Characteristics
Memory
- Zero-allocation tick loop: Reuses module-level
_completedarray - Shared geometry: Textures and base geometry shared across instances
- Minimal state: Only active animations stored in map (~0-20 entries typical)
CPU
- O(active animations): Tick cost scales with animating trees, not total trees
- Early-out optimization: Skips tick when animation map is empty
- Batched GPU uploads:
dissolveDirtyflag batches attribute updates per pool
GPU
- Opaque pass: Trees stay in opaque render pass (no transparency sorting)
- Early-Z rejection: Screen-door dithering preserves depth testing benefits
- No overdraw penalty: Discarded fragments don’t write to framebuffer
Troubleshooting
Trees not dissolving on depletion
Symptoms: Trees remain fully visible when depleted. Causes:onDepleted()not being called byResourceEntity- Dissolve animation map not being ticked
- GPU attribute not being uploaded
Trees flashing during LOD transitions
Symptoms: Trees briefly appear fully visible when switching LOD levels. Cause: Dissolve state not being preserved during LOD swap. Fix: VerifywasDissolve is being read from old pool and passed to addToPool() in new pool.
Dissolve animation too fast/slow
Symptoms: Animation completes in wrong duration. Cause:deltaTime not being passed correctly to tickDissolveAnims().
Fix: Verify update() receives real deltaTime from game loop, not hardcoded 1/60.
Banding/stepping in dissolve animation
Symptoms: Dissolve appears to step in discrete increments rather than smooth fade. Cause: Uint8 color buffer precision insufficient for long animation durations. Fix: ReduceDISSOLVE_DURATION or switch BatchedMesh to Float32 color buffer.
Related Systems
- ResourceSystem (
packages/shared/src/systems/shared/entities/ResourceSystem.ts) - CallsonDepleted()andonRespawn() - GLBTreeInstancer (
packages/shared/src/systems/shared/world/GLBTreeInstancer.ts) - InstancedMesh rendering backend - GLBTreeBatchedInstancer (
packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts) - BatchedMesh rendering backend - GPUMaterials (
packages/shared/src/systems/shared/world/GPUMaterials.ts) - TSL shader integration
Code Examples
Basic Usage
Integration with Tree Instancer
Shader Implementation
Material Creation
Dithering Logic
The shader uses a Bayer 4×4 dithering pattern to discard fragments:Performance Optimization
Batched GPU Uploads
Instead of markingneedsUpdate per-entity, the system uses a dissolveDirty flag per LOD pool:
Zero-Allocation Tick Loop
The_completed array is reused across ticks to avoid per-frame allocation:
Testing
Unit Tests
No dedicated unit tests forDissolveAnimation.ts (pure state machine logic could be tested in isolation).
Integration Tests
Dissolve behavior is tested indirectly through:packages/shared/src/systems/shared/entities/__tests__/ResourceSystem.integration.test.ts- E2E tests that deplete and respawn trees
Visual Verification
Manual testing checklist:- ✅ Deplete a tree → instant transparency
- ✅ Wait for respawn → smooth fade-in over 0.3s
- ✅ Trigger LOD transition during dissolve → no visual pop
- ✅ Deplete multiple trees simultaneously → all dissolve correctly
- ✅ Verify trees stay in opaque pass (check render stats)
Future Enhancements
Potential improvements:- Configurable Dither Patterns: Support different dithering patterns (8×8, blue noise)
- Per-Tree Dissolve Speed: Allow manifest to override
DISSOLVE_DURATIONper tree type - Dissolve Direction Control: Support custom dissolve directions (trunk→canopy, canopy→trunk)
- Dissolve Events: Emit events when dissolve starts/completes for audio/particle effects
- Dissolve Curves: Support easing functions (ease-in, ease-out) instead of linear
Related Documentation
- Tree Collision Proxy - LOD2 geometry for collision detection
- Resource Respawn System - Tick-based respawn mechanics
- GPU Materials - TSL shader implementation
- Performance March 2026 - Server performance overhaul