Skip to content

feat(trees): add dissolve transparency for depleted trees - #1101

Merged
dreaminglucid merged 17 commits into
mainfrom
feat/tree-dissolve-transparency
Mar 27, 2026
Merged

feat(trees): add dissolve transparency for depleted trees#1101
dreaminglucid merged 17 commits into
mainfrom
feat/tree-dissolve-transparency

Conversation

@dreaminglucid

Copy link
Copy Markdown
Collaborator

Depleted trees become 80% transparent instantly on depletion and animate back to full opacity over 0.3s on respawn. Uses per-instance dissolve attributes (InstancedMesh) and batch color blue channel (BatchedMesh) to drive real alpha transparency in the TSL shader.

Depleted trees become 80% transparent instantly on depletion and
animate back to full opacity over 0.3s on respawn. Uses per-instance
dissolve attributes (InstancedMesh) and batch color blue channel
(BatchedMesh) to drive real alpha transparency in the TSL shader.
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Overall this is a clean, well-structured change. The dual InstancedMesh / BatchedMesh approach is handled consistently, and the TSL shader integration is solid. A few items worth addressing:


Bug: Hardcoded dt = 1/60 instead of real delta time

GLBTreeInstancer.ts and GLBTreeBatchedInstancer.ts both use:

const dt = 1 / 60; // fixed step

The comment says "trees use frame-based update, not real deltaTime", but this means the dissolve animation will run faster/slower depending on actual frame rate. At 30 FPS the 0.3s animation takes ~0.6s real time; at 144 FPS it takes ~0.125s. If a real delta time is available from the update loop, it should be passed through. If a fixed-step approach is intentional for determinism, consider accumulating real elapsed time instead.

Potential Issue: transparent = true without sort order consideration

GPUMaterials.ts:1184 sets material.transparent = true on all tree materials, not just depleted ones. Transparent objects require back-to-front sorting to render correctly. With depthWrite = true this partially mitigates z-fighting, but:

  1. Performance: All trees are now in the transparent render pass, even when fully opaque (dissolveVal = 0). This can impact draw call batching and overdraw.
  2. Sorting artifacts: Overlapping transparent trees may flicker or show incorrect ordering.

Consider using material.alphaTest instead of full transparency (if the 80% transparency visual allows it), or toggling transparent only when dissolve is active.

Dead exports: setDepleted / hasDepleted still exist

The old setDepleted and hasDepleted functions are still exported from both GLBTreeInstancer.ts and GLBTreeBatchedInstancer.ts. They're no longer imported by TreeGLBVisualStrategy but remain as public API. If they're truly unused now, they should be removed to avoid confusion. If kept for external consumers, they should be updated to integrate with the new dissolve system.

Encoding dissolve in the blue channel is fragile

Using vBatchColor.z (blue channel) to encode dissolve state for BatchedMesh is clever but couples rendering state to a color channel. A few risks:

  • If any future code sets batch colors (e.g., tinting, damage flash), it could overwrite the dissolve state.
  • The applyHighlightColor function now carefully preserves the blue channel, but _defaultColor (1,1,1) and _hlColor (1.15,1.15,1.15) are still defined and used elsewhere (e.g., line 272 setColorAt(initId, _defaultColor) and line 590). Those set blue=1.0 which means dissolve=0 (correct for init), but this implicit coupling could break if those colors change.

A comment near _defaultColor/_hlColor documenting that blue is reserved for dissolve would help prevent regressions.

Mutation during iteration

Both instancer files iterate dissolveAnims with for...of and call dissolveAnims.delete() inside the loop:

for (const [entityId, anim] of dissolveAnims) {
  // ...
  dissolveAnims.delete(entityId);
}

This works in current JS engines (the ES6 spec supports it for Maps), but it's a pattern that trips up readers and some linters. Consider collecting completed IDs and deleting after the loop for clarity.

Minor: setDissolve is exported but never used

Both instancers export a setDissolve function that's never called. If it's intended as public API for future use, that's fine — but per the project's "production code only" rule, consider removing it until needed.

Positive notes

  • Clean separation between instant (depletion) and animated (respawn) dissolve paths.
  • LOD transition correctly preserves dissolve state when swapping pools — nice attention to detail.
  • Proper cleanup in removeInstance and destroyGLBTree*Instancer prevents memory leaks.
  • The shader math is simple and efficient: alpha * (1.0 - dissolveVal * 0.8).

- Pass real deltaTime to dissolve animations instead of hardcoded 1/60
- Remove dead exports: setDepleted, hasDepleted, setDissolve from tree instancers
- Remove unused _hlColor constant from batched instancer
- Fix mutation during Map iteration: collect completed IDs, delete after loop
- Document blue channel encoding on _defaultColor and transparent flag rationale
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Good refactor — replacing the old swap-to-depleted-pool approach with per-instance dissolve driven through the shader is a cleaner architecture. A few items to consider:

Performance Concerns

  1. material.transparent = true on ALL tree materials (GPUMaterials.ts:1188): This is the biggest concern. Marking the material transparent forces every tree instance into the transparent render pass, even when dissolveVal = 0 and alpha = 1.0. The comment says "Three.js skips sort for opaque fragments in the transparent pass" — this is not accurate. Three.js sorts all objects in the transparent pass by distance from camera. For a forest with hundreds/thousands of trees, this adds non-trivial per-frame sorting cost. Consider:

    • Using material.alphaTest with a small threshold instead of transparent = true, which keeps non-dissolved trees in the opaque pass
    • Or toggling transparent only when there are active dissolve animations, then reverting
    • Or at minimum, benchmarking the frame time impact in a dense forest scene
  2. depthWrite = true with transparent = true (GPUMaterials.ts:1189): This combination can cause rendering artifacts. Transparent fragments that write to the depth buffer will occlude other transparent fragments behind them, which means overlapping semi-transparent trees may not blend correctly (back-to-front ordering is required but depth writes will reject the farther fragments). The 80% transparency makes this very noticeable if two depleted trees overlap.

  3. Temporary array allocations in tick loop (GLBTreeInstancer.ts:805, GLBTreeBatchedInstancer.ts:943): const completed: string[] = []; is allocated every frame. For a hot loop that runs 60fps, consider reusing a module-level array that gets .length = 0 cleared each tick, or deleting entries inline using a reverse iteration pattern.

Correctness Issues

  1. Dissolve state not preserved during LOD transitions in BatchedInstancer: When reading wasDissolveVal during LOD swap (GLBTreeBatchedInstancer.ts:918-921), you only sample from batches[0]. If the entity has a different blue channel value in other batches (e.g., due to a partial highlight update race), this could be inconsistent. Minor, but worth noting.

  2. onDepleted always returns true (TreeGLBVisualStrategy.ts:278): The old code returned whether a depleted pool existed, which was used by callers to decide behavior. Now it unconditionally returns true. Verify that all callers handle this correctly — if any caller used a false return to fall back to a different visual strategy, that path is now dead.

  3. deltaTime defaults to 1/60 (GLBTreeInstancer.ts:808, GLBTreeBatchedInstancer.ts:945): If deltaTime is somehow not passed, the fallback assumes 60fps. This could make dissolve animations run at wrong speed on high-refresh-rate monitors or during frame drops. Since update() now accepts deltaTime in its signature, consider making it required rather than optional.

Code Quality

  1. Duplicated dissolve logic: The dissolve animation system (DissolveAnim interface, dissolveAnims map, startDissolve, applyDissolveValue, tick loop) is nearly identical between GLBTreeInstancer.ts and GLBTreeBatchedInstancer.ts. Consider extracting a shared DissolveAnimator utility to reduce the ~60 lines of duplication and ensure both paths stay in sync.

  2. Encoding dissolve in the blue channel (GLBTreeBatchedInstancer.ts): Clever use of the batch color blue channel, but fragile — any future code that sets batch colors (e.g., tinting, damage flash) could accidentally stomp the dissolve state. The comment warning is good, but a more robust approach would be to use a separate data channel or at least add a validation assertion in setColorAt calls.

Minor

  1. The _hlColor constant was removed from GLBTreeBatchedInstancer.ts but the inline values 1.15 are now hardcoded in applyHighlightColor. Consider keeping a named constant for the highlight intensity.

  2. update(_ctx: ResourceVisualContext, deltaTime: number) — the _ctx parameter is unused. Check if the interface requires it or if you can update the interface signature.

Summary

The core approach is sound — dissolve via shader attributes is much better than swapping mesh pools. The main risk is the performance impact of transparent = true on all tree materials globally. I'd recommend benchmarking in a dense scene and exploring alphaTest as an alternative before merging.

- Make deltaTime required (not optional) in both update functions
- Reuse module-level _completedAnims array to avoid per-frame allocation
- Extract HL_COLOR_INTENSITY constant for highlight multiplier
- Add comment clarifying batches[0] sampling safety during LOD swap
- Improve transparent/depthWrite documentation with instanced rendering context
- Update dissolve transparency from 80% to 70%
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Good approach overall - replacing the old swap-to-depleted-pool mechanism with a shader-driven dissolve is cleaner and eliminates the visual pop. The code is well-commented and the animation logic is straightforward. Here is my feedback:


Performance Concerns

1. material.transparent = true on ALL tree meshes (GPUMaterials.ts:1191)

This is the biggest concern. Setting transparent = true moves every tree InstancedMesh into the transparent render pass, which disables early-Z rejection and requires back-to-front sorting. The comment acknowledges this but downplays it - even though sorting is per-object not per-instance, transparent objects cannot benefit from the depth pre-pass, which matters for overdraw in dense forests.

