vr-app/unity-overlay
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
..

unity-overlay — les fichiers à déposer sur le projet Unity

Ce dossier n'est pas un projet Unity. C'est l'arborescence des fichiers que je génère, calquée sur celle du projet, à recopier par-dessus une fois le projet créé par Unity Hub.

Pourquoi un dossier séparé : Unity Hub refuse de créer un projet dans un dossier non vide. Il faut donc que le projet naisse d'abord, et que mes fichiers arrivent ensuite. Ça a un avantage secondaire — à chaque étape je remets à jour cet overlay, tu recopies, rien ne se perd.

Ordre des opérations

  1. Créer le projet — Unity Hub → New project → Universal 3D (URP) → nom MyInfoMateVR, emplacement vr-app/unity/. Détail : ../docs/01-phase0-setup-unity.md.

  2. Importer le Meta XR SDK (Asset Store → Meta XR All-in-One SDK) et lancer Edit → Project Settings → Meta XRFix All.

  3. Recopier cet overlay :

    Copy-Item -Recurse -Force vr-app\unity-overlay\* vr-app\unity\MyInfoMateVR\
    

    ⚠️ Packages/manifest.json écrase celui du projet. C'est voulu — il porte glTFast, KTX2, Draco et OpenXR. Si tu as déjà importé le SDK Meta, ses lignes com.meta.xr.* sont dans le fichier du projet et seront perdues : dans ce cas, fusionne les deux blocs dependencies à la main plutôt que d'écraser. Unity recompile tout seul au retour dans l'éditeur.

  4. Déposer les deux GLB dans Assets/StreamingAssets/ :

    • world.glb — ton monde Marble exporté en mesh
    • persona.glb — un personnage stylisé avec une animation idle

    calibration.glb y est déjà, il vient de cet overlay.

  5. MyInfoMate → Construire la scène Boot (menu ajouté par BuildBootScene.cs).

  6. Meta → Tools → Building Blocks → Camera Rig, à glisser dans la scène. C'est la seule chose que je ne peux pas générer : référencer le rig depuis un script d'éditeur créerait une dépendance de compilation sur un package importé à la main.

  7. File → Build Profiles → Build And Run.

Regénérer le repère de calibration

python3 vr-app/tools/make_calibration_glb.py vr-app/unity-overlay/Assets/StreamingAssets/calibration.glb

⚠️ Deux numérotations, et elles ne se mélangent pas

Préfixe Plan Ce que ça couvre
S0-S8 ../docs/04-phase3-plan.md La piste Scène 3D : monde GLB, manifeste, viewer web
E0-E11 ../../DOCS/v2/vr-quest-unity-plan.md §XR-4 L'app Unity du canal VR : appairage, export, menu, 360°

Les scripts de bootstrap parlaient E1/E2 en voulant dire S1/S2renommés le 12/09, alors que la doc l'était depuis le 11/09. Si tu croises un E1Bootstrap quelque part, il est périmé.

Ce que contient l'overlay aujourd'hui (S1-S2 · E2-E5, E8-E10)

