feat(trees): add dissolve transparency for depleted trees - #1101
Conversation
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.
PR Review: feat(trees): add dissolve transparency for depleted treesOverall 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
|
- 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
PR Review: feat(trees): add dissolve transparency for depleted treesGood 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
Correctness Issues
Code Quality
Minor
SummaryThe core approach is sound — dissolve via shader attributes is much better than swapping mesh pools. The main risk is the performance impact of |
- 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%
PR Review: feat(trees): add dissolve transparency for depleted treesGood 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 Concerns1. 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:
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 Bugs4. 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:
(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 Quality7. 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
SummaryThe core approach is sound - dissolve-by-transparency is the right call over mesh-swapping for visual quality. The main risks are:
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
PR Review: feat(trees): add dissolve transparency for depleted treesOverall 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 Issues1. Shader highlight false-positive with blue channel (low risk) Since dissolve encodes as 2. 3. A future optimization could be to use 4. Missing dissolve state cleanup in 5. LOD transition preserves dissolve state — good Code Quality — Positive Notes
Minor Suggestions
SummarySolid 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 |
- 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
PR Review: feat(trees): add dissolve transparency for depleted treesOverall 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 QualitySignificant code duplication between The // shared/world/dissolveAnimation.ts
export function tickDissolveAnims(
anims: Map<string, DissolveAnim>,
deltaTime: number,
applyFn: (id: string, value: number) => void,
): void { ... }
Potential Bugs / Issues
Performance Considerations
SecurityNo concerns — this is purely client-side rendering logic with no user input, network calls, or state that could be exploited. TestingNo 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:
Minor Nits
SummarySolid implementation that cleanly replaces a model-swap approach with GPU-driven dissolve. The main actionable items are: (1) verify no caller depends on |
…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
|
test |
PR Review: feat(trees) add dissolve transparency for depleted treesOverall 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
What looks good
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
PR Review: feat(trees): add dissolve transparency for depleted treesOverall this is a well-structured change — extracting shared dissolve logic into Bug:
|
…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.
PR Review: feat(trees): add dissolve transparency for depleted treesNice 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 QualityStrengths:
Suggestions:
Performance Concern:
|
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
PR Review: feat(trees): add dissolve transparency for depleted treesNice refactor — replacing the separate depleted model pool with a per-instance dissolve effect is a significant simplification. The shared Positives
Potential Issues
Minor Suggestions
SummaryThis is a well-executed simplification. The main things to verify before merging are: (1) |
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.
Code Review: feat(trees): add dissolve transparency for depleted treesNice 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
Potential Issues
Performance Considerations
Minor Nits
SummaryOverall 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
PR Review: feat(trees): add dissolve transparency for depleted treesNice refactor — replacing the separate depleted model pool with in-place dissolve dithering is a solid architectural simplification. The shared Code Quality & ArchitectureStrengths:
Minor suggestions:
Potential Bugs / Issues
Performance
SecurityNo concerns — this is purely client-side rendering logic with no user input, network calls, or state persistence. Test CoverageThe 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:
SummaryThis 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:
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
PR Review: feat(trees): add dissolve transparency for depleted treesOverall AssessmentClean, 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 Code Quality & ArchitecturePositive:
Minor concerns:
Potential Bugs
Performance Considerations
SecurityNo security concerns — this is purely client-side rendering logic with no user input, network boundaries, or data persistence involved. Test CoverageNo tests are included in this PR. Given the project's Playwright-based visual testing philosophy from CLAUDE.md, a test that:
...would significantly increase confidence in this change. The dithering effect and the LOD-swap dissolve preservation are the highest-risk areas for visual regressions. SummaryThis is a solid improvement — simpler architecture, fewer GPU resources (no depleted model pools), and a better visual effect. The main actionable items are:
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.
PR Review: feat(trees): add dissolve transparency for depleted treesNice refactor — replacing the separate depleted model pool with a per-instance dissolve effect is a solid simplification. The shared Code Quality & ArchitectureStrengths:
Suggestions:
Potential Bugs
Performance
SecurityNo concerns — this is purely client-side rendering logic with no user input, network calls, or data persistence. Test CoverageThe PR doesn't include tests. Given the project's Playwright-based visual testing philosophy (per CLAUDE.md), this feature would benefit from:
SummaryThis 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.
PR Review: feat(trees): add dissolve transparency for depleted treesOverall this is a well-structured refactor that replaces the separate depleted model pool approach with a shader-driven dissolve effect. The shared Strengths
Issues & Suggestions1. Module-level 2. 3. BatchedMesh blue channel encoding limits dissolve precision (low) 4. 5. Highlight detection in shader now only checks R/G (correctness — verify) 6. Performance
SecurityNo concerns — this is purely client-side rendering logic with no user input, network, or persistence surface. Test CoverageNo tests are included in this PR. Given the project's Playwright-based visual testing philosophy, consider adding a test that:
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 |
…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).
PR Review: feat(trees): add dissolve transparency for depleted treesSummaryThis 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 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 QualityStrengths:
Suggestions:
Potential Bugs / Issues
Performance
SecurityNo concerns — this is purely client-side rendering logic with no user input, network I/O, or secret handling. Test CoverageNo 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:
Minor Nits
VerdictSolid PR. The architectural simplification (removing depleted pools entirely in favor of per-instance dissolve) is a clear improvement. The shared |
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.
PR Review: feat(trees): add dissolve transparency for depleted treesOverall AssessmentThis 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 Strengths
Potential Issues
Minor Nits
PerformanceThe approach is performance-positive overall:
SecurityNo concerns — this is purely client-side rendering logic with no external inputs. Test CoverageNo tests are included in this PR. Given the project's Playwright-based visual testing approach, a test that:
...would be valuable to prevent regressions in this visual behavior. VerdictStrong 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.
PR Review: feat(trees): add dissolve transparency for depleted treesOverall 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 Code Quality & ArchitectureStrengths:
Suggestions:
Potential Bugs / Issues
Performance
No performance concerns. SecurityNo security concerns — this is purely client-side rendering logic with no external inputs. Test CoverageNo tests were added for
Per the project's testing philosophy ("NO MOCKS — use real Hyperscape instances"), integration tests with Playwright would be ideal, but the Minor Nits
SummaryClean, well-documented PR that simplifies the tree depletion flow. The main actionable item is adding unit tests for |
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.