Biddew, l’assistante de Wurus, a longtemps tenu dans cinq images fixes, animées en CSS. Elle danse aujourd’hui sur l’accueil du site, cligne des yeux pendant la conversation vocale et se laisse tourner sous tous les angles dans sa propre scène. Cette note raconte comment, en une semaine, un modèle 3D généré par IA est devenu une mascotte servie dans tout le produit, et surtout comment on l’a fait sans que l’application en devienne plus lourde.
Le point de départ
Avant ce chantier, Biddew existait sous la forme de cinq WebP fixes (32, 48 et 96 pixels pour les avatars, 192 et 512 pour les portraits), utilisés par une vingtaine de composants. La conversation vocale ne la montrait même pas : elle dessinait une sphère dorée en Canvas 2D. Le fondateur a fait générer un modèle 3D complet par un outil, livré en GLB, FBX et OBJ, avec une demande simple : Biddew vivante partout, et une excellente performance.
Ces deux exigences tirent en sens contraire. La 3D temps réel partout aurait coûté three.js (environ 150 Ko compressés), un fichier de plusieurs mégaoctets et un contexte WebGL par avatar, y compris dans la barre du haut affichée sur chaque écran. La décision prise le 3 octobre a été un rendu hybride : des images et des boucles pré-rendues partout, et la vraie 3D seulement là où l’on interagit avec Biddew.
Ce que contenait vraiment le modèle
Avant d’écrire une ligne, le GLB a été audité. Il pesait 8,4 Mo : 10 035 triangles, une seule matière à double face, et une texture JPEG en 4096 × 4096 qui faisait à elle seule 5,6 Mo, les deux tiers du fichier. Le squelette était un rig Mixamo standard de 65 os, doigts compris, avec 17 animations à 24 images par seconde.
Pour savoir quelles animations pouvaient boucler, chaque clip a été mesuré par l’écart de rotation maximal entre sa première et sa dernière image. L’attente au repos revenait à 1,4° de son départ : boucle propre. La réflexion (main au menton) s’écartait de 13,6° : il lui fallait un fondu. Et le clip « respiration » montrait 114° d’écart et des hanches déplacées de 34 cm : cassé, il a été exclu. Ce tableau a fixé une règle qui tient encore : seuls cinq clips bouclent (repos, détente, écoute, réflexion, parole), tous les autres se jouent une fois puis reviennent en fondu vers le repos, comme dans un jeu vidéo.
Un modèle, trois sorties
Le principe directeur tient en une phrase : chaque emplacement reçoit la forme la plus légère qui suffit à le rendre vivant. Les avatars de 96 pixels et moins reçoivent des images fixes de 1 à 5 Ko. Les héros, les états vides et la vitrine reçoivent des boucles pré-rendues. Seuls le Live (la conversation vocale), le portrait du hub et la scène chargent le modèle 3D, à la demande.
Une chaîne de production rejouable
Tout passe par des scripts versionnés dans scripts/biddew-3d/ : Blender en mode headless pour ce qui touche au maillage et au rendu, Node pour l’optimisation et l’encodage. Le fichier .blend de travail reste dans le drive de design ; le dépôt ne garde que les scripts et leurs sorties. Un nouvel export Tripo se rebranche donc sans retoucher le code, et chaque étape se relance seule.
blender -b --python scripts/biddew-3d/blender/prepare.py -- --src BIDDEW.glb --blend biddew.blend --out .work/biddew-prepared.glb
blender -b biddew.blend --python scripts/biddew-3d/blender/face.py -- --save biddew-face.blend --out .work/biddew-face.glb
node scripts/biddew-3d/optimize.mjs
blender -b biddew-face.blend --python scripts/biddew-3d/blender/render.py -- --out .work/renders --stills --size 1024
node scripts/biddew-3d/encode.mjs
prepare.py renomme les clips à partir d’une table explicite, clips.map.json. Les noms Tripo portaient des accents, un suffixe .001 et même un retour à la ligne dans le nom de l’attente au repos ; plutôt que de les deviner, la table les nomme un par un, et une valeur null exclut un clip de l’export sans le supprimer du fichier de travail. Le script soude aussi les cinq boucles sur leurs dix dernières images et coupe l’applaudissement, qui durait 13,7 s, autour de 5 s.
{
"clips": {
"idle \nrespiration": "idle",
"thinking (main au menton)": "thinking",
"wave (salut)": "wave",
"dance_06": "dance",
"respiration": null
},
"loop": ["idle", "rest", "listening", "thinking", "talking"],
"weld_frames": 10
}
Deux modules corrigent ensuite les animations, qui avaient été conçues pour un autre corps. _hands.py mesure, image par image, la profondeur à laquelle la main et l’avant-bras passent sous la surface du buste ou de la robe ample, puis écarte le bras depuis l’épaule juste assez pour les en sortir, avec une correction lissée dans le temps pour ne pas trembler. _pose.py adoucit les gestes Mixamo, pensés pour un corps adulte et sec, en rapprochant buste, cou et tête de la pose de repos, et plie légèrement les coudes des bras qui pendent : c’est ce qui distingue un bras détendu d’un bras de mannequin.
Donner un visage à Biddew
face.py ajoute au maillage sept shape keys, exportées en morph targets glTF : jawOpen, blinkLeft, blinkRight et leurs demi-fermetures, smile et surprised. Le script commence par souder les sommets que l’import glTF avait dupliqués le long des coutures de texture, pour retrouver une topologie continue, puis travaille à partir de repères mesurés sur un rendu de contrôle : la ligne des lèvres, la commissure, le pivot de la mâchoire sous l’oreille, l’ouverture de l’œil en degrés autour du centre du globe.
La bouche a été la partie facile : une rotation de 7° autour du pivot de mâchoire, avec une atténuation en dégradé, puis une lèvre du haut qui se soulève légèrement. Les paupières ont demandé cinq versions dans la même journée. Il n’y en avait pas : il a fallu les construire en géométrie, en coque juste devant l’œil peint. La première version a même été retirée du produit en attendant mieux. La deuxième faisait descendre une vraie paupière avec ses cils, la troisième recalculait sa géométrie, la quatrième se calait sur le vrai contour de l’œil, et la cinquième, validée par le fondateur, est peinte avec la teinte de peau échantillonnée dans la texture et raccordée à la peau d’origine.
En conversation vocale, Biddew n’a pas de synchronisation labiale phonème par phonème : l’amplitude de la voix, échantillonnée 20 fois par seconde et lissée, pilote l’ouverture de la mâchoire et ajoute de légers hochements sur les os de la tête et du cou. Elle cligne des yeux au hasard toutes les 3 à 6 secondes et suit discrètement le curseur du regard. C’est un lip-sync de dessin animé, assumé : il coûte quelques multiplications par image et se lit bien à la taille où on la voit.
Optimiser le fichier : de 8,4 à 1,5 Mo
optimize.mjs repose sur gltf-transform et produit les fichiers réellement servis :
- la texture passe de 4096 en JPEG à 2048 en WebP (extension
EXT_texture_webp). Le plan visait le KTX2, plus économe en mémoire GPU, mais l’outil d’encodage n’était pas installé : c’est noté comme amélioration à brancher, pas oublié ; - la géométrie est soudée, dédupliquée, quantifiée et compressée en meshopt ;
- les animations sont ré-échantillonnées, et surtout débarrassées des pistes immobiles : un filtre maison retire toute piste qui ne quitte jamais la pose de repos de son os (translations et échelles Mixamo, os terminaux). Plus de la moitié des pistes partent ainsi, et avec elles l’essentiel du JSON du fichier ;
- deux variantes sortent :
live, sans les clips de danse et d’expressions, etshow, réservée aux usages de démonstration ; - les noms de fichiers portent un hachage de leur contenu (
biddew.214000a1.glb), servis avec un cache long et immuable, et décrits par unmanifest.jsonqui liste les clips, leur durée, s’ils bouclent, et les morphs disponibles.
Le critère d’acceptation était un fichier de 1,5 Mo au plus. La variante live pèse 1 513 032 octets, et le script refuse d’écrire un fichier qui dépasserait le budget.
Les rendus pré-calculés
Pour les images fixes et les boucles, render.py utilise Blender Eevee sur fond transparent, avec un éclairage trois points et deux caméras (buste pour les avatars, pied pour les héros). Eevee donne des ombres douces et une occlusion ambiante qu’un rendu temps réel sobre n’aurait pas. Avant tout encodage, une planche de contrôle (un tour à 360° et toutes les poses) a été soumise au fondateur.
encode.mjs produit les poses fixes en WebP et AVIF, de 32 à 512 pixels, et surtout réécrit les cinq anciens fichiers sous leurs noms d’origine. C’est le gain le moins coûteux du chantier : une quarantaine d’écrans ont changé de visage sans qu’une ligne de leur code ne bouge.
Pour les boucles, le choix du format s’est fait à la mesure, sur la boucle de parole en 320 pixels et 5 secondes : AVIF animé 173 Ko, WebM VP9 289 Ko (et sans transparence sur Safari), WebP animé 1,37 Mo. L’AVIF animé l’a emporté : il garde la transparence, il est lu par une simple balise <img> dans tous les navigateurs actuels (Safari 16.4 et suivants), et un navigateur qui ne le connaît pas affiche la première image. Treize gestes ont été encodés ainsi.
Côté application, BiddewAvatar montre une image fixe sous 96 pixels, quand le mouvement est réduit, en mode économie de données ou en 2G, et une boucle sinon. Hors de l’écran ou onglet caché, l’avatar repasse sur son image fixe : rien ne se décode pour personne. Les états ponctuels (réussite, oups) se jouent une fois puis reviennent au repos.
La 3D temps réel, et seulement là où elle sert
BiddewStage3D est le seul composant de l’application qui importe three.js, avec ses chargeurs glTF et meshopt. Il vit dans un chunk asynchrone, préchargé à l’intention : le survol ou le focus du bouton qui l’ouvrira lance déjà le téléchargement du fichier et du code. Un contrôle de build, check_boot_graph.mjs, fait échouer la construction si three.js entre un jour dans le chemin de démarrage.
Le rendu est volontairement sobre : un éclairage hémisphérique et trois lumières directionnelles, sans ombres, une densité de pixels plafonnée à 1,5, 30 images par seconde sous 768 pixels de large, rendu suspendu hors de l’écran ou onglet caché, et libération complète (géométries, textures, mixer, renderer) au démontage. Dans le Live, le statut de la conversation choisit l’animation :
-
Ouverture Elle salue
waveune fois, puis retour au repos. -
Écoute Tête penchée
listening, en boucle. -
Réflexion Main au menton
thinkingpendant la connexion, la reconnexion et le calcul. -
Parole Elle parle
talking, mâchoire pilotée par l’amplitude de la voix. -
Proposition Elle montre
pointquand une action vous est proposée. -
Résultat Youpi ou oups
successquand l’action aboutit,oopssur une erreur,agreesur un « oui » vocal.
Quand la 3D n’est pas raisonnable, elle n’est pas tentée. La décision tient dans une fonction :
export function hasStage3DHardware() {
const nav = window.navigator || {};
const modest = (nav.deviceMemory || 8) <= 4 && (nav.hardwareConcurrency || 8) <= 4;
if (modest) return false;
if (nav.connection?.saveData) return false;
const gl = document.createElement("canvas").getContext("webgl2");
gl?.getExtension("WEBGL_lose_context")?.loseContext();
return Boolean(gl);
}
La préférence existante « mascotte ou onde » a été conservée : l’onde garde son orbe dorée. Depuis la 26.14.5, la mascotte est le choix par défaut de tout le monde, comme le raconte la note de version 26.14.5.
Sur le site public : danser sans peser
La section Biddew de l’accueil du site montre la mascotte qui danse en pied, rendue pour elle, et réagit à la souris, au clic et au toucher. Elle est sous la ligne de flottaison : son image fixe est en chargement différé, la boucle attend la fin du chargement de la page et l’entrée dans l’écran, et les gestes attendent une intention. Mesuré sur Lighthouse mobile, trois passes de chaque : LCP médian de 4 972 ms sans la section, 4 977 ms avec (+0,1 %), CLS entre 0 et 0,003, et aucun fichier de Biddew téléchargé pendant le chargement mesuré.
La scène : jouer avec Biddew
Dernière étape, livrée le 10 octobre : la scène, ouverte depuis le portrait du hub. Elle réutilise tout ce qui précède, et n’ajoute que des props rétrocompatibles à BiddewStage3D (variante, orbite de caméra, cadrage, réglages du visage, événements de début et de fin de clip) : le Live et le portrait, qui ne les passent pas, ne changent pas d’un pixel.
La règle la plus importante est invisible : un seul contexte WebGL à la fois. Quand la scène s’ouvre, le portrait du hub démonte sa 3D et affiche son image fixe ; quand elle se ferme, la scène libère tout, puis le portrait remonte la sienne. Le fichier et le code étant en cache, ce remontage coûte une compilation de shaders, pas un téléchargement. Mesuré : entre 0,73 et 0,82 s du clic à la première image 3D, et three.js reste hors du démarrage.
Quelques choix de détail, qui font la différence à l’usage :
- la grille des gestes est construite à partir du manifeste : aucune liste en dur dans la vue, et un geste ajouté au modèle apparaît tout seul ;
- « danse », « salue » ou « bravo » tapés dans le champ passent par une simple table de correspondance, sans aucun appel à un modèle d’IA : la scène ne consomme aucun crédit et fonctionne solde à zéro ;
- le décor est un fond CSS derrière un canvas transparent : changer de décor ne coûte rien au rendu 3D ;
- la photo rend le canvas et le transforme en image dans la même image d’animation, sans activer
preserveDrawingBuffer, qui ralentirait chaque image pour un usage ponctuel ; - sur téléphone, le glisser pour fermer le volet est limité à son en-tête : sur la scène, un glisser fait tourner Biddew, et mélanger les deux gestes aurait été la première source de frustration.
Les budgets, et ce qu’on a mesuré
| Au quotidien | Budget | Mesuré |
|---|---|---|
| Fichier 3D servi | 1,5 Mo au plus | 1,51 Mo (variante live) |
| Première image après préchargement | Moins de 1 s | 434 à 492 ms (1,7 s à froid) |
| Travail JS par image, CPU ×4 | 16,7 ms (60 i/s) | 2,3 ms au p95, 4,4 ms au pire |
| Mémoire GPU | Moins de 100 Mo | Environ 60 Mo pour Biddew |
| Fuite après 30 ouvertures | Aucune | Tas JS de 12 à 13 Mo, mémoire GPU stable |
| LCP mobile de l’accueil | ± 5 % | +0,1 % |
Les mesures du Live ont été faites dans Chrome headless sur le vrai GPU du poste de développement, avec un script dédié, scripts/biddew-3d/perf.mjs. Le mode headless plafonne l’animation à 30 images par seconde : on y mesure donc le travail par image plutôt qu’un compteur d’images, ce qui dit mieux la marge restante.
Ce qui reste ouvert
- Un vrai iPhone : l’AVIF animé et la scène ont été validés dans Chrome sur ordinateur et en émulation mobile, pas encore sur Safari iOS sur un appareil physique.
- La texture en KTX2, qui diviserait encore la mémoire GPU : l’encodeur reste à installer sur le poste de production des fichiers.
- Les six expressions fines de la planche d’origine (rire, clin d’œil, réfléchie, sérieuse…) : un script donne une bouche et des paupières correctes, pas la finesse d’un modelage manuel. Elles restent portées par le corps tant qu’un artiste ne les a pas sculptées.
- Le clip « expressions », absent de la scène : il aurait coûté le téléchargement d’un second fichier 3D.