Skip to content

Commit cdc590a

Browse files
committed
fix(components,reconciler): handle multiple loading screens + improve multiple canvases
1 parent 4dda28b commit cdc590a

File tree

4 files changed

+195
-82
lines changed

4 files changed

+195
-82
lines changed

packages/library/src/components/hosts/CameraHost.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { ComponentInstance, RootContainer } from '@types';
22
import { Host } from './Host';
3-
import { CoreHostProps, WebXRCameraProps } from '@props';
3+
import { CameraProps, CoreHostProps, WebXRCameraProps } from '@props';
44
import { WebXRCamera } from '@babylonjs/core';
55
import { BabylonPackages } from '@dvmstudios/reactylon-common';
66

77
export class CameraHost {
8-
static createInstance(type: string, isBuilder: boolean, Class: any, props: CoreHostProps<WebXRCameraProps>, rootContainer: RootContainer) {
8+
static createInstance(type: string, isBuilder: boolean, Class: any, props: CoreHostProps<WebXRCameraProps & CameraProps>, rootContainer: RootContainer) {
99
if (Class.name === WebXRCamera.name) {
1010
const { isManual, ...rest } = props;
1111
// camera created by XR default experience
@@ -31,8 +31,32 @@ export class CameraHost {
3131
}
3232
}
3333
}
34-
const element = Host.createInstance(type, isBuilder, Class, props, rootContainer);
35-
element.handlers = {};
36-
return element;
34+
const camera = Host.createInstance(type, isBuilder, Class, props, rootContainer);
35+
camera.handlers = {};
36+
37+
const { isMultipleCanvas, isMultipleScene } = rootContainer;
38+
if (isMultipleCanvas) {
39+
const { engine, scene } = rootContainer;
40+
const canvas = props.canvas!;
41+
if (isMultipleScene) {
42+
const view = (engine.views || []).find(view => view.target === canvas);
43+
if (view) {
44+
engine.unRegisterView(canvas);
45+
}
46+
engine.registerView(canvas, camera);
47+
} else {
48+
engine.registerView(canvas, camera);
49+
canvas.onclick = () => {
50+
if (scene.activeCamera !== camera) {
51+
scene.activeCamera?.detachControl();
52+
engine.inputElement = canvas;
53+
scene.activeCamera = camera;
54+
camera.attachControl();
55+
}
56+
};
57+
}
58+
}
59+
60+
return camera;
3761
}
3862
}

packages/library/src/core/Scene.tsx

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { SceneContext, EngineStore, Store, createBabylonStore } from './store';
66
import { RootContainer } from '@types';
77
import Reactylon from '../reconciler';
88
import { type ContextBridge, useContextBridge } from 'its-fine';
9-
import { type CameraProps } from '@props';
109
import { type StoreApi } from 'zustand';
1110