Fichier Rôle
Packages/manifest.json glTFast + KTX2 + Draco + OpenXR
Assets/Scripts/Scene/GltfSpace.cs La conversion glTF → Unity. Le seul endroit
Assets/Scripts/Scene/GltfLoader.cs Chargement GLB au runtime, URI StreamingAssets Android comprise
Assets/Scripts/Scene/CalibrationCheck.cs Le test de la conversion — critère de validation n°2 de S1
Assets/Scripts/Scene/NavigationBounds.cs Zone circulaire, plafond 3 m, repousse sans téléporter
Assets/Scripts/Scene/PersonaInstance.cs GLB + idle en boucle + regard vers le visiteur
Assets/Scripts/Boot/S1Bootstrap.cs Orchestration de S1 — provisoire, remplacé par SceneBuilder en S2
Assets/Scripts/Boot/S2Bootstrap.cs S2 — la scène vient de scene.json
Assets/Scripts/Net/ApiClient.cs E2-E3 — le seul endroit qui parle à manager-service (X-Api-Key, erreurs en phrases)
Assets/Scripts/Net/PairingService.cs E2 — code PIN → clé d'API → POST /api/device avec appType = 3
Assets/Scripts/Net/ConfigurationExport.cs E3GET /api/configuration/{id}/export, un appel pour tout le contenu
Assets/Scripts/Net/ContentCache.cs E4 — cache disque, écriture atomique, médias nommés par hachage d'URL
Assets/Scripts/Net/ContentService.cs E4 — cache d'abord, rafraîchissement de fond silencieux
Assets/Scripts/Net/Telemetry.cs E9POST /api/stats/event en « tire et oublie », appType = "VR"
Assets/Scripts/Boot/PairingBootstrap.cs E2-E4 — appaire, sert le cache, rafraîchit, affiche les sections dans le casque
Assets/Scripts/Ui/Label.cs Tout le texte du casque. TextMeshPro, avec repli sur TextMesh si les ressources TMP manquent
Assets/Scripts/Menu/MenuFeedback.cs Son de survol et de sélection (synthétisés, aucun fichier) + vibration de la manette
Assets/Scripts/Menu/AimSelector.cs E5 — viser à la manette, à la main ou à la tête ; temporisation 1,2 s à la tête seulement
Assets/Scripts/Menu/MenuItemPanel.cs E5 — un panneau : vignette, titre, anneau de progression
Assets/Scripts/Menu/FloatingMenu.cs E5 — l'arc de panneaux, posé une fois, qui ne suit pas la tête
Assets/Scripts/Menu/PagedView.cs E8 — la vue paginée commune : image de 1,6 m, texte, flèches, retour
Assets/Scripts/Menu/SectionPages.cs E8 — ce qui distingue Slider, Map/Parcours et Event
Assets/Scripts/Menu/SkyboxView.cs E6 — image et vidéo 360° en skybox équirectangulaire
Assets/Scripts/Kiosk/KioskSession.cs E10 — fin de visite à la repose du casque ou après 90 s, menu recentré
Assets/Scripts/Boot/MenuBootstrap.cs E5 — l'app : appairage, cache, menu, sections, borne, télémétrie
Assets/Scripts/Editor/BuildBootScene.cs Construit Boot.unity par code — quatre variantes (S1, S2, E2-E3, E5)
Assets/StreamingAssets/calibration.glb Repère RGB asymétrique (X 1,00 m · Y 0,60 m · Z 0,30 m)

Essayer le menu (E5) — c'est l'app

⚠️ Une fois pour le projet : Window → TextMeshPro → Import TMP Essential Resources. Tout le texte du casque passe par TMP depuis le 14/09 (Ui/Label.cs). Sans ces ressources, TMP_Settings n'a pas de police par défaut : Label le dit dans la console et retombe sur TextMesh, donc le texte reste lisible — mais flou, ce qu'on voulait justement corriger.

  1. MyInfoMate → Construire la scène Boot (E5 — menu flottant).
  2. Renseigner baseUrl, le code PIN, headsetName, la langue sur le Bootstrap.
  3. Build And Run. Un arc de panneaux apparaît à 2,2 m. Trois façons d'en choisir un :
Moyen Geste Remarque
Manette Gâchette d'index Prioritaire dès qu'une manette est active
Main Pincement pouce-index Demande le building block Hand Tracking dans la scène
Tête Viser 1,2 s, l'anneau se remplit Le repli universel, et le mode d'une borne publique

⚠️ « À la tête », pas « aux yeux ». Le Quest 2 n'a pas d'eye tracking (seul le Quest Pro en a) : on suit la direction de la tête, ce qui marche sur tous les casques et ne demande aucun SDK.

⚠️ La visée a besoin d'un collider et d'un rig. Si rien ne réagit : vérifier que le Camera Rig des Building Blocks est dans la scène (sans lui Camera.main est nul) et que le module Physics n'a pas été exclu du build. La manette et la main passent par OVRInput / OVRHand, du Core SDK — pas de l'Interaction SDK, qui reste non configuré.

Quatre types de section s'ouvrent : Slider (galerie), Map et Parcours (liste de POI), Event (programme du jour). Les autres affichent leur titre et le disent. ⚠️ La Map n'est pas la maquette 3D promise par le plan — ça demande SectionModel3D ; une carte sur panneau flottant serait moins bonne qu'un téléphone, donc on montre les lieux plutôt que le plan.

