Show & tell: multiple afterimage pass

I wanted to make a sort of motion chromatic aberration effect, where the previous frame was tinted red, the frame before that was tinted green, and the frame before that blue.

I looked at the built-in AfterimagePass for inspiration, but it wasn’t immediately obvious to me how to extend that into “blend previous N frames together”. I searched for various keywords – like afterimage, echoes (After Effects has a similar effect named this), trail, copies, previous N frames, backbuffer, etc – but couldn’t find much help.

Eventually I figured something out and wanted to share it in case it saves anyone else some trouble. You can customize the number and color of echoes/copies near the top of the code.

Feedback is welcome. It’s a bit clunky and I’m sure there’s a cleaner and more efficient way to do it, e.g. without dynamically generating a bunch of duplicate lines of code in the fragment shader. I saw sampler2Darray recommended, but couldn’t figure out how to pass the frame data properly to the shader.

Could definitely use an anti-aliasing pass after. Or you could put some blurring on the echoes within the shader.

Code
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <title></title>

    <script type="importmap">
      {
        "imports": {
          "three": "https://unpkg.com/three@0.169.0/build/three.module.js",
          "three/addons/": "https://unpkg.com/three@0.169.0/examples/jsm/"
        }
      }
    </script>

    <style>
      body {
        display: grid;
        place-items: center;
        width: 100vw !important;
        height: 100vh !important;
        margin: 0;
      }

      #canvas {
        background: black;
        position: absolute;
        inset: 0;
        width: 100% !important;
        height: 100% !important;
      }
    </style>
  </head>

  <body>
    <canvas id="canvas"></canvas>

    <div id="overlay"></div>

    <script type="module">
      import {
        Color,
        GLSL3,
        Group,
        HalfFloatType,
        IcosahedronGeometry,
        Mesh,
        MeshBasicMaterial,
        PerspectiveCamera,
        Scene,
        ShaderMaterial,
        WebGLRenderer,
        WebGLRenderTarget,
      } from "three";
      import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
      import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
      import {
        Pass,
        FullScreenQuad,
      } from "three/addons/postprocessing/Pass.js";
      import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

      class AfterimagePass extends Pass {
        constructor() {
          super();

          this.uniforms = { frames: { value: null } };

          const echoes = [
            ["r", "g", "b", "a"],
            ["2.0", "0.0", "0.0", "0.75 * a"],
            ["2.0", "0.0", "0.0", "0.75 * a"],
            ["0.0", "2.0", "0.0", "0.50 * a"],
            ["0.0", "2.0", "0.0", "0.50 * a"],
            ["0.0", "0.0", "2.0", "0.25 * a"],
            ["0.0", "0.0", "2.0", "0.25 * a"],
          ]
            .map(([r, g, b, a], i) => ({ r, g, b, a, i }))
            .reverse();

          const vertexShader = `
out vec2 xy;

void main() {
  xy = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
      `;

          const fragmentShader = `
#include <common>

uniform sampler2D frames[${echoes.length}];
in vec2 xy;
out vec4 outputPixel;

vec4 blend(vec4 bg, vec4 fg) {
  vec4 result = mix(bg, fg, fg.a);
  result = max(bg, result);
  return result;
}

void main() {
${echoes
  .map(
    ({ r, g, b, a, i }) => `
  {
    vec4 pixel = texture(frames[${i}], xy);
    pixel.r = ${r.replace("r", "pixel.r")};
    pixel.g = ${g.replace("g", "pixel.g")};
    pixel.b = ${b.replace("b", "pixel.b")};
    pixel.a = ${a.replace("a", "pixel.a")};
    outputPixel = blend(outputPixel, pixel);
  }
`
  )
  .join("\n")}
}
`;

          console.log(fragmentShader);

          const shader = new ShaderMaterial({
            uniforms: this.uniforms,
            vertexShader,
            fragmentShader,
            glslVersion: GLSL3,
          });

          const params = [0, 0, { type: HalfFloatType }];

          this.comp = {
            target: new WebGLRenderTarget(...params),
            quad: new FullScreenQuad(shader),
          };

          this.frames = Array(echoes.length)
            .fill()
            .map(() => ({
              target: new WebGLRenderTarget(...params),
              quad: new FullScreenQuad(
                new MeshBasicMaterial({ transparent: true })
              ),
            }));
        }

        render(renderer, writeBuffer, readBuffer) {
          this.frames.unshift(this.frames.pop());

          renderer.setRenderTarget(this.frames[0].target);
          this.frames[0].quad.material.map = readBuffer.texture;
          this.frames[0].quad.render(renderer);

          this.uniforms["frames"].value = this.frames.map(
            (frame) => frame.target.texture
          );

          renderer.setRenderTarget(writeBuffer);
          this.comp.quad.render(renderer);
        }

        setSize(width, height) {
          this.comp.target.setSize(width, height);
          for (const frame of this.frames) frame.target.setSize(width, height);
        }
      }

      const canvas = document.querySelector("#canvas");

      const renderer = new WebGLRenderer({ canvas, alpha: true });
      const scene = new Scene();
      const camera = new PerspectiveCamera(45, 1, 1, 1000);
      camera.position.z = 10;

      const renderPass = new RenderPass(scene, camera);
      const blurPass = new AfterimagePass();
      const outputPass = new OutputPass();
      const composer = new EffectComposer(renderer);
      composer.addPass(renderPass);
      composer.addPass(blurPass);
      composer.addPass(outputPass);

      const group = new Group();
      for (let i = 0; i < 20; i++) {
        const geometry = new IcosahedronGeometry(0.1 + 0.1 * Math.random(), 4);
        const material = new MeshBasicMaterial({
          color: new Color(`hsl(${360 * Math.random()}, 100%, 100%)`),
        });
        const mesh = new Mesh(geometry, material);
        mesh.position.x = 3 * (-0.5 + Math.random());
        mesh.position.y = 3 * (-0.5 + Math.random());
        mesh.position.z = 3 * (-0.5 + Math.random());
        group.add(mesh);
      }
      scene.add(group);

      const animationFrame = () => {
        group.rotation.x += 0.01;
        group.rotation.y += 0.02;
        group.rotation.z += 0.03;
        composer.render();
      };

      const resize = () => {
        camera.aspect = canvas.clientWidth / canvas.clientHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(canvas.clientWidth, canvas.clientHeight);
        composer.setSize(canvas.clientWidth, canvas.clientHeight);
        renderer.setPixelRatio(window.devicePixelRatio);
        composer.setPixelRatio(window.devicePixelRatio);
      };
      resize();
      window.addEventListener("resize", resize);

      const move = (event) => {
        const x = -1 + 2 * (event.clientX / event.target.clientWidth);
        const y = 1 - 2 * (event.clientY / event.target.clientHeight);
        group.position.x = (event.target.clientWidth / 200) * x;
        group.position.y = (event.target.clientHeight / 200) * y;
      };
      window.addEventListener("mousemove", move);

      renderer.setAnimationLoop(animationFrame);
    </script>
  </body>
</html>

2 Likes