Consider alternatives:

  • Use alphaTest instead of alpha blending (e.g., material.alphaTest = 0.01) - this keeps trees in the opaque pass and only discards fully-transparent fragments. The visual difference at 20% opacity vs alpha-tested would be noticeable though, so this is a tradeoff.
  • Dynamically toggle transparent only when a dissolve is active on that LOD pool, and reset it when all instances in the pool are fully opaque. More complex but avoids the permanent perf cost.

2. needsUpdate = true triggered per-instance per-frame (GLBTreeInstancer.ts applyDissolveValue)

During a dissolve animation, applyDissolveValue marks the instanceDissolve attribute as needing upload for every mesh in the LOD pool, every frame, for every animating entity. If multiple trees respawn simultaneously, this multiplies. Consider batching: set needsUpdate once per pool per frame (after all dissolve ticks), not inside the per-entity apply function.

3. _completedAnims module-level array reuse

Good instinct to avoid per-frame allocation, but _completedAnims.length = 0 does not release memory - the backing buffer grows monotonically. Fine in practice since the high-water mark should be small, but worth noting.


Potential Bugs

4. LOD transition loses dissolve state for BatchedMesh (GLBTreeBatchedInstancer.ts:920-927)

When reading wasDissolveVal during LOD transitions, you sample batches[0] only. The comment says applyDissolveColor sets all batches uniformly, which is true for dissolve. But if a batch has zero instances or the entity is not in batches[0], getColorAt could read stale data. The oldIds check mitigates this, but if oldIds[0] somehow refers to a recycled slot (race between remove and LOD swap), you would read garbage. Low probability but worth a defensive clamp:

wasDissolveVal = Math.max(0, Math.min(1, 1.0 - _tmpColor.b));

(You already have Math.max(0, ...) but no upper bound.)

5. onDepleted always returns true (TreeGLBVisualStrategy.ts:274)

The old code returned whether the pool actually existed (hasDepleted). Now it unconditionally returns true, which tells ResourceEntity it handled depletion so do not load an individual depleted model. If startDissolve silently fails (entity not yet in a pool, e.g., during rapid create then deplete), the tree would be invisible with no fallback. Consider returning whether the dissolve was actually applied.

6. createVisual applies instant dissolve for initially-depleted trees, but the instance may not be in a pool yet

In createVisual, after addInstance succeeds, you call startBatchedDissolve(id, 1, true) / startInstancedDissolve(id, 1, true). If addInstance is async (deferred via pendingEnsure), the instance will not be in the pool yet and applyDissolveValue will silently no-op. The tree would appear fully opaque despite being depleted. Worth verifying the timing here, or queuing the initial dissolve state to apply once the instance is actually placed.


Code Quality

7. Duplicated dissolve logic between GLBTreeInstancer and GLBTreeBatchedInstancer

The DissolveAnim interface, DISSOLVE_DURATION, DISSOLVE_MAX, startDissolve, and the animation tick loop are nearly identical in both files. Consider extracting the animation state machine into a small shared utility (e.g., DissolveAnimator class or a set of pure functions) to keep them in sync. If the duration or easing ever changes, updating one but not the other would be an easy mistake.

8. Highlight color preservation in BatchedMesh (GLBTreeBatchedInstancer.ts:622-627)

The highlight toggle now preserves the blue channel correctly - nice. But the read-modify-write (getColorAt then mutate then setColorAt) is not atomic across batches. If another system modifies the color between the get and set on a different batch index, you would overwrite it. In practice this is single-threaded so it is fine, just noting for documentation.


Minor Nits

  • The _ctx prefix in update(_ctx: ResourceVisualContext, deltaTime: number) suggests the context is not used. If the instancer update functions are truly global (not per-entity), should update even receive a context? This is an existing design question, not introduced by this PR.
  • The 0.7 magic number in the shader mul(dissolveVal, float(0.7)) could be a named constant for clarity - it controls the max transparency (30% opacity when fully dissolved).

Summary

The core approach is sound - dissolve-by-transparency is the right call over mesh-swapping for visual quality. The main risks are:

  1. Performance: transparent = true on all trees permanently - worth profiling in a dense forest scene
  2. Correctness: Initially-depleted trees during async pool loading may not get the dissolve applied
  3. Maintainability: Duplicated animation logic across the two instancer files

Nice cleanup of the old setDepleted/hasDepleted code path - the new API surface (startDissolve) is much cleaner.

…stants

- Batch instanceDissolve needsUpdate per-pool via dissolveDirty flag instead
  of marking needsUpdate per-entity inside applyDissolveValue (avoids redundant
  GPU uploads when multiple trees dissolve simultaneously)
- Add upper bound clamp on wasDissolveVal during LOD swap (defensive against
  stale color data)
- Extract DISSOLVE_ALPHA_SCALE constant in shader for the 0.7 opacity factor
- Improve transparent/depthWrite documentation
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Overall this is a well-structured change that replaces the old swap-to-depleted-pool approach with per-instance dissolve transparency. The code is clean, well-commented, and the encoding scheme (blue channel for batched, instanceDissolve attribute for instanced) is sound. A few items to consider:


Potential Issues

1. Shader highlight false-positive with blue channel (low risk)
In GPUMaterials.ts (line ~1148-1151), highlight intensity is detected via:

step(1.01, max(batchColor.x, max(batchColor.y, batchColor.z)))

Since dissolve encodes as blue = 1.0 - dissolveVal, blue will always be ≤1.0, so the max(...) won't falsely trigger highlight. This works correctly — but it's fragile. If anyone later changes the dissolve encoding to use a value >1.0, the highlight detection breaks silently. Consider adding a comment at the shader-level max(...) call noting the blue channel assumption, or switching the shader to only use max(batchColor.x, batchColor.y) for highlight detection since blue is now reserved.

2. _completedAnims module-level array reuse
Both GLBTreeInstancer.ts and GLBTreeBatchedInstancer.ts use a module-level _completedAnims: string[] that's cleared via .length = 0 each frame. This is a good allocation-avoidance pattern, but if updateGLBTreeInstancer were ever called re-entrantly (e.g., from an event handler during the update), it would corrupt the array. Low risk in practice, but worth noting.

3. transparent = true on all tree materials — performance consideration
Setting material.transparent = true (GPUMaterials.ts line ~1195) moves all tree instances into the transparent render pass, even when most have alpha = 1.0. The comment acknowledges this is per-object not per-instance sorting, which is correct. However, transparent objects are generally sorted back-to-front and can't benefit from early-Z rejection. For a forest-heavy scene with many tree LOD meshes, this could have a measurable fill-rate cost.

A future optimization could be to use alphaTest instead of alpha blending (with a threshold like 0.29) — this would keep trees in the opaque pass while still making depleted trees visually distinct. Worth profiling if forests become a bottleneck.

4. Missing dissolve state cleanup in removeInstance for batched path
In GLBTreeBatchedInstancer.ts, removeInstance (line ~725) correctly deletes from dissolveAnims. However, removeFromPool doesn't explicitly clear the dissolve color on the batch — it relies on the instance being removed from the batch entirely. This should be fine since deleteInstance removes the instance, but worth verifying that BatchedMesh.deleteInstance doesn't leave stale color data that could affect a subsequently-added instance at the same internal slot.

5. LOD transition preserves dissolve state — good
Both instancers correctly read dissolve state before removing from the old pool and re-apply it to the new pool during LOD transitions (GLBTreeInstancer.ts ~768-800, GLBTreeBatchedInstancer.ts ~918-945). This prevents visual pops during LOD changes on depleted trees.


Code Quality — Positive Notes

  • Clean removal of the old setDepleted/hasDepleted API in favor of the unified startDissolve API — reduces surface area and complexity.
  • Good use of dissolveDirty flag in the instanced path to batch GPU uploads instead of marking needsUpdate on every applyDissolveValue call.
  • The applyHighlightColor change to read-then-write (preserving blue channel) is correct and well-commented.
  • Thorough cleanup in destroyGLBTreeBatchedInstancer / destroyGLBTreeInstancer clearing the dissolveAnims map.
  • The comment block on material.transparent = true is excellent — it shows awareness of the trade-off and documents why it's acceptable.

Minor Suggestions

  • In GLBTreeBatchedInstancer.ts, applyDissolveValue iterates [pool.lod0, pool.lod1, pool.lod2] and returns early on the first match. Consider documenting that an entity is expected to exist in exactly one LOD pool at a time (or asserting it in debug builds).
  • The DISSOLVE_DURATION = 0.3 constant is duplicated in both instancer files. If these should always match, consider extracting to a shared constant.
  • onDepleted now always returns true — if callers previously used the return value to determine whether a depleted pool exists, make sure no code path depends on a false return to fall back to loading an individual depleted model.

Summary

Solid implementation. The dissolve approach is cleaner than the old pool-swap mechanism and the shader integration is well thought out. The main thing to watch is the transparent = true performance impact in tree-dense scenes — consider profiling and potentially switching to alphaTest if it becomes a concern.

- Use only R/G channels for batch highlight detection (blue is reserved
  for dissolve state), preventing future false-positive highlights
- Extract DISSOLVE_DURATION and DISSOLVE_MAX into GPU_VEG_CONFIG so both
  instancers share the same source of truth
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Overall this is a well-structured change that replaces the old "swap to depleted model" approach with a shader-driven dissolve. The TSL integration is clean and the dual InstancedMesh/BatchedMesh paths are handled consistently. A few items worth addressing:


Code Quality

Significant code duplication between GLBTreeInstancer.ts and GLBTreeBatchedInstancer.ts

The DissolveAnim interface, dissolveAnims map, startDissolve(), applyDissolveValue(), the animation tick loop, and _completedAnims reuse pattern are essentially copy-pasted between the two files. This is a meaningful maintenance risk — if DISSOLVE_DURATION logic or animation curves change later, both files must be updated in lockstep. Consider extracting a shared DissolveAnimationManager class or at minimum a shared tick helper, e.g.:

