Skip to main content

Overview

Hyperscape uses GPU instancing to render thousands of resource entities (rocks, ores, herbs, trees) with minimal draw calls. This system was introduced in PR #946 (February 2026) and provides dramatic performance improvements. Performance Impact:
  • Reduces draw calls from O(n) per resource to O(1) per unique model per LOD level
  • Distance-based LOD switching with hysteresis to prevent flickering
  • Supports depleted models (stumps, empty rocks) with separate instance pools
  • Highlight mesh support for hover/selection effects

Architecture

Instancer Systems

Hyperscape provides two specialized instancers:
InstancerPurposeLocation
GLBResourceInstancerRocks, ores, herbs (non-tree resources)systems/shared/world/GLBResourceInstancer.ts
GLBTreeInstancerTree resources with dissolve materialssystems/shared/world/GLBTreeInstancer.ts
Both instancers share the same architecture:
  • Load each model once, extract geometry by reference
  • Render all instances via single THREE.InstancedMesh per LOD level
  • Distance-based LOD switching per-instance every frame
  • Matrix swap-and-pop for efficient instance removal

Visual Strategy Pattern

Resources use the Strategy Pattern to delegate rendering:
// From packages/shared/src/entities/world/visuals/createVisualStrategy.ts
export function createVisualStrategy(
  config: ResourceEntityConfig
): ResourceVisualStrategy {
  // Trees with GLB models use instanced rendering
  if (config.resourceType === "tree" && hasModel(config))
    return new TreeGLBVisualStrategy();
  
  // All other resources with models use instanced rendering
  if (hasModel(config))
    return new InstancedModelVisualStrategy();
  
  // Fallback to placeholder cubes
  return new PlaceholderVisualStrategy();
}
Visual Strategies:
  1. TreeGLBVisualStrategy - GLB tree models via GLBTreeInstancer
  2. InstancedModelVisualStrategy - Rocks, ores, herbs via GLBResourceInstancer
  3. StandardModelVisualStrategy - Fallback for non-instanced rendering
  4. FishingSpotVisualStrategy - Fishing spot particles
  5. PlaceholderVisualStrategy - Colored cubes for missing models

ResourceVisualStrategy API

Interface

export interface ResourceVisualStrategy {
  createVisual(ctx: ResourceVisualContext): Promise<void>;
  
  /**
   * @returns true if the strategy handled depletion visuals (instanced stump),
   *          false if ResourceEntity should load an individual depleted model.
   */
  onDepleted(ctx: ResourceVisualContext): Promise<boolean>;
  
  onRespawn(ctx: ResourceVisualContext): Promise<void>;
  update(ctx: ResourceVisualContext, deltaTime: number): void;
  destroy(ctx: ResourceVisualContext): void>;
  
  /** Return a temporary mesh positioned at this instance for the outline pass. */
  getHighlightMesh?(ctx: ResourceVisualContext): THREE.Object3D | null;
}

BREAKING CHANGE: onDepleted Return Type

