@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.
321 lines (277 loc) • 15.5 kB
text/typescript
import { getParam } from "../../../../../engine/engine_utils.js";
import { GameObject } from "../../../../Component.js";
import type { IUSDExporterExtension } from "../../Extension.js";
import type { USDObject, USDWriter, USDZExporterContext } from "../../ThreeUSDZExporter.js";
import { AudioExtension } from "./AudioExtension.js";
import { ActionModel, type BehaviorModel, GroupActionModel, IBehaviorElement, type Target, TriggerModel } from "./BehavioursBuilder.js";
const debug = getParam("debugusdzbehaviours");
export interface UsdzBehaviour {
createBehaviours?(ext: BehaviorExtension, model: USDObject, context: USDZExporterContext): void;
beforeCreateDocument?(ext: BehaviorExtension, context: USDZExporterContext): void | Promise<void>;
afterCreateDocument?(ext: BehaviorExtension, context: USDZExporterContext): void | Promise<void>
afterSerialize?(ext: BehaviorExtension, context: USDZExporterContext): void;
}
/** internal USDZ behaviours extension */
export class BehaviorExtension implements IUSDExporterExtension {
get extensionName(): string {
return "Behaviour";
}
private behaviours: BehaviorModel[] = [];
addBehavior(beh: BehaviorModel) {
this.behaviours.push(beh);
}
/** Register audio clip for USDZ export. The clip will be embedded in the resulting file. */
addAudioClip(clipUrl: string) {
if (!clipUrl) return "";
if (typeof clipUrl !== "string") return "";
const clipName = AudioExtension.getName(clipUrl);
const filesKey = "audio/" + clipName;
this.audioClips.push({clipUrl, filesKey});
return filesKey;
}
behaviourComponents: Array<UsdzBehaviour> = [];
private behaviourComponentsCopy: Array<UsdzBehaviour> = [];
private audioClips: Array<{clipUrl: string, filesKey: string}> = [];
private audioClipsCopy: Array<{clipUrl: string, filesKey: string}> = [];
private targetUuids: Set<string> = new Set();
getAllTargetUuids() {
return this.targetUuids;
}
onBeforeBuildDocument(context: USDZExporterContext) {
if (!context.root) return Promise.resolve();
const beforeCreateDocumentPromises : Array<Promise<any>> = [];
context.root.traverse(e => {
GameObject.foreachComponent(e, (comp) => {
const c = comp as unknown as UsdzBehaviour;
// Test if the components has any of the behaviour type methods
if (
typeof c.createBehaviours === "function" ||
typeof c.beforeCreateDocument === "function" ||
typeof c.afterCreateDocument === "function" ||
typeof c.afterSerialize === "function"
) {
this.behaviourComponents.push(c);
// run beforeCreateDocument. We run them in parallel if any of them is async because the order in which this is invoked on the components is not guaranteed anyways
// (or at least no behaviour component should rely on another to have finished this method)
const res = c.beforeCreateDocument?.call(c, this, context);
if(res instanceof Promise) {
beforeCreateDocumentPromises.push(res);
}
}
}, false);
});
if (debug) console.log("onBeforeBuildDocument: all components", this.behaviourComponents);
return Promise.all(beforeCreateDocumentPromises);
}
onExportObject(_object, model: USDObject, context) {
for (const beh of this.behaviourComponents) {
// if (debug) console.log("onExportObject: createBehaviours", beh);
beh.createBehaviours?.call(beh, this, model, context);
}
}
onAfterBuildDocument(context: USDZExporterContext) {
for (const beh of this.behaviourComponents) {
if (typeof beh.afterCreateDocument === "function")
beh.afterCreateDocument(this, context);
}
this.behaviourComponentsCopy = this.behaviourComponents.slice();
this.behaviourComponents.length = 0;
this.audioClipsCopy = this.audioClips.slice();
this.audioClips.length = 0;
// We want to know all trigger sources and action targets.
// These can be nested in Group Actions.
const triggerSources = new Set<Target>();
const actionTargets = new Set<Target>();
const targetUuids = new Set<string>();
const playAnimationActions = new Set<ActionModel>();
// We're assembling a mermaid graph on the go, for easier debugging
const createMermaidGraphForDebugging = debug;
let mermaidGraph = "graph LR\n";
let mermaidGraphTopLevel = "";
function collectAction (actionModel: IBehaviorElement) {
if (actionModel instanceof GroupActionModel) {
if (createMermaidGraphForDebugging) mermaidGraph += `subgraph Group_${actionModel.id}\n`;
for (const action of actionModel.actions) {
if (createMermaidGraphForDebugging) mermaidGraph += `${actionModel.id}[${actionModel.id}] -- ${actionModel.type},loops:${actionModel.loops} --> ${action.id}[${action.id}]\n`;
collectAction(action);
}
if (createMermaidGraphForDebugging) mermaidGraph += `end\n`;
}
else if (actionModel instanceof ActionModel) {
if (actionModel.tokenId === "StartAnimation") {
playAnimationActions.add(actionModel);
}
let actionType = actionModel.tokenId;
if (actionModel.type !== undefined) actionType += ":" + actionModel.type;
const affected = actionModel.affectedObjects;
if (affected) {
if (Array.isArray(affected)) {
for (const a of affected) {
actionTargets.add(a as Target);
//@ts-ignore
if (createMermaidGraphForDebugging) mermaidGraphTopLevel += `${actionModel.id}[${actionModel.id}\n${actionType}] -- ${actionType} --> ${a.uuid}(("${a.displayName || a.name || a.uuid}"))\n`;
}
}
else if (typeof affected === "object") {
actionTargets.add(affected as Target);
//@ts-ignore
if (createMermaidGraphForDebugging) mermaidGraphTopLevel += `${actionModel.id}[${actionModel.id}\n${actionType}] -- ${actionType} --> ${affected.uuid}(("${affected.displayName || affected.name || affected.uuid}"))\n`;
}
else if (typeof affected === "string") {
actionTargets.add({uuid: affected} as any as Target);
}
}
const xform = actionModel.xFormTarget;
if (xform) {
if (typeof xform === "object") {
actionTargets.add(xform as Target);
//@ts-ignore
if (createMermaidGraphForDebugging) mermaidGraphTopLevel += `${actionModel.id}[${actionModel.id}\n${actionType}] -- ${actionType} --> ${xform.uuid}(("${xform.displayName || xform.name || xform.uuid}"))\n`;
}
else if (typeof xform === "string") {
actionTargets.add({uuid: xform} as any as Target);
}
}
}
}
function collectTrigger(trigger: IBehaviorElement | IBehaviorElement[], action: IBehaviorElement) {
if (Array.isArray(trigger)) {
for (const t of trigger)
collectTrigger(t, action);
}
else if (trigger instanceof TriggerModel) {
let triggerType = trigger.tokenId;
if (trigger.type !== undefined) triggerType += ":" + trigger.type;
if (typeof trigger.targetId === "object") {
triggerSources.add(trigger.targetId as Target);
//@ts-ignore
if (createMermaidGraphForDebugging) mermaidGraphTopLevel += `${trigger.targetId.uuid}(("${trigger.targetId.displayName}")) --> ${trigger.id}[${trigger.id}\n${triggerType}]\n`;
}
//@ts-ignore
if (createMermaidGraphForDebugging) mermaidGraph += `${trigger.id}((${trigger.id})) -- ${triggerType} --> ${action.id}[${action.tokenId || action.id}]\n`;
}
}
// collect all targets of all triggers and actions
for (const beh of this.behaviours) {
if (createMermaidGraphForDebugging) mermaidGraph += `subgraph ${beh.id}\n`;
collectAction(beh.action);
collectTrigger(beh.trigger, beh.action);
if (createMermaidGraphForDebugging) mermaidGraph += `end\n`;
}
if (createMermaidGraphForDebugging) mermaidGraph += "\n" + mermaidGraphTopLevel;
if (createMermaidGraphForDebugging) {
console.log("All USDZ behaviours", this.behaviours);
if (this.behaviours.length) {
console.warn("The Mermaid graph can be pasted into https://massive-mermaid.glitch.me/ or https://mermaid.live/edit. It should be in your clipboard already!");
console.log(mermaidGraph);
// copy to clipboard, can be pasted into https://massive-mermaid.glitch.me/ or https://mermaid.live/edit
navigator.clipboard.writeText(mermaidGraph);
}
}
{
// Validation: Check if any PlayAnimation actions are overlapping.
// That means: the target of one of the actions is a child of any other target of another action.
// This leads to undefined behaviour in the runtime.
// See FB15122057 for more information.
let animationsGraph = "gantt\ntitle Animations\ndateFormat X\naxisFormat %s\n";
const arr = Array.from(playAnimationActions);
const animationTargetObjects = new Set<USDObject>();
for (const a of arr) {
if (!a.affectedObjects) continue;
if (typeof a.affectedObjects === "string") continue;
if (Array.isArray(a.affectedObjects)) {
for (const o of a.affectedObjects) {
animationTargetObjects.add(o as USDObject);
}
}
else {
animationTargetObjects.add(a.affectedObjects as USDObject);
}
if (createMermaidGraphForDebugging) {
animationsGraph += `section ${a.animationName} (${a.id})\n`;
animationsGraph += `${a.id} : ${a.start}, ${a.duration}s\n`;
}
}
if (createMermaidGraphForDebugging && playAnimationActions.size) {
console.log(animationsGraph);
}
const animationTargetPaths = new Set<{path: string, obj: USDObject}>();
for (const o of animationTargetObjects) {
if (!o.getPath) {
console.error("USDZExporter: Animation target object has no getPath method. This is likely a bug", o);
}
let path = o.getPath();
// remove < and >, these are part of USD paths
if (path.startsWith("<")) path = path.substring(1);
if (path.endsWith(">")) path = path.substring(0, path.length - 1);
animationTargetPaths.add({path, obj: o});
}
// order by length
const sortedPaths = Array.from(animationTargetPaths).sort((a, b) => a.path.length - b.path.length);
const overlappingTargets = new Array<{child: string, parent: string}>();
for (let i = 0; i < sortedPaths.length; i++) {
for (let j = i + 1; j < sortedPaths.length; j++) {
if (sortedPaths[j].path.startsWith(sortedPaths[i].path)) {
const c = sortedPaths[j];
const p = sortedPaths[i];
overlappingTargets.push({child: c.obj.displayName + " (" + c.path + ")", parent: p.obj.displayName + " (" + p.path + ")"});
}
}
}
// There's some overlapping animation targets – we should warn here, so that this
// can be resolved in the scene.
if (overlappingTargets.length) {
console.warn("USDZExporter: There are overlapping PlayAnimation actions. This can lead to undefined runtime behaviour when playing multiple animations. Please restructure the hierarchy so that animations don't overlap.",
{
overlappingTargets,
playAnimationActions,
}
);
}
}
for (const source of new Set([...triggerSources, ...actionTargets])) {
// shouldn't happen but strictly speaking a trigger source could be set to an array
if (Array.isArray(source)) {
for (const s of source)
targetUuids.add(s.uuid);
}
else
targetUuids.add(source.uuid);
}
if (debug) console.log("All Behavior trigger sources and action targets", triggerSources, actionTargets, targetUuids);
this.targetUuids = new Set(targetUuids);
}
onAfterHierarchy(context: USDZExporterContext, writer: USDWriter) {
if (this.behaviours?.length) {
writer.beginBlock('def Scope "Behaviors"');
for (const beh of this.behaviours)
beh.writeTo(this, context.document, writer);
writer.closeBlock();
}
}
async onAfterSerialize(context: USDZExporterContext) {
if (debug) console.log("onAfterSerialize behaviours", this.behaviourComponentsCopy)
for (const beh of this.behaviourComponentsCopy) {
if (typeof beh.afterSerialize === "function") {
const isAsync = beh.afterSerialize.constructor.name === "AsyncFunction";
if ( isAsync ) {
await beh.afterSerialize(this, context);
} else {
beh.afterSerialize(this, context);
}
}
}
for (const { clipUrl, filesKey } of this.audioClipsCopy) {
// if the clip was already added, don't add it again
if (context.files[filesKey]) return;
const audio = await fetch(clipUrl);
const audioBlob = await audio.blob();
const arrayBuffer = await audioBlob.arrayBuffer();
const audioData: Uint8Array = new Uint8Array(arrayBuffer)
context.files[filesKey] = audioData;
}
// cleanup
this.behaviourComponentsCopy.length = 0;
this.audioClipsCopy.length = 0;
}
}