@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
JavaScript
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