UNPKG

aframe-html-shader

Version:

A shader to render DOM Element for A-Frame VR.

443 lines (375 loc) 10.3 kB
/** * A shader to render HTML DOM Element * Inspired by @scenevr's `htmltexture-component` * @see https://github.com/scenevr/htmltexture-component */ import html2canvas from './lib/html2canvas/core' if (typeof AFRAME === 'undefined') { throw 'Component attempted to register before AFRAME was available.' } /* get util from AFRAME */ const { debug } = AFRAME.utils // debug.enable('shader:html:*') debug.enable('shader:html:warn') const warn = debug('shader:html:warn') const log = debug('shader:html:debug') /* create error message */ function createError (err, target) { return { status: 'error', target: target, message: err, timestamp: Date.now() } } AFRAME.registerShader('html', { /** * For material component: * @see https://github.com/aframevr/aframe/blob/60d198ef8e2bfbc57a13511ae5fca7b62e01691b/src/components/material.js * For example of `registerShader`: * @see https://github.com/aframevr/aframe/blob/41a50cd5ac65e462120ecc2e5091f5daefe3bd1e/src/shaders/flat.js * For MeshBasicMaterial * @see http://threejs.org/docs/#Reference/Materials/MeshBasicMaterial */ schema: { /* For material */ color: { type: 'color' }, fog: { default: true }, /* For texuture */ target: { default: null }, debug: { default: null }, fps: { type: 'number', default: 0 }, width: { default: null }, height: { default: null }, ratio: { default: null }, }, /** * Initialize material. Called once. * @protected */ init (data) { log('init', data) this.__cnv = document.createElement('canvas') this.__cnv.width = 2 this.__cnv.height = 2 this.__ctx = this.__cnv.getContext('2d') this.__texture = new THREE.Texture(this.__cnv) this.__reset() this.material = new THREE.MeshBasicMaterial({ map: this.__texture }) this.el.sceneEl.addBehavior(this) return this.material }, /** * Update or create material. * @param {object|null} oldData */ update (oldData) { log('update', oldData) this.__updateMaterial(oldData) this.__updateTexture(oldData) return this.material }, /** * Called on each scene tick. * @protected */ tick (t) { if (this.__paused || !this.__target || !this.__nextTime) { return } const now = Date.now() if (now > this.__nextTime) { this.__render() } }, /*================================ = material = ================================*/ /** * Updating existing material. * @param {object} data - Material component data. */ __updateMaterial (data) { const { material } = this const newData = this.__getMaterialData(data) Object.keys(newData).forEach(key => { material[key] = newData[key] }) }, /** * Builds and normalize material data, normalizing stuff along the way. * @param {Object} data - Material data. * @return {Object} data - Processed material data. */ __getMaterialData (data) { return { fog: data.fog, color: new THREE.Color(data.color), } }, /*============================== = texure = ==============================*/ /** * set texure * @private * @param {Object} data * @property {string} status - success / error * @property {string} target - target url * @property {DOM Element} targetEl - target * @property {Date} timestamp - created at the texure */ __setTexure (data) { log('__setTexure', data) if (data.status === 'error') { warn(`Error: ${data.message}\ntarget: ${data.target}`) this.__reset() } else if (data.status === 'success' && data.target !== this.__textureSrc) { /* Texture added or changed */ this.__ready(data) } }, /** * Update or create texure. * @param {Object} data - Material component data. */ __updateTexture (data) { const { target, fps, width, height, ratio } = data this.__width = width || this.schema.width.default this.__height = height || this.schema.height.default /* debug */ const resetDebug = () => { if (this.__debugEl) { this.__debugEl.innerHTML = '' this.__debugEl = this.schema.debug.default } } if (data.debug) { const el = this.__validateAndGetQuerySelector(data.debug) if (el && !el.error) { this.__debugEl = el } else resetDebug() } else resetDebug() /* ratio */ if(ratio && ratio === 'width' || ratio === 'height') { this.__ratio = ratio } else { this.__ratio = this.schema.ratio.default } /* fps */ if (fps) { if (this.__fps > 0) { this.__fps = fps } else if (fps === -1) { /* render only once */ this.__fps = this.schema.fps.default if (this.__target) { this.__render() } /* set attribute */ const material = Object.assign({}, this.el.getAttribute('material')) delete material.fps this.el.setAttribute('material', material) } else { this.__fps = fps if (this.__target) { this.play() this.__render() } } } else { if (this.__fps > 0) { this.pause() } else { this.__fps = this.schema.fps.default } } /* target */ if (target) { if (target === this.__target) { return } this.__target = target // return this.__validateSrc(target, this.__setTexure.bind(this)) } else { /* Texture removed */ this.__reset() } }, /*============================================= = varidation for texure = =============================================*/ /** * varidate src * @private * @param {string} target - dom selector * @param {Function} cb - callback */ __validateSrc (target, cb) { let message /* check if target is a query selector */ const el = this.__validateAndGetQuerySelector(target) if (!el || typeof el !== 'object') { return } if (el.error) { message = el.error } else { const tagName = el.tagName.toLowerCase() if (tagName === 'img' || tagName === 'video') { message = `For <${tagName}> element, please use \`shader:flat\`` } else { cb({ status: 'success', target: target, targetEl: el, timestamp: Date.now() }) } } /* if there is message, create error data */ if (message) { const err = createError(message, target) cb(err) } }, /** * Query and validate a query selector, * * @param {string} selector - DOM selector. * @return {object} Selected DOM element | error message object. */ __validateAndGetQuerySelector (selector) { try { var el = document.querySelector(selector) if (!el) { return { error: 'No element was found matching the selector' } } return el } catch (e) { // Capture exception if it's not a valid selector. return { error: 'no valid selector' } } }, /*================================ = playback = ================================*/ /** * Pause video * @public */ pause () { log('pause') this.__paused = true this.__nextTime = null }, /** * Play video * @public */ play () { log('play') this.__paused = false }, /** * Toggle playback. play if paused and pause if played. * @public */ togglePlayback () { if (this.paused()) { this.play() } else { this.pause() } }, /** * Return if the playback is paused. * @public * @return {boolean} */ paused () { return this.__paused }, /*============================== = canvas = ==============================*/ /** * clear canvas * @private */ __clearCanvas () { if (!this.__ctx || !this.__texture) { return } this.__ctx.clearRect(0, 0, this.__width, this.__height) this.__texture.needsUpdate = true }, /** * draw * @private */ __draw (canvas) { log('__draw') if (!this.__ctx || !this.__texture) { return } const ratio = canvas.width / canvas.height const cnvW = this.__cnv.width = THREE.Math.nearestPowerOfTwo(canvas.width) const cnvH = this.__cnv.height = THREE.Math.nearestPowerOfTwo(canvas.height) this.__ctx.drawImage(canvas, 0, 0, cnvW, cnvH) this.__texture.needsUpdate = true if (this.__ratio) { /* change size */ const { width, height } = this.el.getObject3D('mesh').geometry.metadata.parameters this.el.setAttribute('geometry', Object.assign({}, this.el.getAttribute('geometry'), { width: (this.__ratio === 'width')? width : height * ratio, height: (this.__ratio === 'width')? width / ratio : height })) } /* append if debug element exists */ if (this.__debugEl) { this.__debugEl.innerHTML = '' this.__debugEl.appendChild(canvas) } /* setup next tick */ this.__setNextTick() }, /** * render * @private */ __render () { this.__nextTime = null if (!this.__targetEl) { return } const { width, height } = this.__targetEl.getBoundingClientRect() html2canvas(this.__targetEl, { background: undefined, width: this.__width || width, height: this.__height || height, onrendered: this.__draw.bind(this) }) }, /** * get next time to draw * @private */ __setNextTick () { if (this.__fps > 0) { this.__nextTime = Date.now() + (1000 / this.__fps) } }, /*============================ = ready = ============================*/ /** * setup html animation and play if autoplay is true * @private * @property {string} target - target url * @property {DOM Element} targetEl - target */ __ready ({ target, targetEl }) { log('__ready') this.__target = target this.__targetEl = targetEl this.play() this.__render() }, /*============================= = reset = =============================*/ /** * @private */ __reset () { this.pause() this.__clearCanvas() this.__target = null this.__targetEl = null this.__debugEl = null }, })