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>
331 lines
13 KiB
C#
331 lines
13 KiB
C#
using MyInfoMate.Vr.Ui;
|
|
using UnityEngine;
|
|
|
|
namespace MyInfoMate.Vr.Menu
|
|
{
|
|
/// <summary>
|
|
/// Un panneau du menu flottant : une image, un titre, et l'anneau de progression de
|
|
/// la visée — item <b>E5</b> du lot XR-4.
|
|
///
|
|
/// L'anneau ne se remplit qu'en visée à la tête : la manette et la main ont une
|
|
/// gâchette et un pincement, donc rien à attendre (voir <see cref="AimSelector"/>).
|
|
///
|
|
/// Tout est construit par code, avec le <b>même shader</b> que
|
|
/// <see cref="Scene.NavigationBounds"/> (<c>Universal Render Pipeline/Unlit</c>).
|
|
/// Ce n'est pas un détail de style : ce shader est déjà dans <i>Always Included
|
|
/// Shaders</i>, et tout shader supplémentaire embarqué coûte des milliers de
|
|
/// variantes et des dizaines de minutes de build (voir le README de l'overlay).
|
|
///
|
|
/// <b>Les coins arrondis et le liseré ne viennent donc pas d'un shader</b>, mais
|
|
/// d'une texture générée une fois et partagée par tous les panneaux : un rectangle
|
|
/// arrondi en alpha, avec un bord plus clair. C'est la seule façon d'avoir une
|
|
/// matière sans payer une deuxième compilation de variantes.
|
|
/// </summary>
|
|
public class MenuItemPanel : MonoBehaviour, IAimTarget
|
|
{
|
|
public const float WidthMeters = 0.5f;
|
|
public const float HeightMeters = 0.34f;
|
|
|
|
static readonly Color Idle = new Color(0.10f, 0.13f, 0.17f, 0.92f);
|
|
static readonly Color Hover = new Color(0.20f, 0.42f, 0.60f, 0.98f);
|
|
|
|
/// <summary>Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.</summary>
|
|
const float HoverScale = 1.07f;
|
|
|
|
const float TransitionSpeed = 12f;
|
|
|
|
/// <summary>Assez court pour ne pas faire attendre, assez long pour se voir.</summary>
|
|
const float AppearSeconds = 0.35f;
|
|
|
|
/// <summary>
|
|
/// Ce que le panneau désigne : un id de section dans le menu, une commande
|
|
/// (« suivant », « retour ») ailleurs. Le panneau ne sait pas ce que ça veut
|
|
/// dire, et c'est ce qui le rend réutilisable.
|
|
/// </summary>
|
|
public string Id { get; private set; }
|
|
|
|
Material _background;
|
|
LineRenderer _progress;
|
|
Transform _image;
|
|
|
|
Vector3 _baseScale = Vector3.one;
|
|
bool _baseScaleKnown;
|
|
|
|
bool _hovered;
|
|
float _hover;
|
|
|
|
float _appear = 1f;
|
|
float _appearElapsed;
|
|
bool _appearing;
|
|
|
|
public static MenuItemPanel Create(string id, string title, Transform parent)
|
|
{
|
|
var root = new GameObject($"Menu — {title}");
|
|
root.transform.SetParent(parent, false);
|
|
|
|
var panel = root.AddComponent<MenuItemPanel>();
|
|
panel.Build(id, title);
|
|
return panel;
|
|
}
|
|
|
|
void Build(string id, string title)
|
|
{
|
|
Id = id;
|
|
|
|
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
|
|
background.name = "Background";
|
|
background.transform.SetParent(transform, false);
|
|
background.transform.localScale = new Vector3(WidthMeters, HeightMeters, 1f);
|
|
|
|
_background = background.GetComponent<Renderer>().material =
|
|
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
|
|
_background.mainTexture = PanelTexture;
|
|
Transparent(_background);
|
|
_background.color = Idle;
|
|
|
|
// Double face. La normale d'un Quad et l'orientation lisible d'un texte ne
|
|
// pointent pas du même côté : avec le culling par défaut, l'un des deux
|
|
// disparaît, et on ne le découvre que dans le casque.
|
|
NoCulling(_background);
|
|
|
|
// Le collider est ce que la visée touche. Celui d'un Quad est un
|
|
// MeshCollider, qu'on ne peut pas passer en trigger sans qu'il soit convexe :
|
|
// une boîte fine fait le même travail, couvre tout le panneau — texte
|
|
// compris — et ne pose aucune question à la physique.
|
|
Destroy(background.GetComponent<MeshCollider>());
|
|
var box = background.AddComponent<BoxCollider>();
|
|
box.size = new Vector3(1f, 1f, 0.02f);
|
|
|
|
var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white);
|
|
label.transform.localPosition = new Vector3(0f, -HeightMeters * 0.30f, -0.01f);
|
|
label.Text = Label.Wrap(title, 22);
|
|
|
|
BuildProgressRing();
|
|
}
|
|
|
|
void Start()
|
|
{
|
|
// Après le Create : PagedView réduit ses commandes à 0,55 juste après. Capter
|
|
// l'échelle ici évite que le survol et l'apparition l'écrasent.
|
|
_baseScale = transform.localScale;
|
|
_baseScaleKnown = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fait entrer le panneau. <paramref name="delaySeconds"/> décale son arrivée :
|
|
/// c'est ce décalage, et lui seul, qui fait la cascade quand le menu se pose.
|
|
/// </summary>
|
|
public void PlayAppear(float delaySeconds)
|
|
{
|
|
_appearing = true;
|
|
_appearElapsed = -delaySeconds;
|
|
_appear = 0f;
|
|
ApplyScale();
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
_hover = Mathf.MoveTowards(
|
|
_hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed);
|
|
|
|
var color = Color.Lerp(Idle, Hover, _hover);
|
|
|
|
if (_appearing)
|
|
{
|
|
_appearElapsed += Time.deltaTime;
|
|
var t = Mathf.Clamp01(_appearElapsed / AppearSeconds);
|
|
|
|
// Un léger dépassement : le panneau arrive, dépasse sa taille d'un cheveu
|
|
// et se pose. Sans ça l'apparition est linéaire, donc mécanique.
|
|
_appear = _appearElapsed <= 0f ? 0f : Overshoot(t);
|
|
|
|
// La couleur est toujours recalculée depuis Idle/Hover, jamais modifiée
|
|
// sur place : une opacité multipliée image après image tomberait à zéro.
|
|
color.a *= Mathf.Clamp01(_appearElapsed / (AppearSeconds * 0.6f));
|
|
|
|
if (t >= 1f)
|
|
{
|
|
_appearing = false;
|
|
_appear = 1f;
|
|
}
|
|
}
|
|
|
|
_background.color = color;
|
|
ApplyScale();
|
|
}
|
|
|
|
static float Overshoot(float t)
|
|
{
|
|
const float tension = 1.7f;
|
|
var u = t - 1f;
|
|
return 1f + u * u * ((tension + 1f) * u + tension);
|
|
}
|
|
|
|
void ApplyScale()
|
|
{
|
|
if (!_baseScaleKnown) return;
|
|
|
|
transform.localScale = _baseScale
|
|
* Mathf.Lerp(1f, HoverScale, _hover)
|
|
* Mathf.Lerp(0.7f, 1f, _appear);
|
|
}
|
|
|
|
/// <summary>
|
|
/// L'image de la section, une fois téléchargée. Un panneau sans image reste
|
|
/// lisible : le titre suffit, et une borne hors ligne au premier démarrage n'a
|
|
/// encore aucun média en cache.
|
|
/// </summary>
|
|
public void SetImage(Texture2D texture)
|
|
{
|
|
if (texture == null) return;
|
|
|
|
if (_image == null)
|
|
{
|
|
var quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
|
|
quad.name = "Image";
|
|
quad.transform.SetParent(transform, false);
|
|
quad.transform.localPosition = new Vector3(0f, HeightMeters * 0.16f, -0.005f);
|
|
quad.transform.localScale = new Vector3(WidthMeters * 0.86f, HeightMeters * 0.5f, 1f);
|
|
|
|
// L'image ne doit pas intercepter le regard : c'est le panneau qui est
|
|
// la cible, sinon le survol s'éteint dès qu'on regarde la vignette.
|
|
Destroy(quad.GetComponent<Collider>());
|
|
_image = quad.transform;
|
|
}
|
|
|
|
var material = new Material(Shader.Find("Universal Render Pipeline/Unlit"))
|
|
{
|
|
mainTexture = texture
|
|
};
|
|
NoCulling(material);
|
|
_image.GetComponent<Renderer>().material = material;
|
|
}
|
|
|
|
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
|
|
|
|
/// <summary>
|
|
/// URP/Unlit est opaque par défaut : sans ces cinq lignes, l'alpha d'une couleur
|
|
/// est simplement ignoré et le panneau est un rectangle plein aux coins carrés,
|
|
/// texture arrondie ou pas.
|
|
/// </summary>
|
|
static void Transparent(Material material)
|
|
{
|
|
material.SetFloat("_Surface", 1f);
|
|
material.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
|
|
material.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
|
|
material.SetFloat("_ZWrite", 0f);
|
|
material.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
|
|
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
|
|
}
|
|
|
|
static Texture2D _panelTexture;
|
|
|
|
/// <summary>
|
|
/// Le rectangle arrondi, généré une fois pour tous les panneaux. L'alpha porte la
|
|
/// forme et l'anticrénelage du bord ; le RGB porte le liseré, plus clair que
|
|
/// l'intérieur — le matériau le teinte ensuite, donc un seul dessin sert au repos
|
|
/// comme au survol.
|
|
/// </summary>
|
|
static Texture2D PanelTexture
|
|
{
|
|
get
|
|
{
|
|
if (_panelTexture != null) return _panelTexture;
|
|
|
|
const int width = 256;
|
|
const int height = 176;
|
|
const float radius = 30f;
|
|
const float border = 3f;
|
|
|
|
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
|
|
{
|
|
wrapMode = TextureWrapMode.Clamp,
|
|
filterMode = FilterMode.Bilinear
|
|
};
|
|
|
|
var pixels = new Color32[width * height];
|
|
|
|
for (var y = 0; y < height; y++)
|
|
for (var x = 0; x < width; x++)
|
|
{
|
|
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
|
|
|
|
// distance < 0 dedans, > 0 dehors. 1,2 px de dégradé suffisent à
|
|
// effacer l'escalier du bord à 2,20 m.
|
|
var alpha = Mathf.Clamp01(0.5f - distance / 1.2f);
|
|
var lit = distance > -border ? 1f : 0.72f;
|
|
|
|
pixels[y * width + x] = new Color(lit, lit, lit, alpha);
|
|
}
|
|
|
|
texture.SetPixels32(pixels);
|
|
texture.Apply();
|
|
|
|
_panelTexture = texture;
|
|
return _panelTexture;
|
|
}
|
|
}
|
|
|
|
/// <summary>Distance signée au rectangle arrondi, en pixels.</summary>
|
|
static float RoundedRectDistance(float x, float y, float width, float height, float radius)
|
|
{
|
|
var dx = Mathf.Abs(x - width / 2f) - (width / 2f - radius);
|
|
var dy = Mathf.Abs(y - height / 2f) - (height / 2f - radius);
|
|
|
|
var outside = new Vector2(Mathf.Max(dx, 0f), Mathf.Max(dy, 0f)).magnitude;
|
|
return outside + Mathf.Min(Mathf.Max(dx, dy), 0f) - radius;
|
|
}
|
|
|
|
void BuildProgressRing()
|
|
{
|
|
const int segments = 48;
|
|
|
|
var ring = new GameObject("Progress");
|
|
ring.transform.SetParent(transform, false);
|
|
ring.transform.localPosition = new Vector3(0f, 0f, -0.02f);
|
|
|
|
_progress = ring.AddComponent<LineRenderer>();
|
|
_progress.useWorldSpace = false;
|
|
_progress.loop = false;
|
|
_progress.positionCount = 0;
|
|
_progress.widthMultiplier = 0.006f;
|
|
_progress.material = new Material(Shader.Find("Universal Render Pipeline/Unlit"));
|
|
_progress.material.color = new Color(0.4f, 0.8f, 1f, 0.9f);
|
|
|
|
_ringPoints = new Vector3[segments];
|
|
const float radius = 0.055f;
|
|
|
|
for (var i = 0; i < segments; i++)
|
|
{
|
|
// Départ en haut, sens horaire : c'est le sens d'une minuterie, et
|
|
// n'importe qui le lit sans explication.
|
|
var angle = Mathf.PI / 2f - i * Mathf.PI * 2f / (segments - 1);
|
|
_ringPoints[i] = new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0f);
|
|
}
|
|
}
|
|
|
|
Vector3[] _ringPoints;
|
|
|
|
public void OnAimEnter()
|
|
{
|
|
_hovered = true;
|
|
MenuFeedback.Hover();
|
|
}
|
|
|
|
public void OnAimProgress(float ratio)
|
|
{
|
|
var count = Mathf.RoundToInt(ratio * _ringPoints.Length);
|
|
_progress.positionCount = count;
|
|
|
|
for (var i = 0; i < count; i++)
|
|
_progress.SetPosition(i, _ringPoints[i]);
|
|
}
|
|
|
|
public void OnAimExit()
|
|
{
|
|
_hovered = false;
|
|
_progress.positionCount = 0;
|
|
}
|
|
}
|
|
}
|