/* ========================================================================= INTRO SCENE — MVA Global Fret Three.js scene driven by scroll. The cargo airliner is a GLTF model loaded at runtime; GSAP + ScrollTrigger animate the camera position and exposition labels as the page scrolls. The CTA fades in at the end of the timeline. 3D model credit (CC-BY): "Airplane" by Poly by Google https://poly.pizza/m/a3XrQkLNna9 ========================================================================= */ import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; const { gsap, ScrollTrigger } = window; gsap.registerPlugin(ScrollTrigger); /* ── Renderer & camera ─────────────────────────────────────────────────── */ const canvas = document.getElementById('three-canvas'); const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: 'high-performance' }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(38, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 1.4, 18); camera.lookAt(0, 0, 0); /* ── Lighting (golden-hour fill) ───────────────────────────────────────── */ const sun = new THREE.DirectionalLight(0xfff1d6, 1.5); sun.position.set(-6, 8, 4); scene.add(sun); const skyFill = new THREE.HemisphereLight(0xa8c5ff, 0x3a2a1a, 0.65); scene.add(skyFill); const ambient = new THREE.AmbientLight(0xffffff, 0.20); scene.add(ambient); /* ── Plane (GLTF) ──────────────────────────────────────────────────────── */ const planeHolder = new THREE.Group(); scene.add(planeHolder); const loader = new GLTFLoader(); loader.load( 'assets/airplane.glb', (gltf) => { const model = gltf.scene; /* Compute bbox at native scale, then offset model so its center sits at the wrapper's origin. The wrapper scales everything together, so the center offset stays valid after scaling. */ const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); model.position.sub(center); const wrapper = new THREE.Group(); wrapper.add(model); const targetSize = 8.5; wrapper.scale.setScalar(targetSize / Math.max(size.x, size.y, size.z)); /* The Poly by Google plane sits with nose along +X; turn it so the nose faces the camera at the start of the timeline. */ wrapper.rotation.y = Math.PI / 2; planeHolder.add(wrapper); }, undefined, (err) => console.error('Failed to load airplane.glb:', err) ); /* ── Cloud sprites ─────────────────────────────────────────────────────── */ const clouds = new THREE.Group(); const cloudMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1.0, metalness: 0, transparent: true, opacity: 0.85 }); for (let i = 0; i < 14; i++) { const cl = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 10), cloudMat); const r = 18 + Math.random() * 14; const a = Math.random() * Math.PI * 2; cl.position.set(Math.cos(a) * r, (Math.random() - 0.4) * 9, Math.sin(a) * r - 8); cl.scale.set(1.6 + Math.random() * 1.4, 0.9 + Math.random() * 0.6, 1.6 + Math.random() * 1.4); clouds.add(cl); } scene.add(clouds); /* ── Scroll-driven progress ────────────────────────────────────────────── */ const state = { progress: 0 }; ScrollTrigger.create({ trigger: '.scroll-stage', start: 'top top', end: 'bottom bottom', scrub: 0.6, onUpdate: self => { state.progress = self.progress; } }); /* Cross-fade des étiquettes d'acte */ const labelTl = gsap.timeline({ scrollTrigger: { trigger: '.scroll-stage', start: 'top top', end: 'bottom bottom', scrub: 0.4 } }); labelTl .fromTo('#label-paris', { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.05 }, 0.02) .to( '#label-paris', { opacity: 0, y: -20 }, 0.20) .fromTo('#label-cruise', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.40) .to( '#label-cruise', { opacity: 0, y: -20 }, 0.60) .fromTo('#label-tana', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.72) .to( '#label-tana', { opacity: 0, y: -20 }, 0.92); /* CTA reveal — last ~10% of scroll */ gsap.fromTo('#ctaBtn', { opacity: 0, scale: 0.85 }, { opacity: 1, scale: 1, scrollTrigger: { trigger: '.scroll-stage', start: 'bottom-=400 bottom', end: 'bottom bottom', scrub: 0.4, onLeave: () => document.getElementById('ctaBtn').classList.add('revealed'), onEnterBack: () => document.getElementById('ctaBtn').classList.remove('revealed'), } } ); /* ── Render loop ───────────────────────────────────────────────────────── */ const clock = new THREE.Clock(); function tick() { const t = clock.getElapsedTime(); const p = state.progress; /* Camera arc around the plane */ const camAngle = -0.6 + p * 1.7; const camRadius = 18 - p * 6 + Math.sin(p * Math.PI) * -3; const camHeight = 1.4 + Math.sin(p * Math.PI) * 1.8 - p * 0.6; camera.position.set( Math.sin(camAngle) * camRadius, camHeight, Math.cos(camAngle) * camRadius ); camera.lookAt(0, 0, 0); /* Plane bob + roll */ planeHolder.position.y = Math.sin(t * 0.9) * 0.15; planeHolder.rotation.z = Math.sin(t * 0.5) * 0.04 + (p - 0.5) * 0.18; planeHolder.rotation.y = Math.sin(t * 0.4) * 0.025; /* Clouds slow rotation */ clouds.rotation.y = t * 0.04 - p * 0.6; clouds.position.y = Math.sin(t * 0.3) * 0.4; renderer.render(scene, camera); requestAnimationFrame(tick); } /* ── Resize ────────────────────────────────────────────────────────────── */ function resize() { const w = window.innerWidth; const h = window.innerHeight; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } window.addEventListener('resize', resize); resize(); tick();