using System.Collections.Generic; using MyInfoMate.Vr.Ui; using UnityEngine; namespace MyInfoMate.Vr.Menu { /// /// Un panneau du menu flottant : une image, un titre, et l'anneau de progression de /// la visée — item E5 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 ). /// /// Tout est construit par code, avec le même shader que /// (Universal Render Pipeline/Unlit). /// Ce n'est pas un détail de style : ce shader est déjà dans Always Included /// Shaders, 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). /// /// Les coins arrondis et le liseré ne viennent donc pas d'un shader, 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. /// /// Et c'est aussi pour ça que la photo est composée dans la texture et /// non posée sur un deuxième quad : la tuile de home_3.0 et celle de /// visitapp-web sont une photo plein cadre, arrondie, assombrie vers le bas /// pour que le titre reste lisible. Un quad séparé ne peut ni suivre les coins /// arrondis ni porter le dégradé sans un shader de plus. /// public class MenuItemPanel : MonoBehaviour, IAimTarget { /// Taille d'une cellule de la grille du menu, et taille par défaut /// d'un panneau. Un panneau qui couvre plusieurs cellules reçoit la sienne à la /// construction (voir ). public const float WidthMeters = 0.5f; public const float HeightMeters = 0.34f; /// /// Une commande — « Retour au menu » — n'est pas une tuile de contenu : elle est /// large et basse, comme un bouton, et porte un chevron. /// public const float ActionWidthMeters = 0.92f; public const float ActionHeightMeters = 0.21f; public enum Style { Card, Action } 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); /// /// Une tuile qui porte une photo ne se teinte pas : la teinte de survol /// virerait la photo au bleu. Elle s'éclaire — le panneau au repos est /// légèrement assombri, et le survol le rend à sa pleine lumière. /// static readonly Color PhotoIdle = new Color(0.80f, 0.82f, 0.86f, 1f); static readonly Color PhotoHover = Color.white; /// Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin. const float HoverScale = 1.07f; const float TransitionSpeed = 12f; /// Assez court pour ne pas faire attendre, assez long pour se voir. const float AppearSeconds = 0.35f; /// /// 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. /// public string Id { get; private set; } /// Hauteur de ligne du titre d'une tuile, en mètres. const float CardTextMeters = 0.038f; /// Celui d'une commande, qui doit se lire d'un coup d'œil. const float ActionTextMeters = 0.062f; /// Place réservée au chevron sur la gauche d'une commande. const float ChevronWidthMeters = 0.13f; Material _background; LineRenderer _progress; Label _title; Style _style; bool _chevron = true; /// /// La texture composée pour ce panneau — photo cadrée, coins arrondis, dégradé. /// Gardée pour être détruite avec lui : elle est propre à ce panneau, et un menu /// reconstruit à chaque nouvelle visite en laisserait une par tuile en mémoire. /// Texture2D _composed; float _width = WidthMeters; float _height = HeightMeters; Vector3 _baseScale = Vector3.one; bool _baseScaleKnown; bool _hovered; float _hover; float _appear = 1f; float _appearElapsed; bool _appearing; /// /// et valent une /// cellule par défaut : les vues qui n'ont pas de grille (retour, pagination) /// appellent sans les passer et gardent le panneau historique. /// public static MenuItemPanel Create(string id, string title, Transform parent, float widthMeters = WidthMeters, float heightMeters = HeightMeters) { var root = new GameObject($"Menu — {title}"); root.transform.SetParent(parent, false); var panel = root.AddComponent(); panel._width = widthMeters; panel._height = heightMeters; panel.Build(id, title, Style.Card); return panel; } /// /// Une commande : chevron, texte grand, panneau large. Toutes les vues qui se /// ferment passent par ici — sortir d'une 360 ou d'une scène 3D doit se lire de /// la même façon, et surtout se lire tout court : un panneau de la taille /// d'une tuile, avec un titre de tuile, ne se voyait pas. /// public static MenuItemPanel CreateAction(string id, string label, Transform parent, bool chevron = true) { var root = new GameObject($"Action — {label}"); root.transform.SetParent(parent, false); var panel = root.AddComponent(); panel._width = ActionWidthMeters; panel._height = ActionHeightMeters; panel._chevron = chevron; panel.Build(id, label, Style.Action); return panel; } void Build(string id, string title, Style style) { Id = id; _style = style; var background = GameObject.CreatePrimitive(PrimitiveType.Quad); background.name = "Background"; background.transform.SetParent(transform, false); background.transform.localScale = new Vector3(_width, _height, 1f); _background = background.GetComponent().material = new Material(Shader.Find("Universal Render Pipeline/Unlit")); _background.mainTexture = PanelTextureFor(_width, _height); 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()); var box = background.AddComponent(); box.size = new Vector3(1f, 1f, 0.02f); var size = style == Style.Action ? ActionTextMeters : CardTextMeters; _title = Label.Create(transform, "Title", Label.Anchor.MiddleCenter, size, Color.white); // La coupure suit la largeur réelle : sur un panneau double, 22 caractères // laisseraient une colonne de texte au milieu d'un panneau vide. var columns = Mathf.Max(8, Mathf.RoundToInt(22f * _width * CardTextMeters / (WidthMeters * size))); _title.Text = Label.Wrap(title, columns); if (style == Style.Action) { // Le texte laisse sa place au chevron plutôt que de le chevaucher — et // reprend tout le panneau quand il n'y en a pas : un choix de langue n'a // pas de « précédent » à désigner. _title.transform.localPosition = new Vector3( _chevron ? ChevronWidthMeters * 0.5f : 0f, 0f, -0.01f); if (_chevron) BuildChevron(); } else { // Sans photo, le titre est tout ce qu'il y a : il occupe le panneau. // Avec, il descend au-dessus du bord bas (voir SetImage). _title.transform.localPosition = new Vector3(0f, 0f, -0.01f); } 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; } /// /// Fait entrer le panneau. décale son arrivée : /// c'est ce décalage, et lui seul, qui fait la cascade quand le menu se pose. /// 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( _composed != null ? PhotoIdle : Idle, _composed != null ? PhotoHover : 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 les deux teintes, 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); } /// /// 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. /// /// La photo remplace le fond du panneau au lieu de se poser dessus : c'est /// la même texture qui porte la photo, les coins arrondis et le dégradé du bas. /// Le titre descend alors dans ce dégradé, comme sur la tuile de l'app mobile. /// public void SetImage(Texture2D texture) { if (texture == null || _style != Style.Card) return; if (_composed != null) Destroy(_composed); _composed = ComposeCover(texture, _width, _height); _background.mainTexture = _composed; // Le titre est centré sur ce point : la moitié basse du dégradé, assez haut // pour que deux lignes tiennent encore dans le panneau. _title.transform.localPosition = new Vector3(0f, -_height * 0.5f + TitleBaselineMeters, -0.01f); } /// Hauteur du centre du titre au-dessus du bord bas du panneau. const float TitleBaselineMeters = 0.075f; /// /// Résolution de la texture composée. Plus fine que celle du fond nu /// () : un aplat supporte 512 px/m, une photo non. /// const float PhotoPixelsPerMeter = 1024f; /// /// La photo cadrée en cover, arrondie et assombrie vers le bas — la tuile /// de home_3.0, refaite en pixels. /// /// ⚠️ Les pixels de la source sont lus une fois, puis échantillonnés à la /// main. GetPixelBilinear par pixel de sortie coûte un appel natif chacun : /// sur six tuiles, ça se voyait comme un à-coup au moment où le menu se pose. /// static Texture2D ComposeCover(Texture2D source, float widthMeters, float heightMeters) { var width = Mathf.Max(8, Mathf.RoundToInt(widthMeters * PhotoPixelsPerMeter)); var height = Mathf.Max(8, Mathf.RoundToInt(heightMeters * PhotoPixelsPerMeter)); var radius = CornerRadiusMeters * PhotoPixelsPerMeter; var border = BorderMeters * PhotoPixelsPerMeter; var pixels = new Color32[width * height]; var source32 = source.GetPixels32(); // Cover : l'image remplit le panneau et déborde sur son grand côté, plutôt // que de laisser deux bandes vides. Le débordement est coupé des deux côtés, // donc le sujet reste au centre. var scale = Mathf.Max((float)width / source.width, (float)height / source.height); var cropX = (source.width - width / scale) * 0.5f; var cropY = (source.height - height / scale) * 0.5f; for (var y = 0; y < height; y++) for (var x = 0; x < width; x++) { var color = Sample(source32, source.width, source.height, cropX + (x + 0.5f) / scale, cropY + (y + 0.5f) / scale); // Le dégradé de lisibilité : rien sur les 40 % du haut, puis on descend // jusqu'à 72 % de noir au bord bas. Sans lui, un titre blanc sur une // photo claire est illisible — et une tuile sur deux l'est. var fromTop = 1f - (y + 0.5f) / height; var shade = Mathf.InverseLerp(0.4f, 1f, fromTop) * 0.72f; var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius); // Le liseré éclaircit la photo au lieu de la recouvrir : c'est ce qui // détache la tuile d'un fond sombre sans faire un cadre blanc. var lit = distance > -border ? 0.30f : 0f; var value = Color.Lerp(color * (1f - shade), Color.white, lit); value.a = Mathf.Clamp01(0.5f - distance / 1.2f); pixels[y * width + x] = value; } // Avec mipmaps, contrairement au fond nu : une photo vue de loin, sans // niveau réduit, scintille dès que la tête bouge — et dans un casque, la // tête bouge tout le temps. var texture = new Texture2D(width, height, TextureFormat.RGBA32, true) { wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Trilinear }; texture.SetPixels32(pixels); texture.Apply(); return texture; } /// Échantillonnage bilinéaire, en pixels de la source. static Color Sample(Color32[] pixels, int width, int height, float x, float y) { var fx = Mathf.Clamp(x - 0.5f, 0f, width - 1f); var fy = Mathf.Clamp(y - 0.5f, 0f, height - 1f); var x0 = (int)fx; var y0 = (int)fy; var x1 = Mathf.Min(x0 + 1, width - 1); var y1 = Mathf.Min(y0 + 1, height - 1); var tx = fx - x0; var ty = fy - y0; var top = Color.Lerp(pixels[y1 * width + x0], pixels[y1 * width + x1], tx); var bottom = Color.Lerp(pixels[y0 * width + x0], pixels[y0 * width + x1], tx); return Color.Lerp(bottom, top, ty); } /// /// Le chevron d'une commande, tracé plutôt que dessiné dans une texture : trois /// points suffisent, et LineRenderer est déjà là pour l'anneau de visée. /// Une police ne rendrait pas le même signe d'un casque à l'autre. /// void BuildChevron() { var chevron = new GameObject("Chevron"); chevron.transform.SetParent(transform, false); chevron.transform.localPosition = new Vector3(-_width * 0.5f + ChevronWidthMeters * 0.62f, 0f, -0.01f); var line = chevron.AddComponent(); line.useWorldSpace = false; line.widthMultiplier = 0.013f; line.numCapVertices = 2; line.material = new Material(Shader.Find("Universal Render Pipeline/Unlit")); line.material.color = Color.white; const float arm = 0.035f; line.positionCount = 3; line.SetPosition(0, new Vector3(arm * 0.7f, arm, 0f)); line.SetPosition(1, new Vector3(-arm * 0.7f, 0f, 0f)); line.SetPosition(2, new Vector3(arm * 0.7f, -arm, 0f)); } /// La texture composée n'appartient qu'à ce panneau. void OnDestroy() { if (_composed != null) Destroy(_composed); } static void NoCulling(Material material) => material.SetFloat("_Cull", 0f); /// /// 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. /// 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 readonly Dictionary PanelTextures = new Dictionary(); /// Résolution du dessin, en pixels par mètre de panneau. const float PixelsPerMeter = 512f; /// Rayon des coins, en mètres — identique sur tous les panneaux. const float CornerRadiusMeters = 0.059f; /// Épaisseur du liseré, en mètres. const float BorderMeters = 0.006f; /// /// Le rectangle arrondi d'un panneau. 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. /// /// ⚠️ Une texture par taille de panneau, pas une pour tous. Une texture unique /// étirée sur un panneau double donne des coins ovales et un liseré deux fois plus /// épais d'un côté que de l'autre — le panneau double ne ressemble plus au simple. /// Le rayon et le liseré sont donc dits en mètres et convertis ici, ce qui les rend /// constants quelle que soit la taille. Il n'y a qu'une poignée de tailles dans une /// grille bento, donc autant de textures, gardées pour la durée de l'app. /// static Texture2D PanelTextureFor(float widthMeters, float heightMeters) { var key = Mathf.RoundToInt(widthMeters * 1000f) * 10000 + Mathf.RoundToInt(heightMeters * 1000f); if (PanelTextures.TryGetValue(key, out var cached) && cached != null) return cached; var width = Mathf.Max(8, Mathf.RoundToInt(widthMeters * PixelsPerMeter)); var height = Mathf.Max(8, Mathf.RoundToInt(heightMeters * PixelsPerMeter)); var radius = CornerRadiusMeters * PixelsPerMeter; var border = BorderMeters * PixelsPerMeter; 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(); PanelTextures[key] = texture; return texture; } /// Distance signée au rectangle arrondi, en pixels. 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(); _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; } } }