champions-of-luneraria/Assets/Luneraria/Editor/Art/LuneAssetPreview.cs
Martin Stickelbroeck c386206876 U5.5-pilot Figuren: Meshy-Kohlenbeißer, Lava-Glut, GPU-Skinning
Pilot für eine generierte Figur: der Kohlenbeißer g01 kommt als Meshy-Ergebnis
in den Port und ersetzt dort das Phase-4-Modell. Eine Figur genügt, um Pipeline,
Bild und Leistung zu bewerten; Anatomie, Gesichter und Haare bleiben
Künstler-Sculpts oder lizenzierte Packs (Vorgabe vom 1. Oktober).

- Meshy-Pipeline unter tools/meshy/: API-Client (meshy.py, Ledger, Schlüssel
  nie geloggt) und Blender-Generator (build_figure.py) aus den unveränderten
  Downloads in assets-source/meshy/g01/. Export über den Manifest-Eintrag
  kohlenbeisser mit "replaces": "g01"; LuneUnitLibraryBuilder hängt die Figur
  zusätzlich unter der Godot-ID ein.
- UV-Material AI_Kohlenbeisser (Familie authored): die Karten der Figur selbst,
  Lava-Glut aus der Albedo-Alpha (Meshy 7.1 ohne Emission-Map) mit _Glow 1,25.
- Schatten ohne Normalen-Versatz (LuneShadowBias, _ShadowNormalScale 0): die
  UV-Naht-Kopien tragen geknickte Normalen; URP ApplyShadowBias zog helle
  Nähte durch den Schatten. Die Figur wirft nur mit Tiefen-Bias.
- GPU-Batched-Skinning (PlayerSettings.meshDeformation, LuneProjectSetup):
  35 Meshy-Kohlenbeißer kosteten mit CPU-Skinning rund 1 ms Haupt- und
  1,7 ms Render-Thread je Bild.
- LuneBenchmark: -luneBenchmarkRound und -luneBenchmarkArmy (Figuren-Stress).
  LuneAssetPreview: LUNE_ONLY für Vorher/Nachher, Trefferframe aus der
  Kontaktzeit des Manifests. FigureViewsTests (Explicit) für die vier
  Spielmomente der Figur.
- Tests: EditMode 29/29, PlayMode 14/14 (6 Explicit), Verify 718/0,
  Player-Selbsttest 30/30, Kerntests 457/457, Fingerabdrücke unverändert
  (DCE95C96556F3A1A, 7B8E43AEFC0D60C5).
- Leistung (A/B im selben Fenster, 35x g01): Meshy -21 % Mid (109,7 zu 138,8)
  und -13 % Karte (91,7 zu 105,6) gegenüber Phase-4. GPU-Skinning hebt Mid
  um 7-27 % und entlastet Haupt- und Render-Thread. Standard-Partie ab Runde 21
  ohne g01 im Bild: drei Varianten bis 6 %.
- Doku: U5-VISUAL (U5.5), PORT-QA, PORT-STATUS, KNOWN-BEHAVIOR-DIFFERENCES;
  Bilder screenshots/u5.5/ und u5.5-gpu/, Rohdaten logs/u5.5/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:14:59 +02:00

