BabylonJs React scene component - getEngine().resize() on canvas resize

Hi all,

Sorry for the ultranoobish question. Still learning React, Babylon, and react-babylonjs.

Currently implementing the boilerplate SceneComponent in How to use Babylon.js with ReactJS - Babylon.js Documentation.

The parent container of my SceneComponent may be resized without the window being resized. When this happens, the scene’s engine does not automatically run getEngine().resize(). I was hoping to add an event listener for onresize of canvas within the SceneComponent (attempting to mirror the window resize listener) but am not finding success.

Currently my line of thinking is to bind a resize event listener to the React ref “reactCanvas.current”. However, either this is not the right solution, or the way I am inserting it into the SceneComponent is causing it to not bind or something.

import React, { useEffect, useRef } from "react";

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


function BabylonSceneComponent(props) {
    const reactCanvas = useRef(null);
    const { antialias, 
            engineOptions, 
            adaptToDeviceRatio,
            sceneOptions,
            onRender,
            onSceneReady,
            debugMode,
            ...rest
          } = props;

    useEffect(() => {
        if (reactCanvas.current) {
            const engine = new Engine( reactCanvas.current, antialias, engineOptions, adaptToDeviceRatio);
            const scene = new Scene(engine, sceneOptions);

            if (scene.isReady()) {
                props.onSceneReady(scene);
            } else {
                scene.onReadyObservable.addOnce((scene) => props.onSceneReady(scene));
            }

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

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

            ////////////////////////////////////////
            ////   My Update to SceneComponent
            ////////////////////////////////////////
            reactCanvas.current.addEventListener("resize", resize);
            ////////////////////////////////////////
            ////   /Update
            ////////////////////////////////////////

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

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

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

        };
    }, [reactCanvas]);

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


export default BabylonSceneComponent;

Adding @brianzinn our React Guru :slight_smile:

1 Like

Update:

  1. Not sure if this is old information, but it appears that perhaps the resize event listener / ‘onresize’ property only refers to window/document resizing, not individual elements. This might not be a valid solution.

  2. The ResizeObserver class (supported by all modern browsers except IE11) seems to be a solution that provides results:

            ////////////////////////////////////////
            ////   Update to SceneComponent
            ////////////////////////////////////////
            const resizeObs = new ResizeObserver(entries => {
                for (let entry of entries) {
                    resize();
                }
            }).observe(reactCanvas.current);
            ////////////////////////////////////////
            ////   /Update
            ////////////////////////////////////////
  1. Sadly, this solution comes with its own bugs - when a non-window resize occurs, the canvas stops rendering while resizing, resulting in a headache-inducing blink effect. No idea why this happens.

(̶N̶o̶t̶ ̶s̶u̶r̶e̶ ̶i̶f̶ ̶r̶e̶l̶e̶v̶a̶n̶t̶ ̶-̶ ̶T̶e̶s̶t̶e̶d̶ ̶r̶u̶n̶n̶i̶n̶g̶ ̶a̶ ̶c̶o̶n̶s̶o̶l̶e̶.̶l̶o̶g̶ ̶i̶n̶s̶t̶e̶a̶d̶ ̶o̶f̶ ̶a̶ ̶r̶e̶s̶i̶z̶e̶ ̶-̶ ̶n̶o̶t̶i̶c̶e̶d̶ ̶t̶h̶a̶t̶ ̶t̶h̶e̶ ̶R̶e̶s̶i̶z̶e̶O̶b̶s̶e̶r̶v̶e̶r̶ ̶f̶i̶r̶e̶d̶ ̶a̶b̶o̶u̶t̶ ̶1̶.̶5̶x̶ ̶m̶o̶r̶e̶ ̶"̶c̶o̶n̶s̶o̶l̶e̶.̶l̶o̶g̶"̶s̶ ̶t̶h̶a̶n̶ ̶t̶h̶e̶ ̶w̶i̶n̶d̶o̶w̶ ̶r̶e̶s̶i̶z̶e̶ ̶e̶v̶e̶n̶t̶l̶i̶s̶t̶e̶n̶e̶r̶)̶ Likely not - can recreate this nonrendering with very quick & tiny resizes.

1 Like

Hello @Cheebs I’m curious to know how you’re resizing the parent component (if you can share) when clicking the button. I want to see if i can help you figure something out :smiley:

1 Like

Resizing is done by applying a class to the parent wrapper. The class overrides the width, and a transition property allows for an animated open:

MainElement.js:

class MainElement extends React.Component {
[...]
_classMobiMenuOpened() {
        return this.state.mobiMenuOpened ? 'mobiMenuOpened' : '';
    }
[...]
    render() {
        return (
                <SubElement 
                    sendMobiMenuOpen={this._classMobiMenuOpened()}
                />
)
}
}

SubElement.js:

class SubElement extends React.Component {
[...]
 render() {  
        
        return (     
            <div 
                id="renderCanvas_wrapper"
                className={this.props.sendMobiMenuOpen}
                style={{width: "100%", height:"100%"}}
            >
                <SceneComponent 
                    antialias 
                    adaptToDeviceRatio
                    onSceneReady={onSceneReady} 
                    onRender={onRender} 
                    id="renderCanvas"
                >
                </SceneComponent>

            </div>
        );
    }
}

SubElement.css:

#renderCanvas_wrapper { 
    height: 100%; width: 100%;
    transition: all 0.2s ease;
}


 #renderCanvas_wrapper.mobiMenuOpened {
    width: 70% !important;
}

