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.

602 lines • 23.2 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 easily load glTF or GLB assets * You can use `AssetReference.getOrCreate` 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. * - `myAssetReference.preload()` to load the asset binary without creating an instance yet. * - `myAssetReference.loadAssetAsync()` to load the asset and create an instance. * - `myAssetReference.instantiate()` to load the asset and create a new instance. * - `myAssetReference.unload()` to dispose allocated memory and destroy the asset instance. */ 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; } static currentlyInstantiating = new Map(); /** @returns true if this is an AssetReference instance */ get isAssetReference() { return true; } /** The loaded asset */ get asset() { return this._glbRoot ?? this._asset; } set asset(val) { this._asset = val; } _loading; /** 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("/")); } /** * 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._asset; } _asset; _glbRoot; _url; _urlName; _progressListeners = []; _isLoadingRawBinary = false; _rawBinary; /** @internal */ constructor(uri, _hash, asset = null) { this._url = uri; const lastUriPart = uri.lastIndexOf("/"); if (lastUriPart >= 0) { this._urlName = uri.substring(lastUriPart + 1); // remove file extension const lastDot = this._urlName.lastIndexOf("."); if (lastDot >= 0) { this._urlName = this._urlName.substring(0, lastDot); } } else { this._urlName = uri; } if (asset !== null) this.asset = asset; 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; } /** * @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._loading = undefined; 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 creates one instance (assigned to `asset`) * @returns the loaded asset */ async loadAssetAsync(prog) { if (debug) console.log("loadAssetAsync", this.url); if (!this.mustLoad) return this.asset; if (prog) this._progressListeners.push(prog); if (this._loading !== undefined) { // console.warn("Wait for other loading thiny"); return this._loading.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("Failed loading: Invalid raw binary data. Must be of type ArrayBuffer. " + (typeof this._rawBinary)); return null; } this._loading = 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._loading = getLoader().loadSync(context, this.url, this.url, null, prog => { this.raiseProgressEvent(prog); }); } const res = await this._loading; // clear all progress listeners after download has finished this._progressListeners.length = 0; this._glbRoot = this.tryGetActualGameObjectRoot(res); this._loading = undefined; 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.asset = 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); } } 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]; 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); /** Use this if a file is a external image URL * @example * ```ts * @serializable(ImageReference) * myImage?:ImageReference; * ``` */ 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 */ 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