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

221 lines
8.4 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
using UnityEngine.Video;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Lecture 360° — item <b>E6</b> du lot XR-4, et <b>la seule chose que le casque
/// fait mieux que tout le reste</b>.
///
/// Une photo ou une vidéo équirectangulaire devient le ciel : le visiteur est
/// <i>dedans</i>, il tourne la tête et le monde suit. C'est ce qu'aucune tablette ne
/// sait faire, et c'est pour ça que le plan (§8) dit que la Video 360 « gagne
/// vraiment » là où un Article flottant est <i>moins</i> bon qu'un écran.
///
/// Les deux cas passent par le même shader de skybox, donc le même rendu : une image
/// devient une texture, une vidéo devient une <c>RenderTexture</c> qu'un
/// <c>VideoPlayer</c> alimente image par image.
///
/// ⚠️ <b>Ce shader doit être dans <i>Always Included Shaders</i></b>, comme
/// <c>Universal Render Pipeline/Unlit</c> : construit au runtime, il n'est référencé
/// par aucune scène et le build l'élimine en silence. Le ciel sort alors magenta sur
/// le casque et correct dans l'éditeur — le piège déjà payé une fois avec glTFast
/// (voir le README de l'overlay).
/// </summary>
public class SkyboxView : MonoBehaviour
{
/// <summary>Résolution de la cible vidéo. 4096×2048 tient sur un Quest 2.</summary>
const int VideoWidth = 4096;
const int VideoHeight = 2048;
Material _skybox;
Material _previousSkybox;
VideoPlayer _player;
RenderTexture _target;
Texture2D _image;
MenuItemPanel _back;
Transform _head;
public event Action Closed;
public event Action Interacted;
public static SkyboxView Create(Transform head)
{
var root = new GameObject("SkyboxView");
var view = root.AddComponent<SkyboxView>();
view._head = head;
if (head != null)
{
var forward = head.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
root.transform.position = new Vector3(head.position.x, 0f, head.position.z);
root.transform.rotation = Quaternion.LookRotation(forward.normalized);
}
var selector = root.AddComponent<AimSelector>();
selector.Configure(head);
selector.Selected += view.OnSelected;
view.Build();
return view;
}
void Build()
{
_previousSkybox = RenderSettings.skybox;
_skybox = PanoramicSky.CreateMaterial();
if (_skybox == null) return;
// Le seul élément posé dans la scène : de quoi ressortir.
_back = MenuItemPanel.Create("__back", "Regardez ici pour revenir", transform);
_back.transform.localScale = Vector3.one * 0.6f;
PlaceBack();
}
/// <summary>
/// <b>Le retour ne reste pas affiché.</b> Toute la valeur d'une 360 est d'y être :
/// un panneau planté en permanence dans le décor rappelle qu'on regarde une image,
/// et c'est précisément ce qu'on cherchait à faire oublier.
///
/// Il se montre <see cref="VisibleSeconds"/> secondes au début — assez pour qu'on
/// sache qu'il existe et où le retrouver — puis s'efface. Il revient dès que le
/// visiteur <b>baisse les yeux</b>, le geste qu'on fait naturellement pour chercher
/// une commande, ou dès qu'il touche une manette. Sans ce rappel, quelqu'un en
/// borne resterait coincé dans la photo.
/// </summary>
const float VisibleSeconds = 4f;
/// <summary>Sous cet angle, le visiteur cherche quelque chose plutôt qu'il ne regarde.</summary>
const float LookDownDegrees = 25f;
float _hideAt;
void Update()
{
if (_back == null || _head == null) return;
var wants = LookingDown() || ControllerInUse();
if (wants)
{
if (!_back.gameObject.activeSelf) PlaceBack();
_hideAt = Time.time + VisibleSeconds;
}
_back.gameObject.SetActive(Time.time < _hideAt);
}
bool LookingDown() => Vector3.Angle(_head.forward, Vector3.down) < 90f - LookDownDegrees;
static bool ControllerInUse()
{
var controller = OVRInput.GetActiveController();
return controller == OVRInput.Controller.RTouch
|| controller == OVRInput.Controller.LTouch;
}
/// <summary>
/// Reposé sous le regard courant à chaque réapparition : en 360 le visiteur tourne
/// sur lui-même, et un panneau laissé à sa position de départ serait dans son dos
/// au moment où il le cherche.
/// </summary>
void PlaceBack()
{
if (_back == null) return;
var forward = _head != null ? _head.forward : Vector3.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
forward.Normalize();
_back.transform.position =
(_head != null ? _head.position : Vector3.zero) + forward * 2.2f + Vector3.down * 0.8f;
_back.transform.rotation = Quaternion.LookRotation(forward);
_back.gameObject.SetActive(true);
}
public async Task LoadAsync(ConfigurationExport.Resource resource, ApiClient client)
{
if (_skybox == null) return;
if (resource.Type == ConfigurationExport.ResourceKind.Video360)
await PlayVideo(resource, client);
else
await ShowImage(resource, client);
}
async Task ShowImage(ConfigurationExport.Resource resource, ApiClient client)
{
var texture = await PanoramicSky.LoadEquirectangularAsync(client, resource.Url);
if (texture == null || this == null) return;
_image = texture;
_skybox.SetTexture("_MainTex", texture);
RenderSettings.skybox = _skybox;
// Le panneau de retour se montre au début, puis s'efface.
_hideAt = Time.time + VisibleSeconds;
}
async Task PlayVideo(ConfigurationExport.Resource resource, ApiClient client)
{
// Une vidéo 360 pèse des centaines de Mo : elle est lue depuis le cache
// disque, pas gardée en mémoire.
var path = await ContentCache.MediaPathAsync(client, resource.Url);
if (path == null || this == null) return;
_target = new RenderTexture(VideoWidth, VideoHeight, 0);
_player = gameObject.AddComponent<VideoPlayer>();
_player.source = VideoSource.Url;
_player.url = new Uri(path).AbsoluteUri;
_player.renderMode = VideoRenderMode.RenderTexture;
_player.targetTexture = _target;
_player.isLooping = true;
_player.audioOutputMode = VideoAudioOutputMode.Direct;
_player.errorReceived += (_, message) =>
Debug.LogError($"[Skybox] Lecture vidéo impossible : {message}");
_skybox.SetTexture("_MainTex", _target);
RenderSettings.skybox = _skybox;
_hideAt = Time.time + VisibleSeconds;
_player.Play();
}
void OnSelected(IAimTarget target)
{
if (!(target is MenuItemPanel panel) || panel.Id != "__back") return;
Interacted?.Invoke();
Closed?.Invoke();
}
/// <summary>
/// Le ciel est un réglage <b>global</b> : sans cette remise en état, le menu
/// s'afficherait par-dessus la dernière image 360 vue, et le visiteur suivant
/// hériterait du décor du précédent.
/// </summary>
void OnDestroy()
{
RenderSettings.skybox = _previousSkybox;
if (_player != null) _player.Stop();
if (_target != null) _target.Release();
// Une équirectangulaire pèse des dizaines de Mo même compressée : laissée
// derrière, elle s'accumulerait à chaque 360 ouverte jusqu'à ce que le
// système tue l'app — au bout de plusieurs visites, donc jamais en test court.
if (_image != null) Destroy(_image);
}
}
}