Drops the cylinder+box airplane built last commit in favor of a CC-BY 3D commercial airliner from Poly by Google (188 KB GLB, 11.3k tris, hosted in assets/airplane.glb). Loaded at runtime via three/addons GLTFLoader; importmap extended to expose the addons subpath. Bug worth noting: a naive setFromObject + position.sub(center) + scale.setScalar pipeline leaves the model offset by -center after scaling because position is in pre-scale units. Fix is to wrap the model in a Group, apply the centering offset to the inner model, then scale the outer Group — the whole transform stays consistent. Attribution added in two places per CC-BY 3.0: - HTML header comment with creator + source URL + license link - JS file header in intro-scene.js Tone-mapping bumped to ACES filmic for a slightly nicer render. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
171 lines
6.6 KiB
JavaScript
171 lines
6.6 KiB
JavaScript
/* =========================================================================
|
|
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();
|