Replace primitive plane with a real GLTF airliner
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>
This commit is contained in:
parent
341dca7cb5
commit
d99e2a5fc1
BIN
assets/airplane.glb
Normal file
BIN
assets/airplane.glb
Normal file
Binary file not shown.
11
index.html
11
index.html
@ -1,4 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
Crédits :
|
||||||
|
- Modèle 3D « Airplane » par Poly by Google, licence CC-BY 3.0
|
||||||
|
Source : https://poly.pizza/m/a3XrQkLNna9
|
||||||
|
https://creativecommons.org/licenses/by/3.0/
|
||||||
|
-->
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@ -12,11 +18,12 @@
|
|||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="css/parallax.css">
|
<link rel="stylesheet" href="css/parallax.css">
|
||||||
|
|
||||||
<!-- Three.js (ESM via importmap) -->
|
<!-- Three.js (ESM via importmap) + addons (GLTFLoader, etc.) -->
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"three": "https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.module.js"
|
"three": "https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.158.0/examples/jsm/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,24 +1,29 @@
|
|||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
INTRO SCENE — MVA Global Fret
|
INTRO SCENE — MVA Global Fret
|
||||||
Scène Three.js minimaliste : un avion-cargo low-poly construit à partir
|
Three.js scene driven by scroll. The cargo airliner is a GLTF model
|
||||||
de primitives (cylindre + boîtes + cônes), peint aux couleurs de la marque.
|
loaded at runtime; GSAP + ScrollTrigger animate the camera position
|
||||||
La timeline est pilotée par GSAP + ScrollTrigger : la position du scroll
|
and exposition labels as the page scrolls. The CTA fades in at the
|
||||||
anime caméra, avion et étiquettes. À 100% du scroll, le bouton CTA est
|
end of the timeline.
|
||||||
révélé.
|
|
||||||
|
3D model credit (CC-BY): "Airplane" by Poly by Google
|
||||||
|
https://poly.pizza/m/a3XrQkLNna9
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||||
|
|
||||||
const { gsap, ScrollTrigger } = window;
|
const { gsap, ScrollTrigger } = window;
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
/* ── Setup ──────────────────────────────────────────────────────────────── */
|
/* ── Renderer & camera ─────────────────────────────────────────────────── */
|
||||||
const canvas = document.getElementById('three-canvas');
|
const canvas = document.getElementById('three-canvas');
|
||||||
const renderer = new THREE.WebGLRenderer({
|
const renderer = new THREE.WebGLRenderer({
|
||||||
canvas, alpha: true, antialias: true, powerPreference: 'high-performance'
|
canvas, alpha: true, antialias: true, powerPreference: 'high-performance'
|
||||||
});
|
});
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.05;
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
|
|
||||||
@ -26,104 +31,48 @@ const camera = new THREE.PerspectiveCamera(38, window.innerWidth / window.innerH
|
|||||||
camera.position.set(0, 1.4, 18);
|
camera.position.set(0, 1.4, 18);
|
||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
/* ── Lighting ───────────────────────────────────────────────────────────── */
|
/* ── Lighting (golden-hour fill) ───────────────────────────────────────── */
|
||||||
const sun = new THREE.DirectionalLight(0xfff1d6, 1.4); // chaude, golden hour
|
const sun = new THREE.DirectionalLight(0xfff1d6, 1.5);
|
||||||
sun.position.set(-6, 8, 4);
|
sun.position.set(-6, 8, 4);
|
||||||
scene.add(sun);
|
scene.add(sun);
|
||||||
|
|
||||||
const skyFill = new THREE.HemisphereLight(0xa8c5ff, 0x3a2a1a, 0.55);
|
const skyFill = new THREE.HemisphereLight(0xa8c5ff, 0x3a2a1a, 0.65);
|
||||||
scene.add(skyFill);
|
scene.add(skyFill);
|
||||||
|
|
||||||
const ambient = new THREE.AmbientLight(0xffffff, 0.18);
|
const ambient = new THREE.AmbientLight(0xffffff, 0.20);
|
||||||
scene.add(ambient);
|
scene.add(ambient);
|
||||||
|
|
||||||
/* ── Avion (low-poly à partir de primitives) ─────────────────────────────
|
/* ── Plane (GLTF) ──────────────────────────────────────────────────────── */
|
||||||
Couleurs MVA : corps blanc cassé, accents navy + or sur les ailerons. */
|
const planeHolder = new THREE.Group();
|
||||||
const NAVY = new THREE.Color(0x1a1a3e);
|
scene.add(planeHolder);
|
||||||
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 loader = new GLTFLoader();
|
||||||
const matNavy = new THREE.MeshStandardMaterial({ color: NAVY, roughness: 0.55, metalness: 0.10 });
|
loader.load(
|
||||||
const matGold = new THREE.MeshStandardMaterial({ color: GOLD, roughness: 0.35, metalness: 0.55 });
|
'assets/airplane.glb',
|
||||||
const matWindow = new THREE.MeshStandardMaterial({ color: 0x0e1a30, roughness: 0.2, metalness: 0.4, emissive: 0x0a1428, emissiveIntensity: 0.4 });
|
(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 plane = new THREE.Group();
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
// Fuselage — cylindre couché le long de l'axe Z
|
/* ── Cloud sprites ─────────────────────────────────────────────────────── */
|
||||||
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 clouds = new THREE.Group();
|
||||||
const cloudMat = new THREE.MeshStandardMaterial({
|
const cloudMat = new THREE.MeshStandardMaterial({
|
||||||
color: 0xffffff, roughness: 1.0, metalness: 0, transparent: true, opacity: 0.85
|
color: 0xffffff, roughness: 1.0, metalness: 0, transparent: true, opacity: 0.85
|
||||||
@ -138,7 +87,7 @@ for (let i = 0; i < 14; i++) {
|
|||||||
}
|
}
|
||||||
scene.add(clouds);
|
scene.add(clouds);
|
||||||
|
|
||||||
/* ── État animé piloté par le scroll ───────────────────────────────────── */
|
/* ── Scroll-driven progress ────────────────────────────────────────────── */
|
||||||
const state = { progress: 0 };
|
const state = { progress: 0 };
|
||||||
|
|
||||||
ScrollTrigger.create({
|
ScrollTrigger.create({
|
||||||
@ -149,23 +98,23 @@ ScrollTrigger.create({
|
|||||||
onUpdate: self => { state.progress = self.progress; }
|
onUpdate: self => { state.progress = self.progress; }
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Étiquettes : fade-in/out aux bonnes plages de scroll */
|
/* Cross-fade des étiquettes d'acte */
|
||||||
const tl = gsap.timeline({
|
const labelTl = gsap.timeline({
|
||||||
scrollTrigger: { trigger: '.scroll-stage', start: 'top top', end: 'bottom bottom', scrub: 0.4 }
|
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)
|
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)
|
.to( '#label-paris', { opacity: 0, y: -20 }, 0.20)
|
||||||
.fromTo('#label-cruise', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.40)
|
.fromTo('#label-cruise', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.40)
|
||||||
.to( '#label-cruise', { opacity: 0, y: -20 }, 0.60)
|
.to( '#label-cruise', { opacity: 0, y: -20 }, 0.60)
|
||||||
.fromTo('#label-tana', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.72)
|
.fromTo('#label-tana', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.72)
|
||||||
.to( '#label-tana', { opacity: 0, y: -20 }, 0.92);
|
.to( '#label-tana', { opacity: 0, y: -20 }, 0.92);
|
||||||
|
|
||||||
/* CTA : caché jusqu'à ~92% du scroll, puis fade in */
|
/* CTA reveal — last ~10% of scroll */
|
||||||
gsap.fromTo('#ctaBtn',
|
gsap.fromTo('#ctaBtn',
|
||||||
{ opacity: 0, scale: 0.85 },
|
{ opacity: 0, scale: 0.85 },
|
||||||
{
|
{
|
||||||
opacity: 1, scale: 1, duration: 0.5,
|
opacity: 1, scale: 1,
|
||||||
ease: 'cubic-bezier(0.2, 0.8, 0.2, 1)',
|
|
||||||
scrollTrigger: {
|
scrollTrigger: {
|
||||||
trigger: '.scroll-stage',
|
trigger: '.scroll-stage',
|
||||||
start: 'bottom-=400 bottom',
|
start: 'bottom-=400 bottom',
|
||||||
@ -177,16 +126,15 @@ gsap.fromTo('#ctaBtn',
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Render loop ────────────────────────────────────────────────────────── */
|
/* ── Render loop ───────────────────────────────────────────────────────── */
|
||||||
const clock = new THREE.Clock();
|
const clock = new THREE.Clock();
|
||||||
function tick() {
|
function tick() {
|
||||||
const t = clock.getElapsedTime();
|
const t = clock.getElapsedTime();
|
||||||
const p = state.progress;
|
const p = state.progress;
|
||||||
|
|
||||||
/* Caméra : large arrière → côté → arrière en s'éloignant à la fin */
|
/* Camera arc around the plane */
|
||||||
// Arc autour de l'avion
|
const camAngle = -0.6 + p * 1.7;
|
||||||
const camAngle = -0.6 + p * 1.7; // -0.6 rad → +1.1 rad
|
const camRadius = 18 - p * 6 + Math.sin(p * Math.PI) * -3;
|
||||||
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;
|
const camHeight = 1.4 + Math.sin(p * Math.PI) * 1.8 - p * 0.6;
|
||||||
|
|
||||||
camera.position.set(
|
camera.position.set(
|
||||||
@ -196,12 +144,12 @@ function tick() {
|
|||||||
);
|
);
|
||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
/* Avion : léger bob + roulis selon scroll */
|
/* Plane bob + roll */
|
||||||
plane.position.y = Math.sin(t * 0.9) * 0.15;
|
planeHolder.position.y = Math.sin(t * 0.9) * 0.15;
|
||||||
plane.rotation.z = Math.sin(t * 0.5) * 0.04 + (p - 0.5) * 0.18;
|
planeHolder.rotation.z = Math.sin(t * 0.5) * 0.04 + (p - 0.5) * 0.18;
|
||||||
plane.rotation.y = Math.sin(t * 0.4) * 0.025;
|
planeHolder.rotation.y = Math.sin(t * 0.4) * 0.025;
|
||||||
|
|
||||||
/* Nuages : rotation lente autour de l'avion */
|
/* Clouds slow rotation */
|
||||||
clouds.rotation.y = t * 0.04 - p * 0.6;
|
clouds.rotation.y = t * 0.04 - p * 0.6;
|
||||||
clouds.position.y = Math.sin(t * 0.3) * 0.4;
|
clouds.position.y = Math.sin(t * 0.3) * 0.4;
|
||||||
|
|
||||||
@ -209,7 +157,7 @@ function tick() {
|
|||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Resize ─────────────────────────────────────────────────────────────── */
|
/* ── Resize ────────────────────────────────────────────────────────────── */
|
||||||
function resize() {
|
function resize() {
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
const h = window.innerHeight;
|
const h = window.innerHeight;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user