vr-app/docs/04-phase3-plan.md
Thomas Fransolet 0310d28b5e Canal VR Meta Quest : projet Unity, viewer web et documentation
Premier commit du cinquième front. Trois parties :

- `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205),
  un APK unique pour tous les clients. Menu flottant à sélection au regard,
  appairage, chargement de scène GLB, POI, cache de contenu, télémétrie.
- `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec
  les pièges rencontrés consignés dans son README.
- `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript,
  three.js), partagé avec les autres fronts.
- `docs/` : état des lieux, setup Unity, décisions d'architecture et plan
  d'exécution en 9 étapes.

La scène est décrite par un `scene.json` poussé par `adb push` : l'app le
préfère à celui embarqué dans l'APK.

Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par
Git LFS dès ce premier commit — les y faire entrer après coup demanderait de
réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake
(`Library/`, `.utmp/`, Burst debug) sont ignorés.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:26:07 +02:00

19 KiB

Piste Scène 3D — Plan d'implémentation séquencé (S0 → S8)

⚠️ Numérotation, lis ceci d'abord. Les étapes de ce plan s'appellent S0 à S8 depuis le 2026-09-11. Elles s'appelaient E0 à E8, ce qui les confondait avec les items E0 à E11 du lot XR-4 de ../../DOCS/v2/vr-quest-unity-plan.md, qui sont autre chose.

Le plan unifié gouverne. Ce document est le détail d'ingénierie d'une piste, pas le plan du canal : la scène 3D composable (SectionScene3D, manifeste, éditeur web), qui alimente XR-4/E7 (viewer GLB + POI). Le périmètre du POC, lui, est XR-4 : E0 → E2 → E3 → E5 → E6 — menu immersif et vidéo 360 — et il passe avant.

En cas de contradiction entre les deux documents, le plus récemment mis à jour tranche.

Fait à ce jour (2026-09-12) : S0 , S1 le cercle de navigation est là aussi (NavigationBounds.cs, 3 m, repousse sans téléporter), S2 l'audio d'un hotspot est là (HotspotInstance.cs, spatialisé, ⚠️ lu depuis l'URL et non depuis le cache : muet hors ligne), S3 écrit mais jamais essayé.

⚠️ S4 à S8 ont été absorbés par la piste E le 2026-09-12 et n'existent plus comme étapes propres : SectionScene3D + son contrôleur (S4), l'écran manager-app (S5), réseau/cache/sync (S6), menu/langues/kiosque/télémétrie (S7) et la flotte (S8) sont écrits. Un écart assumé : il n'y a pas de route GET /api/Scene3D/{id}/manifest — le manifeste est construit côté client, dans Scene3DView comme dans le viewer web, à partir de l'export. Le contrat de S2 reste le contrat ; seul son lieu de fabrication a changé.

Détail dans ../README.md.

Écrit le 2026-09-03, à partir de 00 (état des lieux), 02 (décisions D1-D6) et 03 (conception).

Format imposé, tenu à chaque étape : ce que je génère · ce que tu fais à la main · le critère de validation. Un critère de validation est une chose qu'on voit, pas une case qu'on coche.


0. La règle de séquencement

Trois principes ont dicté l'ordre, et aucun n'est esthétique :

  1. Le risque technique se lève avant le confort. Ce qui peut échouer bêtement (conversion d'axes, poids d'un monde, perf sur casque) passe en premier, sur un cas minimal.
  2. Aucun backend tant qu'il n'est pas nécessaire. Grâce à la décision D5 (mondes uploadés, pas générés), les trois premières étapes n'écrivent pas une ligne dans manager-service. C'est ce qui permet de démarrer aujourd'hui sans toucher au backlog V1/Postgres.
  3. Le manifeste avant les deux clients. Il est le contrat ; l'écrire après l'un des deux, c'est le tailler pour lui.

Vue d'ensemble

S0 Setup Unity ──► S1 Chaîne bout en bout ──► S2 Manifeste local
   (0,5-1 j)          (3-5 j) ⚠️ OBLIGATOIRE      (2-3 j)
                                                    │
                            ┌───────────────────────┴──────────────┐
                            ▼                                      ▼
                     S3 Viewer + éditeur web              S4 Backend SectionScene3D
                          (8-12 j)                              (4-6 j)
                            │                                      │
                            └──────────────┬───────────────────────┘
                                           ▼
                                S5 Écran manager-app  (5-8 j)
                                           │
                                           ▼
                                S6 Unity : réseau, cache, sync  (5-8 j)
                                           │
                                           ▼
                                S7 Menu, langues, kiosk, télémétrie  (4-6 j)
                                           │
                                           ▼
                                S8 Flotte de casques  (6-8 j)

Total V1 : 8 à 10 semaines d'un dev solo. Cohérent avec l'estimation d'août (POC 3-4 sem., couverture 6-10 sem.) — la conception a déplacé de l'effort de l'app vers le viewer, qui sert maintenant quatre fronts.

Les deux jalons qui comptent

Jalon Après Ce qu'il prouve
🔬 Preuve technique S1 La chaîne marche. Tant que tu ne l'as pas vue de tes yeux, tout le reste est théorique
💼 Démo vendable S3 On compose dans un navigateur, on voit dans le casque. C'est ce qu'on montre à un client pilote — avant d'engager S4 à S8

⚠️ Le go/no-go se place ici, après S3, pas au début. À ce stade on a dépensé ~4 semaines et on a quelque chose à montrer ; S4-S8 (~5 semaines) ne s'engagent qu'avec un pilote identifié.


S0 — Setup Unity

0,5 à 1 jour. Détail complet : 01-phase0-setup-unity.md.

Je génère Packages/manifest.json, .gitignore (fait)
Tu fais Unity Hub + Unity 6 LTS avec Android Build Support (OpenJDK et SDK & NDK Tools cochés), création du projet URP, Meta XR SDK depuis l'Asset Store, réglages Player/XR (guide §5-6), mode développeur casque
Validation Un cube devant toi, en relief, qui reste immobile quand tu bouges la tête. S'il suit ton regard, le rig n'est pas en place

⚠️ Le premier Build And Run prend 10-30 min (IL2CPP). Les suivants, 1-3 min. Ne pas conclure à un plantage.


S1 — La chaîne technique de bout en bout ⚠️ OBLIGATOIRE

3 à 5 jours. Aucun backend. Aucune ligne dans manager-service.

C'est l'étape que le cahier des charges impose avant tout le reste, et elle le mérite : elle lève les trois risques qui coûteraient le plus cher découverts tard.

Ce que tu fais à la main

  1. Générer un monde sur Marble, à partir d'une vraie capture (une vidéo d'une pièce te suffit). Draft d'abord (~20 s, 150 crédits) pour valider le cadrage, puis monde complet (~5 min, 1 500 crédits), puis export mesh GLB HQ (jusqu'à 1 h, 3 500 crédits, facturé une seule fois).
  2. Déposer le .glb dans Assets/StreamingAssets/world.glb.
  3. Trouver un personnage stylisé GLB avec une animation idle (décision D6 : bibliothèque externe sous licence — Mixamo pour l'animation, un pack de modèles stylisés pour le corps). Note la licence, elle devra être tracée.
  4. Build And Run.

Ce que je génère

Script Rôle
GltfSpace.cs La conversion glTF → Unity. Le seul endroit du projet où elle existe
WorldLoader.cs Chargement runtime par glTFast depuis StreamingAssets — pas un glisser-déposer d'éditeur
CalibrationCheck.cs Place un marqueur par coordonnées à côté du L de calibration
NavigationBounds.cs Cercle de 3 m, rendu type garde-fou
PersonaInstance.cs Instancie le personnage, joue l'idle, l'oriente vers le visiteur
Un GLB de calibration L asymétrique : branche longue vers +X, courte vers +Z, marque colorée sur une seule face

Critères de validation — quatre, tous visuels

  1. Le monde s'affiche dans le casque et tu peux tourner la tête dedans.
  2. Le marqueur placé par coordonnées se superpose exactement au repère du GLB de calibration. S'il est du mauvais côté, la conversion est en miroir — et c'est le bug qu'on paie le plus cher plus tard, parce qu'il est invisible sur une scène symétrique.
  3. Le personnage est debout, à la bonne taille (~1,7 m contre le décor), animé, et il te regarde.
  4. Le cercle de navigation est visible et tu ne peux pas en sortir.

Ce que cette étape mesure, et qu'aucune doc ne peut donner

  • Le poids réel d'un monde Marble exporté en mesh. C'est le chiffre qui décide de F5 (KTX2 dans le conteneur, ou GLB servi tel quel) — et on ne peut pas le décider avant de l'avoir.
  • Les FPS sur casque avec ce monde. Le budget de scène en découle, pas l'inverse.
  • Le temps de chargement d'un GLB de cette taille, donc si l'écran de progression est un détail ou un sujet.

➡️ Rapporte-moi ces trois nombres. Ils réécrivent une partie du §7 de la conception.


S2 — Le manifeste, lu localement

2 à 3 jours. Toujours aucun backend.

La scène cesse d'être codée en dur : elle est construite à partir d'un JSON. C'est ici que la règle du §1 de la conception (« un rebuild seulement pour une fonctionnalité ») devient vraie.

Je génère SceneManifest.cs (miroir exact du schéma §2.2), SceneBuilder.cs, HotspotInstance.cs, et un scene.json d'exemple complet
Tu fais Poser le scene.json sur le casque (adb push, ou MQDH), déplacer des valeurs à la main, relancer l'app
Validation Tu changes une position dans le JSON, tu relances l'app — sans recompiler — et l'objet a bougé. Tu ajoutes un hotspot, il apparaît et joue son audio au raycast

⚠️ Le refus explicite est un livrable, pas un oubli : un world.kind autre que "mesh" doit afficher un message lisible, pas une scène vide.


S3 — Le viewer web et son éditeur

8 à 12 jours. L'étape la plus lourde, et la plus rentable : ce viewer sert quatre fronts (décision D3).

Je génère Le projet vr-app/viewer/ complet : manifest.ts (mêmes types qu'en C#), Viewer.ts (three.js, chargement glTF, tone mapping et HDRI alignés sur Unity), Editor.ts (sélection, raycast au sol, poignée de rotation, undo), Bounds.ts, Panel.ts (valeurs numériques + budget), le protocole postMessage
Tu fais npm install && npm run dev, charger le scene.json de S2, placer des objets à la souris
Validation Le test croisé, et c'est le vrai but de l'étape : tu places trois objets dans le viewer, tu exportes le JSON, tu le pousses sur le casque — les trois objets sont exactement là où tu les as mis. C'est la seule preuve valable de la convention d'axes (§2.1 de la conception)

Les trois règles à tenir dès maintenant

Elles gardent la porte ouverte au portage Flutter mobile sans l'imposer (D3) : fichiers statiques servables depuis une origine locale · aucune URL d'API en dur (tout vient de assets[].url) · le manifeste s'injecte, pas seulement par URL.

Ce qu'on sous-estime toujours

L'éditeur, ce n'est pas le viewer plus un clic. C'est un état d'édition, un undo, un enregistrement explicite, et la gestion du « tu as des modifications non enregistrées ». Compte la moitié de l'étape là-dessus.

💼 C'est ici que se place la démo client. Composer dans un navigateur, voir dans le casque : à ce stade on a de quoi aller chercher un pilote, avant d'engager S4 à S8.


S4 — Backend : le type de section et le manifeste servi

4 à 6 jours. Première ligne écrite dans manager-service.

Je génère SectionScene3D.cs, Scene3DDTO, TransformDTO, Scene3DObjectDTO, Scene3DPersonaDTO · les 4 emplacements de SectionFactory · les switch de SectionController et IngestionService · la ligne de discriminant TPH · GeoPoint += SectionScene3DId, LocalTransform · les enums (SectionType.Scene3D, ResourceType.Model3D + Panorama360, ApiKeyAppType.VrApp) · Scene3DController (manifest, publish, budget) · le job Hangfire de calcul des sha256 · les tests
Tu fais Relire la migration avant de l'appliquer, lancer dotnet ef migrations add, dotnet test, corriger l'upload .glb de resources_screen.dart (le switch sans default, F7)
Validation Un curl sur GET /api/Scene3D/{id}/manifest avec une clé d'API rend un manifeste que le viewer de S3 affiche sans modification, et que l'app de S2 charge sans modification

Trois points de vigilance, tous vérifiés dans le code

  1. GetReferencedResourceIds est abstract, pas virtual — le compilateur réclamera l'implémentation, et c'est voulu (Section.cs). Un oubli ici n'est pas un bug d'affichage : c'est un asset manquant sur un casque hors ligne, sur site.
  2. Toutes les valeurs d'enum s'ajoutent EN FIN. Elles sont persistées en int, le commentaire de ResourceType le dit en majuscules. Insérer au milieu transformerait les PDF en JSON.
  3. La migration est purement additive (colonnes nullables sur la table TPH Sections). Elle ne concurrence pas le gel de schéma du lot B, mais elle se pose en le sachant — à annoncer dans STATUS.md, pas à glisser.

S5 — L'écran manager-app

5 à 8 jours. C'est l'étape qui rend le module utilisable par un conservateur.

Je génère L'écran d'édition de SectionScene3D (sélection du monde et des assets via le sélecteur de ressource existant, jauge de budget, liste des objets/personas/hotspots), l'intégration du viewer en HtmlElementView + postMessage, le bouton Publier, les DTO à ajouter à la main dans manager_api_new, l'i18n FR/EN/NL/DE
Tu fais flutter build web (pas flutter analyze — voir la leçon de STATUS.md §1bis), et composer une vraie scène de bout en bout
Validation Tu crées une scène, tu poses un monde, deux objets, un persona et trois hotspots avec audio, tu publies — et le casque affiche la scène après un retour au menu. Sans qu'aucun développeur n'intervienne

⚠️ manager_api_new ne se régénère jamais : les DTO s'ajoutent à la main. C'est une règle du repo, pas une préférence.

⚠️ La jauge de budget doit afficher le chiffre qui bloque. Le bug ouvert de la jauge de stockage (STATUS.md §1 : « 0 KB » affiché en même temps qu'un refus 413) est exactement ce qu'il ne faut pas refaire.


S6 — Unity : appairage, cache, synchronisation

5 à 8 jours. L'app cesse d'être une démo et devient une borne.

Je génère PinCodeFlow.cs (saisie du code, GET /api/instance/app-key?appType=VrApp, POST /api/device avec appType = VR), CredentialStore.cs, ManifestClient.cs, AssetCache.cs (disque, indexé par sha256, éviction, fallback sur la dernière version valide), AssetDownloader.cs (file, reprise, progression)
Tu fais Appairer un vrai casque avec un vrai pincode, puis débrancher le wifi et relancer
Validation Trois choses, dans cet ordre : (1) l'app démarre sur le cache, sans réseau ; (2) après une publication, seuls les assets dont le sha256 a changé sont retéléchargés ; (3) un manifeste illisible ou un serveur injoignable laisse la dernière version valide active, sans message d'erreur au visiteur

⚠️ Le point (3) n'est pas de la robustesse défensive, c'est la promesse produit : une borne ne tombe pas en panne parce que le wifi du musée a bougé. À tester en débranchant, pas en relisant.

⚠️ La sync s'applique au retour au menu, jamais à chaud. Un monde qui se recharge pendant qu'un visiteur est immergé, c'est au mieux une coupure, au pire une nausée.


S7 — Menu flottant, langues, kiosk, télémétrie

4 à 6 jours.

Je génère FloatingMenu.cs (sélection de scène, langue, relance audio, sortie), KioskSession.cs (détection de retrait du casque → reset, décision D4), VisitEventReporter.cs (appType = VR)
Tu fais Faire essayer à quelqu'un qui n'a jamais tenu une manette Quest. Sans l'aider
Validation Deux critères, et le premier est le vrai : (1) cette personne lance une scène et change de langue sans qu'on lui explique ; (2) le casque reposé puis repris repart à l'état initial — langue par défaut, audio arrêté, position de départ

Le reset doit être total, pas un retour de caméra. Un état résiduel entre deux visiteurs (audio en cours, langue changée, hotspot ouvert) est le défaut le plus visible en exploitation réelle — et le plus facile à laisser passer en test solo.

La télémétrie ne demande aucun travail serveur : VisitEvent porte déjà AppType, et le filtre de l'écran de statistiques est générique sur AppType.values avec l'i18n statsChannelVR déjà présente. Le canal VR apparaîtra tout seul au premier event.


S8 — La flotte de casques

6 à 8 jours. Ce sont les lots V-1 et V-2 du plan d'août, inchangés.

Je génère Device.AppType + migration, DeviceController.Create résolvant l'ApplicationInstance sur newDevice.appType (au lieu du AppType.Tablet en dur, DeviceController.cs:155), GetAll avec un paramètre appType optionnel · lib/Screens/Vr_devices/vr_screen.dart cloné de Kiosk_devices/ (4 fichiers, 769 l.) · le branchement de main_screen.dart:704 à la place du Text("TODO vr") · batterie / AppVersion / LastSeen sur la carte casque
Tu fais Vérifier qu'un casque enregistré n'apparaît pas dans l'onglet Kiosk
Validation Deux casques appairés, visibles dans l'onglet VR avec leur batterie et leur version, chacun avec une configuration différente assignée

Device.AppType a pour défaut Tablet (1) : les lignes existantes ne bougent pas. ApiKeyAppType.VrApp s'ajoute en fin d'enum.


Ce qui n'est pas dans ce plan

Hors V1 Quand
Génération de mondes Marble depuis le manager V1.5 — décision D5. Dépend des lots 0/1/3 du Studio (§5 de la conception)
Entité Persona et personnages 3D générés Lot 7 puis 11 du Studio. La couture personaId est déjà dans le manifeste
TTS pré-généré Plan dédié. En V1, audio .mp3 uploadé — même champ, aucune reprise
Splats gaussiens world.kind = "splat", refusé explicitement par le client V1
Assistant IA dans le casque Zéro backend (AiController prend déjà un AppType), mais le TTS est à réimplémenter en C#
Viewer 3D dans visitapp-web et mymuseum-visitapp Le viewer est conçu pour, les trois règles sont posées. Le portage est un chantier à part
KTX2 / Meshopt exécutés côté serveur À rouvrir avec les chiffres de S1, pas avant
MQTT dans Unity, MDM Lot V-5. Inutile sous une dizaine de casques

Les trois choses qui peuvent faire dérailler ce plan

  1. Le poids d'un monde Marble. Si un monde pèse 500 Mo, le budget de scène et le pipeline KTX2 remontent en priorité, et S1 doit le dire. C'est le seul inconnu vraiment structurant.
  2. L'éditeur de S3. C'est l'étape la plus longue et la seule dont l'estimation repose sur peu de précédent dans le repo — il n'y a aucune 3D nulle part aujourd'hui.
  3. Le go/no-go pilote. Il est placé après S3 pour une raison : au-delà, on engage ~5 semaines sur un canal dont le risque est produit, pas technique.