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

211 lines
7.5 KiB
C#

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
using MyInfoMate.Vr.Manifest;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Un point d'intérêt posé dans la scène : un repère visible, visé au regard, qui
/// joue son commentaire audio dans la langue courante.
///
/// <b>Le déclenchement se fait au regard soutenu, pas à la gâchette.</b> Deux
/// raisons, et la seconde est la vraie : le projet vise deux plugins XR (Meta XR
/// SDK et OpenXR) dont les API de manettes diffèrent, et un visiteur de musée qui
/// n'a jamais tenu une manette Quest ne sait pas laquelle presser. S7 pourra
/// ajouter la gâchette en raccourci — pas la remplacer.
/// </summary>
public class HotspotInstance : MonoBehaviour
{
public const float DwellSeconds = 1.2f;
const float MarkerRadius = 0.09f;
SceneHotspot _hotspot;
SceneManifest _manifest;
Transform _visitorHead;
AudioSource _audio;
Renderer _marker;
float _gazeSeconds;
string _language;
AudioClip _clip;
string _loadedClipAssetId;
public SceneHotspot Hotspot => _hotspot;
public void Configure(SceneHotspot hotspot, SceneManifest manifest,
Transform visitorHead, string language)
{
_hotspot = hotspot;
_manifest = manifest;
_visitorHead = visitorHead;
_language = language;
hotspot.Transform.ApplyTo(transform);
BuildMarker();
_audio = gameObject.AddComponent<AudioSource>();
// Spatialisé : un commentaire qui sort de l'objet dont il parle situe le
// point sans qu'on ait à l'indiquer. Un son plat le laisse invisible.
_audio.spatialBlend = 1f;
_audio.rolloffMode = AudioRolloffMode.Linear;
_audio.maxDistance = 8f;
_audio.playOnAwake = false;
}
void BuildMarker()
{
var sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere);
sphere.name = "Marker";
sphere.transform.SetParent(transform, false);
sphere.transform.localScale = Vector3.one * (MarkerRadius * 2f);
_marker = sphere.GetComponent<Renderer>();
_marker.material = UnlitMaterial(IdleColor);
// Un collider plus large que le repère : viser une bille de 9 cm à trois
// mètres au regard est nettement plus dur que ça n'en a l'air.
var collider = sphere.GetComponent<SphereCollider>();
collider.radius = 1.6f;
}
static readonly Color IdleColor = new Color(1f, 0.85f, 0.3f, 1f);
static readonly Color GazeColor = new Color(1f, 1f, 1f, 1f);
/// <summary>
/// ⚠️ <c>Shader.Find</c> ne trouve un shader dans un build que s'il y est
/// embarqué. Les shaders URP utilisés uniquement par du code — jamais par un
/// matériau posé dans une scène — sont <b>retirés au build</b> et le rendu
/// devient magenta sur le casque alors qu'il est correct dans l'éditeur.
/// D'où <c>Always Included Shaders</c> dans Project Settings → Graphics.
/// </summary>
static Material UnlitMaterial(Color color)
{
var shader = Shader.Find("Universal Render Pipeline/Unlit");
if (shader == null)
{
Debug.LogError("[Hotspot] Shader URP/Unlit introuvable : il a été retiré " +
"du build. Project Settings → Graphics → Always Included Shaders.");
shader = Shader.Find("Sprites/Default");
}
return new Material(shader) { color = color };
}
void Update()
{
if (_visitorHead == null) return;
var looking = IsGazedAt();
_gazeSeconds = looking ? _gazeSeconds + Time.deltaTime : 0f;
if (_marker != null)
_marker.material.color = Color.Lerp(
IdleColor, GazeColor, Mathf.Clamp01(_gazeSeconds / DwellSeconds));
if (looking && _gazeSeconds >= DwellSeconds && !_audio.isPlaying)
{
_gazeSeconds = 0f;
StartCoroutine(PlayCurrentLanguage());
}
}
bool IsGazedAt()
{
var ray = new Ray(_visitorHead.position, _visitorHead.forward);
return Physics.Raycast(ray, out var hit, 12f)
&& hit.collider.transform.IsChildOf(transform);
}
public void SetLanguage(string language)
{
if (_language == language) return;
_language = language;
// Un changement de langue pendant la lecture doit couper : laisser finir
// la phrase dans l'ancienne langue est le genre de détail qui fait dire
// « ça ne marche pas ».
if (_audio != null && _audio.isPlaying) _audio.Stop();
}
public void StopAudio()
{
if (_audio != null && _audio.isPlaying) _audio.Stop();
}
IEnumerator PlayCurrentLanguage()
{
var assetId = AudioAssetIdFor(_language);
if (string.IsNullOrEmpty(assetId))
{
Debug.Log($"[Hotspot] {_hotspot.Id} : aucun audio en « {_language} »");
yield break;
}
if (_loadedClipAssetId != assetId)
{
yield return LoadClip(assetId);
_loadedClipAssetId = assetId;
}
if (_clip == null) yield break;
_audio.clip = _clip;
_audio.Play();
}
/// <summary>
/// Le premier contenu du hotspot qui porte un audio dans cette langue. La
/// lecture de la suite de <c>contents</c> viendra avec le panneau de S7 ; en
/// S2 on valide qu'un son sort du bon endroit.
/// </summary>
string AudioAssetIdFor(string language)
{
if (_hotspot.Contents == null) return null;
foreach (var content in _hotspot.Contents)
{
if (string.IsNullOrEmpty(content.AssetId)) continue;
var hasText = content.Description != null
&& content.Description.Exists(t => t.Language == language);
if (hasText || _hotspot.Contents.Count == 1) return content.AssetId;
}
return null;
}
IEnumerator LoadClip(string assetId)
{
var url = SceneBuilder.ResolveAssetUrl(_manifest, assetId);
if (url == null)
{
Debug.LogWarning($"[Hotspot] {_hotspot.Id} : asset {assetId} absent du manifeste");
yield break;
}
// Le disque d'abord. Le préchargement (E4) a normalement déjà déposé ce
// fichier ; sans ce détour, un hotspot restait muet dès que le wifi tombait,
// alors que le son était sur le casque.
var cached = Net.ContentCache.CachedPath(url);
var source = cached != null ? "file://" + cached : url;
using var request = UnityWebRequestMultimedia.GetAudioClip(source, AudioType.MPEG);
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[Hotspot] audio {assetId} illisible : {request.error}");
yield break;
}
_clip = DownloadHandlerAudioClip.GetContent(request);
}
}
}