UNPKG

@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.

376 lines (319 loc) 15.2 kB
import { Matrix4, Object3D, Quaternion, Vector2, Vector3 } from "three"; import * as ThreeMeshUI from 'three-mesh-ui' import { type DocumentedOptions as ThreeMeshUIEveryOptions } from "three-mesh-ui/build/types/core/elements/MeshUIBaseElement.js"; import { foreachComponentEnumerator } from "../../engine/engine_gameobject.js"; import { serializable } from "../../engine/engine_serialization_decorator.js"; import { getParam } from "../../engine/engine_utils.js"; import { GameObject } from '../Component.js'; import { BaseUIComponent } from "./BaseUIComponent.js"; import { type ICanvas, type IRectTransform, type IRectTransformChangedReceiver } from "./Interfaces.js"; import { onChange } from "./Utils.js"; const debug = getParam("debugui"); const debugLayout = getParam("debuguilayout"); export class Size { width!: number; height!: number; } export class Rect { x!: number; y!: number; width!: number; height!: number; } const tempVec = new Vector3(); const tempMatrix = new Matrix4(); const tempQuaternion = new Quaternion(); export class RectTransform extends BaseUIComponent implements IRectTransform, IRectTransformChangedReceiver { get parent() { return this._parentRectTransform; } // @serializable(Object3D) // root? : Object3D; get translation() { return this.gameObject.position; } get rotation() { return this.gameObject.quaternion; } get scale(): Vector3 { return this.gameObject.scale; } private _anchoredPosition!: Vector2; @serializable(Vector2) get anchoredPosition() { if (!this._anchoredPosition) this._anchoredPosition = new Vector2(); return this._anchoredPosition; } private set anchoredPosition(value: Vector2) { this._anchoredPosition = value; } @serializable(Vector2) sizeDelta: Vector2 = new Vector2(100, 100); @serializable(Vector2) pivot: Vector2 = new Vector2(.5, .5); @serializable(Vector2) anchorMin: Vector2 = new Vector2(0, 0); @serializable(Vector2) anchorMax: Vector2 = new Vector2(1, 1); // @serializable(Vector2) // offsetMin: Vector2 = new Vector2(0, 0); // @serializable(Vector2) // offsetMax: Vector2 = new Vector2(0, 0); /** Optional min width in pixel, set to undefined to disable it */ minWidth?: number; /** Optional min height in pixel, set to undefined to disable it */ minHeight?: number; get width() { let width = this.sizeDelta.x; if (this.anchorMin.x !== this.anchorMax.x) { if (this._parentRectTransform) { const parentWidth = this._parentRectTransform.width; const anchorWidth = this.anchorMax.x - this.anchorMin.x; width = parentWidth * anchorWidth; width += this.sizeDelta.x; } } if(this.minWidth !== undefined && width < this.minWidth) return this.minWidth; return width } get height() { let height = this.sizeDelta.y; if (this.anchorMin.y !== this.anchorMax.y) { if (this._parentRectTransform) { const parentHeight = this._parentRectTransform.height; const anchorHeight = this.anchorMax.y - this.anchorMin.y; height = parentHeight * anchorHeight; height += this.sizeDelta.y; } } if(this.minHeight !== undefined && height < this.minHeight) return this.minHeight; return height; } // private lastMatrixWorld!: Matrix4; private lastMatrix!: Matrix4; private rectBlock!: Object3D; private _transformNeedsUpdate: boolean = false; private _initialPosition!: Vector3; private _parentRectTransform?: RectTransform; private _lastUpdateFrame: number = -1; awake() { super.awake(); this._lastUpdateFrame = -1; this._parentRectTransform = undefined; this.rectBlock = new Object3D(); this.rectBlock.name = this.name; this.lastMatrix = new Matrix4(); this._lastAnchoring = null!; // TODO: get rid of the initial position this._initialPosition = this.gameObject.position.clone(); this._initialPosition.z = 0; // this is required if an animator animated the transform anchoring if (!this._anchoredPosition) this._anchoredPosition = new Vector2(); // TODO: we need to replace this with the watch that e.g. Rigibody is using (or the one in utils?) // perhaps we can also just manually check the few properties in the update loops? // TODO: check if value actually changed, this is called on assignment onChange(this, "_anchoredPosition", () => { this.markDirty(); }); onChange(this, "sizeDelta", () => { this.markDirty(); }); onChange(this, "pivot", () => { this.markDirty(); }); onChange(this, "anchorMin", () => { this.markDirty(); }); onChange(this, "anchorMax", () => { this.markDirty(); }); } onEnable() { super.onEnable(); if (!this.rectBlock) this.rectBlock = new Object3D(); if (!this.lastMatrix) this.lastMatrix = new Matrix4(); if (!this._lastAnchoring) this._lastAnchoring = new Vector2(); if (!this._initialPosition) this._initialPosition = new Vector3(); if (!this._anchoredPosition) this._anchoredPosition = new Vector2(); this.addShadowComponent(this.rectBlock); this._transformNeedsUpdate = true; this.canvas?.registerTransform(this); // this.onApplyTransform("enable"); } onDisable() { super.onDisable(); this.removeShadowComponent(); this.canvas?.unregisterTransform(this); } onParentRectTransformChanged(comp: IRectTransform) { if (this._transformNeedsUpdate) return; // When the parent rect transform changes we have to to recalculate our transform this.onApplyTransform(debugLayout ? `${comp.name} changed` : undefined); } get isDirty() { if (!this._transformNeedsUpdate) this._transformNeedsUpdate = !this.lastMatrix.equals(this.gameObject.matrix); return this._transformNeedsUpdate; } // private _copyMatrixAfterRender: boolean = false; markDirty() { if (this._transformNeedsUpdate) return; if (debugLayout) console.warn("RectTransform markDirty()", this.name) this._transformNeedsUpdate = true; // If mark dirty is called explictly we want to allow updating the transform again when updateTransform is called // if we dont reset it here we get delayed layout updates this._lastUpdateFrame = -1; } /** Will update the transforms if it changed or is dirty */ updateTransform() { // TODO: instead of checking matrix again it would perhaps be better to test if position, rotation or scale have changed individually? const transformChanged = this._transformNeedsUpdate || !this.lastMatrix.equals(this.gameObject.matrix);// || !this.lastMatrixWorld.equals(this.gameObject.matrixWorld); if (transformChanged && this.canUpdate()) { this.onApplyTransform(this._transformNeedsUpdate ? "Marked dirty" : "Matrix changed"); } } private canUpdate() { return this._transformNeedsUpdate && this.activeAndEnabled && this._lastUpdateFrame !== this.context.time.frame; } private onApplyTransform(reason?: string) { // TODO: need to improve the update logic, with this UI updates have some frame delay but dont happen exponentially per hierarchy if (this.context.time.frameCount === this._lastUpdateFrame) return; this._lastUpdateFrame = this.context.time.frameCount; const uiobject = this.shadowComponent; if (!uiobject) return; if (this.gameObject.parent) this._parentRectTransform = GameObject.getComponentInParent(this.gameObject.parent, RectTransform) as RectTransform; else this._parentRectTransform = undefined; this._transformNeedsUpdate = false; if (debugLayout) console.warn("RectTransform → ApplyTransform", this.name + " because " + reason); if (!this.isRoot()) { // Reset temp matrix uiobject.matrix.identity(); uiobject.matrixAutoUpdate = false; // calc pivot and apply tempVec.set(0, 0, 0); this.applyPivot(tempVec); uiobject.matrix.setPosition(tempVec.x, tempVec.y, 0); // calc rotation matrix and apply (we can skip this if it's not rotated) if (this.gameObject.quaternion.x || this.gameObject.quaternion.y || this.gameObject.quaternion.z) { tempQuaternion.copy(this.gameObject.quaternion); tempQuaternion.x *= -1; tempQuaternion.z *= -1; tempMatrix.makeRotationFromQuaternion(tempQuaternion); uiobject.matrix.premultiply(tempMatrix); } // calc anchors and offset and apply tempVec.set(0, 0, 0); this.applyAnchoring(tempVec); if(this.canvas?.screenspace) tempVec.z += .1; else tempVec.z += .01; tempMatrix.identity(); tempMatrix.setPosition(tempVec.x, tempVec.y, tempVec.z); uiobject.matrix.premultiply(tempMatrix); // apply scale if necessary uiobject.matrix.scale(this.gameObject.scale); } else { // We have to rotate the canvas when it's in worldspace const canvas = this.Root as any as ICanvas; if (!canvas.screenspace) uiobject.rotation.y = Math.PI; } this.lastMatrix.copy(this.gameObject.matrix); // iterate other components on this object that might need to know about the transform change // e.g. Graphic components should update their width and height const includeChildren = true; for (const comp of foreachComponentEnumerator(this.gameObject, BaseUIComponent, includeChildren, 1)) { if (comp === this) continue; if (!comp.activeAndEnabled) continue; const callback = comp as any as IRectTransformChangedReceiver; if (callback.onParentRectTransformChanged) { // if (debugLayout) console.log(`RectTransform ${this.name} → call`, comp.name + "/" + comp.constructor.name) callback.onParentRectTransformChanged(this); } } // const layout = GameObject.getComponentInParent(this.gameObject, ILayoutGroup); } // onAfterRender() { // if (this._copyMatrixAfterRender) { // // can we only have this event when the transform changed in this frame? Otherwise all RectTransforms will be iterated. Not sure what is better // this.lastMatrixWorld.copy(this.gameObject.matrixWorld); // } // } private _lastAnchoring!: Vector2; /** applies the position offset to the passed in vector */ private applyAnchoring(pos: Vector3) { if (!this._lastAnchoring) this._lastAnchoring = new Vector2(); const diff = this._lastAnchoring.sub(this._anchoredPosition) this.gameObject.position.x += diff.x; this.gameObject.position.y += diff.y; this._lastAnchoring.copy(this._anchoredPosition); pos.x += (this._initialPosition.x - this.gameObject.position.x); pos.y += (this._initialPosition.y - this.gameObject.position.y); pos.z += (this._initialPosition.z - this.gameObject.position.z); const parent = this._parentRectTransform; if (parent) { // Calculate vertical offset let oy = 0; const vert = 1 - this.anchorMax.y - this.anchorMin.y; oy -= (parent.height * .5) * vert; pos.y += oy; // calculate horizontal offset let ox = 0; const horz = 1 - this.anchorMax.x - this.anchorMin.x; ox -= (parent.width * .5) * horz; pos.x += ox; } } /** applies the pivot offset to the passed in vector */ private applyPivot(vec: Vector3) { if (this.pivot && !this.isRoot()) { const pv = this.pivot.x - .5; vec.x -= pv * this.sizeDelta.x * this.gameObject.scale.x; const ph = this.pivot.y - .5; vec.y -= ph * this.sizeDelta.y * this.gameObject.scale.y; } } getBasicOptions(): ThreeMeshUIEveryOptions { // @TODO : instead of getBasicOptions for each component we could use once needleEngine initialized // ThreeMeshUI.DefaultValues.set({ // backgroundOpacity: 1, // borderWidth: 0, // if we dont specify width here a border will automatically propagated to child blocks // borderRadius: 0, // borderOpacity: 0, // }) const opts = { width: this.sizeDelta!.x, height: this.sizeDelta!.y,// * this.context.mainCameraComponent!.aspect, offset: 0, backgroundOpacity: 0, borderWidth: 0, // if we dont specify width here a border will automatically propagated to child blocks borderRadius: 0, borderOpacity: 0, letterSpacing: -0.03, // justifyContent: 'center', // alignItems: 'center', // alignContent: 'center', // backgroundColor: new Color(1, 1, 1), }; this.ensureValidSize(opts); return opts; } // e.g. when a transform has the size 0,0 we still want to render the text private ensureValidSize(opts: Size, fallbackWidth = 0.0001): Size { if (opts.width <= 0) { opts.width = fallbackWidth; } if (opts.height <= 0) opts.height = 0.0001; return opts; } private _createdBlocks: ThreeMeshUI.Block[] = []; private _createdTextBlocks: ThreeMeshUI.Text[] = []; createNewBlock(opts?: ThreeMeshUIEveryOptions | object): ThreeMeshUI.Block { opts = { ...this.getBasicOptions(), ...opts }; if (debug) console.log(this.name, opts); const block = new ThreeMeshUI.Block(opts as ThreeMeshUIEveryOptions); this._createdBlocks.push(block); return block; } createNewText(opts?: ThreeMeshUIEveryOptions | object): ThreeMeshUI.Block { if (debug) console.log(opts) opts = { ...this.getBasicOptions(), ...opts, }; if (debug) console.log(this.name, opts); const block = new ThreeMeshUI.Text(opts as ThreeMeshUIEveryOptions); this._createdTextBlocks.push(block); return block; } }