Conteúdo deste artigo
- The Idea
- What We are Building
- Project Structure
- Setting Up the Stage
- Setting the Camera’s Z Position
- The Mesh Class
- The Vertex Shader
- The Fragment Shader
- Displaying the Textures Fullscreen
- Building a Coordinate System for the Square
- Creating the Square Mask
- Correcting the Aspect Ratio
- Compositing the Two Images
- Applying the CC Lens Distortion
- Adding a Radial RGB Shift
- Making the Square Follow the Mouse
- Adding Motion to the Outside Image
- Adding GUI Controls
- Defining the Shader Parameters
- Creating the GUI
- Updating the Uniforms
- Conclusion
Editor’s Note: We’re delighted to have Tomoyuki Nakata, Creative Developer at baqemono, join our little Three.js celebration ahead of the very first Three.js conference in Paris. As part of our Three.js marathon, he’s bringing us this fantastic mouse-following lens effect built with Three.js and GLSL. We’re thrilled to have him along for the ride! Enjoy!
🇫🇷 Wait… you still don’t have your ticket? The very first Three.js conference is coming to Paris, and tickets won’t wait forever. Use code CODROPS for 15% off and grab your ticket before they sell out →
The Idea
What happens when you combine a grayscale image, a colored image, and a lens effect that follows the mouse? In this tutorial, we will build a mouse-following square lens effect using Three.js and GLSL.
The effect layers two images: a grayscale image covers the screen, while a color image is revealed through a square area that follows the mouse pointer. Inside this square, we add a lens distortion and a radial RGB shift, while the grayscale image has its own subtle wave and noise-like motion.
Although the final result looks fairly complex, the effect is built from a small number of simple pieces. We will create the square mask, keep it square regardless of the viewport size, apply the lens and RGB shift effects in the fragment shader, and smoothly animate the square as it follows the mouse. We will also add a GUI so that the different parameters can be adjusted in real time.
The idea for this project came while I was browsing Pinterest and found a design where part of a grayscale image appears to be cut out by a colored square with a lens effect. You can see the original reference here. It looked like something that could be recreated with WebGL, and I thought the result would be even more interesting if the square followed the mouse and the foreground grayscale image had a separate effect. So I decided to recreate it with Three.js and GLSL.
What We are Building
The final effect consists of the following elements:
- A grayscale image covering the entire screen
- A color image visible only inside the square
- A CC Lens-style distortion that bulges outward from the center of the square
- An RGB shift that becomes stronger toward the edges of the square
- A square mask that remains square regardless of the screen size
- Smooth mouse interaction with a slight delay
- Wave and random distortion applied to the grayscale image
- A GUI for adjusting the parameters in real time
Although the result may look a little complex, it does not use post-processing with a render target or any 3D models. Instead, we create the entire effect inside a fragment shader by combining the elements above.
Project Structure
The files and their roles are organized as follows:
###PRE_3dbbda20ede691c6e8f776e5ab66d79b###
glsl:chunkscontains reusable functions,fragcontains fragment shaders, andvertcontains vertex shaders.mesh(Mesh): Manages the window size, texture loading, mesh creation, mesh sizing, uniform updates, and related tasks.stage(Stage): Manages the scene, scene sizing, camera and renderer creation, and their updates.Webgl: Creates and initializes theMeshandStageclasses, connects the GUI, registers events, and manages the render loop.
The class structure is fairly conventional, so this article focuses primarily on the fragment shader implementation.
Setting Up the Stage
Let us begin with the Stage class. This class creates the scene, camera, renderer, and other essentials for working with Three.js, and sets up rendering and resize handling. Most of it is standard, but one detail worth explaining is how the camera’s Z position is set.
Setting the Camera’s Z Position
The camera’s Z position is set using a function called calcViewportDistance.
###PRE_ebb604d81357706205fcda028be5242b###
Without going into the mathematical details, this calculation finds the distance at which the visible height of the camera matches height. By scaling a 1 x 1 plane to the viewport width and height, the entire mesh fits precisely within the camera’s field of view (FOV).
The Mesh Class
Next, let us briefly look at the Mesh class. It handles everything related to the mesh, including window sizing, texture loading, mesh creation with geometry and material, mesh sizing, and uniform management and updates. Like the Stage class, its structure is fairly standard, so from here we will build the final appearance step by step through the shader implementation.
The Vertex Shader
Because this effect does not deform any vertices, the vertex shader is simple: it passes the geometry’s UV coordinates to the fragment shader and transforms the plane’s vertex positions into screen-space coordinates that account for the mesh scale, camera position, FOV, and related settings.
###PRE_0765e017471634e5c2911f6f47d33d31###
The Fragment Shader
Displaying the Textures Fullscreen
Now let us move on to the fragment shader. We will begin by displaying the two images used in this effect across the full screen. The following code in the Mesh class’s setTexture method loads them: texture1 is the color image shown inside the square, while texture2 is the grayscale image shown across the area outside it.
###PRE_f4936b746169867f1e4d95ad7c5b1c75###
In the fragment shader, we sample colors from both images (textures) and first confirm that each one fills the screen. The two images used here have the same aspect ratio, so u_textureSize1 and u_textureSize2 could be combined into a single value. However, we calculate them separately using each texture’s dimensions to support images with different aspect ratios as well.
###PRE_888af4ab94f959df8a64615aae9789df###
The getCoverUv function used here prevents the image from stretching when its aspect ratio differs from the viewport. Rather than using v_uv directly, it creates UV coordinates that preserve the image’s aspect ratio and crop it from the center, just like CSS background-size: cover.
###PRE_5c952fec47e365492bcc3af43f95e41d###
The color image now fills the screen as shown below.