Clarifying the above :

  • “MainElement” has a boolean state “mobiMenuOpened”. Only when this state is true, this will pass a string (the classname) into the rendered “SubElement” 's property ‘sendMobiMenuOpen’.

  • “SubElement” writes the string from its property ‘sendMobiMenuOpen’ directly into its ‘className’ property.

  • The className ''mobiMenuOpened" will override the width from 100% → 70% - applied with a transition of 0.2s (ease).

After some experimentation, I see you can also recreate this blink-rendering if you comment out ‘window…addEventListener(“resize”, resize);’. The SceneComponent will rely entirely on the ‘ResizeObserver’ object for all resizes, and then resizing anything (even the whole window) will blank the canvas while it resizes.

1 Like

Okay I think I may** have found the issue. It has to do with the css classes toggling. When I manually set the state of the width to 0.7x using setState() and pass it down to the canvas it seems to work fine, but if i toggle using the css class I get the flickering effect you seem to get. Are you able to reproduce that?

PS: I forgot to mention but welcome to the Babylon family!!!

1 Like

Unfortunately I can’t seem to achieve your results. I’ve trimmed off an unnecessary div, and instead of updating ‘className’ I’ve instead updated the ‘style’. I’ve also moved the comparison into the SubElement ‘componentDidUpdate’ and turned it into a setState operation. I’m still reproducing the exact same issue I’m afraid.

MainElement.js:

class MainElement extends React.Component {
[...]
_getMobiMenuOpen() {
        return this.state.mobiMenuOpened;
    }
[...]
    render() {
        return (
                <SubElement 
                    isMobiMenuOpen={this._getMobiMenuOpen()}
                />
)}}

SubElement.js:

class SubElement extends React.Component {
    constructor(props) {
        this.state = {
            width: 100,
        };
    }
[...]
    
    componentDidUpdate(prevProps, prevState) {
        if (this.props.isMobiMenuOpen !== prevProps.isMobiMenuOpen) {
            this.setState({width: (this.props.isMobiMenuOpen ? 70 : 100)});
        }
    }
 render() {  
        return (   
                <SceneComponent 
                    antialias 
                    adaptToDeviceRatio
                    onSceneReady={onSceneReady} 
                    onRender={onRender} 
                    id="renderCanvas"
                    ref={this.selfRef}
                    style={{
                        width: this.state.width+"%",
                        position: "absolute",
                        height: "100%",
                        transition: "all 0.2s ease",
                    }}
                />
        );
    }
}

