Le blog Notes techniques

Note technique : comment Biddew est passée en 3D sans alourdir Wurus

16 octobre 2026 · Notes techniques · 16 min de lecture

Sommaire

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.

8,4 → 1,5Mo du modèle d’origine au fichier servi au navigateur
0,4ms de travail JavaScript par image au p95, pour un budget de 16,7 ms
+0,1% sur le LCP mobile de l’accueil du site, section Biddew comprise
≈ 60Mo de mémoire GPU pour Biddew en 3D, budget fixé à 100

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.

Un modèle, trois sorties : images fixes pour les petits avatars, boucles AVIF animées pour les héros et la vitrine, 3D temps réel chargée à la demande pour le Live, le hub et la scène.
Schéma WURUS.

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.

La chaîne de production : le GLB Tripo de 8,4 Mo passe par prepare.py, face.py, optimize.mjs, render.py et encode.mjs, et sort en GLB de 1,5 Mo, en images fixes et en boucles AVIF.
Schéma WURUS.
bash
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
Les cinq étapes, dans l’ordre (arguments abrégés).

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.

json
{
  "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
}
Extrait de clips.map.json : le nom source, la clé cible, et les clips qui bouclent.

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.

L’onglet Visage de la scène : Biddew de près, le sourire réglé à 90 %, l’étonnement à 50 %, le clignement automatique et le bouton Cligner.
Capture d’écran WURUS.

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 :

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.

Saluer, rendu Eevee 512 px.
Réfléchir, rendu Eevee 512 px.
Rire, rendu Eevee 512 px.
Réussite, rendu Eevee 512 px.

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 :

Du statut de la conversation au geste de Biddew
  1. Ouverture Elle salue wave une fois, puis retour au repos.
  2. Écoute Tête penchée listening, en boucle.
  3. Réflexion Main au menton thinking pendant la connexion, la reconnexion et le calcul.
  4. Parole Elle parle talking, mâchoire pilotée par l’amplitude de la voix.
  5. Proposition Elle montre point quand une action vous est proposée.
  6. Résultat Youpi ou oups success quand l’action aboutit, oops sur une erreur, agree sur un « oui » vocal.

Quand la 3D n’est pas raisonnable, elle n’est pas tentée. La décision tient dans une fonction :

js
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);
}
resources/js/common/biddew/live/stage3d.js, simplifié : pas de WebGL2, appareil modeste ou économie de données, et c’est la boucle 2D.

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 section Biddew de l’accueil du site Wurus : Biddew en pied à gauche, le titre « Elle connaît vos chiffres. Demandez-lui. » et trois questions à essayer.
Capture d’écran WURUS.

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 scène de Biddew ouverte depuis le hub : Biddew en pied, les outils de caméra, le champ pour lui parler et la grille des gestes.
Capture d’écran WURUS.

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.

Séquence d’ouverture de la scène : le portrait 3D du hub se démonte, la scène monte et Biddew salue, puis à la fermeture la scène est libérée et le portrait remonte sa 3D.
Schéma WURUS.

Quelques choix de détail, qui font la différence à l’usage :

La scène avec le décor Nuit : Biddew en pied sur un fond sombre à contre-jour doré, et les quatre décors proposés, Studio doré, Ciel, Nuit et Transparent.
Capture d’écran WURUS.

Les budgets, et ce qu’on a mesuré

Budgets fixés le 3 octobre, mesures du 9 et du 10
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

#Notes techniques #Biddew #3D #three.js #Performance
Équipe WURUS Équipe technique
À lire ensuite