site-mva-global-fret/js/intro-scene.js
MVA Global Fret 6b0f8d9afb Switch parcel sprites to a real 3D parachute GLB with bound cargo
Drops the parcel.png sprite (which baked the parachute and the box
into a single image) for a real 3D model: assets/parachute.glb is
the CC-BY 3.0 « Parachute » by Poly by Google, decompressed from
poly.pizza's static.poly.pizza CDN.

The parcel template now stacks four meshes inside one Group so they
move as a unit:
- the loaded parachute (centered + scaled to ~1.6 world units max)
- a 0.46×0.36×0.46 brown box with metal-low MeshStandardMaterial,
  positioned at y = -0.96 — right below the parachute's harness
  point — so the model's strings appear to terminate on it
- two thin black straps wrapping the box (0.04-thick boxes, one
  horizontal one vertical) for visual reinforcement that the cargo
  is tied down

spawnParcel clones the template (deep), per-instance clones every
material so opacity can be modulated independently per parcel, and
adds a slight pendulum sway + slow Y spin. Falling/scale-up/fade
logic adapted from the sprite version. Cleanup disposes the cloned
materials when a parcel exits.

HTML credit comment extended to attribute both the airplane and the
new parachute under CC-BY 3.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-05 13:32:48 +02:00

301 lines
12 KiB
JavaScript

