On this tutorial, we’ll discover two examples on how GSAP can act as a cinematic director for 3D environments. By connecting scroll movement to digital camera paths, lighting, and shader-driven results, we’ll rework static scenes into fluid, story-like sequences.
The primary demo focuses on shader-based depth — a rotating WebGL cylinder surrounded by reactive particles — whereas the second turns a 3D scene right into a scroll-controlled showcase with transferring cameras and animated typography.
By the tip, you’ll discover ways to orchestrate 3D composition, easing, and timing to create immersive, film-inspired interactions that reply naturally to consumer enter.
Cylindrical Movement: Shader-Pushed Scroll Dynamics
1. Establishing GSAP and customized easings
We’ll import and register ScrollTrigger, ScrollSmoother, and CustomEase.
Customized easing curves are important for controlling how the scroll feels — small variations in acceleration dramatically have an effect on the visible rhythm.
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { ScrollSmoother } from "gsap/ScrollSmoother"
import { CustomEase } from "gsap/CustomEase"
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, ScrollSmoother, CustomEase)
CustomEase.create("cinematicSilk", "0.45,0.05,0.55,0.95")
CustomEase.create("cinematicSmooth", "0.25,0.1,0.25,1")
CustomEase.create("cinematicFlow", "0.33,0,0.2,1")
CustomEase.create("cinematicLinear", "0.4,0,0.6,1")
}
2. Web page format and ScrollSmoother setup
ScrollSmoother works with a wrapper and a content material container.
The WebGL canvas sits fastened within the background, whereas the sleek content material scrolls above it.
{/* overlay textual content synchronized with scroll */}
We initialize the smoother:
const smoother = ScrollSmoother.create({
wrapper: smoothWrapperRef.present,
content material: smoothContentRef.present,
{smooth}: 4,
smoothTouch: 0.1,
results: false
})
3. Constructing the WebGL scene
We’ll use OGL to arrange a renderer, digital camera, and scene. The cylinder shows an picture atlas (a canvas that stitches a number of photographs horizontally). This enables us to scroll by means of a number of photographs seamlessly by rotating a single mesh.

const renderer = new Renderer({
canvas: canvasRef.present,
width: window.innerWidth,
top: window.innerHeight,
dpr: Math.min(window.devicePixelRatio, 2),
alpha: true
})
const gl = renderer.gl
gl.clearColor(0.95, 0.95, 0.95, 1)
gl.disable(gl.CULL_FACE)
const digital camera = new Digicam(gl, { fov: 45 })
digital camera.place.set(0, 0, 8)
const scene = new Remodel()
const geometry = createCylinderGeometry(gl, cylinderConfig)
We create the picture atlas dynamically:
const canvas = doc.createElement("canvas")
const ctx = canvas.getContext("second")!
canvas.width = imageConfig.width * photographs.size
canvas.top = imageConfig.top
photographs.forEach((img, i) => {
drawImageCover(ctx, img, i * imageConfig.width, 0, imageConfig.width, imageConfig.top)
})
const texture = new Texture(gl, { minFilter: gl.LINEAR, magFilter: gl.LINEAR })
texture.picture = canvas
texture.needsUpdate = true

Then connect the feel to the cylinder shader:
Cylinder shaders
The cylinder’s shaders deal with the UV mapping of the picture atlas and refined floor shade modulation.
// cylinderVertex.glsl
attribute vec3 place;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
various vec2 vUv;
void most important() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(place, 1.0);
}
// cylinderFragment.glsl
precision highp float;
uniform sampler2D tMap;
various vec2 vUv;
void most important() {
vec4 shade = texture2D(tMap, vUv);
gl_FragColor = shade;
}
const program = new Program(gl, {
vertex: cylinderVertex,
fragment: cylinderFragment,
uniforms: { tMap: { worth: texture } },
cullFace: null
})
const cylinder = new Mesh(gl, { geometry, program })
cylinder.setParent(scene)
cylinder.rotation.y = 0.5
4. Scroll-driven cinematic timeline
Now we’ll join scroll to digital camera motion and cylinder rotation utilizing ScrollTrigger.
The container’s top: 500vh provides us sufficient room to area out a number of “pictures.”
const cameraAnim = { x: 0, y: 0, z: 8 }
const tl = gsap.timeline({
scrollTrigger: {
set off: containerRef.present,
begin: "prime prime",
finish: "backside backside",
scrub: 1
}
})
tl.to(cameraAnim, { x: 0, y: 0, z: 8, period: 1, ease: "cinematicSilk" })
.to(cameraAnim, { x: 0, y: 5, z: 5, period: 1, ease: "cinematicFlow" })
.to(cameraAnim, { x: 1.5, y: 2, z: 2, period: 2, ease: "cinematicLinear" })
.to(cameraAnim, { x: 0.5, y: 0, z: 0.8, period: 3.5, ease: "power1.inOut" })
.to(cameraAnim, { x: -6, y: -1, z: 8, period: 1, ease: "cinematicSmooth" })
tl.to(cylinder.rotation, { y: "+=28.27", period: 8.5, ease: "none" }, 0)
Render loop:
const animate = () => {
requestAnimationFrame(animate)
digital camera.place.set(cameraAnim.x, cameraAnim.y, cameraAnim.z)
digital camera.lookAt([0, 0, 0])
const vel = cylinder.rotation.y - lastRotation
lastRotation = cylinder.rotation.y
renderer.render({ scene, digital camera })
}
animate()
5. Typographic overlays
Every title part fades out and in in sync with the scroll, dividing the journey into visible chapters.

