site-mva-global-fret/js/intro-scene.js
MVA Global Fret 341dca7cb5 Convert intro into a scroll-driven 3D cinematic
Restructure the page so the first 4 viewports of scroll drive a
Three.js scene composited on top of the Antananarivo parachute video.

What's there:

- Three.js (ESM, r158 via importmap) renders a low-poly cargo airliner
  built from primitives: cylinder fuselage, cone nose, sphere cockpit
  (dark glass + emissive), box wings/tail/fin, cylinder engines with
  torus intakes, gold trim band, navy fin with gold logo box. No
  external model file.
- Hemisphere + directional + ambient lights tuned for golden-hour fill.
- 14 cloud spheres scattered around the plane, slowly rotating.
- GSAP + ScrollTrigger drive a single progress value scrubbed against
  scroll position. Inside the rAF loop, the camera arcs from rear-left
  (-0.6 rad) to front-right (+1.1 rad), radius dipping mid-flight, and
  the plane rolls slightly with scroll.
- Three act labels (Paris CDG / Vol cargo / Antananarivo) cross-fade at
  20%/40%-60%/72% scroll positions via a chained gsap timeline.
- Gold CTA button stays opacity:0 + pointer-events:none until the last
  ~10% of scroll, then fades and scales in. Hover transform rebuilt
  without the old mouse-parallax tilt (fights the scroll animation).
- Scroll hint pill (chevron + "Faites défiler") at the bottom of the
  first viewport, fades out on first scroll event.
- prefers-reduced-motion shortcut: scroll stage hidden, CTA visible,
  no animation. Page reverts to a static screen with the video bg.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-05 10:41:24 +02:00

223 lines
8.9 KiB
JavaScript

