diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index c1389a494..e37389142 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -12,27 +12,26 @@ 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, - 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, @@ -231,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) { @@ -246,8 +248,7 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { worldPos, rotation, baseScale, - config.depletedModelPath ?? null, - config.depletedModelScale ?? 0.3, + initialDissolve, ); } else { let modelPath = config.model; @@ -259,29 +260,40 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { worldPos, rotation, baseScale, - config.depletedModelPath ?? null, - config.depletedModelScale ?? 0.3, + null, // lod1ModelPath — auto-inferred by instancer + null, // lod2ModelPath — auto-inferred by instancer + initialDissolve, ); } if (success) { createCollisionProxy(ctx, baseScale, !!config.modelVariants?.length); + + if (config.depleted) { + 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); + // 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 { - 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) { @@ -305,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/DissolveAnimation.ts b/packages/shared/src/systems/shared/world/DissolveAnimation.ts new file mode 100644 index 000000000..5efb6a988 --- /dev/null +++ b/packages/shared/src/systems/shared/world/DissolveAnimation.ts @@ -0,0 +1,85 @@ +/** + * 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. + * 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[] = []; + +/** + * 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 + */ +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; + } + // 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 }); +} + +/** + * 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 { + if (anims.size === 0) return; + _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 06a4c1ceb..07cd1ad03 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; @@ -34,18 +39,25 @@ const _position = new THREE.Vector3(); const _quaternion = new THREE.Quaternion(); const _scale = new THREE.Vector3(); +// ---- 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). +// 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 _hlColor = new THREE.Color(1.15, 1.15, 1.15); +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; interface TreeSlot { entityId: string; position: THREE.Vector3; rotation: number; scale: number; - depletedScale: number; yOffset: number; currentLOD: 0 | 1 | 2; - depleted: boolean; variantIndex: number; } @@ -73,10 +85,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; } @@ -324,15 +334,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; @@ -475,19 +479,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; })(); @@ -499,59 +497,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( @@ -573,6 +518,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++) { @@ -587,7 +533,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); @@ -602,8 +554,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; @@ -618,9 +568,12 @@ function applyHighlightColor( ): void { const ids = pool.instanceIds.get(entityId); if (!ids) return; - const color = on ? _hlColor : _defaultColor; + const rg = on ? HL_COLOR_INTENSITY : 1.0; 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); + _tmpColor.setRGB(rg, rg, _tmpColor.b); + pool.batches[i].setColorAt(ids[i], _tmpColor); } } @@ -637,7 +590,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); @@ -650,6 +603,7 @@ export function destroyGLBTreeBatchedInstancer(): void { pools.clear(); entityToTreeType.clear(); pendingEnsure.clear(); + dissolveAnims.clear(); scene = null; world = null; } @@ -662,27 +616,20 @@ export async function addInstance( position: THREE.Vector3, rotation: number, scale: number, - depletedModelPath?: string | null, - depletedScale?: number, + initialDissolve = 0, ): 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, }; @@ -690,7 +637,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) { @@ -717,10 +665,10 @@ export function removeInstance(entityId: string): void { pool.instances.delete(entityId); entityToTreeType.delete(entityId); + dissolveAnims.delete(entityId); } function getLodPool(pool: TreeTypePool, slot: TreeSlot): BatchedLODPool | null { - if (slot.depleted) return pool.depleted; return slot.currentLOD === 0 ? pool.lod0 : slot.currentLOD === 1 @@ -728,52 +676,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); } @@ -815,13 +717,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 { @@ -851,9 +746,66 @@ export function clearHighlight(): void { } } +// ---- Dissolve (tree depletion/respawn) ---- + +const DISSOLVE_MAX = GPU_VEG_CONFIG.DISSOLVE_MAX; + +const dissolveAnims = new Map(); + +function applyDissolveColor( + pool: BatchedLODPool, + entityId: string, + dissolveVal: number, +): void { + const ids = pool.instanceIds.get(entityId); + if (!ids || ids.length === 0) return; + // 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++) { + _tmpColor.setRGB(r, g, encoded); + 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; + + // 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( + entityId: string, + direction: 1 | -1, + instant = false, +): void { + startDissolveAnim( + dissolveAnims, + entityId, + direction, + instant, + applyDissolveValue, + ); +} + let lastUpdateFrame = -1; -export function updateGLBTreeBatchedInstancer(): void { +export function updateGLBTreeBatchedInstancer(deltaTime: number): void { if (!world) return; if (world.frame === lastUpdateFrame) return; lastUpdateFrame = world.frame; @@ -868,8 +820,6 @@ export function updateGLBTreeBatchedInstancer(): 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; @@ -895,7 +845,22 @@ 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. + // 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); + if (oldIds && oldIds.length > 0) { + oldPool.batches[0].getColorAt(oldIds[0], _tmpColor); + wasDissolveVal = Math.max( + 0, + Math.min(DISSOLVE_MAX, 1.0 - _tmpColor.b), + ); + } + removeFromPool(oldPool, slot.entityId); + } slot.currentLOD = targetLOD; @@ -907,12 +872,22 @@ export function updateGLBTreeBatchedInstancer(): 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); } } } + // 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 const camY = camPos.y; const players = world.getPlayers(); @@ -928,7 +903,7 @@ export function updateGLBTreeBatchedInstancer(): 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 ff929d9a8..48c252585 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; @@ -43,10 +48,8 @@ interface TreeSlot { position: THREE.Vector3; rotation: number; scale: number; - depletedScale: number; yOffset: number; currentLOD: 0 | 1 | 2; - depleted: boolean; } interface LODPool { @@ -57,8 +60,12 @@ 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) */ + dissolveData: Float32Array; /** * Snapshot of original source geometries (before InstancedBufferAttribute * additions). Retained so collision proxies can use the model shape without @@ -72,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 */ @@ -163,6 +168,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 +179,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; @@ -189,7 +199,9 @@ function createLODPool( slots: new Map(), activeCount: 0, dirty: false, + dissolveDirty: false, highlightData: hlData, + dissolveData, sourceGeometries, }; } @@ -232,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; @@ -320,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; })(); @@ -344,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( @@ -396,13 +358,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; } @@ -418,6 +387,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 +397,7 @@ function removeFromPool(pool: LODPool, entityId: string): void { } } pool.highlightData[lastIdx] = 0; + pool.dissolveData[lastIdx] = 0; pool.slots.delete(entityId); pool.activeCount--; @@ -434,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 ---- @@ -449,7 +422,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); @@ -462,6 +435,7 @@ export function destroyGLBTreeInstancer(): void { pools.clear(); entityToModel.clear(); pendingEnsure.clear(); + dissolveAnims.clear(); scene = null; world = null; } @@ -472,20 +446,14 @@ export async function addInstance( position: THREE.Vector3, rotation: number, scale: number, - depletedModelPath?: string | null, - depletedScale?: number, lod1ModelPath?: string | null, lod2ModelPath?: string | null, + initialDissolve = 0, ): 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( @@ -499,17 +467,15 @@ export async function addInstance( position: position.clone(), rotation, scale, - depletedScale: depletedScale ?? scale, yOffset: pool.yOffset, currentLOD: 0, - depleted: false, }; pool.instances.set(entityId, slot); 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) { @@ -541,61 +507,7 @@ export function removeInstance(entityId: string): void { pool.instances.delete(entityId); entityToModel.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); - } + dissolveAnims.delete(entityId); } export function hasInstance(entityId: string): boolean { @@ -643,17 +555,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; @@ -675,9 +576,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 @@ -708,9 +608,54 @@ export function clearHighlight(): void { } } +// ---- Dissolve (tree depletion/respawn) ---- + +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; + + // 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; + + const idx = lodPool.slots.get(entityId); + if (idx === undefined) return; + + if (lodPool.dissolveData[idx] === value) return; + lodPool.dissolveData[idx] = value; + lodPool.dissolveDirty = true; +} + +export function startDissolve( + entityId: string, + direction: 1 | -1, + instant = false, +): void { + startDissolveAnim( + dissolveAnims, + entityId, + direction, + instant, + applyDissolveValue, + ); +} + let lastUpdateFrame = -1; -export function updateGLBTreeInstancer(): void { +export function updateGLBTreeInstancer(deltaTime: number): void { if (!world) return; if (world.frame === lastUpdateFrame) return; lastUpdateFrame = world.frame; @@ -725,8 +670,6 @@ export function updateGLBTreeInstancer(): 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; @@ -760,10 +703,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( @@ -772,7 +718,7 @@ export function updateGLBTreeInstancer(): void { slot.scale, slot.yOffset, ); - addToPool(newPool, slot.entityId, mat); + addToPool(newPool, slot.entityId, mat, wasDissolve); if (wasHighlighted > 0) { const newIdx = newPool.slots.get(slot.entityId); if (newIdx !== undefined) { @@ -789,6 +735,10 @@ export function updateGLBTreeInstancer(): void { } } + // 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 const camY = camPos.y; const players = world.getPlayers(); @@ -805,7 +755,7 @@ export function updateGLBTreeInstancer(): 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) { @@ -815,6 +765,14 @@ export function updateGLBTreeInstancer(): 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 be8538b23..9abd9e748 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -126,6 +126,32 @@ 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 ========== + // 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. + * 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, + + /** + * Animation progress ceiling (not visual opacity). + * The actual fraction of fragments discarded is controlled by DISSOLVE_ALPHA_SCALE. + */ + DISSOLVE_MAX: 1.0, + + /** + * 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; // ============================================================================ @@ -216,6 +242,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; }; /** @@ -690,7 +723,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; })(); @@ -997,6 +1052,7 @@ export function createTreeDissolveMaterial( const baseDm = createDissolveMaterial(source, { ...options, enableRimHighlight: false, + enableDepletionDissolve: true, }); const material = baseDm as unknown as THREE.MeshStandardNodeMaterial; @@ -1145,10 +1201,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"); } @@ -1167,6 +1221,9 @@ export function createTreeDissolveMaterial( // ---- Sky-color fog ---- const fogged = mix(finalRgb, treeFogTex.rgb, treeFogFactor); + // 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); })();