/* =========================================================================
INTRO SCENE — MVA Global Fret
Photo aérienne d'Antananarivo en fond, avion 3D piloté par la souris.
L'avion lâche périodiquement des colis parachutés qui tombent et
grandissent (perspective).
3D model credits (CC-BY 3.0, Poly by Google) :
- « Airplane » → https://poly.pizza/m/a3XrQkLNna9
- « Parachute » → https://poly.pizza/m/3Z7vJ96JIEB
========================================================================= */
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
/* ── 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(40, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 22);
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;
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));
/* Pivote le modèle pour que le nez pointe vers la gauche (-X) :
l'avion entre par la droite et sort par la gauche. */
wrapper.rotation.y = -Math.PI / 2;
planeHolder.add(wrapper);
},
undefined,
(err) => console.error('Failed to load airplane.glb:', err)
);
/* ── Colis parachutés (modèle 3D Poly by Google + boîte cartonnée) ──────
À intervalle régulier (et tant que l'avion est dans le cadre), on
clone un template « parachute + boîte ». La boîte est solidaire du
parachute (mêmes parent → même position/rotation/échelle), et placée
pile au niveau du harnais pour que l'attachement soit lisible.
*/
const PARCEL_SPAWN_EVERY = 1.4;
const PARCEL_FALL_SPEED = 1.4;
const PARCEL_DRIFT = 0.45;
const PARCEL_INITIAL_SCALE = 0.45;
const PARCEL_FINAL_SCALE = 2.4;
const PARCEL_LIFETIME = 5.5;
const parcels = [];
let lastSpawn = 0;
let parcelTemplate = null;
const parachuteLoader = new GLTFLoader();
parachuteLoader.load(
'assets/parachute.glb',
(gltf) => {
const para = gltf.scene;
/* Centrer + normaliser le parachute à ~1.6 unités max */
const box = new THREE.Box3().setFromObject(para);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
para.position.sub(center);
const baseScale = 1.6 / Math.max(size.x, size.y, size.z);
para.scale.setScalar(baseScale);
/* Boîte cartonnée brune, dimensionnée pour ressembler à un colis
réaliste, attachée juste au-dessous du harnais (bottom of bbox
≈ y -0.8 après centrage + scaling à 1.6). */
const cargo = new THREE.Mesh(
new THREE.BoxGeometry(0.46, 0.36, 0.46),
new THREE.MeshStandardMaterial({
color: 0xb98859, roughness: 0.85, metalness: 0.05
})
);
cargo.position.y = -0.96; // pendu juste sous le harnais
/* Petites « sangles » noires sur la boîte (deux fines bandes
perpendiculaires) → renforce visuellement l'idée d'attachement. */
const strapMat = new THREE.MeshStandardMaterial({ color: 0x2a2118, roughness: 0.6 });
const strapH = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.04, 0.48), strapMat);
strapH.position.copy(cargo.position);
const strapV = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.38, 0.48), strapMat);
strapV.position.copy(cargo.position);
parcelTemplate = new THREE.Group();
parcelTemplate.add(para);
parcelTemplate.add(cargo);
parcelTemplate.add(strapH);
parcelTemplate.add(strapV);
},
undefined,
(err) => console.error('Failed to load parachute.glb:', err)
);
function spawnParcel(x, y) {
if (!parcelTemplate) return;
const g = parcelTemplate.clone(true);
/* On clone aussi les matériaux pour pouvoir gérer le fade par instance */
g.traverse((node) => {
if (node.isMesh && node.material) {
node.material = node.material.clone();
node.material.transparent = true;
}
});
g.position.set(x + (Math.random() - 0.5) * 0.4, y - 0.4, 0.2);
g.scale.setScalar(PARCEL_INITIAL_SCALE);
/* Petit angle initial différent pour chaque colis pour la variété */
g.rotation.y = Math.random() * Math.PI * 2;
scene.add(g);
parcels.push({
group: g, age: 0,
vx: (Math.random() - 0.5) * PARCEL_DRIFT,
sway: 0.5 + Math.random() * 0.6,
swayAmp: 0.08 + Math.random() * 0.08,
spinSpeed: (Math.random() - 0.5) * 0.4
});
}
function updateParcels(dt, t) {
for (let i = parcels.length - 1; i >= 0; i--) {
const p = parcels[i];
p.age += dt;
const life = p.age / PARCEL_LIFETIME;
/* Chute + dérive + balancement (boîte et parachute solidaires) */
p.group.position.y -= PARCEL_FALL_SPEED * dt;
p.group.position.x += p.vx * dt;
p.group.position.x += Math.sin(t * p.sway) * p.swayAmp * dt;
/* Léger basculement — pendulum naturel */
p.group.rotation.z = Math.sin(t * p.sway + p.age) * 0.10;
p.group.rotation.y += p.spinSpeed * dt;
/* Grandit (perspective) */
const k = Math.min(1, life * 1.1);
p.group.scale.setScalar(
PARCEL_INITIAL_SCALE + (PARCEL_FINAL_SCALE - PARCEL_INITIAL_SCALE) * k
);
/* Fade-in rapide / fade-out lent — propagé sur tous les meshes */
let opacity = 1;
if (life < 0.06) opacity = life / 0.06;
else if (life > 0.85) opacity = (1 - life) / 0.15;
opacity = Math.max(0, Math.min(1, opacity));
p.group.traverse((node) => {
if (node.isMesh && node.material) node.material.opacity = opacity;
});
if (life >= 1 || p.group.position.y < -10) {
scene.remove(p.group);
p.group.traverse((node) => {
if (node.isMesh && node.material) node.material.dispose();
});
parcels.splice(i, 1);
}
}
}
/* ── Souris ─────────────────────────────────────────────────────────────
L'avion avance automatiquement à vitesse de croisière (BASE_SPEED).
Bouger la souris ajoute un boost qui le pousse plus vite vers la fin.
Une fois sorti à droite (progress = 1), il reste sorti.
*/
const BASE_SPEED = 1 / 28; // 28 s pour traverser sans toucher la souris
const MOUSE_BOOST = 1 / 4500; // pixels de souris → progress (0..1)
const mouse = {
targetProgress: 0,
progress: 0,
px: 0.5, py: 0.5
};
let lastX = null, lastY = null;
window.addEventListener('mousemove', (e) => {
if (lastX !== null) {
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
mouse.targetProgress = Math.min(1, mouse.targetProgress + Math.hypot(dx, dy) * MOUSE_BOOST);
}
lastX = e.clientX; lastY = e.clientY;
mouse.px = e.clientX / window.innerWidth;
mouse.py = e.clientY / window.innerHeight;
}, { passive: true });
/* Mobile : la rotation du device pousse l'avion plus vite vers la fin */
let lastGamma = null, lastBeta = null;
window.addEventListener('deviceorientation', (e) => {
if (e.gamma == null || e.beta == null) return;
if (lastGamma !== null) {
const dg = e.gamma - lastGamma;
const db = e.beta - lastBeta;
mouse.targetProgress = Math.min(1, mouse.targetProgress + Math.hypot(dg, db) / 120);
}
lastGamma = e.gamma; lastBeta = e.beta;
mouse.px = Math.max(0, Math.min(1, (e.gamma + 30) / 60));
mouse.py = Math.max(0, Math.min(1, (e.beta - 20) / 60));
}, { passive: true });
const root = document.documentElement;
/* ── Render loop ───────────────────────────────────────────────────────── */
const clock = new THREE.Clock();
let lastT = 0;
function tick() {
const t = clock.getElapsedTime();
const dt = Math.min(0.1, t - lastT); // clamp pour éviter les bonds après onglet en arrière-plan
lastT = t;
/* Avance autonome + cible cumulée par la souris, le tout limité à 1 */
mouse.targetProgress = Math.min(1, mouse.targetProgress + BASE_SPEED * dt);
/* Lerp doux pour fluidifier */
mouse.progress += (mouse.targetProgress - mouse.progress) * 0.06;
const p = mouse.progress;
/* Parallaxe légère de la photo de fond */
root.style.setProperty('--mx', ((mouse.px - 0.5) * 2).toFixed(4));
root.style.setProperty('--my', ((mouse.py - 0.5) * 2).toFixed(4));
/* Trajectoire droite → gauche, en très légère montée :
- p = 0 → entre par la droite à mi-hauteur haute (y ≈ 3.5)
- p = 0.5 → traverse à y ≈ 4.25
- p = 1 → sort par la gauche un peu plus haut (y ≈ 5)
Pente ≈ 1.5/40 ≈ 2° (presque horizontal).
*/
const px = 18 - p * 40; // +18 → -22 (toujours bien hors champ aux deux bouts)
const py = 3.5 + p * 1.5; // +3.5 → +5 (douce montée)
const bob = Math.sin(t * 0.9) * 0.08;
planeHolder.position.set(px, py + bob, 0);
/* Spawn d'un colis parachuté à intervalle régulier — uniquement
pendant que l'avion est dans (ou proche de) la zone visible. */
if (p > 0.04 && p < 0.92 && t - lastSpawn > PARCEL_SPAWN_EVERY) {
spawnParcel(px, py + bob);
lastSpawn = t;
}
updateParcels(dt, t);
/* Avec nez à -X et up = +Y :
- rotation.z = PITCH (négatif = nez en l'air)
- rotation.x = ROLL
Pente de 2° → on relève le nez légèrement pour suivre la trajectoire.
*/
const targetPitch = -0.06 - p * 0.01; // nez ~3.5°-4° en l'air
const targetRoll = 0.04 + (p - 0.5) * 0.04; // roulis très subtil
const targetYaw = 0;
planeHolder.rotation.z += (targetPitch - planeHolder.rotation.z) * 0.08;
planeHolder.rotation.x += (targetRoll - planeHolder.rotation.x) * 0.08;
planeHolder.rotation.y += (targetYaw - planeHolder.rotation.y) * 0.08;
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();