var registerSystem = require('../core/system').registerSystem;
var THREE = require('../lib/three');
var utils = require('../utils/');
var isHLS = require('../utils/material').isHLS;
var bind = utils.bind;
var debug = utils.debug;
var error = debug('components:texture:error');
var TextureLoader = new THREE.TextureLoader();
var warn = debug('components:texture:warn');
TextureLoader.setCrossOrigin('anonymous');
/**
* System for material component.
* Handle material registration, updates (for fog), and texture caching.
*
* @member {object} materials - Registered materials.
* @member {object} textureCounts - Number of times each texture is used. Tracked
* separately from textureCache, because the cache (1) is populated in
* multiple places, and (2) may be cleared at any time.
* @member {object} textureCache - Texture cache for:
* - Images: textureCache has mapping of src -> repeat -> cached three.js texture.
* - Videos: textureCache has mapping of videoElement -> cached three.js texture.
*/
module.exports.System = registerSystem('material', {
init: function () {
this.materials = {};
this.textureCounts = {};
this.textureCache = {};
this.sceneEl.addEventListener(
'materialtextureloaded',
bind(this.onMaterialTextureLoaded, this)
);
},
clearTextureCache: function () {
this.textureCache = {};
},
/**
* Determine whether `src` is a image or video. Then try to load the asset, then call back.
*
* @param {string, or element} src - Texture URL or element.
* @param {string} data - Relevant texture data used for caching.
* @param {function} cb - Callback to pass texture to.
*/
loadTexture: function (src, data, cb) {
var self = this;
// Canvas.
if (src.tagName === 'CANVAS') {
this.loadCanvas(src, data, cb);
return;
}
// Video element.
if (src.tagName === 'VIDEO') {
if (!src.src && !src.srcObject && !src.childElementCount) {
warn('Video element was defined with neither `source` elements nor `src` / `srcObject` attributes.');
}
this.loadVideo(src, data, cb);
return;
}
utils.srcLoader.validateSrc(src, loadImageCb, loadVideoCb);
function loadImageCb (src) { self.loadImage(src, data, cb); }
function loadVideoCb (src) { self.loadVideo(src, data, cb); }
},
/**
* High-level function for loading image textures (THREE.Texture).
*
* @param {Element|string} src - Texture source.
* @param {object} data - Texture data.
* @param {function} cb - Callback to pass texture to.
*/
loadImage: function (src, data, handleImageTextureLoaded) {
var hash = this.hash(data);
var textureCache = this.textureCache;
// Texture already being loaded or already loaded. Wait on promise.
if (textureCache[hash]) {
textureCache[hash].then(handleImageTextureLoaded);
return;
}
// Texture not yet being loaded. Start loading it.
textureCache[hash] = loadImageTexture(src, data);
textureCache[hash].then(handleImageTextureLoaded);
},
/**
* High-level function for loading canvas textures (THREE.Texture).
*
* @param {Element|string} src - Texture source.
* @param {object} data - Texture data.
* @param {function} cb - Callback to pass texture to.
*/
loadCanvas: function (src, data, cb) {
var texture;
texture = new THREE.CanvasTexture(src);
setTextureProperties(texture, data);
cb(texture);
},
/**
* Load video texture (THREE.VideoTexture).
* Which is just an image texture that RAFs + needsUpdate.
* Note that creating a video texture is synchronous unlike loading an image texture.
* Made asynchronous to be consistent with image textures.
*
* @param {Element|string} src - Texture source.
* @param {object} data - Texture data.
* @param {function} cb - Callback to pass texture to.
*/
loadVideo: function (src, data, cb) {
var hash;
var texture;
var textureCache = this.textureCache;
var videoEl;
var videoTextureResult;
function handleVideoTextureLoaded (result) {
result.texture.needsUpdate = true;
cb(result.texture, result.videoEl);
}
// Video element provided.
if (typeof src !== 'string') {
// Check cache before creating texture.
videoEl = src;
hash = this.hashVideo(data, 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);
}
// Only URL provided. Use video element to create texture.
videoEl = videoEl || createVideoEl(src, data.width, data.height);
// Generated video element already cached. Use that.
hash = this.hashVideo(data, videoEl);
if (textureCache[hash]) {
textureCache[hash].then(handleVideoTextureLoaded);
return;
}
// Create new video texture.
texture = new THREE.VideoTexture(videoEl);
texture.minFilter = THREE.LinearFilter;
setTextureProperties(texture, data);
// If iOS and video is HLS, do some hacks.
if (this.sceneEl.isIOS &&
isHLS(videoEl.src || videoEl.getAttribute('src'),
videoEl.type || videoEl.getAttribute('type'))) {
// Actually BGRA. Tell shader to correct later.
texture.format = THREE.RGBAFormat;
texture.needsCorrectionBGRA = true;
// Apparently needed for HLS. Tell shader to correct later.
texture.flipY = false;
texture.needsCorrectionFlipY = true;
}
// Cache as promise to be consistent with image texture caching.
videoTextureResult = {texture: texture, videoEl: videoEl};
textureCache[hash] = Promise.resolve(videoTextureResult);
handleVideoTextureLoaded(videoTextureResult);
},
/**
* Create a hash of the material properties for texture cache key.
*/
hash: function (data) {
if (data.src.tagName) {
// Since `data.src` can be an element, parse out the string if necessary for the hash.
data = utils.extendDeep({}, data);
data.src = data.src.src;
}
return JSON.stringify(data);
},
hashVideo: function (data, videoEl) {
return calculateVideoCacheHash(data, videoEl);
},
/**
* Keep track of material in case an update trigger is needed (e.g., fog).
*
* @param {object} material
*/
registerMaterial: function (material) {
this.materials[material.uuid] = material;
},
/**
* Stop tracking material, and dispose of any textures not being used by
* another material component.
*
* @param {object} material
*/
unregisterMaterial: function (material) {
delete this.materials[material.uuid];
// If any textures on this material are no longer in use, dispose of them.
var textureCounts = this.textureCounts;
Object.keys(material)
.filter(function (propName) {
return material[propName] && material[propName].isTexture;
})
.forEach(function (mapName) {
textureCounts[material[mapName].uuid]--;
if (textureCounts[material[mapName].uuid] <= 0) {
material[mapName].dispose();
}
});
},
/**
* Trigger update to all registered materials.
*/
updateMaterials: function (material) {
var materials = this.materials;
Object.keys(materials).forEach(function (uuid) {
materials[uuid].needsUpdate = true;
});
},
/**
* Track textures used by material components, so that they can be safely
* disposed when no longer in use. Textures must be registered here, and not
* through registerMaterial(), because textures may not be attached at the
* time the material is registered.
*
* @param {Event} e
*/
onMaterialTextureLoaded: function (e) {
if (!this.textureCounts[e.detail.texture.uuid]) {
this.textureCounts[e.detail.texture.uuid] = 0;
}
this.textureCounts[e.detail.texture.uuid]++;
}
});
/**
* 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 data {object} - Texture data such as repeat.
* @param videoEl {Element} - Video element.
* @returns {string}
*/
function calculateVideoCacheHash (data, videoEl) {
var i;
var id = videoEl.getAttribute('id');
var hash;
var videoAttributes;
if (id) { return id; }
// Calculate hash using sorted video attributes.
hash = '';
videoAttributes = data || {};
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;
}
/**
* Load image texture.
*
* @private
* @param {string|object} src - An <img> element or url to an image file.
* @param {object} data - Data to set texture properties like `repeat`.
* @returns {Promise} Resolves once texture is loaded.
*/
function loadImageTexture (src, data) {
return new Promise(doLoadImageTexture);
function doLoadImageTexture (resolve, reject) {
var isEl = typeof src !== 'string';
function resolveTexture (texture) {
setTextureProperties(texture, data);
texture.needsUpdate = true;
resolve(texture);
}
// Create texture from an element.
if (isEl) {
resolveTexture(new THREE.Texture(src));
return;
}
// Request and load texture from src string. THREE will create underlying element.
// Use THREE.TextureLoader (src, onLoad, onProgress, onError) to load texture.
TextureLoader.load(
src,
resolveTexture,
function () { /* no-op */ },
function (xhr) {
error('`$s` could not be fetched (Error code: %s; Response: %s)', xhr.status,
xhr.statusText);
}
);
}
}
/**
* Set texture properties such as repeat and offset.
*
* @param {object} data - With keys like `repeat`.
*/
function setTextureProperties (texture, data) {
var offset = data.offset || {x: 0, y: 0};
var repeat = data.repeat || {x: 1, y: 1};
var npot = data.npot || false;
// To support NPOT textures, wrap must be ClampToEdge (not Repeat),
// and filters must not use mipmaps (i.e. Nearest or Linear).
if (npot) {
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearFilter;
}
// Don't bother setting repeat if it is 1/1. Power-of-two is required to repeat.
if (repeat.x !== 1 || repeat.y !== 1) {
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(repeat.x, repeat.y);
}
// Don't bother setting offset if it is 0/0.
if (offset.x !== 0 || offset.y !== 0) {
texture.offset.set(offset.x, offset.y);
}
}
/**
* Create video element to be used as a texture.
*
* @param {string} src - Url to a video file.
* @param {number} width - Width of the video.
* @param {number} height - Height of the video.
* @returns {Element} Video element.
*/
function createVideoEl (src, width, height) {
var videoEl = document.createElement('video');
videoEl.width = width;
videoEl.height = height;
// Support inline videos for iOS webviews.
videoEl.setAttribute('playsinline', '');
videoEl.setAttribute('webkit-playsinline', '');
videoEl.autoplay = true;
videoEl.loop = true;
videoEl.crossOrigin = 'anonymous';
videoEl.addEventListener('error', function () {
warn('`$s` is not a valid video', src);
}, true);
videoEl.src = src;
return videoEl;
}
/**
* Fixes a video element's attributes to prevent developers from accidentally passing the
* wrong attribute values to commonly misused video attributes.
*
* <video> does not treat `autoplay`, `controls`, `crossorigin`, `loop`, and `preload` as
* as booleans. Existence of those attributes will mean truthy.
*
* For example, translates <video loop="false"> to <video>.
*
* @see https://developer.mozilla.org/docs/Web/HTML/Element/video#Attributes
* @param {Element} videoEl - Video element.
* @returns {Element} Video element with the correct properties updated.
*/
function fixVideoAttributes (videoEl) {
videoEl.autoplay = videoEl.hasAttribute('autoplay') && videoEl.getAttribute('autoplay') !== 'false';
videoEl.controls = videoEl.hasAttribute('controls') && videoEl.getAttribute('controls') !== 'false';
if (videoEl.getAttribute('loop') === 'false') {
videoEl.removeAttribute('loop');
}
if (videoEl.getAttribute('preload') === 'false') {
videoEl.preload = 'none';
}
videoEl.crossOrigin = videoEl.crossOrigin || 'anonymous';
// To support inline videos in iOS webviews.
videoEl.setAttribute('playsinline', '');
videoEl.setAttribute('webkit-playsinline', '');
return videoEl;
}