惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

N
News | PayPal Newsroom
P
Proofpoint News Feed
Cyberwarzone
Cyberwarzone
C
Cisco Blogs
SecWiki News
SecWiki News
Know Your Adversary
Know Your Adversary
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Vercel News
Vercel News
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
罗磊的独立博客
NISL@THU
NISL@THU
WordPress大学
WordPress大学
Hacker News - Newest:
Hacker News - Newest: "LLM"
T
Threat Research - Cisco Blogs
AI
AI
Simon Willison's Weblog
Simon Willison's Weblog
Security Archives - TechRepublic
Security Archives - TechRepublic
有赞技术团队
有赞技术团队
L
LINUX DO - 热门话题
Hacker News: Ask HN
Hacker News: Ask HN
V
V2EX
G
GRAHAM CLULEY
TaoSecurity Blog
TaoSecurity Blog
Hugging Face - Blog
Hugging Face - Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
F
Fortinet All Blogs
博客园 - 叶小钗
博客园 - 三生石上(FineUI控件)
云风的 BLOG
云风的 BLOG
Recorded Future
Recorded Future
Latest news
Latest news
The Hacker News
The Hacker News
aimingoo的专栏
aimingoo的专栏
T
Troy Hunt's Blog
S
Schneier on Security
I
Intezer
Google DeepMind News
Google DeepMind News
A
Arctic Wolf
Apple Machine Learning Research
Apple Machine Learning Research
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Threatpost
爱范儿
爱范儿
The Register - Security
The Register - Security
S
SegmentFault 最新的问题
Blog — PlanetScale
Blog — PlanetScale
博客园 - 聂微东
宝玉的分享
宝玉的分享
Recent Commits to openclaw:main
Recent Commits to openclaw:main
美团技术团队
B
Blog RSS Feed

Codrops

