vr-app/docs/02-decisions.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

11 KiB

Décisions d'architecture du module VR

Arbitrées le 2026-09-03, à partir du relevé de code de 00-phase1-etat-des-lieux.md. Ne pas rouvrir sans raison technique forte. Les décisions antérieures (Unity vs WebXR, pricing add-on, MDM, types de section) restent dans ../../DOCS/v2/vr-quest-unity-plan.md §6 à §10.


D1 — Le contenu VR est un type de section, pas une entité parallèle

Retenu : SectionScene3D, dans une Configuration existante.

Le manifeste de scène est une projection de la section, générée par le serveur, jamais saisie à la main. Conséquences, toutes acquises sans code neuf :

On hérite de Parce que
La publication c'est une section, elle part dans /configuration/{id}/export
L'offline GetReferencedResourceIds(language) est implémenté par sous-type — on l'implémente une fois
Les stats par canal VisitEvent porte déjà AppType
Le multilingue List<TranslationDTO> jsonb, comme le reste
Le quota stockage SUM(SizeBytes) sur les Resource référencées
L'affichage web / mobile une section est rendue par les 3 clients visiteurs — c'est ce qui rend l'add-on « contenu immersif » vendable sans casque

Corollaire : GeoPoint est l'objet hotspot unique. Il porte déjà Title, Description, ImageResource et Contents (titre + description + Resource audio, multilingue). Il lui manque une position locale (x, y, z) + rotation à côté de son Geometry PostGIS. Pas de nouvel objet POI.

Écarté : une entité VrScene autonome — elle aurait obligé à réécrire publication, versionnement, cache offline, quotas et stats.

Écarté aussi, pour la V1 : rendre les 13 types de section en immersif. Le §8 du plan du 31/08 garde sa validité comme cap (Menu / Video 360 / Slider / Map gagnent vraiment en VR), mais ce n'est pas le périmètre de la V1 : la V1 rend une scène, pas un CMS flottant.


D2 — Le placement se fait dans le viewer, au clic

Retenu : le viewer THREE.js est aussi l'éditeur de placement.

Clic dans la scène → raycast sur le sol → position. Poignée de rotation sur l'objet sélectionné, panneau numérique à côté pour ajuster au centimètre. Pas d'éditeur 3D complet, pas de gizmo de translation sur 3 axes.

Coût marginal quasi nul : le viewer doit exister de toute façon pour la preview (§3.6 du cahier des charges). C'est le seul mécanisme où le client voit ce qu'il compose.

⚠️ Ce que ça engage : le viewer cesse d'être en lecture seule. Il lui faut un état d'édition, un undo, et un enregistrement explicite. À ne pas sous-estimer — c'est la moitié de son coût.

