Skip to content

Commit 7a2e91e

Browse files
committed
chore(general): add manual render loop + improve react rendering
1 parent 5faf91f commit 7a2e91e

File tree

4 files changed

+95
-22
lines changed

4 files changed

+95
-22
lines changed

packages/library/src/core/Scene.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { RootContainer, EngineContext } from '@types';
88
import Reactylon, { ROOT_IDENTIFIER } from '../reconciler';
99
import { useContextBridge } from 'its-fine';
1010
import type { StoreApi } from 'zustand';
11+
import { Logger } from '@dvmstudios/reactylon-common';
1112

1213
export type SceneProps = React.PropsWithChildren<{
1314
/**
@@ -33,7 +34,7 @@ export type SceneProps = React.PropsWithChildren<{
3334
export let activeScene: BabylonScene | null = null;
3435

3536
export const Scene = ({ children, sceneOptions, onSceneReady, isGui3DManager, xrDefaultExperienceOptions, physicsOptions, _context, ...rest }: SceneProps) => {
36-
const { engine, isMultipleCanvas, isMultipleScene, disposeEngine } = _context as EngineContext;
37+
const { engine, isMultipleCanvas, isMultipleScene, disposeEngine, markSceneAsReady } = _context as EngineContext;
3738
const rootContainer = useRef<Nullable<RootContainer>>(null);
3839
const reconciler = useRef(Reactylon());
3940
const store = useRef<StoreApi<Store>>(null);
@@ -132,11 +133,22 @@ export const Scene = ({ children, sceneOptions, onSceneReady, isGui3DManager, xr
132133
<SceneContext.Provider value={store.current}>{children}</SceneContext.Provider>
133134
</Bridge>,
134135
rootContainer.current!,
136+
{
137+
onFirstCommit: () => {
138+
if (!scene.activeCamera) {
139+
Logger.warn(
140+
'Scene - No active camera after first commit. If you use a declarative camera, ensure it becomes scene.activeCamera (or create one procedurally in onSceneReady).',
141+
);
142+
}
143+
markSceneAsReady?.();
144+
},
145+
},
135146
);
136147
}
137148
})();
138149

139150
return () => {
151+
// the callback will be executed only if there are no more scenes
140152
reconciler.current.unmount(rootContainer.current!, () => {
141153
activeScene = null;
142154
disposeEngine();
@@ -163,7 +175,7 @@ export const Scene = ({ children, sceneOptions, onSceneReady, isGui3DManager, xr
163175
</Bridge>,
164176
rootContainer.current!,
165177
);
166-
});
178+
}, [children]);
167179

168180
return null;
169181
};

packages/library/src/reconciler.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -617,24 +617,29 @@ function createReconciler() {
617617
);
618618
}
619619

620+
type RenderCallbacks = {
621+
onFirstCommit?: () => void;
622+
};
623+
620624
type ReactylonHandler = {
621-
render: (element: any, rootContainer: RootContainer) => void;
625+
render: (element: any, rootContainer: RootContainer, callbacks?: RenderCallbacks) => void;
622626
unmount: (rootContainer: RootContainer, disposeEngine: EngineContext['disposeEngine']) => void;
623627
};
624628

625-
export const roots = new Map<any, FiberRoot>();
629+
export const roots = new Map<any, { root: FiberRoot; didFirstCommit: boolean }>();
626630

627631
const Reactylon = (): ReactylonHandler => {
628632
const reconciler = createReconciler();
629633
return {
630-
render: (element, rootContainer) => {
631-
let root = roots.get(rootContainer);
634+
render: (element, rootContainer, callbacks) => {
635+
let rootEntry = roots.get(rootContainer);
632636
// first render
633-
if (!root) {
637+
if (!rootEntry) {
634638
// Create a container using the reconciler's createContainer method
635639
// @ts-expect-error - @types/react-reconciler doesn't define new types (where for reconciler >= 0.31.0 arguments of createContainer are 10 and not 8)
636-
root = reconciler.createContainer(rootContainer, false, null, false, null, '', console.error, console.error, console.error, null);
637-
roots.set(rootContainer, root);
640+
const root = reconciler.createContainer(rootContainer, false, null, false, null, '', console.error, console.error, console.error, null);
641+
rootEntry = { root, didFirstCommit: false };
642+
roots.set(rootContainer, rootEntry);
638643
reconciler.injectIntoDevTools({
639644
//findFiberByHostInstance: reconciler.findHostInstance,
640645
bundleType: process.env.NODE_ENV === 'development' ? 1 : 0,
@@ -643,14 +648,20 @@ const Reactylon = (): ReactylonHandler => {
643648
});
644649
}
645650
// Update the container with the specified component to trigger the rendering process
646-
reconciler.updateContainer(element, root, null, null);
651+
reconciler.updateContainer(element, rootEntry.root, null, () => {
652+
if (!rootEntry.didFirstCommit) {
653+
rootEntry.didFirstCommit = true;
654+
callbacks?.onFirstCommit?.();
655+
}
656+
});
647657
},
648658

649-
unmount(container: FiberRoot, disposeEngine): void {
659+
unmount(rootContainer: RootContainer, disposeEngine): void {
650660
Logger.log(`unmount - container unmounted`);
651-
const root = roots.get(container);
652-
reconciler.updateContainer(null, root, null, () => {
653-
roots.delete(container);
661+
const rootEntry = roots.get(rootContainer);
662+
if (!rootEntry) return;
663+
reconciler.updateContainer(null, rootEntry.root, null, () => {
664+
roots.delete(rootContainer);
654665
if (roots.size === 0) {
655666
// no more scenes so dispose the engine
656667
disposeEngine?.();

packages/library/src/types/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,5 @@ export type RootContainer = {
9191

9292
export type EngineContext = EngineStore & {
9393
disposeEngine: () => void;
94+
markSceneAsReady?: () => void;
9495
};

packages/library/src/web/Engine.tsx

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,37 @@ export type EngineProps = React.PropsWithChildren<{
3232
* This prop is only for testing purpose and should not be passed to this component.
3333
*/
3434
_nullEngine?: NullEngine;
35+
/**
36+
* Overrides Reactylon's default render-loop scheduling.
37+
*
38+
* By default, Reactylon starts a continuous Babylon.js render loop via `engine.runRenderLoop(...)`
39+
* once all scenes are marked as ready.
40+
*
41+
* When `manualRenderLoop` is provided, Reactylon will **not** call `engine.runRenderLoop`.
42+
* Instead, it will invoke this callback exactly once at the moment the render loop would normally start.
43+
*
44+
* This is primarily intended for **tests** (deterministic stepping) or advanced integrations that
45+
* require full control over scheduling.
46+
*
47+
* @param renderFrame A function that renders a single frame using Reactylon's internal rules
48+
* (e.g., multi-scene view selection). Call it manually to step rendering deterministically.
49+
* @param engine The Babylon.js engine instance. Optional; provided for advanced use-cases
50+
* (e.g., starting/stopping a continuous loop manually).
51+
*
52+
*/
53+
manualRenderLoop?: (renderFrame: () => void, engine: WebGLEngine | WebGPUEngine) => void;
3554
}>;
3655