Meet the Speakers of the First Three.js Conference | Codrops The Architecture Behind Trionn: Coordinating GSAP, Three.js, Lenis, and Web Audio | Codrops Where Stories Become Interactive Worlds: How makemepulse Brings Ideas to Life Across Web, XR, and Beyond | Codrops The Sleepers: Creating an Atmospheric WebGL Experience with Lightweight Techniques Ten Years Away: Designing an Interactive Comic for Studio375’s Tenth Anniversary | Codrops Building a Scroll-Driven 3D Gallery Using a Blender Camera Path with Three.js and GSAP | Codrops From Years of Client Work to a Next.js + Sanity Starter Kit | Codrops Sculpting a Digital Athlete: Capturing Stefanos Tsitsipas Beyond the Court | Codrops Building Persistent Page Transitions with WebGPU and Vanilla JavaScript | Codrops Inside Bisous: Designing an Editorial Experience for Cinematic CGI | Codrops Engineering the Web Experience Behind Shopify’s Spring ’26 Edition: Everywhere | Codrops Shaping Stories into Experience: The Work of Kevin Lam | Codrops Testing What Users Actually See with Vitest and Chromatic | Codrops Podium: Building a Website Where Running Becomes Storytelling | Codrops Designing Beyond the Surface: How DashDigital Turns Complexity into Clarity | Codrops Creative Entrepreneurship: Designing the Machine | Codrops Exploring 3D Image Rotations on Scroll | Codrops Always Building, Always Learning: Cyd Stumpel’s Journey Through the Modern Web | Codrops Designing A Better Lou: Reducing Cognitive Load Through Design, Content, and Systems | Codrops Sketching the Impossible: A 3D Portfolio Built Without a Single 3D Model | Codrops Building Horeca: Advanced Motion Design in Webflow Without the Performance Trade-Offs | Codrops Building an Interactive Digital Stamp Collection with Shaders, Postcards, and Playful Inspection | Codrops No Two Paths Alike: Inside San Rita’s Approach to Digital Experiences | Codrops The Making of the New Lesse Studio Website: Clarity, Performance, and Intentionality | Codrops Creating a Thumbnail Flow Animation with GSAP MotionPath | Codrops Forging Her Own Path: Houmahani Kane’s Journey in Creative Development | Codrops Beaucoup and the Pursuit of Memorable Experiences | Codrops The Future of Emotional Technology: Comfort Websites for Growth and Self-Awareness | Codrops The Never Ending Story: Building a Seamless Infinite Scroll Experience with GSAP & Lenis | Codrops Whooshes, Snaps and Shaders: Adrien Vanderpotte and the Feeling of the Interface | Codrops Building a Scroll-Driven 3D Cube Gallery in Webflow with GSAP | Codrops Creating Scroll-Driven SVG Map Animations with GSAP | Codrops Made With Gsap: Building a Fun Gravity-Based Mouse Trail | Codrops 80s Business Tech and Seamless Scene Transitions: Inside Shader.se’s Scroll-Driven WebGPU Pipeline | Codrops Merlin: The Code Boutique Turning Motion Into Digital Magic | Codrops Designing Ourselves: The New Obys Identity and Website | Codrops Exploring the HTML-in-Canvas Proposal | Codrops Le:mma Studio: Building the Feeling Behind the Screen | Codrops From Shader Uniforms to Clip-Path Wipes: How GSAP Drives My Portfolio | Codrops Reverse-Engineering Claude AI’s Mascot Animations with SVG and GSAP | Codrops Akaru: Crafting Precision-Driven Digital Experiences | Codrops Designing Against the Gallery: A Two-Year Journey to a Layered Portfolio Experience | Codrops Between Human and Machine: Sofia Papadopoulou’s Approach to Augmented Creativity | Codrops More Than a Portfolio: Building a Scroll-Driven 3D World with Something to Say | Codrops Rhumb Studio: Small but Mighty, Shaped by Curiosity | Codrops Susurrus: Crafting a Cozy Watercolor World with Three.js and Shaders | Codrops Building 4WIDE: Turning Distortion, Blur, and Motion into a Coherent Experience | Codrops A Playful Clip Menu with GSAP’s easeReverse | Codrops False Earth: From WebGL Limits to a WebGPU-Driven World | Codrops Interactive Storytelling for the Web: Building Immersive Stories with Timelines, 3D, and Layered Scenes | Codrops The Art of Complex Motion: Corentin Bernadou’s World of Shaders and Experimentation | Codrops They Call Me Giulio: The Making of a Cinematic Cyberpunk Portfolio | Codrops Lusion: Where Digital Craft Meets Ambitious Experimentation | Codrops The Exat Microsite: Pushing a Typography Showcase to New Creative Extremes | Codrops A Single DM, a First SOTD, and the Start of Chanh M. Ho’s Creative Journey | Codrops Creating Custom Page Transitions in Astro with Barba.js and GSAP | Codrops R—K ’26: The Thinking and Code Behind a Portfolio Led by Presence | Codrops Building the Maxima Therapy Website: React, GSAP, and Dabbling with AI | Codrops Where Engineering Meets Craft: Edoardo Lunardi’s Obsession with the Details | Codrops Animating 160,000 Cubes in Three.js to Visualize Dithering | Codrops Arnaud Rocca’s Portfolio: From a GSAP-Powered Motion System to Fluid WebGL | Codrops Where Confidence Needs No Decoration: The Malvah.Studio Story | Codrops Motion Highlights #17 | Codrops
Building an Interactive Wave Propagation Cube Grid with Three.js | Codrops
By Franky Hung · 2026-07-09 · via Codrops

Hi, this is Franky Hung. I’m a creative frontend developer from Hong Kong. In this tutorial, we’re going to build a minimalistic yet mesmerizing 3D wave grid background!

One day, I was scrolling on Pinterest looking for inspirations and I stumbled upon a very cool animation showcasing waves propagating across a grid of cubes with realistic shadows. I thought to myself, “that doesn’t seem too hard to recreate in Three.js, why not consider it a small challenge?”

Before diving right into the details, here’s a quick breakdown of this tutorial:

  1. Project structure
  2. Setting up the stage
  3. Implementing the mouse trail
  4. Propagate waves from the mouse trail
  5. Postprocessing magic
  6. Production-ready optimizations
  7. Customize your wave grid!

Let’s get started!

Project Structure

root
├── src
|  ├── ThreeJS
|  |  ├── Camera.js
|  |  ├── Orchestrator.js
|  |  ├── Renderer.js
|  |  ├── Stage.js
|  |  ├── Effects
|  |  |  ├── MouseTrail.js
|  |  |  └── VignetteRGBShiftShader.js
|  |  └── Utils
|  |     ├── Debug.js
|  |     └── Sizes.js
|  ├── index.html
|  ├── script.js
|  └── style.css
├── public
|  └── arrow.svg
├── package.json
└── vite.config.js