Before (pre-PR #946):
async onDepleted(ctx: ResourceVisualContext): Promise<void> {
  // Hide visual, ResourceEntity always loads depleted model
}
After (PR #946, February 2026):
async onDepleted(ctx: ResourceVisualContext): Promise<boolean> {
  if (this.instanced) {
    setResourceDepleted(ctx.id, true);
    return true; // Strategy handled depletion (instanced stump)
  }
  return false; // ResourceEntity should load individual depleted model
}
Migration Guide: All custom visual strategies must update their onDepleted() signature:
// ❌ Old
async onDepleted(ctx: ResourceVisualContext): Promise<void> {
  // ...
}

// ✅ New
async onDepleted(ctx: ResourceVisualContext): Promise<boolean> {
  // ...
  return false; // or true if strategy handles depletion
}

New Method: getHighlightMesh

Purpose: Provide a positioned mesh for outline rendering on instanced entities. Signature:
getHighlightMesh?(ctx: ResourceVisualContext): THREE.Object3D | null
Implementation Example:
// From InstancedModelVisualStrategy.ts
getHighlightMesh(ctx: ResourceVisualContext): THREE.Object3D | null {
  if (this.fallback) return null;
  return getResourceHighlightMesh(ctx.id);
}
Usage: The EntityHighlightService calls this method to get a temporary mesh for outline rendering:
// From EntityHighlightService.ts
const entity = target.entity as unknown as Record<string, unknown>;
if (typeof entity.getHighlightRoot === "function") {
  const hlRoot = (entity.getHighlightRoot as () => THREE.Object3D | null)();
  if (hlRoot) {
    this.world.stage?.scene?.add?.(hlRoot);
    this.activeHighlightMesh = hlRoot;
    const meshes = this.collectMeshes(hlRoot);
    if (meshes.length > 0) {
      const color = this.getHighlightColor(target.entityType);
      this.composer.setOutlineColor(color);
      this.composer.setOutlineObjects(meshes);
      return;
    }
  }
}

GLBResourceInstancer

Overview

Manages instanced rendering for non-tree resources (rocks, ores, herbs). Location: packages/shared/src/systems/shared/world/GLBResourceInstancer.ts Features:
  • Pools instances by model path
  • Separate InstancedMesh per LOD level (LOD0, LOD1, LOD2)
  • Depleted model pools for stumps/empty rocks
  • Highlight mesh support for hover effects
  • Max 512 instances per pool

API

// Initialize instancer (called in createClientWorld.ts)
initGLBResourceInstancer(scene: THREE.Scene, world: World): void

// Add instance
addInstance(
  modelPath: string,
  entityId: string,
  position: THREE.Vector3,
  rotation: number,
  scale: number,
  depletedModelPath?: string | null,
  depletedScale?: number
): Promise<boolean>

// Remove instance
removeInstance(entityId: string): void

// Set depleted state
setDepleted(entityId: string, depleted: boolean): void

// Check if entity has depleted pool
hasDepleted(entityId: string): boolean

// Get highlight mesh for entity
getHighlightMesh(entityId: string): THREE.Object3D | null

// Update all instances (call every frame)
updateGLBResourceInstancer(): void

// Cleanup
destroyGLBResourceInstancer(): void

LOD System

The instancer automatically switches LOD levels based on camera distance:
// From GLBResourceInstancer.ts
const resourceLOD = getLODDistances("resource");

// LOD switching with hysteresis
const lod1DistSq = resourceLOD.lod1DistanceSq;
const lod2DistSq = resourceLOD.lod2DistanceSq;
const hysteresisSq = 0.81; // Prevents flickering

// Distance-based LOD selection
if (distSq < lod1DistSq * hysteresisSq) {
  targetLOD = 0; // High detail
} else if (distSq < lod2DistSq * hysteresisSq) {
  targetLOD = 1; // Medium detail
} else {
  targetLOD = 2; // Low detail
}
LOD File Naming Convention:
  • LOD0: model.glb (original file)
  • LOD1: model_lod1.glb (inferred)
  • LOD2: model_lod2.glb (inferred)

Depleted Models

Resources can specify depleted models in their configuration:
{
  "id": "oak_tree",
  "model": "trees/oak.glb",
  "modelScale": 3.0,
  "depletedModelPath": "trees/oak_stump.glb",
  "depletedModelScale": 0.3
}
Lifecycle:
  1. Resource spawns → added to normal pool at LOD0
  2. Resource depleted → removed from normal pool, added to depleted pool
  3. Resource respawns → removed from depleted pool, added back to normal pool
Benefits:
  • No individual model loading for depleted states
  • Instant visual transition (matrix swap)
  • Collision proxy persists across transitions
  • Separate Y-offset calculation for depleted models

InstancedModelVisualStrategy

Overview

Thin wrapper that integrates GLBResourceInstancer with the ResourceEntity lifecycle. Location: packages/shared/src/entities/world/visuals/InstancedModelVisualStrategy.ts

Implementation

export class InstancedModelVisualStrategy implements ResourceVisualStrategy {
  private instanced = false;
  private fallback: StandardModelVisualStrategy | null = null;

  async createVisual(ctx: ResourceVisualContext): Promise<void> {
    const { config, id, position } = ctx;
    if (!config.model) return;

    const baseScale = config.modelScale ?? 1.0;
    const worldPos = new THREE.Vector3();
    ctx.node.getWorldPosition(worldPos);

    // Deterministic rotation based on position
    const rotHash = ctx.hashString(
      `${id}_${position.x.toFixed(1)}_${position.z.toFixed(1)}`
    );
    const rotation = ((rotHash % 1000) / 1000) * Math.PI * 2;

    const success = await addResourceInstance(
      config.model,
      id,
      worldPos,
      rotation,
      baseScale,
      config.depletedModelPath ?? null,
      config.depletedModelScale ?? 0.3
    );

    if (success) {
      this.instanced = true;
      if (config.depleted) {
        setResourceDepleted(id, true);
      }
      createCollisionProxy(ctx, baseScale);
      return;
    }

    // Fallback to non-instanced rendering
    this.fallback = new StandardModelVisualStrategy();
    await this.fallback.createVisual(ctx);
  }

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

    if (this.instanced) {
      setResourceDepleted(ctx.id, true);
    }
    const proxy = ctx.getMesh();
    if (proxy) {
      proxy.userData.depleted = true;
      proxy.userData.interactable = false;
    }
    return hasResourceDepleted(ctx.id);
  }

  getHighlightMesh(ctx: ResourceVisualContext): THREE.Object3D | null {
    if (this.fallback) return null;
    return getResourceHighlightMesh(ctx.id);
  }

  async onRespawn(ctx: ResourceVisualContext): Promise<void> {
    if (this.fallback) {
      await this.fallback.onRespawn(ctx);
      return;
    }

    if (this.instanced) {
      setResourceDepleted(ctx.id, false);
    }

    const proxy = ctx.getMesh();
    if (proxy) {
      proxy.userData.depleted = false;
      proxy.userData.interactable = true;
    }
  }

  update(ctx: ResourceVisualContext, deltaTime: number): void {
    if (this.fallback) {
      this.fallback.update(ctx);
      return;
    }

    updateGLBResourceInstancer();
  }

  destroy(ctx: ResourceVisualContext): void {
    if (this.fallback) {
      this.fallback.destroy(ctx);
      return;
    }

    if (this.instanced) {
      removeResourceInstance(ctx.id);
      this.instanced = false;
    }

    const proxy = ctx.getMesh();
    if (proxy) {
      const mesh = proxy as THREE.Mesh;
      if (mesh.geometry) mesh.geometry.dispose();
      if (mesh.material) (mesh.material as THREE.Material).dispose();
      ctx.node.remove(proxy);
      ctx.setMesh(null);
    }
  }
}

Collision Proxy

Since InstancedMesh is not raycastable, the strategy creates an invisible collision proxy:
function createCollisionProxy(ctx: ResourceVisualContext, scale: number): void {
  const isTree = ctx.config.resourceType === "tree";
  const geometry = isTree
    ? new THREE.CylinderGeometry(0.5 * scale, 0.5 * scale, 2 * scale, 6)
    : new THREE.BoxGeometry(0.8 * scale, 0.8 * scale, 0.8 * scale);

  const material = new MeshBasicNodeMaterial();
  material.visible = false;

  const proxy = new THREE.Mesh(geometry, material);
  if (isTree) proxy.position.y = scale;
  else proxy.position.y = 0.4 * scale;
  
  proxy.name = `InstancedProxy_${ctx.id}`;
  proxy.userData = {
    type: "resource",
    entityId: ctx.id,
    name: ctx.config.name,
    interactable: true,
    resourceType: ctx.config.resourceType,
    depleted: ctx.config.depleted,
  };
  proxy.layers.set(1);

  ctx.node.add(proxy);
  ctx.setMesh(proxy);
}
Key Points:
  • Invisible mesh (material.visible = false)
  • Proper userData for interaction detection
  • Layer 1 for raycasting
  • Persists across depletion/respawn transitions

Highlight System

EntityHighlightService Integration

The highlight service supports instanced entities via the getHighlightRoot() method:
// From packages/shared/src/entities/world/ResourceEntity.ts
public getHighlightRoot(): THREE.Object3D | null {
  if (typeof this.visual.getHighlightMesh === "function") {
    return this.visual.getHighlightMesh(this.getVisualCtx());
  }
  return null;
}
Highlight Flow:
  1. User hovers over instanced entity
  2. EntityHighlightService.setHoverTarget() called
  3. Service calls entity.getHighlightRoot()
  4. Strategy returns positioned highlight mesh
  5. Service adds mesh to scene temporarily
  6. Outline pass renders highlight mesh
  7. Service removes mesh when hover ends

Highlight Mesh Lifecycle

// From EntityHighlightService.ts
private activeHighlightMesh: THREE.Object3D | null = null;

setHoverTarget(target: RaycastTarget | null): void {
  const newId = target?.entityId ?? null;
  if (newId === this.currentTargetId) return;

  this.removeActiveHighlightMesh(); // Remove old highlight
  this.currentTargetId = newId;

  if (!this.composer) return;

  if (!target || !target.entity) {
    this.composer.setOutlineObjects([]);
    return;
  }

  // Try instanced highlight path first
  const entity = target.entity as unknown as Record<string, unknown>;
  if (typeof entity.getHighlightRoot === "function") {
    const hlRoot = (entity.getHighlightRoot as () => THREE.Object3D | null)();
    if (hlRoot) {
      this.world.stage?.scene?.add?.(hlRoot);
      this.activeHighlightMesh = hlRoot;
      const meshes = this.collectMeshes(hlRoot);
      if (meshes.length > 0) {
        const color = this.getHighlightColor(target.entityType);
        this.composer.setOutlineColor(color);
        this.composer.setOutlineObjects(meshes);
        return;
      }
    }
  }

  // Fallback: use entity's own scene-graph mesh
  const mesh = target.entity.mesh;
  const node = target.entity.node;
  const root = mesh ?? node;
  // ...
}

private removeActiveHighlightMesh(): void {
  if (this.activeHighlightMesh) {
    this.world.stage?.scene?.remove?.(this.activeHighlightMesh);
    this.activeHighlightMesh = null;
  }
}
State Transition Handling: When a resource transitions between normal and depleted states, the old highlight mesh is removed:
// From GLBResourceInstancer.ts
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;

  // Remove stale highlight mesh from scene
  const oldHlMesh = slot.depleted
    ? pool.depletedHighlightMesh
    : pool.highlightMesh;
  if (oldHlMesh?.parent) {
    oldHlMesh.parent.remove(oldHlMesh);
  }

  slot.depleted = depleted;
  // ... transition logic ...
}