/* =========================================================================
INTRO SCENE — MVA Global Fret
Scène Three.js minimaliste : un avion-cargo low-poly construit à partir
de primitives (cylindre + boîtes + cônes), peint aux couleurs de la marque.
La timeline est pilotée par GSAP + ScrollTrigger : la position du scroll
anime caméra, avion et étiquettes. À 100% du scroll, le bouton CTA est
révélé.
========================================================================= */
import * as THREE from 'three';
const { gsap, ScrollTrigger } = window;
gsap.registerPlugin(ScrollTrigger);
/* ── Setup ──────────────────────────────────────────────────────────────── */
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;
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 ───────────────────────────────────────────────────────────── */
const sun = new THREE.DirectionalLight(0xfff1d6, 1.4); // chaude, golden hour
sun.position.set(-6, 8, 4);
scene.add(sun);
const skyFill = new THREE.HemisphereLight(0xa8c5ff, 0x3a2a1a, 0.55);
scene.add(skyFill);
const ambient = new THREE.AmbientLight(0xffffff, 0.18);
scene.add(ambient);
/* ── Avion (low-poly à partir de primitives) ─────────────────────────────
Couleurs MVA : corps blanc cassé, accents navy + or sur les ailerons. */
const NAVY = new THREE.Color(0x1a1a3e);
const GOLD = new THREE.Color(0xc5a55a);
const BODY = new THREE.Color(0xf2f2f7);
const matBody = new THREE.MeshStandardMaterial({ color: BODY, roughness: 0.45, metalness: 0.08 });
const matNavy = new THREE.MeshStandardMaterial({ color: NAVY, roughness: 0.55, metalness: 0.10 });
const matGold = new THREE.MeshStandardMaterial({ color: GOLD, roughness: 0.35, metalness: 0.55 });
const matWindow = new THREE.MeshStandardMaterial({ color: 0x0e1a30, roughness: 0.2, metalness: 0.4, emissive: 0x0a1428, emissiveIntensity: 0.4 });
const plane = new THREE.Group();
// Fuselage — cylindre couché le long de l'axe Z
const fuselageGeo = new THREE.CylinderGeometry(0.55, 0.5, 7.2, 24);
const fuselage = new THREE.Mesh(fuselageGeo, matBody);
fuselage.rotation.x = Math.PI / 2;
plane.add(fuselage);
// Nez (cône avant)
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.5, 1.4, 24), matBody);
nose.rotation.x = -Math.PI / 2;
nose.position.z = 4.3;
plane.add(nose);
// Cockpit (sphère bleu nuit pour les vitres)
const cockpit = new THREE.Mesh(new THREE.SphereGeometry(0.42, 18, 16, 0, Math.PI * 2, 0, Math.PI / 2), matWindow);
cockpit.rotation.x = -Math.PI / 2;
cockpit.position.set(0, 0.18, 3.4);
plane.add(cockpit);
// Bande dorée le long du fuselage (un cylindre fin)
const goldBand = new THREE.Mesh(new THREE.CylinderGeometry(0.555, 0.555, 7.2, 24, 1, true), matGold);
goldBand.rotation.x = Math.PI / 2;
goldBand.scale.set(1.001, 0.08, 1.001); // bande fine
plane.add(goldBand);
// Ailes principales (boîte aplatie, légèrement en flèche via rotation Y)
const wingGeo = new THREE.BoxGeometry(11, 0.18, 1.6);
const wings = new THREE.Mesh(wingGeo, matBody);
wings.position.set(0, -0.05, -0.4);
plane.add(wings);
// Bord doré sur les ailes
const wingTrim = new THREE.Mesh(new THREE.BoxGeometry(11.05, 0.06, 0.18), matGold);
wingTrim.position.set(0, -0.04, 0.32);
plane.add(wingTrim);
// Empennage horizontal (petites ailes arrière)
const tail = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.14, 0.85), matBody);
tail.position.set(0, 0.25, -3.0);
plane.add(tail);
// Empennage vertical (queue)
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.16, 1.45, 1.6), matNavy);
fin.position.set(0, 0.85, -2.9);
fin.geometry.translate(0, 0, -0.4); // base alignée arrière
plane.add(fin);
// Logo navy sur la queue (un petit cube doré)
const logoBadge = new THREE.Mesh(new THREE.BoxGeometry(0.165, 0.45, 0.35), matGold);
logoBadge.position.set(0, 1.1, -3.1);
plane.add(logoBadge);
// Réacteurs sous les ailes (cylindres)
const engineGeo = new THREE.CylinderGeometry(0.35, 0.32, 1.5, 18);
const engineL = new THREE.Mesh(engineGeo, matNavy);
engineL.rotation.x = Math.PI / 2;
engineL.position.set(-2.6, -0.45, 0.2);
plane.add(engineL);
const engineR = engineL.clone();
engineR.position.x = 2.6;
plane.add(engineR);
// Petites bouches d'admission dorées sur les réacteurs
const intakeGeo = new THREE.TorusGeometry(0.35, 0.05, 10, 24);
const intakeL = new THREE.Mesh(intakeGeo, matGold);
intakeL.position.set(-2.6, -0.45, 0.95);
plane.add(intakeL);
const intakeR = intakeL.clone();
intakeR.position.x = 2.6;
plane.add(intakeR);
scene.add(plane);
/* ── Nuages volants (sprites simples) ───────────────────────────────────── */
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);
/* ── État animé piloté par le scroll ───────────────────────────────────── */
const state = { progress: 0 };
ScrollTrigger.create({
trigger: '.scroll-stage',
start: 'top top',
end: 'bottom bottom',
scrub: 0.6,
onUpdate: self => { state.progress = self.progress; }
});
/* Étiquettes : fade-in/out aux bonnes plages de scroll */
const tl = gsap.timeline({
scrollTrigger: { trigger: '.scroll-stage', start: 'top top', end: 'bottom bottom', scrub: 0.4 }
});
tl.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 : caché jusqu'à ~92% du scroll, puis fade in */
gsap.fromTo('#ctaBtn',
{ opacity: 0, scale: 0.85 },
{
opacity: 1, scale: 1, duration: 0.5,
ease: 'cubic-bezier(0.2, 0.8, 0.2, 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;
/* Caméra : large arrière → côté → arrière en s'éloignant à la fin */
// Arc autour de l'avion
const camAngle = -0.6 + p * 1.7; // -0.6 rad → +1.1 rad
const camRadius = 18 - p * 6 + Math.sin(p * Math.PI) * -3; // 18 → 12, dip au milieu
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);
/* Avion : léger bob + roulis selon scroll */
plane.position.y = Math.sin(t * 0.9) * 0.15;
plane.rotation.z = Math.sin(t * 0.5) * 0.04 + (p - 0.5) * 0.18;
plane.rotation.y = Math.sin(t * 0.4) * 0.025;
/* Nuages : rotation lente autour de l'avion */
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();