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.

647 lines (567 loc) 24.5 kB
import { Object3D } from "three"; import { getParam } from "../../../../../engine/engine_utils.js"; import { makeNameSafeForUSD,USDDocument, USDObject, USDWriter } from "../../ThreeUSDZExporter.js"; import type { RegisteredAnimationInfo } from "./../Animation.js"; import { BehaviorExtension } from "./Behaviour.js"; const debug = getParam("debugusdz"); // TODO: rename to usdz element export interface IBehaviorElement { id: string; writeTo(document: USDDocument, writer: USDWriter); } export class BehaviorModel { static global_id: number = 0; id: string; trigger: IBehaviorElement | IBehaviorElement[]; action: IBehaviorElement; exclusive: boolean = false; makeExclusive(exclusive: boolean): BehaviorModel { this.exclusive = exclusive; return this; } constructor(id: string, trigger: IBehaviorElement | IBehaviorElement[], action: IBehaviorElement) { this.id = "Behavior_" + makeNameSafeForUSD(id) + "_" + BehaviorModel.global_id++; this.trigger = trigger; this.action = action; // Special case: SceneTransition enter triggers should never run multiple times, even if the stage loops. // There seems to have been a change in behavior in iOS, where SceneTransition enter runs even // when the stage length is reached... // BUT unfortunately it looks like child groups are also ignored then, and their own loops stop working // once any parent is set to "ignore", which is different than what's in the documentation. /* if (trigger instanceof TriggerModel) { if (trigger.tokenId === "SceneTransition" && trigger.type === "enter") { const wrapper = ActionBuilder.sequence(action); wrapper.multiplePerformOperation = "ignore"; this.action = wrapper; } } */ // Another idea: we let actions run forever by appending a waitAction to them // Also doesn't seem to work... the scene start action still runs again /* if (trigger instanceof TriggerModel) { if (trigger.tokenId === "SceneTransition" && trigger.type === "enter") { const wrapper = ActionBuilder.sequence(action, ActionBuilder.waitAction(9999999999)); this.action = wrapper; } } */ } writeTo(_ext: BehaviorExtension, document: USDDocument, writer: USDWriter) { if (!this.trigger || !this.action) return; writer.beginBlock(`def Preliminary_Behavior "${this.id}"`); let triggerString = ""; if (Array.isArray(this.trigger)) { triggerString = "["; for (let i = 0; i < this.trigger.length; i++) { const tr = this.trigger[i]; triggerString += "<" + tr.id + ">"; if (i + 1 < this.trigger.length) triggerString += ", "; } triggerString += "]"; } else triggerString = `<${this.trigger.id}>`; writer.appendLine(`rel triggers = ${triggerString}`); writer.appendLine(`rel actions = <${this.action.id}>`); writer.appendLine(`uniform bool exclusive = ${this.exclusive ? 1 : 0}`); // Apple uses 0 and 1 for bools writer.appendLine(); if (Array.isArray(this.trigger)) { for (const trigger of this.trigger) { trigger.writeTo(document, writer); writer.appendLine(); } } else this.trigger.writeTo(document, writer); writer.appendLine(); this.action.writeTo(document, writer); writer.closeBlock(); } } export type Target = USDObject | USDObject[] | Object3D | Object3D[]; const addedStrings = new Set<string>(); /** called to resolve target objects to usdz paths */ function resolve(targetObject: Target, document: USDDocument): string { let result: string = ""; if (Array.isArray(targetObject)) { addedStrings.clear(); let str = "[ "; for (let i = 0; i < targetObject.length; i++) { let obj = targetObject[i]; if (!obj) { console.warn("Invalid target object in behavior", targetObject + ". Is the object exported?"); continue; } if (typeof obj === "string") { if (addedStrings.has(obj)) continue; str += obj; addedStrings.add(obj); } else if (typeof obj === "object") { //@ts-ignore if (obj.isObject3D) { //@ts-ignore obj = document.findById(obj.uuid); if (!obj) { console.warn("Invalid target object in behavior", targetObject + ". Is the object exported?"); continue; } } const res = (obj as any).getPath?.call(obj) as string; if (addedStrings.has(res)) continue; // console.log(str, res, addedStrings) str += res; addedStrings.add(res); } if (i + 1 < targetObject.length) str += ", "; } str += " ]"; result = str; addedStrings.clear(); } else if (typeof targetObject === "object") { const sourceObject = targetObject; //@ts-ignore if (sourceObject.isObject3D) { //@ts-ignore targetObject = document.findById(sourceObject.uuid); } if (!targetObject) { console.error("Invalid target object in behavior, the target object is likely missing from USDZ export. Is the object exported?", sourceObject); throw new Error(`Invalid target object in behavior, the target object is likely missing from USDZ export. Please report a bug. uuid: ${sourceObject.uuid}.`); } result = (targetObject as any).getPath?.call(targetObject) as string; } return result; } export class TriggerModel implements IBehaviorElement { static global_id: number = 0; id: string; targetId?: string | Target; tokenId?: string; type?: string; distance?: number; constructor(targetId?: string | Target, id?: string) { if (targetId) this.targetId = targetId; if (id) this.id = id; else this.id = "Trigger_" + TriggerModel.global_id++; } writeTo(document: USDDocument, writer: USDWriter) { writer.beginBlock(`def Preliminary_Trigger "${this.id}"`); if (this.targetId) { if (typeof this.targetId !== "string") this.targetId = resolve(this.targetId, document); writer.appendLine(`rel affectedObjects = ` + this.targetId); } if (this.tokenId) writer.appendLine(`token info:id = "${this.tokenId}"`); if (this.type) writer.appendLine(`token type = "${this.type}"`); if (typeof this.distance === "number") writer.appendLine(`double distance = ${this.distance}`); writer.closeBlock(); } } /** Adds `RealityKit.InputTarget` child prim to enable Vision OS direct/indirect interactions (by default, only indirect interactions are allowed) */ function addInputTargetComponent(model: USDObject, options: { direct: boolean, indirect: boolean } = { direct: true, indirect: true }) { const empty = USDObject.createEmpty(); empty.name = "InputTarget_" + empty.name; empty.displayName = undefined; empty.type = "RealityKitComponent"; empty.onSerialize = (writer: USDWriter) => { writer.appendLine("bool allowsDirectInput = " + (options.direct ? 1 : 0)); writer.appendLine("bool allowsIndirectInput = " + (options.indirect ? 1 : 0)); writer.appendLine('uniform token info:id = "RealityKit.InputTarget"'); }; model.add(empty); } export class TriggerBuilder { private static __sceneStartTrigger?: TriggerModel; static sceneStartTrigger(): TriggerModel { if (this.__sceneStartTrigger !== undefined) return this.__sceneStartTrigger; const trigger = new TriggerModel(undefined, "SceneStart"); trigger.tokenId = "SceneTransition"; trigger.type = "enter"; this.__sceneStartTrigger = trigger; return trigger; } /** Trigger that fires when an object has been tapped/clicked. * @param targetObject The object or list of objects that can be interacted with. * @param inputMode Input Mode (direct and/or indirect). Only available for USDObject targets. Only supported on Vision OS at the moment. */ static tapTrigger(targetObject: Target, inputMode: { direct: boolean, indirect: boolean } = { direct: true, indirect: true }): TriggerModel { const trigger = new TriggerModel(targetObject); if (Array.isArray(targetObject) && targetObject.length > 1) { for (const obj of targetObject) { if (!(obj instanceof USDObject)) continue; addInputTargetComponent(obj, inputMode); } } else { if (targetObject instanceof USDObject) { addInputTargetComponent(targetObject, inputMode); } } trigger.tokenId = "TapGesture"; return trigger; } static isTapTrigger(trigger?: TriggerModel) { return trigger?.tokenId === "TapGesture"; } static proximityToCameraTrigger(targetObject: Target, distance: number): TriggerModel { const trigger = new TriggerModel(targetObject); trigger.tokenId = "ProximityToCamera"; trigger.distance = distance; return trigger; } } export class GroupActionModel implements IBehaviorElement { static global_id: number = 0; static getId(): number { return this.global_id++; } id: string; actions: IBehaviorElement[]; loops: number = 0; performCount: number = 1; type: string = "serial"; multiplePerformOperation: MultiplePerformOperation | undefined = undefined; constructor(id: string, actions: IBehaviorElement[]) { this.id = id; this.actions = actions; } addAction(el: IBehaviorElement): GroupActionModel { this.actions.push(el); return this; } makeParallel(): GroupActionModel { this.type = "parallel"; return this; } makeSequence(): GroupActionModel { this.type = "serial"; return this; } makeLooping() { this.loops = 1; this.performCount = 0; return this; } makeRepeat(count: number) { this.performCount = count; return this; } writeTo(document: USDDocument, writer: USDWriter) { writer.beginBlock(`def Preliminary_Action "${this.id}"`); writer.beginArray("rel actions"); for (const act of this.actions) { if (!act) continue; const isLast = act === this.actions[this.actions.length - 1]; writer.appendLine("<" + act.id + ">" + (isLast ? "" : ", ")); } writer.closeArray(); writer.appendLine(); writer.appendLine(`token info:id = "Group"`); writer.appendLine(`bool loops = ${this.loops}`); writer.appendLine(`int performCount = ${this.loops > 0 ? 0 : Math.max(0, this.performCount)}`); writer.appendLine(`token type = "${this.type}"`); if (typeof this.multiplePerformOperation === "string") { writer.appendLine(`token multiplePerformOperation = "${this.multiplePerformOperation}"`); } writer.appendLine(); for (const act of this.actions) { if (!act) continue; act.writeTo(document, writer); writer.appendLine(); } writer.closeBlock(); } } /** @internal */ export type EmphasizeActionMotionType = "pop" | "blink" | "bounce" | "flip" | "float" | "jiggle" | "pulse" | "spin"; /** @internal */ export type VisibilityActionMotionType = "none" | "pop" | "scaleUp" | "scaleDown" | "moveLeft" | "moveRight" | "moveAbove" | "moveBelow" | "moveForward" | "moveBack"; /** @internal */ export type MotionStyle = "basic"; /** @internal */ export type Space = "relative" | "absolute"; /** @internal */ export type PlayAction = "play" | "pause" | "stop"; /** @internal */ export type AuralMode = "spatial" | "nonSpatial" | "ambient"; /** @internal */ export type VisibilityMode = "show" | "hide"; // https://developer.apple.com/documentation/arkit/usdz_schemas_for_ar/actions_and_triggers/preliminary_action/multipleperformoperation /** @internal */ export type MultiplePerformOperation = "allow" | "ignore" | "stop"; /** @internal */ export type EaseType = "none" | "in" | "out" | "inout"; export class ActionModel implements IBehaviorElement { private static global_id: number = 0; id: string; tokenId?: "ChangeScene" | "Visibility" | "StartAnimation" | "Wait" | "LookAtCamera" | "Emphasize" | "Transform" | "Audio" | "Impulse"; affectedObjects?: string | Target; easeType?: EaseType;; motionType: EmphasizeActionMotionType | VisibilityActionMotionType | undefined = undefined; duration?: number; moveDistance?: number; style?: MotionStyle; type?: Space | PlayAction | VisibilityMode; // combined types of different actions front?: Vec3; up?: Vec3; start?: number; animationSpeed?: number; reversed?: boolean; pingPong?: boolean; xFormTarget?: Target | string; audio?: string; gain?: number; auralMode?: AuralMode; multiplePerformOperation?: MultiplePerformOperation; velocity?: Vec3; // extra info written as comment at the beginning of the action comment?: string; animationName?: string; clone(): ActionModel { const copy = new ActionModel(); const id = copy.id; Object.assign(copy, this); copy.id = id; return copy; } constructor(affectedObjects?: string | Target, id?: string) { if (affectedObjects) this.affectedObjects = affectedObjects; if (id) this.id = id; else this.id = "Action"; this.id += "_" + ActionModel.global_id++; } writeTo(document: USDDocument, writer: USDWriter) { writer.beginBlock(`def Preliminary_Action "${this.id}"`); if (this.comment) writer.appendLine(`# ${this.comment}`); if (this.affectedObjects) { if (typeof this.affectedObjects !== "string") this.affectedObjects = resolve(this.affectedObjects, document); writer.appendLine('rel affectedObjects = ' + this.affectedObjects); } if (typeof this.duration === "number") { if (typeof this.animationSpeed === "number" && this.animationSpeed !== 1) { writer.appendLine(`double duration = ${this.duration / this.animationSpeed} `); } else { writer.appendLine(`double duration = ${this.duration} `); } } if (this.easeType) writer.appendLine(`token easeType = "${this.easeType}"`); if (this.tokenId) writer.appendLine(`token info:id = "${this.tokenId}"`); if (this.tokenId === "ChangeScene") writer.appendLine(`rel scene = </StageRoot/Scenes/Scene>`); if (this.motionType !== undefined) writer.appendLine(`token motionType = "${this.motionType}"`); if (typeof this.moveDistance === "number") writer.appendLine(`double moveDistance = ${this.moveDistance} `); if (this.style) writer.appendLine(`token style = "${this.style}"`); if (this.type) writer.appendLine(`token type = "${this.type}"`); if (this.front) writer.appendLine(`vector3d front = (${this.front.x}, ${this.front.y}, ${this.front.z})`); if (this.up) writer.appendLine(`vector3d upVector = (${this.up.x}, ${this.up.y}, ${this.up.z})`); if (typeof this.start === "number") { writer.appendLine(`double start = ${this.start} `); } if (typeof this.animationSpeed === "number") { writer.appendLine(`double animationSpeed = ${this.animationSpeed.toFixed(2)} `); } if (typeof this.reversed === "boolean") { writer.appendLine(`bool reversed = ${this.reversed}`) } if (typeof this.pingPong === "boolean") { writer.appendLine(`bool reverses = ${this.pingPong}`) } if (this.xFormTarget) { if (typeof this.xFormTarget !== "string") this.xFormTarget = resolve(this.xFormTarget, document); writer.appendLine(`rel xformTarget = ${this.xFormTarget}`) } if (typeof this.audio === "string") { writer.appendLine(`asset audio = @${this.audio}@`); } if (typeof this.gain ==="number") { writer.appendLine(`double gain = ${this.gain}`); } if (typeof this.auralMode === "string") { writer.appendLine(`token auralMode = "${this.auralMode}"`); } if (typeof this.multiplePerformOperation === "string") { writer.appendLine(`token multiplePerformOperation = "${this.multiplePerformOperation}"`); } if (typeof this.velocity === "object") { writer.appendLine(`vector3d velocity = (${this.velocity.x}, ${this.velocity.y}, ${this.velocity.z})`); } writer.closeBlock(); } } class Vec3 { x: number = 0; y: number = 0; z: number = 0; constructor(x: number, y: number, z: number) { this.x = x; this.y = y; this.z = z; } static get up(): Vec3 { return new Vec3(0, 1, 0); } static get right(): Vec3 { return new Vec3(1, 0, 0); } static get forward(): Vec3 { return new Vec3(0, 0, 1); } static get back(): Vec3 { return new Vec3(0, 0, -1); } static get zero(): Vec3 { return new Vec3(0, 0, 0); } } export class ActionBuilder { static sequence(...params: IBehaviorElement[]) { const group = new GroupActionModel("Group_" + GroupActionModel.getId(), params); return group.makeSequence(); } static parallel(...params: IBehaviorElement[]) { const group = new GroupActionModel("Group_" + GroupActionModel.getId(), params); return group.makeParallel(); } static fadeAction(targetObject: Target, duration: number, show: boolean): ActionModel { const act = new ActionModel(targetObject); act.tokenId = "Visibility"; act.type = show ? "show" : "hide"; act.duration = duration; act.style = "basic"; act.motionType = "none"; // only VisibilityActionMotionType allowed act.moveDistance = 0; act.easeType = "none"; return act; } /** * creates an action that plays an animation * @param start offset in seconds! * @param duration in seconds! 0 means play to end */ static startAnimationAction(targetObject: Target, anim: RegisteredAnimationInfo, reversed: boolean = false, pingPong: boolean = false): IBehaviorElement { const act = new ActionModel(targetObject); act.tokenId = "StartAnimation"; /* if (targetObject instanceof USDObject) { act.cachedTargetObject = targetObject; // try to retarget the animation – this improves animation playback with overlapping roots. act.affectedObjects = anim.nearestAnimatedRoot; } */ const start = anim.start; const duration = anim.duration; const animationSpeed = anim.speed; const animationName = anim.clipName; act.comment = `Animation: ${animationName}, start=${start * 60}, length=${duration * 60}, end=${(start + duration) * 60}`; act.animationName = animationName; // start is time in seconds, the documentation is not right here act.start = start; // duration of 0 is play to end act.duration = duration; act.animationSpeed = animationSpeed; act.reversed = reversed; act.pingPong = pingPong; act.multiplePerformOperation = "allow"; if (reversed) { act.start -= duration; //console.warn("Reversed animation does currently not work. The resulting file will most likely not playback.", act.id, targetObject); } if (pingPong) { act.pingPong = false; const back = act.clone(); back.reversed = !reversed; back.start = act.start; if (back.reversed) { back.start -= duration; } const group = ActionBuilder.sequence(act, back); return group; } // if (debug) console.log("Start Animation Action", act); return act; } static waitAction(duration: number): ActionModel { const act = new ActionModel(); act.tokenId = "Wait"; act.duration = duration; act.motionType = undefined; return act; } static lookAtCameraAction(targets: Target, duration?: number, front?: Vec3, up?: Vec3): ActionModel { const act = new ActionModel(targets); act.tokenId = "LookAtCamera"; act.duration = duration === undefined ? 9999999999999 : duration; act.front = front ?? Vec3.forward; // 0,0,0 is a special case for "free look" // 0,1,0 is for "y-locked look-at" act.up = up ?? Vec3.up; return act; } static emphasize(targets: Target, duration: number, motionType: EmphasizeActionMotionType = "bounce", moveDistance: number = 1, style: MotionStyle = "basic") { const act = new ActionModel(targets); act.tokenId = "Emphasize"; act.duration = duration; act.style = style ?? "basic"; act.motionType = motionType; act.moveDistance = moveDistance; return act; } static transformAction(targets: Target, transformTarget: Target, duration: number, transformType: Space, easeType: EaseType = "inout") { const act = new ActionModel(targets); act.tokenId = "Transform"; act.duration = duration; // Workaround for a bug in QuickLook: if duration is 0, loops stop somewhat randomly. FB13759712 act.duration = Math.max(0.000001, duration); act.type = transformType; act.easeType = duration > 0 ? easeType : "none"; if (Array.isArray(transformTarget)) { console.error("Transform target must not be an array", transformTarget); } act.xFormTarget = transformTarget; return act; } static playAudioAction(targets: Target, audio: string, type: PlayAction = "play", gain: number = 1, auralMode: AuralMode = "spatial") { const act = new ActionModel(targets); act.tokenId = "Audio"; act.type = type; act.audio = audio; act.gain = gain; act.auralMode = auralMode; act.multiplePerformOperation = "allow"; return act; } // Supported only on VisionOS, Preliminary Behaviours can affect RealityKit physics as well static impulseAction(targets: Target, velocity: Vec3) { const act = new ActionModel(targets); act.tokenId = "Impulse"; act.velocity = velocity; return act; } // Currently doesn't work on VisionOS, see FB13761990 /* static reloadSceneAction() { const act = new ActionModel(); act.tokenId = "ChangeScene"; // rel scene = ... is implicit since we only allow one scene right now return act; } */ } export { Vec3 as USDVec3 }