diff --git a/TODO.txt b/TODO.txt index 2e8ab30..854c54d 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,5 +1,21 @@ TODO: + +Improve forkability + Document every variable in config.js + Document every variable, method, and section of the main function in compute.frag + Maybe rewrite it? Make the time based stuff easier to read? + Document resurrectionPass + Label it a WIP + List intended characteristics + Comment makePalette + + Write a document (and include images) that explains the underlying principle of the rain pass + + Create interactive controls? + Is the pipeline stuff just too bizarre? + + Resurrection Modified glyph order? @@ -14,6 +30,11 @@ Resurrection New glyphs? + + + + + Experiment with varying the colors in the palette pass Maybe a separate palette for the non-bloom Maybe dim and widen the bloom diff --git a/js/bloomPass.js b/js/bloomPass.js index cae5b8b..ad9c60b 100644 --- a/js/bloomPass.js +++ b/js/bloomPass.js @@ -1,6 +1,7 @@ import { loadText, makePassFBO, makePyramid, resizePyramid, makePass } from "./utils.js"; // The bloom pass is basically an added high-pass blur. +// The blur approximation is the sum of a pyramid of downscaled textures. const pyramidHeight = 5; const levelStrengths = Array(pyramidHeight) @@ -9,8 +10,10 @@ const levelStrengths = Array(pyramidHeight) .reverse(); export default (regl, config, inputs) => { - const enabled = config.bloomSize > 0 && config.bloomStrength > 0; + const { bloomStrength, bloomSize, highPassThreshold } = config; + const enabled = bloomSize > 0 && bloomStrength > 0; + // If there's no bloom to apply, return a no-op pass with an empty bloom texture if (!enabled) { return makePass({ primary: inputs.primary, @@ -18,16 +21,14 @@ export default (regl, config, inputs) => { }); } - const { bloomStrength, highPassThreshold } = config; - + // Build three pyramids of FBOs, one for each step in the process const highPassPyramid = makePyramid(regl, pyramidHeight, config.useHalfFloat); const hBlurPyramid = makePyramid(regl, pyramidHeight, config.useHalfFloat); const vBlurPyramid = makePyramid(regl, pyramidHeight, config.useHalfFloat); const output = makePassFBO(regl, config.useHalfFloat); - const highPassFrag = loadText("shaders/highPass.frag"); - // The high pass restricts the blur to bright things in our input texture. + const highPassFrag = loadText("shaders/highPass.frag"); const highPass = regl({ frag: regl.prop("frag"), uniforms: { @@ -39,7 +40,8 @@ export default (regl, config, inputs) => { // A 2D gaussian blur is just a 1D blur done horizontally, then done vertically. // The FBO pyramid's levels represent separate levels of detail; - // by blurring them all, this 3x1 blur approximates a more complex gaussian. + // by blurring them all, this basic blur approximates a more complex gaussian: + // https://software.intel.com/en-us/articles/compute-shader-hdr-and-bloom const blurFrag = loadText("shaders/blur.frag"); const blur = regl({ @@ -53,8 +55,8 @@ export default (regl, config, inputs) => { framebuffer: regl.prop("fbo"), }); - // The pyramid of textures gets flattened onto the source texture. - const flattenPyramid = regl({ + // The pyramid of textures gets flattened (summed) into a final blurry "bloom" texture + const sumPyramid = regl({ frag: ` precision mediump float; varying vec2 vUV; @@ -88,15 +90,15 @@ export default (regl, config, inputs) => { blur({ fbo: vBlurFBO, frag: blurFrag.text(), tex: hBlurFBO, direction: [0, 1] }); } - flattenPyramid(); + sumPyramid(); }, (w, h) => { // The blur pyramids can be lower resolution than the screen. - resizePyramid(highPassPyramid, w, h, config.bloomSize); - resizePyramid(hBlurPyramid, w, h, config.bloomSize); - resizePyramid(vBlurPyramid, w, h, config.bloomSize); + resizePyramid(highPassPyramid, w, h, bloomSize); + resizePyramid(hBlurPyramid, w, h, bloomSize); + resizePyramid(vBlurPyramid, w, h, bloomSize); output.resize(w, h); }, - [highPassFrag.laoded, blurFrag.loaded] + [highPassFrag.loaded, blurFrag.loaded] ); }; diff --git a/js/imagePass.js b/js/imagePass.js index 5dc3697..78183e1 100644 --- a/js/imagePass.js +++ b/js/imagePass.js @@ -1,5 +1,7 @@ import { loadImage, loadText, makePassFBO, makePass } from "./utils.js"; +// Multiplies the rendered rain and bloom by a loaded in image + const defaultBGURL = "https://upload.wikimedia.org/wikipedia/commons/0/0a/Flammarion_Colored.jpg"; export default (regl, config, inputs) => { diff --git a/js/main.js b/js/main.js index f22a17a..d9b6a16 100644 --- a/js/main.js +++ b/js/main.js @@ -1,5 +1,7 @@ import { makeFullScreenQuad, makePipeline } from "./utils.js"; -import makeConfig from "./config.js"; + +import makeConfig from "./config.js"; // The settings of the effect, specified in the URL query params + import makeRain from "./rainPass.js"; import makeBloomPass from "./bloomPass.js"; import makePalettePass from "./palettePass.js"; @@ -34,12 +36,10 @@ const effects = { }; const config = makeConfig(window.location.search); -const resolution = config.resolution; -const effect = config.effect in effects ? config.effect : "plain"; const resize = () => { - canvas.width = Math.ceil(canvas.clientWidth * resolution); - canvas.height = Math.ceil(canvas.clientHeight * resolution); + canvas.width = Math.ceil(canvas.clientWidth * config.resolution); + canvas.height = Math.ceil(canvas.clientHeight * config.resolution); }; window.onresize = resize; resize(); @@ -49,12 +49,11 @@ const dimensions = { width: 1, height: 1 }; document.body.onload = async () => { // All this takes place in a full screen quad. const fullScreenQuad = makeFullScreenQuad(regl); - - const bloomPass = effect === "none" ? null : makeBloomPass; - const pipeline = makePipeline([makeRain, bloomPass, effects[effect]], (p) => p.outputs, regl, config); - const uniforms = { tex: pipeline[pipeline.length - 1].outputs.primary }; - const drawToScreen = regl({ uniforms }); - await Promise.all(pipeline.map(({ ready }) => ready)); + const effectName = config.effect in effects ? config.effect : "plain"; + const pipeline = makePipeline([makeRain, makeBloomPass, effects[effectName]], (p) => p.outputs, regl, config); + const screenUniforms = { tex: pipeline[pipeline.length - 1].outputs.primary }; + const drawToScreen = regl({ uniforms: screenUniforms }); + await Promise.all(pipeline.map((step) => step.ready)); const tick = regl.frame(({ viewportWidth, viewportHeight }) => { // tick.cancel(); if (dimensions.width !== viewportWidth || dimensions.height !== viewportHeight) { diff --git a/js/palettePass.js b/js/palettePass.js index c3167d8..2bc3cb6 100644 --- a/js/palettePass.js +++ b/js/palettePass.js @@ -1,5 +1,10 @@ import { loadText, make1DTexture, makePassFBO, makePass } from "./utils.js"; +// Maps the brightness of the rendered rain and bloom to colors +// in a 1D gradient palette texture generated from the passed-in color sequence + +// This shader introduces noise into the renders, to avoid banding + const colorToRGB = ([hue, saturation, lightness]) => { const a = saturation * Math.min(lightness, 1 - lightness); const f = (n) => { diff --git a/js/rainPass.js b/js/rainPass.js index 58f1216..968004f 100644 --- a/js/rainPass.js +++ b/js/rainPass.js @@ -20,64 +20,32 @@ const brVert = [1, 1]; const quadVertices = [tlVert, trVert, brVert, tlVert, brVert, blVert]; export default (regl, config) => { + // The volumetric mode multiplies the number of columns + // to reach the desired density, and then overlaps them const volumetric = config.volumetric; const density = volumetric && config.effect !== "none" ? config.density : 1; const [numRows, numColumns] = [config.numColumns, config.numColumns * density]; + + // The volumetric mode requires us to create a grid of quads, + // rather than a single quad for our geometry const [numQuadRows, numQuadColumns] = volumetric ? [numRows, numColumns] : [1, 1]; const numQuads = numQuadRows * numQuadColumns; const quadSize = [1 / numQuadColumns, 1 / numQuadRows]; + // Various effect-related values const rippleType = config.rippleTypeName in rippleTypes ? rippleTypes[config.rippleTypeName] : -1; const cycleStyle = config.cycleStyleName in cycleStyles ? cycleStyles[config.cycleStyleName] : 0; const slantVec = [Math.cos(config.slant), Math.sin(config.slant)]; const slantScale = 1 / (Math.abs(Math.sin(2 * config.slant)) * (Math.sqrt(2) - 1) + 1); const showComputationTexture = config.effect === "none"; - const uniforms = { - ...extractEntries(config, [ - // general - "glyphHeightToWidth", - "glyphTextureColumns", - // compute - "animationSpeed", - "brightnessMinimum", - "brightnessMix", - "brightnessMultiplier", - "brightnessOffset", - "cursorEffectThreshold", - "cycleSpeed", - "fallSpeed", - "glyphSequenceLength", - "hasSun", - "hasThunder", - "raindropLength", - "rippleScale", - "rippleSpeed", - "rippleThickness", - "resurrectingCodeRatio", - // render vertex - "forwardSpeed", - // render fragment - "glyphEdgeCrop", - "isPolar", - ]), - density, - numRows, + const commonUniforms = { + ...extractEntries(config, ["animationSpeed", "glyphHeightToWidth", "glyphSequenceLength", "glyphTextureColumns", "resurrectingCodeRatio"]), numColumns, - numQuadRows, - numQuadColumns, - quadSize, - volumetric, - - rippleType, - cycleStyle, - slantVec, - slantScale, + numRows, showComputationTexture, }; - const msdf = loadImage(regl, config.glyphTexURL); - // These two framebuffers are used to compute the raining code. // they take turns being the source and destination of the "compute" shader. // The half float data type is crucial! It lets us store almost any real number, @@ -91,14 +59,31 @@ export default (regl, config) => { wrapT: "clamp", type: "half float", }); - - const output = makePassFBO(regl, config.useHalfFloat); - - const updateFrag = loadText("shaders/compute.frag"); - const update = regl({ + const computeFrag = loadText("shaders/compute.frag"); + const computeUniforms = { + ...commonUniforms, + ...extractEntries(config, [ + "brightnessMinimum", + "brightnessMix", + "brightnessMultiplier", + "brightnessOffset", + "cursorEffectThreshold", + "cycleSpeed", + "fallSpeed", + "hasSun", + "hasThunder", + "raindropLength", + "rippleScale", + "rippleSpeed", + "rippleThickness", + ]), + cycleStyle, + rippleType, + }; + const compute = regl({ frag: regl.prop("frag"), uniforms: { - ...uniforms, + ...computeUniforms, lastState: doubleBuffer.back, }, @@ -114,8 +99,27 @@ export default (regl, config) => { ); // We render the code into an FBO using MSDFs: https://github.com/Chlumsky/msdfgen + const msdf = loadImage(regl, config.glyphTexURL); const renderVert = loadText("shaders/rain.vert"); const renderFrag = loadText("shaders/rain.frag"); + const output = makePassFBO(regl, config.useHalfFloat); + const renderUniforms = { + ...commonUniforms, + ...extractEntries(config, [ + // vertex + "forwardSpeed", + // fragment + "glyphEdgeCrop", + "isPolar", + ]), + density, + numQuadColumns, + numQuadRows, + quadSize, + slantScale, + slantVec, + volumetric, + }; const render = regl({ blend: { enable: true, @@ -128,7 +132,7 @@ export default (regl, config) => { frag: regl.prop("frag"), uniforms: { - ...uniforms, + ...renderUniforms, lastState: doubleBuffer.front, glyphTex: msdf.texture, @@ -147,6 +151,7 @@ export default (regl, config) => { framebuffer: output, }); + // Camera and transform math for the volumetric mode const screenSize = [1, 1]; const { mat4, vec3 } = glMatrix; const camera = mat4.create(); @@ -161,7 +166,7 @@ export default (regl, config) => { primary: output, }, () => { - update({ frag: updateFrag.text() }); + compute({ frag: computeFrag.text() }); regl.clear({ depth: 1, color: [0, 0, 0, 1], @@ -175,6 +180,6 @@ export default (regl, config) => { glMatrix.mat4.perspective(camera, (Math.PI / 180) * 90, aspectRatio, 0.0001, 1000); [screenSize[0], screenSize[1]] = aspectRatio > 1 ? [1, aspectRatio] : [1 / aspectRatio, 1]; }, - [msdf.loaded, updateFrag.loaded, renderVert.loaded, renderFrag.loaded] + [msdf.loaded, computeFrag.loaded, renderVert.loaded, renderFrag.loaded] ); }; diff --git a/js/stripePass.js b/js/stripePass.js index a43fbae..1796f91 100644 --- a/js/stripePass.js +++ b/js/stripePass.js @@ -1,5 +1,10 @@ import { loadText, make1DTexture, makePassFBO, makePass } from "./utils.js"; +// Multiplies the rendered rain and bloom by a 1D gradient texture +// generated from the passed-in color sequence + +// This shader introduces noise into the renders, to avoid banding + const transPrideStripeColors = [ [0.3, 1.0, 1.0], [0.3, 1.0, 1.0], @@ -27,6 +32,8 @@ export default (regl, config, inputs) => { const output = makePassFBO(regl, config.useHalfFloat); const { backgroundColor } = config; + + // Expand and convert stripe colors into 1D texture data const stripeColors = "stripeColors" in config ? config.stripeColors.split(",").map(parseFloat) : config.effect === "pride" ? prideStripeColors : transPrideStripeColors; const numStripeColors = Math.floor(stripeColors.length / 3); diff --git a/shaders/imagePass.frag b/shaders/imagePass.frag index 96d264c..e2d1fbf 100644 --- a/shaders/imagePass.frag +++ b/shaders/imagePass.frag @@ -6,6 +6,9 @@ varying vec2 vUV; void main() { vec3 bgColor = texture2D(backgroundTex, vUV).rgb; + + // Combine the texture and bloom, then blow it out to reveal more of the image float brightness = pow(min(1., texture2D(tex, vUV).r * 2.) + texture2D(bloomTex, vUV).r, 1.5); + gl_FragColor = vec4(bgColor * brightness, 1.0); } diff --git a/shaders/palettePass.frag b/shaders/palettePass.frag index 455be54..d611952 100644 --- a/shaders/palettePass.frag +++ b/shaders/palettePass.frag @@ -17,7 +17,13 @@ highp float rand( const in vec2 uv, const in float t ) { void main() { vec4 brightnessRGB = texture2D( tex, vUV ) + texture2D( bloomTex, vUV ); + + // Combine the texture and bloom float brightness = brightnessRGB.r + brightnessRGB.g + brightnessRGB.b; - float at = brightness - rand( gl_FragCoord.xy, time ) * ditherMagnitude; - gl_FragColor = texture2D( palette, vec2(at, 0.0)) + vec4(backgroundColor, 0.0); + + // Dither: subtract a random value from the brightness + brightness = brightness - rand( gl_FragCoord.xy, time ) * ditherMagnitude; + + // Map the brightness to a position in the palette texture + gl_FragColor = texture2D( palette, vec2(brightness, 0.0)) + vec4(backgroundColor, 0.0); } diff --git a/shaders/rain.frag b/shaders/rain.frag index a6d414b..a0f1a82 100644 --- a/shaders/rain.frag +++ b/shaders/rain.frag @@ -33,9 +33,10 @@ void main() { vec2 uv = vUV; + // In normal mode, derives the current glyph and UV from vUV if (!volumetric) { if (isPolar) { - // Curves the UV space to make letters appear to radiate from up above + // Curved space that makes letters appear to radiate from up above uv -= 0.5; uv *= 0.5; uv.y -= 0.5; @@ -43,35 +44,29 @@ void main() { float angle = atan(uv.y, uv.x) / (2. * PI) + 0.5; uv = vec2(angle * 4. - 0.5, 1.5 - pow(radius, 0.5) * 1.5); } else { - // Applies the slant, scaling the UV space - // to guarantee the viewport is still covered + // Applies the slant and scales space so the viewport is fully covered uv = vec2( - (uv.x - 0.5) * slantVec.x + (uv.y - 0.5) * slantVec.y, - (uv.y - 0.5) * slantVec.x - (uv.x - 0.5) * slantVec.y + (uv.x - 0.5) * slantVec.x + (uv.y - 0.5) * slantVec.y, + (uv.y - 0.5) * slantVec.x - (uv.x - 0.5) * slantVec.y ) * slantScale + 0.5; } uv.y /= glyphHeightToWidth; } + // Unpack the values from the data texture vec4 glyph = volumetric ? vGlyph : texture2D(lastState, uv); - - if (showComputationTexture) { - gl_FragColor = glyph; - return; - } - - // Unpack the values from the font texture float brightness = glyph.r; float symbolIndex = getSymbolIndex(glyph.g); float quadDepth = glyph.b; float effect = glyph.a; brightness = max(effect, brightness); + // In volumetric mode, distant glyphs are dimmer if (volumetric) { - brightness = min(1.0, brightness * quadDepth * 1.25); + brightness = brightness * min(1.0, quadDepth); } - // resolve UV to MSDF texture coord + // resolve UV to position of glyph in MSDF texture vec2 symbolUV = vec2(mod(symbolIndex, glyphTextureColumns), floor(symbolIndex / glyphTextureColumns)); vec2 glyphUV = fract(uv * vec2(numColumns, numRows)); glyphUV -= 0.5; @@ -79,10 +74,15 @@ void main() { glyphUV += 0.5; vec2 msdfUV = (glyphUV + symbolUV) / glyphTextureColumns; - // MSDF + // MSDF: calculate brightness of fragment based on distance to shape vec3 dist = texture2D(glyphTex, msdfUV).rgb; float sigDist = median3(dist) - 0.5; float alpha = clamp(sigDist/fwidth(sigDist) + 0.5, 0.0, 1.0); - gl_FragColor = vec4(vChannel * brightness * alpha, 1.0); + if (showComputationTexture) { + gl_FragColor = vec4(glyph.rgb * alpha, 1.0); + } else { + gl_FragColor = vec4(vChannel * brightness * alpha, 1.0); + } + } diff --git a/shaders/rain.vert b/shaders/rain.vert index 330c87c..e8a764a 100644 --- a/shaders/rain.vert +++ b/shaders/rain.vert @@ -26,6 +26,7 @@ void main() { vUV = (aPosition + aCorner) * quadSize; vGlyph = texture2D(lastState, aPosition * quadSize); + // Calculate the world space position float quadDepth = 0.0; if (volumetric && !showComputationTexture) { quadDepth = fract(vGlyph.b + time * animationSpeed * forwardSpeed); @@ -34,16 +35,17 @@ void main() { vec2 position = (aPosition + aCorner * vec2(density, 1.)) * quadSize; vec4 pos = vec4((position - 0.5) * 2.0, quadDepth, 1.0); + // "Resurrected" columns are in the green channel, + // and are vertically flipped (along with their glyphs) vChannel = vec3(1.0, 0.0, 0.0); + if (volumetric && rand(vec2(aPosition.x, 0)) < resurrectingCodeRatio) { + pos.y = -pos.y; + vChannel = vec3(0.0, 1.0, 0.0); + } + // Convert the world space position to screen space if (volumetric) { - if (rand(vec2(aPosition.x, 0)) < resurrectingCodeRatio) { - pos.y = -pos.y; - vChannel = vec3(0.0, 1.0, 0.0); - } - pos.x /= glyphHeightToWidth; - pos = camera * transform * pos; } else { pos.xy *= screenSize; diff --git a/shaders/stripePass.frag b/shaders/stripePass.frag index 90e30c3..6eebd0c 100644 --- a/shaders/stripePass.frag +++ b/shaders/stripePass.frag @@ -17,7 +17,11 @@ highp float rand( const in vec2 uv, const in float t ) { void main() { vec3 color = texture2D(stripes, vUV).rgb; + // Combine the texture and bloom float brightness = min(1., texture2D(tex, vUV).r * 2.) + texture2D(bloomTex, vUV).r; - float at = brightness - rand( gl_FragCoord.xy, time ) * ditherMagnitude; - gl_FragColor = vec4(color * at + backgroundColor, 1.0); + + // Dither: subtract a random value from the brightness + brightness = brightness - rand( gl_FragCoord.xy, time ) * ditherMagnitude; + + gl_FragColor = vec4(color * brightness + backgroundColor, 1.0); }