Skip to main content

📉 Timeline

Step snapshot

You may have seen us coming with this one! The good old Locomotive Scroll + GSAP combo is back 😈 Let's set it up:

views/partials/splash-featured.twig
<canvas 
class="c-splash-featured_canvas"
data-module-splash-canvas
data-splash-canvas-src="{{splash_featured.model}}"
data-scroll
data-scroll-module-progress>
</canvas>
assets/scripts/modules/SplashCanvas.js
import { module as module } from 'modujs'
import gsap from 'gsap'
import * as THREE from 'three';
import { gltfLoader, textureLoader } from '../utils/3d'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { CUSTOM_EVENT } from '../config';

export default class extends module {
constructor(m) {
super(m)

// Selectors & data parse
this.canvas = this.el;
this.src = this.getData('src');

// Listen for resize
this.resize(); // Call a first time before init to get a base sizing
this.resize = this.resize.bind(this);
window.addEventListener(CUSTOM_EVENT.RESIZE_END, this.resize);
}

init() {
this.initRenderer();
this.initScene();
this.initTimeline();
this.loadModel();

// Call a second time after init to impact THREE js objects
this.resize();

// Start the render loop
this.render();
}

initRenderer() {
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
alpha: true,
antialias: true
})

this.renderer.setSize(this.sizes.width, this.sizes.height)
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
}

initScene() {
// ==========================================================================
// BASIS
// ==========================================================================
this.scene = new THREE.Scene();

// Wrapper
this.wrapper = new THREE.Group();
this.wrapper.position.set(0,0,0);
this.scene.add(this.wrapper)

// ==========================================================================
// CAMERA
// ==========================================================================

// Base camera
this.camera = new THREE.PerspectiveCamera(30, this.sizes.width / this.sizes.height, 0.01,100)
this.camera.position.set(15,15,15)
this.scene.add(this.camera)

// Controls
this.controls = new OrbitControls(this.camera, this.canvas)
this.controls.enableDamping = true
this.controls.enableZoom = false
}

initTimeline() {
// Create the timeline
this.tl = gsap.timeline({})

/*
👋 No copy paste this time 😈

With your newly acquired GSAP expertise,
try moving the camera from a point A to a point B by yourself!

Tip: you're going to need to tween `this.camera.position`

*/

// Usual tl calls
this.tl.progress(0);
this.tl.pause()
}

loadModel() {
// Store in a promise to give acces to the preloader
window.model3dLoadPromise = new Promise(resolve => {
gltfLoader.load(this.src, gltf => {

// Use envmap instead of ambient light to get nice realistic reflections
const envMap = textureLoader.load( '/assets/3d/envmap.jpg' );
envMap.mapping = THREE.EquirectangularReflectionMapping;
envMap.colorSpace = THREE.SRGBColorSpace;

// Apply the environment map to the materials
gltf.scene.traverse((node) => {
if (node.isMesh) {
node.material.envMap = envMap;
node.material.envMapIntensity = 2; // Adjust the intensity if needed
}
});

// Add the GLTF to our scene wrapper!
this.wrapper.add(gltf.scene)

resolve();
},
// called while loading is progressing
xhr => {
// console.log( ( xhr.loaded / xhr.total * 100 ) + '% loaded' );
},
// called when loading has errors
error => {
console.error(error)
resolve();
})
});
}

resize() {
// Get canvas dimensions
this.BCR = this.el.getBoundingClientRect()

// Store them
this.sizes = {
width: this.BCR.width,
height: this.BCR.height
}

// Udpate the renderer accordingly
this.renderer?.setSize?.(this.sizes.width, this.sizes.height);
this.renderer?.setPixelRatio?.(Math.min(window.devicePixelRatio, 2))

// Update the camera as well
if(this.camera) {
this.camera.aspect = this.sizes.width / this.sizes.height
this.camera.updateProjectionMatrix()
}
}

render() {
// Auto rotate our wrapper
this.wrapper.rotation.y += (.01 + Math.abs((window.locomotiveScrollData?.velocity ?? 0) * 0.005)) * (window.locomotiveScrollData?.direction ?? 1);

// Update controls
this.controls.update()

// Render
this.renderer.render(this.scene, this.camera)

// Call tick again on the next frame
this.raf = window.requestAnimationFrame(this.render.bind(this))
}

onScrollProgress(progress) {
this.tl?.progress?.(progress)
}

destroy() {
super.destroy();
this.tl?.kill?.();
window.removeEventListener(CUSTOM_EVENT.RESIZE_END, this.resize);
window.cancelAnimationFrame(this.raf)
this.scene = this.renderer = null
}
}
🛑 SOLUTION BELOW (don't spoil yourself)
// Move the camera from origin to target
this.tl.fromTo(
this.camera.position,
{
x: 15,
y: 25,
z: 15
}, {
x: 15,
y: -5,
z: 15,
ease: 'linear'
}
);
// Because we're using OrbitControls, the camera will keep targeting the center of the scene.
// We could get rid of the OrbitControls and manually call camera.lookAt() in the render loop instead
caution

Now would be a good time to re-enable the pointer-events: none; on our canvas