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>
This commit is contained in:
MVA Global Fret 2026-05-05 13:32:48 +02:00
parent 3fc8f26c2e
commit 6b0f8d9afb
4 changed files with 102 additions and 42 deletions

BIN
assets/parachute.glb Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

View File

@ -1,9 +1,9 @@
<!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/
- Modèle 3D « Airplane » par Poly by Google, CC-BY 3.0 — https://poly.pizza/m/a3XrQkLNna9
- Modèle 3D « Parachute » par Poly by Google, CC-BY 3.0 — https://poly.pizza/m/3Z7vJ96JIEB
- Licence : https://creativecommons.org/licenses/by/3.0/
-->
<html lang="fr">
<head>

View File

@ -1,11 +1,12 @@
/* =========================================================================
INTRO SCENE MVA Global Fret
Photo aérienne d'Antananarivo en fond, avion 3D piloté par la souris :
il entre par le haut-gauche quand la souris est à gauche, traverse
l'écran à mesure qu'elle bouge, et sort par la droite. Pas de scroll.
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 credit (CC-BY 3.0) : « Airplane » by Poly by Google
https://poly.pizza/m/a3XrQkLNna9
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';
@ -65,38 +66,89 @@ loader.load(
(err) => console.error('Failed to load airplane.glb:', err)
);
/* Colis parachutés
/* 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
spawn un sprite « parachute + colis » à la position de l'avion.
Chaque colis tombe lentement et grossit (effet de zoom à la
descente vers la caméra). Il s'efface en fin de course.
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 parcelTex = new THREE.TextureLoader().load('assets/parcel.png');
parcelTex.colorSpace = THREE.SRGBColorSpace;
const PARCEL_SPAWN_EVERY = 1.4; // un colis toutes les ~1.4s
const PARCEL_FALL_SPEED = 1.4; // unités monde/s
const PARCEL_DRIFT = 0.45; // dérive horizontale (sortie de soute)
const PARCEL_INITIAL_SCALE = 0.35;
const PARCEL_FINAL_SCALE = 2.6;
const PARCEL_LIFETIME = 5.5; // s avant de disparaître
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) {
const mat = new THREE.SpriteMaterial({
map: parcelTex, transparent: true, opacity: 0
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;
}
});
const sprite = new THREE.Sprite(mat);
sprite.position.set(x + (Math.random() - 0.5) * 0.4, y - 0.2, 0.2);
sprite.scale.setScalar(PARCEL_INITIAL_SCALE);
scene.add(sprite);
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({
sprite, age: 0,
vx: (Math.random() - 0.5) * PARCEL_DRIFT, // dérive latérale légère
sway: 0.5 + Math.random() * 0.6, // freq de balancement
swayAmp: 0.08 + Math.random() * 0.08
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
});
}
@ -106,26 +158,34 @@ function updateParcels(dt, t) {
p.age += dt;
const life = p.age / PARCEL_LIFETIME;
/* Trajectoire : chute douce + dérive + balancement parachute */
p.sprite.position.y -= PARCEL_FALL_SPEED * dt;
p.sprite.position.x += p.vx * dt;
p.sprite.position.x += Math.sin(t * p.sway) * p.swayAmp * dt;
/* 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 en tombant — perspective */
/* Grandit (perspective) */
const k = Math.min(1, life * 1.1);
p.sprite.scale.setScalar(
p.group.scale.setScalar(
PARCEL_INITIAL_SCALE + (PARCEL_FINAL_SCALE - PARCEL_INITIAL_SCALE) * k
);
/* Fade-in rapide au spawn, fade-out lent à la fin */
/* 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;
p.sprite.material.opacity = Math.max(0, Math.min(1, opacity));
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.sprite.position.y < -10) {
scene.remove(p.sprite);
p.sprite.material.dispose();
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);
}
}