@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.
438 lines • 17.6 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Matrix4 } 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 { Camera } from "../Camera.js";
import { GameObject } from "../Component.js";
import { BaseUIComponent, UIRootComponent } from "./BaseUIComponent.js";
import { EventSystem } from "./EventSystem.js";
import { LayoutGroup } from "./Layout.js";
import { RectTransform } from "./RectTransform.js";
import { updateRenderSettings as updateRenderSettingsRecursive } from "./Utils.js";
export var RenderMode;
(function (RenderMode) {
RenderMode[RenderMode["ScreenSpaceOverlay"] = 0] = "ScreenSpaceOverlay";
RenderMode[RenderMode["ScreenSpaceCamera"] = 1] = "ScreenSpaceCamera";
RenderMode[RenderMode["WorldSpace"] = 2] = "WorldSpace";
RenderMode[RenderMode["Undefined"] = -1] = "Undefined";
})(RenderMode || (RenderMode = {}));
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 {
get isCanvas() {
return true;
}
get screenspace() {
return this.renderMode !== RenderMode.WorldSpace;
}
set renderOnTop(val) {
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;
}
_renderOnTop;
set depthWrite(val) {
if (this._depthWrite === val)
return;
this._depthWrite = val;
this.onRenderSettingsChanged();
}
get depthWrite() { return this._depthWrite; }
_depthWrite = false;
set doubleSided(val) {
if (this._doubleSided === val)
return;
this._doubleSided = val;
this.onRenderSettingsChanged();
}
get doubleSided() { return this._doubleSided; }
_doubleSided = true;
set castShadows(val) {
if (this._castShadows === val)
return;
this._castShadows = val;
this.onRenderSettingsChanged();
}
get castShadows() { return this._castShadows; }
_castShadows = false;
set receiveShadows(val) {
if (this._receiveShadows === val)
return;
this._receiveShadows = val;
this.onRenderSettingsChanged();
}
get receiveShadows() { return this._receiveShadows; }
_receiveShadows = false;
get renderMode() {
return this._renderMode;
}
set renderMode(val) {
if (this._renderMode === val)
return;
this._renderMode = val;
this.onRenderSettingsChanged();
}
_renderMode = RenderMode.Undefined;
_rootCanvas;
set rootCanvas(val) {
if (this._rootCanvas instanceof Canvas)
return;
this._rootCanvas = val;
}
get rootCanvas() {
return this._rootCanvas;
}
_scaleFactor = 1;
get scaleFactor() {
return this._scaleFactor;
}
set scaleFactor(val) {
this._scaleFactor = val;
}
worldCamera;
planeDistance = -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() {
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);
}
}
_boundRenderSettingsChanged = this.onRenderSettingsChanged.bind(this);
previousParent = null;
_lastMatrixWorld = null;
_rectTransforms = [];
registerTransform(rt) {
this._rectTransforms.push(rt);
}
unregisterTransform(rt) {
const index = this._rectTransforms.indexOf(rt);
if (index !== -1) {
this._rectTransforms.splice(index, 1);
}
}
_layoutGroups = new Map();
registerLayoutGroup(group) {
const obj = group.gameObject;
this._layoutGroups.set(obj, group);
}
unregisterLayoutGroup(group) {
const obj = group.gameObject;
this._layoutGroups.delete(obj);
}
_receivers = [];
registerEventReceiver(receiver) {
this._receivers.push(receiver);
}
unregisterEventReceiver(receiver) {
const index = this._receivers.indexOf(receiver);
if (index !== -1) {
this._receivers.splice(index, 1);
}
}
async onEnterXR(args) {
// 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) {
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);
};
invokeBeforeRenderEvents() {
for (const receiver of this._receivers) {
receiver.onBeforeCanvasRender?.(this);
}
}
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);
}
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();
}
_updateRenderSettingsRoutine;
onRenderSettingsChanged() {
if (this._updateRenderSettingsRoutine)
return;
this._updateRenderSettingsRoutine = this.startCoroutine(this._updateRenderSettingsDelayed(), FrameEvent.OnBeforeRender);
}
*_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);
}
}
}
_activeRenderMode = -1;
_lastWidth = -1;
_lastHeight = -1;
onUpdateRenderMode(force = 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 = 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;
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;
}
}
}
__decorate([
serializable()
], Canvas.prototype, "renderOnTop", null);
__decorate([
serializable()
], Canvas.prototype, "depthWrite", null);
__decorate([
serializable()
], Canvas.prototype, "doubleSided", null);
__decorate([
serializable()
], Canvas.prototype, "castShadows", null);
__decorate([
serializable()
], Canvas.prototype, "receiveShadows", null);
__decorate([
serializable()
], Canvas.prototype, "renderMode", null);
__decorate([
serializable(Canvas)
], Canvas.prototype, "rootCanvas", null);
__decorate([
serializable()
], Canvas.prototype, "scaleFactor", null);
__decorate([
serializable(Camera)
], Canvas.prototype, "worldCamera", void 0);
__decorate([
serializable()
], Canvas.prototype, "planeDistance", void 0);
//# sourceMappingURL=Canvas.js.map