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.

692 lines 26 kB
import { Object3D, TextureLoader } from "three"; import { getParam, resolveUrl } from "../engine/engine_utils.js"; import { destroy, instantiate, InstantiateOptions, isDestroyed } from "./engine_gameobject.js"; import { getLoader } from "./engine_gltf.js"; import { processNewScripts } from "./engine_mainloop_utils.js"; import { BlobStorage } from "./engine_networking_blob.js"; import { registerPrefabProvider, syncInstantiate } from "./engine_networking_instantiate.js"; import { TypeSerializer } from "./engine_serialization_core.js"; import { Context } from "./engine_setup.js"; const debug = getParam("debugaddressables"); /** * The Addressables class is used to register and manage {@link AssetReference} types * It can be accessed from components via {@link Context.Current} or {@link Context.addressables} (e.g. `this.context.addressables`) */ export class Addressables { _context; _assetReferences = {}; /** @internal */ constructor(context) { this._context = context; this._context.pre_update_callbacks.push(this.preUpdate); } /** @internal */ dispose() { const preUpdateIndex = this._context.pre_update_callbacks.indexOf(this.preUpdate); if (preUpdateIndex >= 0) { this._context.pre_update_callbacks.splice(preUpdateIndex, 1); } for (const key in this._assetReferences) { const ref = this._assetReferences[key]; ref?.unload(); } this._assetReferences = {}; } preUpdate = () => { }; /** * Find a registered AssetReference by its URL */ findAssetReference(url) { return this._assetReferences[url] || null; } /** * Register an asset reference * @internal */ registerAssetReference(ref) { if (!ref.url) return ref; if (!this._assetReferences[ref.url]) { this._assetReferences[ref.url] = ref; } else { console.warn("Asset reference already registered", ref); } return ref; } /** @internal */ unregisterAssetReference(ref) { if (!ref.url) return; delete this._assetReferences[ref.url]; } } const $assetReference = Symbol("assetReference"); /** ### AssetReferences can be used to load glTF or GLB assets * Use {@link AssetReference.getOrCreateFromUrl} to get an AssetReference for a URL to be easily loaded. When using the same URL multiple times the same AssetReference will be returned, this avoids loading or creating the same asset multiple times. * * **Important methods:** * - {@link preload} to load the asset binary without creating an instance yet. * - {@link loadAssetAsync} to load the asset and create an instance. * - {@link instantiate} to load the asset and create another instance. * - {@link unload} to dispose allocated memory and destroy the asset instance. * * @example Loading an asset from a URL * ```ts * import { AssetReference } from '@needle-tools/engine'; * const assetRef = AssetReference.getOrCreateFromUrl("https://example.com/myModel.glb"); * const instance = await assetRef.loadAssetAsync(); * scene.add(instance); * ``` * * @example Referencing an asset in a component and loading it on start * ```ts * import { Behaviour, serializable, AssetReference } from '@needle-tools/engine'; * * export class MyComponent extends Behaviour { * * @serializable(AssetReference) * myModel?: AssetReference; * * // Load the model on start. Start is called after awake and onEnable * start() { * if (this.myModel) { * this.myModel.loadAssetAsync().then(instance => { * if (instance) { * // add the loaded model to this component's game object * this.gameObject.add(instance); * } * }); * } * } * } * ``` * * ### Related: * - {@link ImageReference} to load external image URLs * - {@link FileReference} to load external file URLs * - {@link loadAsset} to load assets directly without using AssetReferences */ export class AssetReference { /** * Get an AssetReference for a URL to be easily loaded. * AssetReferences are cached so calling this method multiple times with the same arguments will always return the same AssetReference. * @param url The URL of the asset to load. The url can be relative or absolute. * @param context The context to use for loading the asset * @returns the AssetReference for the URL */ static getOrCreateFromUrl(url, context) { if (!context) { context = Context.Current; if (!context) throw new Error("Context is required when sourceId is a string. When you call this method from a component you can call it with \"getOrCreate(this, url)\" where \"this\" is the component."); } const addressables = context.addressables; const existing = addressables.findAssetReference(url); if (existing) return existing; const ref = new AssetReference(url, context.hash); addressables.registerAssetReference(ref); return ref; } /** * Get an AssetReference for a URL to be easily loaded. * AssetReferences are cached so calling this method multiple times with the same arguments will always return the same AssetReference. */ static getOrCreate(sourceId, url, context) { if (typeof sourceId === "string") { if (!context) { context = Context.Current; if (!context) throw new Error("Context is required when sourceId is a string. When you call this method from a component you can call it with \"getOrCreate(this, url)\" where \"this\" is the component."); } } else { context = sourceId.context; sourceId = sourceId.sourceId; } const fullPath = resolveUrl(sourceId, url); if (debug) console.log("GetOrCreate Addressable from", sourceId, url, "FinalPath=", fullPath); const addressables = context.addressables; const existing = addressables.findAssetReference(fullPath); if (existing) return existing; const ref = new AssetReference(fullPath, context.hash); addressables.registerAssetReference(ref); return ref; } isAssetReference = true; /** * This is the loaded asset root object. If the asset is a glb/gltf file this will be the {@link three#Scene} object. */ get rawAsset() { return this._rawAsset; } /** The loaded asset root */ get asset() { return this._glbRoot ?? (this._rawAsset?.scene || null); } set asset(val) { if (val) { this._rawAsset = { animations: val.animations, scene: val, scenes: [val], }; } else this._rawAsset = null; } /** The url of the loaded asset (or the asset to be loaded) * @deprecated use url */ get uri() { return this._url; } /** The url of the loaded asset (or the asset to be loaded) */ get url() { return this._url; } /** The name of the assigned url. This name is deduced from the url and might not reflect the actual name of the asset */ get urlName() { return this._urlName; } ; /** * @returns true if the uri is a valid URL (http, https, blob) */ get hasUrl() { return this._url !== undefined && (this._url.startsWith("http") || this._url.startsWith("blob:") || this._url.startsWith("www.") || this._url.includes("/")); } _rawAsset = null; _glbRoot; _url; _urlName; _progressListeners = []; _isLoadingRawBinary = false; _rawBinary; constructor(...args) { if (typeof args[0] === "object") { if ("url" in args[0]) { this._url = args[0].url; } else { this._url = ""; if (args[0].asset) this.asset = args[0].asset; } } else { this._url = args[0]; if (args[2] instanceof Object3D) this.asset = args[2]; } const lastUriPart = this._url.lastIndexOf("/"); if (lastUriPart >= 0) { this._urlName = this._url.substring(lastUriPart + 1); // remove file extension const lastDot = this._urlName.lastIndexOf("."); if (lastDot >= 0) { this._urlName = this._urlName.substring(0, lastDot); } } else { this._urlName = this._url; } registerPrefabProvider(this._url, this.onResolvePrefab.bind(this)); } async onResolvePrefab(url) { if (url === this.url) { if (this.mustLoad) await this.loadAssetAsync(); if (this.asset) { return this.asset; } } return null; } get mustLoad() { return !this.asset || this.asset.__destroyed === true || isDestroyed(this.asset) === true; } _loadingPromise = null; /** * @returns `true` if the asset has been loaded (via preload) or if it exists already (assigned to `asset`) */ isLoaded() { return this._rawBinary || this.asset !== undefined; } /** frees previously allocated memory and destroys the current `asset` instance (if any) */ unload() { if (this.asset) { if (debug) console.log("Unload", this.asset); // TODO: we need a way to remove objects from the context (including components) without actually "destroying" them if ("scene" in this.asset && this.asset.scene) destroy(this.asset.scene, true, true); destroy(this.asset, true, true); } this.asset = null; this._rawBinary = undefined; this._glbRoot = null; this._loadingPromise = null; if (Context.Current) { Context.Current.addressables.unregisterAssetReference(this); } } /** loads the asset binary without creating an instance */ async preload() { if (!this.mustLoad) return null; if (this._isLoadingRawBinary) return null; if (this._rawBinary !== undefined) return this._rawBinary; this._isLoadingRawBinary = true; if (debug) console.log("Preload", this.url); const res = await BlobStorage.download(this.url, p => { this.raiseProgressEvent(p); }); this._rawBinary = res?.buffer ?? null; this._isLoadingRawBinary = false; return this._rawBinary; } // TODO: we need a way to abort loading a resource /** Loads the asset and returns a single shared instance (assigned to {@link asset}). * Calling this multiple times will **not** create additional instances — it returns the same `Object3D`. * To create a new independent clone, use {@link instantiate} instead. * @param prog Optional progress callback invoked during download. * @returns The loaded root `Object3D`, or `null` if loading fails. */ async loadAssetAsync(prog) { if (debug) console.log("[AssetReference] loadAssetAsync", this.url); if (!this.mustLoad) { if (this.asset?.parent) { console.warn(`[AssetReference] "${this.urlName}" is already loaded and parented to "${this.asset.parent.name || "scene"}". loadAssetAsync() returns the same shared instance — use .instantiate() to create a new copy.`); } return this.asset; } if (prog) this._progressListeners.push(prog); if (this._loadingPromise !== null) { return this._loadingPromise.then(_ => this.asset); } const context = Context.Current; // TODO: technically we shouldnt call awake only when the object is added to a scene // addressables now allow loading things without adding them to a scene immediately // we should "address" (LUL) this // console.log("START LOADING"); if (this._rawBinary) { if (!(this._rawBinary instanceof ArrayBuffer)) { console.error("[AssetReference] Failed loading – Invalid data. Must be of type ArrayBuffer. " + (typeof this._rawBinary)); return null; } this._loadingPromise = getLoader().parseSync(context, this._rawBinary, this.url, null); this.raiseProgressEvent(new ProgressEvent("progress", { loaded: this._rawBinary.byteLength, total: this._rawBinary.byteLength })); } else { if (debug) console.log("Load async", this.url); this._loadingPromise = getLoader().loadSync(context, this.url, this.url, null, prog => { this.raiseProgressEvent(prog); }); } this._loadingPromise.finally(() => this._loadingPromise = null); const res = await this._loadingPromise; // clear all progress listeners after download has finished this._progressListeners.length = 0; this._glbRoot = this.tryGetActualGameObjectRoot(res); if (res) { // Make sure the loaded roots all have a reference to this AssetReference // that was originally loading it. // We need this when the loaded asset is being disposed // TODO: we have to prevent disposing resources that are still in use res[$assetReference] = this; if (this._glbRoot) this._glbRoot[$assetReference] = this; if (this.asset) this.asset[$assetReference] = this; // we need to handle the pre_setup callsbacks before instantiating // because that is where deserialization happens processNewScripts(context); if (res.scene !== undefined) { this._rawAsset = res; } return this.asset; } return null; } /** loads and returns a new instance of `asset` */ instantiate(parent) { return this.onInstantiate(parent, false); } /** loads and returns a new instance of `asset` - this call is networked so an instance will be created on all connected users */ instantiateSynced(parent, saveOnServer = true) { return this.onInstantiate(parent, true, saveOnServer); } beginListenDownload(evt) { if (this._progressListeners.indexOf(evt) < 0) this._progressListeners.push(evt); } endListenDownload(evt) { const index = this._progressListeners.indexOf(evt); if (index >= 0) { this._progressListeners.splice(index, 1); } } raiseProgressEvent(prog) { for (const list of this._progressListeners) { list(this, prog); } } static currentlyInstantiating = new Map(); async onInstantiate(opts, networked = false, saveOnServer) { const context = Context.Current; // clone the instantiate options immediately // in case the user is not awaiting this call and already modifying the options const options = new InstantiateOptions(); if (opts instanceof Object3D) { options.parent = opts; } else if (opts) { // Assign to e.g. have SyncInstantiateOptions Object.assign(options, opts); options.cloneAssign(opts); } if (options.parent === undefined) options.parent = context.scene; // ensure the asset is loaded if (this.mustLoad) { await this.loadAssetAsync(); } if (debug) console.log("Instantiate", this.url, "parent:", opts); if (this.asset) { if (debug) console.log("Add to scene", this.asset); let count = AssetReference.currentlyInstantiating.get(this.url); // allow up to 10000 instantiations of the same prefab in the same frame if (count !== undefined && count >= 10000) { console.error("Recursive or too many instantiations of " + this.url + " in the same frame (" + count + ")"); return null; } try { if (count === undefined) count = 0; count += 1; AssetReference.currentlyInstantiating.set(this.url, count); if (networked) { options.context = context; const prefab = this.asset; prefab.guid = this.url; const instance = syncInstantiate(prefab, options, undefined, saveOnServer); if (instance) { return instance; } } else { const instance = instantiate(this.asset, options); if (instance) { return instance; } } } finally { context.post_render_callbacks.push(() => { if (count === undefined || count < 0) count = 0; else count -= 1; AssetReference.currentlyInstantiating.set(this.url, count); }); } } else if (debug) console.warn("Failed to load asset", this.url); return null; } /** * try to ignore the intermediate created object * because it causes trouble if we instantiate an assetreference per player * and call destroy on the player marker root * @returns the scene root object if the asset was a glb/gltf */ tryGetActualGameObjectRoot(asset) { if (asset && asset.scene) { // some exporters produce additional root objects const scene = asset.scene; if (scene.isGroup && scene.children.length === 1 && scene.children[0].name + "glb" === scene.name) { const root = scene.children[0]; // HACK we want to keep the animations on the root object. Please keep in sync with engine_loader. This is so autoplay animations works (e.g. with AnimationUtils.autoplayAnimations where we get the animations from the root object). One case where an additioanl root exists is the SOC old mcdonald scene root.animations = scene.animations; return root; } // ok the scene is the scene, just use that then else return scene; } return null; } } class AddressableSerializer extends TypeSerializer { constructor() { super([AssetReference], "AssetReferenceSerializer"); } onSerialize(data, _context) { if (data && data.uri !== undefined && typeof data.uri === "string") { return data.uri; } } onDeserialize(data, context) { if (typeof data === "string") { if (!context.context) { console.error("Missing context"); return null; } if (!context.gltfId) { console.error("Missing source id"); return null; } const ref = AssetReference.getOrCreate(context.gltfId, data, context.context); return ref; } else if (data instanceof Object3D) { if (!context.context) { console.error("Missing context"); return null; } if (!context.gltfId) { console.error("Missing source id"); return null; } const obj = data; const ctx = context.context; const guid = obj["guid"] ?? obj.uuid; const existing = ctx.addressables.findAssetReference(guid); if (existing) return existing; const ref = new AssetReference(guid, undefined, obj); ctx.addressables.registerAssetReference(ref); return ref; } return null; } } new AddressableSerializer(); const failedTexturePromise = Promise.resolve(null); /** * Load images or textures from external URLs. * * **Important methods:** * - {@link createHTMLImage} to create an HTMLImageElement from the URL * - {@link createTexture} to create a Three.js Texture from the URL * * @example * ```ts * import { ImageReference, serializable } from '@needle-tools/engine'; * * export class MyComponent extends Behaviour { * @serializable(ImageReference) * myImage?:ImageReference; * async start() { * if(this.myImage) { * const texture = await this.myImage.createTexture(); * if(texture) { * // use the texture * } * } * } * ``` * * ### Related: * - {@link AssetReference} to load glTF or GLB assets * - {@link FileReference} to load external file URLs */ export class ImageReference { static imageReferences = new Map(); static getOrCreate(url) { let ref = ImageReference.imageReferences.get(url); if (!ref) { ref = new ImageReference(url); ImageReference.imageReferences.set(url, ref); } return ref; } constructor(url) { this.url = url; } url; _bitmap; _bitmapObject; dispose() { if (this._bitmapObject) { this._bitmapObject.close(); } this._bitmap = undefined; } createHTMLImage() { const img = new Image(); img.src = this.url; return img; } loader = null; createTexture() { if (!this.url) { console.error("Can not load texture without url"); return failedTexturePromise; } if (!this.loader) this.loader = new TextureLoader(); this.loader.setCrossOrigin("anonymous"); return this.loader.loadAsync(this.url).then(res => { if (res && !res.name?.length) { // default name if no name is defined res.name = this.url.split("/").pop() ?? this.url; } return res; }); // return this.getBitmap().then((bitmap) => { // if (bitmap) { // const texture = new Texture(bitmap); // texture.needsUpdate = true; // return texture; // } // return null; // }); } /** Loads the bitmap data of the image */ getBitmap() { if (this._bitmap) return this._bitmap; this._bitmap = new Promise((res, _) => { const imageElement = document.createElement("img"); imageElement.addEventListener("load", () => { this._bitmap = createImageBitmap(imageElement).then((bitmap) => { this._bitmapObject = bitmap; res(bitmap); return bitmap; }); }); imageElement.addEventListener("error", err => { console.error("Failed to load image:" + this.url, err); res(null); }); imageElement.src = this.url; }); return this._bitmap; } } /** @internal */ export class ImageReferenceSerializer extends TypeSerializer { constructor() { super([ImageReference], "ImageReferenceSerializer"); } onSerialize(_data, _context) { return null; } onDeserialize(data, _context) { if (typeof data === "string") { const url = resolveUrl(_context.gltfId, data); return ImageReference.getOrCreate(url); } return undefined; } } new ImageReferenceSerializer(); /** * Use this if a file is a external file URL. The file can be any arbitrary binary data like a videofile or a text asset. * * ### Related: * - {@link AssetReference} to load glTF or GLB assets * - {@link ImageReference} to load external image URLs */ export class FileReference { static cache = new Map(); static getOrCreate(url) { let ref = FileReference.cache.get(url); if (!ref) { ref = new FileReference(url); FileReference.cache.set(url, ref); } return ref; } /** Load the file binary data * @returns a promise that resolves to the binary data of the file. Make sure to await this request or use `.then(res => {...})` to get the result. */ async loadRaw() { if (!this.res) this.res = fetch(this.url); return this.res.then(res => res.blob()); } /** Load the file as text (if the referenced file is a text file like a .txt or .json file) * @returns a promise that resolves to the text data of the file. Make sure to await this request or use `.then(res => {...})` to get the result. If the format is json you can use `JSON.parse(result)` to convert it to a json object */ async loadText() { if (!this.res) this.res = fetch(this.url); return this.res.then(res => res.text()); } /** The resolved url to the file */ url; res; constructor(url) { this.url = url; } } /** @internal */ export class FileReferenceSerializer extends TypeSerializer { constructor() { super([FileReference], "FileReferenceSerializer"); } onSerialize(_data, _context) { return null; } onDeserialize(data, _context) { if (typeof data === "string") { const url = resolveUrl(_context.gltfId, data); return FileReference.getOrCreate(url); } return undefined; } } new FileReferenceSerializer(); //# sourceMappingURL=engine_addressables.js.map