/**
* LittleJS Post Processing Plugin
* - Supports shadertoy style post processing shaders
* - call new PostProcessPlugin() to setup post processing
* - can be enabled to pass other canvases through a final shader
* - iResolution is the canvas backing store, so it grows with canvasPixelRatio
* like shadertoy does. Effects that use it only for uv (p/iResolution.xy) are
* unaffected, but ones that set a feature size from it, like scan lines, get
* finer as the ratio rises. Divide by getCanvasPixelRatio() to pin them.
* @namespace PostProcess
*/
'use strict';
///////////////////////////////////////////////////////////////////////////////
/** Global Post Process plugin object
* @type {PostProcessPlugin}
* @memberof PostProcess */
let postProcess;
/////////////////////////////////////////////////////////////////////////
/**
* Post Process Plugin - Applies a full screen shader to the rendered output
* - Create it after any plugin that draws, since plugins render in the order they are made
* and this one shades what is on the canvas when its turn comes
* - It runs after gameRenderPost, so a HUD drawn there with WebGL is shaded (and bloomed) too;
* draw the HUD with useWebGL=false (the main canvas) or from a plugin created after this one
* @memberof PostProcess
*/
class PostProcessPlugin
{
/** Create global post processing shader
* @param {string} [shaderCode] - Shadertoy style mainImage code, a pass-through when left out
* @param {boolean} [includeMainCanvas] - combine mainCanvas onto glCanvas
* @param {boolean} [feedbackTexture] - also pass the shader's own output from the previous frame as iChannel1,
* for trails and echoes; iChannel0 is still the frame just drawn
* @example
* // create the post process plugin object
* new PostProcessPlugin(shaderCode);
*/
constructor(shaderCode, includeMainCanvas=false, feedbackTexture=false)
{
ASSERT(engineInitialized || headlessMode, 'create the plugin after engineInit, e.g. in gameInit');
ASSERT(!postProcess, 'Post process already initialized');
ASSERT(!(includeMainCanvas && feedbackTexture), 'Post process cannot both include main canvas and use feedback texture');
postProcess = this;
if (!shaderCode) // default shader pass through
shaderCode = 'void mainImage(out vec4 c,vec2 p){c=texture(iChannel0,p/iResolution.xy);}';
/** @property {WebGLProgram|undefined} - Shader for post processing
* @type {WebGLProgram|undefined} */
this.shader = undefined;
/** @property {WebGLTexture|undefined} - Texture for post processing
* @type {WebGLTexture|undefined} */
this.texture = undefined;
/** @property {WebGLTexture|undefined} - The previous frame's output, iChannel1, when feedbackTexture is set
* @type {WebGLTexture|undefined} */
this.feedbackTexture = undefined;
/** @property {WebGLVertexArrayObject|undefined} - Vertex array object
* @type {WebGLVertexArrayObject|undefined} */
this.vao = undefined;
// setup the post processing plugin
initPostProcess();
engineAddPlugin(undefined, postProcessRender, postProcessContextLost, postProcessContextRestored);
function initPostProcess()
{
if (headlessMode) return;
if (!glEnable)
{
console.warn('PostProcessPlugin: WebGL not enabled!');
return;
}
// create resources, the feedback starting black, as if the frame before the first were empty
if (feedbackTexture)
{
postProcess.feedbackTexture = glCreateTexture();
glContext.bindTexture(glContext.TEXTURE_2D, postProcess.feedbackTexture);
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, 1, 1, 0, glContext.RGBA,
glContext.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 255]));
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
}
postProcess.texture = glCreateTexture();
postProcess.shader = glCreateProgram(
'#version 300 es\n' + // specify GLSL ES version
'precision highp float;'+ // use highp for accuracy
'in vec2 p;'+ // position
'void main(){'+ // shader entry point
'gl_Position=vec4(p+p-1.,1,1);'+ // set position
'}' // end of shader
,
postProcessFragmentSource(shaderCode)
);
// setup VAO for post processing
postProcess.vao = glContext.createVertexArray();
glContext.bindVertexArray(postProcess.vao);
glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
// configure vertex attributes
const vertexByteStride = 8;
const pLocation = glContext.getAttribLocation(postProcess.shader, 'p');
glContext.enableVertexAttribArray(pLocation);
glContext.vertexAttribPointer(pLocation, 2, glContext.FLOAT, false, vertexByteStride, 0);
}
function postProcessContextLost()
{
postProcess.shader = undefined;
postProcess.texture = undefined;
postProcess.feedbackTexture = undefined;
LOG('PostProcessPlugin: WebGL context lost');
}
function postProcessContextRestored()
{
initPostProcess();
LOG('PostProcessPlugin: WebGL context restored');
}
function postProcessRender()
{
if (headlessMode || !glEnable) return;
// made now if WebGL was off when the plugin was made, or when a lost context came back
if (!postProcess.shader)
{
if (glContext.isContextLost()) return;
initPostProcess();
}
// clear out the buffer
glFlush();
// ensure we render to the default framebuffer (in case any earlier
// caller this frame left a render target bound)
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
// setup shader program to draw a quad
glContext.useProgram(postProcess.shader);
glContext.bindVertexArray(postProcess.vao);
glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, true);
// upload the canvas the way it shows, premultiplied, since the shader writes full alpha; unpremultiplied
// a see-through pixel would come out at its full brightness instead of faded over the background
glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
glContext.disable(glContext.BLEND);
// setup texture
glContext.activeTexture(glContext.TEXTURE0);
glContext.bindTexture(glContext.TEXTURE_2D, postProcess.texture);
if (includeMainCanvas)
{
// copy main canvas to work canvas at the backing store size,
// mainCanvasSize is css pixels so it would lose resolution
workCanvas.width = mainCanvas.width;
workCanvas.height = mainCanvas.height;
glCopyToContext(workContext);
workContext.drawImage(mainCanvas, 0, 0);
// clear the main canvas with clearRect, resizing it would also
// reset the context state (smoothing, line caps) for anything
// drawn after this and reallocate the canvas every frame
mainContext.save();
mainContext.setTransform(1, 0, 0, 1, 0, 0);
mainContext.clearRect(0, 0, mainCanvas.width, mainCanvas.height);
mainContext.restore();
// copy work canvas to texture
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, workCanvas);
}
else
{
// copy glCanvas to texture
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
}
// the previous frame's output, on the second texture unit
if (feedbackTexture)
{
glContext.activeTexture(glContext.TEXTURE1);
glContext.bindTexture(glContext.TEXTURE_2D, postProcess.feedbackTexture);
}
// the 3D depth, when render3D draws it, on the third texture unit, with what sceneDepth needs to read it
const depth = typeof render3D != 'undefined' && render3D?.depthTexture ? render3D : undefined;
glContext.activeTexture(glContext.TEXTURE2);
glContext.bindTexture(glContext.TEXTURE_2D, depth?.cameraDepthTexture || null);
glContext.activeTexture(glContext.TEXTURE0);
const camera = depth?.camera;
camera && glContext.uniform3f(glUniformLocation(postProcess.shader, 'iDepthRange'), camera.near,
camera.far == Infinity ? 0 : camera.far, camera.orthographic ? 1 : 0);
// set uniforms and draw
const uniformLocation = (name)=>glUniformLocation(postProcess.shader, name);
glContext.uniform1i(uniformLocation('iChannel0'), 0);
glContext.uniform1i(uniformLocation('iChannel1'), 1);
glContext.uniform1i(uniformLocation('iChannel2'), 2);
glContext.uniform1f(uniformLocation('iTime'), time);
glContext.uniform3f(uniformLocation('iResolution'), mainCanvas.width, mainCanvas.height, 1);
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
if (feedbackTexture)
{
// keep this frame's output for the next one, then hand the first texture unit back to the engine
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
glContext.activeTexture(glContext.TEXTURE0);
}
// the depth leaves the third unit for whatever draws next
glContext.activeTexture(glContext.TEXTURE2);
glContext.bindTexture(glContext.TEXTURE_2D, null);
glContext.activeTexture(glContext.TEXTURE0);
// restore defaults so subsequent dynamic texture uploads aren't flipped or premultiplied
glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
// bind back the texture the 2D batch thinks is bound, a plugin drawing after this one uses it
if (glActiveTexture)
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
// force it to set instanced mode
glSetInstancedMode(true);
}
}
}
///////////////////////////////////////////////////////////////////////////////
// the fragment shader of the post process pass around its mainImage snippet: the frame on iChannel0, the last output
// on iChannel1 with a feedback texture, and the 3D depth on iChannel2 when render3D.depthTexture is on, read with
// sceneDepth(uv), the distance from the camera along its view in world units, uv 0 to 1 across the screen
function postProcessFragmentSource(shaderCode)
{
return '#version 300 es\n' + // specify GLSL ES version
'precision highp float;'+ // use highp for accuracy
'uniform sampler2D iChannel0;'+ // input texture
'uniform sampler2D iChannel1;'+ // the previous frame's output, when feedbackTexture is set
'uniform sampler2D iChannel2;'+ // the 3D depth, when render3D.depthTexture is on
'uniform vec3 iResolution;'+ // size of output texture
'uniform float iTime;'+ // time
'uniform vec3 iDepthRange;'+ // the camera's near, its far or 0 for none, and 1 when orthographic
'out vec4 c;'+ // out color
// the depth texture's value back to a distance, as the camera's projection put it there
'float sceneDepth(vec2 uv){'+
'float d=texture(iChannel2,uv).r*2.-1.,n=iDepthRange.x,f=iDepthRange.y;'+
'return iDepthRange.z>0.?(d*(f-n)+f+n)/2.:f>0.?2.*n*f/(f+n-d*(f-n)):2.*n/(1.-d);}'+
'\n' + shaderCode + '\n'+ // insert custom shader code
'void main(){'+ // shader entry point
'mainImage(c,gl_FragCoord.xy);'+ // call post process function
'c.a=1.;'+ // always use full alpha
'}'; // end of shader
}
/**
* Shader code for a bloom effect, the bright parts of the image blurred back over it
* - Pass it to PostProcessPlugin, or edit the string to build an effect on top of it
* @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
* @param {number} [strength] - How much glow to add
* @param {number} [size] - How far the glow spreads in pixels, which also sets how many samples it takes
* @return {string}
* @memberof PostProcess
*/
function postProcessBloomShader(threshold=.6, strength=1, size=6)
{
ASSERT(isNumber(threshold) && isNumber(strength) && isNumber(size), 'bloom settings must be numbers');
ASSERT(size > 0, 'bloom size must be above zero');
ASSERT(size <= 32, 'a bloom this wide takes a sample every few pixels of every ring, which is hundreds of samples a pixel', size);
// Taps on three rings over a disc of the given size, one every three pixels or so of each ring
// so there is no gap wide enough to show. The count follows the ring all the way out: hold it
// still and a wider glow only spreads the same taps further apart, until they show up as the
// ring of evenly spaced copies a single ring of eight leaves around anything bright.
// Each ring has its own count, odd and unequal, and its own turn off the last, so the little
// the taps do miss comes out as fine ripple instead of a shape of its own.
const rings = 3;
let code = '', taps = 0;
for (let j = 0; j < rings; ++j)
{
const radius = ((j + .5) / rings) ** .5 * size; // equal area per ring
const count = max(5 + 2 * j, round(2 * radius)) | 1;
taps += count;
code += `
for (int k = 0; k < ${count}; ++k)
{
float a = float(k) * ${(2 * PI / count).toFixed(7)}${j ? ' + ' + (j * 2.3999632).toFixed(7) : ''};
glow += max(vec3(0), texture(iChannel0, uv + vec2(cos(a), sin(a)) * ${radius.toFixed(4)} / iResolution.xy).rgb - ${threshold.toFixed(4)});
}`;
}
return `
void mainImage(out vec4 color, vec2 pixel)
{
vec2 uv = pixel / iResolution.xy;
color = texture(iChannel0, uv);
vec3 glow = vec3(0);${code}
color.rgb += glow * ${(strength / taps).toFixed(6)};
}`;
}
/**
* Set up post processing with a bloom effect, so bright colors and lights glow
* @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
* @param {number} [strength] - How much glow to add
* @param {number} [size] - How far the glow spreads in pixels
* @param {boolean} [includeMainCanvas] - Glow the 2D canvas too, off by default so HUD text stays crisp
* (a HUD drawn with WebGL in gameRenderPost glows either way, draw it with useWebGL=false)
* @return {PostProcessPlugin}
* @memberof PostProcess
* @example
* postProcessBloom(); // in gameInit, after any Render3DPlugin
*/
function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=false)
{ return new PostProcessPlugin(postProcessBloomShader(threshold, strength, size), includeMainCanvas); }