From 87f3f1218739c8a909182eccd270d2884f4a086d Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 07:24:01 -0400 Subject: [PATCH 01/17] feat(trees): add dissolve transparency for depleted trees 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. --- .../world/visuals/TreeGLBVisualStrategy.ts | 35 ++++-- .../shared/world/GLBTreeBatchedInstancer.ts | 109 +++++++++++++++- .../systems/shared/world/GLBTreeInstancer.ts | 117 ++++++++++++++++-- .../src/systems/shared/world/GPUMaterials.ts | 15 ++- 4 files changed, 254 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index c1389a494..0b9918444 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -15,24 +15,22 @@ import { MeshBasicNodeMaterial } from "three/webgpu"; import { addInstance as addInstancedTree, removeInstance as removeInstancedTree, - setDepleted as setInstancedDepleted, - hasDepleted as hasInstancedDepleted, setHighlight as setInstancedHighlight, getModelDimensions as getInstancedDimensions, getProxyGeometry as getInstancedProxyGeometry, hasInstance as isInInstancedPool, updateGLBTreeInstancer, + startDissolve as startInstancedDissolve, } from "../../../systems/shared/world/GLBTreeInstancer"; import { addInstance as addBatchedTree, removeInstance as removeBatchedTree, - setDepleted as setBatchedDepleted, - hasDepleted as hasBatchedDepleted, setHighlight as setBatchedHighlight, getModelDimensions as getBatchedDimensions, getProxyGeometry as getBatchedProxyGeometry, hasInstance as isInBatchedPool, updateGLBTreeBatchedInstancer, + startDissolve as startBatchedDissolve, } from "../../../systems/shared/world/GLBTreeBatchedInstancer"; import type { ResourceVisualContext, @@ -266,22 +264,36 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { if (success) { createCollisionProxy(ctx, baseScale, !!config.modelVariants?.length); + + // If tree starts depleted (initial load), set dissolve instantly (no animation) + if (config.depleted) { + if (config.modelVariants?.length) { + startBatchedDissolve(id, 1, true); + } else { + startInstancedDissolve(id, 1, true); + } + const proxy = ctx.getMesh(); + if (proxy) { + proxy.userData.depleted = true; + proxy.userData.interactable = false; + } + } } } async onDepleted(ctx: ResourceVisualContext): Promise { - const b = isBatched(ctx.id); - if (b) { - setBatchedDepleted(ctx.id, true); + // Instant dissolve — immediate visual feedback on depletion + if (isBatched(ctx.id)) { + startBatchedDissolve(ctx.id, 1, true); } else { - setInstancedDepleted(ctx.id, true); + startInstancedDissolve(ctx.id, 1, true); } const proxy = ctx.getMesh(); if (proxy) { proxy.userData.depleted = true; proxy.userData.interactable = false; } - return b ? hasBatchedDepleted(ctx.id) : hasInstancedDepleted(ctx.id); + return true; } setShaderHighlight(ctx: ResourceVisualContext, on: boolean): void { @@ -293,10 +305,11 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { } async onRespawn(ctx: ResourceVisualContext): Promise { + // Start reverse dissolve animation (trunk → canopy) if (isBatched(ctx.id)) { - setBatchedDepleted(ctx.id, false); + startBatchedDissolve(ctx.id, -1); } else { - setInstancedDepleted(ctx.id, false); + startInstancedDissolve(ctx.id, -1); } const proxy = ctx.getMesh(); if (proxy) { diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 06a4c1ceb..372b19b54 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -618,9 +618,12 @@ function applyHighlightColor( ): void { const ids = pool.instanceIds.get(entityId); if (!ids) return; - const color = on ? _hlColor : _defaultColor; for (let i = 0; i < pool.batches.length; i++) { - pool.batches[i].setColorAt(ids[i], color); + // Preserve blue channel (encodes dissolve state) + pool.batches[i].getColorAt(ids[i], _tmpColor); + const blue = _tmpColor.b; + _tmpColor.setRGB(on ? 1.15 : 1.0, on ? 1.15 : 1.0, blue); + pool.batches[i].setColorAt(ids[i], _tmpColor); } } @@ -650,6 +653,7 @@ export function destroyGLBTreeBatchedInstancer(): void { pools.clear(); entityToTreeType.clear(); pendingEnsure.clear(); + dissolveAnims.clear(); scene = null; world = null; } @@ -717,6 +721,7 @@ export function removeInstance(entityId: string): void { pool.instances.delete(entityId); entityToTreeType.delete(entityId); + dissolveAnims.delete(entityId); } function getLodPool(pool: TreeTypePool, slot: TreeSlot): BatchedLODPool | null { @@ -851,6 +856,78 @@ export function clearHighlight(): void { } } +// ---- Height-based dissolve (tree depletion/respawn) ---- + +const DISSOLVE_DURATION = 0.3; +const DISSOLVE_MAX = 1.0; + +interface DissolveAnim { + direction: 1 | -1; + progress: number; +} + +const dissolveAnims = new Map(); + +function applyDissolveColor( + pool: BatchedLODPool, + entityId: string, + dissolveVal: number, +): void { + const ids = pool.instanceIds.get(entityId); + if (!ids) return; + for (let i = 0; i < pool.batches.length; i++) { + pool.batches[i].getColorAt(ids[i], _tmpColor); + // Encode dissolve in blue channel: blue = 1.0 - dissolveVal + _tmpColor.b = 1.0 - dissolveVal; + pool.batches[i].setColorAt(ids[i], _tmpColor); + } +} + +function applyDissolveValue(entityId: string, value: number): void { + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; + + const pool = pools.get(treeType); + if (!pool) return; + + const slot = pool.instances.get(entityId); + if (!slot) return; + + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2]) { + if (!lodPool || !lodPool.instanceIds.has(entityId)) continue; + applyDissolveColor(lodPool, entityId, value); + return; + } +} + +/** + * Set dissolve value directly (no animation). 0 = visible, 1 = dissolved. + */ +export function setDissolve(entityId: string, value: number): void { + dissolveAnims.delete(entityId); + applyDissolveValue(entityId, value); +} + +/** + * Start a dissolve animation. direction=1 dissolves out (depletion), + * direction=-1 dissolves in (respawn). + */ +export function startDissolve( + entityId: string, + direction: 1 | -1, + instant = false, +): void { + if (instant) { + const target = direction > 0 ? DISSOLVE_MAX : 0.0; + applyDissolveValue(entityId, target); + dissolveAnims.delete(entityId); + return; + } + const current = direction > 0 ? 0.0 : DISSOLVE_MAX; + applyDissolveValue(entityId, current); + dissolveAnims.set(entityId, { direction, progress: current }); +} + let lastUpdateFrame = -1; export function updateGLBTreeBatchedInstancer(): void { @@ -895,7 +972,16 @@ export function updateGLBTreeBatchedInstancer(): void { const oldPool = getLodPool(pool, slot); const wasHl = oldPool ? isHighlighted(oldPool, slot.entityId) : false; - if (oldPool) removeFromPool(oldPool, slot.entityId); + // Read dissolve state from old pool's color before removing + let wasDissolveVal = 0; + if (oldPool) { + const oldIds = oldPool.instanceIds.get(slot.entityId); + if (oldIds && oldIds.length > 0) { + oldPool.batches[0].getColorAt(oldIds[0], _tmpColor); + wasDissolveVal = Math.max(0, 1.0 - _tmpColor.b); + } + removeFromPool(oldPool, slot.entityId); + } slot.currentLOD = targetLOD; @@ -909,10 +995,27 @@ export function updateGLBTreeBatchedInstancer(): void { ); addToPool(newPool, slot.entityId, mat, slot.variantIndex); if (wasHl) applyHighlightColor(newPool, slot.entityId, true); + if (wasDissolveVal > 0.001) { + applyDissolveColor(newPool, slot.entityId, wasDissolveVal); + } } } } + // Tick dissolve animations + const dt = 1 / 60; + for (const [entityId, anim] of dissolveAnims) { + anim.progress += (anim.direction * dt) / DISSOLVE_DURATION; + anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); + applyDissolveValue(entityId, anim.progress); + if ( + (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || + (anim.direction < 0 && anim.progress <= 0) + ) { + dissolveAnims.delete(entityId); + } + } + // Update dissolve uniforms const camY = camPos.y; const players = world.getPlayers(); diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index ff929d9a8..ee4098e35 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -59,6 +59,8 @@ interface LODPool { dirty: boolean; /** Shared backing array for per-instance highlight intensity (0 or 1) */ highlightData: Float32Array; + /** Shared backing array for per-instance dissolve progress (0 = visible, 1 = dissolved) */ + dissolveData: Float32Array; /** * Snapshot of original source geometries (before InstancedBufferAttribute * additions). Retained so collision proxies can use the model shape without @@ -163,6 +165,7 @@ function createLODPool( const materials: DissolveMaterial[] = []; const sourceGeometries: THREE.BufferGeometry[] = []; const hlData = new Float32Array(MAX_INSTANCES); + const dissolveData = new Float32Array(MAX_INSTANCES); for (const part of parts) { // Store the original geometry before adding instanced attributes sourceGeometries.push(part.geometry); @@ -173,6 +176,10 @@ function createLODPool( hlAttr.setUsage(THREE.DynamicDrawUsage); geo.setAttribute("instanceHighlight", hlAttr); + const dsAttr = new THREE.InstancedBufferAttribute(dissolveData, 1); + dsAttr.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute("instanceDissolve", dsAttr); + const im = new THREE.InstancedMesh(geo, part.material, MAX_INSTANCES); im.count = 0; im.frustumCulled = false; @@ -190,6 +197,7 @@ function createLODPool( activeCount: 0, dirty: false, highlightData: hlData, + dissolveData, sourceGeometries, }; } @@ -418,6 +426,7 @@ function removeFromPool(pool: LODPool, entityId: string): void { im.setMatrixAt(idx, _swapMatrix); } pool.highlightData[idx] = pool.highlightData[lastIdx]; + pool.dissolveData[idx] = pool.dissolveData[lastIdx]; for (const [eid, eidIdx] of pool.slots) { if (eidIdx === lastIdx) { @@ -427,6 +436,7 @@ function removeFromPool(pool: LODPool, entityId: string): void { } } pool.highlightData[lastIdx] = 0; + pool.dissolveData[lastIdx] = 0; pool.slots.delete(entityId); pool.activeCount--; @@ -462,6 +472,7 @@ export function destroyGLBTreeInstancer(): void { pools.clear(); entityToModel.clear(); pendingEnsure.clear(); + dissolveAnims.clear(); scene = null; world = null; } @@ -541,6 +552,7 @@ export function removeInstance(entityId: string): void { pool.instances.delete(entityId); entityToModel.delete(entityId); + dissolveAnims.delete(entityId); } export function setDepleted(entityId: string, depleted: boolean): void { @@ -708,6 +720,72 @@ export function clearHighlight(): void { } } +// ---- Height-based dissolve (tree depletion/respawn) ---- + +const DISSOLVE_DURATION = 0.3; // seconds for respawn dissolve-in +const DISSOLVE_MAX = 1.0; + +interface DissolveAnim { + /** 1 = dissolving out, -1 = appearing in */ + direction: 1 | -1; + progress: number; +} + +const dissolveAnims = new Map(); + +function applyDissolveValue(entityId: string, value: number): void { + const modelPath = entityToModel.get(entityId); + if (!modelPath) return; + + const pool = pools.get(modelPath); + if (!pool) return; + + const slot = pool.instances.get(entityId); + if (!slot) return; + + // Apply to whichever LOD pool the instance is currently in + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2]) { + if (!lodPool) continue; + const idx = lodPool.slots.get(entityId); + if (idx === undefined) continue; + + lodPool.dissolveData[idx] = value; + for (const im of lodPool.meshes) { + const attr = im.geometry.getAttribute("instanceDissolve"); + if (attr) (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + return; + } +} + +/** + * Set dissolve value directly (no animation). 0 = visible, 1 = dissolved. + */ +export function setDissolve(entityId: string, value: number): void { + dissolveAnims.delete(entityId); + applyDissolveValue(entityId, value); +} + +/** + * Start a dissolve animation. direction=1 dissolves out (depletion), + * direction=-1 dissolves in (respawn). + */ +export function startDissolve( + entityId: string, + direction: 1 | -1, + instant = false, +): void { + if (instant) { + const target = direction > 0 ? DISSOLVE_MAX : 0.0; + applyDissolveValue(entityId, target); + dissolveAnims.delete(entityId); + return; + } + const current = direction > 0 ? 0.0 : DISSOLVE_MAX; + applyDissolveValue(entityId, current); + dissolveAnims.set(entityId, { direction, progress: current }); +} + let lastUpdateFrame = -1; export function updateGLBTreeInstancer(): void { @@ -760,10 +838,13 @@ export function updateGLBTreeInstancer(): void { const newPool = targetLOD === 0 ? pool.lod0 : targetLOD === 1 ? pool.lod1 : pool.lod2; - const wasHighlighted = - oldPool && oldPool.slots.has(slot.entityId) - ? oldPool.highlightData[oldPool.slots.get(slot.entityId)!] - : 0; + let wasHighlighted = 0; + let wasDissolve = 0; + if (oldPool && oldPool.slots.has(slot.entityId)) { + const oldIdx = oldPool.slots.get(slot.entityId)!; + wasHighlighted = oldPool.highlightData[oldIdx]; + wasDissolve = oldPool.dissolveData[oldIdx]; + } if (oldPool) removeFromPool(oldPool, slot.entityId); if (newPool) { const mat = composeInstanceMatrix( @@ -773,9 +854,9 @@ export function updateGLBTreeInstancer(): void { slot.yOffset, ); addToPool(newPool, slot.entityId, mat); - if (wasHighlighted > 0) { - const newIdx = newPool.slots.get(slot.entityId); - if (newIdx !== undefined) { + const newIdx = newPool.slots.get(slot.entityId); + if (newIdx !== undefined) { + if (wasHighlighted > 0) { newPool.highlightData[newIdx] = wasHighlighted; for (const im of newPool.meshes) { const attr = im.geometry.getAttribute("instanceHighlight"); @@ -783,12 +864,34 @@ export function updateGLBTreeInstancer(): void { (attr as THREE.InstancedBufferAttribute).needsUpdate = true; } } + if (wasDissolve > 0) { + newPool.dissolveData[newIdx] = wasDissolve; + for (const im of newPool.meshes) { + const attr = im.geometry.getAttribute("instanceDissolve"); + if (attr) + (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + } } } slot.currentLOD = targetLOD; } } + // Tick dissolve animations + const dt = 1 / 60; // fixed step (trees use frame-based update, not real deltaTime) + for (const [entityId, anim] of dissolveAnims) { + anim.progress += (anim.direction * dt) / DISSOLVE_DURATION; + anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); + applyDissolveValue(entityId, anim.progress); + if ( + (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || + (anim.direction < 0 && anim.progress <= 0) + ) { + dissolveAnims.delete(entityId); + } + } + // Flush dirty pools + update dissolve uniforms const camY = camPos.y; const players = world.getPlayers(); diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index be8538b23..44e933cba 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -1167,9 +1167,22 @@ export function createTreeDissolveMaterial( // ---- Sky-color fog ---- const fogged = mix(finalRgb, treeFogTex.rgb, treeFogFactor); - return vec4(fogged, pbrOut.a); + // ---- Dissolve transparency (depleted trees) ---- + // Uniform 80% transparency across the whole tree when dissolved. + const dissolveVal = options.batched + ? clamp( + sub(float(1.0), varyingProperty("vec3", "vBatchColor").z), + float(0.0), + float(1.0), + ) + : attribute("instanceDissolve", "float"); + const dissolveAlpha = sub(float(1.0), mul(dissolveVal, float(0.8))); + + return vec4(fogged, mul(pbrOut.a, dissolveAlpha)); })(); + material.transparent = true; + material.depthWrite = true; material.needsUpdate = true; const treeMat = baseDm as TreeDissolveMaterial; From 1aa5f171089ad8ec86e3cb014b1eeee37aebf351 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 07:35:39 -0400 Subject: [PATCH 02/17] fix(trees): address PR review feedback on dissolve transparency - 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 --- .../world/visuals/TreeGLBVisualStrategy.ts | 6 +- .../shared/world/GLBTreeBatchedInstancer.ts | 72 ++-------------- .../systems/shared/world/GLBTreeInstancer.ts | 82 ++----------------- .../src/systems/shared/world/GPUMaterials.ts | 4 + 4 files changed, 19 insertions(+), 145 deletions(-) diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index 0b9918444..4dd1b4d0b 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -318,9 +318,9 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { } } - update(): void { - updateGLBTreeInstancer(); - updateGLBTreeBatchedInstancer(); + update(_ctx: ResourceVisualContext, deltaTime: number): void { + updateGLBTreeInstancer(deltaTime); + updateGLBTreeBatchedInstancer(deltaTime); } destroy(ctx: ResourceVisualContext): void { diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 372b19b54..3ba0592af 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -34,8 +34,9 @@ const _position = new THREE.Vector3(); const _quaternion = new THREE.Quaternion(); const _scale = new THREE.Vector3(); +// Blue channel of batch colors encodes dissolve state (blue = 1.0 - dissolveVal). +// Do NOT change the blue component of these defaults without updating applyDissolveColor. const _defaultColor = new THREE.Color(1, 1, 1); -const _hlColor = new THREE.Color(1.15, 1.15, 1.15); interface TreeSlot { entityId: string; @@ -733,52 +734,6 @@ function getLodPool(pool: TreeTypePool, slot: TreeSlot): BatchedLODPool | null { : pool.lod2; } -export function setDepleted(entityId: string, depleted: boolean): void { - const treeType = entityToTreeType.get(entityId); - if (!treeType) return; - - const pool = pools.get(treeType); - if (!pool) return; - - const slot = pool.instances.get(entityId); - if (!slot || slot.depleted === depleted) return; - - slot.depleted = depleted; - - if (depleted) { - const lodPool = getLodPool(pool, slot); - if (lodPool) removeFromPool(lodPool, entityId); - - if (pool.depleted) { - const mat = composeInstanceMatrix( - slot.position, - slot.rotation, - slot.depletedScale, - pool.depletedYOffset, - ); - addToPool(pool.depleted, entityId, mat, 0); - } - } else { - if (pool.depleted) { - removeFromPool(pool.depleted, entityId); - } - - const mat = composeInstanceMatrix( - slot.position, - slot.rotation, - slot.scale, - slot.yOffset, - ); - const lodPool = - slot.currentLOD === 0 - ? pool.lod0 - : slot.currentLOD === 1 - ? pool.lod1 - : pool.lod2; - if (lodPool) addToPool(lodPool, entityId, mat, slot.variantIndex); - } -} - export function hasInstance(entityId: string): boolean { return entityToTreeType.has(entityId); } @@ -820,13 +775,6 @@ export function getProxyGeometry( }; } -export function hasDepleted(entityId: string): boolean { - const treeType = entityToTreeType.get(entityId); - if (!treeType) return false; - const pool = pools.get(treeType); - return !!pool?.depleted; -} - let highlightedEntityId: string | null = null; export function setHighlight(entityId: string, on: boolean): void { @@ -900,14 +848,6 @@ function applyDissolveValue(entityId: string, value: number): void { } } -/** - * Set dissolve value directly (no animation). 0 = visible, 1 = dissolved. - */ -export function setDissolve(entityId: string, value: number): void { - dissolveAnims.delete(entityId); - applyDissolveValue(entityId, value); -} - /** * Start a dissolve animation. direction=1 dissolves out (depletion), * direction=-1 dissolves in (respawn). @@ -930,7 +870,7 @@ export function startDissolve( let lastUpdateFrame = -1; -export function updateGLBTreeBatchedInstancer(): void { +export function updateGLBTreeBatchedInstancer(deltaTime?: number): void { if (!world) return; if (world.frame === lastUpdateFrame) return; lastUpdateFrame = world.frame; @@ -1003,7 +943,8 @@ export function updateGLBTreeBatchedInstancer(): void { } // Tick dissolve animations - const dt = 1 / 60; + const dt = deltaTime ?? 1 / 60; + const completed: string[] = []; for (const [entityId, anim] of dissolveAnims) { anim.progress += (anim.direction * dt) / DISSOLVE_DURATION; anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); @@ -1012,9 +953,10 @@ export function updateGLBTreeBatchedInstancer(): void { (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || (anim.direction < 0 && anim.progress <= 0) ) { - dissolveAnims.delete(entityId); + completed.push(entityId); } } + for (const id of completed) dissolveAnims.delete(id); // Update dissolve uniforms const camY = camPos.y; diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index ee4098e35..1a250d8ff 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -555,61 +555,6 @@ export function removeInstance(entityId: string): void { dissolveAnims.delete(entityId); } -export function setDepleted(entityId: string, depleted: boolean): void { - const modelPath = entityToModel.get(entityId); - if (!modelPath) return; - - const pool = pools.get(modelPath); - if (!pool) return; - - const slot = pool.instances.get(entityId); - if (!slot || slot.depleted === depleted) return; - - slot.depleted = depleted; - - if (depleted) { - // Remove from living LOD pool - const lodPool = - slot.currentLOD === 0 - ? pool.lod0 - : slot.currentLOD === 1 - ? pool.lod1 - : pool.lod2; - if (lodPool) removeFromPool(lodPool, entityId); - - // Add to depleted pool (instanced stump) - if (pool.depleted) { - const mat = composeInstanceMatrix( - slot.position, - slot.rotation, - slot.depletedScale, - pool.depletedYOffset, - ); - addToPool(pool.depleted, entityId, mat); - } - } else { - // Remove from depleted pool - if (pool.depleted) { - removeFromPool(pool.depleted, entityId); - } - - // Re-add to living LOD pool - const mat = composeInstanceMatrix( - slot.position, - slot.rotation, - slot.scale, - slot.yOffset, - ); - const lodPool = - slot.currentLOD === 0 - ? pool.lod0 - : slot.currentLOD === 1 - ? pool.lod1 - : pool.lod2; - if (lodPool) addToPool(lodPool, entityId, mat); - } -} - export function hasInstance(entityId: string): boolean { return entityToModel.has(entityId); } @@ -655,17 +600,6 @@ export function getProxyGeometry( }; } -/** - * Returns true if the instancer has a depleted pool for this entity's model. - * When true, ResourceEntity can skip loading an individual depleted model. - */ -export function hasDepleted(entityId: string): boolean { - const modelPath = entityToModel.get(entityId); - if (!modelPath) return false; - const pool = pools.get(modelPath); - return !!pool?.depleted; -} - /** Track which entity is currently highlighted so we can clear it */ let highlightedEntityId: string | null = null; @@ -758,14 +692,6 @@ function applyDissolveValue(entityId: string, value: number): void { } } -/** - * Set dissolve value directly (no animation). 0 = visible, 1 = dissolved. - */ -export function setDissolve(entityId: string, value: number): void { - dissolveAnims.delete(entityId); - applyDissolveValue(entityId, value); -} - /** * Start a dissolve animation. direction=1 dissolves out (depletion), * direction=-1 dissolves in (respawn). @@ -788,7 +714,7 @@ export function startDissolve( let lastUpdateFrame = -1; -export function updateGLBTreeInstancer(): void { +export function updateGLBTreeInstancer(deltaTime?: number): void { if (!world) return; if (world.frame === lastUpdateFrame) return; lastUpdateFrame = world.frame; @@ -879,7 +805,8 @@ export function updateGLBTreeInstancer(): void { } // Tick dissolve animations - const dt = 1 / 60; // fixed step (trees use frame-based update, not real deltaTime) + const dt = deltaTime ?? 1 / 60; + const completed: string[] = []; for (const [entityId, anim] of dissolveAnims) { anim.progress += (anim.direction * dt) / DISSOLVE_DURATION; anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); @@ -888,9 +815,10 @@ export function updateGLBTreeInstancer(): void { (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || (anim.direction < 0 && anim.progress <= 0) ) { - dissolveAnims.delete(entityId); + completed.push(entityId); } } + for (const id of completed) dissolveAnims.delete(id); // Flush dirty pools + update dissolve uniforms const camY = camPos.y; diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 44e933cba..f179aedcf 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -1181,6 +1181,10 @@ export function createTreeDissolveMaterial( return vec4(fogged, mul(pbrOut.a, dissolveAlpha)); })(); + // transparent = true is required for real alpha blending on depleted trees. + // Non-depleted instances output alpha=1.0 (dissolveVal=0) so they don't incur + // sorting overhead — Three.js skips sort for opaque fragments in the transparent pass. + // depthWrite stays true to prevent z-fighting between overlapping trees. material.transparent = true; material.depthWrite = true; material.needsUpdate = true; From 37f2653335dec7b569a3853351e44400b9c9bd98 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 07:45:12 -0400 Subject: [PATCH 03/17] fix(trees): address second round of PR review feedback - 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% --- .../shared/world/GLBTreeBatchedInstancer.ts | 24 ++++++++++++------- .../systems/shared/world/GLBTreeInstancer.ts | 14 ++++++----- .../src/systems/shared/world/GPUMaterials.ts | 11 +++++---- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 3ba0592af..dcee035ed 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -612,6 +612,9 @@ function isHighlighted(pool: BatchedLODPool, entityId: string): boolean { return _tmpColor.r > 1.01; } +/** Highlight multiplier for R/G channels (>1.0 brightens; shader detects via step(1.01)) */ +const HL_COLOR_INTENSITY = 1.15; + function applyHighlightColor( pool: BatchedLODPool, entityId: string, @@ -619,11 +622,11 @@ function applyHighlightColor( ): void { const ids = pool.instanceIds.get(entityId); if (!ids) return; + const rg = on ? HL_COLOR_INTENSITY : 1.0; for (let i = 0; i < pool.batches.length; i++) { // Preserve blue channel (encodes dissolve state) pool.batches[i].getColorAt(ids[i], _tmpColor); - const blue = _tmpColor.b; - _tmpColor.setRGB(on ? 1.15 : 1.0, on ? 1.15 : 1.0, blue); + _tmpColor.setRGB(rg, rg, _tmpColor.b); pool.batches[i].setColorAt(ids[i], _tmpColor); } } @@ -870,7 +873,10 @@ export function startDissolve( let lastUpdateFrame = -1; -export function updateGLBTreeBatchedInstancer(deltaTime?: number): void { +// Reused across ticks to avoid per-frame allocation +const _completedAnims: string[] = []; + +export function updateGLBTreeBatchedInstancer(deltaTime: number): void { if (!world) return; if (world.frame === lastUpdateFrame) return; lastUpdateFrame = world.frame; @@ -912,7 +918,8 @@ export function updateGLBTreeBatchedInstancer(deltaTime?: number): void { const oldPool = getLodPool(pool, slot); const wasHl = oldPool ? isHighlighted(oldPool, slot.entityId) : false; - // Read dissolve state from old pool's color before removing + // Read dissolve state from old pool's color before removing. + // Safe to sample batches[0] only — applyDissolveColor sets all batches uniformly. let wasDissolveVal = 0; if (oldPool) { const oldIds = oldPool.instanceIds.get(slot.entityId); @@ -943,20 +950,19 @@ export function updateGLBTreeBatchedInstancer(deltaTime?: number): void { } // Tick dissolve animations - const dt = deltaTime ?? 1 / 60; - const completed: string[] = []; + _completedAnims.length = 0; for (const [entityId, anim] of dissolveAnims) { - anim.progress += (anim.direction * dt) / DISSOLVE_DURATION; + anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); applyDissolveValue(entityId, anim.progress); if ( (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || (anim.direction < 0 && anim.progress <= 0) ) { - completed.push(entityId); + _completedAnims.push(entityId); } } - for (const id of completed) dissolveAnims.delete(id); + for (const id of _completedAnims) dissolveAnims.delete(id); // Update dissolve uniforms const camY = camPos.y; diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 1a250d8ff..7245ce026 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -714,7 +714,10 @@ export function startDissolve( let lastUpdateFrame = -1; -export function updateGLBTreeInstancer(deltaTime?: number): void { +// Reused across ticks to avoid per-frame allocation +const _completedAnims: string[] = []; + +export function updateGLBTreeInstancer(deltaTime: number): void { if (!world) return; if (world.frame === lastUpdateFrame) return; lastUpdateFrame = world.frame; @@ -805,20 +808,19 @@ export function updateGLBTreeInstancer(deltaTime?: number): void { } // Tick dissolve animations - const dt = deltaTime ?? 1 / 60; - const completed: string[] = []; + _completedAnims.length = 0; for (const [entityId, anim] of dissolveAnims) { - anim.progress += (anim.direction * dt) / DISSOLVE_DURATION; + anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); applyDissolveValue(entityId, anim.progress); if ( (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || (anim.direction < 0 && anim.progress <= 0) ) { - completed.push(entityId); + _completedAnims.push(entityId); } } - for (const id of completed) dissolveAnims.delete(id); + for (const id of _completedAnims) dissolveAnims.delete(id); // Flush dirty pools + update dissolve uniforms const camY = camPos.y; diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index f179aedcf..5a7dac5ab 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -1176,15 +1176,18 @@ export function createTreeDissolveMaterial( float(1.0), ) : attribute("instanceDissolve", "float"); - const dissolveAlpha = sub(float(1.0), mul(dissolveVal, float(0.8))); + const dissolveAlpha = sub(float(1.0), mul(dissolveVal, float(0.7))); return vec4(fogged, mul(pbrOut.a, dissolveAlpha)); })(); // transparent = true is required for real alpha blending on depleted trees. - // Non-depleted instances output alpha=1.0 (dissolveVal=0) so they don't incur - // sorting overhead — Three.js skips sort for opaque fragments in the transparent pass. - // depthWrite stays true to prevent z-fighting between overlapping trees. + // This does put all tree InstancedMesh objects into the transparent render pass, + // but Three.js sorts per-object (not per-instance), so the cost is proportional + // to the number of InstancedMesh objects (~one per model per LOD level), not the + // total tree count. depthWrite stays true so non-depleted instances (alpha=1.0) + // still write depth correctly; the rare case of overlapping depleted trees may + // show minor ordering artifacts, but depleted trees are sparse in practice. material.transparent = true; material.depthWrite = true; material.needsUpdate = true; From 7433ce05c398617c84a3dac48c27c1d486929a93 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 07:58:10 -0400 Subject: [PATCH 04/17] fix(trees): batch dissolve GPU uploads, add defensive clamp, name constants - 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 --- .../shared/world/GLBTreeBatchedInstancer.ts | 2 +- .../systems/shared/world/GLBTreeInstancer.ts | 19 ++++++++++++++----- .../src/systems/shared/world/GPUMaterials.ts | 8 ++++++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index dcee035ed..0ab6fe741 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -925,7 +925,7 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void { const oldIds = oldPool.instanceIds.get(slot.entityId); if (oldIds && oldIds.length > 0) { oldPool.batches[0].getColorAt(oldIds[0], _tmpColor); - wasDissolveVal = Math.max(0, 1.0 - _tmpColor.b); + wasDissolveVal = Math.max(0, Math.min(1, 1.0 - _tmpColor.b)); } removeFromPool(oldPool, slot.entityId); } diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 7245ce026..6d581e925 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -57,6 +57,8 @@ interface LODPool { slots: Map; activeCount: number; dirty: boolean; + /** True when dissolveData has changed and needs GPU upload */ + dissolveDirty: boolean; /** Shared backing array for per-instance highlight intensity (0 or 1) */ highlightData: Float32Array; /** Shared backing array for per-instance dissolve progress (0 = visible, 1 = dissolved) */ @@ -196,6 +198,7 @@ function createLODPool( slots: new Map(), activeCount: 0, dirty: false, + dissolveDirty: false, highlightData: hlData, dissolveData, sourceGeometries, @@ -677,17 +680,15 @@ function applyDissolveValue(entityId: string, value: number): void { const slot = pool.instances.get(entityId); if (!slot) return; - // Apply to whichever LOD pool the instance is currently in + // Apply to whichever LOD pool the instance is currently in. + // Sets dissolveDirty — the update loop flushes needsUpdate once per pool. for (const lodPool of [pool.lod0, pool.lod1, pool.lod2]) { if (!lodPool) continue; const idx = lodPool.slots.get(entityId); if (idx === undefined) continue; lodPool.dissolveData[idx] = value; - for (const im of lodPool.meshes) { - const attr = im.geometry.getAttribute("instanceDissolve"); - if (attr) (attr as THREE.InstancedBufferAttribute).needsUpdate = true; - } + lodPool.dissolveDirty = true; return; } } @@ -848,6 +849,14 @@ export function updateGLBTreeInstancer(deltaTime: number): void { lodPool.dirty = false; } + if (lodPool.dissolveDirty) { + for (const im of lodPool.meshes) { + const attr = im.geometry.getAttribute("instanceDissolve"); + if (attr) (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + lodPool.dissolveDirty = false; + } + for (const mat of lodPool.materials) { mat.dissolveUniforms.cameraPos.value.set(camPos.x, camY, camPos.z); mat.dissolveUniforms.playerPos.value.set( diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 5a7dac5ab..f2f66f9fb 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -1168,7 +1168,8 @@ export function createTreeDissolveMaterial( const fogged = mix(finalRgb, treeFogTex.rgb, treeFogFactor); // ---- Dissolve transparency (depleted trees) ---- - // Uniform 80% transparency across the whole tree when dissolved. + // Max alpha reduction when fully dissolved (0.7 = 30% opacity at full dissolve) + const DISSOLVE_ALPHA_SCALE = 0.7; const dissolveVal = options.batched ? clamp( sub(float(1.0), varyingProperty("vec3", "vBatchColor").z), @@ -1176,7 +1177,10 @@ export function createTreeDissolveMaterial( float(1.0), ) : attribute("instanceDissolve", "float"); - const dissolveAlpha = sub(float(1.0), mul(dissolveVal, float(0.7))); + const dissolveAlpha = sub( + float(1.0), + mul(dissolveVal, float(DISSOLVE_ALPHA_SCALE)), + ); return vec4(fogged, mul(pbrOut.a, dissolveAlpha)); })(); From d23bfbca2c58d3b017a69ccd095a0bd18c9bde2c Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 08:05:13 -0400 Subject: [PATCH 05/17] fix(trees): harden highlight detection, deduplicate dissolve constants - 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 --- .../shared/world/GLBTreeBatchedInstancer.ts | 4 ++-- .../src/systems/shared/world/GLBTreeInstancer.ts | 4 ++-- .../src/systems/shared/world/GPUMaterials.ts | 14 ++++++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 0ab6fe741..9407fca67 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -809,8 +809,8 @@ export function clearHighlight(): void { // ---- Height-based dissolve (tree depletion/respawn) ---- -const DISSOLVE_DURATION = 0.3; -const DISSOLVE_MAX = 1.0; +const DISSOLVE_DURATION = GPU_VEG_CONFIG.DISSOLVE_DURATION; +const DISSOLVE_MAX = GPU_VEG_CONFIG.DISSOLVE_MAX; interface DissolveAnim { direction: 1 | -1; diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 6d581e925..8d6262e4f 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -659,8 +659,8 @@ export function clearHighlight(): void { // ---- Height-based dissolve (tree depletion/respawn) ---- -const DISSOLVE_DURATION = 0.3; // seconds for respawn dissolve-in -const DISSOLVE_MAX = 1.0; +const DISSOLVE_DURATION = GPU_VEG_CONFIG.DISSOLVE_DURATION; +const DISSOLVE_MAX = GPU_VEG_CONFIG.DISSOLVE_MAX; interface DissolveAnim { /** 1 = dissolving out, -1 = appearing in */ diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index f2f66f9fb..80e982fa3 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -126,6 +126,14 @@ export const GPU_VEG_CONFIG = { /** Distance from camera where geometry is fully dissolved (meters) - at near clip */ NEAR_CAMERA_FADE_END: 0.05, + + // ========== TREE DEPLETION DISSOLVE ========== + + /** Duration of the respawn dissolve-in animation (seconds). Depletion is instant. */ + DISSOLVE_DURATION: 0.3, + + /** Maximum dissolve progress (1.0 = fully dissolved) */ + DISSOLVE_MAX: 1.0, } as const; // ============================================================================ @@ -1145,10 +1153,8 @@ export function createTreeDissolveMaterial( let hlIntensity; if (options.batched) { const batchColor = varyingProperty("vec3", "vBatchColor"); - hlIntensity = step( - float(1.01), - max(batchColor.x, max(batchColor.y, batchColor.z)), - ); + // Only check R/G for highlight — blue channel is reserved for dissolve state + hlIntensity = step(float(1.01), max(batchColor.x, batchColor.y)); } else { hlIntensity = attribute("instanceHighlight", "float"); } From c2afdb9cdcda8243536f07cd705139dbd2c68c9f Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 08:11:36 -0400 Subject: [PATCH 06/17] refactor(trees): extract shared DissolveAnimation module, move constants to config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../systems/shared/world/DissolveAnimation.ts | 67 +++++++++++++++++++ .../shared/world/GLBTreeBatchedInstancer.ts | 56 ++++++---------- .../systems/shared/world/GLBTreeInstancer.ts | 52 ++++---------- .../src/systems/shared/world/GPUMaterials.ts | 6 +- 4 files changed, 104 insertions(+), 77 deletions(-) create mode 100644 packages/shared/src/systems/shared/world/DissolveAnimation.ts diff --git a/packages/shared/src/systems/shared/world/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts new file mode 100644 index 000000000..ef65a5570 --- /dev/null +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -0,0 +1,67 @@ +/** + * Shared dissolve animation state machine used by both GLBTreeInstancer + * and GLBTreeBatchedInstancer. Keeps the tick logic, constants, and + * cleanup in one place so the two instancers stay in sync. + */ + +import { GPU_VEG_CONFIG } from "./GPUMaterials"; + +const DISSOLVE_DURATION = GPU_VEG_CONFIG.DISSOLVE_DURATION; +const DISSOLVE_MAX = GPU_VEG_CONFIG.DISSOLVE_MAX; + +export interface DissolveAnim { + /** 1 = dissolving out, -1 = appearing in */ + direction: 1 | -1; + progress: number; +} + +/** Reused across ticks to avoid per-frame allocation */ +const _completed: string[] = []; + +/** + * Start or instantly apply a dissolve. + * + * @param anims The animation map to manage + * @param applyFn Callback that writes the dissolve value to the rendering backend + */ +export function startDissolve( + anims: Map, + entityId: string, + direction: 1 | -1, + instant: boolean, + applyFn: (entityId: string, value: number) => void, +): void { + if (instant) { + const target = direction > 0 ? DISSOLVE_MAX : 0.0; + applyFn(entityId, target); + anims.delete(entityId); + return; + } + const current = direction > 0 ? 0.0 : DISSOLVE_MAX; + applyFn(entityId, current); + anims.set(entityId, { direction, progress: current }); +} + +/** + * Advance all active dissolve animations by deltaTime and apply values. + * Completed animations are removed from the map. + */ +export function tickDissolveAnims( + anims: Map, + deltaTime: number, + applyFn: (entityId: string, value: number) => void, +): void { + _completed.length = 0; + for (const [entityId, anim] of anims) { + anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; + anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); + applyFn(entityId, anim.progress); + if ( + (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || + (anim.direction < 0 && anim.progress <= 0) + ) { + _completed.push(entityId); + } + } + for (const id of _completed) anims.delete(id); +} diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 9407fca67..896e01c62 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -807,15 +807,15 @@ export function clearHighlight(): void { } } -// ---- Height-based dissolve (tree depletion/respawn) ---- +// ---- Dissolve (tree depletion/respawn) ---- -const DISSOLVE_DURATION = GPU_VEG_CONFIG.DISSOLVE_DURATION; -const DISSOLVE_MAX = GPU_VEG_CONFIG.DISSOLVE_MAX; +import { + type DissolveAnim, + startDissolve as startDissolveAnim, + tickDissolveAnims, +} from "./DissolveAnimation"; -interface DissolveAnim { - direction: 1 | -1; - progress: number; -} +const DISSOLVE_MAX = GPU_VEG_CONFIG.DISSOLVE_MAX; const dissolveAnims = new Map(); @@ -851,31 +851,22 @@ function applyDissolveValue(entityId: string, value: number): void { } } -/** - * Start a dissolve animation. direction=1 dissolves out (depletion), - * direction=-1 dissolves in (respawn). - */ export function startDissolve( entityId: string, direction: 1 | -1, instant = false, ): void { - if (instant) { - const target = direction > 0 ? DISSOLVE_MAX : 0.0; - applyDissolveValue(entityId, target); - dissolveAnims.delete(entityId); - return; - } - const current = direction > 0 ? 0.0 : DISSOLVE_MAX; - applyDissolveValue(entityId, current); - dissolveAnims.set(entityId, { direction, progress: current }); + startDissolveAnim( + dissolveAnims, + entityId, + direction, + instant, + applyDissolveValue, + ); } let lastUpdateFrame = -1; -// Reused across ticks to avoid per-frame allocation -const _completedAnims: string[] = []; - export function updateGLBTreeBatchedInstancer(deltaTime: number): void { if (!world) return; if (world.frame === lastUpdateFrame) return; @@ -925,7 +916,10 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void { const oldIds = oldPool.instanceIds.get(slot.entityId); if (oldIds && oldIds.length > 0) { oldPool.batches[0].getColorAt(oldIds[0], _tmpColor); - wasDissolveVal = Math.max(0, Math.min(1, 1.0 - _tmpColor.b)); + wasDissolveVal = Math.max( + 0, + Math.min(DISSOLVE_MAX, 1.0 - _tmpColor.b), + ); } removeFromPool(oldPool, slot.entityId); } @@ -950,19 +944,7 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void { } // Tick dissolve animations - _completedAnims.length = 0; - for (const [entityId, anim] of dissolveAnims) { - anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; - anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); - applyDissolveValue(entityId, anim.progress); - if ( - (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || - (anim.direction < 0 && anim.progress <= 0) - ) { - _completedAnims.push(entityId); - } - } - for (const id of _completedAnims) dissolveAnims.delete(id); + tickDissolveAnims(dissolveAnims, deltaTime, applyDissolveValue); // Update dissolve uniforms const camY = camPos.y; diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 8d6262e4f..b72c834bb 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -657,16 +657,13 @@ export function clearHighlight(): void { } } -// ---- Height-based dissolve (tree depletion/respawn) ---- +// ---- Dissolve (tree depletion/respawn) ---- -const DISSOLVE_DURATION = GPU_VEG_CONFIG.DISSOLVE_DURATION; -const DISSOLVE_MAX = GPU_VEG_CONFIG.DISSOLVE_MAX; - -interface DissolveAnim { - /** 1 = dissolving out, -1 = appearing in */ - direction: 1 | -1; - progress: number; -} +import { + type DissolveAnim, + startDissolve as startDissolveAnim, + tickDissolveAnims, +} from "./DissolveAnimation"; const dissolveAnims = new Map(); @@ -693,31 +690,22 @@ function applyDissolveValue(entityId: string, value: number): void { } } -/** - * Start a dissolve animation. direction=1 dissolves out (depletion), - * direction=-1 dissolves in (respawn). - */ export function startDissolve( entityId: string, direction: 1 | -1, instant = false, ): void { - if (instant) { - const target = direction > 0 ? DISSOLVE_MAX : 0.0; - applyDissolveValue(entityId, target); - dissolveAnims.delete(entityId); - return; - } - const current = direction > 0 ? 0.0 : DISSOLVE_MAX; - applyDissolveValue(entityId, current); - dissolveAnims.set(entityId, { direction, progress: current }); + startDissolveAnim( + dissolveAnims, + entityId, + direction, + instant, + applyDissolveValue, + ); } let lastUpdateFrame = -1; -// Reused across ticks to avoid per-frame allocation -const _completedAnims: string[] = []; - export function updateGLBTreeInstancer(deltaTime: number): void { if (!world) return; if (world.frame === lastUpdateFrame) return; @@ -809,19 +797,7 @@ export function updateGLBTreeInstancer(deltaTime: number): void { } // Tick dissolve animations - _completedAnims.length = 0; - for (const [entityId, anim] of dissolveAnims) { - anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; - anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); - applyDissolveValue(entityId, anim.progress); - if ( - (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || - (anim.direction < 0 && anim.progress <= 0) - ) { - _completedAnims.push(entityId); - } - } - for (const id of _completedAnims) dissolveAnims.delete(id); + tickDissolveAnims(dissolveAnims, deltaTime, applyDissolveValue); // Flush dirty pools + update dissolve uniforms const camY = camPos.y; diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 80e982fa3..e21b92ed9 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -134,6 +134,9 @@ export const GPU_VEG_CONFIG = { /** Maximum dissolve progress (1.0 = fully dissolved) */ DISSOLVE_MAX: 1.0, + + /** Alpha reduction factor when fully dissolved (0.7 = 30% opacity) */ + DISSOLVE_ALPHA_SCALE: 0.7, } as const; // ============================================================================ @@ -1174,8 +1177,7 @@ export function createTreeDissolveMaterial( const fogged = mix(finalRgb, treeFogTex.rgb, treeFogFactor); // ---- Dissolve transparency (depleted trees) ---- - // Max alpha reduction when fully dissolved (0.7 = 30% opacity at full dissolve) - const DISSOLVE_ALPHA_SCALE = 0.7; + const DISSOLVE_ALPHA_SCALE = GPU_VEG_CONFIG.DISSOLVE_ALPHA_SCALE; const dissolveVal = options.batched ? clamp( sub(float(1.0), varyingProperty("vec3", "vBatchColor").z), From f871dc19ee2ce079ff022e0a0bb63cfc8508d175 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 15:25:29 -0400 Subject: [PATCH 07/17] fix(trees): eliminate dissolve reentrancy risk, LOD flash, and mid-file 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 --- .../systems/shared/world/DissolveAnimation.ts | 9 ++--- .../shared/world/GLBTreeBatchedInstancer.ts | 31 ++++++++++------ .../systems/shared/world/GLBTreeInstancer.ts | 36 +++++++++---------- 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/shared/src/systems/shared/world/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts index ef65a5570..72bd0b746 100644 --- a/packages/shared/src/systems/shared/world/DissolveAnimation.ts +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -15,9 +15,6 @@ export interface DissolveAnim { progress: number; } -/** Reused across ticks to avoid per-frame allocation */ -const _completed: string[] = []; - /** * Start or instantly apply a dissolve. * @@ -51,7 +48,7 @@ export function tickDissolveAnims( deltaTime: number, applyFn: (entityId: string, value: number) => void, ): void { - _completed.length = 0; + const completed: string[] = []; for (const [entityId, anim] of anims) { anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); @@ -60,8 +57,8 @@ export function tickDissolveAnims( (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || (anim.direction < 0 && anim.progress <= 0) ) { - _completed.push(entityId); + completed.push(entityId); } } - for (const id of _completed) anims.delete(id); + for (const id of completed) anims.delete(id); } diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 896e01c62..31efde0f7 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -26,6 +26,11 @@ import { } from "./GPUMaterials"; import type { Wind } from "./Wind"; import { getLODDistances, inferLOD1Path, inferLOD2Path } from "./LODConfig"; +import { + type DissolveAnim, + startDissolve as startDissolveAnim, + tickDissolveAnims, +} from "./DissolveAnimation"; const MAX_INSTANCES = 512; @@ -574,6 +579,7 @@ function addToPool( entityId: string, mat: THREE.Matrix4, variantIndex: number, + dissolve = 0, ): void { const ids: number[] = []; for (let i = 0; i < pool.batches.length; i++) { @@ -588,7 +594,13 @@ function addToPool( } const instId = pool.batches[i].addInstance(geoId); pool.batches[i].setMatrixAt(instId, mat); - pool.batches[i].setColorAt(instId, _defaultColor); + if (dissolve > 0) { + // Write dissolve into blue channel immediately to avoid a 1-frame flash + _tmpColor.setRGB(1, 1, 1.0 - dissolve); + pool.batches[i].setColorAt(instId, _tmpColor); + } else { + pool.batches[i].setColorAt(instId, _defaultColor); + } ids.push(instId); } pool.instanceIds.set(entityId, ids); @@ -809,12 +821,6 @@ export function clearHighlight(): void { // ---- Dissolve (tree depletion/respawn) ---- -import { - type DissolveAnim, - startDissolve as startDissolveAnim, - tickDissolveAnims, -} from "./DissolveAnimation"; - const DISSOLVE_MAX = GPU_VEG_CONFIG.DISSOLVE_MAX; const dissolveAnims = new Map(); @@ -934,11 +940,14 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void { slot.scale, slot.yOffset, ); - addToPool(newPool, slot.entityId, mat, slot.variantIndex); + addToPool( + newPool, + slot.entityId, + mat, + slot.variantIndex, + wasDissolveVal, + ); if (wasHl) applyHighlightColor(newPool, slot.entityId, true); - if (wasDissolveVal > 0.001) { - applyDissolveColor(newPool, slot.entityId, wasDissolveVal); - } } } } diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index b72c834bb..7c6712696 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -29,6 +29,11 @@ import { } from "./GPUMaterials"; import type { Wind } from "./Wind"; import { getLODDistances, inferLOD1Path, inferLOD2Path } from "./LODConfig"; +import { + type DissolveAnim, + startDissolve as startDissolveAnim, + tickDissolveAnims, +} from "./DissolveAnimation"; const MAX_INSTANCES = 512; @@ -407,13 +412,20 @@ function composeInstanceMatrix( return _matrix.compose(_position, _quaternion, _scale); } -function addToPool(pool: LODPool, entityId: string, mat: THREE.Matrix4): void { +function addToPool( + pool: LODPool, + entityId: string, + mat: THREE.Matrix4, + dissolve = 0, +): void { const idx = pool.activeCount; for (const im of pool.meshes) { im.setMatrixAt(idx, mat); im.count = idx + 1; } pool.slots.set(entityId, idx); + pool.dissolveData[idx] = dissolve; + if (dissolve > 0) pool.dissolveDirty = true; pool.activeCount++; pool.dirty = true; } @@ -659,12 +671,6 @@ export function clearHighlight(): void { // ---- Dissolve (tree depletion/respawn) ---- -import { - type DissolveAnim, - startDissolve as startDissolveAnim, - tickDissolveAnims, -} from "./DissolveAnimation"; - const dissolveAnims = new Map(); function applyDissolveValue(entityId: string, value: number): void { @@ -771,10 +777,10 @@ export function updateGLBTreeInstancer(deltaTime: number): void { slot.scale, slot.yOffset, ); - addToPool(newPool, slot.entityId, mat); - const newIdx = newPool.slots.get(slot.entityId); - if (newIdx !== undefined) { - if (wasHighlighted > 0) { + addToPool(newPool, slot.entityId, mat, wasDissolve); + if (wasHighlighted > 0) { + const newIdx = newPool.slots.get(slot.entityId); + if (newIdx !== undefined) { newPool.highlightData[newIdx] = wasHighlighted; for (const im of newPool.meshes) { const attr = im.geometry.getAttribute("instanceHighlight"); @@ -782,14 +788,6 @@ export function updateGLBTreeInstancer(deltaTime: number): void { (attr as THREE.InstancedBufferAttribute).needsUpdate = true; } } - if (wasDissolve > 0) { - newPool.dissolveData[newIdx] = wasDissolve; - for (const im of newPool.meshes) { - const attr = im.geometry.getAttribute("instanceDissolve"); - if (attr) - (attr as THREE.InstancedBufferAttribute).needsUpdate = true; - } - } } } slot.currentLOD = targetLOD; From 25053d2c8babe97bfd4ecafaac3d36906c82ae2f Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 15:34:06 -0400 Subject: [PATCH 08/17] refactor(trees): remove dead depleted pool code, document blue-channel encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../world/visuals/TreeGLBVisualStrategy.ts | 4 - .../shared/world/GLBTreeBatchedInstancer.ts | 88 +------------------ .../systems/shared/world/GLBTreeInstancer.ts | 78 ++-------------- .../src/systems/shared/world/GPUMaterials.ts | 19 ++-- 4 files changed, 23 insertions(+), 166 deletions(-) diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index 4dd1b4d0b..ea50c016d 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -244,8 +244,6 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { worldPos, rotation, baseScale, - config.depletedModelPath ?? null, - config.depletedModelScale ?? 0.3, ); } else { let modelPath = config.model; @@ -257,8 +255,6 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { worldPos, rotation, baseScale, - config.depletedModelPath ?? null, - config.depletedModelScale ?? 0.3, ); } diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 31efde0f7..00656effb 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -48,10 +48,8 @@ interface TreeSlot { position: THREE.Vector3; rotation: number; scale: number; - depletedScale: number; yOffset: number; currentLOD: 0 | 1 | 2; - depleted: boolean; variantIndex: number; } @@ -79,10 +77,8 @@ interface TreeTypePool { lod0: BatchedLODPool | null; lod1: BatchedLODPool | null; lod2: BatchedLODPool | null; - depleted: BatchedLODPool | null; instances: Map; yOffset: number; - depletedYOffset: number; modelHeight: number; modelRadius: number; } @@ -330,15 +326,9 @@ const pendingEnsure = new Map>(); async function ensureTreeTypePool( treeType: string, variantPaths: string[], - depletedModelPath?: string | null, ): Promise { const existing = pools.get(treeType); - if (existing) { - if (depletedModelPath && !existing.depleted) { - await loadDepletedPool(existing, depletedModelPath); - } - return existing; - } + if (existing) return existing; const pending = pendingEnsure.get(treeType); if (pending) return pending; @@ -481,19 +471,13 @@ async function ensureTreeTypePool( lod0: lod0Pool, lod1: lod1Pool, lod2: lod2Pool, - depleted: null, instances: new Map(), yOffset: bounds.yOffset, - depletedYOffset: 0, modelHeight: bounds.height, modelRadius: bounds.radius, }; pools.set(treeType, pool); - if (depletedModelPath) { - await loadDepletedPool(pool, depletedModelPath); - } - return pool; })(); @@ -505,59 +489,6 @@ async function ensureTreeTypePool( } } -async function loadDepletedPool( - pool: TreeTypePool, - depletedModelPath: string, -): Promise { - if (pool.depleted) return; - const depletedParts = await loadLODParts(depletedModelPath); - if (!depletedParts) return; - - let depletedYOffset = 0; - try { - const { scene: depScene } = await modelCache.loadModel( - depletedModelPath, - world!, - ); - depletedYOffset = computeModelBounds(depScene, 1).yOffset; - } catch { - /* use 0 */ - } - - const dissolveOpts = { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - enableRimHighlight: true, - }; - - const depletedDissolveParts = depletedParts.map((p) => { - const dm = createTreeDissolveMaterial(p.material, { - ...dissolveOpts, - batched: true, - }); - dm.side = THREE.DoubleSide; - enableTextureRepeat(dm); - world!.setupMaterial(dm); - return [{ geometry: p.geometry, material: dm }]; - }); - - // Depleted has 1 "variant" (the stump) - // Transpose: depletedDissolveParts is [slot][1 variant] but we need [1 variant][slot] - const numSlots = depletedParts.length; - const singleVariant: { - geometry: THREE.BufferGeometry; - material: DissolveMaterial; - }[] = []; - for (let s = 0; s < numSlots; s++) { - singleVariant.push(depletedDissolveParts[s][0]); - } - pool.depleted = createBatchedLODPool([singleVariant]); - pool.depletedYOffset = depletedYOffset; -} - // ---- Instance matrix helper ---- function composeInstanceMatrix( @@ -656,7 +587,7 @@ export function initGLBTreeBatchedInstancer(s: THREE.Scene, w: World): void { */ export function destroyGLBTreeBatchedInstancer(): void { for (const pool of pools.values()) { - for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2]) { if (!lodPool) continue; for (const bm of lodPool.batches) { scene?.remove(bm); @@ -682,27 +613,19 @@ export async function addInstance( position: THREE.Vector3, rotation: number, scale: number, - depletedModelPath?: string | null, - depletedScale?: number, ): Promise { if (!scene || !world) return false; try { - const pool = await ensureTreeTypePool( - treeType, - variantPaths, - depletedModelPath, - ); + const pool = await ensureTreeTypePool(treeType, variantPaths); const slot: TreeSlot = { entityId, position: position.clone(), rotation, scale, - depletedScale: depletedScale ?? scale, yOffset: pool.yOffset, currentLOD: 0, - depleted: false, variantIndex, }; @@ -741,7 +664,6 @@ export function removeInstance(entityId: string): void { } function getLodPool(pool: TreeTypePool, slot: TreeSlot): BatchedLODPool | null { - if (slot.depleted) return pool.depleted; return slot.currentLOD === 0 ? pool.lod0 : slot.currentLOD === 1 @@ -888,8 +810,6 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void { for (const pool of pools.values()) { for (const slot of pool.instances.values()) { - if (slot.depleted) continue; - const dx = camPos.x - slot.position.x; const dz = camPos.z - slot.position.z; const distSq = dx * dx + dz * dz; @@ -970,7 +890,7 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void { const wind = world.getSystem("wind") as Wind | null; for (const pool of pools.values()) { - for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2]) { if (!lodPool) continue; for (const mat of lodPool.materials) { diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 7c6712696..59c89f65b 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -48,10 +48,8 @@ interface TreeSlot { position: THREE.Vector3; rotation: number; scale: number; - depletedScale: number; yOffset: number; currentLOD: 0 | 1 | 2; - depleted: boolean; } interface LODPool { @@ -81,10 +79,8 @@ interface ModelPool { lod0: LODPool | null; lod1: LODPool | null; lod2: LODPool | null; - depleted: LODPool | null; instances: Map; yOffset: number; - depletedYOffset: number; /** Unscaled model height from bounding box */ modelHeight: number; /** Unscaled model horizontal radius from bounding box */ @@ -248,17 +244,11 @@ function enableTextureRepeat(mat: DissolveMaterial): void { async function ensureModelPool( modelPath: string, - depletedModelPath?: string | null, lod1ModelPath?: string | null, lod2ModelPath?: string | null, ): Promise { const existing = pools.get(modelPath); - if (existing) { - if (depletedModelPath && !existing.depleted) { - await loadDepletedPool(existing, depletedModelPath); - } - return existing; - } + if (existing) return existing; const pending = pendingEnsure.get(modelPath); if (pending) return pending; @@ -336,19 +326,13 @@ async function ensureModelPool( lod0: lod0Pool, lod1: lod1Pool, lod2: lod2Pool, - depleted: null, instances: new Map(), yOffset: bounds.yOffset, - depletedYOffset: 0, modelHeight: bounds.height, modelRadius: bounds.radius, }; pools.set(modelPath, pool); - if (depletedModelPath) { - await loadDepletedPool(pool, depletedModelPath); - } - return pool; })(); @@ -360,44 +344,6 @@ async function ensureModelPool( } } -async function loadDepletedPool( - pool: ModelPool, - depletedModelPath: string, -): Promise { - if (pool.depleted) return; - const depletedParts = await loadLODParts(depletedModelPath); - if (!depletedParts) return; - - let depletedYOffset = 0; - try { - const { scene: depScene } = await modelCache.loadModel( - depletedModelPath, - world!, - ); - depletedYOffset = computeModelBounds(depScene, 1).yOffset; - } catch { - /* use 0 */ - } - - const dissolveOpts = { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - enableRimHighlight: true, - }; - const depletedDissolveParts = depletedParts.map((p) => { - const dm = createTreeDissolveMaterial(p.material, dissolveOpts); - dm.side = THREE.DoubleSide; - enableTextureRepeat(dm); - world!.setupMaterial(dm); - return { geometry: p.geometry, material: dm }; - }); - pool.depleted = createLODPool(depletedDissolveParts); - pool.depletedYOffset = depletedYOffset; -} - // ---- Instance matrix helper ---- function composeInstanceMatrix( @@ -474,7 +420,7 @@ export function initGLBTreeInstancer(s: THREE.Scene, w: World): void { */ export function destroyGLBTreeInstancer(): void { for (const pool of pools.values()) { - for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2]) { if (!lodPool) continue; for (const im of lodPool.meshes) { scene?.remove(im); @@ -498,20 +444,13 @@ export async function addInstance( position: THREE.Vector3, rotation: number, scale: number, - depletedModelPath?: string | null, - depletedScale?: number, lod1ModelPath?: string | null, lod2ModelPath?: string | null, ): Promise { if (!scene || !world) return false; try { - const pool = await ensureModelPool( - modelPath, - depletedModelPath, - lod1ModelPath, - lod2ModelPath, - ); + const pool = await ensureModelPool(modelPath, lod1ModelPath, lod2ModelPath); if (pool.lod0 && pool.lod0.activeCount >= MAX_INSTANCES) { console.warn( @@ -525,10 +464,8 @@ export async function addInstance( position: position.clone(), rotation, scale, - depletedScale: depletedScale ?? scale, yOffset: pool.yOffset, currentLOD: 0, - depleted: false, }; pool.instances.set(entityId, slot); @@ -636,9 +573,8 @@ export function setHighlight(entityId: string, on: boolean): void { const slot = pool.instances.get(entityId); if (!slot) return; - const lodPool = slot.depleted - ? pool.depleted - : slot.currentLOD === 0 + const lodPool = + slot.currentLOD === 0 ? pool.lod0 : slot.currentLOD === 1 ? pool.lod1 @@ -727,8 +663,6 @@ export function updateGLBTreeInstancer(deltaTime: number): void { for (const pool of pools.values()) { for (const slot of pool.instances.values()) { - if (slot.depleted) continue; - const dx = camPos.x - slot.position.x; const dz = camPos.z - slot.position.z; const distSq = dx * dx + dz * dz; @@ -813,7 +747,7 @@ export function updateGLBTreeInstancer(deltaTime: number): void { const wind = world.getSystem("wind") as Wind | null; for (const pool of pools.values()) { - for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2]) { if (!lodPool) continue; if (lodPool.dirty) { diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index e21b92ed9..f9c171403 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -1177,6 +1177,10 @@ export function createTreeDissolveMaterial( const fogged = mix(finalRgb, treeFogTex.rgb, treeFogFactor); // ---- Dissolve transparency (depleted trees) ---- + // For BatchedMesh: dissolve is encoded in the blue channel of per-instance + // batch colors (blue = 1.0 - dissolveVal). R/G channels carry highlight + // state. See GLBTreeBatchedInstancer.applyDissolveColor / applyHighlightColor. + // For InstancedMesh: dissolve is a dedicated per-instance float attribute. const DISSOLVE_ALPHA_SCALE = GPU_VEG_CONFIG.DISSOLVE_ALPHA_SCALE; const dissolveVal = options.batched ? clamp( @@ -1194,12 +1198,15 @@ export function createTreeDissolveMaterial( })(); // transparent = true is required for real alpha blending on depleted trees. - // This does put all tree InstancedMesh objects into the transparent render pass, - // but Three.js sorts per-object (not per-instance), so the cost is proportional - // to the number of InstancedMesh objects (~one per model per LOD level), not the - // total tree count. depthWrite stays true so non-depleted instances (alpha=1.0) - // still write depth correctly; the rare case of overlapping depleted trees may - // show minor ordering artifacts, but depleted trees are sparse in practice. + // This puts all tree meshes into the transparent render pass, which prevents + // early-Z rejection for opaque trees. Dynamic toggling per-mesh was considered + // but any pool with at least one depleted tree (common during gameplay) would + // remain transparent anyway, so the benefit is marginal for added complexity. + // A dithered/discard-based dissolve could avoid the transparent pass entirely + // but would look noticeably worse at the 0.3s animation speed. + // depthWrite stays true so non-depleted instances (alpha=1.0) still write depth + // correctly; overlapping depleted trees may show minor ordering artifacts since + // Three.js sorts per-object not per-instance, but depleted trees are sparse. material.transparent = true; material.depthWrite = true; material.needsUpdate = true; From 5d9a0e2356eb50c82a96736fd4f184ffd34d0ff4 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 15:49:32 -0400 Subject: [PATCH 09/17] perf(trees): switch dissolve from alpha blending to dithered discard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../systems/shared/world/DissolveAnimation.ts | 9 ++- .../shared/world/GLBTreeBatchedInstancer.ts | 8 +-- .../systems/shared/world/GLBTreeInstancer.ts | 3 +- .../src/systems/shared/world/GPUMaterials.ts | 63 ++++++++++++------- 4 files changed, 53 insertions(+), 30 deletions(-) diff --git a/packages/shared/src/systems/shared/world/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts index 72bd0b746..20d08ad75 100644 --- a/packages/shared/src/systems/shared/world/DissolveAnimation.ts +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -15,6 +15,9 @@ export interface DissolveAnim { progress: number; } +/** Reused across ticks to avoid per-frame allocation (zero-alloc hot path) */ +const _completed: string[] = []; + /** * Start or instantly apply a dissolve. * @@ -48,7 +51,7 @@ export function tickDissolveAnims( deltaTime: number, applyFn: (entityId: string, value: number) => void, ): void { - const completed: string[] = []; + _completed.length = 0; for (const [entityId, anim] of anims) { anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); @@ -57,8 +60,8 @@ export function tickDissolveAnims( (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || (anim.direction < 0 && anim.progress <= 0) ) { - completed.push(entityId); + _completed.push(entityId); } } - for (const id of completed) anims.delete(id); + for (const id of _completed) anims.delete(id); } diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 00656effb..15c6038e6 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -42,6 +42,8 @@ const _scale = new THREE.Vector3(); // Blue channel of batch colors encodes dissolve state (blue = 1.0 - dissolveVal). // Do NOT change the blue component of these defaults without updating applyDissolveColor. const _defaultColor = new THREE.Color(1, 1, 1); +/** Highlight multiplier for R/G channels (>1.0 brightens; shader detects via step(1.01)) */ +const HL_COLOR_INTENSITY = 1.15; interface TreeSlot { entityId: string; @@ -555,9 +557,6 @@ function isHighlighted(pool: BatchedLODPool, entityId: string): boolean { return _tmpColor.r > 1.01; } -/** Highlight multiplier for R/G channels (>1.0 brightens; shader detects via step(1.01)) */ -const HL_COLOR_INTENSITY = 1.15; - function applyHighlightColor( pool: BatchedLODPool, entityId: string, @@ -872,7 +871,8 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void { } } - // Tick dissolve animations + // Tick dissolve animations — runs AFTER LOD transitions above so that + // applyDissolveValue always finds the entity in its current (post-swap) pool. tickDissolveAnims(dissolveAnims, deltaTime, applyDissolveValue); // Update dissolve uniforms diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 59c89f65b..955aee589 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -728,7 +728,8 @@ export function updateGLBTreeInstancer(deltaTime: number): void { } } - // Tick dissolve animations + // Tick dissolve animations — runs AFTER LOD transitions above so that + // applyDissolveValue always finds the entity in its current (post-swap) pool. tickDissolveAnims(dissolveAnims, deltaTime, applyDissolveValue); // Flush dirty pools + update dissolve uniforms diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index f9c171403..3437abd46 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -128,6 +128,10 @@ export const GPU_VEG_CONFIG = { NEAR_CAMERA_FADE_END: 0.05, // ========== TREE DEPLETION DISSOLVE ========== + // BatchedMesh encodes dissolve in the **blue channel** of per-instance batch + // colors (blue = 1.0 - dissolveVal). R/G channels carry highlight intensity. + // See GLBTreeBatchedInstancer.applyDissolveColor / applyHighlightColor. + // InstancedMesh uses a dedicated per-instance `instanceDissolve` float attribute. /** Duration of the respawn dissolve-in animation (seconds). Depletion is instant. */ DISSOLVE_DURATION: 0.3, @@ -135,7 +139,10 @@ export const GPU_VEG_CONFIG = { /** Maximum dissolve progress (1.0 = fully dissolved) */ DISSOLVE_MAX: 1.0, - /** Alpha reduction factor when fully dissolved (0.7 = 30% opacity) */ + /** + * Fraction of fragments discarded when fully dissolved via screen-door dithering. + * 0.7 = ~70% of the Bayer 4×4 grid cells are discarded, giving a stippled look. + */ DISSOLVE_ALPHA_SCALE: 0.7, } as const; @@ -1176,11 +1183,11 @@ export function createTreeDissolveMaterial( // ---- Sky-color fog ---- const fogged = mix(finalRgb, treeFogTex.rgb, treeFogFactor); - // ---- Dissolve transparency (depleted trees) ---- - // For BatchedMesh: dissolve is encoded in the blue channel of per-instance - // batch colors (blue = 1.0 - dissolveVal). R/G channels carry highlight - // state. See GLBTreeBatchedInstancer.applyDissolveColor / applyHighlightColor. - // For InstancedMesh: dissolve is a dedicated per-instance float attribute. + // ---- Dithered dissolve (depleted trees — screen-door pattern) ---- + // Uses Bayer 4×4 dithering to discard fragments proportional to dissolveVal, + // keeping all tree meshes in the opaque render pass for full early-Z benefits. + // At the 0.3s animation speed the stipple pattern is barely visible, and many + // AAA titles use this exact technique for vegetation fade (e.g. UE stippled LOD). const DISSOLVE_ALPHA_SCALE = GPU_VEG_CONFIG.DISSOLVE_ALPHA_SCALE; const dissolveVal = options.batched ? clamp( @@ -1189,26 +1196,38 @@ export function createTreeDissolveMaterial( float(1.0), ) : attribute("instanceDissolve", "float"); - const dissolveAlpha = sub( - float(1.0), - mul(dissolveVal, float(DISSOLVE_ALPHA_SCALE)), + const dissolveAmount = mul(dissolveVal, float(DISSOLVE_ALPHA_SCALE)); + + // Bayer 4×4 dither (same pattern used by vegetation far-fade elsewhere) + const dix = mod(floor(viewportCoordinate.x), float(4.0)); + const diy = mod(floor(viewportCoordinate.y), float(4.0)); + const db0x = mod(dix, float(2.0)); + const db1x = floor(mul(dix, float(0.5))); + const db0y = mod(diy, float(2.0)); + const db1y = floor(mul(diy, float(0.5))); + const dxr0 = abs(sub(db0x, db0y)); + const dxr1 = abs(sub(db1x, db1y)); + const dbayer = add( + add( + add(mul(dxr0, float(8.0)), mul(db0y, float(4.0))), + mul(dxr1, float(2.0)), + ), + db1y, ); + const dither = mul(dbayer, float(0.0625)); - return vec4(fogged, mul(pbrOut.a, dissolveAlpha)); + // When dissolveAmount exceeds the dither threshold, force alpha to 0 + // so alphaTest discards the fragment. hasDissolve gate prevents dither + // noise on fully-visible trees (dissolveVal ≈ 0). + const hasDissolve = step(float(0.001), dissolveAmount); + const shouldDiscard = mul(step(dither, dissolveAmount), hasDissolve); + const finalAlpha = mul(pbrOut.a, sub(float(1.0), shouldDiscard)); + + return vec4(fogged, finalAlpha); })(); - // transparent = true is required for real alpha blending on depleted trees. - // This puts all tree meshes into the transparent render pass, which prevents - // early-Z rejection for opaque trees. Dynamic toggling per-mesh was considered - // but any pool with at least one depleted tree (common during gameplay) would - // remain transparent anyway, so the benefit is marginal for added complexity. - // A dithered/discard-based dissolve could avoid the transparent pass entirely - // but would look noticeably worse at the 0.3s animation speed. - // depthWrite stays true so non-depleted instances (alpha=1.0) still write depth - // correctly; overlapping depleted trees may show minor ordering artifacts since - // Three.js sorts per-object not per-instance, but depleted trees are sparse. - material.transparent = true; - material.depthWrite = true; + // Dithered dissolve uses alphaTest discard — no transparent pass needed. + // Trees stay in the opaque render pass with full early-Z rejection benefits. material.needsUpdate = true; const treeMat = baseDm as TreeDissolveMaterial; From e618e9030992d923584d162e24cdec33ea06b081 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 16:00:17 -0400 Subject: [PATCH 10/17] fix(trees): move dissolve dithering to alphaTestNode where discard works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/systems/shared/world/GPUMaterials.ts | 79 ++++++++----------- 1 file changed, 35 insertions(+), 44 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 3437abd46..1341d7a14 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -234,6 +234,13 @@ export type DissolveMaterialOptions = { enableRimHighlight?: boolean; /** Use BatchedMesh highlight (vBatchColor varying) instead of InstancedMesh attribute */ batched?: boolean; + /** + * Enable depletion dissolve dithering (tree-specific). + * Reads dissolve progress from instanceDissolve attribute (InstancedMesh) + * or vBatchColor blue channel (BatchedMesh) and applies screen-door + * dithering in the alphaTestNode. + */ + enableDepletionDissolve?: boolean; }; /** @@ -708,7 +715,29 @@ export function createDissolveMaterial( const waterCullValue = enableWaterCulling ? mul(step(worldPos.y, waterCutoff), float(2.0)) : float(0.0); - const threshold = max(ditherThreshold, waterCullValue); + let threshold = max(ditherThreshold, waterCullValue); + + // Depletion dissolve: screen-door dithering for depleted trees. + // Reuses the same Bayer dither value computed above for distance fade. + if (options.enableDepletionDissolve) { + const dissolveVal = options.batched + ? clamp( + sub(float(1.0), varyingProperty("vec3", "vBatchColor").z), + float(0.0), + float(1.0), + ) + : attribute("instanceDissolve", "float"); + const dissolveAmount = mul( + dissolveVal, + float(GPU_VEG_CONFIG.DISSOLVE_ALPHA_SCALE), + ); + const hasDissolve = step(float(0.001), dissolveAmount); + const dissolveDiscard = mul( + mul(step(ditherValue, dissolveAmount), hasDissolve), + float(2.0), + ); + threshold = max(threshold, dissolveDiscard); + } return threshold; })(); @@ -1015,6 +1044,7 @@ export function createTreeDissolveMaterial( const baseDm = createDissolveMaterial(source, { ...options, enableRimHighlight: false, + enableDepletionDissolve: true, }); const material = baseDm as unknown as THREE.MeshStandardNodeMaterial; @@ -1183,51 +1213,12 @@ export function createTreeDissolveMaterial( // ---- Sky-color fog ---- const fogged = mix(finalRgb, treeFogTex.rgb, treeFogFactor); - // ---- Dithered dissolve (depleted trees — screen-door pattern) ---- - // Uses Bayer 4×4 dithering to discard fragments proportional to dissolveVal, - // keeping all tree meshes in the opaque render pass for full early-Z benefits. - // At the 0.3s animation speed the stipple pattern is barely visible, and many - // AAA titles use this exact technique for vegetation fade (e.g. UE stippled LOD). - const DISSOLVE_ALPHA_SCALE = GPU_VEG_CONFIG.DISSOLVE_ALPHA_SCALE; - const dissolveVal = options.batched - ? clamp( - sub(float(1.0), varyingProperty("vec3", "vBatchColor").z), - float(0.0), - float(1.0), - ) - : attribute("instanceDissolve", "float"); - const dissolveAmount = mul(dissolveVal, float(DISSOLVE_ALPHA_SCALE)); - - // Bayer 4×4 dither (same pattern used by vegetation far-fade elsewhere) - const dix = mod(floor(viewportCoordinate.x), float(4.0)); - const diy = mod(floor(viewportCoordinate.y), float(4.0)); - const db0x = mod(dix, float(2.0)); - const db1x = floor(mul(dix, float(0.5))); - const db0y = mod(diy, float(2.0)); - const db1y = floor(mul(diy, float(0.5))); - const dxr0 = abs(sub(db0x, db0y)); - const dxr1 = abs(sub(db1x, db1y)); - const dbayer = add( - add( - add(mul(dxr0, float(8.0)), mul(db0y, float(4.0))), - mul(dxr1, float(2.0)), - ), - db1y, - ); - const dither = mul(dbayer, float(0.0625)); - - // When dissolveAmount exceeds the dither threshold, force alpha to 0 - // so alphaTest discards the fragment. hasDissolve gate prevents dither - // noise on fully-visible trees (dissolveVal ≈ 0). - const hasDissolve = step(float(0.001), dissolveAmount); - const shouldDiscard = mul(step(dither, dissolveAmount), hasDissolve); - const finalAlpha = mul(pbrOut.a, sub(float(1.0), shouldDiscard)); - - return vec4(fogged, finalAlpha); + // Depletion dissolve is handled by the base dissolve material's alphaTestNode + // (enableDepletionDissolve: true), which uses Bayer 4×4 dithering to discard + // fragments. Trees stay in the opaque render pass with full early-Z benefits. + return vec4(fogged, pbrOut.a); })(); - // Dithered dissolve uses alphaTest discard — no transparent pass needed. - // Trees stay in the opaque render pass with full early-Z rejection benefits. material.needsUpdate = true; const treeMat = baseDm as TreeDissolveMaterial; From 414cadbdff25febfb862a868b27dd55cca713204 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 16:35:55 -0400 Subject: [PATCH 11/17] fix(trees): set dissolveDirty on pool removal swap, document channel 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 --- .../shared/src/systems/shared/world/DissolveAnimation.ts | 6 +++++- .../src/systems/shared/world/GLBTreeBatchedInstancer.ts | 7 +++++-- .../shared/src/systems/shared/world/GLBTreeInstancer.ts | 2 ++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/systems/shared/world/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts index 20d08ad75..2e681a9e4 100644 --- a/packages/shared/src/systems/shared/world/DissolveAnimation.ts +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -15,7 +15,11 @@ export interface DissolveAnim { progress: number; } -/** Reused across ticks to avoid per-frame allocation (zero-alloc hot path) */ +/** + * Reused across ticks to avoid per-frame allocation (zero-alloc hot path). + * Safe because each instancer owns its own dissolveAnims map and calls + * tickDissolveAnims sequentially within the same frame — never concurrently. + */ const _completed: string[] = []; /** diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 15c6038e6..a398526f7 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -39,8 +39,11 @@ const _position = new THREE.Vector3(); const _quaternion = new THREE.Quaternion(); const _scale = new THREE.Vector3(); -// Blue channel of batch colors encodes dissolve state (blue = 1.0 - dissolveVal). -// Do NOT change the blue component of these defaults without updating applyDissolveColor. +// ---- Batch color channel layout ---- +// R = highlight intensity (1.0 = normal, >1.0 = highlighted via HL_COLOR_INTENSITY) +// G = highlight intensity (same as R — shader detects highlight via step(1.01, max(R, G))) +// B = 1.0 - dissolveVal (1.0 = fully visible, 0.0 = fully dissolved) +// Only modify channels through applyHighlightColor (R/G) and applyDissolveColor (B). const _defaultColor = new THREE.Color(1, 1, 1); /** Highlight multiplier for R/G channels (>1.0 brightens; shader detects via step(1.01)) */ const HL_COLOR_INTENSITY = 1.15; diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 955aee589..f80095aa0 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -405,6 +405,8 @@ function removeFromPool(pool: LODPool, entityId: string): void { im.count = pool.activeCount; } pool.dirty = true; + // Swap may have moved dissolve data to a different slot — flush to GPU + pool.dissolveDirty = true; } // ---- Public API ---- From 5c1df5dae3575ee8ab1bc6d6264ef3b462b9b356 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 16:39:31 -0400 Subject: [PATCH 12/17] fix(trees): add reentrancy warning and early-out for empty dissolve anims - Upgrade _completed comment to WARNING about reentrancy constraint - Skip _completed reset and iteration when dissolveAnims map is empty --- .../shared/src/systems/shared/world/DissolveAnimation.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/systems/shared/world/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts index 2e681a9e4..619bc2f68 100644 --- a/packages/shared/src/systems/shared/world/DissolveAnimation.ts +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -17,8 +17,10 @@ export interface DissolveAnim { /** * Reused across ticks to avoid per-frame allocation (zero-alloc hot path). - * Safe because each instancer owns its own dissolveAnims map and calls - * tickDissolveAnims sequentially within the same frame — never concurrently. + * WARNING: Not re-entrant — if applyFn ever triggers another tickDissolveAnims + * call, _completed will be corrupted. Safe today because each instancer owns + * its own dissolveAnims map and calls tickDissolveAnims sequentially within + * the same frame, never from within an applyFn callback. */ const _completed: string[] = []; @@ -55,6 +57,7 @@ export function tickDissolveAnims( deltaTime: number, applyFn: (entityId: string, value: number) => void, ): void { + if (anims.size === 0) return; _completed.length = 0; for (const [entityId, anim] of anims) { anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; From eab22a8c76f7c81d9081e961d8eff80c68e20438 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 16:51:15 -0400 Subject: [PATCH 13/17] fix(trees): atomic initial dissolve, O(1) LOD lookup, local completed array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../world/visuals/TreeGLBVisualStrategy.ts | 14 +++++----- .../systems/shared/world/DissolveAnimation.ts | 15 +++-------- .../shared/world/GLBTreeBatchedInstancer.ts | 14 +++++----- .../systems/shared/world/GLBTreeInstancer.ts | 26 +++++++++++-------- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index ea50c016d..dee0a3b44 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -12,6 +12,7 @@ import THREE from "../../../extras/three/three"; import { MeshBasicNodeMaterial } from "three/webgpu"; +import { GPU_VEG_CONFIG } from "../../../systems/shared/world/GPUMaterials"; import { addInstance as addInstancedTree, removeInstance as removeInstancedTree, @@ -229,6 +230,9 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { ); const rotation = ((rotHash % 1000) / 1000) * Math.PI * 2; + // Pass initial dissolve through addInstance so the GPU attribute is set + // atomically with pool insertion — no 1-frame flash on initial load. + const initialDissolve = config.depleted ? GPU_VEG_CONFIG.DISSOLVE_MAX : 0; let success = false; if (config.modelVariants?.length) { @@ -244,6 +248,7 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { worldPos, rotation, baseScale, + initialDissolve, ); } else { let modelPath = config.model; @@ -255,19 +260,16 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { worldPos, rotation, baseScale, + undefined, + undefined, + initialDissolve, ); } if (success) { createCollisionProxy(ctx, baseScale, !!config.modelVariants?.length); - // If tree starts depleted (initial load), set dissolve instantly (no animation) if (config.depleted) { - if (config.modelVariants?.length) { - startBatchedDissolve(id, 1, true); - } else { - startInstancedDissolve(id, 1, true); - } const proxy = ctx.getMesh(); if (proxy) { proxy.userData.depleted = true; diff --git a/packages/shared/src/systems/shared/world/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts index 619bc2f68..d62e19c10 100644 --- a/packages/shared/src/systems/shared/world/DissolveAnimation.ts +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -15,15 +15,6 @@ export interface DissolveAnim { progress: number; } -/** - * Reused across ticks to avoid per-frame allocation (zero-alloc hot path). - * WARNING: Not re-entrant — if applyFn ever triggers another tickDissolveAnims - * call, _completed will be corrupted. Safe today because each instancer owns - * its own dissolveAnims map and calls tickDissolveAnims sequentially within - * the same frame, never from within an applyFn callback. - */ -const _completed: string[] = []; - /** * Start or instantly apply a dissolve. * @@ -58,7 +49,7 @@ export function tickDissolveAnims( applyFn: (entityId: string, value: number) => void, ): void { if (anims.size === 0) return; - _completed.length = 0; + const completed: string[] = []; for (const [entityId, anim] of anims) { anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); @@ -67,8 +58,8 @@ export function tickDissolveAnims( (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || (anim.direction < 0 && anim.progress <= 0) ) { - _completed.push(entityId); + completed.push(entityId); } } - for (const id of _completed) anims.delete(id); + for (const id of completed) anims.delete(id); } diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index a398526f7..3e7135742 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -615,6 +615,7 @@ export async function addInstance( position: THREE.Vector3, rotation: number, scale: number, + initialDissolve = 0, ): Promise { if (!scene || !world) return false; @@ -635,7 +636,8 @@ export async function addInstance( entityToTreeType.set(entityId, treeType); const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); - if (pool.lod0) addToPool(pool.lod0, entityId, mat, variantIndex); + if (pool.lod0) + addToPool(pool.lod0, entityId, mat, variantIndex, initialDissolve); return true; } catch (error) { @@ -774,11 +776,11 @@ function applyDissolveValue(entityId: string, value: number): void { const slot = pool.instances.get(entityId); if (!slot) return; - for (const lodPool of [pool.lod0, pool.lod1, pool.lod2]) { - if (!lodPool || !lodPool.instanceIds.has(entityId)) continue; - applyDissolveColor(lodPool, entityId, value); - return; - } + // Use slot.currentLOD for O(1) pool lookup instead of searching all 3 pools. + const lodPool = getLodPool(pool, slot); + if (!lodPool) return; + + applyDissolveColor(lodPool, entityId, value); } export function startDissolve( diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index f80095aa0..4a0fb08b9 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -448,6 +448,7 @@ export async function addInstance( scale: number, lod1ModelPath?: string | null, lod2ModelPath?: string | null, + initialDissolve = 0, ): Promise { if (!scene || !world) return false; @@ -474,7 +475,7 @@ export async function addInstance( entityToModel.set(entityId, modelPath); const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); - addToPool(pool.lod0!, entityId, mat); + addToPool(pool.lod0!, entityId, mat, initialDissolve); return true; } catch (error) { @@ -621,17 +622,20 @@ function applyDissolveValue(entityId: string, value: number): void { const slot = pool.instances.get(entityId); if (!slot) return; - // Apply to whichever LOD pool the instance is currently in. - // Sets dissolveDirty — the update loop flushes needsUpdate once per pool. - for (const lodPool of [pool.lod0, pool.lod1, pool.lod2]) { - if (!lodPool) continue; - const idx = lodPool.slots.get(entityId); - if (idx === undefined) continue; + // Use slot.currentLOD for O(1) pool lookup instead of searching all 3 pools. + const lodPool = + slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; + if (!lodPool) return; - lodPool.dissolveData[idx] = value; - lodPool.dissolveDirty = true; - return; - } + const idx = lodPool.slots.get(entityId); + if (idx === undefined) return; + + lodPool.dissolveData[idx] = value; + lodPool.dissolveDirty = true; } export function startDissolve( From 833052c24693ddcde562b91ca1ccd3fda13c104f Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 17:02:28 -0400 Subject: [PATCH 14/17] fix(trees): interrupted animation pop, reuse _completed, skip redundant 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. --- .../world/visuals/TreeGLBVisualStrategy.ts | 4 ++-- .../systems/shared/world/DissolveAnimation.ts | 21 +++++++++++++++---- .../shared/world/GLBTreeBatchedInstancer.ts | 5 +++-- .../systems/shared/world/GLBTreeInstancer.ts | 1 + 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index dee0a3b44..54bb0177a 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -260,8 +260,8 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { worldPos, rotation, baseScale, - undefined, - undefined, + null, // lod1ModelPath — auto-inferred by instancer + null, // lod2ModelPath — auto-inferred by instancer initialDissolve, ); } diff --git a/packages/shared/src/systems/shared/world/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts index d62e19c10..eed3041e1 100644 --- a/packages/shared/src/systems/shared/world/DissolveAnimation.ts +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -15,9 +15,16 @@ export interface DissolveAnim { progress: number; } +/** Reused across ticks to avoid per-frame allocation. */ +const _completed: string[] = []; + /** * Start or instantly apply a dissolve. * + * If an animation is already in progress for this entity (e.g. interrupted + * mid-dissolve), continues from the current progress instead of resetting + * to avoid a visible pop. + * * @param anims The animation map to manage * @param applyFn Callback that writes the dissolve value to the rendering backend */ @@ -34,7 +41,13 @@ export function startDissolve( anims.delete(entityId); return; } - const current = direction > 0 ? 0.0 : DISSOLVE_MAX; + // If already animating, continue from current progress to avoid a pop. + const existing = anims.get(entityId); + const current = existing + ? existing.progress + : direction > 0 + ? 0.0 + : DISSOLVE_MAX; applyFn(entityId, current); anims.set(entityId, { direction, progress: current }); } @@ -49,7 +62,7 @@ export function tickDissolveAnims( applyFn: (entityId: string, value: number) => void, ): void { if (anims.size === 0) return; - const completed: string[] = []; + _completed.length = 0; for (const [entityId, anim] of anims) { anim.progress += (anim.direction * deltaTime) / DISSOLVE_DURATION; anim.progress = Math.max(0, Math.min(DISSOLVE_MAX, anim.progress)); @@ -58,8 +71,8 @@ export function tickDissolveAnims( (anim.direction > 0 && anim.progress >= DISSOLVE_MAX) || (anim.direction < 0 && anim.progress <= 0) ) { - completed.push(entityId); + _completed.push(entityId); } } - for (const id of completed) anims.delete(id); + for (const id of _completed) anims.delete(id); } diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 3e7135742..1cbd4e226 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -45,6 +45,7 @@ const _scale = new THREE.Vector3(); // B = 1.0 - dissolveVal (1.0 = fully visible, 0.0 = fully dissolved) // Only modify channels through applyHighlightColor (R/G) and applyDissolveColor (B). const _defaultColor = new THREE.Color(1, 1, 1); +const _tmpColor = new THREE.Color(); /** Highlight multiplier for R/G channels (>1.0 brightens; shader detects via step(1.01)) */ const HL_COLOR_INTENSITY = 1.15; @@ -551,8 +552,6 @@ function removeFromPool(pool: BatchedLODPool, entityId: string): void { pool.instanceIds.delete(entityId); } -const _tmpColor = new THREE.Color(); - function isHighlighted(pool: BatchedLODPool, entityId: string): boolean { const ids = pool.instanceIds.get(entityId); if (!ids || ids.length === 0) return false; @@ -841,6 +840,8 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void { const wasHl = oldPool ? isHighlighted(oldPool, slot.entityId) : false; // Read dissolve state from old pool's color before removing. // Safe to sample batches[0] only — applyDissolveColor sets all batches uniformly. + // Defaults to 0 (fully visible) if instance IDs are missing — this edge case + // can only occur if the entity wasn't fully added, which shouldn't happen in practice. let wasDissolveVal = 0; if (oldPool) { const oldIds = oldPool.instanceIds.get(slot.entityId); diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 4a0fb08b9..48c252585 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -634,6 +634,7 @@ function applyDissolveValue(entityId: string, value: number): void { const idx = lodPool.slots.get(entityId); if (idx === undefined) return; + if (lodPool.dissolveData[idx] === value) return; lodPool.dissolveData[idx] = value; lodPool.dissolveDirty = true; } From 7add25b3017ada2b7f2e252bce09a4b63a83ce1e Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 17:10:06 -0400 Subject: [PATCH 15/17] docs(trees): clarify dissolve config naming, channel precision, and safety 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). --- .../src/entities/world/visuals/TreeGLBVisualStrategy.ts | 4 +++- .../shared/src/systems/shared/world/DissolveAnimation.ts | 6 +++++- .../src/systems/shared/world/GLBTreeBatchedInstancer.ts | 2 ++ packages/shared/src/systems/shared/world/GPUMaterials.ts | 5 ++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index 54bb0177a..e37389142 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -280,7 +280,9 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { } async onDepleted(ctx: ResourceVisualContext): Promise { - // Instant dissolve — immediate visual feedback on depletion + // Always returns true — dissolve handles depletion for all trees. + // Returning false would trigger ResourceEntity.loadDepletedModel() fallback, + // which is only needed by non-tree strategies (e.g. InstancedModelVisualStrategy). if (isBatched(ctx.id)) { startBatchedDissolve(ctx.id, 1, true); } else { diff --git a/packages/shared/src/systems/shared/world/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts index eed3041e1..9bee3bc53 100644 --- a/packages/shared/src/systems/shared/world/DissolveAnimation.ts +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -15,7 +15,11 @@ export interface DissolveAnim { progress: number; } -/** Reused across ticks to avoid per-frame allocation. */ +/** + * Reused across ticks to avoid per-frame allocation. + * Safe because both instancers call tickDissolveAnims sequentially on the + * main thread within the same frame — never concurrently or from workers. + */ const _completed: string[] = []; /** diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 1cbd4e226..fe7697858 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -44,6 +44,8 @@ const _scale = new THREE.Vector3(); // G = highlight intensity (same as R — shader detects highlight via step(1.01, max(R, G))) // B = 1.0 - dissolveVal (1.0 = fully visible, 0.0 = fully dissolved) // Only modify channels through applyHighlightColor (R/G) and applyDissolveColor (B). +// NOTE: If the underlying color buffer is Uint8 (256 levels), dissolve precision is +// ~0.004 per step. At 0.3s duration / 60fps (~18 steps) this is more than sufficient. const _defaultColor = new THREE.Color(1, 1, 1); const _tmpColor = new THREE.Color(); /** Highlight multiplier for R/G channels (>1.0 brightens; shader detects via step(1.01)) */ diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 1341d7a14..1872f1d88 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -136,7 +136,10 @@ export const GPU_VEG_CONFIG = { /** Duration of the respawn dissolve-in animation (seconds). Depletion is instant. */ DISSOLVE_DURATION: 0.3, - /** Maximum dissolve progress (1.0 = fully dissolved) */ + /** + * Animation progress ceiling (not visual opacity). + * The actual fraction of fragments discarded is controlled by DISSOLVE_ALPHA_SCALE. + */ DISSOLVE_MAX: 1.0, /** From 284c118464b4d3757ff4037d76f6c90e6f8c66f2 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 17:21:08 -0400 Subject: [PATCH 16/17] perf(trees): skip redundant batch color writes in applyDissolveColor 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. --- .../src/systems/shared/world/GLBTreeBatchedInstancer.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index fe7697858..65e83efbd 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -758,11 +758,14 @@ function applyDissolveColor( dissolveVal: number, ): void { const ids = pool.instanceIds.get(entityId); - if (!ids) return; + if (!ids || ids.length === 0) return; + // Skip redundant writes — read blue from batches[0] (all batches are uniform) + pool.batches[0].getColorAt(ids[0], _tmpColor); + const encoded = 1.0 - dissolveVal; + if (Math.abs(_tmpColor.b - encoded) < 1e-6) return; for (let i = 0; i < pool.batches.length; i++) { pool.batches[i].getColorAt(ids[i], _tmpColor); - // Encode dissolve in blue channel: blue = 1.0 - dissolveVal - _tmpColor.b = 1.0 - dissolveVal; + _tmpColor.b = encoded; pool.batches[i].setColorAt(ids[i], _tmpColor); } } From de421cd4443d2419186f7c3e9b4008070f565ba5 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Fri, 27 Mar 2026 17:30:59 -0400 Subject: [PATCH 17/17] fix(trees): harden dissolve docs and skip redundant batch color reads 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. --- .../shared/src/systems/shared/world/DissolveAnimation.ts | 7 +++++-- .../src/systems/shared/world/GLBTreeBatchedInstancer.ts | 8 +++++--- packages/shared/src/systems/shared/world/GPUMaterials.ts | 7 ++++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/systems/shared/world/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts index 9bee3bc53..5efb6a988 100644 --- a/packages/shared/src/systems/shared/world/DissolveAnimation.ts +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -17,8 +17,11 @@ export interface DissolveAnim { /** * Reused across ticks to avoid per-frame allocation. - * Safe because both instancers call tickDissolveAnims sequentially on the - * main thread within the same frame — never concurrently or from workers. + * WARNING: Not re-entrant — callers must invoke tickDissolveAnims sequentially + * on the main thread. Currently both instancers do so within the same frame, + * never concurrently or from workers. Adding a third caller or async scheduling + * would silently corrupt this array. + * @internal */ const _completed: string[] = []; diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 65e83efbd..07cd1ad03 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -759,13 +759,15 @@ function applyDissolveColor( ): void { const ids = pool.instanceIds.get(entityId); if (!ids || ids.length === 0) return; - // Skip redundant writes — read blue from batches[0] (all batches are uniform) + // Skip redundant writes — read from batches[0] (all batches are kept uniform) pool.batches[0].getColorAt(ids[0], _tmpColor); const encoded = 1.0 - dissolveVal; if (Math.abs(_tmpColor.b - encoded) < 1e-6) return; + // Reuse R/G from the first read — only blue changes for dissolve + const r = _tmpColor.r; + const g = _tmpColor.g; for (let i = 0; i < pool.batches.length; i++) { - pool.batches[i].getColorAt(ids[i], _tmpColor); - _tmpColor.b = encoded; + _tmpColor.setRGB(r, g, encoded); pool.batches[i].setColorAt(ids[i], _tmpColor); } } diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 1872f1d88..9abd9e748 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -133,7 +133,12 @@ export const GPU_VEG_CONFIG = { // See GLBTreeBatchedInstancer.applyDissolveColor / applyHighlightColor. // InstancedMesh uses a dedicated per-instance `instanceDissolve` float attribute. - /** Duration of the respawn dissolve-in animation (seconds). Depletion is instant. */ + /** + * Duration of the respawn dissolve-in animation (seconds). Depletion is instant. + * NOTE: BatchedMesh encodes dissolve in a Uint8 blue channel (~256 levels). + * At 60fps this gives ~18 steps over 0.3s, which is smooth enough. Increasing + * this value significantly may require switching to Float32 encoding to avoid banding. + */ DISSOLVE_DURATION: 0.3, /**