Skip to main content

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 manage
  • entityId: string - Entity to dissolve
  • direction: 1 | -1 - 1 for depletion, -1 for respawn
  • instant: boolean - If true, skip animation and apply immediately
  • applyFn: (entityId: string, value: number) => void - Callback to write dissolve value to rendering backend
Behavior:
  • 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
Example:
tickDissolveAnims(anims, deltaTime, applyFn)
Advance all active dissolve animations by deltaTime and apply values. Parameters:
  • anims: Map<string, DissolveAnim> - Animation map to tick
  • deltaTime: number - Time elapsed since last tick (seconds)
  • applyFn: (entityId: string, value: number) => void - Callback to write dissolve value to rendering backend
Behavior:
  • 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 _completed array to avoid per-frame allocation
Example:

Configuration

File: packages/shared/src/systems/shared/world/GPUMaterials.ts

Implementation Details

Encoding Strategy

InstancedMesh

Uses dedicated instanceDissolve float attribute per instance:

BatchedMesh

Encodes dissolve in the blue channel of per-instance batch colors:
Precision Note: Uint8 color buffer provides ~256 levels. At 0.3s duration / 60fps (~18 steps), this is sufficient. Longer durations may show banding.

Shader Integration

File: packages/shared/src/systems/shared/world/GPUMaterials.ts The dissolve effect uses Bayer 4×4 screen-door dithering in the alphaTestNode:
Key Points:
  • 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:
This prevents a 1-frame flash of the full tree before dissolve is applied.

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)
Example:

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
Example:

update(_ctx: ResourceVisualContext, deltaTime: number): void

Called every frame to tick dissolve animations. Behavior:
  • Calls updateGLBTreeInstancer(deltaTime) and updateGLBTreeBatchedInstancer(deltaTime)
  • Both instancers tick their dissolve animations via tickDissolveAnims()
  • Dissolve ticks run AFTER LOD transitions to ensure entities are in correct pools
Example:

Performance Characteristics

Memory

  • Zero-allocation tick loop: Reuses module-level _completed array
  • 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: dissolveDirty flag 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:
  1. onDepleted() not being called by ResourceEntity
  2. Dissolve animation map not being ticked
  3. GPU attribute not being uploaded
Debug:

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: Verify wasDissolve 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: Reduce DISSOLVE_DURATION or switch BatchedMesh to Float32 color buffer.
  • ResourceSystem (packages/shared/src/systems/shared/entities/ResourceSystem.ts) - Calls onDepleted() and onRespawn()
  • 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:
Bayer 4×4 Pattern:

Performance Optimization

Batched GPU Uploads

Instead of marking needsUpdate 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 for DissolveAnimation.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:
  1. ✅ Deplete a tree → instant transparency
  2. ✅ Wait for respawn → smooth fade-in over 0.3s
  3. ✅ Trigger LOD transition during dissolve → no visual pop
  4. ✅ Deplete multiple trees simultaneously → all dissolve correctly
  5. ✅ 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_DURATION per 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