La borne se réinitialise seule : casque reposé, ou 90 s sans rien → retour au menu, recentré devant le visiteur suivant, nouvelle session de stats. Dans l'éditeur et le simulateur, le casque est considéré porté — sinon la scène se réinitialiserait toutes les 90 s pendant qu'on travaille.

Essayer l'appairage seul (E2-E3)

La variante réseau existe encore, et elle sert : elle isole un problème d'appairage d'un problème de rendu, ce qu'on ne peut plus faire une fois que tout est dans le menu.

  1. MyInfoMate → Construire la scène Boot (E2-E3 — appairage et export).
  2. Sur le Bootstrap de la scène : renseigner baseUrl, le code PIN de l'instance, la langue. Le PIN se saisit dans l'inspecteur tant que l'Interaction SDK (E1) n'a pas donné de pointeur.
  3. Build And Run. Le casque doit afficher le nom de la configuration et ses sections.

⚠️ Le canal VR doit être activé sur l'instance : isVR et une ApplicationInstance de type VR, sinon le POST /api/device répond 404 et le casque affiche « Le canal VR n'est pas activé pour ce lieu ». Il n'y a pas encore d'écran pour ça — c'est deux appels d'API, décrits au §24.0 de ../../DOCS/test-plan.md.

L'appairage est persistant (PlayerPrefs) : au deuxième lancement le PIN n'est plus lu. Pour réappairer, cocher forgetPairing.

Le seul résultat qui compte

Les trois sphères doivent coiffer le bout de la branche de leur couleur.

Si l'une part du côté opposé, la conversion est en miroir : dans GltfSpace.cs, passer Axis à Convention.NegateZ et relancer. C'est la seule ligne à changer, et c'est pour ça qu'elle est seule.


Pièges rencontrés au premier build (11/09/2026, Quest 2)

Tous constatés sur le vrai matériel, tous coûteux à rechercher. Dans l'ordre où ils tombent.

com.unity.meshopt.decompress n'existe pas au registre. Il figurait dans le manifest de l'overlay et faisait échouer la résolution entière. Retiré : glTFast gère Draco et KTX2, la décompression meshopt n'est pas nécessaire pour nos GLB. Ne pas le remettre.

OpenXR n'est activé que pour Standalone après le Fix All. Les réglages XR sont par plateforme. Tant que la plateforme active est Windows, le Fix All de Meta ne touche pas la colonne Android, et XRGeneralSettingsPerBuildTarget.asset ne contient qu'une entrée. Ordre correct : File → Build Profiles → Android → Switch Platform, puis Project Settings → Meta XR → Fix All, puis vérifier XR Plug-in Management → onglet Android → OpenXR coché

  • groupe de features Meta Quest.

INSTALL_FAILED_MISSING_SHARED_LIBRARY — le build réussit, l'install échoue.

Supplement horizonos-supplement-hzplatformclientcore does not exist.

Le manifeste généré par le SDK Meta déclare horizonos:targetSdkVersion="205". Un casque sur une version d'Horizon OS plus ancienne (v74 dans notre cas) n'a pas le composant système correspondant et refuse le paquet. Deux sorties : mettre le casque à jour (la bonne), ou abaisser targetSdkVersion dans Assets/Plugins/Android/AndroidManifest.xml pour débloquer (contournement — à remettre à 205 une fois le casque à jour). Cette déclaration ne sert qu'au Platform SDK Meta, que nos scripts n'utilisent pas.

Deux appareils branchés = Unity déploie au hasard. Un téléphone Android connecté en USB apparaît dans adb devices au même titre que le casque. Fixer Run Device sur le Quest dans Build Profiles, ou débrancher le téléphone.

Le premier build prend 20 minutes, les suivants 1 à 3. Mesuré : 1245 s pour un APK de 66 Mo. C'est IL2CPP. Ne pas conclure à un plantage.

Package name et Company Name restent ceux du template URP si on ne les change pas (com.UnityTechnologies.com.unity.template.urpblank). À corriger avant le premier sideload : changer l'identifiant plus tard fait apparaître une deuxième app dans le casque.

Tester sans casque — le simulateur