Depleted Model System

Configuration

Resources specify depleted models in their manifest:
{
  "id": "oak_tree",
  "name": "Oak tree",
  "resourceType": "tree",
  "model": "trees/oak.glb",
  "modelScale": 3.0,
  "depletedModelPath": "trees/oak_stump.glb",
  "depletedModelScale": 0.3,
  "respawnTime": 30
}

Instancer Implementation

The instancer maintains separate pools for normal and depleted states:
interface ModelPool {
  modelPath: string;
  lod0: LODPool | null;
  lod1: LODPool | null;
  lod2: LODPool | null;
  depleted: LODPool | null; // NEW: Separate pool for depleted models
  instances: Map<string, ResourceSlot>;
  yOffset: number;
  depletedYOffset: number; // NEW: Y-offset for depleted models
  highlightMesh: THREE.Mesh | null;
  depletedHighlightMesh: THREE.Mesh | null; // NEW: Highlight for depleted state
}
Depletion Flow:
  1. Resource depleted → setDepleted(entityId, true) called
  2. Remove instance from current LOD pool (LOD0/LOD1/LOD2)
  3. Add instance to depleted pool with depletedScale
  4. Update collision proxy userData (depleted: true, interactable: false)
  5. Remove old highlight mesh if entity is currently hovered