// shared/world/dissolveAnimation.ts
export function tickDissolveAnims(
  anims: Map<string, DissolveAnim>,
  deltaTime: number,
  applyFn: (id: string, value: number) => void,
): void { ... }

_completedAnims module-level array reuse — Good instinct to avoid per-frame allocation, but note that both files declare their own _completedAnims at module scope. If either updateGLBTree*Instancer were ever called re-entrantly (unlikely but worth noting), the shared array would corrupt. This is fine for now but is another argument for the shared helper above.


Potential Bugs / Issues

  1. onDepleted now always returns true (line ~283 in TreeGLBVisualStrategy.ts). The old code returned whether a depleted pool existed (hasBatchedDepleted / hasInstancedDepleted). If any caller uses this return value to decide whether to load a fallback depleted model, those callers will now skip the fallback unconditionally — even if the dissolve didn't actually apply (e.g., entity not yet in any pool). Worth verifying no caller depends on a false return to trigger fallback behavior.

  2. Dissolve state lost when startDissolve is called before the entity is in a pool. In onAdd, if config.depleted is true, startBatchedDissolve(id, 1, true) is called immediately after addInstance. If addInstance is async or deferred (the pool creation uses pendingEnsure), applyDissolveValue will silently no-op because the slot doesn't exist yet. The tree would appear fully opaque despite being depleted. Consider queuing the initial dissolve state on the slot itself so it's applied when the instance is actually added to a LOD pool.

  3. BatchedMesh: applyDissolveColor iterates all batches but only checks instanceIds — if an entity is in one LOD level but not another, instanceIds.get(entityId) returns undefined and the loop continues harmlessly, but it's doing unnecessary iterations. Minor, but a simple if (!ids) return already handles this.


Performance Considerations

  • material.transparent = true on ALL tree materials is the most impactful change here. The comment acknowledges this well — the cost is per-InstancedMesh-object, not per-instance. However, transparent objects are sorted back-to-front by Three.js, and depthWrite = true with transparency can cause visual artifacts when non-depleted (alpha=1.0) trees overlap with other transparent geometry (particles, water, UI elements). If visual artifacts appear in practice, consider using alphaTest with a threshold instead of true alpha blending, or toggling transparent only when any instance in the mesh is actually dissolved.

  • dissolveDirty flag (InstancedMesh path) — well done. This avoids unnecessary GPU uploads when dissolve hasn't changed. The BatchedMesh path doesn't need this since it uses setColorAt which handles its own dirty tracking.

  • DISSOLVE_ALPHA_SCALE = 0.7 is hardcoded in the shader closure. Consider moving it to GPU_VEG_CONFIG alongside DISSOLVE_DURATION and DISSOLVE_MAX for consistency and easier tuning.


Security

No concerns — this is purely client-side rendering logic with no user input, network calls, or state that could be exploited.


Testing

No tests are included in this PR. Per the project's testing philosophy (Playwright with real Hyperscape instances, visual verification via screenshots), this feature would benefit from:

  • A test that depletes a tree and verifies the visual change via screenshot comparison
  • A test that verifies respawn animation completes and the tree returns to full opacity
  • Edge case: tree depleted on initial load (the config.depleted path in onAdd)

Minor Nits

  • The update signature change from update(): void to update(_ctx: ResourceVisualContext, deltaTime: number): void — the _ctx parameter is unused. If the interface requires it, that's fine, but if this is a new signature, consider whether ctx is actually needed.
  • wasDissolveVal clamping in updateGLBTreeBatchedInstancer uses Math.max(0, Math.min(1, ...)) — since DISSOLVE_MAX is configurable, should this use DISSOLVE_MAX instead of 1?

Summary

Solid implementation that cleanly replaces a model-swap approach with GPU-driven dissolve. The main actionable items are: (1) verify no caller depends on onDepleted returning false, (2) handle the race where startDissolve is called before the instance is pooled, and (3) consider extracting the duplicated dissolve animation logic. The transparent = true tradeoff is well-documented and reasonable for the current use case.

…nts to config

- Extract DissolveAnim interface, startDissolve, and tickDissolveAnims into
  shared DissolveAnimation.ts — eliminates ~60 lines of duplication between
  the two instancer files
- Move DISSOLVE_ALPHA_SCALE to GPU_VEG_CONFIG alongside DISSOLVE_DURATION
  and DISSOLVE_MAX for centralized tuning
- Use only R/G channels for batch highlight detection since blue is reserved
  for dissolve encoding
- Clamp wasDissolveVal to DISSOLVE_MAX instead of hardcoded 1
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

test

@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees) add dissolve transparency for depleted trees

Overall well-structured change. The shared DissolveAnimation state machine is a good abstraction, and encoding dissolve in the blue channel for BatchedMesh while using a dedicated InstancedBufferAttribute for InstancedMesh is pragmatic.


Issues

1. Module-level mutable singleton in DissolveAnimation.ts - The _completed array is shared across all callers of tickDissolveAnims. If called re-entrantly, _completed would be corrupted. Consider making it local.

2. transparent: true on all tree materials (GPUMaterials.ts) - All trees go through the transparent render pass, disabling early-Z rejection. Consider: alphaTest, dynamically toggling transparent per pool, or a separate material for dissolving trees.

3. LOD transition may flash dissolve state (GLBTreeInstancer.ts) - Between addToPool and the dissolve write, the dissolve value resets to zero for one frame. Write dissolve value before or as part of addToPool.

4. onDepleted always returns true now - Previously returned whether a depleted pool existed. If entity not yet in pool, returning true suppresses the fallback model.


Minor

  • DissolveAnimation imports are mid-file in both instancers, not at top
  • DISSOLVE_MAX declared in two places; use one canonical location
  • Verify vBatchColor is full-precision varying in shader pipeline

What looks good

  • Clean separation of animation logic into DissolveAnimation.ts
  • Dissolve state preserved across LOD transitions
  • Highlight R/G preserved when writing blue channel
  • Proper cleanup on removeInstance and destroy
  • Nice instant flag for initial load vs animated respawn

Main concern is item 2 (transparent pass for all trees) -- most likely to impact production performance.

…le imports

- Make _completed array local in tickDissolveAnims to prevent reentrancy corruption
- Pass dissolve value directly into addToPool to eliminate 1-frame flash on LOD transitions
- Move DissolveAnimation imports to top of both instancer files
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Overall this is a well-structured change — extracting shared dissolve logic into DissolveAnimation.ts is clean, and preserving dissolve state across LOD transitions is handled carefully. A few items worth addressing:


Bug: slot.depleted is dead but still read

setDepleted() was removed from both instancers, so slot.depleted is never set to true for trees anymore. However, it's still read in several places:

  • GLBTreeInstancer.ts:639getLodPool checks slot.depleted and returns pool.depleted
  • GLBTreeInstancer.ts:730 — LOD update loop: if (slot.depleted) continue;
  • GLBTreeBatchedInstancer.ts:744getLodPool checks slot.depleted
  • GLBTreeBatchedInstancer.ts:891 — LOD update loop: if (slot.depleted) continue;

Since slot.depleted defaults to false and is never set, this is dead code rather than a runtime bug. But getLodPool() still references pool.depleted, which means the depleted pool is still being created, loaded, and consuming memory/GPU resources for something that's no longer used. Recommend removing the depleted pool creation and cleaning up all slot.depleted / pool.depleted references in the tree instancers.

Performance: transparent = true on all tree materials

Setting material.transparent = true globally (GPUMaterials.ts) puts every tree InstancedMesh into the transparent render pass, even though the vast majority of instances have alpha = 1.0 at any given time. This:

  • Prevents early-Z rejection for opaque trees, which could hurt fill-rate on dense forests
  • Requires proper back-to-front sorting (Three.js sorts per-object, not per-instance, so overlapping depleted trees will have artifacts as the comment acknowledges)

Suggestion: Consider only enabling transparent = true when at least one instance in the mesh is actively dissolving, and reverting to opaque when all are fully visible. Alternatively, a dithered/stippled dissolve pattern (discard-based) could avoid the transparent pass entirely while still looking good at the 0.3s animation speed.

Minor: PR description vs config mismatch

PR says "80% transparent" (i.e., 20% opacity), but DISSOLVE_ALPHA_SCALE = 0.7 yields alpha = 1.0 - (1.0 × 0.7) = 0.3 → 30% opacity. Either the description or the config constant should be updated to match.

Minor: Blue-channel encoding is fragile

Using color.b = 1.0 - dissolveVal in BatchedMesh colors as an implicit data channel works but is an undocumented contract between applyDissolveColor, applyHighlightColor, the LOD transition code, and the TSL shader. A future contributor modifying batch colors could easily break dissolve. The code comments help, but consider adding a brief note in GPUMaterials.ts near the shader code explaining the encoding scheme, since that's where someone debugging visual issues would look first.

Nit: _ctx unused parameter

TreeGLBVisualStrategy.update(_ctx, deltaTime) — the _ctx parameter is unused. The interface requires it, so this is fine, but just noting it.


What looks good

  • DissolveAnimation.ts: Clean shared state machine, single source of truth for timing constants. Good separation.
  • LOD transition handling: Both instancers correctly read and carry dissolve state when swapping LOD pools — this is the kind of detail that's easy to get wrong.
  • Instant dissolve on initial load: Handling the "starts depleted" case in createVisual avoids a flash-of-full-opacity.
  • onDepleted always returning true: The interface documents this as "strategy handled depletion visuals" — since dissolve is now always applied in-place, true is correct and simplifies the caller.

…l encoding

The depleted LODPool/BatchedLODPool was loaded but never populated since
setDepleted() was removed — slot.depleted was always false. Remove the
entire depleted pool pipeline (loadDepletedPool, depleted field on pool
interfaces, depletedModelPath/depletedScale params) from both tree
instancers to stop wasting memory/GPU resources on unused assets.

