From 69cf2fa32da5f7bf7e55641937a27fac6dfe8d2b Mon Sep 17 00:00:00 2001 From: Kevin Ngo Date: Tue, 8 Mar 2016 17:19:00 -0800 Subject: [PATCH] texture caching (fixes #606) --- src/shaders/standard.js | 37 ++-- src/utils/index.js | 1 + src/utils/texture.js | 297 +++++++++++++++++++++--------- tests/assets/test.mp4 | Bin 0 -> 9177 bytes tests/assets/test.ogg | Bin 0 -> 5321 bytes tests/assets/test.png | Bin 0 -> 7940 bytes tests/assets/test2.mp4 | Bin 0 -> 9177 bytes tests/assets/test2.png | Bin 0 -> 81358 bytes tests/components/material.test.js | 104 ++++++++++- tests/karma.conf.js | 3 +- 10 files changed, 328 insertions(+), 114 deletions(-) create mode 100644 tests/assets/test.mp4 create mode 100644 tests/assets/test.ogg create mode 100644 tests/assets/test.png create mode 100644 tests/assets/test2.mp4 create mode 100644 tests/assets/test2.png diff --git a/src/shaders/standard.js b/src/shaders/standard.js index e8deb6c4235..50131850f98 100755 --- a/src/shaders/standard.js +++ b/src/shaders/standard.js @@ -1,7 +1,7 @@ var registerShader = require('../core/shader').registerShader; var srcLoader = require('../utils/src-loader'); var THREE = require('../lib/three'); -var utils = require('../utils/texture'); +var utils = require('../utils/'); var CubeLoader = new THREE.CubeTextureLoader(); var texturePromises = {}; @@ -67,12 +67,12 @@ module.exports.Component = registerShader('standard', { // Texture added or changed. this.textureSrc = src; srcLoader.validateSrc(src, - utils.loadImage.bind(this, material, data), - utils.loadVideo.bind(this, material, data) + utils.texture.loadImage.bind(this, material, data), + utils.texture.loadVideo.bind(this, material, data) ); } else { // Texture removed. - utils.updateMaterial(material, null); + utils.texture.updateMaterial(material, null); } }, @@ -97,33 +97,36 @@ module.exports.Component = registerShader('standard', { var self = this; var material = this.material; var envMap = data.envMap; - // Environment cubemaps. + + // No envMap defined or already loading. if (!envMap || this.isLoadingEnvMap) { material.envMap = null; material.needsUpdate = true; return; } this.isLoadingEnvMap = true; + + // Another material is already loading this texture. Wait on promise. if (texturePromises[envMap]) { - // Another material is already loading this texture. Wait on promise. texturePromises[envMap].then(function (cube) { self.isLoadingEnvMap = false; material.envMap = cube; material.needsUpdate = true; }); - } else { - // Material is first to load this texture. Load and resolve texture. - texturePromises[envMap] = new Promise(function (resolve) { - srcLoader.validateCubemapSrc(envMap, function loadEnvMap (urls) { - CubeLoader.load(urls, function (cube) { - // Texture loaded. - self.isLoadingEnvMap = false; - material.envMap = cube; - resolve(cube); - }); + return; + } + + // Material is first to load this texture. Load and resolve texture. + texturePromises[envMap] = new Promise(function (resolve) { + srcLoader.validateCubemapSrc(envMap, function loadEnvMap (urls) { + CubeLoader.load(urls, function (cube) { + // Texture loaded. + self.isLoadingEnvMap = false; + material.envMap = cube; + resolve(cube); }); }); - } + }); } }); diff --git a/src/utils/index.js b/src/utils/index.js index 8ef7113aa10..845b7642144 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -6,6 +6,7 @@ var objectAssign = require('object-assign'); module.exports.coordinates = require('./coordinates'); module.exports.debug = require('./debug'); +module.exports.texture = require('./texture'); /** * Fires a custom DOM event. diff --git a/src/utils/texture.js b/src/utils/texture.js index cc786b914ac..3f45f256a94 100755 --- a/src/utils/texture.js +++ b/src/utils/texture.js @@ -1,68 +1,216 @@ +/** + * Texture helpers for standard material component. + * + * @member textureCache {object} - Texture cache for: + * - Images: textureCache has mapping of src -> repeat -> cached three.js texture. + * - Videos: textureCache has mapping of videoElement -> cached three.js texture. + * @member videoCache {object} - Cache of video elements. + */ var debug = require('./debug'); -var error = debug('components:texture:error'); var THREE = require('../lib/three'); + +var EVENTS = { + TEXTURE_LOADED: 'material-texture-loaded' +}; +var error = debug('components:texture:error'); +var textureCache = {}; var TextureLoader = new THREE.TextureLoader(); -var texturePromises = {}; var warn = debug('components:texture:warn'); /** - * Sets image texture on material as `map`. + * High-level function for loading image textures. Meat of logic is in `loadImageTexture`. + * Bound to material component instance and three.js material. + * + * @param material {object} - three.js material, bound by the A-Frame shader. + * @param data {object} - Shader data, bound by the A-Frame shader. + * @param src {Element|string} - Texture source, bound by `src-loader` utils. + */ +function loadImage (material, data, src) { + var el = this.el; + var repeat = data.repeat || '1 1'; + var srcString = src; + + if (typeof src !== 'string') { srcString = src.getAttribute('src'); } + + // Another material is already loading this texture. Wait on promise. + if (textureCache[src] && textureCache[src][repeat]) { + textureCache[src][repeat].then(handleImageTextureLoaded); + return; + } + + // Material instance is first to try to load this texture. Load it. + textureCache[srcString] = textureCache[srcString] || {}; + textureCache[srcString][repeat] = textureCache[srcString][repeat] || {}; + textureCache[srcString][repeat] = loadImageTexture(material, src, repeat); + textureCache[srcString][repeat].then(handleImageTextureLoaded); + + function handleImageTextureLoaded (texture) { + updateMaterial(material, texture); + el.emit(EVENTS.TEXTURE_LOADED, { src: src, texture: texture }); + } +} + +/** + * Load video texture. + * Bound to material component instance and three.js material. + * Note that creating a video texture is more synchronous than creating an image texture. + * + * @param material {object} - three.js material, bound by the A-Frame shader. + * @param data {object} - Shader data, bound by the A-Frame shader. + * @param src {Element|string} - Texture source, bound by `src-loader` utils. + */ +function loadVideo (material, data, src) { + var el = this.el; + var hash; + var texture; + var videoEl; + + if (typeof src !== 'string') { + // Check cache before creating texture. + videoEl = src; + hash = calculateVideoCacheHash(videoEl); + if (textureCache[hash]) { + textureCache[hash].then(handleVideoTextureLoaded); + return; + } + + // If not in cache, fix up the attributes then start to create the texture. + fixVideoAttributes(videoEl); + } + + // Use video element to create texture. + videoEl = videoEl || createVideoEl(material, src, data.width, data.height); + + // Generated video element already cached. Use that. + hash = calculateVideoCacheHash(videoEl); + if (textureCache[hash]) { + textureCache[hash].then(handleVideoTextureLoaded); + return; + } + + // Create new video texture. + texture = new THREE.VideoTexture(videoEl); + texture.minFilter = THREE.LinearFilter; + + // Cache as promise to be consistent with image texture caching. + textureCache[calculateVideoCacheHash(videoEl)] = Promise.resolve(texture, videoEl); + handleVideoTextureLoaded(texture, videoEl); + + function handleVideoTextureLoaded (texture, videoEl) { + updateMaterial(material, texture); + el.emit(EVENTS.TEXTURE_LOADED, { element: videoEl, src: src }); + videoEl.addEventListener('loadeddata', function () { + el.emit('material-video-loadeddata', { element: videoEl, src: src }); + }); + videoEl.addEventListener('ended', function () { + // Works for non-looping videos only. + el.emit('material-video-ended', { element: videoEl, src: src }); + }); + } +} + +/** + * Calculates consistent hash from a video element using its attributes. + * If the video element has an ID, use that. + * Else build a hash that looks like `src:myvideo.mp4;height:200;width:400;`. * + * @param videoEl {Element} - Video element. + * @returns {string} + */ +function calculateVideoCacheHash (videoEl) { + var i; + var id = videoEl.getAttribute('id'); + var hash; + var videoAttributes; + + if (id) { return id; } + + // Calculate hash using sorted video attributes. + hash = ''; + videoAttributes = {}; + for (i = 0; i < videoEl.attributes.length; i++) { + videoAttributes[videoEl.attributes[i].name] = videoEl.attributes[i].value; + } + Object.keys(videoAttributes).sort().forEach(function (name) { + hash += name + ':' + videoAttributes[name] + ';'; + }); + + return hash; +} + +/** + * Set material texture and update if necessary. + * + * @param {object} material + * @param {object} texture + */ +function updateMaterial (material, texture) { + var oldMap = material.map; + if (texture) { texture.needsUpdate = true; } + material.map = texture; + + // Only need to update three.js material if presence or not of texture has changed. + if (oldMap === null && material.map || material.map === null && oldMap) { + material.needsUpdate = true; + } +} + +/** + * Set image texture on material as `map`. + * + * @private + * @param {object} el - Entity element. * @param {object} material - three.js material. * @param {string|object} src - An element or url to an image file. * @param {string} repeat - X and Y value for size of texture repeating (in UV units). + * @returns {Promise} Resolves once texture is loaded. */ function loadImageTexture (material, src, repeat) { - return new Promise(function (resolve, reject) { + return new Promise(doLoadImageTexture); + + function doLoadImageTexture (resolve, reject) { var isEl = typeof src !== 'string'; - var onLoad = createTexture; - var onProgress = function () {}; - var onError = function (xhr) { - error('The URL "$s" could not be fetched (Error code: %s; Response: %s)', - xhr.status, xhr.statusText); - }; + // Create texture from an element. if (isEl) { createTexture(src); - } else { - TextureLoader.load(src, onLoad, onProgress, onError); + return; } + // Load texture from src string. THREE will create underlying element. + // Use THREE.TextureLoader (src, onLoad, onProgress, onError) to load texture. + TextureLoader.load( + src, + createTexture, + function () { /* no-op */ }, + function (xhr) { + error('`$s` could not be fetched (Error code: %s; Response: %s)', xhr.status, + xhr.statusText); + } + ); + + /** + * Texture loaded. Set it. + */ function createTexture (texture) { - if (!(texture instanceof THREE.Texture)) { texture = new THREE.Texture(texture); } var repeatXY; - if (repeat) { - repeatXY = repeat.split(' '); - if (repeatXY.length === 2) { - texture.wrapS = THREE.RepeatWrapping; - texture.wrapT = THREE.RepeatWrapping; - texture.repeat.set(parseInt(repeatXY[0], 10), - parseInt(repeatXY[1], 10)); - } + if (!(texture instanceof THREE.Texture)) { texture = new THREE.Texture(texture); } + + // Handle UV repeat. + repeatXY = repeat.split(' '); + if (repeatXY.length === 2) { + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.repeat.set(parseInt(repeatXY[0], 10), parseInt(repeatXY[1], 10)); } - material.map = texture; - texture.needsUpdate = true; - material.needsUpdate = true; + resolve(texture); } - }); -} - -/** - * Updates material texture - * @param {object} material [description] - * @param {object} texture [description] - */ -function updateMaterial (material, texture) { - if (material.map !== undefined) { - if (texture) { texture.needsUpdate = true; } - material.map = texture; - material.needsUpdate = true; } } /** - * Creates a video element to be used as a texture. + * Create video element to be used as a texture. * * @param {object} material - three.js material. * @param {string} src - Url to a video file. @@ -72,17 +220,15 @@ function updateMaterial (material, texture) { */ function createVideoEl (material, src, width, height) { var el = material.videoEl || document.createElement('video'); - function onError () { - warn('The URL "$s" is not a valid image or video', src); - } el.width = width; el.height = height; - // Attach event listeners if brand new video element. if (el !== this.videoEl) { el.autoplay = true; el.loop = true; el.crossOrigin = true; - el.addEventListener('error', onError, true); + el.addEventListener('error', function () { + warn('`$s` is not a valid video', src); + }, true); material.videoEl = el; } el.src = src; @@ -90,73 +236,40 @@ function createVideoEl (material, src, width, height) { } /** - * Sets video texture on material as map. + * Fixes a video element's attributes to prevent developers from accidentally passing the + * wrong attribute values to commonly misused video attributes. * - * @param {object} material - three.js material. - * @param {string} src - Url to a video file. - * @param {number} width - Width of the video. - * @param {number} height - Height of the video. -*/ -function loadVideoTexture (material, src, height, width) { - // three.js video texture loader requires a