React UI + Babylon.js : How to Avoid useState Re-Rendering Canvas?

Hi All,

Brand new to Babylon.js and I feel like I’m missing something obvious here. I’m exploring a proof of concept for a 3D editor focused on physics and robotics and I’ve seen lots of great examples out there of traditional UI’s (assuming react) and the Babylon canvas. @dested has done some great work on QuickGa.me: Web Based 3D Multiplayer Game Engine and Hosting Platform!

When starting to build a React UI and using it to update the state of a mesh, I have no trouble accomplishing this with pure javascript and a state variable outside of my component but when using useState I get a constant re-render every time the state changes. On a certain level, this makes sense, but I’ve seen so many other examples doing this that I’m curious if I’m missing something.

In my examples below, I have a simple slider that is mapping from 0 to 360 and adjusting the rotation of the playground box. With useState my entire Babylon canvas refreshes each time I move the slider. I’ve come across this post How to change mesh properties with react state hook? which creates the same issue.

Any advice on best practices?

Vanilla JS State Handling: Working

import { useEffect, useRef, useState } from "react";
import { ArcRotateCamera, Vector3, Color3, MeshBuilder, HemisphericLight} from "@babylonjs/core";
import { GridMaterial } from "@babylonjs/materials"
import SceneComponent from './SceneComponent';
import SliderComponent from "./SliderComponent";


let box;
let boxRotation = 0;

const BasicPlaygroundComponent = (props) => {

  // Will execute once when scene is ready
  const onSceneReady = (scene) => {

    // This creates and positions an arc rotate camera
    var camera = new ArcRotateCamera("camera1", -Math.PI / 2, Math.PI / 2.5, 75, new Vector3(0, 0, 0));

    // Initialize canvase
    const canvas = scene.getEngine().getRenderingCanvas();

    // Attaches camera canvas
    camera.attachControl(canvas, true);

    // This creates a light, aiming 0,1,0 - to the sky (non-mesh)
    var light = new HemisphericLight("light", new Vector3(0, 1, 0), scene);

    // Default intensity is 1. Let's dim the light a small amount
    light.intensity = 0.7;

    // Our built-in 'box' shape.
    box = MeshBuilder.CreateBox("box", { size: 10 }, scene);

    // Move the box upward 1/2 its height
    box.position.y = 10;

    // Our built-in 'ground' shape.
    var ground = MeshBuilder.CreateGround("ground", { width: 150, height: 150 }, scene);

  };


  //Will run on every frame render.  We are spinning the box on y-axis.
  const onRender = (scene) => {
    box.rotation.y = boxRotation;
  };


  const updateBoxRotation = (val) => {
    boxRotation = (val/100)*Math.PI*2;
  }


  return(
    <div>
      <SceneComponent antialias onSceneReady={onSceneReady} onRender={onRender} id='viewport'></SceneComponent>
      <SliderComponent toUpdate={updateBoxRotation}></SliderComponent>

    </div>
  )
}

export default BasicPlaygroundComponent;

useState State Handling: Re-Renders canvas on each change

import { useEffect, useRef, useState } from "react";
import { ArcRotateCamera, Vector3, Color3, MeshBuilder, HemisphericLight} from "@babylonjs/core";
import { GridMaterial } from "@babylonjs/materials"
import SceneComponent from './SceneComponent';
import SliderComponent from "./SliderComponent";


let box;

const BasicPlaygroundComponentUseState = (props) => {
  const sceneRef = useRef(null);
  const [boxRotation, setBoxRotation] = useState(0)

  // Will execute once when scene is ready
  const onSceneReady = (scene) => {

    sceneRef.current = scene;

    // This creates and positions an arc rotate camera
    var camera = new ArcRotateCamera("camera1", -Math.PI / 2, Math.PI / 2.5, 75, new Vector3(0, 0, 0));

    // Initialize canvase
    const canvas = scene.getEngine().getRenderingCanvas();

    // Attaches camera canvas
    camera.attachControl(canvas, true);

    // This creates a light, aiming 0,1,0 - to the sky (non-mesh)
    var light = new HemisphericLight("light", new Vector3(0, 1, 0), scene);

    // Default intensity is 1. Let's dim the light a small amount
    light.intensity = 0.7;

    // Our built-in 'box' shape.
    box = MeshBuilder.CreateBox("box", { size: 10 }, scene);

    // Move the box upward 1/2 its height
    box.position.y = 10;

    // Our built-in 'ground' shape.
    var ground = MeshBuilder.CreateGround("ground", { width: 150, height: 150 }, scene);

  };


  //Will run on every frame render.  We are spinning the box on y-axis.
  const onRender = (scene) => {
    //box.rotation.y = boxRotation;
  };

  useEffect( () => {
      const scene = sceneRef.current;
      if(scene){
        box.rotation.y = boxRotation;
      }
    }, [boxRotation]);

  return(
    <div>
      <SceneComponent antialias onSceneReady={onSceneReady} onRender={onRender} id='viewport'></SceneComponent>
      <SliderComponent toUpdate={setBoxRotation}></SliderComponent>

    </div>
  )
}