Also add blue-channel encoding documentation in the shader code
(GPUMaterials.ts) and expand the transparent=true rationale comment.
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Nice refactor — replacing the separate depleted model pools with in-place dissolve transparency is a solid simplification. The net deletion of code while adding a smoother visual effect is a clear win. Here's my feedback:


Architecture & Code Quality

Strengths:

  • Clean extraction of DissolveAnimation.ts as a shared state machine — keeps both instancers in sync without duplication.
  • Dissolve state is properly preserved across LOD transitions in both instancers. Good attention to detail reading the old dissolve value before removeFromPool.
  • Proper cleanup of dissolveAnims in destroy() and removeInstance() paths.
  • Good inline comments explaining the blue channel encoding convention and the transparent = true trade-off rationale.

Suggestions:

  1. onDepleted now always returns true (TreeGLBVisualStrategy.ts:~L275). Previously it returned whether a depleted pool actually existed — callers may have relied on false to trigger fallback behavior. Worth verifying no caller depends on a false return to load an individual depleted model or take an alternate path.

  2. completed array allocation in tickDissolveAnims (DissolveAnimation.ts:L53): A new array is allocated every frame. For a hot path ticked every frame, consider reusing a module-level array that gets cleared:

    const _completed: string[] = [];
    export function tickDissolveAnims(...) {
      _completed.length = 0;
      // ... push to _completed ...
      for (const id of _completed) anims.delete(id);
    }

    Minor, but consistent with the zero-alloc style used elsewhere in these instancers (reusing _matrix, _position, etc.).


Performance Concern: material.transparent = true on ALL trees