37-
export const Engine = ({ engineOptions, loadingScreenOptions, canvasId = 'reactylon-canvas', _nullEngine, isMultipleCanvas, forceWebGL, ...rest }: EngineProps) => {
56+
export const Engine = ({
57+
engineOptions,
58+
loadingScreenOptions,
59+
canvasId = 'reactylon-canvas',
60+
_nullEngine,
61+
isMultipleCanvas,
62+
forceWebGL,
63+
manualRenderLoop,
64+
...rest
65+
}: EngineProps) => {
3866
const [context, setContext] = useState<EngineContext | null>(null);
3967
const engineRef = useRef<{
4068
engine: WebGLEngine | WebGPUEngine;
@@ -44,6 +72,10 @@ export const Engine = ({ engineOptions, loadingScreenOptions, canvasId = 'reacty
4472
const canvasRef = useRef<HTMLCanvasElement>(null);
4573

4674
const children = Children.toArray(rest.children) as Array<React.ReactElement>;
75+
const initialScenesRef = useRef(children.length);
76+
const readyScenesRef = useRef(0);
77+
const isRenderLoopStarted = useRef(false);
78+
4779
const isMultipleScene = children.length > 1;
4880

4981
useEffect(() => {
@@ -75,20 +107,36 @@ export const Engine = ({ engineOptions, loadingScreenOptions, canvasId = 'reacty
75107
const { component, animationStyle } = loadingScreenOptions;
76108
engine.loadingScreen = new CustomLoadingScreen(canvas as HTMLCanvasElement, component, animationStyle) as unknown as ILoadingScreen;
77109
}
78-
engine.runRenderLoop(() => {
110+
const renderFrame = () => {
79111
const camera = engine!.activeView?.camera;
80112
engine!.scenes.forEach(scene => {
81-
if (!scene.activeCamera) {
82-
// meantime you are setting a camera
83-
Logger.warn('Engine - runRenderLoop - Waiting for active camera...');
84-
}
85-
if (scene.cameras?.length > 0) {
113+
if (scene.activeCamera) {
86114
if (!isMultipleScene || scene.activeCamera === camera) {
87115
scene.render();
88116
}
89117
}
90118
});
91-
});
119+
};
120+
121+
const startRenderLoop = () => {
122+
if (isRenderLoopStarted.current) return;
123+
isRenderLoopStarted.current = true;
124+
125+
if (manualRenderLoop) {
126+
manualRenderLoop(renderFrame, engine);
127+
return;
128+
}
129+
engine.runRenderLoop(renderFrame);
130+
};
131+
132+
const markSceneAsReady = () => {
133+
readyScenesRef.current += 1;
134+
// start render loop only when all scenes are marked as ready
135+
if (readyScenesRef.current >= initialScenesRef.current) {
136+
startRenderLoop();
137+
}
138+
};
139+
92140
engineRef.current = {
93141
engine,
94142
onResizeWindow: () => engine.resize(),
@@ -99,6 +147,7 @@ export const Engine = ({ engineOptions, loadingScreenOptions, canvasId = 'reacty
99147
engine,
100148
isMultipleCanvas: !!isMultipleCanvas,
101149
isMultipleScene,
150+
markSceneAsReady,
102151
disposeEngine: () => {
103152
window.removeEventListener('resize', engineRef.current!.onResizeWindow);
104153
engineRef.current!.engine.dispose();

0 commit comments

Comments
 (0)