Project_WL/AgentScripts/WL788_Measure.cs

447 lines
22 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// WL788_Measure.cs — PD 지시 #788 크레센트 로컬 프레임 실측 (에디트 모드 · Assets 무수정)
// unity command run_script --file AgentScripts/WL788_Measure.cs --entry WL788_Measure.Dump --args '["Effect_Slash_10101_1"]'
// unity command run_script --file AgentScripts/WL788_Measure.cs --entry WL788_Measure.DumpMany --args '["Effect_Slash_10101_1,Effect_Slash_10501_1"]'
//
// 측정 방법 (구 방식 "메시 바운즈 최소축" 의 한계를 넘기 위한 정밀화):
// ① 프리팹을 프리뷰 씬에 인스턴스(현재 씬 무영향 · isDirty 유발 없음)
// ② 크레센트 이미터를 수명 30% 지점까지 Simulate → ParticleSystemRenderer.BakeMesh
// = 정렬(alignment) · startRotation · startSize · 스트레치가 모두 반영된 **실제 렌더 지오메트리**
// ③ 베이크 메시의 삼각형을 UV 로 리샘플하며 머티리얼 텍스처의 알파×휘도(잉크)를 가중치로 삼아
// 루트 로컬 공간의 "보이는 픽셀 구름" 을 만든다 (크레센트가 메시 모양이든 텍스처든 동일 처리)
// ④ 가중 PCA → 평면 법선 · 평면 투영 후 Kasa 원 적합 → 호 중심 · 내/외 반지름 · 각도 범위
// → 시위 방향(start→end) · 볼록 방향(중심→호 중점) 확정
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
public static class WL788_Measure
{
// ── 결과 구조 ────────────────────────────────────────────────────────────
public struct Frame
{
public string emitter;
public Vector3 normal; // 루트 로컬 — 크레센트 평면 법선
public Vector3 chord; // 루트 로컬 — 호 시작→끝 (시위) 방향, 정규화
public Vector3 convex; // 루트 로컬 — 호 중심→호 중점 (볼록) 방향, 정규화
public Vector3 center; // 루트 로컬 — 호의 원 중심
public float innerR, outerR, midR;
public float arcDeg; // 호가 덮는 각도
public float ink; // 잉크 총량(가중치 합)
public float planarity; // 평면성 = 1 - (최소분산/중간분산). 1 에 가까울수록 평평
public float circleRms; // 원 적합 잔차 RMS / midR
public int samples;
public string inkMode;
public bool oriented; // 방향 정보를 담고 있는가(카메라 정렬이 아닌가)
public string tile; // 스프라이트 시트에서 고른 타일
}
public static object DumpMany(string csv)
{
var sb = new StringBuilder();
foreach (var n in csv.Split(',')) { sb.AppendLine(Dump(n.Trim()) as string); }
return sb.ToString();
}
public static object Dump(string prefabName)
{
string path = FindPrefab(prefabName);
if (path == null) return "prefab not found: " + prefabName;
var src = AssetDatabase.LoadAssetAtPath<GameObject>(path);
var sb = new StringBuilder();
sb.AppendLine("== " + prefabName + " " + path);
sb.AppendLine(" rootScale=" + src.transform.localScale.ToString("F3") + " rootEuler=" + src.transform.localEulerAngles.ToString("F1"));
Scene preview = default(Scene);
GameObject inst = null;
GameObject camGo = null;
try
{
preview = EditorSceneManager.NewPreviewScene();
inst = Object.Instantiate(src);
inst.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
SceneManager.MoveGameObjectToScene(inst, preview);
camGo = new GameObject("wl788cam");
SceneManager.MoveGameObjectToScene(camGo, preview);
var cam = camGo.AddComponent<Camera>();
camGo.transform.SetPositionAndRotation(new Vector3(0f, 0f, -10f), Quaternion.identity);
cam.enabled = false;
var root = inst.transform;
foreach (var ps in inst.GetComponentsInChildren<ParticleSystem>(true))
{
var psr = ps.GetComponent<ParticleSystemRenderer>();
if (psr == null) continue;
var main = ps.main;
string mat = psr.sharedMaterial != null ? psr.sharedMaterial.name : "-";
var tex = GetTex(psr.sharedMaterial);
var tsa = ps.textureSheetAnimation;
sb.AppendLine(string.Format(
" -- {0,-16} on={1} render={2}{3} align={4} size={5:F2} life={6:F2} rot3D={7} rot={8:F0} sim={9} mat={10} tex={11}{12}",
ps.gameObject.name, psr.enabled, psr.renderMode,
psr.renderMode == ParticleSystemRenderMode.Mesh && psr.mesh != null ? "(" + psr.mesh.name + " v" + psr.mesh.vertexCount + ")" : "",
psr.alignment, main.startSize.constantMax, main.startLifetime.constantMax,
main.startRotation3D, main.startRotation.constantMax * Mathf.Rad2Deg, main.simulationSpace, mat,
tex != null ? tex.name + " " + tex.width + "x" + tex.height : "-",
tsa.enabled ? string.Format(" sheet={0}x{1}", tsa.numTilesX, tsa.numTilesY) : ""));
if (!psr.enabled) continue;
Frame f;
if (!MeasureEmitter(ps, psr, root, cam, out f)) { sb.AppendLine(" (측정 불가 — 잉크 없음)"); continue; }
sb.AppendLine(Format(f));
}
}
finally
{
if (camGo != null) Object.DestroyImmediate(camGo);
if (inst != null) Object.DestroyImmediate(inst);
if (preview.IsValid()) EditorSceneManager.ClosePreviewScene(preview);
}
return sb.ToString();
}
static string Format(Frame f)
{
return string.Format(
" FRAME n={0} chord={1} convex={2} center={3}\n" +
" innerR={4:F3} outerR={5:F3} midR={6:F3} arc={7:F1}deg ink={8:F0} planar={9:F3} circRms={10:F3} pts={11}",
f.normal.ToString("F3"), f.chord.ToString("F3"), f.convex.ToString("F3"), f.center.ToString("F3"),
f.innerR, f.outerR, f.midR, f.arcDeg, f.ink, f.planarity, f.circleRms, f.samples)
+ " " + f.inkMode + (string.IsNullOrEmpty(f.tile) ? "" : " tile=" + f.tile);
}
// ── 이미터 1개 실측 (결정론적) ───────────────────────────────────────────
// Simulate + BakeMesh 는 **여러 파티클의 합집합**이라 shape 산포·랜덤 시드 때문에
// 실행마다 호 중심·시위 방향이 달라진다(실측 2026-09-07: 같은 이미터 2회 측정에서
// 법선은 4° 이내로 같았지만 chord 가 평면 안에서 60° 이상 회전). 캘리브레이션 값은
// 재현 가능해야 하므로 **파티클 1개의 소스 지오메트리**를 결정론적으로 잰다.
// · Mesh 렌더 → psr.mesh 의 정점/UV 를 그대로 (메시 로컬)
// · Billboard/Local → 이미터 로컬 XY 평면의 단위 쿼드로 합성 (모양은 텍스처가 갖는다)
// · View 정렬 → 항상 카메라를 보므로 방향 정보 없음 → 측정 제외
// 그 뒤 이미터의 루트 기준 상대 회전·스케일·위치와 startSize·startRotation 을 곱한다.
public static bool MeasureEmitter(ParticleSystem ps, ParticleSystemRenderer psr, Transform root, Camera cam, out Frame f)
{
f = new Frame();
f.emitter = ps.gameObject.name;
var main = ps.main;
bool oriented = psr.alignment != ParticleSystemRenderSpace.View;
f.oriented = oriented;
if (!oriented) return false;
// ① 파티클 로컬 지오메트리 (정점 + UV + 삼각형)
Vector3[] verts; Vector2[] uvs; int[] tris;
if (psr.renderMode == ParticleSystemRenderMode.Mesh && psr.mesh != null)
{
verts = psr.mesh.vertices; uvs = psr.mesh.uv; tris = psr.mesh.triangles;
}
else
{
// 단위 쿼드 — 이미터 로컬 XY 평면, 법선 +Z
verts = new[] { new Vector3(-0.5f, -0.5f, 0f), new Vector3(0.5f, -0.5f, 0f), new Vector3(0.5f, 0.5f, 0f), new Vector3(-0.5f, 0.5f, 0f) };
uvs = new[] { new Vector2(0, 0), new Vector2(1, 0), new Vector2(1, 1), new Vector2(0, 1) };
tris = new[] { 0, 1, 2, 0, 2, 3 };
}
if (verts.Length < 3 || tris.Length < 3) return false;
if (uvs == null || uvs.Length != verts.Length) uvs = null;
// ② 텍스처 (스프라이트 시트면 잉크가 가장 많은 타일을 고른다)
Texture2D readable = MakeReadable(GetTex(psr.sharedMaterial));
Vector2 tileScale = Vector2.one, tileOffset = Vector2.zero;
var tsa = ps.textureSheetAnimation;
if (readable != null && tsa.enabled && tsa.numTilesX > 0 && tsa.numTilesY > 0)
{
tileScale = new Vector2(1f / tsa.numTilesX, 1f / tsa.numTilesY);
float best = -1f;
for (int ty = 0; ty < tsa.numTilesY; ty++)
for (int tx = 0; tx < tsa.numTilesX; tx++)
{
float ink = TileInk(readable, new Vector2(tx * tileScale.x, ty * tileScale.y), tileScale);
if (ink > best) { best = ink; tileOffset = new Vector2(tx * tileScale.x, ty * tileScale.y); }
}
f.tile = string.Format("{0}x{1}@{2:F2},{3:F2}", tsa.numTilesX, tsa.numTilesY, tileOffset.x, tileOffset.y);
}
try
{
// ③ 파티클 로컬 → 루트 로컬 변환
// 루트 기준 상대 회전 · 스케일. startSize 는 파티클 크기 배수.
Quaternion rel = Quaternion.Inverse(root.rotation) * ps.transform.rotation;
float rotZ = main.startRotation.constantMax * Mathf.Rad2Deg;
if (Mathf.Abs(rotZ) > 0.01f) rel = rel * Quaternion.Euler(0f, 0f, rotZ);
Vector3 relScale = InvScale(ps.transform.lossyScale, root.lossyScale) * Mathf.Max(main.startSize.constantMax, 1e-4f);
Vector3 origin = root.InverseTransformPoint(ps.transform.position);
float[] thresholds = { 0.05f, 0.01f, 0f }; // 마지막 0 = 지오메트리 전용 폴백
for (int ti2 = 0; ti2 < thresholds.Length; ti2++)
{
float thr = thresholds[ti2];
var pts = new List<Vector3>(8192);
var wts = new List<float>(8192);
const int GRID = 14;
int triCount = tris.Length / 3;
for (int ti = 0; ti < triCount; ti++)
{
Vector3 a = verts[tris[ti * 3]], b = verts[tris[ti * 3 + 1]], c = verts[tris[ti * 3 + 2]];
Vector2 ua = Vector2.zero, ub = Vector2.zero, uc = Vector2.zero;
if (uvs != null) { ua = uvs[tris[ti * 3]]; ub = uvs[tris[ti * 3 + 1]]; uc = uvs[tris[ti * 3 + 2]]; }
for (int i = 0; i <= GRID; i++)
for (int j = 0; i + j <= GRID; j++)
{
float w1 = (float)i / GRID, w2 = (float)j / GRID, w0 = 1f - w1 - w2;
float w = 1f;
if (readable != null && uvs != null && thr > 0f)
{
Vector2 uv = ua * w0 + ub * w1 + uc * w2;
uv = new Vector2(tileOffset.x + Frac(uv.x) * tileScale.x, tileOffset.y + Frac(uv.y) * tileScale.y);
var col = readable.GetPixelBilinear(uv.x, uv.y);
// 잉크 = 알파 × 휘도. 가산 합성 텍스처는 알파가 1 이라 휘도가 지배한다.
float lum = col.r * 0.299f + col.g * 0.587f + col.b * 0.114f;
w = col.a * Mathf.Max(lum, 0.0001f);
if (w < thr) continue;
}
Vector3 pLocal = a * w0 + b * w1 + c * w2;
pLocal = new Vector3(pLocal.x * relScale.x, pLocal.y * relScale.y, pLocal.z * relScale.z);
pts.Add(origin + rel * pLocal);
wts.Add(w);
}
}
if (pts.Count < 32) continue;
f.inkMode = thr > 0f ? "tex>" + thr.ToString("F2") : "geom";
bool ok = Solve(pts, wts, ref f);
f.emitter = ps.gameObject.name;
f.oriented = oriented;
return ok;
}
return false;
}
finally
{
if (readable != null) Object.DestroyImmediate(readable);
}
}
static float Frac(float v) { return v - Mathf.Floor(v); }
static Vector3 InvScale(Vector3 child, Vector3 parent)
{
return new Vector3(
Mathf.Abs(parent.x) > 1e-6f ? child.x / parent.x : child.x,
Mathf.Abs(parent.y) > 1e-6f ? child.y / parent.y : child.y,
Mathf.Abs(parent.z) > 1e-6f ? child.z / parent.z : child.z);
}
static float TileInk(Texture2D tex, Vector2 off, Vector2 scale)
{
float sum = 0f;
const int N = 24;
for (int y = 0; y < N; y++)
for (int x = 0; x < N; x++)
{
var c = tex.GetPixelBilinear(off.x + (x + 0.5f) / N * scale.x, off.y + (y + 0.5f) / N * scale.y);
sum += c.a * (c.r * 0.299f + c.g * 0.587f + c.b * 0.114f);
}
return sum;
}
// ── 가중 PCA + 원 적합 ───────────────────────────────────────────────────
static bool Solve(List<Vector3> pts, List<float> wts, ref Frame f)
{
int n = pts.Count;
float wsum = 0f; Vector3 mean = Vector3.zero;
for (int i = 0; i < n; i++) { mean += pts[i] * wts[i]; wsum += wts[i]; }
if (wsum <= 1e-6f) return false;
mean /= wsum;
// 3x3 공분산
double[,] C = new double[3, 3];
for (int i = 0; i < n; i++)
{
Vector3 d = pts[i] - mean; float w = wts[i];
C[0, 0] += w * d.x * d.x; C[0, 1] += w * d.x * d.y; C[0, 2] += w * d.x * d.z;
C[1, 1] += w * d.y * d.y; C[1, 2] += w * d.y * d.z; C[2, 2] += w * d.z * d.z;
}
C[1, 0] = C[0, 1]; C[2, 0] = C[0, 2]; C[2, 1] = C[1, 2];
for (int a = 0; a < 3; a++) for (int b = 0; b < 3; b++) C[a, b] /= wsum;
Vector3[] ev; float[] va;
Jacobi(C, out ev, out va);
// 오름차순 정렬 — va[0] 최소 = 법선
for (int i = 0; i < 2; i++)
for (int j = i + 1; j < 3; j++)
if (va[j] < va[i]) { var tv = va[i]; va[i] = va[j]; va[j] = tv; var te = ev[i]; ev[i] = ev[j]; ev[j] = te; }
Vector3 nrm = ev[0].normalized;
Vector3 e1 = ev[2].normalized; // 최대 분산 축(평면 내)
Vector3 e2 = Vector3.Cross(nrm, e1).normalized;
f.planarity = va[1] > 1e-12f ? 1f - Mathf.Sqrt(Mathf.Max(va[0], 0f) / va[1]) : 1f;
// 평면 2D 좌표
var x = new float[n]; var y = new float[n];
for (int i = 0; i < n; i++) { Vector3 d = pts[i] - mean; x[i] = Vector3.Dot(d, e1); y[i] = Vector3.Dot(d, e2); }
// Kasa 원 적합 (가중)
double Sx = 0, Sy = 0, Sxx = 0, Syy = 0, Sxy = 0, Sxz = 0, Syz = 0, Sz = 0, W = 0;
for (int i = 0; i < n; i++)
{
double w = wts[i], xi = x[i], yi = y[i], zi = xi * xi + yi * yi;
W += w; Sx += w * xi; Sy += w * yi; Sxx += w * xi * xi; Syy += w * yi * yi;
Sxy += w * xi * yi; Sxz += w * xi * zi; Syz += w * yi * zi; Sz += w * zi;
}
double a11 = Sxx - Sx * Sx / W, a12 = Sxy - Sx * Sy / W, a22 = Syy - Sy * Sy / W;
double b1 = 0.5 * (Sxz - Sx * Sz / W), b2 = 0.5 * (Syz - Sy * Sz / W);
double det = a11 * a22 - a12 * a12;
double cx, cy;
bool circleOk = System.Math.Abs(det) > 1e-14;
if (circleOk) { cx = (b1 * a22 - b2 * a12) / det; cy = (a11 * b2 - a12 * b1) / det; }
else { cx = 0; cy = 0; }
// 반지름 분포 · 각도 범위
var rad = new float[n]; var ang = new float[n];
for (int i = 0; i < n; i++)
{
double dx = x[i] - cx, dy = y[i] - cy;
rad[i] = (float)System.Math.Sqrt(dx * dx + dy * dy);
ang[i] = Mathf.Atan2((float)dy, (float)dx);
}
float rMid = WeightedPercentile(rad, wts, 0.5f);
f.innerR = WeightedPercentile(rad, wts, 0.05f);
f.outerR = WeightedPercentile(rad, wts, 0.95f);
f.midR = rMid;
double rms = 0;
for (int i = 0; i < n; i++) { double d = rad[i] - rMid; rms += wts[i] * d * d; }
f.circleRms = rMid > 1e-6f ? (float)System.Math.Sqrt(rms / W) / rMid : 999f;
// 각도 평균 = 원형 평균(±π 경계 안전)
double sa = 0, ca = 0;
for (int i = 0; i < n; i++) { sa += wts[i] * Mathf.Sin(ang[i]); ca += wts[i] * Mathf.Cos(ang[i]); }
float midAng = Mathf.Atan2((float)sa, (float)ca);
// 중점 기준 상대각으로 펴서 범위를 잰다
var rel = new float[n];
for (int i = 0; i < n; i++) rel[i] = Mathf.DeltaAngle(midAng * Mathf.Rad2Deg, ang[i] * Mathf.Rad2Deg);
float lo = WeightedPercentile(rel, wts, 0.03f), hi = WeightedPercentile(rel, wts, 0.97f);
f.arcDeg = hi - lo;
// 프레임 확정
Vector3 Dir(float degFromMid)
{
float t = midAng + degFromMid * Mathf.Deg2Rad;
return (e1 * Mathf.Cos(t) + e2 * Mathf.Sin(t)).normalized;
}
Vector3 convex = Dir(0f);
Vector3 start = Dir(lo), end = Dir(hi);
Vector3 chord = end - start; // 시위 = 호 시작점 → 끝점
if (chord.sqrMagnitude < 1e-8f) chord = Vector3.Cross(nrm, convex);
chord.Normalize();
// 법선 방향을 (chord × convex) 오른손 규약으로 고정한다 — 뒤집힘 없이 재현 가능
Vector3 nFixed = Vector3.Cross(chord, convex).normalized;
if (Vector3.Dot(nFixed, nrm) < 0f) nrm = -nrm;
nrm = nFixed;
f.normal = nrm;
f.chord = chord;
f.convex = convex;
f.center = mean + e1 * (float)cx + e2 * (float)cy;
f.ink = wsum;
f.samples = n;
return circleOk;
}
static float WeightedPercentile(float[] v, List<float> w, float p)
{
int n = v.Length;
var idx = Enumerable.Range(0, n).ToArray();
System.Array.Sort(idx, (a, b) => v[a].CompareTo(v[b]));
float total = 0f; for (int i = 0; i < n; i++) total += w[i];
float acc = 0f, target = total * p;
for (int i = 0; i < n; i++) { acc += w[idx[i]]; if (acc >= target) return v[idx[i]]; }
return v[idx[n - 1]];
}
// 대칭 3x3 야코비 고유분해
static void Jacobi(double[,] a, out Vector3[] vec, out float[] val)
{
double[,] v = new double[3, 3] { { 1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 } };
double[,] m = (double[,])a.Clone();
for (int sweep = 0; sweep < 24; sweep++)
{
double off = m[0, 1] * m[0, 1] + m[0, 2] * m[0, 2] + m[1, 2] * m[1, 2];
if (off < 1e-20) break;
for (int p = 0; p < 2; p++)
for (int q = p + 1; q < 3; q++)
{
if (System.Math.Abs(m[p, q]) < 1e-18) continue;
double theta = (m[q, q] - m[p, p]) / (2 * m[p, q]);
double tt = System.Math.Sign(theta) / (System.Math.Abs(theta) + System.Math.Sqrt(theta * theta + 1));
if (theta == 0) tt = 1;
double c = 1 / System.Math.Sqrt(tt * tt + 1), s = tt * c;
for (int k = 0; k < 3; k++)
{
double mkp = m[k, p], mkq = m[k, q];
m[k, p] = c * mkp - s * mkq; m[k, q] = s * mkp + c * mkq;
}
for (int k = 0; k < 3; k++)
{
double mpk = m[p, k], mqk = m[q, k];
m[p, k] = c * mpk - s * mqk; m[q, k] = s * mpk + c * mqk;
}
for (int k = 0; k < 3; k++)
{
double vkp = v[k, p], vkq = v[k, q];
v[k, p] = c * vkp - s * vkq; v[k, q] = s * vkp + c * vkq;
}
}
}
vec = new Vector3[3]; val = new float[3];
for (int i = 0; i < 3; i++)
{
vec[i] = new Vector3((float)v[0, i], (float)v[1, i], (float)v[2, i]);
val[i] = (float)m[i, i];
}
}
// ── 유틸 ────────────────────────────────────────────────────────────────
public static string FindPrefab(string name)
{
var guids = AssetDatabase.FindAssets(name + " t:Prefab");
return guids.Select(AssetDatabase.GUIDToAssetPath)
.FirstOrDefault(p => System.IO.Path.GetFileNameWithoutExtension(p) == name);
}
public static Texture GetTex(Material m)
{
if (m == null) return null;
if (m.mainTexture != null) return m.mainTexture;
string[] names = { "_MainTex", "_BaseMap", "_BaseColorMap", "_Texture" };
foreach (var n in names) if (m.HasProperty(n) && m.GetTexture(n) != null) return m.GetTexture(n);
return null;
}
/// <summary>읽기 불가 텍스처도 RenderTexture 경유로 픽셀을 읽는다(임포터 무수정).</summary>
public static Texture2D MakeReadable(Texture src)
{
if (src == null) return null;
int w = Mathf.Min(src.width, 256), h = Mathf.Min(src.height, 256);
var rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
var prev = RenderTexture.active;
Graphics.Blit(src, rt);
RenderTexture.active = rt;
var tex = new Texture2D(w, h, TextureFormat.RGBA32, false, true);
tex.ReadPixels(new Rect(0, 0, w, h), 0, 0);
tex.Apply();
RenderTexture.active = prev;
RenderTexture.ReleaseTemporary(rt);
return tex;
}
}