Vite is chosen for bundling as it’s a widely-used and very performant modern build tool. script.js is our entry which initializes the Three.js scene, binds it to the canvas element in the index.html and runs other interactive code. Everything Three.js related is grouped under the ThreeJS folder.

The way I structure my Three.js code is heavily inspired by Bruno Simon’s boilerplates in his course. Let me briefly explain the roles of each file:

  • Orchestrator.js : A central singleton that initializes and coordinates the scene, camera, renderer, essential utils and update loop. This is the backbone of our Three.js scene.
  • Camera.js : Manages the PerspectiveCamera and the mouse-based camera tilt behavior.
  • Renderer.js : Handles the WebGLRenderer setup and the post-processing pipeline using EffectComposer.
  • Stage.js : Manages all the content to be displayed in the scene, including lighting, the interactive cube grid and the gui controls for all tweakable parameters.
  • Utils/Debug.js : Holds a singleton GUI instance and toggles the controls menu based on whether #debug is present in the URL.
  • Utils/Sizes.js : Monitors window dimensions and pixel ratio, emitting resize events for the camera and renderer to update accordingly.
  • Effects/MouseTrail.js : Tracks mouse history to generate a dynamic trail texture used for real-time wave displacement.
  • Effects/VignetteRGBShiftShader.js : A custom shader providing subtle peripheral darkening and chromatic aberration post-processing.

Setting up the stage

Time to build! I’m not going to paste every line of code here so please refer to the Github repo whenever in need.

Although there aren’t going to be like thousands of cubes, but reducing 400 draw calls into one is definitely a big reason to using instancing. For this tutorial though, we are aiming for the aesthetic of relatively larger cubes, so only a fraction of the 400 cubes will be rendered. Since we need the cubes to cast shadows, MeshPhongMaterial would suffice; we don’t need MeshStandardMaterial here since we don’t need physically accurate lighting for simple cube surfaces.

Let’s define setGrid, updateGrid functions and a setLighting function for their respective roles.

setGrid() {
    const count = this.gridSize * this.gridSize; // gridSize: 20
    const geometry = new THREE.BoxGeometry(
        this.cubeWidth, // width: 0.8
        this.cubeHeight, // height: 3
        this.cubeWidth,
    );
    const material = new THREE.MeshPhongMaterial({ color: 0xffffff });

    // Per-instance XZ world position passed to the vertex shader
    this.offsetAttribute = new THREE.InstancedBufferAttribute(
        new Float32Array(count * 2),
        2,
    );
    geometry.setAttribute("aOffset", this.offsetAttribute);

    this.instancedMesh = new THREE.InstancedMesh(geometry, material, count);
    this.instancedMesh.castShadow = true;
    this.instancedMesh.receiveShadow = true;
    this.scene.add(this.instancedMesh);

    // Arrange cube instances into a grid
    this.updateGrid();
}

Notice that we are defining an extra buffer attribute, this acts as both an ID and the 2D position of each instance, which will be useful later. It’s important to set castShadow and receiveShadow to true to let the cubes cast shadows onto themselves.

In setLighting(), these lines are particularly important:

this.directionalLight.castShadow = true; // don't forget this else there'll be no shadows!
this.directionalLight.shadow.mapSize.set(1024, 1024);
this.directionalLight.shadow.radius = 6;

The shadow.mapSize determines the resolution of the shadow map rendered by this light. To optimize your application as much as possible, reduce resolutions to the minimum until the visual quality starts to drop below what’s acceptable. Increase the shadow radius to get softer shadows. Finally, do not forget to toggle the main switch of shadows on for the renderer!

this.instance.shadowMap.enabled = true;
this.instance.shadowMap.type = THREE.PCFShadowMap; // PCFSoftShadowMap was deprecated in Three.js version r182

And voila! Now you have a grid of white cubes with not much to see!

Implementing the mouse trail

Now with our static grid in place, it’s time to bring it to life! The core idea is to make waves that ripple out from the path of the cursor. To do this, we need to know where the cursor is, not on the 2D screen, but in our 3D world. This logic is all handled within our MouseTrail.js class. Let’s dive in.

