Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
87f3f12
feat(trees): add dissolve transparency for depleted trees
dreaminglucid Mar 27, 2026
1aa5f17
fix(trees): address PR review feedback on dissolve transparency
dreaminglucid Mar 27, 2026
37f2653
fix(trees): address second round of PR review feedback
dreaminglucid Mar 27, 2026
7433ce0
fix(trees): batch dissolve GPU uploads, add defensive clamp, name con…
dreaminglucid Mar 27, 2026
d23bfbc
fix(trees): harden highlight detection, deduplicate dissolve constants
dreaminglucid Mar 27, 2026
c2afdb9
refactor(trees): extract shared DissolveAnimation module, move consta…
dreaminglucid Mar 27, 2026
f871dc1
fix(trees): eliminate dissolve reentrancy risk, LOD flash, and mid-fi…
dreaminglucid Mar 27, 2026
25053d2
refactor(trees): remove dead depleted pool code, document blue-channe…
dreaminglucid Mar 27, 2026
5d9a0e2
perf(trees): switch dissolve from alpha blending to dithered discard
dreaminglucid Mar 27, 2026
e618e90
fix(trees): move dissolve dithering to alphaTestNode where discard works
dreaminglucid Mar 27, 2026
414cadb
fix(trees): set dissolveDirty on pool removal swap, document channel …
dreaminglucid Mar 27, 2026
5c1df5d
fix(trees): add reentrancy warning and early-out for empty dissolve a…
dreaminglucid Mar 27, 2026
eab22a8
fix(trees): atomic initial dissolve, O(1) LOD lookup, local completed…
dreaminglucid Mar 27, 2026
833052c
fix(trees): interrupted animation pop, reuse _completed, skip redunda…
dreaminglucid Mar 27, 2026
7add25b
docs(trees): clarify dissolve config naming, channel precision, and s…
dreaminglucid Mar 27, 2026
284c118
perf(trees): skip redundant batch color writes in applyDissolveColor
dreaminglucid Mar 27, 2026
de421cd
fix(trees): harden dissolve docs and skip redundant batch color reads
dreaminglucid Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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<boolean> {
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 {
Expand All @@ -293,10 +305,11 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy {
}

async onRespawn(ctx: ResourceVisualContext): Promise<void> {
// 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) {
Expand All @@ -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 {
Expand Down
85 changes: 85 additions & 0 deletions packages/shared/src/systems/shared/world/DissolveAnimation.ts
Original file line number Diff line number Diff line change
@@ -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<string, DissolveAnim>,
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<string, DissolveAnim>,
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);
}
Loading
Loading