You can find the code up to this point in 01-display-color-image.glsl.
Once the color image is visible, replace the final line with the following code and confirm that the grayscale image is displayed as well.
###PRE_5a86171bf91a1756f35886ba7421eebe###

You can find the code up to this point in 02-display-grayscale-image.glsl.
The color and grayscale image values are named insideColor and outsideColor, respectively, to make their roles in the final result easier to understand.
Building a Coordinate System for the Square
Next, we create a mask that switches between the grayscale and color images. To make the square’s center and size easier to work with, we convert v_uv to the -1.0 to 1.0 range and create a coordinate system with its origin at the center of the screen.
###PRE_9b17696812f7d77946c3949392c5c48c###
Creating the Square Mask
We use the uvSquare coordinates created above to build the mask. Treating u_squareSize as half the length of one side of the square, we define its left, right, bottom, and top boundaries.
###PRE_9639db6d31613e4ca9d2de917a897840###
We use step to test whether the current pixel lies within each of the four boundaries, then multiply the four results together. This produces 1.0 inside the square, where every condition is satisfied, and 0.0 everywhere else.
###PRE_2c3cc283297d1365d2be7fd5f4cadb3d###
To check the shape of the mask, we output the squareMask value directly as a color.
###PRE_e2fb7bfd9b3ccf10835b14daa1027030###
The inside of the mask appears white and the outside black, producing a white rectangle in the center.

You can find the code up to this point in 03-create-square-mask.glsl.
Correcting the Aspect Ratio
We now have a rectangular mask, but its aspect ratio changes when the viewport is resized. To correct this, we use the mesh dimensions to calculate an aspect-ratio correction factor for determining the square’s bounds.。
###PRE_04d26dcadb9c15e377b1620e97725156###
We divide the coordinates used for the square test by this aspect-ratio correction factor, adjusting the mask’s visible area so that the square does not stretch horizontally or vertically.
###PRE_0f6f9034067fc66222f9ab43479c9ee2###

You can find the code up to this point in 04-correct-mask-aspect-ratio.glsl.
Compositing the Two Images
We use the squareMask value to blend the grayscale and color images. Where the mask is 0.0, outsideColor is selected; where it is 1.0, insideColor is selected. The result is a square crop of the color image displayed over the grayscale image.
###PRE_8d1f3c04a8f8d5592be92d7e2e63ec89###