views.forEach((perspective, i) => {
const textEl = textRefs.present[i]
if (!textEl) return
const part = 100 / views.size
const begin = `${i * part}% prime`
const finish = `${(i + 1) * part}% prime`
gsap.timeline({
scrollTrigger: {
set off: containerRef.present,
begin, finish,
scrub: 0.8
}
})
.fromTo(textEl, { opacity: 0 }, { opacity: 1, period: 0.2, ease: "cinematicSmooth" })
.to(textEl, { opacity: 1, period: 0.6, ease: "none" })
.to(textEl, { opacity: 0, period: 0.2, ease: "cinematicSmooth" })
})
6. Particles with rotational inertia
To intensify movement, we’ll add refined line-based particles orbiting the cylinder.
Their opacity will increase when the cylinder spins and fades because it slows down.

for (let i = 0; i < particleConfig.numParticles; i++) {
const { geometry, userData } = createParticleGeometry(gl, particleConfig, i, cylinderConfig.top)
const program = new Program(gl, {
vertex: particleVertex,
fragment: particleFragment,
uniforms: { uColor: { worth: [0,0,0] }, uOpacity: { worth: 0.0 } },
clear: true,
depthTest: true
})
const particle = new Mesh(gl, { geometry, program, mode: gl.LINE_STRIP })
particle.userData = userData
particle.setParent(scene)
particles.push(particle)
}
Contained in the render loop:
const inertiaFactor = 0.15
const decayFactor = 0.92
momentum = momentum * decayFactor + vel * inertiaFactor
const isRotating = Math.abs(vel) > 0.0001
const velocity = Math.abs(vel) * 100
particles.forEach(p => {
const goal = isRotating ? Math.min(velocity * 3, 0.95) : 0
p.program.uniforms.uOpacity.worth += (goal - p.program.uniforms.uOpacity.worth) * 0.15
const rotationOffset = vel * p.userData.velocity * 1.5
p.userData.baseAngle += rotationOffset
const positions = p.geometry.attributes.place.information as Float32Array
for (let j = 0; j <= particleConfig.segments; j++) {
const t = j / particleConfig.segments
const angle = p.userData.baseAngle + p.userData.angleSpan * t
positions[j*3 + 0] = Math.cos(angle) * p.userData.radius
positions[j*3 + 1] = p.userData.baseY
positions[j*3 + 2] = Math.sin(angle) * p.userData.radius
}
p.geometry.attributes.place.needsUpdate = true
})
Particle shaders
Every particle line is outlined by a vertex shader that positions factors alongside an arc and a fraction shader that controls shade and opacity.

// particleVertex.glsl
attribute vec3 place;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
void most important() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(place, 1.0);
}
// particleFragment.glsl
precision highp float;
uniform vec3 uColor;
uniform float uOpacity;
void most important() {
gl_FragColor = vec4(uColor, uOpacity);
}
Scene Path: Scroll-Managed Storytelling in Three.js
1. GSAP setup
Register the plugins as soon as on the shopper. We’ll use ScrollTrigger, ScrollSmoother, and SplitText to orchestrate digital camera strikes and textual content beats.
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { ScrollSmoother } from "gsap/ScrollSmoother"
import { SplitText } from "gsap/SplitText"
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, ScrollSmoother, SplitText)
}
2. Web page format + ScrollSmoother
We preserve the 3D canvas fastened behind, overlay UI on prime (scroll trace + progress), and wrap the lengthy content material space with #smooth-wrapper / #smooth-content to allow smoothing.
...
{/* Left scroll trace and backside progress bar overlays right here */}
Activate smoothing:
ScrollSmoother.create({
wrapper: smoothWrapperRef.present!,
content material: smoothContentRef.present!,
{smooth}: 4,
results: false,
smoothTouch: 2,
})
3. The 3D scene (R3F + drei)
We mount a PerspectiveCamera that we are able to replace each body, add fog for depth, and lightweight the constructing. The constructing mannequin is loaded with useGLTF and calmly remodeled.

