




Planted:
Status: decay
Intended Audience: Creative coders, Front-end developers
Tech: three.js, GSAP
This note details how to make a camera move around a three.js scene as the user scrolls.
The page is made of two layers:
position: fixed so it remains within the viewport as the user scrolls.When a section intersects the center of the viewport, the scene's camera changes to a different position.
This is controlled using a data-camera-state attribute on each section, with values: floor, cube, cone, or sphere.
An intersection observer watches the sections and triggers a move camera function based on the attribute.
...<main><section data-camera-state="floor">...
function initIntersectionObserver(camera) {const copySections = document.querySelectorAll(`.${config.classNames.copySection}`);if (copySections.length === 0) {throw new Error(`Elements not found`);}// I would use rootMargin instead of threshold but there seems to be a bug with codepen// rootMargin: "-50% 0%"const options = { threshold: "0.5" };function onIntersect(entries) {if (config.gui.enableOrbitControls) {return;}entries.forEach((entry) => {if (entry.isIntersecting) {const state = entry.target.getAttribute(config.attributes.cameraState);if (state) {moveCameraImpl({ camera, state });}}});}const observer = new IntersectionObserver(onIntersect, options);copySections.forEach((section) => {observer.observe(section);});}
To avoid rapid camera movement, each section needs a minimum height of at least half the viewport height — I'm using 80vh.
Each camera state has:
position: the camera's coordinates (x, y, z)lookAt: the coordinates the camera is looking atconst config = {camera: {positions: {floor: {position: [-1, 17, 0],lookAt: [-1, 0, 0]},cube: {position: [11, 3, 9],lookAt: [-2, 0, 3]},...
To make it easy to capture desired camera positions, I've added a GUI option enableOrbitControls.
When enabled, the page enters a dev mode:
This lets us freely explore the scene. When we find a position we like, we can copy and paste the logged values into the config object.
function initLoggingCameraPosition({ controls, camera }) {function logCameraPosition() {if (!config.gui.enableOrbitControls) {return;}const { position } = camera;const { target } = controls;const positionImpl = [position.x, position.y, position.z].map(Math.round);const targetImpl = [target.x, target.y, target.z].map(Math.round);console.log("Camera Position:", positionImpl);console.log("Camera Target:", targetImpl);const data = {position: positionImpl,target: targetImpl};const json = JSON.stringify(data);navigator.clipboard.writeText(json);}controls.addEventListener("change", logCameraPosition);}
GSAP is used to animate the camera movement.
For position, we apply the config's position coordinates to camera.position.
For rotation, instead of changing camera.rotation (Euler angles), we use camera.quaternion.
While Euler angles (x, y, z) are intuitive, they can cause issues like gimbal lock and result in choppy transitions.
Quaternions are a more robust way to represent 3D rotation and avoids those issues.
Note: camera.rotation and camera.quaternion are linked - updating one automatically updates the other.
function getLookAtQuaternion({ position, lookAt }) {const tempCam = new THREE.PerspectiveCamera();tempCam.position.copy(new THREE.Vector3(...position));tempCam.lookAt(new THREE.Vector3(...lookAt));return tempCam.quaternion.clone();}function moveCamera({ camera, position, lookAt }) {const targetQuat = getLookAtQuaternion({ position, lookAt });gsap.to(camera.position, {x: position[0],y: position[1],z: position[2],...config.camera.animation});gsap.to(camera.quaternion, {x: targetQuat.x,y: targetQuat.y,z: targetQuat.z,w: targetQuat.w,...config.camera.animation,onUpdate: () => camera.updateMatrixWorld()});}
Have any feedback about this note or just want to comment on the state of the economy?