You can find the code up to this point in 05-composite-images.glsl.
Applying the CC Lens Distortion
Next, we apply a CC Lens-style distortion to the color image inside the square. First, we convert uvSquare to the 0.0 to 1.0 range to create local UV coordinates called squareUv. These coordinates let us calculate the lens distortion relative to the square’s center, even when the square’s size changes.
###PRE_9ee14755a51aef79411848d5482322d9###
We then pass these UV coordinates to the getCCLensUv function to produce UV coordinates distorted by the CC Lens effect. The function is defined as follows:
###PRE_eedacdfe73285f275b3bb000384818bb###
The function first uses uv - 0.5 to move the center of the UV coordinates to the origin, then uses dot to calculate the squared distance from that center. Based on this distance, it calculates a scale that changes more for pixels farther from the center and offsets the texture sampling position. Because we are applying the function to local UV coordinates inside a square, we pass vec2(1.0) as resolution to represent a 1:1 aspect ratio. For distortion, we pass the u_lensDistortion uniform so that its value can later be changed through the GUI.
###PRE_22bd89cfbd11986f4b29735fc82b49bd###
We now have distortedSquareUv, the distorted local UV coordinates inside the square. However, using them directly as the color image’s UV coordinates would remap the entire image into the square. We want the grayscale and color images to remain aligned in size and position while distorting only the color image, so we calculate an offset from the difference between the UV coordinates before and after distortion. squareLensOffset stores how far the CC Lens effect moved the local UV coordinates within the square.
###PRE_3ef04b0a212053c2f85e2781f4ef4b9a###
Adding this offset directly to the full-screen v_uv would treat a square-relative movement as though it were relative to the entire screen, making the distortion too large. We therefore multiply it by the square size and the aspect-ratio correction factor, converting the square-relative offset into viewportLensOffset, an offset relative to the full-screen UV coordinates.
###PRE_0af85a1ce190d97c295363120cbbe59e###
We add the converted viewportLensOffset to v_uv to create the UV coordinates used to sample the color image.
###PRE_38452d8489264bf216791afe283faaa9###
We then replace the texture1Uv previously used to sample insideColor with lensTexture1Uv.
###PRE_804db006cb5c7d9bd36b51d700736273###
The color image now appears with lens distortion inside the square mask.

You can find the code up to this point in 06-apply-cc-lens-distortion.glsl.
Adding a Radial RGB Shift
In addition to the lens distortion, we apply an RGB shift inside the square. Simply adding a constant value to the UV coordinates would shift the colors in the same direction and by the same amount everywhere in the square. Instead, as with the lens effect, we use the center of the square as the reference point so that there is no color shift at the center and the shift becomes stronger toward the outside.
The squareUv coordinates used for the lens distortion are local UV coordinates where the bottom-left corner of the square is 0.0 and the top-right corner is 1.0. For the RGB shift, we need the direction and distance from the square’s center to each pixel. We first subtract 0.5 from squareUv to move the square’s center to the origin, then multiply by 2.0 to create rgbShiftDirection in a size-independent -1.0 to 1.0 range.
###PRE_f8604e5c6a6ada99f81e5b5f3729a9a2###
The shift amount for each RGB channel is supplied through three uniforms: u_rgbShiftR, u_rgbShiftG, and u_rgbShiftB. These values will also be adjustable through the GUI. Each component of rgbShiftDirection is 0.0 at the center of the square and reaches -1.0 or 1.0 at the corresponding edge, so each uniform sets the maximum per-axis UV shift at that edge. Positive and negative values shift in opposite directions, while 0.0 leaves that channel unchanged.
###PRE_662f2c71734f6d6f418418c1d55b29ae###
Next, starting from the lens-distorted lensTexture1Uv, we add an offset for each channel by multiplying rgbShiftDirection by its shift amount. We then recombine the sampled R, G, and B values into insideColor.
###PRE_9d399fc986799c4eb3afb1718c096068###
The RGB shift is now applied. With the default values of 0.01 for R, 0.0 for G, and -0.01 for B, all three channels overlap at the center of the square, while R and B separate in opposite directions toward the edges. Later, when we subtract u_mouse from uvSquare to move the square, the origin of the RGB shift derived from squareUv moves together with the center of the lens.