The classic Three.js technique is to use a Raycaster to translate the cursor position into 3D world position, with an active “pointermove” event listener bound to the canvas. We “shoot” a ray from the camera through the normalized coordinates of the pointer and see where it intersects with a large, invisible horizontal plane that sits at y = 0, the same as the grid.

this.raycaster = new THREE.Raycaster();

// Invisible horizontal plane for pointer → world-space raycasting
this.rayPlane = new THREE.Mesh(
    new THREE.PlaneGeometry(bounds, bounds),
    new THREE.MeshBasicMaterial({
        side: THREE.DoubleSide,
        visible: false,
    }),
);
this.rayPlane.rotation.x = -Math.PI / 2;
this.rayPlane.updateMatrixWorld(true); // important!

Note that we need to call rayPlane.updateMatrixWorld(true) after rotation due to its absence in the actual scene for display; Three.js does not automatically update its world matrix so we need to do this manually. This gives us a flat surface to consistently intersect with, allowing us to get the (x, z) coordinates of the mouse on the grid’s plane.

Next, we need to initialize the trail data and texture for later use:

// DataTexture (MAX_TRAIL × 1, RGBA float): trail data for the shader
this.trailData = new Float32Array(MAX_TRAIL * 4);
this.trailTexture = new THREE.DataTexture(
    this.trailData,
    MAX_TRAIL, // 128
    1,
    THREE.RGBAFormat,
    THREE.FloatType,
);
this.trailTexture.needsUpdate = true;

After having the rayPlane ready, here’s what we do whenever a pointermove event fires:

  1. Using the raycaster to calculate the (x, z) coordinates of the mouse projected on the 3D grid.
  2. Calculate a distDelta that stores the distance between current cursor position and the last one. This will be useful in adjusting wave elevations.
  3. Evict the oldest point in the trail data array if we’re storing more than what the MAX_TRAIL defines.
  4. Push the new point into the trail data array, also assigning new point’s coordinates as last point’s for the next cycle.

For the trail data array, for each point we store: { x, z, age, distDelta }. We only add a new point when the cursor has moved a minimum distance (trailSpacing), which prevents the trail from becoming too dense when the mouse is almost stationary.

To pass this data to the GPU efficiently, we’ll serialize it into a DataTexture. This is a special texture where we can directly manipulate the raw pixel data. We’ll create a MAX_TRAIL x 1 texture, where each “pixel” is a 4-component float (RGBA) that stores the data for one trail point.

  • R: x position
  • G: z position
  • B: age of the point in seconds, 0 for new point
  • A: distDelta (distance from the previous point), 0 for first point

Updating the trail every frame

In our main animation loop, we call the update() method of MouseTrail. This method has two jobs:

  1. Age and Prune: iterates through the trail array, increases the age of each point, and removes any points that have exceeded their lifespan.
  2. Upload to GPU: copies the data from the trail array into our trailData Float32Array, and then flags the texture for an update by setting this.trailTexture.needsUpdate = true. This tells Three.js to upload our latest trail data to the graphics card.
// Points survive for fadeTime * 4 seconds; at that age the shader
// fade factor exp(-4) ≈ 0.018 makes them visually negligible.
const expiry = this.params.fadeTime * 4;

for (let i = this.trail.length - 1; i >= 0; i--) {
    this.trail[i].age += delta;
    if (this.trail[i].age > expiry) {
        this.trail.splice(i, 1);
    }
}
// Upload the latest MAX_TRAIL live points to the texture
const count = Math.min(this.trail.length, MAX_TRAIL);

if (count > 0 || this._uniforms.uTrailCount.value > 0) {
    for (let i = 0; i < count; i++) {
        const ti = i * 4;
        this.trailData[ti] = this.trail[i].x;
        this.trailData[ti + 1] = this.trail[i].z;
        this.trailData[ti + 2] = this.trail[i].age;
        this.trailData[ti + 3] = this.trail[i].distDelta;
    }
    this.trailTexture.needsUpdate = true;
    this._uniforms.uTrailCount.value = count;
}

