UNPKG

tangram

Version:
548 lines (474 loc) 20.6 kB
// Texture management import log from '../utils/log'; import Utils from '../utils/utils'; import subscribeMixin from '../utils/subscribe'; import WorkerBroker from '../utils/worker_broker'; // GL texture wrapper object for keeping track of a global set of textures, keyed by a unique user-defined name export default class Texture { constructor(gl, name, options = {}) { options = Texture.sliceOptions(options); // exclude any non-texture-specific props this.gl = gl; this.texture = gl.createTexture(); if (this.texture) { this.valid = true; } this.bind(); this.name = name; this.retain_count = 0; this.config_type = null; this.loading = null; // a Promise object to track the loading state of this texture this.loaded = false; // successfully loaded as expected this.filtering = options.filtering; this.density = options.density || 1; // native pixel density of texture this.sprites = options.sprites; this.texcoords = {}; // sprite UVs ([0, 1] range) this.sizes = {}; // sprite sizes (pixel size) this.css_sizes = {}; // sprite sizes, adjusted for native texture pixel density this.aspects = {}; // sprite aspect ratios // Default to a 1-pixel transparent black texture so we can safely render while we wait for an image to load // See: http://stackoverflow.com/questions/19722247/webgl-wait-for-texture-to-load this.setData(1, 1, new Uint8Array([0, 0, 0, 0]), { filtering: 'nearest' }); this.loaded = false; // don't consider loaded when only placeholder data is present // Destroy previous texture if present if (Texture.textures[this.name]) { // Preserve previous retain count this.retain_count = Texture.textures[this.name].retain_count; Texture.textures[this.name].retain_count = 0; // allow to be freed Texture.textures[this.name].destroy(); } // Cache texture instance and definition Texture.textures[this.name] = this; Texture.texture_configs[this.name] = JSON.stringify(Object.assign({ name }, options)); this.load(options); log('trace', `creating Texture ${this.name}`); } // Destroy a single texture instance destroy({ force } = {}) { if (this.retain_count > 0 && !force) { log('error', `Texture '${this.name}': destroying texture with retain count of '${this.retain_count}'`); return; } if (!this.valid) { return; } this.gl.deleteTexture(this.texture); this.texture = null; if (Texture.textures[this.name] === this) { delete Texture.textures[this.name]; delete Texture.texture_configs[this.name]; } this.valid = false; log('trace', `destroying Texture ${this.name}`); } retain () { this.retain_count++; } release () { if (this.retain_count <= 0) { log('error', `Texture '${this.name}': releasing texture with retain count of '${this.retain_count}'`); } this.retain_count--; if (this.retain_count <= 0) { this.destroy(); } } bind(unit = 0) { if (!this.valid) { return; } if (Texture.activeUnit !== unit) { this.gl.activeTexture(this.gl.TEXTURE0 + unit); Texture.activeUnit = unit; Texture.boundTexture = null; // texture must be re-bound when unit changes } if (Texture.boundTexture !== this.texture) { this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture); Texture.boundTexture = this.texture; } } load(options) { if (!options) { return this.loading || Promise.resolve(this); } this.loading = null; if (typeof options.url === 'string') { this.config_type = 'url'; this.setUrl(options.url, options); } else if (options.element) { this.config_type = 'element'; this.setElement(options.element, options); } else if (options.data && options.width && options.height) { this.config_type = 'data'; this.setData(options.width, options.height, options.data, options); } this.loading = (this.loading && this.loading.then(() => { this.calculateSprites(); return this; })) || Promise.resolve(this); return this.loading; } // Sets texture from an url setUrl(url, options = {}) { if (!this.valid) { return; } this.url = url; // save URL reference (will be overwritten when element is loaded below) this.loading = new Promise(resolve => { let image = new Image(); image.onload = () => { try { // For data URL images, first draw the image to a separate canvas element. Workaround for // obscure bug seen with small (<28px) SVG images encoded as data URLs in Chrome and Safari. if (this.url.slice(0, 5) === 'data:') { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = image.width; canvas.height = image.height; ctx.drawImage(image, 0, 0); this.setElement(canvas, options); } else { this.setElement(image, options); } } catch (e) { this.loaded = false; log('warn', `Texture '${this.name}': failed to load url: '${this.url}'`, e, options); Texture.trigger('warning', { message: `Failed to load texture from ${this.url}`, error: e, texture: options }); } this.loaded = true; resolve(this); }; image.onerror = e => { // Warn and resolve on error this.loaded = false; log('warn', `Texture '${this.name}': failed to load url: '${this.url}'`, e, options); Texture.trigger('warning', { message: `Failed to load texture from ${this.url}`, error: e, texture: options }); resolve(this); }; // Safari has a bug loading data-URL images with CORS enabled, so it must be disabled in that case // https://bugs.webkit.org/show_bug.cgi?id=123978 if (!(Utils.isSafari() && this.url.slice(0, 5) === 'data:')) { image.crossOrigin = 'anonymous'; } image.src = this.url; }); return this.loading; } // Sets texture to a raw image buffer setData(width, height, data, options = {}) { this.width = width; this.height = height; // Convert regular array to typed array if (Array.isArray(data)) { data = new Uint8Array(data); } this.update(data, options); this.setFiltering(options); this.loaded = true; this.loading = Promise.resolve(this); return this.loading; } // Sets the texture to track a element (canvas/image) setElement(element, options) { let el = element; // a string element is interpeted as a CSS selector if (typeof element === 'string') { element = document.querySelector(element); } if (element instanceof HTMLCanvasElement || element instanceof HTMLImageElement || element instanceof HTMLVideoElement) { this.update(element, options); this.setFiltering(options); } else { this.loaded = false; let msg = `the 'element' parameter (\`element: ${JSON.stringify(el)}\`) must be a CSS `; msg += 'selector string, or a <canvas>, <image> or <video> object'; log('warn', `Texture '${this.name}': ${msg}`, options); Texture.trigger('warning', { message: `Failed to load texture because ${msg}`, texture: options }); } this.loaded = true; this.loading = Promise.resolve(this); return this.loading; } // Uploads current image or buffer to the GPU (can be used to update animated textures on the fly) update(source, options = {}) { if (!this.valid) { return; } this.bind(); // Image or Canvas element if (source instanceof HTMLCanvasElement || source instanceof HTMLVideoElement || (source instanceof HTMLImageElement && source.complete)) { this.width = source.width; this.height = source.height; this.gl.pixelStorei(this.gl.UNPACK_FLIP_Y_WEBGL, (options.UNPACK_FLIP_Y_WEBGL === false ? false : true)); this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, options.UNPACK_PREMULTIPLY_ALPHA_WEBGL || false); this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, source); } // Raw image buffer else { // these pixel store params are deprecated for non-DOM element uploads // (e.g. when creating texture from raw data) // setting them to null avoids a Firefox warning this.gl.pixelStorei(this.gl.UNPACK_FLIP_Y_WEBGL, null); this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, null); this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.width, this.height, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, source); } Texture.trigger('update', this); } // Determines appropriate filtering mode setFiltering(options = {}) { if (!this.valid) { return; } options.filtering = options.filtering || 'linear'; var gl = this.gl; this.bind(); // For power-of-2 textures, the following presets are available: // mipmap: linear blend from nearest mip // linear: linear blend from original image (no mips) // nearest: nearest pixel from original image (no mips, 'blocky' look) if (Utils.isPowerOf2(this.width) && Utils.isPowerOf2(this.height)) { this.power_of_2 = true; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, options.TEXTURE_WRAP_S || (options.repeat && gl.REPEAT) || gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, options.TEXTURE_WRAP_T || (options.repeat && gl.REPEAT) || gl.CLAMP_TO_EDGE); if (options.filtering === 'mipmap') { this.filtering = 'mipmap'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); // TODO: use trilinear filtering by defualt instead? gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.generateMipmap(gl.TEXTURE_2D); } else if (options.filtering === 'linear') { this.filtering = 'linear'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); } else if (options.filtering === 'nearest') { this.filtering = 'nearest'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); } } else { // WebGL has strict requirements on non-power-of-2 textures: // No mipmaps and must clamp to edge this.power_of_2 = false; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); if (options.filtering === 'nearest') { this.filtering = 'nearest'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); } else { // default to linear for non-power-of-2 textures this.filtering = 'linear'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); } } Texture.trigger('update', this); } // Pre-calc sprite regions for a texture sprite in UV [0, 1] space calculateSprites() { if (this.sprites) { for (let s in this.sprites) { let sprite = this.sprites[s]; // Map [0, 0] to [1, 1] coords to the appropriate sprite sub-area of the texture this.texcoords[s] = Texture.getTexcoordsForSprite( [sprite[0], sprite[1]], [sprite[2], sprite[3]], [this.width, this.height] ); // Pixel size of sprite // Divide by native texture density to get correct CSS pixels this.sizes[s] = [sprite[2], sprite[3]]; this.css_sizes[s] = [sprite[2] / this.density, sprite[3] / this.density]; this.aspects[s] = sprite[2] / sprite[3]; } } } // Get the tetxure size in bytes byteSize() { // mipmaps use 33% additional memory return Math.round(this.width * this.height * 4 * (this.filtering == 'mipmap' ? 1.33 : 1)); } } // Static/class methods and state Texture.create = function (gl, name, options) { return new Texture(gl, name, options); }; Texture.retain = function (name) { if (Texture.textures[name]) { Texture.textures[name].retain(); } }; Texture.release = function (name) { if (Texture.textures[name]) { Texture.textures[name].release(); } }; // Destroy all texture instances for a given GL context Texture.destroy = function (gl) { var textures = Object.keys(Texture.textures); textures.forEach(t => { var texture = Texture.textures[t]; if (texture.gl === gl) { texture.destroy({ force: true }); } }); }; // Get sprite pixel size and UVs Texture.getSpriteInfo = function (texname, sprite) { let texture = Texture.textures[texname]; return texture && { size: texture.sizes[sprite], css_size: texture.css_sizes[sprite], aspect: texture.aspects[sprite], texcoords: texture.texcoords[sprite] }; }; // Re-scale UVs from [0, 1] range to a smaller area within the image Texture.getTexcoordsForSprite = function (area_origin, area_size, tex_size) { var area_origin_y = tex_size[1] - area_origin[1] - area_size[1]; return [ area_origin[0] / tex_size[0], area_origin_y / tex_size[1], (area_size[0] + area_origin[0]) / tex_size[0], (area_size[1] + area_origin_y) / tex_size[1] ]; }; // Create a set of textures keyed in an object // Optionally load each if it has a URL specified Texture.createFromObject = function (gl, textures) { let loading = []; if (textures) { for (let texname in textures) { let config = textures[texname]; if (config.skip_create) { // explicitly skip (re-)creating this texture // used for dynamic canvas textures that we *know* haven't changed // (internal raster tiles, vs. user-supplied canvas where pixels may have changed) continue; } // If texture already exists and definition hasn't changed, no need to re-create // Note: to avoid flicker when other textures/scene items change if (!Texture.changed(texname, config)) { continue; } let texture = Texture.create(gl, texname, config); loading.push(texture.loading); } } return Promise.all(loading); }; // Create a 'default' texture (1x1 pixel) that can be used as a placeholder // (for example to prevent GL from complaining about unbound textures) Texture.default = '__default'; Texture.createDefault = function (gl) { return Texture.create(gl, Texture.default); }; // Only include texture-specific properties (avoid faulty equality comparisons between // textures when caller may include other ancillary props) Texture.sliceOptions = function(options) { return { filtering: options.filtering, sprites: options.sprites, url: options.url, element: options.element, data: options.data, width: options.width, height: options.height, density: options.density, repeat: options.repeat, TEXTURE_WRAP_S: options.TEXTURE_WRAP_S, TEXTURE_WRAP_T: options.TEXTURE_WRAP_T, UNPACK_FLIP_Y_WEBGL: options.UNPACK_FLIP_Y_WEBGL, UNPACK_PREMULTIPLY_ALPHA_WEBGL: options.UNPACK_PREMULTIPLY_ALPHA_WEBGL }; }; // Indicate if a texture definition would be a change from the current cache Texture.changed = function (name, config) { let texture = Texture.textures[name]; if (texture) { // cached texture // canvas/image-based textures are considered dynamic and always refresh if (texture.config_type === 'element' || config.element != null) { return true; } // compare definitions config = Texture.sliceOptions(config); // exclude any non-texture-specific props if (Texture.texture_configs[name] === JSON.stringify(Object.assign({ name }, config))) { return false; } } return true; }; // Get metadata for a texture by name // Returns via promise, in case texture is still loading // Can be called on main thread from worker, to sync texture info to worker Texture.getInfo = function (name) { // Get info for all textures by default if (!name) { name = Object.keys(Texture.textures); } // Get multiple textures if (Array.isArray(name)) { return Promise.all(name.map(n => Texture.getInfo(n))); } // Get single texture var tex = Texture.textures[name]; if (tex) { // Wait for this texture to finish loading, or return immediately var loading = tex.loading || Promise.resolve(tex); return loading.then(() => { // Return a subset of texture info // (compatible w/structured cloning, suitable for passing to a worker) return { name: tex.name, width: tex.width, height: tex.height, density: tex.density, css_size: [ tex.width / tex.density, tex.height / tex.density ], aspect: tex.width / tex.height, sprites: tex.sprites, texcoords: tex.texcoords, sizes: tex.sizes, css_sizes: tex.css_sizes, aspects: tex.aspects, filtering: tex.filtering, power_of_2: tex.power_of_2, valid: tex.valid }; }); } else { // No texture found return Promise.resolve(null); } }; // Sync texture info to worker // Called from worker, gets info on one or more textures info from main thread via remote call, then stores it // locally in worker. 'textures' can be an array of texture names to sync, or if null, all textures are synced. Texture.syncTexturesToWorker = function (names) { return WorkerBroker.postMessage('Texture.getInfo', names). then(textures => { if (textures) { textures .filter(x => x) // remove nulls .forEach(t => Texture.textures[t.name] = t); } return Texture.textures; }); }; // Report max texture size for a GL context Texture.getMaxTextureSize = function (gl) { return gl.getParameter(gl.MAX_TEXTURE_SIZE); }; // Global set of textures, by name Texture.textures = {}; Texture.texture_configs = {}; Texture.boundTexture = null; Texture.activeUnit = null; WorkerBroker.addTarget('Texture', Texture); subscribeMixin(Texture);