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;
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);
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);
}
}
}
}