Le Meta XR Simulator (panneau Meta XR Tools) lance la scène en mode Play dans l'éditeur, sans casque. E1Bootstrap tolère l'absence de world.glb et persona.glb — il se contente de logger une erreur — donc le critère de validation n°2 de S1, le test de miroir de GltfSpace, est jouable dès maintenant avec le seul calibration.glb, sans aucun asset Marble. C'est le bug le plus cher du lot : autant le lever tôt.

Quand le simulateur est activé, la barre de statut l'affiche et ▶ ne va plus au casque. La bascule est dans Meta → Tools → Meta XR Simulator.

Shaders glTFast : magenta sur le casque, correct dans l'éditeur

Le piège le plus coûteux du lot, et il ne se voit que sur le device.

glTFast construit ses matériaux au runtime et trouve ses shaders par Shader.Find("Shader Graphs/glTF-pbrMetallicRoughness"). Dans l'éditeur, tous les assets du projet sont disponibles, donc ça marche toujours. Dans un build, Shader.Find ne voit que ce qui a été explicitement embarqué — et un shader vivant dans un package n'est référencé par aucune scène, donc il est éliminé. Tout ce qui vient d'un glTF sort en magenta, et new Material(null) lève ArgumentNullException: Parameter name: shader.

Un ShaderVariantCollection dans Preloaded Shaders ne suffit pas. Vérifié sur le casque : la collection capturée contenait bien Universal Render Pipeline/Unlit, et Shader.Find renvoyait quand même null. Cette liste précharge les variantes de shaders déjà embarqués ; elle n'embarque rien. Seul Always Included Shaders fait entrer un shader dans le build.

Le minimum qui marche, dans Project Settings → Graphics → Always Included Shaders :

Shader Référence YAML
Universal Render Pipeline/Unlit (l'anneau de NavigationBounds, les panneaux du menu) {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3}
glTF-pbrMetallicRoughness {fileID: -6465566751694194690, guid: b9d29dfa1474148e792ac720cbd45122, type: 3}
Skybox/Panoramic — la 360° (E6). Même piège, même conséquence : sans lui le ciel sort magenta sur le casque et correct dans l'éditeur ajouté par MyInfoMate → Embarquer les shaders du runtime

⚠️ Universal Render Pipeline/Lit ne doit PAS être dans cette liste. Il pèse 2 359 296 variantes, plus que ce qu'Unity accepte d'embarquer : le build échoue sur « has too many Shader variants » — et il échoue à la fin, après 35 minutes de compilation. Constaté le 14/09. Aucun Shader.Find du projet ne le demande : tout ce qui est dessiné par code est en Unlit, et les GLB passent par les graphes glTFast. IncludeRuntimeShaders.cs ne l'ajoute plus ; s'il est déjà dans la liste, le retirer à la main — le script ajoute, il ne retire jamais.

⚠️ GUI/Text Shader non plus, et pour une autre raison. Il vit dans Library/unity default resources, marqué HideFlags.DontSave : dans la liste, il fait échouer le build sur « An asset is marked with HideFlags.DontSave but is included in the build », suivi d'une assertion m_LockCount == 0 et de « Failed to write file: Library/PlayerDataCache/Android/Data/Resources/unity_builtin_extra ». Constaté le 14/09, juste après avoir réglé le cas Lit. Le TextMesh de SceneMessage n'en a pas besoin : son matériau vient de la police référencée par le composant, embarquée par le chemin normal.

⚠️ N'ajoute que les shaders réellement utilisés. Un Shader Graph dans cette liste fait compiler toutes ses combinaisons de mots-clés : les trois graphes glTFast d'un coup, c'est 16 384 variantes et ~50 minutes de build. Pour savoir lesquels comptent, joue la scène dans l'éditeur puis lis la capture (Graphics → Shader Loading → Save to asset…) : les shaders qu'elle liste sont exactement ceux dont ton contenu a besoin. Le cache de shaders est conservé, la facture ne se paie qu'une fois.

NavigationBounds : mesurer la tête, déplacer le rig

En VR le rig ne bouge pas quand le visiteur marche — le tracking applique le déplacement à la caméra, à l'intérieur du rig. Une contrainte de zone qui teste rig.position ne se déclenche donc jamais, quelle que soit la distance parcourue : on sort du cercle sans rien sentir. Il faut mesurer Camera.main.transform et retrancher l'excédent au rig.