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.