This is the biggest concern in the PR. Setting transparent = true on every tree dissolve material moves all tree instances into the transparent render pass, which:

  • Disables early-Z rejection for opaque trees (the vast majority)
  • Requires depth sorting per-object (Three.js can't sort per-instance within an InstancedMesh/BatchedMesh)
  • May cause overdraw issues in dense forests

The comment at GPUMaterials.ts:~L1165 acknowledges this and argues the benefit of dynamic toggling is marginal. I'd push back slightly:

  • Consider a screen-door / alpha-test dissolve instead: discard fragments where dissolveVal * pattern > threshold. This keeps trees in the opaque pass with full early-Z benefits. At 0.3s animation speed the dithering pattern is barely noticeable, and many AAA games use this exact approach for vegetation fade.
  • If sticking with alpha blending: At minimum, benchmark the FPS impact in a dense forest scene. The overhead could be significant on lower-end GPUs where fill rate is the bottleneck.
  • Alternative middle ground: Only set transparent = true on materials belonging to pools that currently have at least one dissolving instance, and revert when all dissolves complete. The dissolveAnims map already tracks this — when it becomes empty for a pool, flip transparent back to false.

Correctness

  1. Blue channel encoding fragility (BatchedMesh path): The blue channel of batch colors is now semantically overloaded — it encodes dissolve state while R/G encode highlight. This works but is fragile. Any future code that calls setColorAt without knowing about this convention will silently break dissolve. The comments help, but consider:

    • Adding a brief note in GPUMaterials.ts near GPU_VEG_CONFIG documenting this channel convention
    • Or extracting a helper like setBatchColor(pool, entityId, { highlight, dissolve }) that always writes all channels consistently
  2. LOD transition race with dissolve animation: When a LOD transition happens during an active dissolve, the dissolve value is correctly read from the old pool and written to the new one. However, tickDissolveAnims uses applyDissolveValue which searches all LOD pools. If a LOD transition happens between the removeFromPool and addToPool calls (same frame, synchronous — so this is fine), the entity momentarily exists in no pool. Since this is synchronous within the same tick, it's actually safe, but worth a comment noting that tickDissolveAnims runs after LOD transitions.


Minor Nits

  1. _hlColor was removed from BatchedInstancer and replaced with HL_COLOR_INTENSITY constant — good, cleaner. But the constant is declared between two functions rather than at the top with other constants. Consider moving it up near _defaultColor for consistency.

  2. The _ctx parameter in update(_ctx: ResourceVisualContext, deltaTime: number) — if the context isn't used, the signature change is fine, but confirm the interface/caller has been updated to pass both args.


Summary

The core approach is sound and the code is well-structured. The main actionable item is the transparent = true performance impact — I'd strongly recommend either benchmarking it or switching to a discard-based dissolve to keep trees in the opaque pass. Everything else is minor polish.

Replace material.transparent=true with screen-door Bayer 4×4 dithering
that discards fragments proportional to dissolveVal. Trees stay in the
opaque render pass with full early-Z rejection, eliminating the fill-rate
cost of putting all tree InstancedMesh/BatchedMesh into the transparent
pass. The dither pattern matches the far-fade dissolve used elsewhere.

Also:
- Revert _completed to module-level array for zero-alloc consistency
- Move HL_COLOR_INTENSITY constant near _defaultColor
- Add LOD transition timing comment for tickDissolveAnims
- Document blue-channel encoding convention near GPU_VEG_CONFIG
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Nice refactor — replacing the separate depleted model pool with a per-instance dissolve effect is a significant simplification. The shared DissolveAnimation state machine and screen-door dithering approach are well-chosen. Here's detailed feedback:

Positives

  • Major complexity reduction: Removing loadDepletedPool, the depleted LOD pool, and depletedScale/depletedYOffset fields eliminates a lot of async loading, model-swapping, and pool management code (~312 lines deleted). This is a clear win.
  • Good abstraction: DissolveAnimation.ts cleanly extracts the animation tick logic so both instancers stay in sync without duplication.
  • Performance-conscious: Keeping trees in the opaque pass with screen-door dithering (vs. real transparency) preserves early-Z rejection. The zero-alloc _completed array reuse in the hot path is a nice touch.
  • LOD transition handling: Both instancers correctly carry dissolve state across LOD swaps, preventing visual pops.

Potential Issues

  1. Module-level _completed array is shared across callers (DissolveAnimation.ts:19): Both GLBTreeInstancer and GLBTreeBatchedInstancer import tickDissolveAnims from the same module. Since they each pass their own anims map, the shared _completed array works fine as long as calls are never concurrent. Currently they're called sequentially in the same frame, so this is safe — but it's fragile. If a future change calls them in parallel (e.g., via Promise.all or web workers), you'd get data corruption. Consider making _completed local to tickDissolveAnims, or at minimum add a comment warning about single-threaded assumption.

  2. onDepleted now always returns true (TreeGLBVisualStrategy.ts:~275): Previously it returned hasDepleted() which checked whether the depleted pool actually existed. Now it unconditionally returns true. If any caller uses this return value to decide whether depletion visually succeeded (e.g., to fall back to a different visual strategy), it will now always think it succeeded even if the entity wasn't found in any pool. Worth verifying no caller depends on a false return.

  3. BatchedMesh blue channel encoding is clever but fragile: Encoding dissolve state in the color blue channel (blue = 1.0 - dissolveVal) works but creates a hidden coupling between applyDissolveColor, applyHighlightColor, addToPool, and the TSL shader. The comments document this well, but consider:

    • If any code path calls setColorAt with a plain Color(1,1,1) without going through the helpers, it silently resets dissolve state.
    • The _defaultColor = new THREE.Color(1, 1, 1) at the top is used in addToPool only when dissolve === 0, which is correct, but any future use of _defaultColor in a new code path could be a bug.
  4. Missing alphaTest setup: The shader computes finalAlpha using the dither pattern and relies on alphaTest to discard fragments. I don't see where material.alphaTest is being set to a non-zero value in this diff. If it was already set by the base material or createTreeDissolveMaterial, that's fine — but if alphaTest defaults to 0, the finalAlpha = 0 fragments won't actually be discarded by Three.js. Worth double-checking that alphaTest > 0 is set somewhere upstream.

  5. startDissolve called before instance is in a pool (TreeGLBVisualStrategy.ts:~256-258): In the initial load path, startBatchedDissolve(id, 1, true) / startInstancedDissolve(id, 1, true) are called right after addInstance succeeds. Since instant=true calls applyFn immediately, the instance should be in the pool by then. However, addInstance is async — if the pool ensure is still resolving when startDissolve fires, applyDissolveValue would find no slot and silently no-op. The current code structure looks safe (dissolve is called after await resolves via the if (success) guard), but it's worth noting.

Minor Suggestions

  • DISSOLVE_MAX: 1.0 in GPU_VEG_CONFIG: If this is always 1.0, the Math.min(DISSOLVE_MAX, ...) clamp in tickDissolveAnims and the clamp(…, float(1.0)) in the shader are redundant. If it's meant to be tunable to values < 1.0 (partial dissolve), the config name is clear enough — just noting it's currently a no-op clamp.

  • The update method signature changed from update(): void to update(_ctx: ResourceVisualContext, deltaTime: number): void. The _ctx parameter is unused (prefixed with _). Make sure the interface/caller side matches this new signature and that all implementations of ResourceVisualStrategy.update() were updated.

Summary

This is a well-executed simplification. The main things to verify before merging are: (1) alphaTest is set upstream so dithered fragments actually get discarded, and (2) no caller depends on onDepleted returning false. The blue-channel encoding trick is pragmatic and well-documented. Good work.

The previous commit put the dithered dissolve in outputNode and relied
on alphaTest comparing against the vec4 alpha — but Three.js node
materials use alphaTestNode (not outputNode alpha) for discard decisions.

Move the depletion dissolve dithering into createDissolveMaterial's
alphaTestNode via a new enableDepletionDissolve option, reusing the
same Bayer 4×4 dither pattern already computed for distance fade.
createTreeDissolveMaterial passes enableDepletionDissolve: true
automatically. The tree outputNode now returns plain pbrOut.a.
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

Code Review: feat(trees): add dissolve transparency for depleted trees

Nice refactor — replacing the separate depleted model pool with per-instance dissolve dithering is a solid architectural simplification. The net deletion of code while adding a new visual effect is a good sign. Here's detailed feedback:

Strengths

  • Good separation of concerns: Extracting DissolveAnimation.ts as a shared state machine keeps the two instancers (Instanced/Batched) in sync without duplicating tick logic.
  • LOD transition correctness: Both instancers properly read dissolve state from the old pool before removing, and carry it forward into the new pool — prevents visual pops during LOD switches.
  • Zero-alloc hot path: Reusing _completed array in tickDissolveAnims avoids per-frame allocation, which matters at scale with hundreds of trees.
  • Opaque pass preservation: Using screen-door dithering (alphaTestNode) instead of real alpha blending keeps trees in the opaque pass with full early-Z benefits. Smart tradeoff.

Potential Issues

  1. Shared _completed array is not re-entrant (DissolveAnimation.ts:141)

    const _completed: string[] = [];

    This module-level array is reused across calls via _completed.length = 0. If tickDissolveAnims were ever called from two different instancers in the same frame (e.g., both batched and instanced update paths), the second call would clobber the first call's cleanup list. Currently safe because each instancer has its own dissolveAnims map, but worth a comment noting the assumption, or switching to a local array (the allocation cost of a small temp array is negligible vs. the Map iteration).

  2. Blue channel encoding fragility (GLBTreeBatchedInstancer.ts)
    Encoding dissolve state in the blue channel of batch colors is clever but brittle — any future code that sets batch colors (e.g., tinting, damage flash) could silently break dissolve or vice versa. The comments are good, but consider:

    • Adding a validation helper or assertion that blue channel is only modified through applyDissolveColor
    • Documenting the channel layout in GPU_VEG_CONFIG (partially done, but a structured comment like // Batch color layout: R=highlight, G=highlight, B=1-dissolve at the top of the instancer would help)
  3. onDepleted always returns true (TreeGLBVisualStrategy.ts:270)

    async onDepleted(ctx: ResourceVisualContext): Promise<boolean> {
      ...
      return true;
    }

    The old code returned whether a depleted pool existed (hasDepleted), presumably so callers could handle the "no visual change" case. Now it always claims success. If any caller uses this return value to decide whether to show a fallback, this could mask failures (e.g., if the entity was already removed). Worth checking callers.

  4. Missing dissolveAnims.delete on instance removal in GLBTreeInstancer — Actually, I see it's there at line 504. Good.

  5. dissolveDirty flag not set on LOD swap (GLBTreeInstancer.ts:387-397)
    In removeFromPool, when swapping the last element into the removed slot, dissolveData[idx] is updated but dissolveDirty is not set. The addToPool path does set it conditionally (if (dissolve > 0)), but if a tree with dissolve=0 is added after a tree with dissolve>0 was swapped, the GPU attribute may not be flushed. The dirty flag triggers instanceMatrix.needsUpdate but not instanceDissolve.needsUpdate. Consider always setting dissolveDirty = true in removeFromPool when pool.dissolveData[idx] changes, or unifying the two dirty flags.

Performance Considerations

  • The applyDissolveValue function in GLBTreeBatchedInstancer iterates all 3 LOD pools per entity per tick to find which one the entity is in. For many simultaneously dissolving trees, this is O(anims × 3). Consider caching the current LOD pool reference in the dissolve anim or slot to make it O(1).
  • getColorAt / setColorAt on BatchedMesh does per-instance attribute reads/writes. During the animation (0.3s at 60fps = ~18 frames), this runs for every animating tree every frame. Should be fine for typical tree counts but worth profiling if hundreds dissolve simultaneously.

Minor Nits

  • GPU_VEG_CONFIG.DISSOLVE_MAX is set to 1.0 and used as a clamp bound. If it's always 1.0, the Math.min(DISSOLVE_MAX, ...) calls are redundant. If it's intended to be configurable to < 1.0 (partial dissolve), the DISSOLVE_ALPHA_SCALE interaction should be documented.
  • The _ctx unused parameter in update(_ctx: ResourceVisualContext, deltaTime: number) — consider if the interface can be updated to not require the context, or use it for something.

Summary

Overall this is a well-executed change that simplifies the tree rendering pipeline by removing the separate depleted model loading path in favor of a shader-driven dissolve effect. The main areas to watch are the blue channel encoding brittleness and the dirty flag gap during LOD swaps. The code is clean, well-commented, and follows the project's TSL/WebGPU patterns correctly.

…layout

- Set dissolveDirty = true in removeFromPool when swap moves dissolve
  data to a different slot, ensuring the GPU attribute gets flushed
- Add structured batch color channel layout comment (R/G=highlight,
  B=1-dissolve) at the top of GLBTreeBatchedInstancer
- Document _completed array reentrancy safety assumption
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Nice refactor — replacing the separate depleted model pool with in-place dissolve dithering is a solid architectural simplification. The shared DissolveAnimation state machine, clean LOD-transition dissolve preservation, and the screen-door dithering approach are all well done. Here's detailed feedback:


Code Quality & Architecture

Strengths:

  • Excellent extraction of DissolveAnimation.ts as a shared state machine — DRY between both instancer types with a clean callback-based applyFn pattern.
  • Good separation of concerns: config constants in GPU_VEG_CONFIG, animation logic in DissolveAnimation, rendering integration in the instancers.
  • The batch color channel layout is well-documented with the comment block explaining R/G/B usage.
  • Preserving dissolve state across LOD transitions (both instancer types) is a subtle but important detail handled correctly.

Minor suggestions:

  • TreeGLBVisualStrategy.update() signature changed to (_ctx, deltaTime) — the unused _ctx parameter is a mild smell. If the interface requires it, that's fine, but worth confirming the interface actually mandates both params.

Potential Bugs / Issues

  1. Shared _completed array in DissolveAnimation.ts (line 24): The comment says it's safe because instancers call tickDissolveAnims sequentially within the same frame. This is true today, but it's fragile — if anyone ever calls tickDissolveAnims from a nested/reentrant context (e.g., a callback inside applyFn triggers another tick), _completed will be silently corrupted. Consider making this a local array, or at minimum add a // WARNING: comment about the reentrancy constraint. The allocation savings are minimal (one small array per frame vs. two).

  2. onDepleted now always returns true: Previously it returned whether a depleted pool existed (hasBatchedDepleted / hasInstancedDepleted). Callers may have relied on a false return to trigger fallback behavior (e.g., loading individual depleted models). Verify no caller uses the return value to decide whether depletion was visually applied.

  3. BatchedMesh getColorAt on LOD swap: In GLBTreeBatchedInstancer, when reading wasDissolveVal during LOD transitions (around line 837), you sample batches[0] only. The comment says this is safe since applyDissolveColor sets all batches uniformly — good. But if addToPool is called with a non-zero dissolve and then applyHighlightColor runs before the next tick, R/G get modified but B is preserved correctly. Looks correct, just noting I traced through it.


Performance

  • Screen-door dithering over true alpha: Great choice. Keeping trees in the opaque pass preserves early-Z and avoids sort-order issues — well-documented in the final comment block.
  • dissolveDirty flag for InstancedMesh: Good optimization — only flagging needsUpdate on the dissolve attribute when it actually changed, rather than every frame.
  • Zero-alloc tick loop: The reused _completed array avoids per-frame allocation. Minor win but shows attention to the hot path.
  • Consider: When all dissolve animations complete, dissolveAnims will be empty and tickDissolveAnims becomes a no-op (just _completed.length = 0 + empty iteration). This is fine, but you could add an early-out if (anims.size === 0) return; to skip the _completed reset on idle frames. Micro-optimization, not blocking.

Security

No concerns — this is purely client-side rendering logic with no user input, network calls, or state persistence.


Test Coverage

The PR description doesn't mention tests. Per the project's CLAUDE.md, every feature should have Playwright tests with real gameplay actions and visual verification. Given this is a visual effect:

  • Recommended: Add a Playwright test that depletes a tree, takes a screenshot, waits for respawn, takes another screenshot, and verifies the dissolve animation ran (e.g., checking that the tree entity's visual state changed). Even a smoke test that depletes + respawns without crashing would add value.
  • At minimum, verify the dissolve doesn't cause visual artifacts during LOD transitions (a scenario easy to miss in manual testing).

Summary

This is a clean, well-thought-out refactor that simplifies the tree rendering pipeline by ~50 net lines removed while adding a better visual effect. The main actionable items are:

  1. Verify onDepleted returning true unconditionally doesn't break any callers
  2. Consider making _completed local in DissolveAnimation.ts to avoid the reentrancy footgun
  3. Add visual regression tests per project conventions

Looks good to merge with those items addressed or confirmed as non-issues.

…nims

- Upgrade _completed comment to WARNING about reentrancy constraint
- Skip _completed reset and iteration when dissolveAnims map is empty
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Overall Assessment

Clean, well-structured refactor that replaces the old "swap to depleted stump model" approach with a per-instance dissolve effect using screen-door dithering. The net deletion of code (312 removed, 362 added with the depleted model pool infrastructure completely removed) is a good sign — this simplifies the rendering pipeline while adding a nicer visual effect. The architecture of sharing DissolveAnimation.ts across both instancers is sound.


Code Quality & Architecture

Positive:

  • Good separation of concerns with DissolveAnimation.ts as a shared state machine
  • Proper preservation of dissolve state across LOD transitions (both instancers correctly read/restore dissolve values on pool swaps)
  • Smart reuse of the existing Bayer dither pattern for the depletion dissolve — no new render passes needed, trees stay in the opaque pass with early-Z benefits
  • The batch color channel layout is well-documented with clear comments about R/G = highlight, B = dissolve

Minor concerns:

  1. Module-level _completed array in DissolveAnimation.ts (line 147): The comment acknowledges the re-entrancy risk, which is good. However, this is a latent footgun — if a future refactor ever calls tickDissolveAnims from within an applyFn callback, the shared array silently corrupts. Consider making _completed a local array inside tickDissolveAnims instead. The per-frame allocation cost of a small array is negligible compared to the GPU work being done, and it eliminates an entire class of bugs.

  2. DISSOLVE_MAX is always 1.0: This constant adds indirection without configurability benefit. If DISSOLVE_MAX were ever changed to something other than 1.0, the shader's clamp(sub(1.0, blue), 0.0, 1.0) logic and the blue channel encoding (1.0 - dissolveVal) would need corresponding changes that aren't parameterized. Consider whether this constant earns its keep or if inlining 1.0 would be clearer.

  3. Interface consistency across strategies: TreeGLBVisualStrategy.update() now takes (_ctx, deltaTime) matching the interface, but other strategies like PlaceholderVisualStrategy and TreeProcgenVisualStrategy still have update(): void (no params). TypeScript allows this, but it's inconsistent — a follow-up to add the params for consistency would be nice.


Potential Bugs

  1. onDepleted always returns true (TreeGLBVisualStrategy.ts ~line 86): Previously it returned the result of hasDepleted() which checked if the depleted pool actually existed. Now it unconditionally returns true. If any caller uses this return value to decide whether depletion was visually applied, this could mask failures (e.g., if startDissolve silently fails because the entity isn't in any pool yet). Worth checking callers to confirm true is always safe.

  2. Race condition on initial load: In createVisual(), the dissolve is applied after addInstance succeeds. But addInstance is async — if the tree starts depleted and the pool creation takes time, could startDissolve be called before the instance is actually in the LOD pool? The applyDissolveValue function would silently no-op (returns without finding the entity in any pool). The instant: true path in DissolveAnimation.ts also deletes from the anim map, so there's no retry. Check if the initial dissolve value is correctly set via the dissolve parameter in addToPool or if it could show a 1-frame flash of the full tree.

    Update: I see addToPool in GLBTreeInstancer does accept a dissolve param — but in createVisual, the dissolve is applied after the addInstance call, not passed through it. For the batched instancer, addToPool also accepts dissolve. But the createVisual path calls startBatchedDissolve(id, 1, true) separately rather than passing the initial dissolve to addInstance. This means the instance is first added with dissolve=0, then immediately set to dissolve=1. For instant: true this should resolve within the same frame, but it's worth verifying there's no single-frame flash.


Performance Considerations

  1. applyDissolveValue iterates all 3 LOD pools: For each dissolve tick, it searches lod0, lod1, lod2 linearly to find which pool the entity is in. This is O(active_dissolves × 3) per frame, which is fine for typical counts. But if many trees deplete/respawn simultaneously (e.g., a server event), the linear scan could add up. Consider caching which LOD pool an entity is currently in (the currentLOD field on the slot gives this info already — you could use it to go directly to the right pool).

  2. dissolveDirty flag and GPU uploads: Good that you batch the needsUpdate = true flag and only flush once per pool per frame. The InstancedMesh path correctly separates dirty (matrix) from dissolveDirty (dissolve attribute) to avoid unnecessary GPU uploads.

  3. Depleted trees still participate in LOD transitions: Previously depleted trees were skipped (if (slot.depleted) continue). Now all trees run through the LOD distance check every frame. With hundreds of depleted trees this adds some CPU cost, but it's trivial compared to the GPU savings from removing the depleted model pool entirely.


Security

No security concerns — this is purely client-side rendering logic with no user input, network boundaries, or data persistence involved.


Test Coverage

No tests are included in this PR. Given the project's Playwright-based visual testing philosophy from CLAUDE.md, a test that:

  1. Depletes a tree and screenshots the dissolved state
  2. Verifies respawn animation completes and tree returns to full opacity
  3. Checks LOD transitions preserve dissolve state

...would significantly increase confidence in this change. The dithering effect and the LOD-swap dissolve preservation are the highest-risk areas for visual regressions.


Summary

This is a solid improvement — simpler architecture, fewer GPU resources (no depleted model pools), and a better visual effect. The main actionable items are:

  • Verify no 1-frame flash on initial depleted tree load (item 5)
  • Consider using currentLOD to skip the LOD pool search in applyDissolveValue (item 6)
  • Add visual regression tests for dissolve states and LOD transitions

Nice work on the cleanup — removing ~300 lines of depleted pool management while gaining a smoother visual transition is a clear win.

… array

Pass initialDissolve through addInstance→addToPool so depleted trees have
the GPU attribute set at pool insertion time (no 1-frame flash). Use
slot.currentLOD for direct pool lookup in applyDissolveValue instead of
iterating all 3 LOD pools. Make _completed array local to tickDissolveAnims
to eliminate reentrancy concern.
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Nice refactor — replacing the separate depleted model pool with a per-instance dissolve effect is a solid simplification. The shared DissolveAnimation state machine and the dither-based approach that keeps trees in the opaque pass are well thought out. Here's detailed feedback:

Code Quality & Architecture

Strengths:

  • Good extraction of DissolveAnimation.ts as a shared module — keeps both instancers in sync with one tick/start implementation.
  • Removing the entire loadDepletedPool path and depleted slot fields eliminates a lot of complexity (separate model loading, offset tracking, pool management). Net -314 lines across the instancers is a healthy cleanup.
  • The channel layout documentation in GLBTreeBatchedInstancer (R=highlight, G=highlight, B=dissolve) is helpful and necessary given the packed encoding.
  • Ordering dissolve ticks AFTER LOD transitions avoids stale-pool bugs — good comment calling this out.

Suggestions:

  1. _tmpColor declared after first use — In GLBTreeBatchedInstancer, _tmpColor is used in addToPool (line ~535) but declared later (line ~554). This works at runtime due to hoisting of const in module scope, but it reads confusingly. Consider moving the declaration up near the other temp variables (_position, _quaternion, _scale, _defaultColor).

  2. onDepleted now always returns true — The old code returned whether a depleted pool actually existed (hasDepleted), giving callers a signal about success. The new code unconditionally returns true. If any caller uses this return value to decide fallback behavior, this is a behavior change. Worth verifying no caller depends on a false return.

Potential Bugs

  1. Reverse dissolve starting value — In DissolveAnimation.ts:37, when direction === -1 (respawn/appear), the initial progress is set to DISSOLVE_MAX and animated toward 0. But startDissolve is only called with instant=false for respawn. If the entity was already at DISSOLVE_MAX (depleted), this is fine. But if startDissolve(id, -1) is called on a tree that was only partially dissolved (e.g., interrupted mid-animation), the progress resets to DISSOLVE_MAX rather than continuing from the current value. This could cause a visible pop. Consider reading the current dissolve value and starting from there instead of always resetting.

  2. LOD transition dissolve carry-over (BatchedMesh) — In the LOD swap path (~line 848), you read the dissolve from batches[0] blue channel before removing. If oldIds has length 0 (edge case where the entity wasn't fully added), getColorAt on ids[0] will be undefined behavior. The oldIds && oldIds.length > 0 guard handles this, but wasDissolveVal defaults to 0 — meaning a partially dissolved tree would snap to fully visible on LOD change in that edge case. Probably fine in practice, but worth a comment.

  3. initialDissolve passed as undefined for batched path — In TreeGLBVisualStrategy.ts line ~262, the batched addInstance call passes initialDissolve correctly, but the instanced path (line ~260) passes undefined, undefined, initialDissolve for the lod1ModelPath and lod2ModelPath parameters. This works because undefined is the intended default, but passing explicit undefined for optional params is a minor code smell. Consider using named options or at least a comment.

Performance

  1. Good: dither stays in opaque pass — Using screen-door dithering via alphaTestNode instead of real alpha transparency means trees keep early-Z benefits and don't need sorting. This is the right call for performance with many trees.

  2. tickDissolveAnims allocates a completed array every frame — For the hot path, consider reusing a module-level array or deleting inline (iterating with a delete-safe pattern) to avoid GC pressure. With hundreds of trees respawning, the allocations could add up.

  3. dissolveDirty flag on InstancedMesh — The flag correctly batches the needsUpdate GPU upload. However, every call to applyDissolveValue sets dissolveDirty = true even if the value didn't actually change. For fully dissolved/visible trees where the animation has completed, this could cause unnecessary GPU uploads on subsequent LOD transitions. Minor, but could be tightened.

Security

No concerns — this is purely client-side rendering logic with no user input, network calls, or data persistence.

Test Coverage

The PR doesn't include tests. Given the project's Playwright-based visual testing philosophy (per CLAUDE.md), this feature would benefit from:

  • A test that depletes a tree and verifies the dissolve visual (screenshot comparison or scene introspection checking the dissolve attribute value)
  • A test that respawns a tree and verifies the animation completes (dissolve returns to 0)
  • A test that LOD-transitions a dissolved tree and verifies the dissolve state is preserved

Summary

This is a clean simplification that replaces a complex depleted-model-pool system with a much simpler per-instance dissolve. The architecture is sound — shared animation module, proper LOD carry-over, staying in the opaque pass. The main items to address are the potential pop on interrupted animations (#3), and adding visual tests. The rest are minor polish items.

…nt uploads

Continue from current progress when reversing a mid-animation dissolve
instead of resetting to DISSOLVE_MAX (avoids visible pop). Reuse
module-level _completed array to avoid per-frame allocation in
tickDissolveAnims. Skip dissolveDirty when value unchanged. Move
_tmpColor declaration near other temp vars. Add edge-case comment on
LOD transition dissolve carry-over default.
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Overall this is a well-structured refactor that replaces the separate depleted model pool approach with a shader-driven dissolve effect. The shared DissolveAnimation state machine is clean and the channel-packing strategy for BatchedMesh is clever. Good work eliminating a lot of complexity (net -316 lines removed from the old depleted pool machinery).

Strengths

  • Shared animation module (DissolveAnimation.ts) cleanly separates tick logic from the two instancer backends, preventing drift.
  • Interrupt handling — continuing from current progress on direction reversal avoids visual pops. Nice touch.
  • LOD transition preservation — dissolve state is correctly read before removeFromPool and written into the new pool, so LOD swaps don't flash.
  • Initial dissolve on addInstance eliminates the 1-frame flash for trees that spawn already depleted.
  • Screen-door dithering keeps trees in the opaque pass with early-Z benefits — good perf decision vs. alpha blending.

Issues & Suggestions

1. Module-level _completed array is shared across callers (medium)
DissolveAnimation.ts:19_completed is a module-level singleton reused across ticks. Since both GLBTreeInstancer and GLBTreeBatchedInstancer each call tickDissolveAnims with their own anims map, this works today. But if they were ever called concurrently (e.g. in a worker or microtask), _completed.length = 0 at line 65 would clobber state. Consider making _completed local to tickDissolveAnims — the allocation cost for a small array is negligible compared to the GPU uploads happening in the same frame, or pass it as an internal parameter scoped to the call.

2. onDepleted always returns true — semantic change (low)
TreeGLBVisualStrategy.tsonDepleted previously returned whether a depleted pool existed (hasBatchedDepleted / hasInstancedDepleted). Now it unconditionally returns true. If any caller uses the return value to decide whether to fall back to individual model loading, this could change behavior. Worth confirming no callers rely on false to trigger a fallback path.

3. BatchedMesh blue channel encoding limits dissolve precision (low)
The blue channel stores 1.0 - dissolveVal. THREE.Color internally stores float values, but getColorAt/setColorAt may quantize depending on the underlying color buffer format (some BatchedMesh implementations use Uint8 color buffers → only 256 steps). With DISSOLVE_DURATION: 0.3s at 60fps that's ~18 steps needed, so 8-bit should be fine, but worth a comment noting the precision floor if this is ever extended to slower animations.

4. DISSOLVE_MAX is 1.0 but DISSOLVE_ALPHA_SCALE is 0.7 — naming could be clearer (nit)
DISSOLVE_MAX sounds like the maximum visual effect, but the actual maximum discard fraction is DISSOLVE_ALPHA_SCALE (0.7). A brief inline comment on DISSOLVE_MAX clarifying it's the animation progress ceiling (not the visual opacity) would help future readers.

5. Highlight detection in shader now only checks R/G (correctness — verify)
GPUMaterials.ts:1257 — The highlight detection changed from max(R, G, B) to max(R, G) since blue is now reserved for dissolve. This is correct for the new encoding. However, if any other code path sets batch color blue > 1.01 (unlikely but worth grep-confirming), it would no longer trigger highlight. Looks intentional and safe, just flagging for completeness.

6. dissolveDirty flag on InstancedMesh — consider batching with existing dirty flag (nit)
The InstancedMesh path introduces a separate dissolveDirty flag. Since dirty and dissolveDirty are both flushed in the same loop iteration, you could merge them into a single dirty flag (or a bitmask) to simplify the state tracking. Minor, and the current approach is clear enough.

Performance

  • The dissolve animation tick is O(active animations), not O(total instances) — good.
  • Per-frame applyDissolveValue does 3 map lookups (entityToModel → pools → instances) per animating entity. For the expected count (<50 simultaneous dissolves) this is fine. If it ever scaled higher, a direct slot reference in the DissolveAnim would eliminate the lookups.
  • The Bayer dither reuse in the shader is efficient — no extra texture samples.

Security

No concerns — this is purely client-side rendering logic with no user input, network, or persistence surface.

Test Coverage

No tests are included in this PR. Given the project's Playwright-based visual testing philosophy, consider adding a test that:

  1. Spawns a tree, depletes it, and screenshots to verify dissolve
  2. Respawns and screenshots to verify the animation completes
  3. Verifies LOD transitions during dissolve don't cause visual artifacts

This would catch regressions in the channel-packing or animation timing.


Verdict: Solid refactor with good architecture decisions. The issues above are mostly minor. The main actionable items are confirming no callers depend on onDepleted returning false and adding visual test coverage.

…afety invariants

Add inline comments: DISSOLVE_MAX is animation progress ceiling (not
visual opacity), blue-channel Uint8 precision is sufficient for 0.3s
animations, _completed reuse is safe due to sequential main-thread
calling, onDepleted returns true because dissolve always handles
depletion (false triggers fallback model loading).
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Summary

This PR replaces the old depleted-tree approach (loading separate stump models into dedicated instancer pools) with a screen-door dithering dissolve effect. Depleted trees become ~70% transparent instantly, and animate back to full opacity over 0.3s on respawn. The change removes ~316 lines of pool management code and adds a clean shared DissolveAnimation state machine.

Overall this is a well-structured improvement — simpler architecture, fewer GPU resources, and trees stay in the opaque render pass (no transparency sorting). A few items worth addressing:


Code Quality

Strengths:

  • Clean extraction of DissolveAnimation.ts as a shared state machine avoids duplication between the two instancers
  • Dissolve state is correctly preserved across LOD transitions in both instancers
  • The dissolveDirty flag on LODPool avoids unnecessary GPU uploads per frame
  • Good use of comments explaining the batch color channel layout (R/G = highlight, B = dissolve)
  • The enableDepletionDissolve option integrates cleanly into the existing TSL shader pipeline

Suggestions:

  • The _completed array in DissolveAnimation.ts (line 159) is a module-level mutable singleton reused across ticks. The safety comment is accurate today (sequential, single-threaded), but this is a subtle invariant. Consider adding a brief @internal JSDoc or making it a local array inside tickDissolveAnims — the allocation cost at typical animation counts (~0-20 entries) is negligible vs. the fragility risk if someone later calls this from a worker

Potential Bugs / Issues

  1. onDepleted passes instant=true but startDissolve still calls applyFn before deleting the anim (DissolveAnimation.ts:178-181). This is correct behavior, but worth confirming: when a tree spawns already depleted (initialDissolve path in TreeGLBVisualStrategy), the dissolve value is set atomically in addToPool. When onDepleted is called at runtime, startDissolve(id, 1, true) applies the max dissolve value immediately. These two paths are consistent — good.

  2. BatchedMesh blue channel precision: The comment at line 245 of GLBTreeBatchedInstancer correctly notes Uint8 gives ~0.004 precision per step. However, DISSOLVE_ALPHA_SCALE is 0.7, meaning the effective dissolve range maps to only ~179 discrete levels. At 18 steps over 0.3s this is fine, but if DISSOLVE_DURATION is ever increased significantly, the stepping could become visible. Not a bug today, just worth a note in GPU_VEG_CONFIG.

  3. LOD transition dissolve transfer in BatchedMesh (GLBTreeBatchedInstancer.ts ~line 840): The code reads dissolve from batches[0] only before removing. The comment says "applyDissolveColor sets all batches uniformly" — this is true per the current implementation, but if a future change writes dissolve to batches independently, this assumption silently breaks. Consider adding an assertion or reading from the same batch index as the ID lookup.


Performance

  • Removing dedicated depleted pools (and their separate model loading, geometry, materials) is a net win for memory and GPU resources
  • Screen-door dithering keeps everything in the opaque pass — no alpha blending or sort-order issues. This is the right approach
  • The dissolveAnims.size === 0 early return in tickDissolveAnims avoids iteration cost when no trees are animating
  • applyDissolveValue in GLBTreeInstancer has a dissolveData[idx] === value early-out to skip redundant writes. The batched equivalent (applyDissolveColor) does not have this guard — it always writes to the color buffer. Consider adding a similar check for consistency

Security

No concerns — this is purely client-side rendering logic with no user input, network I/O, or secret handling.


Test Coverage

No tests are added or modified in this PR. The project's testing philosophy (CLAUDE.md) requires Playwright-based visual tests with real browser instances. While shader-level dithering is inherently difficult to assert on pixel-perfectly, the following would add value:

  • A test that depletes a tree and verifies proxy.userData.depleted === true and proxy.userData.interactable === false
  • A test that respawns a depleted tree and verifies the flags reset
  • A screenshot comparison test capturing the dissolve visual at t=0 (instant depletion) and t=0.3s (respawn complete)

Minor Nits

  • update(_ctx: ResourceVisualContext, deltaTime: number) — the _ctx parameter is unused. If the interface requires it, fine, but if not, consider removing it to keep the signature clean
  • null, // lod1ModelPath — auto-inferred by instancer comments in the call site are helpful for now but may drift if the signature changes. Named options objects would be cleaner long-term, though not worth changing in this PR

Verdict

Solid PR. The architectural simplification (removing depleted pools entirely in favor of per-instance dissolve) is a clear improvement. The shared DissolveAnimation module is well-factored. Main feedback is around the missing test coverage and a couple of defensive checks for batch color channel reads. Ship it with tests added.

Check current blue channel value before writing to avoid unnecessary
setColorAt calls when dissolve value hasn't changed, matching the
existing early-out in the InstancedMesh applyDissolveValue path.
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Overall Assessment

This is a well-executed refactor that replaces the separate depleted model pool approach with in-place screen-door dissolve dithering. The result is significantly simpler code (-316 lines of pool-swapping logic) while delivering a better visual effect (smooth fade instead of abrupt model swap). The architecture is clean — shared DissolveAnimation state machine, dual encoding paths for InstancedMesh vs BatchedMesh, and dissolve state correctly preserved across LOD transitions.

Strengths

  • Significant simplification: Removing the entire loadDepletedPool path, depleted LOD pools, depletedScale/depletedYOffset fields, and setDepleted/hasDepleted APIs eliminates a lot of complexity and potential edge cases.
  • Screen-door dithering keeps trees in the opaque pass — no transparency sorting overhead, early-Z still works. Smart choice for a game with potentially hundreds of trees.
  • LOD transition handling is thorough: Both instancers correctly read and carry dissolve state when swapping between LOD pools (e.g. wasDissolveVal / wasDissolve).
  • DissolveAnimation.ts is well-factored: Single source of truth for tick logic, shared between both instancers. The _completed array reuse avoids per-frame allocation.
  • Initial dissolve on addInstance prevents 1-frame flash for trees loaded already depleted — good attention to detail.
  • Excellent inline documentation: The batch color channel layout comment, Uint8 precision note, and explanations of why onDepleted returns true are all helpful.

Potential Issues

  1. _completed shared array in DissolveAnimation.ts (line 159): The comment correctly notes this is safe because both instancers call tickDissolveAnims sequentially on the main thread. However, this is a fragile contract — if a future contributor adds a third caller or introduces async scheduling, it silently corrupts. Consider making _completed local to tickDissolveAnims (the allocation cost of a small array is negligible vs. the Map iteration) or at minimum adding a // WARNING: annotation rather than just // Safe because.

  2. BatchedMesh blue channel precision: When the underlying color buffer is Uint8 (256 levels), the dissolve precision is ~0.004 per step. The comment on line ~46 of GLBTreeBatchedInstancer.ts notes this is sufficient for 0.3s/60fps (~18 steps). However, if DISSOLVE_DURATION is ever increased (e.g. for a slower respawn animation), the quantization could become visible as banding. The InstancedMesh path doesn't have this limitation since it uses a Float32 attribute. Worth noting in the GPU_VEG_CONFIG comments near DISSOLVE_DURATION.

  3. applyDissolveColor redundant reads: In GLBTreeBatchedInstancer.ts, applyDissolveColor reads from batches[0] for the early-out check, then reads again from each batch in the loop. Since the comment says all batches are kept uniform, the second getColorAt calls could reuse the R/G from the first read and only set the new blue — minor optimization but this runs per-entity per-frame during animations.

  4. Missing dissolveDirty flag on addToPool: In GLBTreeInstancer.ts, addToPool sets pool.dissolveDirty = true only when dissolve > 0. But during LOD transitions, addToPool is called with wasDissolve which could be 0 for a tree that just finished dissolving back in. The dirty flag is set regardless, so the matrix upload handles it, but the dissolve attribute won't be flushed if the swapped-in value is 0 and the slot previously held a non-zero value from a different entity. This is likely a non-issue because removeFromPool already sets dissolveDirty = true (it zeros the last slot and may have swapped data), but the logic path is worth verifying with a targeted test case.

  5. onDepleted instant dissolve direction: startDissolve(entityId, 1, true) — the instant flag means it jumps to DISSOLVE_MAX immediately. The PR description says "80% transparent instantly on depletion" but DISSOLVE_ALPHA_SCALE is 0.7 (70% of fragments discarded). These numbers are close but not identical — is the description aspirational or should DISSOLVE_ALPHA_SCALE be 0.8?

Minor Nits

  • _ctx unused parameter: In TreeGLBVisualStrategy.update(_ctx, deltaTime), the _ctx prefix correctly signals it's unused, but the interface requires it. This is fine as-is.
  • The update method previously took no args (update(): void) and now takes (_ctx, deltaTime). The ResourceVisualStrategy interface already declares update(ctx, deltaTime) so this is a bug fix, not a breaking change.

Performance

The approach is performance-positive overall:

  • Eliminates depleted pool management (fewer GPU buffers, less memory)
  • Dissolve tick is O(active animations), not O(all trees)
  • Screen-door dithering has zero fragment shader cost beyond the existing Bayer computation
  • dissolveDirty flag avoids unnecessary GPU attribute uploads

Security

No concerns — this is purely client-side rendering logic with no external inputs.

Test Coverage

No tests are included in this PR. Given the project's Playwright-based visual testing approach, a test that:

  1. Spawns a tree, depletes it, and screenshots the dissolved state
  2. Triggers respawn and captures the animation mid-way
  3. Verifies LOD transitions preserve dissolve state

...would be valuable to prevent regressions in this visual behavior.

Verdict

Strong PR. The dissolve approach is architecturally superior to the depleted-pool swap, the code is clean and well-documented, and the edge cases (LOD transitions, initial load, interrupted animations) are handled carefully. The items above are suggestions for hardening, not blockers.

Add WARNING + @internal to _completed array documenting the sequential
calling contract. Note Uint8 banding risk on DISSOLVE_DURATION in
GPU_VEG_CONFIG. Reuse R/G from first getColorAt in applyDissolveColor
instead of re-reading each batch.
@claude

claude Bot commented Mar 27, 2026

Copy link
Copy Markdown

PR Review: feat(trees): add dissolve transparency for depleted trees

Overall this is a well-structured refactor that replaces the separate depleted model pool approach with in-place dissolve transparency via screen-door dithering. The shared DissolveAnimation module is clean, the LOD-transition dissolve preservation is handled correctly, and removing ~50 lines per instancer by eliminating the depleted pool machinery is a net win. Good work.

Code Quality & Architecture

Strengths:

  • Extracting DissolveAnimation.ts as a shared state machine for both instancers keeps the tick logic DRY and well-documented.
  • The batch color channel layout comment block (R=highlight, G=highlight, B=dissolve) is a great addition — encoding contracts like this are easy to forget.
  • Preserving dissolve state across LOD transitions in both instancers is correctly handled (reading before remove, writing on add).
  • The initialDissolve parameter on addInstance to prevent 1-frame flash is a thoughtful touch.

Suggestions:

  • DissolveAnimation.ts:61 — the _completed array is module-level and reused across ticks to avoid allocation. The WARNING: Not re-entrant comment is appreciated, but this is fragile. Consider making _completed local to tickDissolveAnims — at typical dissolve counts (<50 entities), the per-frame allocation cost is negligible and eliminates a class of subtle bugs if the architecture ever changes. If perf is truly critical here, at minimum add a debug assertion that _completed.length === 0 at entry.

Potential Bugs / Issues

  1. onDepleted always returns true (TreeGLBVisualStrategy.ts:283) — The old code returned whether a depleted pool actually existed, which let ResourceEntity fall back to loading an individual depleted model. Now it unconditionally returns true. Verify that no code path in ResourceEntity.loadDepletedModel() depends on a false return to load a stump mesh for trees that need one. If all trees now use dissolve-only (no stump swap), this is fine — but worth a quick audit.

  2. Batched dissolve precision on long durations — The comment at GPUMaterials.ts:1199 notes Uint8 gives ~256 levels which is "smooth enough" at 0.3s/60fps (~18 steps). This is correct, but DISSOLVE_DURATION is now in a public config object. If someone increases it to e.g. 2.0s, that's ~120 steps which is still fine — but at 5+ seconds, banding would become visible. Consider adding a brief note in the config comment about the practical upper bound, or clamping in the animation tick.

  3. startDissolve with instant=true and direction=1 on depletion — In onDepleted, startInstancedDissolve(ctx.id, 1, true) is called. This sets dissolve to DISSOLVE_MAX instantly and deletes the animation entry. Good. But in onRespawn, startInstancedDissolve(ctx.id, -1) is called without instant, so it animates. This asymmetry (instant depletion, animated respawn) is presumably intentional per the PR description ("80% transparent instantly on depletion"), but worth confirming the UX intent since DISSOLVE_ALPHA_SCALE = 0.7 means only 70% of fragments are discarded, not 80% opacity reduction.

Performance

  • The dissolve tick in tickDissolveAnims iterates only active animations, not all instances — good.
  • applyDissolveColor in the batched instancer reads from batches[0] to skip redundant writes — nice optimization.
  • applyDissolveValue in the instanced path uses the dissolveDirty flag to batch GPU uploads — correct approach.
  • LOD transitions now read dissolve state before removing, which adds one getColorAt call per LOD swap (batched path). This is negligible.

No performance concerns.

Security

No security concerns — this is purely client-side rendering logic with no external inputs.

Test Coverage

No tests were added for DissolveAnimation.ts. This module has self-contained, pure-ish logic (state machine + callbacks) that would be straightforward to unit test:

  • startDissolve with instant vs animated, forward vs reverse
  • tickDissolveAnims advancing progress, clamping, and completing
  • Interrupting a dissolve mid-animation (direction reversal)
  • Edge case: deltaTime = 0, very large deltaTime (jump past target)

Per the project's testing philosophy ("NO MOCKS — use real Hyperscape instances"), integration tests with Playwright would be ideal, but the DissolveAnimation module is pure logic that could reasonably be unit-tested with Vitest since it doesn't touch the DOM or GPU.

Minor Nits

  • GLBTreeInstancer.ts:467pool.lod0! non-null assertion. This is safe since lod0 is always created in ensureModelPool, but the assertion could mask a future refactor. A guard would be more defensive.
  • TreeGLBVisualStrategy.ts:125 — the update signature changed from update(): void to update(_ctx, deltaTime). Confirm the ResourceVisualStrategy interface was updated to match, or this will cause a type error if the interface enforces the old signature.

Summary

Clean, well-documented PR that simplifies the tree depletion flow. The main actionable item is adding unit tests for DissolveAnimation.ts. The onDepleted always-true return is worth a quick audit against ResourceEntity callers. Ship it after addressing test coverage.

@dreaminglucid
dreaminglucid merged commit c8684ac into main Mar 27, 2026
10 of 11 checks passed
@lalalune
lalalune deleted the feat/tree-dissolve-transparency branch June 3, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant