Lane-Kopie mit 4.998 statt 20.714 Dreiecken aus dem Generator (Nähte verschweißt, Collapse, Skin-Gewichte bleiben), LODGroup aus den Namen, Wechsel unter rund 10 % Bildhöhe; Porträts erzwingen die volle Fassung. Stresstest 35 Kohlenbeißer: Mid 109,8 -> 120,2 fps, Karte 93,9 -> 103,8 (starr 133,3 / 106,4). Tests grün, Fingerabdrücke unverändert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
458 lines
19 KiB
C#
458 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Luneraria.View;
|
|
using UnityEngine;
|
|
using UnityEngine.Rendering;
|
|
using UnityEngine.UIElements;
|
|
|
|
namespace Luneraria.UI
|
|
{
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public sealed class LunePortraits : MonoBehaviour
|
|
{
|
|
public const int Small = 160;
|
|
public const int Large = 400;
|
|
const float Yaw = .42f;
|
|
const float Pitch = .2f;
|
|
/// <summary>Layer of the portrait stage; the portrait camera renders nothing else.</summary>
|
|
public const int Layer = 31;
|
|
const int PerFrame = 3;
|
|
|
|
public static LunePortraits Shared { get; private set; }
|
|
public event Action<string> PortraitReady;
|
|
public LuneUnitLibrary Library;
|
|
/// <summary>Portraits rendered so far (tests and the performance log).</summary>
|
|
public int Rendered { get; private set; }
|
|
|
|
readonly Dictionary<string, Texture> _textures = new Dictionary<string, Texture>();
|
|
readonly List<string> _queue = new List<string>();
|
|
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<LunePortraits>();
|
|
portraits.Library = library;
|
|
return portraits;
|
|
}
|
|
|
|
void Awake()
|
|
{
|
|
Shared = this;
|
|
var cameraObject = new GameObject("PortraitCamera");
|
|
cameraObject.transform.SetParent(transform, false);
|
|
_camera = cameraObject.AddComponent<Camera>();
|
|
_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<UnityEngine.Rendering.Universal.UniversalAdditionalCameraData>();
|
|
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>();
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>The cached portrait, or null while it is queued (PortraitReady follows).</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Renders every queued portrait now (tests, captures).</summary>
|
|
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<Light> others = FindObjectsByType<Light>(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);
|
|
_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<Transform>(true))
|
|
node.gameObject.layer = Layer;
|
|
Library.Materials?.Dress(model, 2);
|
|
// A figure with a lane copy (U5.5) shows its full mesh: the portrait is a close-up, and
|
|
// the LOD choice of a model rendered in the frame it was made in left the card empty.
|
|
foreach (LODGroup group in model.GetComponentsInChildren<LODGroup>(true))
|
|
group.ForceLOD(0);
|
|
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<Animator>();
|
|
if (idle != null)
|
|
idle.SampleAnimation(animator != null ? animator.gameObject : model, Mathf.Min(.4f, idle.length));
|
|
}
|
|
return model;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
static void ShowDetail(GameObject model)
|
|
{
|
|
var block = new MaterialPropertyBlock();
|
|
foreach (Renderer renderer in model.GetComponentsInChildren<Renderer>(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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Frames the model's bounds: the same view direction for every portrait, size from bounds.</summary>
|
|
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<Renderer>(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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<GeometryChangedEvent>(_ => PlaceImage());
|
|
RegisterCallback<DetachFromPanelEvent>(_ => 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>Diagonal hatching over the background (attack champions: temporary units).</summary>
|
|
public Color Hatch
|
|
{
|
|
get { return _hatch; }
|
|
set
|
|
{
|
|
if (value == _hatch)
|
|
return;
|
|
_hatch = value;
|
|
MarkDirtyRepaint();
|
|
}
|
|
}
|
|
|
|
/// <summary>Fallen units: a red cross over the portrait.</summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|