UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

558 lines (555 loc) 20 kB
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'; /** * @import { AssetRegistry } from './asset-registry.js' * @import { ResourceLoaderCallback } from '../handlers/loader.js' */ // auto incrementing number for asset ids 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' ]; /** * @callback AssetReadyCallback * Callback used by {@link Asset#ready} and called when an asset is ready. * @param {Asset} asset - The ready asset. * @returns {void} */ /** * An asset record of a file or data resource that can be loaded by the engine. The asset contains * four important fields: * * - `file`: contains the details of a file (filename, url) which contains the resource data, e.g. * an image file for a texture asset. * - `data`: contains a JSON blob which contains either the resource data for the asset (e.g. * material data) or additional data for the file (e.g. material mappings for a model). * - `options`: contains a JSON blob with handler-specific load options. * - `resource`: contains the final resource when it is loaded. (e.g. a {@link StandardMaterial} or * a {@link Texture}). * * See the {@link AssetRegistry} for details on loading resources from assets. * * @category Asset */ class Asset extends EventHandler { static{ /** * Fired when the asset has completed loading. * * @event * @example * asset.on('load', (asset) => { * console.log(`Asset loaded: ${asset.name}`); * }); */ this.EVENT_LOAD = 'load'; } static{ /** * 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}`); * }); */ this.EVENT_UNLOAD = 'unload'; } static{ /** * Fired when the asset is removed from the asset registry. * * @event * @example * asset.on('remove', (asset) => { * console.log(`Asset removed: ${asset.name}`); * }); */ this.EVENT_REMOVE = 'remove'; } static{ /** * Fired if the asset encounters an error while loading. * * @event * @example * asset.on('error', (err, asset) => { * console.error(`Error loading asset ${asset.name}: ${err}`); * }); */ this.EVENT_ERROR = 'error'; } static{ /** * 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}`); * }); */ this.EVENT_CHANGE = 'change'; } static{ /** * 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}`); * }); */ this.EVENT_PROGRESS = 'progress'; } static{ /** * 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}`); * }); */ this.EVENT_ADDLOCALIZED = 'add:localized'; } static{ /** * 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}`); * }); */ this.EVENT_REMOVELOCALIZED = 'remove:localized'; } /** * 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 */ this._file = null, /** * A string-assetId dictionary that maps locale to asset id. * * @type {object} * @private */ this._i18n = {}, /** * Whether to preload the asset. * * @type {boolean} * @private */ this._preload = false, /** * This is where the loaded resource(s) are stored. * * @type {object[]} * @private */ this._resources = [], /** * The asset id. * * @type {number} */ 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. * * @type {boolean} */ this.loaded = false, /** * True if the resource is currently being loaded. * * @type {boolean} */ this.loading = false, /** * Optional JSON data that contains the asset handler options. * * @type {object} */ this.options = {}, /** * The asset registry that this Asset belongs to. * * @type {AssetRegistry|null} */ this.registry = null, /** * Asset tags. Enables finding of assets by tags using the {@link AssetRegistry#findByTag} method. * * @type {Tags} */ this.tags = new Tags(this), /** * The URL object. * * @type {string | null} * @ignore */ 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 contains variants, choose the correct variant first if (value && value.variants && [ 'texture', 'textureatlas', 'bundle' ].indexOf(this.type) !== -1) { // search for active variant 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 the device supports the variant if (value.variants[variant] && device[VARIANT_SUPPORT[variant]]) { value = value.variants[variant]; break; } // if the variant does not exist but the asset is in a bundle // and the bundle contain assets with this variant then return the default // file for the asset 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) { // fire change event when data changes // because the asset might need reloading if that happens 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 set up. * * @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; // the loadFaces property should be part of the asset data block // because changing the flag should result in asset patch being invoked. // here we must invoke it manually instead. 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 = "&lt;img src='" + assets[0].getFileUrl() + "'&gt;"; */ 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; } // add file hash to avoid hard-caching problems 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) { // tries to find either the desired locale or a fallback 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() { // no need to be reloaded 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; } // clear resources on the asset this.resources = []; this.loaded = false; // remove resource from loader cache if (this.file) { this.registry._loader.clearCache(this.getFileUrl(), this.type); } // destroy resources 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) { // asset file contents were provided setTimeout(()=>{ callback(null, asset.file.contents); }); } else { // asset contents must be downloaded http.get(loadUrl, { cache: true, responseType: 'arraybuffer', retry: maxRetries > 0, maxRetries: maxRetries, progress: asset }, callback); } } } export { Asset };