// 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(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(); camGo.transform.SetPositionAndRotation(new Vector3(0f, 0f, -10f), Quaternion.identity); cam.enabled = false; var root = inst.transform; foreach (var ps in inst.GetComponentsInChildren(true)) { var psr = ps.GetComponent(); 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(8192); var wts = new List(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 pts, List 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 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; } /// 읽기 불가 텍스처도 RenderTexture 경유로 픽셀을 읽는다(임포터 무수정). 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; } }