Écartés : la mini-carte 2D vue de dessus (il aurait fallu produire un rendu orthographique du monde, et le client place à l'aveugle sur la hauteur) ; les ancrages prédéfinis (composition pauvre, sensation de gabarit — mauvais en démo commerciale).


D3 — Un seul viewer, app web autonome, partagé par les quatre fronts

Retenu : le viewer est une application web autonome nourrie par une URL de manifeste. Précisé le 2026-09-03 : il ne sert pas deux fronts mais quatre.

Front Comment il l'héberge Mode
manager-app (Flutter Web) iframe (HtmlElementView) + postMessage édition
visitapp-web (Next.js) composant, ou iframe si plus simple lecture
mymuseum-visitapp (Flutter mobile) WebView servant le viewer empaqueté dans l'app lecture, hors ligne
vr-app (Unity/Quest) pas concerné — Unity a son propre rendu natif

Un seul moteur de rendu 3D pour tout l'écosystème. C'est ce qui attaque de front le risque n°2 du plan d'août (« nouvelle stack, zéro mutualisation ») : le rendu 3D n'est pas un quatrième front, c'est un composant partagé.

Sur Flutter mobile, il n'y a pas d'alternative sérieuse : model_viewer_plus, la solution habituelle, est elle-même un WebView autour de <model-viewer> de Google. Autant que ce soit notre viewer dans ce WebView, avec nos hotspots et notre manifeste, qu'un composant tiers qu'on ne contrôle pas.

L'offline mobile : souhaitable, pas bloquant — arbitré le 2026-09-03

mymuseum-visitapp est offline-first, et un WebView pointant sur une URL distante casserait ça. Thomas a tranché : si la 3D hors ligne s'avère trop coûteuse, on l'assume et on le dit au client dans manager-app. C'est acceptable, et surtout c'est déjà la convention de la maison :

  • SectionVideo.GetReferencedResourceIds (SectionVideo.cs:25-27) exclut explicitement une vidéo dont la source est une URL http — elle ne part pas hors ligne ;
  • SectionWeb ne collecte que sa vignette.

Une scène 3D non disponible hors ligne serait donc le troisième cas d'une règle existante, pas une exception. Rien à inventer, juste à signaler dans l'éditeur.

Ceci dit, c'est probablement faisable, et le coût n'est pas dans le rendu mais dans la plomberie : viewer empaqueté dans les assets Flutter, servi depuis une origine locale, GLB téléchargés dans le dossier documents, URL du manifeste réécrites en chemins locaux. Le pipeline offline fait déjà tout ça pour les médias 2D. À évaluer au moment du portage mobile, pas maintenant.

⚠️ Les trois règles à poser dès la V1 — indépendamment de l'offline

Elles ne coûtent rien maintenant, elles coûtent une réécriture plus tard, et elles sont de toute façon du bon design : un viewer qui ne sait pas d'où viennent ses données est un viewer réutilisable.

  1. Le viewer est un jeu de fichiers statiques, embarquable dans les assets d'une app Flutter et servable depuis une origine locale. Pas de build qui suppose un serveur.
  2. Aucune URL d'API en dur dans le viewer. Il ne connaît que le manifeste qu'on lui donne — toutes les URL d'assets viennent de assets[].url. C'est ce qui permet de lui passer un manifeste dont les URL ont été réécrites en chemins locaux par l'app, exactement comme le fait déjà le pipeline offline pour les médias.
  3. Le manifeste est une entrée, pas un fetch. Le viewer accepte un manifeste injecté (postMessage / paramètre), pas seulement une URL à charger.

Ce que ça débloque au passage

Le 360 et le GLB s'affichent alors en web et en mobile, pas seulement au casque — c'est exactement la part de l'add-on « contenu immersif » qui se vend sans casque, et sur laquelle repose son pricing (vr-quest-unity-plan.md §6).

⚠️ Rappel de contrat : le viewer est un aperçu indicatif — placement, échelle, composition. Pas un rendu final. Alignement à tenir : même tone mapping, même HDRI d'environnement, PBR core glTF uniquement. La validation réelle passe par une publication sur canal de test et un essai au casque.


D4 — La borne se réinitialise à la repose du casque

Retenu : détection du retrait (capteur de proximité) → fondu au noir, reset de la scène, retour au menu de sélection dans la langue par défaut.

Précédent direct dans l'écosystème : le retour à l'accueil de la borne tablette après 5 min (DOCS/kanban/done/140-*). Un agent n'a rien à faire, ce qui est cohérent avec l'argument qui avait fait retenir Unity — l'exploitation sans surveillance.

Le timeout d'inactivité reste utile en filet secondaire (casque posé sans être retiré proprement), pas comme mécanisme principal.


D5 — La V1 consomme des mondes uploadés ; la génération vient juste après

Retenu : upload manuel d'abord, génération intégrée ensuite.

En V1, le monde est un GLB uploadé dans la médiathèque — produit hors ligne, sur le site de World Labs, par nous, pour les clients pilotes. Le module VR devient alors indépendant du socle Studio et livrable seul, sans attendre les lots 0 / 1 / 3.

La génération in-app (le bouton « Générer un monde » du §3.1 du cahier des charges) arrive quand ces lots existent, sans rien casser : au bout du pipeline, c'est la même Resource GLB référencée par la même section. La seule chose à faire dès maintenant, c'est de ne rien coder qui empêche cette greffe — donc :

  • le monde est toujours un ResourceId, jamais un chemin ou une URL en dur ;
  • Resource.AiProvenance est prévu dans le manifeste dès la V1, même vide ;
  • Marble n'est jamais appelé depuis le code VR : le jour venu, ce sera un IGenerationProvider de plus (ProviderKey = "worldlabs", GenerationKind.World), pas un client HTTP maison.

Écarté : la génération dès la V1 (elle mettait plusieurs semaines de backend V2 devant la première scène visible) et le compromis « lot 0 seul + appel Marble en dur » (il recrée exactement la dette que studio-plan.md cherche à éviter : modèle codé en dur, pas de journal, pas de réservation de crédits).


D6 — Personnages 3D : catalogue externe sous licence, mais branché sur la médiathèque dès le jour 1

Retenu : une petite bibliothèque de personnages stylisés sous licence (modèles type Quaternius / Kenney ou un pack payant, animations idle type Mixamo), livrée comme catalogue de départ. Le client choisit, il ne modélise pas.

⚠️ Condition posée par Thomas, et elle est structurante : « il faut quand même prévoir vite d'intégrer ce qu'on génère dans le Studio ». Donc le catalogue n'est pas une liste en dur dans l'APK. Dès la V1 :

  • un personnage 3D est un Resource de type Model3D dans la médiathèque, comme n'importe quel autre asset — le catalogue de départ est juste un jeu de ressources pré-chargées, marquées comme telles ;
  • Persona.Model3DResourceId pointe dessus. Le jour où le Studio produit un GLB, il remplace la valeur du champ, il ne demande aucune refonte ;
  • la licence de chaque modèle du catalogue de départ est tracée dans AiProvenance.rightsHolder (ou son équivalent pour un asset non généré) — sujet droits, pas seulement technique, pour des institutions publiques.

Écartés : le studio 3D sur devis en V1 (aucun pilote ne pourrait composer une scène sans payer d'abord une prestation) et l'absence de personnage en V1 (c'est l'élément le plus démonstratif).


Ce qui reste ouvert

Sujet Où c'est décrit Ce qui manque pour trancher
KTX2 / Meshopt : où s'exécute la transformation ? état des lieux F5 Binaires natifs dans l'image Docker vs GLB servi tel quel en V1. Défaut retenu faute d'objection : tel quel en V1, avec le bloc variants par asset posé dans le manifeste dès le départ pour que l'ajout n'y touche pas. À rouvrir dès qu'on aura un vrai poids de monde Marble mesuré
Go/no-go client pilote vr-quest-unity-plan.md §5 Décision produit, inchangée : ne pas engager l'app Unity complète sans un lieu pilote identifié. D5 réduit l'enjeu — la chaîne technique se prouve sans client