And that’s it! We now have a system that continuously feeds our mouse’s path to the GPU. In the next section, we’ll finally modify the cube’s vertex shader to read this texture and create beautiful waves.

Propagate waves from the mouse trail

This is the fun part! We can finally use our trailTexture to make waves! We’ll be modifying the default MeshPhongMaterial vertex shader to displace the tops of our cubes based on their proximity to the mouse trail. Let’s dive back into Stage.js.

Extending the vertex shader

Three.js provides a powerful hook, onBeforeCompile, that lets us inject custom GLSL code into its built-in shaders before they’re compiled. This is perfect for our needs, as we can add our wave logic without writing a whole new shader from scratch.

Inside Stage.js, we’ll define a method overrideVertexShader that takes the default vertex shader and injects our wave logic. This method will also be reused by the custom depth material later. The core of this logic happens inside a loop that iterates through each point in our uTrailTexture.

Let’s break down the key concepts here:

The texture loop

// inside overrideVertexShader(vertexShader)
...inserted after #include <begin_vertex>
for ( int i = 0; i < uTrailCount; i++ ) {
    // texel layout: (worldX, worldZ, age, distDelta)
    vec4 td = texture2D(
        uTrailTexture,
        vec2( ( float(i) + 0.5 ) / 128.0, 0.5 )
    );
    ...
}

Since our DataTexture dimensions are 128×1, we only need to roll the x coordinate of the uv index. We also add 0.5 to both axes to pinpoint the center position of each texel.

Gaussian envelope

float dist      = length( worldXZ - td.rg );
float wavefront = uWaveSpeed * td.b;
float relDist   = dist - wavefront;

// Gaussian envelope centred on the expanding wavefront
float window = exp( -( relDist * relDist ) / ( uWaveWidth * uWaveWidth ) );

For each cube and each point in the mouse trail, we calculate the cube’s distance from the expanding circular wavefront. The relDist variable is key: it’s the cube’s distance from the trail point minus the distance the wave has already traveled (wavefront = uWaveSpeed * age), which gives us the distance between the wavefront and the cube.

We then use this relDist in a Gaussian function. This creates a smooth, bell-shaped “bump” centered on the wavefront. The uWaveWidth uniform controls the width of this bump. The result is a soft, natural-looking ripple.

Attenuating the waves

To make the effect feel more physical and less chaotic, we apply several forms of attenuation to control the wave’s strength (weight):

// Exponential time-fade + distance attenuation
float fade   = exp( -td.b / uFadeTime );
float atten  = 1.0 / ( 1.0 + dist * 0.1 );
float weight = fade * window * atten * td.a; // td.a is distDelta, used to weaken waves from closely spaced trail points

Time Fade: As a trail point gets older (its age, stored in td.b, increases), its influence fades out exponentially. Increase the uFadeTime uniform to slow down the time-based attenuation.

Distance Attenuation: The further a cube is from a trail point, the weaker the wave’s effect on it. This is a simple inverse relationship with distance.

Speed Attenuation: Remember distDelta from MouseTrail.js? We stored it in the ‘A’ channel of our texture. We use it here to scale the wave’s weight. If the mouse moves too slowly, distDelta is small, which weakens the wave. This prevents a stationary or slow-moving mouse from creating a huge, overpowering wave.

Wave frequency and weighted average for subtler wave fronts

float waveHeight  = 0.0;
float totalWeight = 0.0;
for ( int i = 0; i < uTrailCount; i++ ) {
    ...

    waveHeight  += weight * cos( uWaveFreq * relDist );
    totalWeight += weight;
}
// Weighted average: overlapping waves average rather than stack,
// cancelling chaotic superposition while preserving single-wave peaks.
waveHeight /= max( totalWeight, 1.0 );

Before adding the calculated weight to the waveHeight of each cube, we oscillate it with a cosine function, controlled by the wave frequency uniform. By using a higher wave frequency, you could enable multiple wavefronts instead of a single wavefront from your mouse swipes. But for the sake of a subtle aesthetic, let’s keep the wave frequency at a lower number ~1.

If multiple waves from different parts of the trail overlap, simply adding their heights can create chaotic, spiky, and visually jarring superpositions. To avoid this, we calculate a totalWeight for all contributing waves and then compute a weighted average of their heights. This ensures that when waves overlap, they blend together smoothly, creating a more subtle and pleasing effect while still allowing single, strong waves to reach their full height.

