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

View File

@ -1,11 +1,12 @@
/* ========================================================================= /* =========================================================================
INTRO SCENE MVA Global Fret INTRO SCENE MVA Global Fret
Photo aérienne d'Antananarivo en fond, avion 3D piloté par la souris : 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'avion lâche périodiquement des colis parachutés qui tombent et
l'écran à mesure qu'elle bouge, et sort par la droite. Pas de scroll. grandissent (perspective).
3D model credit (CC-BY 3.0) : « Airplane » by Poly by Google 3D model credits (CC-BY 3.0, Poly by Google) :
https://poly.pizza/m/a3XrQkLNna9 - « Airplane » https://poly.pizza/m/a3XrQkLNna9
- « Parachute » https://poly.pizza/m/3Z7vJ96JIEB
========================================================================= */ ========================================================================= */
import * as THREE from 'three'; import * as THREE from 'three';
@ -65,38 +66,89 @@ loader.load(
(err) => console.error('Failed to load airplane.glb:', err) (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 À intervalle régulier (et tant que l'avion est dans le cadre), on
spawn un sprite « parachute + colis » à la position de l'avion. clone un template « parachute + boîte ». La boîte est solidaire du
Chaque colis tombe lentement et grossit (effet de zoom à la parachute (mêmes parent même position/rotation/échelle), et placée
descente vers la caméra). Il s'efface en fin de course. pile au niveau du harnais pour que l'attachement soit lisible.
*/ */
const parcelTex = new THREE.TextureLoader().load('assets/parcel.png'); const PARCEL_SPAWN_EVERY = 1.4;
parcelTex.colorSpace = THREE.SRGBColorSpace; const PARCEL_FALL_SPEED = 1.4;
const PARCEL_DRIFT = 0.45;
const PARCEL_SPAWN_EVERY = 1.4; // un colis toutes les ~1.4s const PARCEL_INITIAL_SCALE = 0.45;
const PARCEL_FALL_SPEED = 1.4; // unités monde/s const PARCEL_FINAL_SCALE = 2.4;
const PARCEL_DRIFT = 0.45; // dérive horizontale (sortie de soute) const PARCEL_LIFETIME = 5.5;
const PARCEL_INITIAL_SCALE = 0.35;
const PARCEL_FINAL_SCALE = 2.6;
const PARCEL_LIFETIME = 5.5; // s avant de disparaître
const parcels = []; const parcels = [];
let lastSpawn = 0; 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) { function spawnParcel(x, y) {
const mat = new THREE.SpriteMaterial({ if (!parcelTemplate) return;
map: parcelTex, transparent: true, opacity: 0 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); g.position.set(x + (Math.random() - 0.5) * 0.4, y - 0.4, 0.2);
sprite.scale.setScalar(PARCEL_INITIAL_SCALE); g.scale.setScalar(PARCEL_INITIAL_SCALE);
scene.add(sprite); /* 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({ parcels.push({
sprite, age: 0, group: g, age: 0,
vx: (Math.random() - 0.5) * PARCEL_DRIFT, // dérive latérale légère vx: (Math.random() - 0.5) * PARCEL_DRIFT,
sway: 0.5 + Math.random() * 0.6, // freq de balancement sway: 0.5 + Math.random() * 0.6,
swayAmp: 0.08 + Math.random() * 0.08 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; p.age += dt;
const life = p.age / PARCEL_LIFETIME; const life = p.age / PARCEL_LIFETIME;
/* Trajectoire : chute douce + dérive + balancement parachute */ /* Chute + dérive + balancement (boîte et parachute solidaires) */
p.sprite.position.y -= PARCEL_FALL_SPEED * dt; p.group.position.y -= PARCEL_FALL_SPEED * dt;
p.sprite.position.x += p.vx * dt; p.group.position.x += p.vx * dt;
p.sprite.position.x += Math.sin(t * p.sway) * p.swayAmp * 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); 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 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; let opacity = 1;
if (life < 0.06) opacity = life / 0.06; if (life < 0.06) opacity = life / 0.06;
else if (life > 0.85) opacity = (1 - life) / 0.15; 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) { if (life >= 1 || p.group.position.y < -10) {
scene.remove(p.sprite); scene.remove(p.group);
p.sprite.material.dispose(); p.group.traverse((node) => {
if (node.isMesh && node.material) node.material.dispose();
});
parcels.splice(i, 1); parcels.splice(i, 1);
} }
} }