aframe-html-shader
Version:
A shader to render DOM Element for A-Frame VR.
443 lines (375 loc) • 10.3 kB
JavaScript
/**
* 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
},
})