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>