@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
450 lines (399 loc) • 17 kB
text/typescript
import { Matrix4, Object3D } from "three";
import * as ThreeMeshUI from 'three-mesh-ui'
import { Mathf } from "../../engine/engine_math.js";
import { serializable } from "../../engine/engine_serialization_decorator.js";
import { FrameEvent } from "../../engine/engine_setup.js";
import { delayForFrames, getParam } from "../../engine/engine_utils.js";
import { type NeedleXREventArgs } from "../../engine/xr/api.js";
import { Camera } from "../Camera.js";
import { GameObject } from "../Component.js";
import { BaseUIComponent, UIRootComponent } from "./BaseUIComponent.js";
import { EventSystem } from "./EventSystem.js";
import type { ICanvas, ICanvasEventReceiver, ILayoutGroup, IRectTransform } from "./Interfaces.js";
import { LayoutGroup } from "./Layout.js";
import { RectTransform } from "./RectTransform.js";
import { updateRenderSettings as updateRenderSettingsRecursive } from "./Utils.js";
export enum RenderMode {
ScreenSpaceOverlay = 0,
ScreenSpaceCamera = 1,
WorldSpace = 2,
Undefined = -1,
}
const debugLayout = getParam("debuguilayout");
/**
* [Canvas](https://engine.needle.tools/docs/api/Canvas) is the root component for all UI elements in a scene.
* Defines the rendering area and manages layout for child UI elements.
*
* **Render modes:**
* - `WorldSpace` - UI exists in 3D space, can be viewed from any angle
* - `ScreenSpaceOverlay` - UI rendered on top of everything (HUD)
* - `ScreenSpaceCamera` - UI rendered at a distance from a specific camera
*
* **Usage:**
* All UI components ({@link Button}, {@link Text}, {@link Image}) must be
* children of a Canvas to render correctly. Multiple canvases can exist
* in a scene with different settings.
*
* **Rendering options:**
* - `renderOnTop` - Always render above other objects
* - `depthWrite` - Write to depth buffer (affects occlusion)
* - `doubleSided` - Render both sides of UI elements
*
* @example Create a world-space UI panel
* ```ts
* const canvas = panel.getComponent(Canvas);
* canvas.renderMode = RenderMode.WorldSpace;
* canvas.doubleSided = true;
* ```
*
* @summary Root component for UI elements, managing layout and rendering settings
* @category User Interface
* @group Components
* @see {@link RenderMode} for rendering options
* @see {@link RectTransform} for UI layout
* @see {@link Button} for clickable UI elements
* @see {@link Text} for UI text rendering
*/
export class Canvas extends UIRootComponent implements ICanvas {
get isCanvas() {
return true;
}
get screenspace(): any {
return this.renderMode !== RenderMode.WorldSpace;
}
set renderOnTop(val: boolean) {
if (val === this._renderOnTop) {
return;
}
this._renderOnTop = val;
this.onRenderSettingsChanged();
}
get renderOnTop() {
if (this._renderOnTop !== undefined) return this._renderOnTop;
if (this.screenspace) {
// Render ScreenSpaceOverlay always on top
if (this._renderMode === RenderMode.ScreenSpaceOverlay) return true;
}
return false;
}
private _renderOnTop: boolean | undefined;
set depthWrite(val: boolean) {
if (this._depthWrite === val) return;
this._depthWrite = val;
this.onRenderSettingsChanged();
}
get depthWrite() { return this._depthWrite; }
private _depthWrite: boolean = false;
set doubleSided(val: boolean) {
if (this._doubleSided === val) return;
this._doubleSided = val;
this.onRenderSettingsChanged();
}
get doubleSided() { return this._doubleSided; }
private _doubleSided: boolean = true;
set castShadows(val: boolean) {
if (this._castShadows === val) return;
this._castShadows = val;
this.onRenderSettingsChanged();
}
get castShadows() { return this._castShadows; }
private _castShadows: boolean = false;
set receiveShadows(val: boolean) {
if (this._receiveShadows === val) return;
this._receiveShadows = val;
this.onRenderSettingsChanged();
}
get receiveShadows() { return this._receiveShadows; }
private _receiveShadows: boolean = false;
get renderMode(): RenderMode {
return this._renderMode;
}
set renderMode(val: RenderMode) {
if (this._renderMode === val) return;
this._renderMode = val;
this.onRenderSettingsChanged();
}
private _renderMode: RenderMode = RenderMode.Undefined;
private _rootCanvas!: Canvas;
set rootCanvas(val: Canvas) {
if (this._rootCanvas instanceof Canvas) return;
this._rootCanvas = val;
}
get rootCanvas(): Canvas {
return this._rootCanvas;
}
private _scaleFactor: number = 1;
get scaleFactor(): number {
return this._scaleFactor;
}
private set scaleFactor(val: number) {
this._scaleFactor = val;
}
worldCamera?: Camera;
planeDistance: number = -1;
awake() {
//@ts-ignore
this.shadowComponent = this.gameObject;
this.previousParent = this.gameObject.parent;
if (debugLayout)
console.log("Canvas.Awake()", this.previousParent?.name + "/" + this.gameObject.name)
super.awake();
}
start() {
this.applyRenderSettings();
}
onEnable() {
super.onEnable();
this._updateRenderSettingsRoutine = undefined;
this._lastMatrixWorld = new Matrix4();
this.applyRenderSettings();
document.addEventListener("resize", this._boundRenderSettingsChanged);
// We want to run AFTER all regular onBeforeRender callbacks
this.context.pre_render_callbacks.push(this.onBeforeRenderRoutine);
this.context.post_render_callbacks.push(this.onAfterRenderRoutine);
}
onDisable(): void {
super.onDisable();
document.removeEventListener("resize", this._boundRenderSettingsChanged);
// Remove callbacks
const preRenderIndex = this.context.pre_render_callbacks.indexOf(this.onBeforeRenderRoutine);
if (preRenderIndex !== -1) {
this.context.pre_render_callbacks.splice(preRenderIndex, 1);
}
const postRenderIndex = this.context.post_render_callbacks.indexOf(this.onAfterRenderRoutine);
if (postRenderIndex !== -1) {
this.context.post_render_callbacks.splice(postRenderIndex, 1);
}
}
private _boundRenderSettingsChanged = this.onRenderSettingsChanged.bind(this);
private previousParent: Object3D | null = null;
private _lastMatrixWorld: Matrix4 | null = null;
private _rectTransforms: IRectTransform[] = [];
registerTransform(rt: IRectTransform) {
this._rectTransforms.push(rt);
}
unregisterTransform(rt: IRectTransform) {
const index = this._rectTransforms.indexOf(rt);
if (index !== -1) {
this._rectTransforms.splice(index, 1);
}
}
private _layoutGroups: Map<Object3D, ILayoutGroup> = new Map();
registerLayoutGroup(group: ILayoutGroup) {
const obj = group.gameObject;
this._layoutGroups.set(obj, group)
}
unregisterLayoutGroup(group: ILayoutGroup) {
const obj = group.gameObject;
this._layoutGroups.delete(obj);
}
private _receivers: ICanvasEventReceiver[] = [];
registerEventReceiver(receiver: ICanvasEventReceiver) {
this._receivers.push(receiver);
}
unregisterEventReceiver(receiver: ICanvasEventReceiver) {
const index = this._receivers.indexOf(receiver);
if (index !== -1) {
this._receivers.splice(index, 1);
}
}
async onEnterXR(args: NeedleXREventArgs) {
// workaround for https://linear.app/needle/issue/NE-4114
if (this.screenspace) {
if (args.xr.isVR || args.xr.isPassThrough) {
this.gameObject.visible = false;
}
}
else {
this.gameObject.visible = false;
await delayForFrames(1).then(()=>{
this.gameObject.visible = true;
});
}
}
onLeaveXR(args: NeedleXREventArgs): void {
if (this.screenspace) {
if (args.xr.isVR || args.xr.isPassThrough) {
this.gameObject.visible = true;
}
}
}
onBeforeRenderRoutine = () => {
this.previousParent = this.gameObject.parent;
if ((this.context.xr?.isVR || this.context.xr?.isPassThrough) && this.screenspace) {
// see https://linear.app/needle/issue/NE-4114
this.gameObject.visible = false;
this.gameObject.removeFromParent();
return;
}
// console.log(this.previousParent?.name + "/" + this.gameObject.name);
if (this.renderOnTop || this.screenspace) {
// TODO: Ideally all screenspace canvases should be combined in one render pass
this.gameObject.removeFromParent();
}
else {
this.onUpdateRenderMode();
this.handleLayoutUpdates();
// TODO: we might need to optimize this. This is here to make sure the TMUI text clipping matrices are correct. Ideally the text does use onBeforeRender and apply the clipping matrix there so we dont have to force update all the matrices here
this.shadowComponent?.updateMatrixWorld(true);
this.shadowComponent?.updateWorldMatrix(true, true);
this.invokeBeforeRenderEvents();
EventSystem.ensureUpdateMeshUI(ThreeMeshUI, this.context);
}
}
onAfterRenderRoutine = () => {
if ((this.context.xr?.isVR || this.context.xr?.isPassThrough) && this.screenspace) {
this.previousParent?.add(this.gameObject);
// this is currently causing an error during XR (https://linear.app/needle/issue/NE-4114)
// this.gameObject.visible = true;
return;
}
if ((this.screenspace || this.renderOnTop) && this.previousParent && this.context.mainCamera) {
if (this.screenspace) {
const camObj = this.context.mainCamera;
camObj?.add(this.gameObject);
} else {
this.previousParent.add(this.gameObject);
}
const prevAutoClearDepth = this.context.renderer.autoClear;
const prevAutoClearColor = this.context.renderer.autoClearColor;
this.context.renderer.autoClear = false;
this.context.renderer.autoClearColor = false;
this.context.renderer.clearDepth();
this.onUpdateRenderMode(true);
this.handleLayoutUpdates();
this.shadowComponent?.updateMatrixWorld(true);
// this.handleLayoutUpdates();
this.invokeBeforeRenderEvents();
EventSystem.ensureUpdateMeshUI(ThreeMeshUI, this.context, true);
this.context.renderer.render(this.gameObject, this.context.mainCamera);
this.context.renderer.autoClear = prevAutoClearDepth;
this.context.renderer.autoClearColor = prevAutoClearColor;
this.previousParent.add(this.gameObject);
}
this._lastMatrixWorld?.copy(this.gameObject.matrixWorld);
}
private invokeBeforeRenderEvents() {
for (const receiver of this._receivers) {
receiver.onBeforeCanvasRender?.(this);
}
}
private handleLayoutUpdates() {
if (this._lastMatrixWorld === null) {
this._lastMatrixWorld = new Matrix4();
}
const matrixWorldChanged = !this._lastMatrixWorld.equals(this.gameObject.matrixWorld);
if (debugLayout && matrixWorldChanged) console.log("Canvas Layout changed", this.context.time.frameCount, this.name);
// TODO: optimize this, we should only need to update a subhierarchy of the parts where layout has changed
const didLog = false;
for (const ch of this._rectTransforms) {
if (matrixWorldChanged) ch.markDirty();
let layout = this._layoutGroups.get(ch.gameObject);
if (ch.isDirty && !layout) {
layout = ch.gameObject.getComponentInParent(LayoutGroup) as LayoutGroup;
}
if (ch.isDirty || layout?.isDirty) {
if (debugLayout && !didLog) {
console.log("CANVAS UPDATE ### " + ch.name + " ##################################### " + this.context.time.frame);
// didLog = true;
}
layout?.updateLayout();
ch.updateTransform();
}
}
}
applyRenderSettings() {
this.onRenderSettingsChanged();
}
private _updateRenderSettingsRoutine?: Generator;
private onRenderSettingsChanged() {
if (this._updateRenderSettingsRoutine) return;
this._updateRenderSettingsRoutine = this.startCoroutine(this._updateRenderSettingsDelayed(), FrameEvent.OnBeforeRender);
}
private *_updateRenderSettingsDelayed() {
yield;
this._updateRenderSettingsRoutine = undefined;
if (this.shadowComponent) {
this.onUpdateRenderMode();
// this.onWillUpdateRenderSettings();
updateRenderSettingsRecursive(this.shadowComponent, this);
for (const ch of GameObject.getComponentsInChildren(this.gameObject, BaseUIComponent)) {
updateRenderSettingsRecursive(ch.shadowComponent!, this);
}
}
}
private _activeRenderMode: RenderMode = -1;
private _lastWidth: number = -1;
private _lastHeight: number = -1;
private onUpdateRenderMode(force: boolean = false) {
if (!force) {
if (this._renderMode === this._activeRenderMode && this._lastWidth === this.context.domWidth && this._lastHeight === this.context.domHeight) {
return;
}
}
this._activeRenderMode = this._renderMode;
let camera = this.context.mainCameraComponent;
let planeDistance: number = 10;
if (camera && camera.nearClipPlane > 0 && camera.farClipPlane > 0) {
// TODO: this is a hack/workaround for event system currently only passing events to the nearest object so we move the canvas close to the nearplane
planeDistance = Mathf.lerp(camera.nearClipPlane, camera.farClipPlane, .01);
}
if (this._renderMode === RenderMode.ScreenSpaceCamera) {
if (this.worldCamera)
camera = this.worldCamera as Camera;
if (this.planeDistance > 0)
planeDistance = this.planeDistance;
}
switch (this._renderMode) {
case RenderMode.ScreenSpaceOverlay:
case RenderMode.ScreenSpaceCamera:
this._lastWidth = this.context.domWidth;
this._lastHeight = this.context.domHeight;
// showBalloonWarning("Screenspace Canvas is not supported yet. Please use worldspace");
if (!camera) return;
// we move the plane SLIGHTLY closer to be sure not to cull the canvas
const plane = planeDistance + .01;
this.gameObject.position.x = 0;
this.gameObject.position.y = 0;
this.gameObject.position.z = -plane;
this.gameObject.quaternion.identity();
const rect = this.gameObject.getComponent(RectTransform)!;
let hasChanged = false;
if (rect.sizeDelta.x !== this.context.domWidth) {
hasChanged = true;
}
if (rect.sizeDelta.y !== this.context.domHeight) {
hasChanged = true;
}
const vFOV = camera.fieldOfView! * Math.PI / 180;
const h = 2 * Math.tan(vFOV / 2) * Math.abs(plane);
this.gameObject.scale.x = h / this.context.domHeight;
this.gameObject.scale.y = h / this.context.domHeight;
// Set scale.z, otherwise small offsets in screenspace mode have different visual results based on export scale and other settings
this.gameObject.scale.z = .01;
if (hasChanged) {
rect.sizeDelta.x = this.context.domWidth;
rect.sizeDelta.y = this.context.domHeight;
rect?.markDirty();
}
// this.context.scene.add(this.gameObject)
// this.gameObject.scale.multiplyScalar(.01);
// this.gameObject.position.set(0,0,0);
break;
case RenderMode.WorldSpace:
this._lastWidth = -1;
this._lastHeight = -1;
break;
}
}
}