export default BasicPlaygroundComponentUseState;
1 Like

I appreciate the shout out!

I’m not sure about best practices, or common behavior, but for my project I split out the concepts of the UI (initially react, and now solidjs) and babylon completely.

The issue once you do that is communication between the two universes. Originally I was just passing around my setState calls to the babylon side, but I was still causing a lot of rerenders of react which was dragging performance down. Recently, especially after switching to solid, I started using signals more, which is giving me much more surgical access to the html rendering, and much finer grained dom updates.

Your mileage may vary since I really could not waste a single millisecond. The goal is to run on extremely cheap chromebooks with a voxel editor that gobbles up memory, every extra dom update had to be mitigated.

I’m around if you want to chat more about some of the hurdles I’ve gone through building a full web based editor, feel free to DM me, but I will say that I would never have been able to pull it off without BabylonJS :slight_smile:

2 Likes

wrap either the const canvas = scene.getEngine().getRenderingCanvas() or onSceneReady function in a useMemo. You can also wrap the whole function in a memo and remove the props arg. like this
const BasicPlaygroundComponentUseState = memo(() => { stuff }). Remove props because its unused, but if u do use later, changing a prop value would invalidate the memo.

1 Like

in your example SceneComponent will always render with every state change. You could export that component as:

export default React.memo(SceneComponent);

One could avoid using useState at all to go for performance. Those state updates are going to cause renders and scheduling updates. With a ref to the mesh you can alter directly by events and same with DOM to synchronize back and forth. That doesn’t follow the standard React paradigm. With memoization you should get close to same speed.

Also, you shouldn’t notice the “entire Babylon canvas refresh” with each state update - can you post your SceneComponent?

2 Likes

All thanks for the great notes!

OK to summarize, I’ll check out React.memo while also realizing react state may not be the best performance and storing the scene state and meshes in a vanilla JS object outside of my component may be the better way to go. Also looking forward to checking out signals as @dested mentioned.

@brianzinn adding SceneComponent script here. Live example of this entire canvas refresh issue can be seen on BigTex’s react+babylyon.js tutorial here. His demo displaying the issue is live here.

import { useEffect, useRef } from "react";
import { Engine, Scene } from "@babylonjs/core";

export default ({ antialias, engineOptions, adaptToDeviceRatio, sceneOptions, onRender, onSceneReady, ...rest}) => {
  const reactCanvas = useRef(null);

  // set up basic engine and scene
  useEffect(() => {
    const { current: canvas } = reactCanvas;

    if (!canvas) return;

    canvas.width = window.innerWidth
    canvas.height = window.innerHeight

    const engine = new Engine(canvas, antialias, engineOptions, adaptToDeviceRatio);
    const scene = new Scene(engine, sceneOptions);
    if (scene.isReady()) {
      onSceneReady(scene);
    } else {
      scene.onReadyObservable.addOnce((scene) => onSceneReady(scene));
    }

    engine.runRenderLoop(() => {
      if (typeof onRender === "function") onRender(scene);
      scene.render();
    });

    const resize = () => {
      scene.getEngine().resize();
    };

    if (window) {
      window.addEventListener("resize", resize);
    }

    return () => {
      scene.getEngine().dispose();

      if (window) {
        window.removeEventListener("resize", resize);
      }
    };
  }, [antialias, engineOptions, adaptToDeviceRatio, sceneOptions, onRender, onSceneReady]);

  return <canvas ref={reactCanvas} {...rest} />;
};

Looks like that will rerender every time, because your “onRender” method is different every time (see the dependency list). I almost cannot believe my burning eyes and then I look at the console and a new Engine is being created with every button click! :smile: that’s easy to fix at least.

@brianzinn this is interesting, can you clarify? I took the SceneComponent from the Babylon React Docs here. Are you saying the onRender as a dependency at the end of the useEffect is the problem? Or is there another aspect of the code you’re referencing?

every time a render occurs. you create a new function:

const onRender = () => { ... };

Then in your component you are passing in a new version of a dependency of the useEffect hook, so it will create a new Engine!

Looks like that was added as a “Best Practice” on Feb 24th…
Update best practices · BabylonJS/Documentation@d93f66d (github.com)

1 Like