1211
type SceneProps = React.PropsWithChildren<{
@@ -85,20 +84,8 @@ export const Scene: React.FC<SceneProps> = ({ children, sceneOptions, onSceneRea
8584
if (scene.activeCamera) {
8685
engine.registerView(canvas, scene.activeCamera as Camera);
8786
}
88-
8987
scene.detachControl();
9088

91-
scene.onNewCameraAddedObservable.add(camera => {
92-
// HACK: ensure that camera.dispose() is invoked on old camera before to unregister and register new canvas and camera
93-
setTimeout(() => {
94-
const view = (engine.views || []).find(view => view.target === canvas);
95-
if (view) {
96-
engine.unRegisterView(canvas);
97-
}
98-
engine.registerView(canvas, camera);
99-
}, 0);
100-
});
101-
10289
canvas.onclick = () => {
10390
if (activeScene !== scene) {
10491
activeScene?.detachControl();
@@ -107,21 +94,6 @@ export const Scene: React.FC<SceneProps> = ({ children, sceneOptions, onSceneRea
10794
activeScene = scene;
10895
}
10996
};
110-
} else {
111-
scene.onNewCameraAddedObservable.add(camera => {
112-
const augmentedCamera = camera as Camera & CameraProps;
113-
const canvas = augmentedCamera.canvas!;
114-
engine.registerView(canvas, camera);
115-
116-
canvas.onclick = () => {
117-
if (scene.activeCamera !== camera) {
118-
scene.activeCamera?.detachControl();
119-
engine.inputElement = canvas;
120-
scene.activeCamera = camera;
121-
camera.attachControl();
122-
}
123-
};
124-
});
12597
}
12698
}
12799

Lines changed: 159 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,180 @@
1-
import { ILoadingScreen } from '@babylonjs/core';
2-
import { createRoot } from 'react-dom/client';
1+
import { AbstractEngine, EngineStore, ILoadingScreen as BabylonILoadingScreen, Nullable, Observer } from '@babylonjs/core';
2+
import { renderToStaticMarkup } from 'react-dom/server';
33

4-
class CustomLoadingScreen implements ILoadingScreen {
5-
//optional, but needed due to interface definitions
6-
public loadingUIBackgroundColor: string;
7-
public loadingUIText: string;
4+
type TransitionStyle = {
5+
start: Partial<CSSStyleDeclaration>;
6+
end: Partial<CSSStyleDeclaration>;
7+
};
8+
9+
export type LoadingScreenOptions = {
10+
component: React.FC;
11+
animationStyle?: TransitionStyle;
12+
};
13+
14+
class CustomLoadingScreen implements Pick<BabylonILoadingScreen, 'displayLoadingUI' | 'hideLoadingUI'> {
815
private _loadingDiv: HTMLDivElement;
9-
private _renderingCanvas: HTMLCanvasElement;
1016

17+
private _engine: Nullable<AbstractEngine>;
18+
private _resizeObserver: Nullable<Observer<AbstractEngine>>;
19+
private _isLoading: boolean;
1120
/**
12-
* Creates a new loading screen rendering the React element in input
13-
* @param renderingCanvas defines the canvas used to render the scene
21+
* Maps a loading `HTMLDivElement` to a tuple containing the associated `HTMLCanvasElement`
22+
* and its `DOMRect` (or `null` if not yet available).
1423
*/
15-
constructor(renderingCanvas: HTMLCanvasElement, Loader: React.FC) {
16-
this._resizeLoadingUI = this._resizeLoadingUI.bind(this);
17-
this._renderingCanvas = renderingCanvas;
18-
/* if (this._loadingDiv) {
19-
// Do not add a loading screen if there is already one
20-
return;
21-
} */
22-
this._loadingDiv = document.createElement('div');
23-
this._loadingDiv.id = 'loader';
24-
this._loadingDiv.style.display = 'none';
25-
document.body.appendChild(this._loadingDiv);
26-
const loadingDiv = createRoot(this._loadingDiv);
27-
loadingDiv.render(<Loader />);
28-
}
24+
private _loadingDivToRenderingCanvasMap: Map<HTMLDivElement, [HTMLCanvasElement, DOMRect | null]> = new Map();
2925

30-
// Resize
31-
_resizeLoadingUI() {
32-
const canvasRect = this._renderingCanvas.getBoundingClientRect();
33-
const canvasPositioning = window.getComputedStyle(this._renderingCanvas).position;
34-
if (!this._loadingDiv) {
35-
return;
26+
/**
27+
* Creates a new loading screen rendering the React element in input
28+
* @param _renderingCanvas defines the canvas used to render the scene
29+
* @param loader defines the custom React component to show
30+
* @param _animationStyle the custom CSS styles applied at the beginning and end of the animation
31+
*/
32+
constructor(
33+
private _renderingCanvas: HTMLCanvasElement,
34+
Loader: React.FC,
35+
private _animationStyle?: TransitionStyle,
36+
) {
37+
const loadingDiv = document.createElement('div');
38+
loadingDiv.id = 'loader';
39+
loadingDiv.innerHTML = renderToStaticMarkup(<Loader />);
40+
if (this._animationStyle) {
41+
Object.assign(loadingDiv.style, this._animationStyle.start);
42+
} else {
43+
loadingDiv.style.transition = 'opacity 1s';
3644
}
37-
this._loadingDiv.style.position = canvasPositioning === 'fixed' ? 'fixed' : 'absolute';
38-
const scrollLeft = document.documentElement.scrollLeft;
39-
const scrollTop = document.documentElement.scrollTop;
40-
this._loadingDiv.style.left = canvasRect.left + scrollLeft + 'px';
41-
this._loadingDiv.style.top = canvasRect.top + scrollTop + 'px';
42-
this._loadingDiv.style.width = canvasRect.width + 'px';
43-
this._loadingDiv.style.height = canvasRect.height + 'px';
45+
loadingDiv.style.display = 'none';
46+
this._loadingDiv = loadingDiv;
4447
}
45-
4648
/**
4749
* Function called to display the loading screen
4850
*/
49-
displayLoadingUI() {
51+
public displayLoadingUI(): void {
52+
if (this._isLoading) {
53+
// Do not add a loading screen if it is already loading
54+
return;
55+
}
56+
57+
this._isLoading = true;
58+
// get current engine by rendering canvas
59+
this._engine = EngineStore.Instances.find(engine => engine.getRenderingCanvas() === this._renderingCanvas) as AbstractEngine;
60+
61+
const canvases: Array<HTMLCanvasElement> = [];
62+
const views = this._engine.views;
63+
if (views?.length) {
64+
for (const view of views) {
65+
if (view.enabled) {
66+
canvases.push(view.target);
67+
}
68+
}
69+
} else {
70+
canvases.push(this._renderingCanvas);
71+
}
72+
canvases.forEach((canvas, index) => {
73+
const clonedLoadingDiv = this._loadingDiv!.cloneNode(true) as HTMLDivElement;
74+
clonedLoadingDiv.id += `-${index}`;
75+
clonedLoadingDiv.style.display = 'block';
76+
this._loadingDivToRenderingCanvasMap.set(clonedLoadingDiv, [canvas, null]);
77+
});
78+
5079
this._resizeLoadingUI();
51-
window.addEventListener('resize', this._resizeLoadingUI);
52-
this._loadingDiv.style.display = 'block';
80+
81+
this._resizeObserver = this._engine.onResizeObservable.add(() => {
82+
this._resizeLoadingUI();
83+
});
84+
85+
this._loadingDivToRenderingCanvasMap.forEach((_, loadingDiv) => {
86+
document.body.appendChild(loadingDiv);
87+
});
5388
}
5489

5590
/**
5691
* Function called to hide the loading screen
5792
*/
58-
hideLoadingUI() {
59-
window.removeEventListener('resize', this._resizeLoadingUI);
60-
this._loadingDiv.style.display = 'none';
93+
public hideLoadingUI(): void {
94+
if (!this._isLoading) {
95+
return;
96+
}
97+
98+
let completedTransitions = 0;
99+
100+
const onTransitionEnd = (event: TransitionEvent) => {
101+
const loadingDiv = event.target as HTMLDivElement;
102+
// ensure that ending transition event is generated by one of the current loadingDivs
103+
const isTransitionEndOnLoadingDiv = this._loadingDivToRenderingCanvasMap.has(loadingDiv);
104+
105+
if (isTransitionEndOnLoadingDiv) {
106+
completedTransitions++;
107+
loadingDiv.remove();
108+
109+
const allTransitionsCompleted = completedTransitions === this._loadingDivToRenderingCanvasMap.size;
110+
if (allTransitionsCompleted) {
111+
window.removeEventListener('transitionend', onTransitionEnd);
112+
this._engine!.onResizeObservable.remove(this._resizeObserver);
113+
this._loadingDivToRenderingCanvasMap.clear();
114+
this._engine = null;
115+
this._isLoading = false;
116+
}
117+
}
118+
};
119+
120+
this._loadingDivToRenderingCanvasMap.forEach((_, loadingDiv) => {
121+
if (this._animationStyle) {
122+
Object.assign(loadingDiv.style, this._animationStyle.end);
123+
} else {
124+
loadingDiv.style.opacity = '0';
125+
}
126+
});
127+
128+
window.addEventListener('transitionend', onTransitionEnd);
61129
}
130+
131+
/**
132+
* Checks if the layout of the canvas has changed by comparing the current layout
133+
* rectangle with the previous one.
134+
*
135+
* This function compares of the two `DOMRect` objects to determine if any of the layout dimensions have changed.
136+
* If the layout has changed or if there is no previous layout (i.e., `previousCanvasRect` is `null`),
137+
* it returns `true`. Otherwise, it returns `false`.
138+
*
139+
* @param previousCanvasRect defines the previously recorded `DOMRect` of the canvas, or `null` if no previous state exists.
140+
* @param currentCanvasRect defines the current `DOMRect` of the canvas to compare against the previous layout.
141+
* @returns `true` if the layout has changed, otherwise `false`.
142+
*/
143+
private _isCanvasLayoutChanged(previousCanvasRect: DOMRect | null, currentCanvasRect: DOMRect) {
144+
return (
145+
!previousCanvasRect ||
146+
previousCanvasRect.left !== currentCanvasRect.left ||
147+
previousCanvasRect.top !== currentCanvasRect.top ||
148+
previousCanvasRect.right !== currentCanvasRect.right ||
149+
previousCanvasRect.bottom !== currentCanvasRect.bottom ||
150+
previousCanvasRect.width !== currentCanvasRect.width ||
151+
previousCanvasRect.height !== currentCanvasRect.height ||
152+
previousCanvasRect.x !== currentCanvasRect.x ||
153+
previousCanvasRect.y !== currentCanvasRect.y
154+
);
155+
}
156+
157+
// Resize
158+
private _resizeLoadingUI = () => {
159+
if (!this._isLoading) {
160+
return;
161+
}
162+
163+
this._loadingDivToRenderingCanvasMap.forEach(([canvas, previousCanvasRect], loadingDiv) => {
164+
const currentCanvasRect = canvas.getBoundingClientRect();
165+
if (this._isCanvasLayoutChanged(previousCanvasRect, currentCanvasRect)) {
166+
const canvasPositioning = window.getComputedStyle(canvas).position;
167+
168+
loadingDiv.style.position = canvasPositioning === 'fixed' ? 'fixed' : 'absolute';
169+
loadingDiv.style.left = currentCanvasRect.left + window.scrollX + 'px';
170+
loadingDiv.style.top = currentCanvasRect.top + window.scrollY + 'px';
171+
loadingDiv.style.width = currentCanvasRect.width + 'px';
172+
loadingDiv.style.height = currentCanvasRect.height + 'px';
173+
174+
this._loadingDivToRenderingCanvasMap.set(loadingDiv, [canvas, currentCanvasRect]);
175+
}
176+
});
177+
};
62178
}
63179

64180
export default CustomLoadingScreen;

packages/library/src/web/Engine.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useEffect, Children, useState, useRef, isValidElement, cloneElement } from 'react';
2-
import { Engine as BabylonEngine, NullEngine, type EngineOptions, Scene, EventState, type NullEngineOptions } from '@babylonjs/core';
3-
import CustomLoadingScreen from './CustomLoadingScreen';
2+
import { Engine as BabylonEngine, NullEngine, type EngineOptions, Scene, EventState, type NullEngineOptions, ILoadingScreen } from '@babylonjs/core';
3+
import CustomLoadingScreen, { type LoadingScreenOptions } from './CustomLoadingScreen';
44
import { FiberProvider } from 'its-fine';
55
import { type EngineStore } from '../core/store';
66
import { Logger } from '@dvmstudios/reactylon-common';
@@ -10,7 +10,7 @@ export type EngineProps = React.PropsWithChildren<{
1010
isMultipleCanvas?: boolean;
1111
engineOptions?: EngineOptions;
1212
adaptToDeviceRatio?: boolean;
13-
loader?: React.FC;
13+
loadingScreenOptions?: LoadingScreenOptions;
1414
/**
1515
* This property is typically not required and has no effect when using multiple scenes.
1616
* @default 'reactylon-canvas'
@@ -29,7 +29,7 @@ export const Engine: React.FC<EngineProps> = ({
2929
antialias,
3030
engineOptions,
3131
adaptToDeviceRatio,
32-
loader,
32+
loadingScreenOptions,
3333
canvasId = 'reactylon-canvas',
3434
_nullEngineOptions,
3535
isMultipleCanvas,
@@ -70,8 +70,9 @@ export const Engine: React.FC<EngineProps> = ({
7070
/* ENGINE
7171
------------------------------------------------------------------------------------------ */
7272
const engine = process.env.NODE_ENV === 'test' ? new NullEngine(_nullEngineOptions) : new BabylonEngine(canvas, antialias, engineOptions, adaptToDeviceRatio);
73-
if (loader) {
74-
engine.loadingScreen = new CustomLoadingScreen(canvas as HTMLCanvasElement, loader);
73+
if (loadingScreenOptions) {
74+
const { component, animationStyle } = loadingScreenOptions;
75+
engine.loadingScreen = new CustomLoadingScreen(canvas as HTMLCanvasElement, component, animationStyle) as unknown as ILoadingScreen;
7576
}
7677
engine.runRenderLoop(() => {
7778
const camera = engine!.activeView?.camera;

0 commit comments

Comments
 (0)