You can find the code up to this point in 07-apply-rgb-shift.glsl.
Making the Square Follow the Mouse
Next, we make the square mask and the center of the lens follow the mouse. Add a u_mouse uniform and the following line:
###PRE_dd63a115892ed90cdeb488b58803a50c###
DOM mouse coordinates are measured in pixels from the top-left corner, while uvSquare uses coordinates in the -1.0 to 1.0 range with the origin at the center of the screen. We therefore convert the mouse coordinates to the same range in TypeScript.
###PRE_2d4df742472e03760fabfc8809d9e433###
The Y coordinate is negated because DOM coordinates increase downward, whereas the shader treats upward as the positive direction.
Then, inside the render method, we use Vector2.lerp for linear interpolation and copy the resulting this.mouseEase value to u_mouse, making the effect follow the mouse smoothly.
###PRE_848781aed4e19a08b7a4280390036fc2###
The square mask and lens center now use the mouse position as their origin and follow its movement smoothly.
You can find the code up to this point in 08-follow-mouse.glsl.
Adding Motion to the Outside Image
Next, we add some subtle motion only to the grayscale image covering the screen. Because both the wave distortion and random distortion we are about to create animate over time, we first add a u_time uniform.
###PRE_df943081bf510faa23f57be57b924f48###
On the TypeScript side, we convert the elapsed time returned by performance.now() to seconds and pass it to u_time on every frame.
###PRE_7291428256a1ef53e98854bd44791a8d###
Adding the Wave Distortion
We use sin to add a wave-like effect. The three values u_waveFrequency, u_waveSpeed, and u_waveStrength are added as uniforms so they can be adjusted through the GUI.
###PRE_a0cf7f770049a8a19e97935f63a9dca5###
Using these uniforms, we apply a wave-like offset to the grayscale image’s sampling position along the Y axis.
###PRE_a5bb39a65c998ac520997b0239803edf###
Adding the Random Distortion
Next, we use the random3 function to add fine, noise-like distortion. As with the wave, u_randomFrequency, u_randomSpeed, and u_randomStrength are added as uniforms so they can be adjusted through the GUI.
###PRE_f0e5a2da98072b4f2f63886f72f09fd7###
The random3 function is adapted from this Shadertoy example.Rather than writing this function directly in the fragment shader, we move it into glsl/chunks/random3.glsl so that it can be reused by other shaders.
###PRE_cc66fb73995dec4e6eb6ba8f603330d6###
Before the main function in frag.glsl, we include this chunk in the same way as coverUv.glsl and ccLens.glsl. During the build, the function body is inserted at the #include directive, allowing main to call it like a regular GLSL function.
###PRE_3e130ebd5e5264e6558afbae7c831a4d###
Inside main, we use the random3 function to offset texture2Uv as follows:
###PRE_0f4c9ce5688ebdad6fe5a58a7d00117f###
This completes the effect shown at the beginning!

Adding GUI Controls
The visual effect and interaction are now complete. Finally, we add the parameters to a GUI so that we can adjust the square size, lens strength, RGB shift amounts, wave and random motion, and more.
Defining the Shader Parameters
The Webgl class manages the logic that ties the demo together, including initialization of the Stage and Mesh classes, event registration, and the render loop. Since this is a conventional structure, we will skip the details and focus on creating the GUI and updating the shader parameters. The default parameter values are managed in the Webgl class’s constructor.
###PRE_c59ac9d4c32116b6b961eb2767b92cb7###
Creating the GUI
The GUI is created and its parameters are added in the Webgl class’s setGUI method. Here, we add the parameters to folders organized by effect.
###PRE_0acd15126b67cb31aa192c0d4bce194d###
Updating the Uniforms
When a parameter changes in the GUI, shaderParams is passed to the Mesh class through the updateShaderParams method.
###PRE_cc8e10905462d3a7fce736933eea6753###
In the Mesh class, the values received from the GUI are assigned to their corresponding uniforms as shown below. Because pointerEase is not passed to the shader, it is updated as a property of the Mesh class instead. This lets us change the parameter values in real time.
###PRE_fa7f9810615cc7eebdd7ba006b61ca2b###
The GUI is now complete as well. Try adjusting the values to see how they change the appearance of the effect!
Conclusion
What did you think? I hope this article showed that even an effect that looks complex at first glance can be built by combining small, simple elements. You could also add your own parameters to this implementation or change the adjustable ranges to create something original. This project was inspired by a single image I found on Pinterest, but ideas are everywhere: in images, websites, videos, and everyday life. When something catches your attention, try turning it into code as we did here. Finally, if you have any questions about this article, feel free to contact me on X. Thank you for reading!