Respawn Flow:
  1. Resource respawns → setDepleted(entityId, false) called
  2. Remove instance from depleted pool
  3. Add instance back to LOD0 pool with normal scale
  4. Update collision proxy userData (depleted: false, interactable: true)

Y-Offset Calculation

Each model pool calculates Y-offsets to ensure models sit flush on terrain:
function computeYOffset(root: THREE.Object3D, scale: number): number {
  const saved = root.scale.clone();
  root.scale.set(scale, scale, scale);
  const bbox = new THREE.Box3().setFromObject(root);
  root.scale.copy(saved);
  return -bbox.min.y; // Offset to align bottom of bbox with y=0
}
Separate Offsets:
  • yOffset - Normal model offset
  • depletedYOffset - Depleted model offset (stumps may have different proportions)

Performance Characteristics

Draw Call Reduction

Before Instancing:
  • 1000 oak trees = 1000 draw calls (one per tree)
  • 500 copper rocks = 500 draw calls
  • Total: 1500 draw calls for resources
After Instancing:
  • 1000 oak trees = 3 draw calls (LOD0, LOD1, LOD2)
  • 500 copper rocks = 3 draw calls (LOD0, LOD1, LOD2)
  • Total: 6 draw calls for resources
Reduction: 99.6% fewer draw calls

Memory Efficiency

Shared Resources:
  • Geometry buffers shared across all instances
  • Materials shared per LOD level
  • Highlight meshes shared per model pool
  • Only instance matrices stored per-entity
