The black rectangles the user circled in the screenshot were the strap meshes (strapH/strapV, 0.04-thick black boxes) — they were meant to wrap the cardboard box but ended up rendering as detached rectangles in some viewing angles. They're gone. Cargo positioning is now computed from the actual scaled parachute bbox: parachuteBottom = -(size.y · baseScale)/2, cargo center sits just inside that line for a slight overlap so the parachute strings visually terminate on the box top. The cargo is now a child of the parachute mesh (para.add(cargo)), so any transform applied to the parachute — scale, rotation, position — carries the box along with it. To keep the visible box size consistent regardless of the parachute's baseScale, the cargo's local position and scale are divided by baseScale. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
302 lines
12 KiB
JavaScript
302 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);
|
|
|
|
/* Position du bas du parachute après centrage + scaling */
|
|
const parachuteBottom = -(size.y * baseScale) / 2;
|
|
|
|
/* Boîte cartonnée brune, attachée pile sous le harnais (top de la
|
|
boîte = bottom du parachute, donc center.y = bottom - height/2) */
|
|
const cargoH = 0.36;
|
|
const cargo = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.46, cargoH, 0.46),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0xb98859, roughness: 0.85, metalness: 0.05
|
|
})
|
|
);
|
|
cargo.position.y = parachuteBottom - cargoH / 2 + 0.05; // léger chevauchement
|
|
/* Le cargo est ENFANT du parachute para — comme ça le clone et toutes
|
|
les transformations (échelle, rotation) restent solidaires : la
|
|
boîte ne peut pas se détacher visuellement.
|
|
Pour compenser le scale du para, on inverse le baseScale sur la
|
|
position et la taille du cargo (cargo doit être dans l'espace
|
|
AVANT scaling de para). */
|
|
cargo.position.divideScalar(baseScale);
|
|
cargo.scale.setScalar(1 / baseScale);
|
|
para.add(cargo);
|
|
|
|
parcelTemplate = new THREE.Group();
|
|
parcelTemplate.add(para);
|
|
},
|
|
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();
|