For his 2026 portfolio, Gionatan Nese got here up with “just a bit thought”: to rework each picture right into a circle that responds to your mouse actions whereas sustaining the looks of a film. Sure, it’s not rocket science. All you might want to do is organize a bunch of images exactly round a circle to create a {smooth}, elegant look.
Easy, proper? Let’s discover out!
Half 1: Laying the Photographs Out on a Circle

Mathematical Basis
The round association is predicated on fundamental trigonometric guidelines (principally highschool maths). For every a part of the sequence:
Angle = (index / totalElements) × 2π
X place = radius × cos(angle)
Y place = radius × sin(angle)
This works as a result of mathematically it is going to place the item equidistantly across the central level.
Code Implementation
Okay, sufficient speaking. Right here’s the way it works:
for (let i = 0; i < config.numPlanes; i++) {
const angle = (i / config.numPlanes) * Math.PI * 2;
const x = Math.cos(angle) * config.radius;
const y = Math.sin(angle) * config.radius;
// Create dual-sided planes for correct visible presentation
const entrance = new THREE.Mesh(geometry, frontMaterial);
const again = new THREE.Mesh(geometry, backMaterial);
// Orient planes towards middle
entrance.place.set(x, y, 0);
entrance.rotation.z = angle;
planeData.push({
baseAngle: angle,
baseX: x,
baseY: y
});
}
Wait, Why Two Planes Per Picture?
Good query. You might be in all probability considering we may simply use “THREE.DoubleSide” and name it a day. However no, we’re fancy and we care a ton even for smaller particulars.
The Drawback: When the carousel rotates, you see either side of every airplane. With a single double-sided airplane, the again aspect reveals your picture mirrored… or we are able to say flipped. Like taking a look at it in a mirror. Not very skilled.
The Resolution: Create TWO meshes per picture:
const materialFront = new THREE.MeshStandardMaterial({
map: textureFront,
aspect: THREE.FrontSide, // Solely renders entrance face
metalness: 0,
roughness: 0,
clear: true
});
const materialBack = new THREE.MeshStandardMaterial({
map: textureBack,
aspect: THREE.BackSide, // Solely renders again face
metalness: 0,
roughness: 0,
clear: true
});
// The magic trick: flip the again texture vertically
textureBack.wrapT = THREE.RepeatWrapping;
textureBack.repeat.y = -1; // ← This reverses the picture vertically
Why This Works
- Entrance face reveals picture usually
- Again face reveals a vertically flipped model
- When considered from behind, the flip cancels out the mirror impact
- Outcome: readable picture from either side
Efficiency Observe: Sure, this doubles the mesh depend. However every airplane is simply 2 triangles. You’ll survive. (I hope so)
As you’ll be able to see, since all of the planes properly face the middle (they’re all rotated to level to the middle), the two-plane trick avoids the scene from having an odd or poor-looking look when the carousel spins. The picture under could assist make clear our makes an attempt to align the playing cards.

