( I started writing this answer by hand, but then I put it through gemini to clean it up and check the logic.. I haven’t actually tested it, but it articulates what I’m trying to explain, so treat it as pseudocode )
I think you might be overcomplicating the level of granularity required here. You don’t need to loop through objects one-by-one or pass textures around manually.
Keep in mind that by default, Three.js already sorts opaque meshes front-to-back before rendering them. Because of modern GPU early-Z rejection, you actually get a lot of occlusion optimization out of the box.
If you still need a true depth prepass (for instance, if you have massive merged meshes where object-level sorting fails), it’s done at a macro pipeline level, not mesh-by-mesh. You just let Three.js traverse the scene graph natively and manipulate the global renderer state between two quick passes.
Here is a clean, simplified way to structure it using standard Three.js paradigms:
// 1. Setup a dedicated material for the prepass
// MeshDepthMaterial automatically handles skinning/morph targets if you have them
const depthPrepassMaterial = new THREE.MeshDepthMaterial();
depthPrepassMaterial.colorWrite = false; // CRITICAL: Don't waste fill rate writing colors
// 2. Configure your render loop
renderer.autoClear = false; // Take manual control
function animate() {
requestAnimationFrame(animate);
// Clear everything manually at frame start
renderer.clear(true, true, true);
// --- PASS 1: Depth Prepass ---
// Force the whole scene to use our color-blind depth material
fpsScene.overrideMaterial = depthPrepassMaterial;
renderer.state.buffers.depth.setTest(true);
renderer.state.buffers.depth.setMask(true);
renderer.state.buffers.depth.setFunc(THREE.LessEqualDepth);
renderer.render(fpsScene, fpsCamera);
// --- PASS 2: The Real Color Pass ---
fpsScene.overrideMaterial = null; // Restore original heavy materials
// Change depth function to EQUAL.
// The GPU will now instantly discard pixels that don't match our prepass depth,
// BEFORE running your expensive fragment shaders.
renderer.state.buffers.depth.setFunc(THREE.EqualDepth);
renderer.state.buffers.depth.setMask(false);
renderer.render(fpsScene, fpsCamera);
// --- PASS 3: Weapon Overlay Pass ---
renderer.state.buffers.depth.setFunc(THREE.LessEqualDepth);
renderer.state.buffers.depth.setMask(true);
renderer.clearDepth(); // Clear depth so the gun renders cleanly over the world
renderer.render(overlayScene, overlayCamera);
}
You’re on the right track in understanding how/why the depth prepass works/is needed, but the granularity required in javascript+threejs is different than in a to-the-metal C/C++ app.
In javascript, the state switching between the GPU and JS has higher overhead than in C/C++.. since the JS scheduler is handling a lot of bookkeeping than a pure C/C++ thread, so you want to minimize the number of bounces between JS and the GPU thread, per frame.