perform CyberpunkBuilding() {
const { scene } = useGLTF("/cyberpunk_skyscraper.glb")
useEffect(() => {
if (scene) {
scene.scale.set(3, 3, 3)
scene.place.set(0, 0, 0)
}
}, [scene])
return
}
perform AnimatedCamera({ cameraAnimRef, targetAnimRef }: any) {
const cameraRef = useRef(null)
const { set } = useThree()
useEffect(() => {
if (cameraRef.present) set({ digital camera: cameraRef.present })
}, [set])
useFrame(() => {
if (cameraRef.present) {
cameraRef.present.place.set(
cameraAnimRef.present.x,
cameraAnimRef.present.y,
cameraAnimRef.present.z
)
cameraRef.present.lookAt(
targetAnimRef.present.x,
targetAnimRef.present.y,
targetAnimRef.present.z
)
}
})
return
}
function Scene({ cameraAnimRef, targetAnimRef }: any) {
const { scene } = useThree()
useEffect(() => {
if (scene) {
const fogColor = new THREE.Color("#0a0a0a")
scene.fog = new THREE.Fog(fogColor, 12, 28)
scene.background = new THREE.Color("#0a0a0a")
}
}, [scene])
return (
<>
>
)
}

As the scene takes shape, the lighting and scale help establish depth, but what truly brings it to life is motion. The next step is to connect the scroll to the camera itself — transforming simple input into cinematic direction.
4. Camera timeline driven by scroll
We keep two mutable refs: cameraAnimRef (camera position) and targetAnimRef (look-at). A single timeline maps scene segments (from a scenePerspectives config) to scroll progress.
const cameraAnimRef = useRef({ x: -20, y: 0, z: 0 })
const targetAnimRef = useRef({ x: 0, y: 15, z: 0 })
const tl = gsap.timeline({
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate: (self) => {
const progress = self.progress * 100
setProgressWidth(progress) // quickSetter for width %
setProgressText(String(Math.round(progress)).padStart(3, "0") + "%")
}
}
})
scenePerspectives.forEach((p) => {
const start = p.scrollProgress.start / 100
const end = p.scrollProgress.end / 100
tl.to(cameraAnimRef.current, { x: p.camera.x, y: p.camera.y, z: p.camera.z, duration: end - start, ease: "none" }, start)
tl.to(targetAnimRef.current, { x: p.target.x, y: p.target.y, z: p.target.z, duration: end - start, ease: "none" }, start)
})
5. SplitText chapter cues
For each perspective, we place a text block in a screen position derived from its config and animate chars in/out with small staggers.
scenePerspectives.forEach((p, index) => {
const textEl = textRefs.current[index]
if (!textEl) return
if (p.hideText) { gsap.set(textEl, { opacity: 0, pointerEvents: "none" }); return }
const titleEl = textEl.querySelector("h2")
const subtitleEl = textEl.querySelector("p")
if (titleEl && subtitleEl) {
const titleSplit = new SplitText(titleEl, { kind: "chars" })
const subtitleSplit = new SplitText(subtitleEl, { kind: "chars" })
splitInstancesRef.present.push(titleSplit, subtitleSplit)
const textTl = gsap.timeline({
scrollTrigger: {
set off: containerRef.present,
begin: `${p.scrollProgress.begin}% prime`,
finish: `${p.scrollProgress.finish}% prime`,
scrub: 0.5,
}
})
const isFirst = index === 0
const isLast = index === scenePerspectives.size - 1
if (isFirst) {
gsap.set([titleSplit.chars, subtitleSplit.chars], { x: 0, opacity: 1 })
textTl.to([titleSplit.chars, subtitleSplit.chars], {
x: 100, opacity: 0, period: 1, stagger: 0.02, ease: "power2.in"
})
} else {
textTl
.fromTo([titleSplit.chars, subtitleSplit.chars],
{ x: -100, opacity: 0 },
{
x: 0, opacity: 1,
period: isLast ? 0.2 : 0.25,
stagger: isLast ? 0.01 : 0.02,
ease: "power2.out"
}
)
.to({}, { period: isLast ? 1.0 : 0.5 })
.to([titleSplit.chars, subtitleSplit.chars], {
x: 100, opacity: 0, period: 0.25, stagger: 0.02, ease: "power2.in"
})
}
}
})
6. Overlay UI: scroll trace + progress
A minimal scroll trace on the left and a centered progress bar on the backside. We use gsap.quickSetter to replace width and label effectively from ScrollTrigger’s onUpdate.
const setProgressWidth = gsap.quickSetter(progressBarRef.present, "width", "%")
const setProgressText = gsap.quickSetter(progressTextRef.present, "textContent")
// ... used inside ScrollTrigger's onUpdate() above
Conclusion
That’s it for this tutorial. You’ve seen how scroll movement can form a scene, how timing and easing can recommend rhythm, and the way digital camera motion can flip a static format into one thing that feels intentional and cinematic. With GSAP, all of it stays versatile and fluid — each movement turns into simpler to regulate and refine.
The strategies listed here are simply a place to begin. Strive shifting the main target, slowing issues down, or exaggerating transitions to see the way it modifications the temper. Deal with the scroll as a director’s cue, guiding the viewer’s consideration by means of area, mild, and movement.
In the long run, what makes these experiences participating isn’t the complexity of the code, however the sense of circulation you create. Maintain experimenting, keep curious, and let your subsequent venture inform its story one scroll at a time.









