UNPKG

kampos

Version:

Tiny and fast effects compositor on WebGL

966 lines (834 loc) 24.7 kB
const LUMA_COEFFICIENT = 'const vec3 lumcoeff = vec3(0.2125, 0.7154, 0.0721);'; const MATH_PI = `const float PI = ${Math.PI};`; const DEBUG = false; const vertexSimpleTemplate = ({ uniform = '', attribute = '', varying = '', constant = '', main = '', }) => ` precision highp float; ${uniform} ${attribute} attribute vec2 a_position; ${varying} ${LUMA_COEFFICIENT} ${MATH_PI} ${constant} void main() { ${main} gl_Position = vec4(a_position.xy, 0.0, 1.0); }`; const vertexMediaTemplate = ({ uniform = '', attribute = '', varying = '', constant = '', main = '', }) => ` precision highp float; ${uniform} ${attribute} attribute vec2 a_texCoord; attribute vec2 a_position; ${varying} varying vec2 v_texCoord; ${LUMA_COEFFICIENT} ${MATH_PI} ${constant} void main() { v_texCoord = a_texCoord; ${main} gl_Position = vec4(a_position.xy, 0.0, 1.0); }`; const fragmentSimpleTemplate = ({ uniform = '', varying = '', constant = '', main = '', source = '', }) => ` precision highp float; ${varying} ${uniform} ${LUMA_COEFFICIENT} ${MATH_PI} ${constant} void main() { ${source} vec3 color = vec3(0.0); float alpha = 1.0; ${main} gl_FragColor = vec4(color, 1.0) * alpha; }`; const fragmentMediaTemplate = ({ uniform = '', varying = '', constant = '', main = '', source = '', }) => ` precision highp float; ${varying} varying vec2 v_texCoord; ${uniform} uniform sampler2D u_source; ${LUMA_COEFFICIENT} ${MATH_PI} ${constant} void main() { vec2 sourceCoord = v_texCoord; ${source} vec4 pixel = texture2D(u_source, sourceCoord); vec3 color = pixel.rgb; float alpha = pixel.a; ${main} gl_FragColor = vec4(color, 1.0) * alpha; }`; const TEXTURE_WRAP = { stretch: 'CLAMP_TO_EDGE', repeat: 'REPEAT', mirror: 'MIRRORED_REPEAT', }; const SHADER_ERROR_TYPES = { vertex: 'VERTEX', fragment: 'FRAGMENT', }; /** * Initialize a compiled WebGLProgram for the given canvas and effects. * * @private * @param {Object} config * @param {WebGLRenderingContext} config.gl * @param {Object} config.plane * @param {Object[]} config.effects * @param {{width: number, heignt: number}} [config.dimensions] * @param {fboConfig} [config.fbo] * @param {boolean} [config.noSource] * @return {{gl: WebGLRenderingContext, data: kamposSceneData, [dimensions]: {width: number, height: number}, [fboData]: fboSceneData}} */ export function init({ gl, plane, effects, dimensions, noSource, fbo }) { const hasFBO = !!fbo; const programData = _initProgram(gl, plane, effects, hasFBO, noSource); let fboData if (hasFBO) { fboData = _initFBOProgram(gl, plane, fbo); } return { gl, data: programData, dimensions: dimensions || {}, fboData }; } let WEBGL_CONTEXT_SUPPORTED = false; /** * Get a webgl context for the given canvas element. * * Will return `null` if can not get a context. * * @private * @param {HTMLCanvasElement} canvas * @return {WebGLRenderingContext|null} */ export function getWebGLContext(canvas) { let context; const config = { preserveDrawingBuffer: false, // should improve performance - https://stackoverflow.com/questions/27746091/preservedrawingbuffer-false-is-it-worth-the-effort antialias: false, // should improve performance depth: false, // turn off for explicitness - and in some cases perf boost stencil: false, // turn off for explicitness - and in some cases perf boost }; context = canvas.getContext('webgl', config); if (context) { WEBGL_CONTEXT_SUPPORTED = true; } else if (!WEBGL_CONTEXT_SUPPORTED) { context = canvas.getContext('experimental-webgl', config); } else { return null; } return context; } /** * Resize the target canvas. * * @private * @param {WebGLRenderingContext} gl * @param {{width: number, height: number}} [dimensions] * @return {boolean} */ export function resize(gl, dimensions) { const canvas = gl.canvas; const realToCSSPixels = 1; //window.devicePixelRatio; const { width, height } = dimensions || {}; let displayWidth, displayHeight; if (width && height) { displayWidth = width; displayHeight = height; } else { // Lookup the size the browser is displaying the canvas. displayWidth = Math.floor(canvas.clientWidth * realToCSSPixels); displayHeight = Math.floor(canvas.clientHeight * realToCSSPixels); } // Check if the canvas is not the same size. if (canvas.width !== displayWidth || canvas.height !== displayHeight) { // Make the canvas the same size canvas.width = displayWidth; canvas.height = displayHeight; } gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); } /** * Draw a given scene * * @private * @param {WebGLRenderingContext} gl * @param {planeConfig} plane * @param {ArrayBufferView|ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement|ImageBitmap} media * @param {kamposSceneData} data * @param {fboSceneData} [fboData] */ export function draw(gl, plane = {}, media, data, fboData) { if (fboData) { drawFBO(gl, fboData); } const { program, source, attributes, uniforms, textures, extensions, vao } = data; const { xSegments = 1, ySegments = 1 } = plane; if (media && source && source.texture && (source.shouldUpdate || !source._sampled)) { source._sampled = true; gl.bindTexture(gl.TEXTURE_2D, source.texture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, media, ); } gl.useProgram(program); // resize back to default viewport if (fboData) { gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); } if (vao) { extensions.vao.bindVertexArrayOES(vao); } else { _enableVertexAttributes(gl, attributes); } _setUniforms(gl, uniforms); let startTex = gl.TEXTURE0; if (fboData) { // bind fbo texture gl.activeTexture(startTex); gl.bindTexture(gl.TEXTURE_2D, fboData.oldInfo.texture); gl.uniform1i(gl.getUniformLocation(program, 'u_FBOMap'), 0); startTex++; } if (source) { gl.activeTexture(startTex); gl.bindTexture(gl.TEXTURE_2D, source.texture); startTex++; } if (textures) { for (let i = 0; i < textures.length; i++) { gl.activeTexture(startTex + i); const tex = textures[i]; gl.bindTexture(gl.TEXTURE_2D, tex.texture); if (tex.update) { gl.texImage2D( gl.TEXTURE_2D, 0, gl[tex.format], gl[tex.format], gl.UNSIGNED_BYTE, tex.data, ); } } } gl.drawArrays(gl.TRIANGLES, 0, 6 * xSegments * ySegments); } function drawFBO(gl, fboData) { const { size, program, uniforms } = fboData; gl.useProgram(program); gl.viewport(0, 0, size, size); // write in new fb gl.bindFramebuffer(gl.FRAMEBUFFER, fboData.newInfo.buffer); // read old texture gl.bindTexture(gl.TEXTURE_2D, fboData.oldInfo.texture); // // Set uniforms _setUniforms(gl, uniforms); gl.drawArrays(gl.TRIANGLES, 0, 6); // Swap textures and framebuffers { const temp = fboData.oldInfo; fboData.oldInfo = fboData.newInfo; fboData.newInfo = temp; } // clear framebuffer gl.bindFramebuffer(gl.FRAMEBUFFER, null); } /** * Free all resources attached to a specific webgl context. * * @private * @param {WebGLRenderingContext} gl * @param {kamposSceneData | fboSceneData} data */ export function destroy(gl, data) { const { program, vertexShader, fragmentShader, source, attributes, extensions, vao, oldInfo, newInfo, } = data; // delete buffers (attributes || []).forEach((attr) => gl.deleteBuffer(attr.buffer)); if (vao) extensions.vao.deleteVertexArrayOES(vao); // delete textures and framebuffers if (source && source.texture) gl.deleteTexture(source.texture); if (oldInfo) { oldInfo.texture && gl.deleteTexture(oldInfo.texture); oldInfo.buffer && gl.deleteFramebuffer(oldInfo.buffer); } if (newInfo) { newInfo.texture && gl.deleteTexture(newInfo.texture); newInfo.buffer && gl.deleteFramebuffer(newInfo.buffer); } // delete program gl.deleteProgram(program); // delete shaders gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); } function _initProgram(gl, plane, effects, hasFBO = false, noSource = false) { const source = noSource ? null : { texture: createTexture(gl).texture, buffer: null, }; if (source) { // flip Y axis for source texture gl.bindTexture(gl.TEXTURE_2D, source.texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); } const data = _mergeEffectsData(plane, effects, hasFBO, noSource); const vertexSrc = _stringifyShaderSrc( data.vertex, noSource ? vertexSimpleTemplate : vertexMediaTemplate, ); const fragmentSrc = _stringifyShaderSrc( data.fragment, noSource ? fragmentSimpleTemplate : fragmentMediaTemplate, ); // compile the GLSL program const { program, vertexShader, fragmentShader, error, type } = _getWebGLProgram(gl, vertexSrc, fragmentSrc); if (error || DEBUG) { logShaders(type, error, vertexSrc, fragmentSrc); } let vaoExt, vao; try { vaoExt = gl.getExtension('OES_vertex_array_object'); vao = vaoExt.createVertexArrayOES(); vaoExt.bindVertexArrayOES(vao); } catch (e) { // ignore } // set up the vertex data const attributes = _initVertexAttributes(gl, program, data.attributes); if (vao) { _enableVertexAttributes(gl, attributes); vaoExt.bindVertexArrayOES(null); } // setup uniforms const uniforms = _initUniforms(gl, program, data.uniforms); return { extensions: { vao: vaoExt, }, program, vertexShader, fragmentShader, source, attributes, uniforms, textures: data.textures, vao, }; } function _initFBOProgram(gl, plane, fbo) { const data = _mergeEffectsData(plane, fbo.effects, false, true); const vertexSrc = _stringifyShaderSrc( data.vertex, vertexSimpleTemplate, ); const fragmentSrc = _stringifyShaderSrc( data.fragment, fragmentSimpleTemplate, ); const { program, vertexShader, fragmentShader, error, type } = _getWebGLProgram(gl, vertexSrc, fragmentSrc); if (error || DEBUG) { logShaders(type, error, vertexSrc, fragmentSrc); } const uniforms = _initUniforms(gl, program, data.uniforms); const tex1 = _createFloatTexture(gl, { width: fbo.size, height: fbo.size }).texture; const tex2 = _createFloatTexture(gl, { width: fbo.size, height: fbo.size }).texture; const oldInfo = { buffer: _createFramebuffer(gl, tex1), texture: tex1, }; const newInfo = { buffer: _createFramebuffer(gl, tex2), texture: tex2, }; return { program, vertexShader, fragmentShader, uniforms, oldInfo, newInfo, size: fbo.size, }; } function _mergeEffectsData(plane, effects, hasFBO = false, noSource = false) { return effects.reduce( (result, config) => { const { attributes = [], uniforms = [], textures = [], varying = {}, } = config; const merge = (shader) => Object.keys(config[shader] || {}).forEach((key) => { if ( key === 'constant' || key === 'main' || key === 'source' ) { result[shader][key] += config[shader][key] + '\n'; } else { result[shader][key] = { ...result[shader][key], ...config[shader][key], }; } }); merge('vertex'); merge('fragment'); attributes.forEach((attribute) => { const found = result.attributes.some((attr) => { if (attr.name === attribute.name) { Object.assign(attr, attribute); return true; } }); if (!found) { result.attributes.push(attribute); } }); result.attributes.forEach((attr) => { if (attr.extends) { const found = result.attributes.some((attrToExtend) => { if (attrToExtend.name === attr.extends) { Object.assign(attr, attrToExtend, { name: attr.name, }); return true; } }); if (!found) { throw new Error( `Could not find attribute ${attr.extends} to extend`, ); } } }); result.uniforms.push(...uniforms); result.textures.push(...textures); Object.assign(result.vertex.varying, varying); Object.assign(result.fragment.varying, varying); return result; }, getEffectDefaults(plane, hasFBO, noSource), ); } function _getPlaneCoords({ xEnd, yEnd, factor }, plane = {}) { const { xSegments = 1, ySegments = 1 } = plane; const result = []; for (let i = 0; i < xSegments; i++) { for (let j = 0; j < ySegments; j++) { /* A */ result.push( (xEnd * i) / xSegments - factor, (yEnd * j) / ySegments - factor, ); /* B */ result.push( (xEnd * i) / xSegments - factor, (yEnd * (j + 1)) / ySegments - factor, ); /* C */ result.push( (xEnd * (i + 1)) / xSegments - factor, (yEnd * j) / ySegments - factor, ); /* D */ result.push( (xEnd * (i + 1)) / xSegments - factor, (yEnd * j) / ySegments - factor, ); /* E */ result.push( (xEnd * i) / xSegments - factor, (yEnd * (j + 1)) / ySegments - factor, ); /* F */ result.push( (xEnd * (i + 1)) / xSegments - factor, (yEnd * (j + 1)) / ySegments - factor, ); } } return result; } function getEffectDefaults(plane, hasFBO, noSource) { /* * Default uniforms */ const uniforms = noSource ? [] : [ { name: 'u_source', type: 'i', data: [hasFBO ? 1 : 0], }, ]; /* * Default attributes */ const attributes = [ { name: 'a_position', data: new Float32Array( _getPlaneCoords({ xEnd: 2, yEnd: 2, factor: 1 }, plane), ), size: 2, type: 'FLOAT', }, ]; if (!noSource) { attributes.push({ name: 'a_texCoord', data: new Float32Array( _getPlaneCoords({ xEnd: 1, yEnd: 1, factor: 0 }, plane), ), size: 2, type: 'FLOAT', }); } return { vertex: { uniform: {}, attribute: {}, varying: {}, constant: '', main: '', }, fragment: { uniform: {}, varying: {}, constant: '', main: '', source: '', }, attributes, uniforms, /* * Default textures */ textures: [], }; } function _stringifyShaderSrc(data, template) { const templateData = Object.entries(data).reduce((result, [key, value]) => { if (['uniform', 'attribute', 'varying'].includes(key)) { result[key] = Object.entries(value).reduce( (str, [name, type]) => str + `${key} ${type} ${name};\n`, '', ); } else { result[key] = value; } return result; }, {}); return template(templateData); } function _getWebGLProgram(gl, vertexSrc, fragmentSrc) { const vertexShader = _createShader(gl, gl.VERTEX_SHADER, vertexSrc); const fragmentShader = _createShader(gl, gl.FRAGMENT_SHADER, fragmentSrc); if (vertexShader.error) { return vertexShader; } if (fragmentShader.error) { return fragmentShader; } return _createProgram(gl, vertexShader, fragmentShader); } function _createProgram(gl, vertexShader, fragmentShader) { const program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); const success = gl.getProgramParameter(program, gl.LINK_STATUS); if (success) { return { program, vertexShader, fragmentShader }; } const exception = { error: gl.getProgramInfoLog(program), type: 'program', }; gl.deleteProgram(program); return exception; } function _createShader(gl, type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS); if (success) { return shader; } const exception = { error: gl.getShaderInfoLog(shader), type: type === gl.VERTEX_SHADER ? SHADER_ERROR_TYPES.vertex : SHADER_ERROR_TYPES.fragment, }; gl.deleteShader(shader); return exception; } /** * Create a WebGLTexture object. * * @private * @param {WebGLRenderingContext} gl * @param {Object} [config] * @param {number} config.width * @param {number} config.height * @param {ArrayBufferView|ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement|ImageBitmap} config.data * @param {string} config.format * @param {Object} config.wrap * @return {{texture: WebGLTexture, width: number, height: number}} */ export function createTexture( gl, { width = 1, height = 1, data = null, format = 'RGBA', wrap = 'stretch', filter = 'LINEAR', textureType = 'UNSIGNED_BYTE', } = {}, ) { const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); // Set the parameters so we can render any size image gl.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl[_getTextureWrap(wrap.x || wrap)], ); gl.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl[_getTextureWrap(wrap.y || wrap)], ); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl[filter]); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl[filter]); if (data) { // Upload the image into the texture gl.texImage2D( gl.TEXTURE_2D, 0, gl[format], gl[format], gl[textureType], data, ); } else { // Create empty texture gl.texImage2D( gl.TEXTURE_2D, 0, gl[format], width, height, 0, gl[format], gl[textureType], null, ); } return { texture, width, height, format }; } function _createBuffer(gl, program, name, data) { const location = gl.getAttribLocation(program, name); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); return { location, buffer }; } function _createFramebuffer(gl, tex) { const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0 ); return fb; } function _initVertexAttributes(gl, program, data) { return (data || []).map((attr) => { const { location, buffer } = _createBuffer( gl, program, attr.name, attr.data, ); return { name: attr.name, location, buffer, type: attr.type, size: attr.size, }; }); } function _initUniforms(gl, program, uniforms) { return (uniforms || []).map((uniform) => { const location = gl.getUniformLocation(program, uniform.name); return { location, size: uniform.size || uniform.data.length, type: uniform.type, data: uniform.data, }; }); } function _setUniforms(gl, uniformData) { (uniformData || []).forEach((uniform) => { let { size, type, location, data } = uniform; if (type === 'i') { data = new Int32Array(data); } gl[`uniform${size}${type}v`](location, data); }); } function _enableVertexAttributes(gl, attributes) { (attributes || []).forEach((attrib) => { const { location, buffer, size, type } = attrib; gl.enableVertexAttribArray(location); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.vertexAttribPointer(location, size, gl[type], false, 0, 0); }); } function _getTextureWrap(key) { return TEXTURE_WRAP[key] || TEXTURE_WRAP['stretch']; } function _createFloatTexture( gl, { width, height, data = null, format = 'RGBA', wrap = 'stretch', filter = 'NEAREST', } = {}) { // Enable OES_texture_float extension const ext = gl.getExtension('OES_texture_float'); if (!ext) { throw new Error('OES_texture_float not supported'); } return createTexture(gl, { width, height, data, format, wrap, filter, textureType: 'FLOAT', }); } function logShaders(type, error, vertexSrc, fragmentSrc) { function addLineNumbers(str) { return str.split('\n').map((line, i) => `${i + 1}: ${line}`).join('\n'); } if (error) { throw new Error( `${type} error:: ${error}\n${addLineNumbers(type === SHADER_ERROR_TYPES.fragment ? fragmentSrc : vertexSrc)}`, ); } if (DEBUG) { console.log(addLineNumbers(vertexSrc)); console.log(addLineNumbers(fragmentSrc)); } } /** * @private * @typedef {Object} kamposSceneData * @property {WebGLProgram} program * @property {{vao: OES_vertex_array_object?}} extensions * @property {WebGLShader} vertexShader * @property {WebGLShader} fragmentShader * @property {kamposTarget} source * @property {kamposAttribute[]} attributes * @property {Uniform[]} uniforms * @property {Texture[]} textures * @property {WebGLVertexArrayObjectOES} [vao] * * @private * @typedef {Object} fboSceneData * @property {WebGLProgram} program * @property {WebGLShader} vertexShader * @property {WebGLShader} fragmentShader * @property {Uniform[]} uniforms * @property {kamposTarget} oldInfo * @property {kamposTarget} newInfo * @property {number} size * * @typedef {Object} kamposTarget * @property {WebGLTexture} texture * @property {WebGLFramebuffer|null} buffer * @property {number} [width] * @property {number} [height] * * @typedef {Object} kamposAttribute * @property {string} name * @property {GLint} location * @property {WebGLBuffer} buffer * @property {string} type @property {number} size */