playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
545 lines (544 loc) • 17.1 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { path } from "../../core/path.js";
import { Tags } from "../../core/tags.js";
import { EventHandler } from "../../core/event-handler.js";
import { findAvailableLocale } from "../i18n/utils.js";
import { ABSOLUTE_URL } from "./constants.js";
import { AssetFile } from "./asset-file.js";
import { getApplication } from "../globals.js";
import { http } from "../../platform/net/http.js";
let assetIdCounter = -1;
const VARIANT_SUPPORT = {
pvr: "extCompressedTexturePVRTC",
dxt: "extCompressedTextureS3TC",
etc2: "extCompressedTextureETC",
etc1: "extCompressedTextureETC1",
basis: "canvas"
// dummy, basis is always supported
};
const VARIANT_DEFAULT_PRIORITY = ["pvr", "dxt", "etc2", "etc1", "basis"];
class Asset extends EventHandler {
/**
* Create a new Asset record. Generally, Assets are created in the loading process and you
* won't need to create them by hand.
*
* @param {string} name - A non-unique but human-readable name which can be later used to
* retrieve the asset.
* @param {"animation"|"audio"|"binary"|"container"|"cubemap"|"css"|"font"|"gsplat"|"json"|"html"|"material"|"model"|"render"|"script"|"shader"|"sprite"|"template"|"text"|"texture"|"textureatlas"} type - Type of asset.
* @param {object} [file] - Details about the file the asset is made from. At the least must
* contain the 'url' field. For assets that don't contain file data use null.
* @param {string} [file.url] - The URL of the resource file that contains the asset data.
* @param {string} [file.filename] - The filename of the resource file or null if no filename
* was set (e.g from using {@link AssetRegistry#loadFromUrl}).
* @param {number} [file.size] - The size of the resource file or null if no size was set
* (e.g. from using {@link AssetRegistry#loadFromUrl}).
* @param {string} [file.hash] - The MD5 hash of the resource file data and the Asset data
* field or null if hash was set (e.g from using {@link AssetRegistry#loadFromUrl}).
* @param {ArrayBuffer} [file.contents] - Optional file contents. This is faster than wrapping
* the data in a (base64 encoded) blob. Currently only used by container assets.
* @param {object|string} [data] - JSON object or string with additional data about the asset.
* (e.g. for texture and model assets) or contains the asset data itself (e.g. in the case of
* materials).
* @param {object} [options] - The asset handler options. For container options see
* {@link ContainerHandler}.
* @param {'anonymous'|'use-credentials'|null} [options.crossOrigin] - For use with texture assets
* that are loaded using the browser. This setting overrides the default crossOrigin specifier.
* For more details on crossOrigin and its use, see
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/crossOrigin.
* @example
* const asset = new pc.Asset("a texture", "texture", {
* url: "http://example.com/my/assets/here/texture.png"
* });
*/
constructor(name, type, file, data = {}, options = {}) {
super();
/**
* @type {AssetFile | null}
* @private
*/
__publicField(this, "_file", null);
/**
* A string-assetId dictionary that maps locale to asset id.
*
* @type {object}
* @private
*/
__publicField(this, "_i18n", {});
/**
* Whether to preload the asset.
*
* @private
*/
__publicField(this, "_preload", false);
/**
* This is where the loaded resource(s) are stored.
*
* @type {object[]}
* @private
*/
__publicField(this, "_resources", []);
/**
* The asset id.
*
* @type {number}
*/
__publicField(this, "id", assetIdCounter--);
/**
* True if the asset has finished attempting to load the resource. It is not guaranteed
* that the resources are available as there could have been a network error.
*/
__publicField(this, "loaded", false);
/**
* True if the resource is currently being loaded.
*/
__publicField(this, "loading", false);
/**
* Optional JSON data that contains the asset handler options.
*
* @type {object}
*/
__publicField(this, "options", {});
/**
* The asset registry that this Asset belongs to.
*
* @type {AssetRegistry|null}
*/
__publicField(this, "registry", null);
/**
* Asset tags. Enables finding of assets by tags using the {@link AssetRegistry#findByTag} method.
*
* @type {Tags}
*/
__publicField(this, "tags", new Tags(this));
/**
* The type of the asset.
*
* @type {"animation"|"audio"|"binary"|"container"|"cubemap"|"css"|"font"|"gsplat"|"json"|"html"|"material"|"model"|"render"|"script"|"shader"|"sprite"|"template"|"text"|"texture"|"textureatlas"}
*/
__publicField(this, "type");
/**
* The URL object.
*
* @type {string | null}
* @ignore
*/
__publicField(this, "urlObject", null);
this._name = name || "";
this.type = type;
this._data = data || {};
this.options = options || {};
if (file) this.file = file;
}
/**
* Sets the asset name.
*
* @type {string}
*/
set name(value) {
if (this._name === value) {
return;
}
const old = this._name;
this._name = value;
this.fire("name", this, this._name, old);
}
/**
* Gets the asset name.
*
* @type {string}
*/
get name() {
return this._name;
}
/**
* Sets the file details or null if no file.
*
* @type {object}
*/
set file(value) {
if (value && value.variants && ["texture", "textureatlas", "bundle"].indexOf(this.type) !== -1) {
const app = this.registry?._loader?._app || getApplication();
const device = app?.graphicsDevice;
if (device) {
for (let i = 0, len = VARIANT_DEFAULT_PRIORITY.length; i < len; i++) {
const variant = VARIANT_DEFAULT_PRIORITY[i];
if (value.variants[variant] && device[VARIANT_SUPPORT[variant]]) {
value = value.variants[variant];
break;
}
if (app.enableBundles) {
const bundles = app.bundles.listBundlesForAsset(this);
if (bundles && bundles.find((b) => {
return b?.file?.variants[variant];
})) {
break;
}
}
}
}
}
const oldFile = this._file;
const newFile = value ? new AssetFile(value.url, value.filename, value.hash, value.size, value.opt, value.contents) : null;
if (!!newFile !== !!oldFile || newFile && !newFile.equals(oldFile)) {
this._file = newFile;
this.fire("change", this, "file", newFile, oldFile);
this.reload();
}
}
/**
* Gets the file details or null if no file.
*
* @type {object}
*/
get file() {
return this._file;
}
/**
* Sets optional asset JSON data. This contains either the complete resource data (such as in
* the case of a material) or additional data (such as in the case of a model which contains
* mappings from mesh to material).
*
* @type {object}
*/
set data(value) {
const old = this._data;
this._data = value;
if (value !== old) {
this.fire("change", this, "data", value, old);
if (this.loaded) {
this.registry._loader.patch(this, this.registry);
}
}
}
/**
* Gets optional asset JSON data.
*
* @type {object}
*/
get data() {
return this._data;
}
/**
* Sets the asset resource. For example, a {@link StandardMaterial} or a {@link Texture}.
*
* @type {object}
*/
set resource(value) {
const _old = this._resources[0];
this._resources[0] = value;
this.fire("change", this, "resource", value, _old);
}
/**
* Gets the asset resource.
*
* @type {object}
*/
get resource() {
return this._resources[0];
}
/**
* Sets the asset resources. Some assets can hold more than one runtime resource (cube maps,
* for example).
*
* @type {object[]}
*/
set resources(value) {
const _old = this._resources;
this._resources = value;
this.fire("change", this, "resources", value, _old);
}
/**
* Gets the asset resources.
*
* @type {object[]}
*/
get resources() {
return this._resources;
}
/**
* Sets whether to preload an asset. If true, the asset will be loaded during the preload phase
* of application initialization or when calling {@link AssetRegistry#add}.
*
* @type {boolean}
*/
set preload(value) {
value = !!value;
if (this._preload === value) {
return;
}
this._preload = value;
if (this._preload && !this.loaded && !this.loading && this.registry) {
this.registry.load(this);
}
}
/**
* Gets whether to preload an asset.
*
* @type {boolean}
*/
get preload() {
return this._preload;
}
set loadFaces(value) {
value = !!value;
if (!this.hasOwnProperty("_loadFaces") || value !== this._loadFaces) {
this._loadFaces = value;
if (this.loaded) {
this.registry._loader.patch(this, this.registry);
}
}
}
get loadFaces() {
return this._loadFaces;
}
/**
* Return the URL required to fetch the file for this asset.
*
* @returns {string|null} The URL. Returns null if the asset has no associated file.
* @example
* const assets = app.assets.find("My Image", "texture");
* const img = "<img src='" + assets[0].getFileUrl() + "'>";
*/
getFileUrl() {
const file = this.file;
if (!file || !file.url) {
return null;
}
let url = file.url;
if (this.registry && this.registry.prefix && !ABSOLUTE_URL.test(url)) {
url = this.registry.prefix + url;
}
if (this.type !== "script" && file.hash) {
const separator = url.indexOf("?") !== -1 ? "&" : "?";
url += `${separator}t=${file.hash}`;
}
return url;
}
/**
* Construct an asset URL from this asset's location and a relative path. If the relativePath
* is a blob or Base64 URI, then return that instead.
*
* @param {string} relativePath - The relative path to be concatenated to this asset's base url.
* @returns {string} Resulting URL of the asset.
* @ignore
*/
getAbsoluteUrl(relativePath) {
if (relativePath.startsWith("blob:") || relativePath.startsWith("data:")) {
return relativePath;
}
const base = path.getDirectory(this.file.url);
return path.join(base, relativePath);
}
/**
* Returns the asset id of the asset that corresponds to the specified locale.
*
* @param {string} locale - The desired locale e.g. Ar-AR.
* @returns {number} An asset id or null if there is no asset specified for the desired locale.
* @ignore
*/
getLocalizedAssetId(locale) {
locale = findAvailableLocale(locale, this._i18n);
return this._i18n[locale] || null;
}
/**
* Adds a replacement asset id for the specified locale. When the locale in
* {@link AppBase#i18n} changes then references to this asset will be replaced with the
* specified asset id. (Currently only supported by the {@link ElementComponent}).
*
* @param {string} locale - The locale e.g. Ar-AR.
* @param {number} assetId - The asset id.
* @ignore
*/
addLocalizedAssetId(locale, assetId) {
this._i18n[locale] = assetId;
this.fire("add:localized", locale, assetId);
}
/**
* Removes a localized asset.
*
* @param {string} locale - The locale e.g. Ar-AR.
* @ignore
*/
removeLocalizedAssetId(locale) {
const assetId = this._i18n[locale];
if (assetId) {
delete this._i18n[locale];
this.fire("remove:localized", locale, assetId);
}
}
/**
* Take a callback which is called as soon as the asset is loaded. If the asset is already
* loaded the callback is called straight away.
*
* @param {AssetReadyCallback} callback - The function called when the asset is ready. Passed
* the (asset) arguments.
* @param {object} [scope] - Scope object to use when calling the callback.
* @example
* const asset = app.assets.find("My Asset");
* asset.ready((asset) => {
* // asset loaded
* });
* app.assets.load(asset);
*/
ready(callback, scope) {
scope = scope || this;
if (this.loaded) {
callback.call(scope, this);
} else {
this.once("load", (asset) => {
callback.call(scope, asset);
});
}
}
reload() {
if (this.loaded) {
this.loaded = false;
this.registry.load(this);
}
}
/**
* Destroys the associated resource and marks asset as unloaded.
*
* @example
* const asset = app.assets.find("My Asset");
* asset.unload();
* // asset.resource is null
*/
unload() {
if (!this.loaded && this._resources.length === 0) {
return;
}
this.fire("unload", this);
this.registry.fire(`unload:${this.id}`, this);
const old = this._resources;
if (this.urlObject) {
URL.revokeObjectURL(this.urlObject);
this.urlObject = null;
}
this.resources = [];
this.loaded = false;
if (this.file) {
this.registry._loader.clearCache(this.getFileUrl(), this.type);
}
for (let i = 0; i < old.length; ++i) {
old[i]?.destroy?.();
}
}
/**
* Helper function to resolve asset file data and return the contents as an ArrayBuffer. If the
* asset file contents are present, that is returned. Otherwise the file data is be downloaded
* via http.
*
* @param {string} loadUrl - The URL as passed into the handler
* @param {ResourceLoaderCallback} callback - The callback function to receive results.
* @param {Asset} [asset] - The asset
* @param {number} maxRetries - Number of retries if http download is required
* @ignore
*/
static fetchArrayBuffer(loadUrl, callback, asset, maxRetries = 0) {
if (asset?.file?.contents) {
setTimeout(() => {
callback(null, asset.file.contents);
});
} else {
http.get(loadUrl, {
cache: true,
responseType: "arraybuffer",
retry: maxRetries > 0,
maxRetries,
progress: asset
}, callback);
}
}
}
/**
* Fired when the asset has completed loading.
*
* @event
* @example
* asset.on('load', (asset) => {
* console.log(`Asset loaded: ${asset.name}`);
* });
*/
__publicField(Asset, "EVENT_LOAD", "load");
/**
* Fired just before the asset unloads the resource. This allows for the opportunity to prepare
* for an asset that will be unloaded. E.g. Changing the texture of a model to a default before
* the one it was using is unloaded.
*
* @event
* @example
* asset.on('unload', (asset) => {
* console.log(`Asset about to unload: ${asset.name}`);
* });
*/
__publicField(Asset, "EVENT_UNLOAD", "unload");
/**
* Fired when the asset is removed from the asset registry.
*
* @event
* @example
* asset.on('remove', (asset) => {
* console.log(`Asset removed: ${asset.name}`);
* });
*/
__publicField(Asset, "EVENT_REMOVE", "remove");
/**
* Fired if the asset encounters an error while loading.
*
* @event
* @example
* asset.on('error', (err, asset) => {
* console.error(`Error loading asset ${asset.name}: ${err}`);
* });
*/
__publicField(Asset, "EVENT_ERROR", "error");
/**
* Fired when one of the asset properties `file`, `data`, `resource` or `resources` is changed.
*
* @event
* @example
* asset.on('change', (asset, property, newValue, oldValue) => {
* console.log(`Asset ${asset.name} has property ${property} changed from ${oldValue} to ${newValue}`);
* });
*/
__publicField(Asset, "EVENT_CHANGE", "change");
/**
* Fired when the asset's stream download progresses.
*
* Please note:
* - only gsplat assets current emit this event
* - totalBytes may not be reliable as it is based on the content-length header of the response
*
* @event
* @example
* asset.on('progress', (receivedBytes, totalBytes) => {
* console.log(`Asset ${asset.name} progress ${readBytes / totalBytes}`);
* });
*/
__publicField(Asset, "EVENT_PROGRESS", "progress");
/**
* Fired when we add a new localized asset id to the asset.
*
* @event
* @example
* asset.on('add:localized', (locale, assetId) => {
* console.log(`Asset ${asset.name} has added localized asset ${assetId} for locale ${locale}`);
* });
*/
__publicField(Asset, "EVENT_ADDLOCALIZED", "add:localized");
/**
* Fired when we remove a localized asset id from the asset.
*
* @event
* @example
* asset.on('remove:localized', (locale, assetId) => {
* console.log(`Asset ${asset.name} has removed localized asset ${assetId} for locale ${locale}`);
* });
*/
__publicField(Asset, "EVENT_REMOVELOCALIZED", "remove:localized");
export {
Asset
};