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.

549 lines 21.1 kB
import { getParam } from "../../../../../engine/engine_utils.js"; import { makeNameSafeForUSD, USDObject } from "../../ThreeUSDZExporter.js"; const debug = getParam("debugusdz"); export class BehaviorModel { static global_id = 0; id; trigger; action; exclusive = false; makeExclusive(exclusive) { this.exclusive = exclusive; return this; } constructor(id, trigger, action) { 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, document, writer) { 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(); } } const addedStrings = new Set(); /** called to resolve target objects to usdz paths */ function resolve(targetObject, document) { let result = ""; 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.getPath?.call(obj); 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.getPath?.call(targetObject); } return result; } export class TriggerModel { static global_id = 0; id; targetId; tokenId; type; distance; constructor(targetId, id) { if (targetId) this.targetId = targetId; if (id) this.id = id; else this.id = "Trigger_" + TriggerModel.global_id++; } writeTo(document, writer) { 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, options = { direct: true, indirect: true }) { const empty = USDObject.createEmpty(); empty.name = "InputTarget_" + empty.name; empty.displayName = undefined; empty.type = "RealityKitComponent"; empty.onSerialize = (writer) => { 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 { static __sceneStartTrigger; static sceneStartTrigger() { 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, inputMode = { direct: true, indirect: true }) { 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) { return trigger?.tokenId === "TapGesture"; } static proximityToCameraTrigger(targetObject, distance) { const trigger = new TriggerModel(targetObject); trigger.tokenId = "ProximityToCamera"; trigger.distance = distance; return trigger; } } export class GroupActionModel { static global_id = 0; static getId() { return this.global_id++; } id; actions; loops = 0; performCount = 1; type = "serial"; multiplePerformOperation = undefined; constructor(id, actions) { this.id = id; this.actions = actions; } addAction(el) { this.actions.push(el); return this; } makeParallel() { this.type = "parallel"; return this; } makeSequence() { this.type = "serial"; return this; } makeLooping() { this.loops = 1; this.performCount = 0; return this; } makeRepeat(count) { this.performCount = count; return this; } writeTo(document, writer) { 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(); } } export class ActionModel { static global_id = 0; id; tokenId; affectedObjects; easeType; ; motionType = undefined; duration; moveDistance; style; type; // combined types of different actions front; up; start; animationSpeed; reversed; pingPong; xFormTarget; audio; gain; auralMode; multiplePerformOperation; velocity; // extra info written as comment at the beginning of the action comment; animationName; clone() { const copy = new ActionModel(); const id = copy.id; Object.assign(copy, this); copy.id = id; return copy; } constructor(affectedObjects, id) { if (affectedObjects) this.affectedObjects = affectedObjects; if (id) this.id = id; else this.id = "Action"; this.id += "_" + ActionModel.global_id++; } writeTo(document, writer) { 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 = 0; y = 0; z = 0; constructor(x, y, z) { this.x = x; this.y = y; this.z = z; } static get up() { return new Vec3(0, 1, 0); } static get right() { return new Vec3(1, 0, 0); } static get forward() { return new Vec3(0, 0, 1); } static get back() { return new Vec3(0, 0, -1); } static get zero() { return new Vec3(0, 0, 0); } } export class ActionBuilder { static sequence(...params) { const group = new GroupActionModel("Group_" + GroupActionModel.getId(), params); return group.makeSequence(); } static parallel(...params) { const group = new GroupActionModel("Group_" + GroupActionModel.getId(), params); return group.makeParallel(); } static fadeAction(targetObject, duration, show) { 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, anim, reversed = false, pingPong = false) { 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) { const act = new ActionModel(); act.tokenId = "Wait"; act.duration = duration; act.motionType = undefined; return act; } static lookAtCameraAction(targets, duration, front, up) { 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, duration, motionType = "bounce", moveDistance = 1, style = "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, transformTarget, duration, transformType, 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, audio, type = "play", gain = 1, 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, velocity) { const act = new ActionModel(targets); act.tokenId = "Impulse"; act.velocity = velocity; return act; } } export { Vec3 as USDVec3 }; //# sourceMappingURL=BehavioursBuilder.js.map