289 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
namespace Luneraria.EditorTools
{
/// <summary>
/// Renders every pipeline asset in a neutral preview scene (URP, one sun, 3/4 front view) to
/// docs/port/screenshots/u2a/. Buildings show their intact state; the tower also its ruin;
/// rigged figures their bind pose and the hit frame of attack_a.
/// Unity -batchmode -projectPath . -executeMethod Luneraria.EditorTools.LuneAssetPreview.RenderAll
/// U5.5: like the sheets, RenderAll takes LUNE_EVIDENCE (folder) and LUNE_DRESS=1 (the game's
/// materials); LUNE_ONLY=g01,kohlenbeisser renders just those ids (before/after of a figure).
/// </summary>
public static class LuneAssetPreview
{
const int Width = 1600;
const int Height = 1000;
public static void RenderAll()
{
int code = 0;
try
{
string evidence = Environment.GetEnvironmentVariable("LUNE_EVIDENCE");
string only = Environment.GetEnvironmentVariable("LUNE_ONLY");
var wanted = string.IsNullOrEmpty(only) ? null : new HashSet<string>(only.Split(','));
string folder = Path.Combine(Path.GetDirectoryName(Application.dataPath), "docs/port/screenshots", string.IsNullOrEmpty(evidence) ? "u2a" : evidence);
Directory.CreateDirectory(folder);
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var sun = new GameObject("Sun").AddComponent<Light>();
sun.type = LightType.Directional;
sun.intensity = 1.6f;
sun.color = new Color(1f, .89f, .74f);
sun.shadows = LightShadows.Soft;
sun.transform.rotation = Quaternion.Euler(48f, 150f, 0f);
RenderSettings.ambientMode = AmbientMode.Trilight;
RenderSettings.ambientSkyColor = new Color(.55f, .6f, .68f);
RenderSettings.ambientEquatorColor = new Color(.42f, .42f, .44f);
RenderSettings.ambientGroundColor = new Color(.2f, .19f, .18f);
var ground = GameObject.CreatePrimitive(PrimitiveType.Plane);
ground.transform.localScale = new Vector3(20f, 1f, 20f);
var camera = new GameObject("PreviewCamera").AddComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(.16f, .18f, .21f);
camera.fieldOfView = 30f;
// Loaded after the new preview scene: opening a scene unloads assets nothing references.
var materials = Environment.GetEnvironmentVariable("LUNE_DRESS") == "1"
? AssetDatabase.LoadAssetAtPath<Luneraria.View.LuneMaterialSet>("Assets/Luneraria/Materials/LuneMaterials.asset") : null;
foreach (string model in ExportReport.AllModels("Assets/Luneraria/Art").OrderBy(p => p, StringComparer.Ordinal))
{
ExportReport report = ExportReport.ForModel(model);
if (wanted != null && !wanted.Contains(report.Id))
continue;
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(model);
foreach (var shot in Shots(report))
{
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
shot.Value(instance);
if (materials != null)
materials.Dress(instance, 2);
Render(camera, instance, Path.Combine(folder, report.Id + shot.Key + ".png"));
UnityEngine.Object.DestroyImmediate(instance);
}
}
}
catch (Exception error)
{
Debug.LogError("LUNE_PREVIEW failed: " + error);
code = 1;
}
EditorApplication.Exit(code);
}
/// <summary>
/// One contact sheet per asset group (docs/port/screenshots/u2a-batch/&lt;group&gt;.png), six
/// columns, assets sorted by id; the order is written to docs/port/logs/u2a/sheets.json.
/// U5.4: LUNE_EVIDENCE names another folder; LUNE_DRESS=1 dresses every model with the
/// game's material set (as LuneUnitView and the portraits do), so the sheets show the
/// figures as they appear in the match.
/// </summary>
public static void RenderSheets()
{
int code = 0;
try
{
const int tileWidth = 400, tileHeight = 300, columns = 6;
string evidence = Environment.GetEnvironmentVariable("LUNE_EVIDENCE");
bool dress = Environment.GetEnvironmentVariable("LUNE_DRESS") == "1";
string folder = Path.Combine(Path.GetDirectoryName(Application.dataPath), "docs/port/screenshots", string.IsNullOrEmpty(evidence) ? "u2a-batch" : evidence);
Directory.CreateDirectory(folder);
Camera camera = Stage();
// Loaded after the new preview scene: opening a scene unloads assets nothing references.
var materials = dress ? AssetDatabase.LoadAssetAtPath<Luneraria.View.LuneMaterialSet>("Assets/Luneraria/Materials/LuneMaterials.asset") : null;
Debug.Log("LUNE_PREVIEW sheets to " + folder + (materials != null ? ", dressed with " + materials.Entries.Count + " materials" : ", imported materials"));
var index = new Newtonsoft.Json.Linq.JObject();
var reports = ExportReport.AllModels("Assets/Luneraria/Art").Select(ExportReport.ForModel).Where(r => r != null);
foreach (var group in reports.GroupBy(r => (string)r.Data["group"]).OrderBy(g => g.Key, StringComparer.Ordinal))
{
var list = group.OrderBy(r => r.Id, StringComparer.Ordinal).ToList();
int rows = (list.Count + columns - 1) / columns;
var sheet = new Texture2D(columns * tileWidth, rows * tileHeight, TextureFormat.RGB24, false);
var fill = new Color32[sheet.width * sheet.height];
for (int i = 0; i < fill.Length; i++)
fill[i] = new Color32(28, 31, 36, 255);
sheet.SetPixels32(fill);
for (int i = 0; i < list.Count; i++)
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(list[i].Path.Replace(".export.json", ".fbx"));
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
Shots(list[i]).First().Value(instance);
if (materials != null)
materials.Dress(instance, 2);
Color[] tile = RenderTile(camera, instance, tileWidth, tileHeight);
int column = i % columns;
int row = rows - 1 - i / columns;
sheet.SetPixels(column * tileWidth, row * tileHeight, tileWidth, tileHeight, tile);
UnityEngine.Object.DestroyImmediate(instance);
}
sheet.Apply();
File.WriteAllBytes(Path.Combine(folder, group.Key + ".png"), sheet.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(sheet);
index[group.Key] = new Newtonsoft.Json.Linq.JArray(list.Select(r => r.Id));
Debug.Log("LUNE_PREVIEW sheet " + group.Key + ": " + list.Count + " assets");
}
if (string.IsNullOrEmpty(evidence))
{
string logs = Path.Combine(Path.GetDirectoryName(Application.dataPath), "docs/port/logs/u2a");
Directory.CreateDirectory(logs);
File.WriteAllText(Path.Combine(logs, "sheets.json"), index.ToString() + "\n");
}
}
catch (Exception error)
{
Debug.LogError("LUNE_PREVIEW failed: " + error);
code = 1;
}
EditorApplication.Exit(code);
}
static Camera Stage()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var sun = new GameObject("Sun").AddComponent<Light>();
sun.type = LightType.Directional;
sun.intensity = 1.6f;
sun.color = new Color(1f, .89f, .74f);
sun.shadows = LightShadows.Soft;
sun.transform.rotation = Quaternion.Euler(48f, 150f, 0f);
RenderSettings.ambientMode = AmbientMode.Trilight;
RenderSettings.ambientSkyColor = new Color(.55f, .6f, .68f);
RenderSettings.ambientEquatorColor = new Color(.42f, .42f, .44f);
RenderSettings.ambientGroundColor = new Color(.2f, .19f, .18f);
var camera = new GameObject("PreviewCamera").AddComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(.16f, .18f, .21f);
camera.fieldOfView = 30f;
return camera;
}
static Color[] RenderTile(Camera camera, GameObject root, int width, int height)
{
var renderers = root.GetComponentsInChildren<Renderer>().Where(r => r.enabled).ToList();
Bounds bounds = renderers[0].bounds;
foreach (Renderer renderer in renderers)
bounds.Encapsulate(renderer.bounds);
Vector3 direction = new Vector3(.62f, .42f, 1f).normalized;
float radius = bounds.extents.magnitude;
float distance = radius / Mathf.Sin(camera.fieldOfView * .5f * Mathf.Deg2Rad) * .92f;
camera.aspect = (float)width / height;
camera.transform.position = bounds.center + direction * distance;
camera.transform.LookAt(bounds.center);
camera.nearClipPlane = Mathf.Max(.01f, distance - radius * 2f);
camera.farClipPlane = distance + radius * 4f;
var target = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32) { antiAliasing = 8 };
camera.targetTexture = target;
camera.Render();
RenderTexture.active = target;
var image = new Texture2D(width, height, TextureFormat.RGB24, false);
image.ReadPixels(new Rect(0, 0, width, height), 0, 0);
image.Apply();
Color[] pixels = image.GetPixels();
RenderTexture.active = null;
camera.targetTexture = null;
UnityEngine.Object.DestroyImmediate(target);
UnityEngine.Object.DestroyImmediate(image);
return pixels;
}
static IEnumerable<KeyValuePair<string, Action<GameObject>>> Shots(ExportReport report)
{
string group = (string)report.Data["group"];
if (group == "Buildings")
{
yield return Shot("", go => Show(go, n => !n.StartsWith("Upper_Damaged") && !n.StartsWith("Debris") && !n.StartsWith("Ruin")));
if (report.Id == "grenzwacht")
yield return Shot("-ruin", go => Show(go, n => n.StartsWith("Ruin")));
}
else if (group == "Environment")
{
yield return Shot("", Row);
}
else if (report.Skinned)
{
// The hit frame: the clip's contact time, 0.9 s where the Blender action names none.
double contact = report.Clips.Where(c => (string)c["name"] == "attack_a").Select(c => (double)c["contact_seconds"]).DefaultIfEmpty(-1.0).First();
float hit = contact > 0.0 ? (float)contact : .9f;
yield return Shot("", go => Sample(go, "idle", 0f));
yield return Shot("-attack_a", go => Sample(go, "attack_a", hit));
}
else
{
yield return Shot("", go => { });
}
}
static KeyValuePair<string, Action<GameObject>> Shot(string suffix, Action<GameObject> prepare)
{
return new KeyValuePair<string, Action<GameObject>>(suffix, prepare);
}
static void Show(GameObject root, Func<string, bool> visible)
{
foreach (Renderer renderer in root.GetComponentsInChildren<Renderer>(true))
renderer.enabled = visible(renderer.gameObject.name);
}
/// <summary>The prop library keeps every prop at the origin; the preview sets them out in a grid.</summary>
static void Row(GameObject root)
{
const int columns = 5;
const float cell = 7.5f;
int index = 0;
foreach (Transform child in root.transform.Cast<Transform>().OrderBy(t => t.name, StringComparer.Ordinal).ToList())
{
var renderer = child.GetComponent<Renderer>();
if (renderer == null)
continue;
Vector3 center = renderer.bounds.center;
var slot = new Vector3((index % columns) * cell, 0f, -(index / columns) * cell);
child.position += slot - new Vector3(center.x, 0f, center.z);
index++;
}
}
static void Sample(GameObject root, string clipName, float seconds)
{
string path = AssetDatabase.GetAssetPath(PrefabUtility.GetCorrespondingObjectFromSource(root));
AnimationClip clip = AssetDatabase.LoadAllAssetsAtPath(path).OfType<AnimationClip>().FirstOrDefault(c => c.name == clipName);
if (clip != null)
clip.SampleAnimation(root, seconds);
}
static void Render(Camera camera, GameObject root, string file)
{
var renderers = root.GetComponentsInChildren<Renderer>().Where(r => r.enabled).ToList();
Bounds bounds = renderers[0].bounds;
foreach (Renderer renderer in renderers)
bounds.Encapsulate(renderer.bounds);
// 3/4 front view: the models face +Z, the camera stands in front, to the right, above.
Vector3 direction = new Vector3(.62f, .42f, 1f).normalized;
float radius = bounds.extents.magnitude;
float distance = radius / Mathf.Sin(camera.fieldOfView * .5f * Mathf.Deg2Rad) * .92f;
camera.aspect = (float)Width / Height;
camera.transform.position = bounds.center + direction * distance;
camera.transform.LookAt(bounds.center);
camera.nearClipPlane = Mathf.Max(.01f, distance - radius * 2f);
camera.farClipPlane = distance + radius * 4f;
var target = new RenderTexture(Width, Height, 24, RenderTextureFormat.ARGB32) { antiAliasing = 8 };
camera.targetTexture = target;
camera.Render();
RenderTexture.active = target;
var image = new Texture2D(Width, Height, TextureFormat.RGB24, false);
image.ReadPixels(new Rect(0, 0, Width, Height), 0, 0);
image.Apply();
File.WriteAllBytes(file, image.EncodeToPNG());
RenderTexture.active = null;
camera.targetTexture = null;
UnityEngine.Object.DestroyImmediate(target);
UnityEngine.Object.DestroyImmediate(image);
Debug.Log("LUNE_PREVIEW wrote " + file);
}
}
}