If you could share your example of multiplying width by 0.7x using setState, I’m curious if I could learn something about the rendering methods I’m missing.

Try removing the “all” from the transition style

Negative, transition doesn’t even seem to matter at all. Removing the property entirely makes the width jump from 100% → 70%, but it still blinks on rendering for a frame when it does.

Update with solution:

You can add an additional scene.render() call at the end of the ResizeObserver callback.

Code at bottom.

My guess at what’s happening → ResizeObserver callback during a resize event is preventing the engine.runRenderLoop from firing until it completes. I’m guessing something in the window resize event allows the screen to recalulate and redraw after every frame of resize, but the ResizeObserver does not (seems to be a more general issue between canvas elements and ResizeObserver - found: When resize canvas , there is a white background flickering · Issue #3395 · pixijs/pixijs · GitHub)

Only performance hit I see is resize() getting called twice on resizing the Window. Not sure if there are other performance implications, but this is a currently working version:

    useEffect(() => {
        if (reactCanvas.current) {
            const engine = new Engine( reactCanvas.current, antialias, engineOptions, adaptToDeviceRatio);
            const scene = new Scene(engine, sceneOptions);
            
            const resize = () => {
                scene.getEngine().resize();
            };

            ////////////////////////////////////////
            ////   Update to SceneComponent
            ////////////////////////////////////////
            const resizeObs = new ResizeObserver(entries => {
                for (let entry of entries) {
                    resize();
                }

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

            }).observe(reactCanvas.current);
            ////////////////////////////////////////
            ////   /Update
            ////////////////////////////////////////
            

            if (scene.isReady()) {
                props.onSceneReady(scene);
            } else {
                scene.onReadyObservable.addOnce((scene) => props.onSceneReady(scene));
            }


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

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

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

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

                if (resizeObs) {
                    resizeObs.disconnect();
                }
            }
3 Likes

Oh wow I’m glad you found a solution that works for you!! Great job finding that fix!! :smiley:

2 Likes

hi @Cheebs - Welcome to the babylonjs forum. Thank-you for sharing the solution. I did look into that at one point, but you found a great solution.

I wouldn’t worry about the performance hit of calling engine.resize() - in the playground it is triggered in the engine.runRenderLoop(...)!:

if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) {
  this._engine.resize();
}

Babylon.js/rendererComponent.tsx at master · BabylonJS/Babylon.js (github.com)

3 Likes

hi @Cheebs thanks for your sample code and solution for the flickering! I made a couple of changes to it to have it work on my side. My code ended up like this (notes included for the 3 changes I made on my side):

useEffect(() => {
  // create scene, engine, etc.
  ...
  let resizeObserver: Nullable<ResizeObserver> = null;
  // note 1: should check window.ResizeObserver for browser compatibility (i added an opt-out prop) and didn't check "entries" as suspect there is only 1.
  if (props.observeCanvasResize !== false && window?.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      engine.resize();
      if (scene.activeCamera /* note 2: if there is no active camera scene.render() throws */) {
        // render to prevent flickering on resize
        if (typeof onRender === 'function') {
          onRender(scene);
        }
        scene.render();
      }
    });
    // note 3: observe() returns void - it is not a "builder" or "fluent" API, so is not disconnected in your version
    resizeObserver.observe(reactCanvas.current);
  }

  return () => {
    // cleanup
    if (resizeObserver !== null) {
      resizeObserver.disconnect();
    }

    if (window) {
      window.removeEventListener('resize', resize);
    }

    scene.getEngine().dispose();
  }
})

Calling render() at the end of the ResizeObserver callback has a downside: if you are also calling render() in an animation frame loop, then you’ll be double rendering (double CPU/GPU usage, and the available frame budget is cut in half).

1 Like