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>
221 lines
8.4 KiB
C#
221 lines
8.4 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|