Memory Savings:
  • 1000 trees with individual meshes: ~50MB geometry data
  • 1000 trees with instancing: ~50KB geometry data + 64KB matrices
  • Savings: 99% reduction in geometry memory

LOD Hysteresis

Hysteresis prevents flickering when camera distance oscillates near LOD boundaries:
const hysteresisSq = 0.81; // 90% of distance

// Switching from LOD0 to LOD1
if (distSq < lod1DistSq * hysteresisSq) {
  targetLOD = 0; // Stay at LOD0
} else if (distSq < lod1DistSq) {
  targetLOD = slot.currentLOD === 0 ? 0 : 1; // Only switch if already at LOD1
}
Effect:
  • LOD0 → LOD1 switch at 100% distance
  • LOD1 → LOD0 switch at 90% distance
  • 10% hysteresis band prevents rapid switching

Fallback Behavior

When Instancing Fails

The strategy falls back to StandardModelVisualStrategy when:
  • Instance pool is full (MAX_INSTANCES = 512)
  • Model loading fails
  • Instancer not initialized
const success = await addResourceInstance(/* ... */);

if (success) {
  this.instanced = true;
  createCollisionProxy(ctx, baseScale);
  return;
}

// Fallback to non-instanced rendering
this.fallback = new StandardModelVisualStrategy();
await this.fallback.createVisual(ctx);
Fallback Behavior:
  • All methods delegate to fallback strategy
  • No instancing benefits, but entity still renders
  • Prevents visual gaps when pools are full

Integration

Initialization

The instancers are initialized in createClientWorld.ts:
// From packages/shared/src/runtime/createClientWorld.ts
import {
  initGLBResourceInstancer,
  destroyGLBResourceInstancer,
} from "../systems/shared/world/GLBResourceInstancer";

// Clean up any previous instancer state from prior world
destroyGLBTreeInstancer();
destroyPlaceholderInstancer();
destroyGLBResourceInstancer();

// ... later, after stage system is ready ...

if (stageSystem) {
  stageSystem.THREE = THREE as unknown as StageSystem["THREE"];
  initGLBTreeInstancer(stageSystem.scene as unknown as THREE.Scene, world);
  initPlaceholderInstancer(stageSystem.scene as unknown as THREE.Scene);
  initGLBResourceInstancer(
    stageSystem.scene as unknown as THREE.Scene,
    world
  );
}

Per-Frame Update

The instancer must be updated every frame for LOD switching:
// From InstancedModelVisualStrategy.ts
update(ctx: ResourceVisualContext, deltaTime: number): void {
  if (this.fallback) {
    this.fallback.update(ctx);
    return;
  }

  updateGLBResourceInstancer(); // Updates all instances
}
Update Logic:
  • Checks camera distance for each instance
  • Switches LOD levels as needed
  • Updates dissolve material uniforms (camera pos, player pos)
  • Marks instance matrices as dirty when instances move between pools

Testing

Unit Tests

The instanced rendering system includes comprehensive tests:
// Example test structure
describe("GLBResourceInstancer", () => {
  it("should add instance to LOD0 pool", async () => {
    const success = await addInstance(
      "models/rock.glb",
      "rock_1",
      new THREE.Vector3(10, 0, 10),
      0,
      1.0
    );
    expect(success).toBe(true);
  });

  it("should switch LOD based on distance", () => {
    // Move camera far away
    world.camera.position.set(100, 0, 100);
    updateGLBResourceInstancer();
    
    // Verify instance moved to LOD2 pool
    const slot = getInstanceSlot("rock_1");
    expect(slot.currentLOD).toBe(2);
  });

  it("should handle depletion transition", () => {
    setDepleted("rock_1", true);
    
    // Verify instance moved to depleted pool
    const inDepletedPool = hasDepleted("rock_1");
    expect(inDepletedPool).toBe(true);
  });
});

Integration Tests

Full resource lifecycle tests:
it("should render instanced resource with depletion", async () => {
  // Spawn resource
  const resource = await world.spawnResource({
    id: "oak_tree",
    position: { x: 10, y: 0, z: 10 }
  });

  // Verify instanced
  expect(hasInstance(resource.id)).toBe(true);

  // Deplete resource
  await resource.deplete();

  // Verify moved to depleted pool
  expect(hasDepleted(resource.id)).toBe(true);

  // Respawn resource
  await resource.respawn();

  // Verify back in normal pool
  expect(hasDepleted(resource.id)).toBe(false);
});