using System; using System.Collections.Generic; using System.Linq; using Luneraria.View; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.UIElements; namespace Luneraria.UI { /// /// Portraits from the real game models (port of components/portrait_cache.gd). An offscreen /// camera renders one model at a time with the same orthographic three-quarter view, the same /// key, fill and rim light and a transparent background; every model is framed from its own /// bounds, so small creatures and the Schlundkrone fill the card alike. Each portrait is /// rendered once and kept, so cards never hold live views. Requests are queued and rendered a /// few per frame (Godot: one per frame). /// public sealed class LunePortraits : MonoBehaviour { public const int Small = 160; public const int Large = 400; const float Yaw = .42f; const float Pitch = .2f; /// Layer of the portrait stage; the portrait camera renders nothing else. public const int Layer = 31; const int PerFrame = 3; public static LunePortraits Shared { get; private set; } public event Action PortraitReady; public LuneUnitLibrary Library; /// Portraits rendered so far (tests and the performance log). public int Rendered { get; private set; } readonly Dictionary _textures = new Dictionary(); readonly List _queue = new List(); readonly List _baked = new List(); Camera _camera; readonly Light[] _lights = new Light[3]; public static string KeyFor(string modelId, bool large = false) { return modelId + (large ? "@L" : ""); } public static LunePortraits Create(LuneUnitLibrary library) { if (Shared != null) { Shared.Library = library; return Shared; } var host = new GameObject("LunePortraits"); host.transform.position = new Vector3(0f, -2000f, 0f); var portraits = host.AddComponent(); portraits.Library = library; return portraits; } void Awake() { Shared = this; var cameraObject = new GameObject("PortraitCamera"); cameraObject.transform.SetParent(transform, false); _camera = cameraObject.AddComponent(); _camera.enabled = false; _camera.orthographic = true; _camera.clearFlags = CameraClearFlags.SolidColor; _camera.backgroundColor = new Color(0f, 0f, 0f, 0f); _camera.cullingMask = 1 << Layer; _camera.allowMSAA = true; _camera.allowHDR = false; var data = cameraObject.AddComponent(); data.renderPostProcessing = false; data.renderShadows = false; // Key, fill and rim light of the reference. Godot rotation_degrees (x, y) with the light along // -Z (YXZ order) become Unity Euler (-x, 180 - y) with the light along +Z and x mirrored. Vector3[] angles = { new Vector3(38f, 212f, 0f), new Vector3(18f, 30f, 0f), new Vector3(8f, 140f, 0f) }; Color[] colors = { LuneUiTheme.Hex("ffe2bc"), LuneUiTheme.Hex("9fc4ff"), LuneUiTheme.Hex("c8d4e4") }; float[] energies = { 1.75f, .9f, .35f }; for (int index = 0; index < 3; index++) { var lightObject = new GameObject("PortraitLight" + index); lightObject.transform.SetParent(transform, false); lightObject.transform.rotation = Quaternion.Euler(angles[index]); Light light = lightObject.AddComponent(); light.type = LightType.Directional; light.color = colors[index]; light.intensity = energies[index]; light.shadows = LightShadows.None; light.cullingMask = 1 << Layer; light.enabled = false; _lights[index] = light; } } void OnDestroy() { if (Shared == this) Shared = null; foreach (Texture texture in _textures.Values) { if (texture is RenderTexture target) target.Release(); } } /// The cached portrait, or null while it is queued (PortraitReady follows). public Texture Portrait(string modelId, bool large = false) { if (string.IsNullOrEmpty(modelId)) return null; string key = KeyFor(modelId, large); if (_textures.TryGetValue(key, out Texture texture)) return texture; if (!_queue.Contains(key)) _queue.Add(key); return null; } public int Pending { get { return _queue.Count; } } void LateUpdate() { for (int count = 0; count < PerFrame && _queue.Count > 0; count++) { string key = _queue[0]; _queue.RemoveAt(0); RenderNow(key); } } /// Renders every queued portrait now (tests, captures). public void Flush() { while (_queue.Count > 0) { string key = _queue[0]; _queue.RemoveAt(0); RenderNow(key); } } void RenderNow(string key) { bool large = key.EndsWith("@L", StringComparison.Ordinal); string modelId = large ? key.Substring(0, key.Length - 2) : key; int edge = large ? Large : Small; GameObject model = Instantiate(modelId); if (model == null) { _textures[key] = null; PortraitReady?.Invoke(key); return; } var target = new RenderTexture(edge, edge, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4, name = "Portrait " + key }; Frame(model); // Only the portrait lights shine; the scene's lights and ambient come back afterwards. List others = FindObjectsByType(FindObjectsSortMode.None).Where(l => l.enabled && Array.IndexOf(_lights, l) < 0).ToList(); foreach (Light light in others) light.enabled = false; foreach (Light light in _lights) light.enabled = true; SphericalHarmonicsL2 ambient = RenderSettings.ambientProbe; var probe = new SphericalHarmonicsL2(); probe.AddAmbientLight(LuneUiTheme.Hex("a8b4c8").linear * .62f); RenderSettings.ambientProbe = probe; bool fog = RenderSettings.fog; RenderSettings.fog = false; _camera.targetTexture = target; _camera.Render(); _camera.targetTexture = null; RenderSettings.fog = fog; RenderSettings.ambientProbe = ambient; foreach (Light light in _lights) light.enabled = false; foreach (Light light in others) light.enabled = true; // Destroy takes effect at the end of the frame: hide the model now, or the next portrait // of the same frame would show it too. model.SetActive(false); Destroy(model); foreach (Mesh baked in _baked) Destroy(baked); _baked.Clear(); _textures[key] = target; Rendered++; PortraitReady?.Invoke(key); } GameObject Instantiate(string modelId) { if (Library == null) return null; // H1: "sylvara#blutmond" is the figure in a skin. string skin = ""; int mark = modelId.IndexOf('#'); if (mark >= 0) { skin = modelId.Substring(mark + 1); modelId = modelId.Substring(0, mark); } LuneUnitLibrary.UnitModel entry = Library.Find(modelId); if (entry == null || entry.Prefab == null) return null; GameObject model = UnityEngine.Object.Instantiate(entry.Prefab, transform, false); model.SetActive(true); foreach (Transform node in model.GetComponentsInChildren(true)) node.gameObject.layer = Layer; Library.Materials?.Dress(model, 2, skin); ShowDetail(model); // Skinned figures stand in their idle pose (Godot preview("idle", .4)). if (entry.Controller != null) { AnimationClip idle = entry.Controller.animationClips.FirstOrDefault(c => c.name == "idle") ?? entry.Controller.animationClips.FirstOrDefault(); Animator animator = model.GetComponentInChildren(); if (idle != null) idle.SampleAnimation(animator != null ? animator.gameObject : model, Mathf.Min(.4f, idle.length)); } Bake(model); return model; } /// /// Skinned figures are drawn as a baked mesh of their sampled pose (U5.5). With GPU skinning /// a skinned mesh rendered in the frame it was made in had no deformed vertices yet, and /// some cards stayed empty. A figure with a lane copy (_LOD0/_LOD1) shows its full mesh. /// void Bake(GameObject model) { foreach (LODGroup group in model.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(group); foreach (SkinnedMeshRenderer skinned in model.GetComponentsInChildren(true)) { skinned.enabled = false; if (skinned.name.EndsWith("_LOD1", StringComparison.Ordinal) || skinned.sharedMesh == null) continue; var baked = new Mesh { name = skinned.sharedMesh.name + " (portrait)" }; skinned.BakeMesh(baked, true); var copy = new GameObject(skinned.name + "_Portrait") { layer = Layer }; copy.transform.SetParent(skinned.transform, false); copy.AddComponent().sharedMesh = baked; var renderer = copy.AddComponent(); renderer.sharedMaterials = skinned.sharedMaterials; var block = new MaterialPropertyBlock(); for (int i = 0; i < renderer.sharedMaterials.Length; i++) { if (!skinned.HasPropertyBlock()) break; skinned.GetPropertyBlock(block, i); renderer.SetPropertyBlock(block, i); } _baked.Add(baked); } } /// /// The portrait is the close-up of a figure: its surface detail (the family's PBR set in the /// figure's own space, U5.4) is switched on here, while the field skips it for frame rate. /// Each material slot keeps its own properties; the block only adds the two switches. /// static void ShowDetail(GameObject model) { var block = new MaterialPropertyBlock(); foreach (Renderer renderer in model.GetComponentsInChildren(true)) { Material[] slots = renderer.sharedMaterials; for (int i = 0; i < slots.Length; i++) { if (slots[i] == null || !slots[i].HasProperty("_ObjectSpace") || slots[i].GetFloat("_ObjectSpace") < .5f) continue; renderer.GetPropertyBlock(block, i); block.SetFloat("_Quality", 1f); renderer.SetPropertyBlock(block, i); } } } /// Frames the model's bounds: the same view direction for every portrait, size from bounds. void Frame(GameObject model) { Bounds box = BoundsOf(model); Vector3 center = box.center; float radius = Mathf.Max(.3f, box.size.magnitude * .5f); // Godot view direction (sin yaw cos pitch, sin pitch, cos yaw cos pitch) in Unity's frame (x mirrored). var direction = new Vector3(-Mathf.Sin(Yaw) * Mathf.Cos(Pitch), Mathf.Sin(Pitch), Mathf.Cos(Yaw) * Mathf.Cos(Pitch)); float size = Mathf.Max(box.size.y, Mathf.Max(box.size.x, box.size.z) * .92f) * 1.12f; _camera.orthographicSize = size * .5f; _camera.nearClipPlane = .05f; _camera.farClipPlane = radius * 8f + 10f; _camera.transform.position = center + direction * (radius * 3f + 2f); _camera.transform.LookAt(center, Vector3.up); } static Bounds BoundsOf(GameObject model) { Renderer[] renderers = model.GetComponentsInChildren(true); if (renderers.Length == 0) return new Bounds(model.transform.position + Vector3.up * .8f, new Vector3(1f, 1.6f, 1f)); Bounds result = renderers[0].bounds; foreach (Renderer renderer in renderers) result.Encapsulate(renderer.bounds); return result; } } /// /// A real-model portrait on the neutral card background (dark slate gradient, soft floor; /// Godot LunePortraitRect). It asks LunePortraits for the texture and fills in when the /// render is done. Options: dimmed, a coloured ring, diagonal hatching (attack champions), /// a red cross (fallen units). /// public sealed class LunePortraitElement : VisualElement { public string ModelId { get; private set; } = ""; public bool Large { get; private set; } readonly VisualElement _image; readonly VisualElement _overlay; Texture _texture; bool _dimmed; Color _ring; Color _hatch; bool _crossed; bool _listening; public LunePortraitElement() { pickingMode = PickingMode.Ignore; style.overflow = Overflow.Hidden; style.flexShrink = 0f; generateVisualContent += DrawBackground; _image = new VisualElement { pickingMode = PickingMode.Ignore }; _image.style.position = Position.Absolute; Add(_image); _overlay = new VisualElement { pickingMode = PickingMode.Ignore }; _overlay.style.position = Position.Absolute; _overlay.style.left = 0f; _overlay.style.top = 0f; _overlay.style.right = 0f; _overlay.style.bottom = 0f; _overlay.generateVisualContent += DrawOverlay; Add(_overlay); RegisterCallback(_ => PlaceImage()); RegisterCallback(_ => Unlisten()); } public bool HasPortrait { get { return _texture != null; } } public void ShowModel(string id, bool large = false) { if (id == ModelId && large == Large && _texture != null) return; ModelId = id ?? ""; Large = large; LunePortraits cache = LunePortraits.Shared; _texture = cache != null ? cache.Portrait(ModelId, large) : null; if (_texture == null && cache != null && !_listening) { cache.PortraitReady += OnReady; _listening = true; } ApplyTexture(); } void OnReady(string key) { if (key != LunePortraits.KeyFor(ModelId, Large) || LunePortraits.Shared == null) return; _texture = LunePortraits.Shared.Portrait(ModelId, Large); ApplyTexture(); Unlisten(); } void Unlisten() { if (_listening && LunePortraits.Shared != null) LunePortraits.Shared.PortraitReady -= OnReady; _listening = false; } void ApplyTexture() { if (_texture is RenderTexture target) _image.style.backgroundImage = new StyleBackground(Background.FromRenderTexture(target)); else if (_texture is Texture2D texture) _image.style.backgroundImage = new StyleBackground(texture); else _image.style.backgroundImage = StyleKeyword.None; _image.style.unityBackgroundImageTintColor = _dimmed ? new Color(1f, 1f, 1f, .45f) : Color.white; MarkDirtyRepaint(); } void PlaceImage() { float edge = Mathf.Min(layout.width, layout.height); if (float.IsNaN(edge)) return; _image.style.left = (layout.width - edge) * .5f; _image.style.top = (layout.height - edge) * .5f; _image.style.width = edge; _image.style.height = edge; } public bool Dimmed { get { return _dimmed; } set { if (value == _dimmed) return; _dimmed = value; ApplyTexture(); } } public Color Ring { get { return _ring; } set { if (value == _ring) return; _ring = value; _overlay.MarkDirtyRepaint(); } } /// Diagonal hatching over the background (attack champions: temporary units). public Color Hatch { get { return _hatch; } set { if (value == _hatch) return; _hatch = value; MarkDirtyRepaint(); } } /// Fallen units: a red cross over the portrait. public bool Crossed { get { return _crossed; } set { if (value == _crossed) return; _crossed = value; _overlay.MarkDirtyRepaint(); } } void DrawBackground(MeshGenerationContext context) { var rect = new Rect(0f, 0f, layout.width, layout.height); if (rect.width <= 0f || rect.height <= 0f) return; LunePlates.Gradient(context, LuneHpBar.Corners(rect), rect, LuneUiTheme.Hex("202b37"), LuneUiTheme.Hex("0d1218")); Painter2D painter = context.painter2D; var floor = new Vector2[20]; for (int index = 0; index < 20; index++) { float angle = Mathf.PI * 2f * index / 20f; floor[index] = new Vector2(rect.width * .5f + Mathf.Cos(angle) * rect.width * .36f, rect.height * .88f + Mathf.Sin(angle) * rect.height * .06f); } LunePlates.Polygon(painter, floor, new Color(0f, 0f, 0f, .35f)); if (_hatch.a > 0f) { for (float x = -rect.height; x < rect.width; x += 9f) { var from = new Vector2(Mathf.Max(x, 0f), Mathf.Max(0f, -x)); var to = new Vector2(Mathf.Min(x + rect.height, rect.width), Mathf.Min(rect.height, rect.width - x)); LunePlates.Line(painter, from, to, _hatch, 2f); } } if (_texture == null) { float inset = Mathf.Min(rect.width, rect.height) * .22f; LuneGlyphs.Draw(painter, "human", new Rect(inset, inset, rect.width - inset * 2f, rect.height - inset * 2f), new Color(1f, 1f, 1f, .08f)); } } void DrawOverlay(MeshGenerationContext context) { Painter2D painter = context.painter2D; float width = _overlay.layout.width; float height = _overlay.layout.height; if (_ring.a > 0f) LunePlates.Outline(painter, LuneHpBar.Corners(new Rect(1f, 1f, width - 2f, height - 2f)), _ring, 2f); if (_crossed) { float inset = Mathf.Min(width, height) * .22f; var color = new Color(1f, .42f, .36f, .85f); LunePlates.Line(painter, new Vector2(inset, inset), new Vector2(width - inset, height - inset), color, 2f); LunePlates.Line(painter, new Vector2(width - inset, inset), new Vector2(inset, height - inset), color, 2f); } } } }