i < tableSegs; i++) {
const angle = (i / tableSegs) * Math.PI * 2;
positions.push(Math.cos(angle) * 0.56, 0.36, Math.sin(angle) * 0.56);
}
// Girdle top & bottom rings
const girdleTopY = 0.05;
const girdleBotY = -0.92;
const girdleSegs = tableSegs * 2;
for (let i = 0; i < girdleSegs; i++) {
const angle = (i / girdleSegs) * Math.PI * 2;
positions.push(Math.cos(angle) * 1.0, girdleTopY, Math.sin(angle) * 1.0);
positions.push(Math.cos(angle) * 1.0, girdleBotY, Math.sin(angle) * 1.0);
}
// Culet point
positions.push(0, girdleBotY - 0.05, 0);
// Build facet indices (simplified for brevity)
// In production, use a fan/triangle strip generator for each ring transition
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
return geo;
}
**Why this works:** Flat shading is applied per facet, allowing independent light interaction. Vertex rings provide predictable topology for morph targets. No external asset loading means zero network latency for geometry.
### 2. State Morphing via Morph Targets
Transitions between raw and polished states should not require swapping meshes. Morph targets interpolate vertex positions on a single geometry, enabling seamless state changes driven by animation timelines.
```typescript
function attachMorphState(geometry: THREE.BufferGeometry, roughVertices: number[]) {
const morphAttr = new THREE.Float32BufferAttribute(roughVertices, 3);
geometry.morphAttributes.position = [morphAttr];
}
// Runtime state control
const gemMesh = new THREE.Mesh(generatedGeo, opticalMaterial);
gemMesh.morphTargetInfluences = [1.0]; // Fully rough
During the cutting sequence, a single GSAP tween interpolates the influence value while simultaneously updating material properties. This eliminates geometry swaps and maintains GPU buffer stability.
3. Optical Material Simulation
Diamonds require accurate refractive behavior. MeshPhysicalMaterial in Three.js r184 supports transmission, dispersion, and physically based IOR values.
const diamondMaterial = new THREE.MeshPhysicalMaterial({
ior: 2.42,
transmission: 0.0,
thickness: 0.0,
dispersion: 0.0,
clearcoat: 0.0,
envMapIntensity: 0.0,
roughness: 1.0,
metalness: 0.0,
flatShading: true,
transparent: true,
side: THREE.DoubleSide
});
// PMREM environment setup
const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
const envTexture = pmremGenerator.fromScene(new THREE.RoomEnvironment()).texture;
scene.environment = envTexture;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
Why this works: IOR 2.42 matches real diamond physics. Transmission and dispersion are animated from zero to full strength during the morph transition, simulating the removal of surface roughness. PMREM prefiltering ensures reflections are cached, avoiding real-time ray tracing overhead. ACES Filmic tone mapping preserves highlight roll-off without clipping.
4. Timeline Orchestration
Scroll must drive animation, but not rigidly. Lenis provides inertial smoothing, while GSAP ScrollTrigger maps scroll position to a master timeline. Weighted sections allocate scroll distance based on narrative importance rather than fixed viewport heights.
class ScrollTimelineOrchestrator {
private lenis: Lenis;
private masterTimeline: gsap.core.Timeline;
constructor(scrollContainer: HTMLElement, sectionWeights: number[]) {
this.lenis = new Lenis({ lerp: 0.08, smoothWheel: !window.matchMedia('(prefers-reduced-motion: reduce)').matches });
this.masterTimeline = gsap.timeline({ paused: true });
// Sync Lenis inertia with GSAP ticker
this.lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => this.lenis.raf(time * 1000));
gsap.ticker.fps = 60;
// Calculate total scroll height from weights
const totalWeight = sectionWeights.reduce((sum, w) => sum + w, 0);
scrollContainer.style.height = `${totalWeight * 100}vh`;
ScrollTrigger.create({
trigger: scrollContainer,
start: 'top top',
end: 'bottom bottom',
scrub: 0.5,
onUpdate: (self) => {
this.masterTimeline.progress(self.progress);
}
});
}
addSceneBeat(startTime: number, duration: number, onUpdate: (t: number) => void) {
this.masterTimeline.to({}, {
duration: duration,
onStart: () => {},
onUpdate: function() {
onUpdate(this.progress());
}
}, startTime);
}
}
Why this works: Weighted mapping ensures complex sequences (e.g., deep-time montages) receive proportional scroll space. Lenis inertia prevents jitter, while GSAP scrub maintains frame-accurate interpolation. The master timeline owns all camera, material, and geometry updates, eliminating race conditions.
Pitfall Guide
Explanation: Assigning fixed vh values to narrative sections forces pacing to match viewport size, not story weight. Users rush through complex beats or stall on simple ones.
Fix: Use weighted ratios. Calculate total scroll height dynamically: totalHeight = sum(weights) * 100vh. Map timeline progress to scroll percentage, not absolute pixels.
2. Ignoring Inertia-Timeline Mismatch
Explanation: Lenis smooths scroll input, but GSAP ScrollTrigger reads raw scroll events by default. This causes scrub lag and frame drops during fast scrolls.
Fix: Bind Lenis raf to gsap.ticker and call ScrollTrigger.update on Lenis scroll events. This synchronizes the physics engine with the animation ticker.
3. Overloading Morph Attributes
Explanation: Creating multiple morph targets for minor state changes bloats vertex buffers and increases GPU memory pressure.
Fix: Use a single morph attribute for primary state transitions (rough β polished). Animate material properties (roughness, transmission, dispersion) in parallel using the same tween duration.
4. Unbounded Device Pixel Ratio
Explanation: Modern mobile devices report DPR values of 3.0β4.0. Rendering at native DPR saturates mobile GPUs, causing thermal throttling and frame drops.
Fix: Cap renderer pixel ratio: renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)). This preserves visual sharpness while maintaining 60fps on mid-tier hardware.
5. Neglecting Video Lifecycle Management
Explanation: Background <video> elements continue decoding and consuming CPU/GPU resources even when off-screen, causing audio bleed and performance degradation.
Fix: Implement an intersection observer or scroll-position tracker to pause videos outside the viewport. Only activate playback when the element enters the active scroll window.
6. Accessibility Blind Spots
Explanation: Smooth scroll and scrubbed animations violate prefers-reduced-motion guidelines, causing vestibular discomfort and breaking native scroll behavior for assistive technologies.
Fix: Detect prefers-reduced-motion: reduce at initialization. Disable Lenis inertia, set GSAP scrub to 0, and fallback to native DOM scroll. Ensure all narrative content remains readable without animation.
7. Non-Deterministic QA Testing
Explanation: Scroll-driven animations are inherently non-deterministic, making visual regression testing and screenshot capture unreliable.
Fix: Expose a deterministic frame renderer when ?static is present in the URL. Attach a global hook like window.__shot(progress) that forces the timeline to a specific progress value and renders a single frame. This enables CI/CD visual testing.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Marketing microsite with heavy narrative | Weighted scroll + procedural geo + morph targets | Maximizes pacing control, minimizes payload, maintains 60fps | Low infrastructure, moderate dev time |
| Product configurator with real-time editing | Fixed timeline + static GLB + raycasting | Requires precise geometry manipulation, not narrative pacing | Higher asset hosting, faster interaction |
| Educational/accessible experience | Reduced-motion fallback + DOM-first layout | Ensures WCAG compliance, reduces GPU dependency | Minimal dev overhead, broader reach |
| High-fidelity optical simulation | PMREM environment + ACES tone mapping + dispersion | Accurate light behavior without path tracing | Moderate GPU usage, high visual ROI |
Configuration Template
// scroll-3d-pipeline.ts
import * as THREE from 'three';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import Lenis from 'lenis';
gsap.registerPlugin(ScrollTrigger);
export class ScrollDrivenScene {
private renderer: THREE.WebGLRenderer;
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private lenis: Lenis;
private timeline: gsap.core.Timeline;
constructor(container: HTMLElement, weights: number[]) {
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
container.appendChild(this.renderer.domElement);
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
this.lenis = new Lenis({
lerp: 0.08,
smoothWheel: !window.matchMedia('(prefers-reduced-motion: reduce)').matches
});
this.lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((t) => this.lenis.raf(t * 1000));
gsap.ticker.fps = 60;
const totalWeight = weights.reduce((a, b) => a + b, 0);
container.style.height = `${totalWeight * 100}vh`;
this.timeline = gsap.timeline({ paused: true });
ScrollTrigger.create({
trigger: container,
start: 'top top',
end: 'bottom bottom',
scrub: 0.5,
onUpdate: (self) => this.timeline.progress(self.progress)
});
this.setupEnvironment();
this.animate();
}
private setupEnvironment() {
const pmrem = new THREE.PMREMGenerator(this.renderer);
pmrem.compileEquirectangularShader();
this.scene.environment = pmrem.fromScene(new THREE.RoomEnvironment()).texture;
}
private animate = () => {
requestAnimationFrame(this.animate);
this.renderer.render(this.scene, this.camera);
};
public addBeat(startTime: number, duration: number, callback: (progress: number) => void) {
this.timeline.to({}, { duration, onUpdate: () => callback(this.timeline.progress()) }, startTime);
}
}
Quick Start Guide
- Initialize the pipeline: Import
ScrollDrivenScene, pass a container element and an array of narrative weights (e.g., [1.0, 3.5, 1.2, 1.0]). The constructor handles renderer setup, Lenis sync, and scroll height calculation.
- Generate geometry: Call
buildFacetedGem(segments) to create a procedural mesh. Attach a morph attribute containing rough-state vertices using attachMorphState().
- Configure materials: Instantiate
MeshPhysicalMaterial with ior: 2.42. Set transmission, dispersion, and clearcoat to 0 initially. Animate these values alongside the morph influence using addBeat().
- Validate performance: Open Chrome DevTools β Performance. Record a scroll session. Verify
devicePixelRatio is capped, video elements pause off-screen, and frame times stay under 16.6ms. Append ?static to the URL and call window.__shot(0.5) to capture a deterministic frame for regression testing.