Facing issues with useFBO render lags for first person view outside canvas

Hey!

I am trying to render the view faced by my model using the canvas element. I am facing lags when there is movement in the model. It isn’t smooth and misses a lot of frames in between. Is there a better way to capture the frames and stream? Initially, when I do orbital controls it gets stuck and then moves smoothly.
Here is the code I used to render the first-person view.
Thanks in advance. Would appreciate solutions to make it a fast and smooth view.


function Render({ pCamera }) {
    const { setCameraView } = useStore();
    const aTarget = useFBO(640, 480, {
        type: THREE.UnsignedByteType
    })

    const guiCamera = useRef()

    useThree()

    const debugBG = new THREE.Color('#fff')

    useFrame(({ gl, camera, scene }) => {
        gl.autoClear = false

        scene.background = debugBG

        /** Render scene from camera A to a render target */
        if (pCamera && pCamera.current) {
            gl.setRenderTarget(aTarget)
            gl.render(scene, pCamera.current)

            const width = aTarget.width
            const height = aTarget.height

            // Create a temporary canvas to draw the texture
            const canvas = document.createElement('canvas')
            canvas.width = width
            canvas.height = height
            const context = canvas.getContext('2d')

            // Read pixels from the render target
            const pixels = new Uint8Array(4 * width * height)
            gl.readRenderTargetPixels(aTarget, 0, 0, width, height, pixels)

            // Create ImageData with the correct dimensions
            const imageData = context.createImageData(width, height)

            // Copy the pixel data to the ImageData, handling potential padding
            for (let i = 0; i < imageData.data.length; i += 4) {
                imageData.data[i] = pixels[i]
                imageData.data[i + 1] = pixels[i + 1]
                imageData.data[i + 2] = pixels[i + 2]
                imageData.data[i + 3] = pixels[i + 3]
            }

            // Put the image data on the canvas
            context.putImageData(imageData, 0, 0)

            // Flip the image vertically
            context.scale(1, -1)
            context.translate(0, -height)
            context.drawImage(canvas, 0, 0)

            // Get the data URL
            const dataURL = canvas.toDataURL()
            setCameraView(dataURL);
        }

        scene.overrideMaterial = null
        gl.setRenderTarget(null)
        gl.render(scene, camera)

    }, 1)
   
    return <OrthographicCamera ref={guiCamera} near={0.0001} far={1} />
}
  • const canvas = document.createElement('canvas') is a very expensive thing to do (and will eventually crash the tab, since you’re creating new canvas contexts 60 times per seconds.)
  • for (let i = 0; i < imageData.data.length; i += 4) { is an even more expensive thing to do.
  • const dataURL = canvas.toDataURL() super expensive thing to do.

You’ve put a lot of strain on the CPU, which is single threaded in the browser. All lines between gl.render(scene, pCamera.current) and setCameraView(dataURL); should be happening on the GPU. Why do you need dataURL outside of GPU (the only case I could imagine that is using the frame in a separate <img> element or exporting it to server / local machine, but either shouldn’t be happening at 60fps) ? Do you need it continuously updated, or you just want to use it as a texture in WebGL (in which case you can just use aTarget.texture and pass it to further materials.)

I want it to be continuously updated (can be 30fps) and streamed to the server.
Can you recommend a better way to do it? how do I access the camera view and stream it?

Before diving too deep into streaming, I’d try the following and see if performance-wise it’s acceptable:

// 1. Create the canvas only once
//    Pass { willReadFrequently: true } to canvas context
//    to make it do the I/O a bit faster
const width = 640;
const height = 480;

const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", {
    willReadFrequently: true,
});

// 2. This can also be reused, no need to recreate on each framr
const pixels = new Uint8Array(4 * width * height);

// 3. Create the component
function Render({ pCamera }) {
  const { setCameraView } = useStore();
  const aTarget = useFBO(width, height, { type: THREE.UnsignedByteType });
  const guiCamera = useRef();
  const debugBG = new THREE.Color("#fff");

  // 4. Replace useFrame with useEffect, useFrame runs at 60fps, useEffect runs at whatever you tell it to
  //    If you see flickering due to interval race-conditioning with useFrame, use requestAnimationFrame
  //    instead of setInterval
  const { gl, camera, scene } = useThree();

  useEffect(() => {
    const render = () => {
        if (!pCamera.current) {
            return;
        }

        gl.autoClear = false;

        scene.background = debugBG;

        gl.setRenderTarget(aTarget);
        gl.render(scene, pCamera.current);
        gl.readRenderTargetPixels(aTarget, 0, 0, width, height, pixels);

        // 5. Pass pixels directly to ImageData (https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData)
        //    to avoid the loop
        const imageData = new ImageData(pixels, width, height);
        context.putImageData(imageData, 0, 0);

        context.scale(1, -1);
        context.translate(0, -height);
        context.drawImage(canvas, 0, 0);

        const dataURL = canvas.toDataURL();
        setCameraView(dataURL);

        scene.overrideMaterial = null;

        gl.setRenderTarget(null);

        // 6. Note - if you're using R3F, no need to call .render manually
        // gl.render(scene, camera);
    };

    const interval = setInterval(() => render(), 1000 / 30); // 30fps

    return () => clearInterval(interval);
  }, [ gl, camera, scene ]);

  return <OrthographicCamera ref={guiCamera} near={0.0001} far={1} />;
}

At that low resolution, this could be enough to make the app run smoothly.

Thank you so much for the help!!
I tried the changes you mentioned. setInterval with useEffect was updating the camera feed once my WASD controls stopped.
I replaced it with requestAnimationFrame in useEffect and it seems to work better.
the CPU time was reduced by 50% but the FPS of the entire scene remained at 11-12 even after the changes. Before I add this camera viewer the FPS was 20-22.
How can I increase overall fps of the scene?

This indicates the app itself has some significant issues with optimisation, since the default FPS would generally be 60 (on low end devices) or 120 (on high end devices.) If it’s 20 - I’d remove the streaming / FBO entirely and first optimise the rest of the code. When it gets back to 60-120 fps, only then work with the FBOs.