Why We Switched from InstancedMesh2 to Regular THREE.InstancedMesh

The Problem: Invisible Projectiles and Performance Issues

In our Electric Sheep Run game, we initially implemented the projectile system using the InstancedMesh2 library, which promised better performance and more features than the standard THREE.js InstancedMesh. However, we encountered several critical issues that ultimately led us to switch back to the regular THREE.js implementation.

Issue 1: Synchronization Problems

The most significant problem was projectile visibility synchronization. Projectiles would exist physically in the game world (they could hit enemies and deal damage) but were completely invisible to the player. This created a confusing gameplay experience where enemies would take damage from seemingly nothing.

// InstancedMesh2 - Problematic synchronization
instancedMesh.addInstances(instances); // Physical instances created
// But visual rendering was often delayed or failed entirely

Issue 2: Speed Configuration Conflicts

The upgrade system was designed to work with standard THREE.js materials and properties, but InstancedMesh2 had different initialization patterns that caused speed upgrades to be overridden:

// ProjectileUpgradeManager expected standard material properties
material.emissive.setHex(color); // Failed with InstancedMesh2's MeshBasicMaterial

Issue 3: Instance Removal and Reuse Problems

InstancedMesh2 had significant issues with instance lifecycle management:

// The count property never updated correctly
console.log(instancedMesh.count); // Always 0, even with active instances

// addInstances returned incorrect entities
const entity = instancedMesh.addInstances(1, (newEntity) => {
  console.log('Callback entity ID:', newEntity.id); // Correct: 0, 1, 2...
});
console.log('Returned entity ID:', entity.id); // Wrong: always 13

Instance Reuse Issues:

  • The count property remained at 0 regardless of active instances
  • addInstances() returned static/incorrect entities instead of the newly created ones
  • Only the callback parameter and instances array were reliable for tracking entities
  • Instance removal required complex workarounds with invisible positioning rather than true removal

Workarounds We Had to Implement:

// Instead of proper removal, we had to hide instances
entity.visible = false;
entity.scale.set(0, 0, 0);
entity.position.set(-10000, -10000, -10000);

// Track instances manually since count was unreliable
this.instanceRegistry[type] = new Set();
this.instanceRegistry[type].add(entity);

Issue 4: Complex Debugging

InstancedMesh2’s internal instance management made it difficult to debug issues. The library handled instance counting and visibility internally, which obscured the root causes of our problems.

The Solution: Back to Basics

We decided to replace InstancedMesh2 with regular THREE.InstancedMesh and implement our own instance management system. This approach gave us:

1. Direct Control Over Instance Lifecycle

// RegularInstancedProjectileManager.js
class RegularInstancedProjectileManager {
  fireSingleProjectile(position, direction, type = 'standard') {
    const freeIndices = this.freeIndices.get(type);
    const instanceIndex = freeIndices.pop();
    
    // Direct control over instance visibility and positioning
    const matrix = new THREE.Matrix4();
    matrix.compose(position, quaternion, scale);
    instancedMesh.setMatrixAt(instanceIndex, matrix);
    instancedMesh.instanceMatrix.needsUpdate = true;
    instancedMesh.count = this.getVisibleCount(type);
  }
}

2. Compatible Material System

We switched from MeshBasicMaterial to MeshStandardMaterial to support the upgrade system’s emissive properties:

// Before: InstancedMesh2 with MeshBasicMaterial
material: new THREE.MeshBasicMaterial({ 
  color: 0x00ffff,
  transparent: true,
  opacity: 0.9
})

// After: Regular InstancedMesh with MeshStandardMaterial
material: new THREE.MeshStandardMaterial({ 
  color: 0x00ffff,
  emissive: 0x002222,        // Now supports emissive properties
  emissiveIntensity: 0.5,
  transparent: true,
  opacity: 0.9,
  roughness: 0.3,
  metalness: 0.7
})

3. Adapter Pattern for Compatibility

To maintain compatibility with existing code, we implemented an adapter pattern:

// ProjectileManagerAdapter.js
export class ProjectileManagerAdapter {
  constructor(game) {
    // Wrap the new implementation
    this.regularManager = new RegularInstancedProjectileManager(game);
    
    // Maintain compatibility properties
    this.activeProjectiles = [];
    this.projectileCount = 1;
    this.baseSpeed = gameConfig.movement.projectile.standardSpeed;
  }

  // Expose the same interface as the old system
  fireProjectile(position, direction, type = 'standard') {
    return this.regularManager.fireSingleProjectile(position, direction, type);
  }
}

Results: Immediate Improvements

The switch yielded immediate and significant improvements:

Visibility Issues Resolved

  • Projectiles are now always visible when fired
  • No more synchronization delays between physics and rendering
  • Consistent visual feedback for player actions

Performance Improvements

  • Maintained 120 FPS with better frame consistency
  • Reduced complexity in the rendering pipeline
  • More predictable memory usage patterns

Upgrade System Compatibility

  • Material property upgrades now work correctly
  • Emissive effects and color changes apply immediately
  • No more setHex errors on undefined properties

Easier Debugging

  • Direct access to instance data and state
  • Clear separation between physics and rendering logic
  • Comprehensive logging for troubleshooting

Key Lessons Learned

1. Sometimes Simpler is Better

While InstancedMesh2 offered advanced features, the standard THREE.js InstancedMesh provided everything we needed with better compatibility and predictability.

2. Control vs. Convenience

Having direct control over instance management was more valuable than the convenience features of InstancedMesh2, especially when debugging complex issues.

3. Material Compatibility Matters

The upgrade system’s dependency on specific material properties (like emissive) required careful consideration of material types across the entire rendering pipeline.

4. Adapter Pattern for Migration

Using an adapter pattern allowed us to switch implementations without breaking existing code, making the migration smooth and reversible.

Conclusion

The switch from InstancedMesh2 to regular THREE.InstancedMesh was a clear win for our project. While InstancedMesh2 is a powerful library with many advanced features, it wasn’t the right fit for our specific use case. The regular THREE.js implementation provided the reliability, compatibility, and control we needed to deliver a smooth gaming experience.

Key Takeaway: When choosing between a feature-rich third-party library and a simpler standard implementation, consider your specific requirements, debugging needs, and integration complexity. Sometimes the standard solution is the best solution.


This refactor was completed as part of the Electric Sheep Run game development, resolving critical projectile visibility and performance issues while maintaining full backward compatibility. Projectile size adjustments may vary based on player feedback and developer second-guessing.

Leave a Comment