Wave jitter effect

vec2 jitter  = hash2( aOffset ) * uJitter;
vec2 worldXZ = aOffset + jitter;

If you want to break up the perfect circles of the waves, just sprinkle in a bit of “jitter”. We use a simple hash function based on the cube’s static world position (aOffset) to generate a deterministic, pseudo-random offset for each cube. This jitter is applied before the wave calculations, slightly shifting each cube’s position in the wave field.

Using the wave height to mix colors

This feature has arguably the most profound impact to the visual identity of the scene, yet it is the easiest to implement! Until now our cubes are very monotonic; adding a new color changes everything. By passing the calculated vHeight from the vertex shader to the fragment shader, we can then use this height to mix between a base color and a highlight color.

// inside material.onBeforeCompile
shader.fragmentShader = shader.fragmentShader
    .replace(
        "#include <common>",
        `#include <common>
        varying float vHeight;
        uniform vec3  uColorBase;
        uniform vec3  uColorHigh;
        uniform float uMaxHeight;`
    )
    .replace(
        "#include <color_fragment>",
        `#include <color_fragment>
        float t = clamp( vHeight / uMaxHeight, 0.0, 1.0 );
        diffuseColor.rgb = mix( uColorBase, uColorHigh, t );`
    );

This simple addition really makes the waves “pop” visually, with the peaks glowing in the highlight color and the rest of the wave tinted in a smooth gradient towards the base color.

Don’t forget the shadows!

Here’s a crucial detail: for our shadows to look correct, the objects in the shadow map pass must be deformed in exactly the same way as the objects in the main camera pass. If they don’t match, you’ll get weird artifacts where shadows don’t line up with the objects casting them.

Three.js makes this easy. InstancedMesh has a customDepthMaterial property. We can create a separate MeshDepthMaterial, give it the exact same onBeforeCompile vertex shader logic, and assign it. This ensures our wavy shadows are perfectly in sync with our wavy cubes.

// inside setGrid()
const depthMaterial = new THREE.MeshDepthMaterial();

depthMaterial.onBeforeCompile = (shader) => {
    // ... copy all uniforms from the main material ...
    shader.uniforms.uWaveSpeed = { value: this.params.waveSpeed };
    // ... etc ...

    // Use the exact same vertex shader override
    shader.vertexShader = this.overrideVertexShader(
        shader.vertexShader,
    );
};

this.instancedMesh.customDepthMaterial = depthMaterial;

Update scene background

Previously I’ve noticed sudden dark flashes in the gaps between cubes as my mouse is moving around the screen. To prevent this issue, we should ensure the background color matches the shade of the base color of the cubes. When the user changes the colorBase in the GUI, we’ll update both the shader uniform and the scene’s background property.

// inside contructor()
this.scene.background = new THREE.Color(this.params.colorBase).multiplyScalar(0.5); // half of colorBase for a more subtle background

// inside setGUI()
this.gui
    .addColor(this.params, "colorBase")
    .name("Base Color")
    .onChange((v) => {
        if (this.shaderRef)
            this.shaderRef.uniforms.uColorBase.value.set(v);
        this.scene.background = new THREE.Color(v).multiplyScalar(0.5); // half of uColorBase for a more subtle background
    });

Post-processing magic

Our scene is looking good, but let’s be honest, it’s a bit too… clean. To give the scene a more stylized look, we’re going to need post-processing. Post-processing allows us to apply full-screen effects to our rendered scene, much like applying a filter to a photograph. It’s a powerful way to define the mood and aesthetic of your work.

For this project, we’ll create a custom effect that combines a subtle vignette (darkening the corners) with a chromatic aberration (splitting the color channels). This will add to the scene a slightly dreamy, retro-futuristic vibe, as if viewed through a vintage camera lens.

The EffectComposer

First, we need to set up an EffectComposer in our Renderer.js file. This is Three.js’s tool for chaining together different post-processing passes.

The setup is straightforward. We create an EffectComposer and add a few essential passes:

  1. RenderPass: This is the foundational pass. It takes our scene and camera and renders the scene as-is, providing the input for the subsequent effects.
  2. ShaderPass: This is where our custom magic happens. We’ll feed it our own VignetteRGBShiftShader to manipulate the image.
  3. OutputPass: This is the final step. It takes the result from the previous passes and renders it to the screen.