Now the following step is to align your digicam such that solely a sure section of the circle is seen to you. (Play with digicam positioning)
The Shade Area Factor No one Talks About
Oh, and add these two traces? They make a MASSIVE distinction:
textureFront.colorSpace = THREE.SRGBColorSpace;
textureBack.colorSpace = THREE.SRGBColorSpace;
With out it: Your photographs look washed out, desaturated, and usually sadish.
Why?
- Picture recordsdata (JPG, PNG, WebP) retailer colours in sRGB coloration house (gamma-corrected for shows)
- Three.js renders internally in linear coloration house (bodily correct for lighting calculations)
- If you happen to don’t inform Three.js your textures are sRGB, it assumes they’re linear
- Outcome: colours get double-gamma-corrected and look horrible
Let’s consider it as telling Three.js, “Hey, this picture is already display-ready, don’t mess with it.”
Half 2: Scroll Interplay Physics
Preliminary Implementation (The Boring Approach)
You scroll and it spins. Unimaginable expertise, I do know. Uhm… please clap. Anyhow…
let rotation = 0;
const onScroll = (occasion) => {
rotation += occasion.deltaY * 0.01;
};
Positive, this strategy works… when you’re okay with all the pieces feeling a bit low-cost and never like gliding in butter. As a result of it lacks smoothness, momentum, or any sense that these planes have precise weight.
Enhanced Movement Dynamics (Including Weight)
Since we’re going for luxurious vibes, we applied a dual-value construction that preserves momentum, as a result of {smooth} movement is form of the entire level.
let targetRotation = 0; // The place we WANT to be
let currentRotation = 0; // The place we ACTUALLY are
const onScroll = (occasion) => {
const scrollAmount = occasion.deltaY * config.scrollSensitivity;
const constrainedScroll = Math.max(-config.maxMomentum,
Math.min(config.maxMomentum, scrollAmount));
targetRotation += constrainedScroll;
};
The goal/present break up is all the pieces. Goal updates immediately along with your scroll enter. Present chases after it easily. This creates that buttery, physics-based really feel.
The Animation System (Lerp Is Your Finest Buddy)
The animation loop creates a {smooth} scroll-to-spin expertise:
const animate = () => {
currentRotation += (targetRotation - currentRotation) * config.dampening;
planes.forEach((airplane, i) => {
const newAngle = airplane.baseAngle + currentRotation;
const newX = Math.cos(newAngle) * config.radius;
const newY = Math.sin(newAngle) * config.radius;
airplane.place.set(newX, newY, 0);
airplane.rotation.z = newAngle;
});
};
Understanding Linear Interpolation (Lerp)
Time for a fast sidebar as a result of this sample seems in each scroll carousel on Awwwards:
present += (goal - present) * issue;
The way it works
- Calculate the distinction between your present place and your goal: (goal – present)
- Shut a share of that hole: * issue
- Add it to your present worth: present +=
Instance with issue = 0.1 (10% per body)
This may increasingly assist perceive it higher:
present = 0, goal = 100
Body 1: present = 0 + (100 - 0) * 0.1 = 10
Body 2: present = 10 + (100 - 10) * 0.1 = 19
Body 3: present = 19 + (100 - 19) * 0.1 = 27.1
Body 4: present = 27.1 + (100 - 27.1) * 0.1 = 34.4
As we are able to see, on every body the present rotation begins to return nearer to the goal rotation worth, and at one level they each would develop into equal, cancelling one another out. In outcome, the carousel stops spinning.
The issue controls the pace
- 0.05 = gradual, heavy, luxurious (takes ~60 frames to achieve 95%)
- 0.1 = balanced (takes ~30 frames)
- 0.3 = snappy, responsive (takes ~10 frames)
I counsel utilizing a smaller worth because it makes the carousel really feel prefer it really has weight, so the rotation form of glides after the goal with this fake-but-fancy sense of inertia.
Half 3: The Cursor-Dependent Interactive Layer
Coordinate Transformation (Raycasting)
“As a result of your mouse is flat and the world is 3D, we’ve got to shoot it a tiny invisible laser and hope it lands in the suitable spot. Welcome to raycasting.” — Yousuf
Okay, sufficient jokes… Mainly, what’s taking place is that this: your mouse provides us an X and Y on the display. That’s it, only a flat little dot. However our carousel lives in a 3D world. So we “solid” an invisible ray from the digicam by that dot and see the place it hits our carousel airplane. That hit level tells us precisely the place in 3D house your mouse is pointing.
const onMouseMove = (occasion) => {
// Step 1: Convert pixel coordinates to NDC (Normalized System Coordinates)
// NDC ranges from -1 to +1 for each X and Y
mouse.x = (occasion.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(occasion.clientY / window.innerHeight) * 2 + 1;
// ↑ Observe the unfavourable! Display screen Y goes down, WebGL Y goes up
// Step 2: Create a ray from the digicam by the mouse place
raycaster.setFromCamera(mouse, digicam);
// Step 3: Outline the carousel airplane (Z = 0 airplane)
const carouselPlane = new THREE.Aircraft(new THREE.Vector3(0, 0, 1), 0);
// Step 4: Discover the place the ray intersects our airplane
const intersectionPoint = new THREE.Vector3();
const intersection = raycaster.ray.intersectPlane(carouselPlane, intersectionPoint);
if (intersection) {
cursorWorldPosition.copy(intersectionPoint);
}
};
Breaking it down
- Your mouse place in pixels means nothing to WebGL, so we convert it into that particular −1 to +1 vary so WebGL is aware of the place the mouse is on the display.
- Think about firing a laser beam from the digicam, by the mouse level, into the 3D scene. That beam tells us precisely what path the mouse is pointing in 3D house.
- Your carousel lives on the flat airplane at Z = 0. We verify the place the ray (the laser beam) hits that airplane.
- This provides you an actual 3D place like (x: 5.2, y: –3.8, z: 0). Now the place the mouse exists inside your 3D world, you’ll be able to examine that to the carousel’s factor.
Proximity-Based mostly Interplay Zones
Every airplane responds to cursor proximity by graduated affect zones:
planes.forEach((airplane, i) => {
const distance = calculateDistance(airplane.place, cursorWorldPosition);
if (distance < config.interactionRadius) {
if (distance < config.fullRotationRadius) {
// INNER ZONE: Full impact
targetRotationX = config.maxRotation;
targetOffset = config.maxZ;
} else {
// GRADIENT ZONE: Clean falloff
const proximityRatio = 1 - ((distance - config.fullRotationRadius) /
(config.interactionRadius - config.fullRotationRadius));
targetRotationX = proximityRatio * config.maxRotation;
targetOffset = proximityRatio * config.maxZ;
}
}
// Clean interpolation to focus on values
airplane.currentRotationX += (targetRotationX - airplane.currentRotationX) * config.lerpFactor;
airplane.currentOffset += (targetOffset - airplane.currentOffset) * config.lerpFactor;
});

This creates that buttery-smooth gradient the place planes step by step reply as you strategy them, as a substitute of snapping on/off like a light-weight swap.
Digicam Zoom Enhancement
The digicam joins the drama by zooming out and in everytime you work together. Very elegant. Very further. Very tacky…
if (anyPlaneHovered) {
targetCameraZ = config.cameraZ + config.cameraZoomAmount;
} else {
targetCameraZ = config.cameraZ;
}
currentCameraZ += (targetCameraZ - currentCameraZ) * config.cameraZoomSpeed;
digicam.place.z = currentCameraZ;
Why zoom? It creates focus. If you work together with a airplane, the digicam strikes nearer, making that airplane really feel extra necessary. It’s cinematic framing, drawing consideration to what the person is taking a look at.
Values for the zoom that I used:
- cameraZ: 12.5 – Base digicam distance
- cameraZoomAmount: 3.5 – How a lot nearer we get (zooms to 16)
- cameraZoomSpeed: 0.05 – Clean lerp issue
Discover we’re utilizing lerp once more. As a result of all the pieces that strikes on this impact makes use of lerp. It’s lerps all the way in which; it’s essential for buttery motion…
Half 5: Distortion & Chromatic Aberration (The Visible Enhance Layer)
Why Submit-Processing As a substitute of Materials Results?
Earlier than we dive into shader code, let’s tackle the elephant within the room: “Why not simply distort the airplane supplies?”
Submit-processing strategy — what we’re doing
- Impacts the entire rendered body, not particular person objects
- Edge distortion is screen-space (constant visible impact)
- Single shader go (environment friendly)
- Impartial of scene complexity
- Applies AFTER all the pieces is rendered
Materials-based strategy (what we’re NOT doing)
- Would wish to distort the UV coordinates of every airplane individually
- Wouldn’t naturally have an effect on the sides of the display
- Harder to coordinate throughout 18+ objects
- Can’t simply create lens-style results
Purpose: Our aim is to realize a “classic lens distortion” just like wanting by barely warped glass. The impact requires screen-space results which warp the ultimate picture, not object-space deformation of particular person meshes.
Consider it like Instagram filters: they have an effect on the entire photograph after it’s taken, not particular person objects earlier than the shot. (Mainly, you don’t look good earlier than; you look good after post-processing… Simply kidding)
Understanding the Layered Visible Results
To make the visuals really feel further premium, a shader fuses two results collectively right into a single, very dramatic expertise utilizing the post-processing pipeline.
- Horizontal Wave Distortion – provides a tender ripple, just like the scene is whispering
- Chromatic Aberration – sprinkles a little bit of coloration separation for that “I really feel costly” look
We solely present these results on the highest and backside edges, so it feels cinematic, not overwhelming.
Full Shader Implementation
Right here is the precise shader code used to realize these cinematic visible results:
const DistortionChromaticAberrationShader = {
uniforms: {
tDiffuse: { worth: null }, // Unique render texture
uTime: { worth: 0 }, // Animation timeline
uStrength: { worth: 0.027 }, // Distortion depth
uFreq: { worth: 40.0 }, // Wave frequency
uSpeed: { worth: 0.0 }, // Animation pace (0 = static)
uTopStart: { worth: 0.89 }, // Prime impact begin (89% from backside)
uBottomEnd: { worth: 0.11 }, // Backside impact finish (11% from backside)
uChromaticAberration: { worth: 0.013 } // Shade separation power
},
vertexShader: `
precision highp float;
various vec2 vUv;
void important() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(place, 1.0);
}
`,
fragmentShader: `
precision highp float;
uniform sampler2D tDiffuse;
uniform float uTime;
uniform float uStrength;
uniform float uFreq;
uniform float uSpeed;
uniform float uTopStart;
uniform float uBottomEnd;
uniform float uChromaticAberration;
various vec2 vUv;
// Clean interpolation perform for gradual transitions
float ramp(float begin, float finish, float place) {
return smoothstep(begin, finish, place);
}
void important() {
vec2 uv = vUv;
// STEP 1: Create Vertical Masks for Prime and Backside Areas
// This creates a worth that's 1.0 at display edges and 0.0 within the middle
float topFactor = ramp(uTopStart, 1.0, uv.y); // Ramp up at high
float bottomFactor = 1.0 - ramp(0.0, uBottomEnd, uv.y); // Ramp up at backside
float masks = topFactor - bottomFactor; // Mix each areas
// STEP 2: Generate Animated Wave Sample
// Creates horizontal sine waves that transfer over time
float wave = sin((uv.x * uFreq) + (uTime * uSpeed));
float displacement = masks * wave; // Apply vertical masks to wave
// STEP 3: Apply Base Distortion (Horizontal Warping)
vec2 warpedUv = uv;
warpedUv.x += displacement * uStrength; // Shift X coordinates
warpedUv = clamp(warpedUv, 0.0, 1.0); // Stop sampling exterior texture
// STEP 4: Apply Chromatic Aberration (Shade Separation)
// The CA depth is proportional to each the masks and base distortion
float chromaticIntensity = uChromaticAberration * masks;
// Create separate UV coordinates for every coloration channel
vec2 redChannelUv = warpedUv;
redChannelUv.x += displacement * chromaticIntensity; // Pink shifts proper
vec2 greenChannelUv = warpedUv; // Inexperienced stays centered
vec2 blueChannelUv = warpedUv;
blueChannelUv.x -= displacement * chromaticIntensity; // Blue shifts left
// STEP 5: Pattern Every Shade Channel Individually
vec4 redColor = texture2D(tDiffuse, redChannelUv);
vec4 greenColor = texture2D(tDiffuse, greenChannelUv);
vec4 blueColor = texture2D(tDiffuse, blueChannelUv);
// STEP 6: Recombine Shade Channels with RGB Separation
vec4 finalColor = vec4(
redColor.r, // Pink from right-shifted pattern
greenColor.g, // Inexperienced from centered pattern
blueColor.b, // Blue from left-shifted pattern
greenColor.a // Alpha from centered pattern
);
// STEP 7: Gamma Correction for Correct Shade Show
// Convert from linear coloration house to sRGB for proper look
finalColor = vec4(pow(finalColor.rgb, vec3(1.0 / 2.2)), finalColor.a);
gl_FragColor = finalColor;
}
`
};
Detailed Shader Breakdown: How Every Layer Works
Layer 1: Vertical Masks Creation
The inspiration of the impact is a rigorously constructed masks that determines the place the distortion happens:
float topFactor = ramp(uTopStart, 1.0, uv.y); // 0.0 to 1.0 from 89% to high
float bottomFactor = 1.0 - ramp(0.0, uBottomEnd, uv.y); // 1.0 to 0.0 from backside to 11%
float masks = topFactor - bottomFactor; // 1.0 at edges, 0.0 in middle
What smoothstep does (inside ramp):
smoothstep(0.89, 1.0, y):
y = 0.89 → returns 0.0 (no impact)
y = 0.945 → returns 0.5 (50% impact)
y = 1.0 → returns 1.0 (full impact)
This makes for a transitional space through which the results feather out and in properly, avoiding abrupt visible edges. The masks is actually saying, “Be invisible within the center, seen on the edges, and {smooth} in between.”
Layer 2: Wave Sample Era
The horizontal distortion makes use of a sine wave sample:
float wave = sin((uv.x * uFreq) + (uTime * uSpeed));
Breaking it down:
- uv.x * uFreq (40.0): Controls what number of waves seem throughout the display
- Increased frequency = extra waves = tighter ripples
- Decrease frequency = fewer waves = broader ripples
- uTime * uSpeed: Animates over time
- uSpeed = 0.0 (our default): Static impact, no animation
- uSpeed = 2.0: Waves slide throughout the display
- sin() ranges between -1.0 and 1.0, subsequently characterising the push-pull movement
The outcome: A horizontal wave sample, oscillating from left to proper alongside the display.
Layer 3: Distortion Utility

The wave sample is utilized to the horizontal coordinates:
warpedUv.x += displacement * uStrength;
warpedUv = clamp(warpedUv, 0.0, 1.0);
What’s taking place:
- displacement is our masked wave (solely lively at high/backside)
- uStrength (0.027) controls how far pixels shift
- We solely distort the X coordinate (horizontal warp)
- clamp() prevents studying exterior the feel bounds
With out clamp:
warpedUv.x = clamp(1.05, 0.0, 1.0) = 1.0 // Protected
Outcome: Repeats edge pixels, appears to be like tremendous
This creates the attribute horizontal bending impact, strongest on the display edges and fading towards the middle.
Layer 4: Chromatic Aberration Implementation

That is the place the magic occurs: we create the colour separation impact:
float chromaticIntensity = uChromaticAberration * masks;
vec2 redChannelUv = warpedUv;
redChannelUv.x += displacement * chromaticIntensity; // Pink shifts RIGHT
vec2 blueChannelUv = warpedUv;
blueChannelUv.x -= displacement * chromaticIntensity; // Blue shifts LEFT
// Inexperienced stays at warpedUv (CENTER)
By shifting the pink and blue channels in reverse instructions whereas protecting inexperienced centered, we create the basic chromatic aberration look, the place colours seem to separate, just like classic digicam lenses.
Actual-world analogy: Previous digicam lenses couldn’t focus all colours to the identical level. Pink gentle bent barely otherwise than blue gentle. This created coloration fringing at high-contrast edges. We’re recreating that “imperfection” as a result of it appears to be like cinematic.
That is the place the distortion pushes proper, pink leads and blue trails. The place it pulls left, blue leads and pink trails. The result’s that classic lens really feel.
Layer 5: Recombining Channels
vec4 redColor = texture2D(tDiffuse, redChannelUv);
vec4 greenColor = texture2D(tDiffuse, greenChannelUv);
vec4 blueColor = texture2D(tDiffuse, blueChannelUv);
vec4 finalColor = vec4(
redColor.r, // Take ONLY pink from red-shifted pattern
greenColor.g, // Take ONLY inexperienced from centered pattern
blueColor.b, // Take ONLY blue from blue-shifted pattern
greenColor.a // Alpha from centered pattern
);
Why pattern 3 times? We have to learn from three barely completely different positions to get the separated colours. Every of the texture2D calls reads all 4 channels (RGBA), however we solely use one coloration channel from every.
Efficiency notice: Three texture samples sound costly, however trendy GPUs deal with this simply. The visible impression is price it.
Layer 6: Gamma Correction (The Factor Everybody Forgets)
finalColor = vec4(pow(finalColor.rgb, vec3(1.0 / 2.2)), finalColor.a);
This line is essential. With out it, your post-processed output appears to be like too darkish.
Why?
- Three.js renders in linear coloration house (bodily correct for lighting)
- Your monitor expects sRGB coloration house (gamma-corrected)
- Submit-processing breaks Three.js’s automated coloration administration
- We manually convert: linear → sRGB utilizing gamma 2.2
The maths:
Gamma correction: color_sRGB = color_linear ^ (1/2.2)
Visible distinction:
- With out correction: Darkish, muddy colours that lack pop
- With correction: Vibrant, vibrant, appears to be like appropriate
With correction: Vibrant, vibrant, appears to be like appropriate. That is separate from (and along with) the feel coloration house we set earlier. That was for enter photographs. That is for the ultimate output.
Integration with Three.js Submit-Processing
The shader is built-in into the rendering pipeline utilizing Three.js’s post-processing system:
// Create impact composer
const composer = new EffectComposer(renderer);
composer.setSize(window.innerWidth, window.innerHeight);
// Step 1: Render the scene usually
const renderPass = new RenderPass(scene, digicam);
composer.addPass(renderPass);
// Step 2: Apply our customized shader to the rendered picture
const shaderPass = new ShaderPass(DistortionChromaticAberrationShader);
shaderPass.materials.uniforms.uStrength.worth = config.distortionStrength;
shaderPass.materials.uniforms.uChromaticAberration.worth = config.chromaticAberration;
composer.addPass(shaderPass);
// In animation loop: use composer as a substitute of renderer.render()
composer.render();
Vital: As soon as you employ composer.render(), you by no means name renderer.render() once more. The composer handles all the pieces.
Actual-Time Parameter Management
The GUI system permits fine-tuning of all shader parameters (we’re utilizing Lil-GUI):
const distortionFolder = gui.addFolder("Prime/Backside Distortion & CA");
distortionFolder
.add(config, "distortionStrength", 0, 0.2, 0.001)
.identify("Warp Energy")
.onChange((v) => {
if (shaderPassRef.present)
shaderPassRef.present.materials.uniforms.uStrength.worth = v;
});
Identical to this, you’ll be able to add extra params that you would tweak to get your required outcome. It’s essential within the case of shaders to seek out the right remaining values to make use of.
The Finish
By tuning these distortion and chromatic aberration controls, you’ll be able to obtain your required impact. You’re free to play with it and add your personal contact to make it really feel prefer it belongs to you 🙂 I hope you discovered this text useful—you’ll be seeing extra of this “cool stuff” sooner or later too!!


