vr-app/unity-overlay/Assets/Scripts/Menu/ImmersiveBackdrop.cs
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

135 lines
5.6 KiB
C#

using System;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
using UnityEngine.Video;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Le fond immersif d'une visite — le reste de l'item <b>E5</b>, spécifié au §4 de
/// <c>DOCS/v2/immersif-frontiere-plan.md</c>.
///
/// <b>Sans lui, le menu flotte dans le noir.</b> C'est le défaut d'Unity : une scène
/// vide est un vide, et un visiteur qui met le casque se retrouve devant six panneaux
/// suspendus dans le néant. Avec le panorama du lieu derrière, il est <i>dans</i> le
/// musée avant d'avoir choisi quoi que ce soit.
///
/// Trois différences avec <see cref="SkyboxView"/>, et elles tiennent toutes à la même
/// chose — <b>un fond n'est pas un contenu</b> :
/// <list type="bullet">
/// <item>on ne le choisit pas et on n'en sort pas : aucun panneau de retour ;</item>
/// <item>il reste en place tant que la visite dure, y compris entre deux sections ;</item>
/// <item>il s'efface devant une 360 ouverte, puis revient — <c>SkyboxView</c> restaure
/// le ciel précédent en se détruisant, et le ciel précédent, c'est celui-ci.</item>
/// </list>
///
/// Son absence est un cas nominal : un lieu sans fond garde le noir, sans un mot.
/// </summary>
public class ImmersiveBackdrop : MonoBehaviour
{
const int VideoWidth = 4096;
const int VideoHeight = 2048;
Material _sky;
Texture2D _image;
VideoPlayer _player;
RenderTexture _target;
/// <summary>
/// Pose le fond de cette visite, s'il y en a un. Rend le composant créé, ou null —
/// pas de fond n'est pas une erreur.
/// </summary>
public static async Task<ImmersiveBackdrop> ApplyAsync(
ConfigurationExport export, ApiClient client)
{
var background = export?.ImmersiveBackground;
if (background == null || string.IsNullOrEmpty(background.ResourceUrl)) return null;
// Une scène 3D en fond est du ressort du viewer de scène, pas du ciel : la
// rendre ici donnerait un décor plaqué sur une sphère. Le §4 la prévoit, E7
// sait la charger — le jour où on les branche, c'est ici que ça se décide.
if (background.Kind == ConfigurationExport.ImmersiveBackgroundKind.Scene3D)
{
Debug.Log("[Backdrop] Fond de type Scène 3D : pas encore rendu en fond, " +
"le menu reste sur le ciel par défaut.");
return null;
}
var root = new GameObject("ImmersiveBackdrop");
var backdrop = root.AddComponent<ImmersiveBackdrop>();
await backdrop.LoadAsync(background, client);
return backdrop;
}
async Task LoadAsync(ConfigurationExport.Backdrop background, ApiClient client)
{
_sky = PanoramicSky.CreateMaterial();
if (_sky == null) return;
if (background.Kind == ConfigurationExport.ImmersiveBackgroundKind.Video360)
PlayVideo(background, client);
else
await ShowImage(background, client);
}
async Task ShowImage(ConfigurationExport.Backdrop background, ApiClient client)
{
var texture = await PanoramicSky.LoadEquirectangularAsync(client, background.ResourceUrl);
if (texture == null || this == null) return;
_image = texture;
_sky.SetTexture("_MainTex", texture);
RenderSettings.skybox = _sky;
}
/// <summary>
/// ⚠️ <b>Une vidéo en fond tourne en permanence.</b> Sur une borne qui fonctionne
/// huit heures, c'est un décodeur matériel occupé toute la journée et une batterie
/// qui descend plus vite — à mesurer avant de la vendre comme le cas normal. Le son
/// est coupé : un fond qui parle par-dessus le commentaire d'une section serait
/// intenable.
/// </summary>
void PlayVideo(ConfigurationExport.Backdrop background, ApiClient client)
{
_target = new RenderTexture(VideoWidth, VideoHeight, 0);
_player = gameObject.AddComponent<VideoPlayer>();
_player.source = VideoSource.Url;
_player.renderMode = VideoRenderMode.RenderTexture;
_player.targetTexture = _target;
_player.isLooping = true;
_player.audioOutputMode = VideoAudioOutputMode.None;
_player.errorReceived += (_, message) =>
Debug.LogError($"[Backdrop] Lecture vidéo impossible : {message}");
_sky.SetTexture("_MainTex", _target);
RenderSettings.skybox = _sky;
StartPlayback(background.ResourceUrl, client);
}
async void StartPlayback(string url, ApiClient client)
{
var path = await ContentCache.MediaPathAsync(client, url);
if (path == null || this == null || _player == null) return;
_player.url = new Uri(path).AbsoluteUri;
_player.Play();
}
void OnDestroy()
{
// Le ciel est un réglage global. On ne le remet à null que s'il est encore le
// nôtre : une 360 ouverte par-dessus a déjà pris la place, et lui arracher son
// ciel en repassant par ici la laisserait dans le noir.
if (RenderSettings.skybox == _sky) RenderSettings.skybox = null;
if (_player != null) _player.Stop();
if (_target != null) _target.Release();
if (_image != null) Destroy(_image);
}
}
}