using System; using System.IO; using System.Threading.Tasks; using MyInfoMate.Vr.Net; using UnityEngine; using UnityEngine.Video; namespace MyInfoMate.Vr.Menu { /// /// Lecture 360° — item E6 du lot XR-4, et la seule chose que le casque /// fait mieux que tout le reste. /// /// Une photo ou une vidéo équirectangulaire devient le ciel : le visiteur est /// dedans, 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 moins 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 RenderTexture qu'un /// VideoPlayer alimente image par image. /// /// ⚠️ Ce shader doit être dans Always Included Shaders, comme /// Universal Render Pipeline/Unlit : 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). /// public class SkyboxView : MonoBehaviour { /// Résolution de la cible vidéo. 4096×2048 tient sur un Quest 2. 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(); 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(); 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(); } /// /// Le retour ne reste pas affiché. 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 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 baisse les yeux, 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. /// const float VisibleSeconds = 4f; /// Sous cet angle, le visiteur cherche quelque chose plutôt qu'il ne regarde. 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; } /// /// 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. /// 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(); _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(); } /// /// Le ciel est un réglage global : 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. /// 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); } } }