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

208 lines
8.3 KiB
C#

using System.Collections.Generic;
using System.Threading.Tasks;
using MyInfoMate.Vr.Manifest;
using UnityEngine;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Construit une scène <b>à partir d'un manifeste</b>, et remplace à ce titre
/// <c>S1Bootstrap</c> dont les chemins étaient en dur.
///
/// C'est ici que la règle du §1 de la conception devient vraie : <b>un rebuild
/// seulement pour une fonctionnalité</b>. Changer une position, ajouter un objet,
/// déplacer un hotspot — tout ça se fait dans le JSON, sans recompiler.
///
/// Le builder ne lit ni ne valide le manifeste : <see cref="ManifestReader"/> le
/// fait avant, et un manifeste refusé n'arrive jamais jusqu'ici.
/// </summary>
public class SceneBuilder : MonoBehaviour
{
public Transform Root { get; private set; }
public NavigationBounds Bounds { get; private set; }
readonly List<HotspotInstance> _hotspots = new List<HotspotInstance>();
readonly List<PersonaInstance> _personas = new List<PersonaInstance>();
SceneManifest _manifest;
string _language;
public IReadOnlyList<HotspotInstance> Hotspots => _hotspots;
public async Task<bool> BuildAsync(SceneManifest manifest, Transform visitorHead,
Transform visitorRig, string language)
{
_manifest = manifest;
_language = language ?? manifest.DefaultLanguage;
var watch = System.Diagnostics.Stopwatch.StartNew();
Root = new GameObject($"Scene:{manifest.SceneId}").transform;
Root.SetParent(transform, false);
var worldLoaded = await LoadWorld();
await LoadObjects();
await LoadPersonas(visitorHead);
BuildHotspots(visitorHead);
SetUpBounds(visitorHead, visitorRig);
PlaceVisitor(visitorRig);
// Le chiffre que S1 doit rapporter (§« ce que cette étape mesure »), mais
// mesuré ici sur la scène réelle plutôt que sur un GLB isolé : c'est
// celui-là qui décide si l'écran de progression est un détail ou un sujet.
Debug.Log($"[SceneBuilder] Scène {manifest.SceneId} v{manifest.Version} " +
$"construite en {watch.ElapsedMilliseconds} ms " +
$"({_manifest.Objects.Count} objets, {_personas.Count} personnages, " +
$"{_hotspots.Count} hotspots, budget " +
$"{manifest.Budget?.TotalBytes ?? 0} octets)");
return worldLoaded;
}
async Task<bool> LoadWorld()
{
var url = ResolveAssetUrl(_manifest, _manifest.World.AssetId);
if (url == null)
{
Debug.LogError($"[SceneBuilder] décor {_manifest.World.AssetId} absent du manifeste");
return false;
}
var holder = new GameObject("World");
holder.transform.SetParent(Root, false);
_manifest.World.Transform.ApplyTo(holder.transform);
return await GltfLoader.LoadAsync(url, holder.transform, "Model") != null;
}
async Task LoadObjects()
{
var objectsRoot = new GameObject("Objects").transform;
objectsRoot.SetParent(Root, false);
foreach (var sceneObject in _manifest.Objects)
{
var url = ResolveAssetUrl(_manifest, sceneObject.AssetId);
if (url == null)
{
// Un asset manquant n'interrompt pas la scène : sur site, une
// amphore absente vaut mieux qu'une salle noire.
Debug.LogWarning($"[SceneBuilder] objet {sceneObject.Id} : " +
$"asset {sceneObject.AssetId} absent du manifeste");
continue;
}
var holder = new GameObject($"Object:{sceneObject.Id}");
holder.transform.SetParent(objectsRoot, false);
sceneObject.Transform.ApplyTo(holder.transform);
await GltfLoader.LoadAsync(url, holder.transform, "Model");
}
}
async Task LoadPersonas(Transform visitorHead)
{
var personasRoot = new GameObject("Personas").transform;
personasRoot.SetParent(Root, false);
foreach (var persona in _manifest.Personas)
{
var url = ResolveAssetUrl(_manifest, persona.AssetId);
if (url == null)
{
Debug.LogWarning($"[SceneBuilder] persona {persona.Id} : " +
$"asset {persona.AssetId} absent du manifeste");
continue;
}
var holder = new GameObject($"Persona:{persona.Id}");
holder.transform.SetParent(personasRoot, false);
persona.Transform.ApplyTo(holder.transform);
var instance = holder.AddComponent<PersonaInstance>();
// L'échelle du manifeste s'applique au porteur ; le regard, lui, ne
// vise que si le personnage a été demandé comme tel.
if (!await instance.LoadAsync(url, persona.GazeAtVisitor ? visitorHead : null))
continue;
_personas.Add(instance);
}
}
void BuildHotspots(Transform visitorHead)
{
var hotspotsRoot = new GameObject("Hotspots").transform;
hotspotsRoot.SetParent(Root, false);
foreach (var hotspot in _manifest.Hotspots)
{
var holder = new GameObject($"Hotspot:{hotspot.Id}");
holder.transform.SetParent(hotspotsRoot, false);
var instance = holder.AddComponent<HotspotInstance>();
instance.Configure(hotspot, _manifest, visitorHead, _language);
_hotspots.Add(instance);
}
}
void SetUpBounds(Transform visitorHead, Transform visitorRig)
{
Bounds = new GameObject("NavigationBounds").AddComponent<NavigationBounds>();
Bounds.transform.SetParent(Root, false);
Bounds.Configure(visitorRig,
visitorHead,
_manifest.Navigation.ClampedRadiusMeters,
_manifest.Navigation.ShowBoundary);
}
/// <summary>
/// L'origine du manifeste <b>est</b> le point de spawn, au sol (§2.1). Un
/// <c>spawn</c> non identitaire est donc l'exception, pas la règle — mais il
/// existe, et il doit déplacer le visiteur, pas le décor : déplacer le décor
/// désaligne le garde-fou natif du casque.
/// </summary>
void PlaceVisitor(Transform visitorRig)
{
if (visitorRig == null) return;
visitorRig.localPosition = _manifest.Navigation.Spawn.UnityPosition;
visitorRig.localRotation = _manifest.Navigation.Spawn.UnityRotation;
}
public void SetLanguage(string language)
{
_language = language;
foreach (var hotspot in _hotspots) hotspot.SetLanguage(language);
}
public void StopAllAudio()
{
foreach (var hotspot in _hotspots) hotspot.StopAudio();
}
/// <summary>
/// Résolution de l'URL d'un asset, et <b>le seul endroit</b> qui la fait.
///
/// Le manifeste servi par le serveur porte des URL absolues. Mais la règle 2
/// de la décision D3 impose qu'un manifeste dont les URL ont été réécrites en
/// chemins locaux soit lisible tel quel — c'est ce qui rend l'offline
/// possible, et c'est ce qu'utilise le <c>scene.json</c> de S2 avec ses noms
/// de fichiers nus. Une URL sans schéma est donc un fichier de
/// StreamingAssets aujourd'hui, du cache disque en S6, sans changer d'appelant.
/// </summary>
public static string ResolveAssetUrl(SceneManifest manifest, string assetId)
{
var asset = manifest.FindAsset(assetId);
if (asset == null || string.IsNullOrEmpty(asset.Url)) return null;
return asset.Url.Contains("://")
? asset.Url
: GltfLoader.StreamingAssetsUrl(asset.Url);
}
}
}