@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
text/typescript
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;
get anchoredPosition() {
if (!this._anchoredPosition) this._anchoredPosition = new Vector2();
return this._anchoredPosition;
}
private set anchoredPosition(value: Vector2) {
this._anchoredPosition = value;
}
sizeDelta: Vector2 = new Vector2(100, 100);
pivot: Vector2 = new Vector2(.5, .5);
anchorMin: Vector2 = new Vector2(0, 0);
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;
}
}