Here’s how it looks in Renderer.js:

// ...
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
import { VignetteRGBShiftShader } from "./Effects/VignetteRGBShiftShader.js";

export default class Renderer {
    // ...
    setPostProcessing() {
        this.composer = new EffectComposer(this.instance);
        this.renderPass = new RenderPass(this.scene, this.camera.instance);
        this.composer.addPass(this.renderPass);

        this.vignetteRGBShiftPass = new ShaderPass(VignetteRGBShiftShader);
        this.vignetteRGBShiftPass.uniforms.shiftAmount.value = 0.005; // Adjust the intensity of the RGB shift
        this.vignetteRGBShiftPass.uniforms.vignetteRadius.value = 0.3; // Adjust where the effect starts (0.0 to 1.0)
        this.vignetteRGBShiftPass.uniforms.vignetteSoftness.value = 0.3; // Adjust the falloff smoothness of the effect
        this.composer.addPass(this.vignetteRGBShiftPass);

        this.outputPass = new OutputPass();
        this.composer.addPass(this.outputPass);
    }
    // ...
    update() {
        // render via composer when you have post-processing effects
        this.composer.render();
    }
}

The Custom VignetteRGBShiftShader

Now for the home-made post-processing recipe! You may wonder that there are built-in effects for Vignette and RGBShift, why make a custom shader? The answer is simple: what I’m trying to achieve is a blended effect of the two; using the vignette as a mask to apply the chromatic aberration effect. Also, to add more depth to the scene, we scale the color shift based on the strength of the vignette mask.

Here’s how the fragment shader is implemented in VignetteRGBShiftShader.js:

// ...
void main() {
  // 1. Calculate distance from the center of the screen
  vec2 center = vec2(0.5);
  float dist = distance(vUv, center);

  // 2. Create the vignette mask
  float vignetteFactor = smoothstep(vignetteRadius, vignetteRadius + vignetteSoftness, dist);

  // 3. Scale the shift intensity based on the vignette mask
  float currentShift = shiftAmount * vignetteFactor;

  // 4. Sample the color channels with the dynamic shift
  float r = texture2D(tDiffuse, vUv + vec2(currentShift, currentShift)).r;
  float g = texture2D(tDiffuse, vUv).g;
  float b = texture2D(tDiffuse, vUv - vec2(currentShift, currentShift)).b;

  // 5. Apply a standard darkening vignette overlay
  float darken = 1.0 - vignetteFactor * 0.5;

  gl_FragColor = vec4(vec3(r, g, b) * darken, 1.0);
}

Before and After:

before postprocessing was added
after postprocessing was added

Production-ready optimizations

The code you see has been optimized well for production release, but I think it’s helpful to list out what I’ve optimized before releasing this online.

  • Reduced texture loop iterations: Reduced MAX_TRAIL from 200 to 128 for data texture width and the vertex shader. This reduced the shader computation cost, lowering GPU pressure.
  • Efficient Texture Management: Added conditional checks to skip needsUpdate on the trail data texture when no points are active, reducing unnecessary CPU-to-GPU data transfers.
  • Shader Optimization: Simplified the wave height calculation in the vertex shader by skipping the bottom vertices of the cubes, which are not visible and don’t contribute to the final visual effect. This further reduces the computational load by 50% without affecting the visual quality.

Workaround for mobile devices

There was a significant problem for mobile devices; they don’t have a mouse pointer to trigger the waves! Although the user could still swipe on the screen with fingers to trigger the same pointermove events, but that is not intuitive, especially when my landing page is taking up all the screen space already, with no scrolling needed.

I came up with a simple fix: to start adding random points to the trail after 3 seconds of inactivity, and stop when the user touches the screen or moves the mouse. Moreover, the page starts with isPlacingRandomPoint = true, so that some waves will start rippling across even if the mobile user hasn’t touched or swiped the screen yet.

Customize your wave grid!

This is the end of my tutorial and thank you for reading this far. Finally, who doesn’t love a scene with params to play and tweak around with? Here are some fun examples of what could be achieved to change the visual style of the grid:

Credits: landing page overlay design from the free template.