champions-of-luneraria/Assets/Luneraria/Editor/World/LuneUnitLibraryBuilder.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

197 lines
10 KiB
C#

using System.Collections.Generic;
using System.IO;
using System.Linq;
using Luneraria.View;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using UnityEngine.Rendering;
namespace Luneraria.EditorTools
{
/// <summary>
/// Builds what the unit views need (U2C): a one-mesh version of the rigid Kohlenbeißer (131
/// named parts combined per material, like Godot's rig groups), animator controllers with the
/// Blender clips of the rigged figures, team ring and bar meshes and their materials.
/// </summary>
public static class LuneUnitLibraryBuilder
{
public const string LibraryPath = "Assets/Luneraria/Settings/LuneUnits.asset";
const string Generated = "Assets/Luneraria/Art/Generated";
const string Art = "Assets/Luneraria/Art/";
public static LuneUnitLibrary Build(LuneMaterialSet materials, LuneGround ground)
{
Directory.CreateDirectory(Generated);
AssetDatabase.Refresh();
var library = AssetDatabase.LoadAssetAtPath<LuneUnitLibrary>(LibraryPath);
if (library == null)
{
library = ScriptableObject.CreateInstance<LuneUnitLibrary>();
AssetDatabase.CreateAsset(library, LibraryPath);
}
library.Materials = materials;
library.Ground = ground;
library.Models = Models();
library.RingRound = Keep(Ring("RingRound", 40, 0f));
library.RingDiamond = Keep(Ring("RingDiamond", 4, 45f));
library.RingHexagon = Keep(Ring("RingHexagon", 6, 0f));
library.OwnerRingRound = Keep(Ring("OwnerRingRound", 40, 0f, 1f, 1.12f));
library.OwnerRingDiamond = Keep(Ring("OwnerRingDiamond", 4, 45f, 1f, 1.12f));
library.Quad = Keep(Quad());
for (int team = 0; team < 3; team++)
{
library.TeamRing[team] = Marker("Ring_" + team, LuneMaterialFactory.TeamColor[team], team == 2 ? .6f : 1.4f, false);
library.BarFill[team] = Marker("BarFill_" + team, Color.Lerp(LuneMaterialFactory.TeamColor[team], Color.white, .15f), 1f, true);
library.Bolt[team] = Marker("Bolt_" + team, LuneMaterialFactory.TeamLight[team], 3f, false);
library.BoltLight[team] = LuneMaterialFactory.TeamLight[team];
}
library.HeroRing = Marker("Ring_Hero", LuneMaterialFactory.Hex("f2c96b"), 2.2f, false);
for (int team = 0; team < 2; team++)
{
library.OwnerRing[team] = Marker("Ring_Owner_" + team, LuneMaterialFactory.TeamColor[team], 3f, false);
library.AncientBarFill[team] = Marker("AncientBarFill_" + team, Color.Lerp(LuneMaterialFactory.TeamLight[team], Color.white, .15f), 1f, true);
}
library.BarBack = Marker("BarBack", new Color(0f, 0f, 0f, .7f), 1f, true);
EditorUtility.SetDirty(library);
AssetDatabase.SaveAssets();
return library;
}
/// <summary>Groups whose models stand for units; buildings and environment have their own views.</summary>
static readonly string[] UnitGroups = { "Units", "Enemies", "Characters", "Peoples" };
/// <summary>
/// One entry per exported unit model (tools/blender/export_manifest.json): rigid models by
/// their id, skinned figures with an animator controller. A skinned figure also stands in
/// for the Godot id it replaced (w04 is Ringborke, worker is the Arbeiter, ...) or the id
/// it replaces in the port ("replaces": the Meshy Kohlenbeißer of U5.5 for g01).
/// </summary>
static List<LuneUnitLibrary.UnitModel> Models()
{
var manifest = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(Path.Combine(Path.GetDirectoryName(Application.dataPath), "tools/blender/export_manifest.json")));
var models = new List<LuneUnitLibrary.UnitModel>();
var aliases = new List<LuneUnitLibrary.UnitModel>();
foreach (var asset in manifest["assets"])
{
string id = (string)asset["id"];
string group = (string)asset["group"];
if (!UnitGroups.Contains(group))
continue;
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(Art + group + "/" + id + ".fbx");
if (prefab == null)
continue;
bool skinned = (string)asset["kind"] == "skinned";
var model = new LuneUnitLibrary.UnitModel { Id = id, Prefab = prefab, Controller = skinned ? Controller(id, Art + group + "/" + id + ".fbx") : null };
models.Add(model);
string godotId = (string)asset["godot_manifest"]?["id"] ?? (string)asset["replaces"];
if (skinned && godotId != null && godotId != id)
aliases.Add(new LuneUnitLibrary.UnitModel { Id = godotId, Prefab = prefab, Controller = model.Controller });
}
// Skinned figures replace the rigid model of the same Godot id.
foreach (LuneUnitLibrary.UnitModel alias in aliases)
{
models.RemoveAll(m => m.Id == alias.Id);
models.Add(alias);
}
return models.OrderBy(m => m.Id, System.StringComparer.Ordinal).ToList();
}
/// <summary>One state per clip of the model (named like the Blender action), idle first.</summary>
static RuntimeAnimatorController Controller(string id, string fbx)
{
// Updated in place (stable GUID, no duplicates): existing states are replaced.
string path = Generated + "/" + id + ".controller";
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(path) ?? AnimatorController.CreateAnimatorControllerAtPath(path);
AnimatorStateMachine machine = controller.layers[0].stateMachine;
AnimationClip[] clips = AssetDatabase.LoadAllAssetsAtPath(fbx).OfType<AnimationClip>().Where(c => !c.name.StartsWith("__preview__"))
.OrderBy(c => c.name == "idle" ? 0 : 1).ThenBy(c => c.name).ToArray();
// Unchanged clips: keep the states (rebuilding them gave new file ids on every build).
ChildAnimatorState[] current = machine.states;
bool same = current.Length == clips.Length && current.Select((child, index) => child.state.name == clips[index].name && child.state.motion == clips[index]).All(match => match);
if (same)
return controller;
foreach (ChildAnimatorState child in machine.states.ToArray())
machine.RemoveState(child.state);
foreach (AnimationClip clip in clips)
{
AnimatorState state = machine.AddState(clip.name);
state.motion = clip;
if (clip.name == "idle")
machine.defaultState = state;
}
EditorUtility.SetDirty(controller);
return controller;
}
/// <summary>Flat ring on the ground (Godot: TorusMesh inner .42, outer .52, flattened).</summary>
static Mesh Ring(string name, int sides, float offsetDegrees, float inner = .42f, float outer = .52f)
{
var vertices = new List<Vector3>();
var triangles = new List<int>();
for (int i = 0; i <= sides; i++)
{
float angle = (offsetDegrees + 360f * i / sides) * Mathf.Deg2Rad;
var direction = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle));
vertices.Add(direction * inner);
vertices.Add(direction * outer);
if (i < sides)
{
int a = i * 2;
triangles.AddRange(new[] { a, a + 1, a + 3, a, a + 3, a + 2 });
}
}
var mesh = new Mesh { name = name };
mesh.SetVertices(vertices);
mesh.SetTriangles(triangles, 0);
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
static Mesh Quad()
{
var mesh = new Mesh { name = "MarkerQuad" };
mesh.SetVertices(new List<Vector3> { new Vector3(-.5f, -.5f, 0f), new Vector3(.5f, -.5f, 0f), new Vector3(-.5f, .5f, 0f), new Vector3(.5f, .5f, 0f) });
mesh.SetTriangles(new[] { 0, 2, 1, 2, 3, 1 }, 0);
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
static Mesh Keep(Mesh mesh)
{
string path = Generated + "/" + mesh.name + ".asset";
var existing = AssetDatabase.LoadAssetAtPath<Mesh>(path);
if (existing == null)
{
AssetDatabase.CreateAsset(mesh, path);
return mesh;
}
EditorUtility.CopySerialized(mesh, existing);
EditorUtility.SetDirty(existing);
return existing;
}
/// <summary>Unshaded marker colour (Godot _flat with emission energy); bars draw on top.</summary>
static Material Marker(string name, Color srgb, float energy, bool onTop)
{
string path = LuneMaterialFactory.Folder + "/Markers/" + name + ".mat";
Directory.CreateDirectory(Path.GetDirectoryName(path));
Shader shader = Shader.Find("Luneraria/Unlit");
var material = AssetDatabase.LoadAssetAtPath<Material>(path);
if (material == null)
{
material = new Material(shader);
AssetDatabase.CreateAsset(material, path);
}
material.shader = shader;
Color linear = srgb.linear * energy;
material.SetColor("_Color", new Color(linear.r, linear.g, linear.b, srgb.a));
material.SetFloat("_ZTest", onTop ? (float)CompareFunction.Always : (float)CompareFunction.LessEqual);
material.enableInstancing = true;
EditorUtility.SetDirty(material);
return material;
}
}
}