Noticeable 'hiccup'/freeze when not visible object gets visible for the first time

Hi all!

I have an object (InstancedMesh) that I want to be visible=false at camera zoom < 0.5 and make it visible at zoom >= 0.5.
The problem is when I set it to visible=true for the first time (the initital state of the camera can be with zoom<0.5) I notice a slight but very anoying ‘hiccup’/freeze as the CPU spikes to compile his material and I guess uploading it (material, geometry, buffers, etc) for the very first time to the GPU.

I know some dirty ways to prevent that by rendering it at least once on loading setting it to visible=true at least once, but I would like to ask the forum’s wisdom about the best/intended way to do it. Could that be done even if the object is not yet added to the scene? Is there some ‘clean’ way to do it?

Thx!

Hello. When scene will be loaded, run code:

InstancedMesh.visible=true;
InstancedMesh.frustumCulled=false;
renderer.render(InstancedMesh,camera);
InstancedMesh.frustumCulled=true;

Or look at this three.js docs

1 Like

The hitch could be any of:

  • uploading geometry
  • uploading textures
  • compiling materials

If you run a performance profile in Chrome dev tools and look at the slower functions in the profile, it should be clearer which of these you’re facing. Or you could disable one piece or another to figure it out by process of elimination.

1 Like

When you say “uploading geometry”, do you refer to creating a new material and using it for the first time, or some other work necessary on every new frame?

I’m already reusing global materials to avoid compilation hiccup, but wondering if using global geometries is a good idea too. I assume (in R3F) useLoader(GLTFLoader) caches and reuses geometries, so that I can mount the same component using a loader many times without incurring the cost of recreating those geometries?

By uploading geometry, I mean that the first time a Mesh renders, its vertex data is uploaded to GPU memory. No relationship to the material, and renderer.compile does not (to my knowledge) upload geometry either. Reusing geometries is certainly a good idea, if the geometries are non-trivial in size. Mainly a matter of vertex count and perhaps morph targets.

I don’t know whether useLoader() returns reused geometries when you call it multiple times with the same URL. I suspect it does not, and that it could be better to load once and clone or instance the object.

In models that contain many instances of the same geometry, there’s also some variation in whether the model reuses that geometry, or duplicates it. To ensure you get the former, you can use tools like glTF Transform or gltfjsx.

That said – in my experience the material compilation and texture uploads are usually the slower steps.

@tommhuth @donmccurdy all loader hooks are suspense cached. multiple calls across the component graph will always refer to the same exact data given you use the same url which acts as a cache key. this is generally good for performance and memory impact. there is rarely a need to clone anything in react. i would consider cloning an anti pattern, if i see it in my code i treat it as a problem.

ps, in my experience gl.compile does not work @Chaser_Code threejs starts uploading when the camera that is used to render “sees” something. if there are objects out of its frustum (behind, etc) compile won’t do anything.

in drei there is a handy component called <Preload all />, which parses the whole scene, flips everything to visible, compiles, then renders once with a low res cube camera which sees all, and then restores defaults. this ensures that on the very first screen render there can be no unwanted glitches and hiccups.

in vanilla just take the code inside useLayoutEffect

export function Preload({ all, scene, camera }: Props) {
  const gl = useThree(({ gl }) => gl)
  const dCamera = useThree(({ camera }) => camera)
  const dScene = useThree(({ scene }) => scene)

  // Layout effect because it must run before React commits
  React.useLayoutEffect(() => {
    const invisible: Object3D[] = []
    if (all) {
      // Find all invisible objects, store and then flip them
      ;(scene || dScene).traverse((object) => {
        if (object.visible === false) {
          invisible.push(object)
          object.visible = true
        }
      })
    }
    // Now compile the scene
    gl.compile(scene || dScene, camera || dCamera)
    // And for good measure, hit it with a cube camera
    const cubeRenderTarget = new WebGLCubeRenderTarget(128)
    const cubeCamera = new CubeCamera(0.01, 100000, cubeRenderTarget)
    cubeCamera.update(gl, (scene || dScene) as Scene)
    cubeRenderTarget.dispose()
    // Flips these objects back
    invisible.forEach((object) => (object.visible = false))
  }, [])
}
3 Likes

Thx everybody for your help!

@Chaser_Code was right on point, I would mark it as solution but I think it’s better if I explain here a bit more and mark this reply as solution for future viewers.

@donmccurdy Great advice, it seems the first upload of the main texture used by the object material (a big 4096x4096 CanvasTexture where I pre-draw all the necessary text labels I need) to the GPU was the main problem:

To solve it I first tried @Chaser_Code suggestion to use the renderer.compile() method. It worked partially as I could see the shader program compiled and uploaded to the GPU in my renderer stats, but the texture was not yet uploaded (which makes sense, right?).

So in the end I tried the other proposed method by @Chaser_Code to just call renderer.render() on the object at least once makeing sure it’s visible at that precise moment and it worked wonders. With that the freeze no longer happens and I can clearly see in the renderer stats that the program + geometry + textures of the object are all uploaded to the GPU before I need to display it on screen :+1:.

This is how the final solution looks for me:

// We add the legends to the GUI scene so they are not affected by the grapScene Camera
guiScene.add(legendsMesh);

// Nos we will forcefully render it once so the texture is uploaded to the GPU and there is no 'freeze' when the legends mesh is made visible for the first time, which depends on camera zoom in my implementation.
// First we store the visibility to restore it later
const prevVisibility = legendsMesh.visible;
// We make it forcefully visible
legendsMesh.visible = true;
// Rendering it will compile shaders and upload the shaders+geometry+textures to the GPU
renderer.render(legendsMesh, guiCamera);
// Finally we restore the mesh visibility to whatever value it had
legendsMesh.visible = prevVisibility;
2 Likes

Glad that works!

If you needed something just for the texture specifically, renderer.initTexture can also be used.

3 Likes

Thx a lot @donmccurdy, I wasn’t aware of that method, great to know!