Project_WL/Assets/WL/Combat/SlashArcMeasure.cs

962 lines
46 KiB
C#
Raw 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.

// ─────────────────────────────────────────────────────────────────────────────
// SlashArcMeasure.cs — 이펙트 프리팹의 "호(크레센트)" · "찌르기 축" 기하 실측
//
// PD 지시 #792 (2026-09-07 01:00 / 01:0x):
// ① "무기 궤적을 그대로 따라가야 한다"가 아니라 **무기를 휘두르는 방향에 맞게
// 이펙트의 각도·위치·크기를 무기에 딱 맞게 배치**해서 자연스럽게 보이게 할 것.
// ② 휘두르기 = NamuFX `Slash_B_recolor_1` · 찌르기 = EricWang `FX_Blue Stab`.
//
// ── 왜 새 실측 코드가 필요한가 (기존 WeaponTrailDriver.Measure 의 결함) ──────
// 기존 Measure() 는 이미터 메시의 **바운딩 박스**만 보고
// 법선 = 최단축 · "호가 뻗는 방향" = 최장축
// 으로 프레임을 잡은 뒤, 그 최장축을 스윙의 `outward`(= 칼끝 손잡이 = 반경 방향)에
// 맞췄다. 그런데 크레센트(원호 리본)의 바운딩 박스 최장축은 **현(chord) 방향**,
// 즉 호의 시작→끝을 잇는 접선 방향이지 반경 방향이 아니다.
// · 반각 θ 인 호의 현 길이 = 2·r·sinθ, 반경 방향 폭 = r_o r_i·cosθ
// θ=60° → 1.73r vs 0.77r · θ=90° → 2.00r vs 1.00r · θ=135° → 2.00r vs 1.71r
// 어느 경우에도 현이 길다. 즉 최장축은 항상 현이다.
// → 기존 방식은 호를 스윙 평면 **안에서 90° 돌려** 놓는다. 이것이 PD 가 지적한
// "무기를 휘두르는 방향에 맞지 않는다"의 기하학적 원인이다.
//
// ── 이 파일이 대신 재는 것 ─────────────────────────────────────────────────
// 메시 정점을 직접 읽어 **호를 원(circle)으로 피팅**한다:
// · 평면 법선 n — 삼각형 법선의 면적 가중 합(부호 정렬). 뒷면이 상쇄되지 않는다.
// · 원 중심 c — 평면에 투영한 뒤 Kåsa 대수 원피팅(선형 최소자승)
// · 반경 r_i/r_o — |pc| 의 최소/최대 (리본의 안/바깥 가장자리)
// · 스윕 구간 — 각도 정렬 후 **최대 공백(gap)** 을 찾아 그 바깥이 호 구간
// · 시작/끝 방향 — UV 의 u 축 오름차순을 호 진행 방향으로 본다(플래그로 반전 가능)
// 이렇게 재면 호를 스윙에 맞추는 일이 "프레임 대 프레임" 문제로 환원된다:
// Q · n_mesh = n_swing (부호까지 · 스윙 진행 방향 일치)
// Q · bisector_mesh = bisector_swing (볼록 방향 = 스윙 바깥)
// s = r_swingTip / r_o (균등 스케일)
// pos = c_swing Q·(s · c_mesh) (호의 원 중심 = 스윙 피벗)
//
// 🔴 런타임에서 메시 정점을 읽지 않는다.
// 임포트된 FBX 는 Read/Write 가 꺼져 있는 것이 기본이라 **빌드된 플레이어에서
// Mesh.vertices 가 실패**한다(에디터에서만 우연히 통한다). 그래서 이 클래스의
// 정점 기반 경로는 **에디터 베이크 전용**이고, 결과 CrescentFrame 은
// SlashTrailSettings 에 데이터로 구워 둔다(C45 — 값의 SOT 는 SO 하나).
// 런타임은 구워진 프레임만 읽으며, 프레임이 없으면 바운딩 박스 폴백으로
// 조용히 떨어진다(C8 롤백 경로 — 게임이 멈추지 않는다).
//
// 🔴 어셈블리 주의: 힘민지 Assets/Script/ 에는 .asmdef 가 없다(전부 Assembly-CSharp).
// Assets/WL/Combat/ 에도 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
namespace WL.Combat
{
/// <summary>
/// 이펙트 프리팹 안에서 실측한 "호" 1개의 기하.
/// 모든 값은 **프리팹 루트 로컬 · 루트 스케일 1 · 루트 회전 항등** 기준이다
/// (측정 시 루트를 원점·항등·스케일1 로 잠깐 옮겨 놓고 잰다 — 아래 MeasureInstance 참고).
/// </summary>
[System.Serializable]
public struct CrescentFrame
{
[Tooltip("유효한 실측인가. false 면 호출부는 바운딩 박스 폴백을 쓴다")]
public bool valid;
[Tooltip("실측에 쓴 이미터 GameObject 이름(추적성)")]
public string emitter;
[Tooltip("호가 놓인 평면의 법선. 호의 시작→끝이 이 축에서 볼 때 반시계(CCW)가 되도록 부호를 잡는다")]
public Vector3 normal;
[Tooltip("호가 그리는 원의 중심(루트 로컬). 스윙 피벗(손잡이 궤적 중심)에 맞춘다")]
public Vector3 center;
[Tooltip("원 중심 → 호 중앙(볼록 방향, 단위 벡터). 스윙 바깥(칼끝 방향)에 맞춘다")]
public Vector3 bisector;
[Tooltip("원 중심 → 호 시작(단위 벡터). 검증용 — 호 방향 오차를 재는 데 쓴다")]
public Vector3 startDir;
[Tooltip("호 바깥 가장자리 반지름(m · 루트 스케일 1)")]
public float radiusOuter;
[Tooltip("호 안쪽 가장자리 반지름(m · 루트 스케일 1)")]
public float radiusInner;
[Tooltip("호가 덮는 각도(도)")]
public float sweepDeg;
public static CrescentFrame Invalid { get { return new CrescentFrame { valid = false, emitter = "(none)" }; } }
/// <summary>메시 프레임 → 목표 프레임 변환 회전. (법선, 볼록 방향) 두 축을 동시에 맞춘다.</summary>
public Quaternion FrameRotation
{
get { return Quaternion.LookRotation(normal, bisector); }
}
}
/// <summary>
/// 이펙트 프리팹 안에서 실측한 "직선(찌르기) 축" 기하.
/// 좌표계 규약은 <see cref="CrescentFrame"/> 과 같다.
/// </summary>
[System.Serializable]
public struct StabFrame
{
public bool valid;
[Tooltip("실측 근거 메모(추적성)")]
public string note;
[Tooltip("찌르기 진행 방향(단위 벡터 · 꼬리 → 칼끝)")]
public Vector3 axis;
[Tooltip("부착 기준점 = 프리팹 루트를 축에 투영한 지점(루트 로컬). 여기를 칼끝 출발점에 맞춘다")]
public Vector3 start;
[Tooltip("기준점에서 앞끝까지의 도달 거리(m · 루트 스케일 1). 뒤로 흐르는 꼬리는 길이에 넣지 않는다")]
public float length;
[Tooltip("축에 수직인 방향의 시각 두께(m). 롤(축 회전)을 맞출 때 참고")]
public float thickness;
[Tooltip("이펙트가 놓인 납작한 평면의 법선(루트 로컬). 2D 스프라이트 계열의 '앞면'이다")]
public Vector3 planeNormal;
public static StabFrame Invalid { get { return new StabFrame { valid = false, note = "(none)" }; } }
}
/// <summary>
/// 이펙트 프리팹의 호·직선 기하를 실측한다.
///
/// 사용 시점:
/// · <b>에디터 베이크</b> — 정점 기반 정밀 실측(<see cref="MeasureCrescent"/>).
/// 결과를 SlashTrailSettings 에 구워 둔다.
/// · <b>런타임</b> — 구워진 프레임이 없을 때만 바운딩 박스 폴백
/// (<see cref="MeasureCrescentFallback"/>). 정점을 읽지 않는다.
/// </summary>
public static class SlashArcMeasure
{
// ─────────────────────────────────────────────────────────────────────
// 공통 — 인스턴스를 원점·항등·스케일1 로 잠깐 놓고 재기
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 인스턴스의 루트를 원점·항등 회전·스케일 1 로 옮긴 뒤 <paramref name="body"/> 를 실행하고
/// 원래 자세로 되돌린다. 이렇게 하면 자식의 world 좌표가 곧 "루트 로컬 · 스케일 1" 좌표가 된다.
///
/// Unity 의 트랜스폼 계층은 즉시 갱신되므로(지연 없음) 비활성 오브젝트에서도 정확하다.
/// InGameInfo.Show_EffectEx 는 actSpawned 를 SetActive **직전에** 부르므로,
/// 이 함수를 그 콜백 안에서 호출해도 파티클이 한 프레임도 잘못된 자리에 뜨지 않는다.
/// </summary>
public static void InNeutralPose(Transform root, System.Action body)
{
if (root == null || body == null) return;
Vector3 p = root.position;
Quaternion r = root.rotation;
Vector3 s = root.localScale;
try
{
root.position = Vector3.zero;
root.rotation = Quaternion.identity;
root.localScale = Vector3.one;
body();
}
finally
{
root.position = p;
root.rotation = r;
root.localScale = s;
}
}
// ─────────────────────────────────────────────────────────────────────
// 호(크레센트) 실측 — 정점 기반 (에디터 베이크 전용)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 프리팹 인스턴스에서 호 이미터를 골라 원 피팅으로 프레임을 실측한다.
///
/// <paramref name="preferredEmitters"/> 에 이름이 있으면 1순위로 쓴다(C45 — 어느 이미터가
/// 호 본체인지는 데이터다). 없으면 "카메라 정렬이 아닌 Mesh 렌더 이미터 중 가장 큰 것"을 고른다.
///
/// 🔴 메시 정점을 읽으므로 **에디터에서만** 부른다. isReadable 이 꺼진 FBX 는
/// 빌드된 플레이어에서 실패한다. 실패하면 Invalid 를 돌려준다(예외를 던지지 않는다).
/// </summary>
public static CrescentFrame MeasureCrescent(GameObject instance, string[] preferredEmitters, bool flipSweep)
{
if (instance == null) return CrescentFrame.Invalid;
var result = CrescentFrame.Invalid;
InNeutralPose(instance.transform, () =>
{
ParticleSystem best;
float bestSize;
if (!PickCrescentEmitter(instance, preferredEmitters, out best, out bestSize)) return;
var psr = best.GetComponent<ParticleSystemRenderer>();
var mesh = psr != null ? psr.mesh : null;
if (mesh == null) return;
Vector3[] verts;
int[] tris;
Vector2[] uvs;
if (!TryReadMesh(mesh, out verts, out tris, out uvs)) return;
// 파티클 메시는 startSize 로 스케일된 뒤 이미터 트랜스폼에 놓인다.
// (RenderAlignment=Local · SimulationSpace=Local · 방출 위치 = 이미터 원점 가정)
var tm = best.transform;
var pts = new Vector3[verts.Length];
for (int i = 0; i < verts.Length; i++)
pts[i] = tm.TransformPoint(verts[i] * bestSize);
result = FitCrescent(pts, tris, uvs, flipSweep);
result.emitter = best.gameObject.name;
});
return result;
}
/// <summary>
/// <b>시간 순서대로 들어온</b> 점들을 원호로 피팅한다 — 스윙 궤적(칼끝 샘플)용.
///
/// 정점 피팅(<see cref="FitCrescent"/>)과 달리 점의 순서가 곧 호의 진행 방향이므로
/// UV·테이퍼 같은 간접 단서가 필요 없다. 법선의 부호는 pts[0]→pts[count-1] 이
/// +normal 축에서 볼 때 반시계가 되도록 잡는다(= 스윙 진행 방향을 법선이 담는다).
/// </summary>
public static CrescentFrame FitArcOrdered(Vector3[] pts, int count)
{
var bad = CrescentFrame.Invalid;
if (pts == null || count < 3) return bad;
// ① 중심(무게) · Newell 법선
Vector3 origin = Vector3.zero;
for (int i = 0; i < count; i++) origin += pts[i];
origin /= count;
Vector3 n = Vector3.zero;
for (int i = 0; i + 1 < count; i++)
n += Vector3.Cross(pts[i] - origin, pts[i + 1] - origin);
if (n.sqrMagnitude < 1e-12f)
{
// 거의 직선이다 — 평면을 못 정한다. 주성분 두 개의 외적으로 근사한다.
Vector3 a1 = PrincipalAxis(pts, count, origin);
Vector3 a2 = SecondaryAxis(pts, count, origin, a1);
n = Vector3.Cross(a1, a2);
if (n.sqrMagnitude < 1e-12f) return bad;
}
n.Normalize();
// ② 평면 기저 · 2D 투영
Vector3 u = Vector3.Cross(n, Mathf.Abs(n.y) < 0.9f ? Vector3.up : Vector3.right);
if (u.sqrMagnitude < 1e-12f) return bad;
u.Normalize();
Vector3 v = Vector3.Cross(n, u);
var xs = new float[count];
var ys = new float[count];
for (int i = 0; i < count; i++)
{
Vector3 d = pts[i] - origin;
xs[i] = Vector3.Dot(d, u);
ys[i] = Vector3.Dot(d, v);
}
// ③ 원 피팅
float cx, cy;
if (!FitCircleKasa(xs, ys, out cx, out cy)) return bad;
Vector3 center = origin + u * cx + v * cy;
// ④ 반지름 · 시작/끝 방향 · 스윕(부호 있음)
float rSum = 0f, rMin = float.MaxValue, rMax = 0f;
for (int i = 0; i < count; i++)
{
float r = Vector3.Distance(pts[i], center);
rSum += r;
if (r < rMin) rMin = r;
if (r > rMax) rMax = r;
}
float rMean = rSum / count;
if (rMean < 1e-4f) return bad;
Vector3 s0 = pts[0] - center;
Vector3 s1 = pts[count - 1] - center;
s0 -= n * Vector3.Dot(s0, n);
s1 -= n * Vector3.Dot(s1, n);
if (s0.sqrMagnitude < 1e-10f || s1.sqrMagnitude < 1e-10f) return bad;
s0.Normalize(); s1.Normalize();
float signed = Vector3.SignedAngle(s0, s1, n); // (-180, 180]
// 스윙이 180° 를 넘으면 부호가 뒤집혀 나온다. 누적 각으로 총량을 다시 잰다.
float accum = 0f;
Vector3 prev = s0;
for (int i = 1; i < count; i++)
{
Vector3 d = pts[i] - center;
d -= n * Vector3.Dot(d, n);
if (d.sqrMagnitude < 1e-10f) continue;
d.Normalize();
accum += Vector3.SignedAngle(prev, d, n);
prev = d;
}
if (Mathf.Abs(accum) > 1e-3f) signed = accum;
// 법선은 "진행 방향이 CCW" 가 되도록 잡는다.
if (signed < 0f) { n = -n; signed = -signed; }
if (signed < 1e-3f) return bad;
Vector3 bisector = (Quaternion.AngleAxis(signed * 0.5f, n) * s0).normalized;
return new CrescentFrame
{
valid = true,
emitter = "(swing)",
normal = n,
center = center,
bisector = bisector,
startDir = s0,
radiusOuter = rMax,
radiusInner = rMin,
sweepDeg = signed
};
}
/// <summary>점들의 무게중심을 평면(법선 normal · 기준점 onPlane)에 투영해 돌려준다.</summary>
public static Vector3 CentroidOnPlane(Vector3[] pts, int count, Vector3 normal, Vector3 onPlane)
{
if (pts == null || count <= 0) return onPlane;
Vector3 c = Vector3.zero;
for (int i = 0; i < count; i++) c += pts[i];
c /= count;
return c - normal * Vector3.Dot(c - onPlane, normal);
}
/// <summary>중심에서 잰 반지름의 표준편차. 원 피팅이 실제로 원을 찾았는지 보는 척도.</summary>
public static float RadiusDeviation(Vector3[] pts, int count, Vector3 center, Vector3 normal, float mean)
{
if (pts == null || count <= 0) return 0f;
float s = 0f;
for (int i = 0; i < count; i++)
{
Vector3 d = pts[i] - center;
d -= normal * Vector3.Dot(d, normal);
float e = d.magnitude - mean;
s += e * e;
}
return Mathf.Sqrt(s / count);
}
/// <summary>
/// 피벗을 바꾼 뒤 시작 방향 · 볼록 방향 · 스윕을 다시 잰다.
/// 진행 방향이 CCW 가 되도록 <paramref name="normal"/> 의 부호도 함께 맞춘다.
/// </summary>
public static bool RecomputeAboutPivot(Vector3[] pts, int count, Vector3 pivot, ref Vector3 normal,
out Vector3 startDir, out Vector3 bisector, out float sweepDeg)
{
startDir = Vector3.forward;
bisector = Vector3.forward;
sweepDeg = 0f;
if (pts == null || count < 2) return false;
Vector3 s0 = pts[0] - pivot;
s0 -= normal * Vector3.Dot(s0, normal);
if (s0.sqrMagnitude < 1e-10f) return false;
s0.Normalize();
float accum = 0f;
Vector3 prev = s0;
for (int i = 1; i < count; i++)
{
Vector3 d = pts[i] - pivot;
d -= normal * Vector3.Dot(d, normal);
if (d.sqrMagnitude < 1e-10f) continue;
d.Normalize();
accum += Vector3.SignedAngle(prev, d, normal);
prev = d;
}
if (accum < 0f) { normal = -normal; accum = -accum; }
if (accum < 1e-3f) return false;
startDir = s0;
sweepDeg = accum;
bisector = (Quaternion.AngleAxis(accum * 0.5f, normal) * s0).normalized;
return true;
}
/// <summary>중심에서 잰 평균 반지름 — 손잡이 반경처럼 "이 원 기준으로 얼마인가"를 잴 때.</summary>
public static float MeanRadius(Vector3[] pts, int count, Vector3 center, Vector3 normal)
{
if (pts == null || count <= 0) return 0f;
float sum = 0f;
for (int i = 0; i < count; i++)
{
Vector3 d = pts[i] - center;
d -= normal * Vector3.Dot(d, normal);
sum += d.magnitude;
}
return sum / count;
}
/// <summary>정점 집합을 원호로 피팅한다. 실패하면 Invalid.</summary>
public static CrescentFrame FitCrescent(Vector3[] pts, int[] tris, Vector2[] uvs, bool flipSweep)
{
var bad = CrescentFrame.Invalid;
if (pts == null || pts.Length < 8) return bad;
// ① 평면 법선 — 삼각형 법선의 면적 가중 합. 양면 메시에서 앞/뒤가 상쇄되지 않도록
// 가장 큰 삼각형의 법선을 기준으로 부호를 정렬한 뒤 더한다.
Vector3 n = AreaWeightedNormal(pts, tris);
if (n.sqrMagnitude < 1e-12f) return bad;
n.Normalize();
// ② 평면 기저
Vector3 u = Vector3.Cross(n, Mathf.Abs(n.y) < 0.9f ? Vector3.up : Vector3.right);
if (u.sqrMagnitude < 1e-12f) return bad;
u.Normalize();
Vector3 v = Vector3.Cross(n, u); // (u, v, n) 이 오른손 좌표계
// ③ 평면 위 2D 좌표
int cnt = pts.Length;
var xs = new float[cnt];
var ys = new float[cnt];
Vector3 origin = Vector3.zero;
for (int i = 0; i < cnt; i++) origin += pts[i];
origin /= cnt;
for (int i = 0; i < cnt; i++)
{
Vector3 d = pts[i] - origin;
xs[i] = Vector3.Dot(d, u);
ys[i] = Vector3.Dot(d, v);
}
// ④ 원 피팅 — Kåsa 대수 피팅 뒤 **바깥 가장자리만 남기며 재피팅**한다.
//
// 🔴 전체 정점에 한 번만 걸면 중심이 치우친다(실측 2026-09-07):
// 호는 안/바깥 반지름이 크게 다른 고리라 대수 잔차가 큰 반지름 쪽으로 끌린다.
// SlashMesh 에서 중심이 0.22 m 어긋났고, 그 탓에 안/바깥 비가 0.47 → 0.27 로 왜곡됐다.
// 바깥 경계만 남기면 그것은 **진짜 원**이라 정확히 수렴한다
// (재피팅 후 잔차 σ = 0.0000 · 안/바깥 = 0.470 으로 구 WL 독립 실측치와 일치).
float cx, cy;
if (!FitCircleKasa(xs, ys, out cx, out cy)) return bad;
var keepX = new List<float>(xs);
var keepY = new List<float>(ys);
for (int pass = 0; pass < 5 && keepX.Count >= 12; pass++)
{
var rr = new List<float>(keepX.Count);
for (int i = 0; i < keepX.Count; i++)
{
float dx = keepX[i] - cx, dy = keepY[i] - cy;
rr.Add(Mathf.Sqrt(dx * dx + dy * dy));
}
var sortedR = new List<float>(rr);
sortedR.Sort();
float cut = sortedR[sortedR.Count / 2]; // 중앙값 이상만 남긴다
var nx = new List<float>();
var ny = new List<float>();
for (int i = 0; i < keepX.Count; i++)
if (rr[i] >= cut) { nx.Add(keepX[i]); ny.Add(keepY[i]); }
if (nx.Count < 8) break;
keepX = nx; keepY = ny;
float ncx, ncy;
if (!FitCircleKasa(keepX.ToArray(), keepY.ToArray(), out ncx, out ncy)) break;
cx = ncx; cy = ncy;
}
Vector3 center = origin + u * cx + v * cy;
// ⑤ 반지름 · 각도
float rMin = float.MaxValue, rMax = 0f;
var ang = new float[cnt];
for (int i = 0; i < cnt; i++)
{
float dx = xs[i] - cx, dy = ys[i] - cy;
float r = Mathf.Sqrt(dx * dx + dy * dy);
if (r < rMin) rMin = r;
if (r > rMax) rMax = r;
ang[i] = Mathf.Atan2(dy, dx); // (-π, π]
}
if (rMax < 1e-5f) return bad;
// ⑥ 스윕 구간 — 각도를 정렬해 **최대 공백**을 찾는다. 호는 그 공백의 바깥이다.
// (원 전체를 덮는 링이면 공백이 거의 0 이라 sweep ≈ 360° 로 나온다)
var sorted = (float[])ang.Clone();
System.Array.Sort(sorted);
float gap = sorted[0] + Mathf.PI * 2f - sorted[cnt - 1];
float startAng = sorted[0]; // 기본 = 공백 다음(= 최소각)
for (int i = 1; i < cnt; i++)
{
float g = sorted[i] - sorted[i - 1];
if (g > gap) { gap = g; startAng = sorted[i]; }
}
float sweep = Mathf.PI * 2f - gap;
if (sweep < 1e-3f) return bad;
// ⑦ 호 진행 방향 — UV 의 u 축이 오름차순인 쪽을 시작으로 본다.
// (NamuFX 슬래시 메시는 텍스처가 호를 따라 흐른다) UV 가 없으면 CCW 를 그대로 쓴다.
bool reverse = flipSweep;
if (uvs != null && uvs.Length == cnt)
reverse ^= UvRunsAgainstAngle(uvs, ang, startAng, sweep);
float endAng = startAng + sweep;
float midAng = startAng + sweep * 0.5f;
Vector3 startDir = (u * Mathf.Cos(startAng) + v * Mathf.Sin(startAng)).normalized;
Vector3 endDir = (u * Mathf.Cos(endAng) + v * Mathf.Sin(endAng)).normalized;
Vector3 bisector = (u * Mathf.Cos(midAng) + v * Mathf.Sin(midAng)).normalized;
// 진행 방향이 반대라면 시작/끝만 맞바꾸고 법선을 뒤집는다.
// (호가 차지하는 구간·볼록 방향은 그대로이므로 bisector 는 건드리지 않는다)
if (reverse)
{
n = -n;
Vector3 tmp = startDir; startDir = endDir; endDir = tmp;
}
return new CrescentFrame
{
valid = true,
emitter = "(fit)",
normal = n,
center = center,
bisector = bisector,
startDir = startDir,
radiusOuter = rMax,
radiusInner = rMin,
sweepDeg = sweep * Mathf.Rad2Deg
};
}
/// <summary>
/// 정점을 못 읽을 때의 폴백 — 바운딩 박스로 대략의 프레임을 만든다.
/// 최단축 = 법선 · 최장축 = 현(chord) 으로 보고, 현에 수직인 평면 내 축을 볼록 방향으로 잡는다.
/// 반경은 "반원(180°)" 을 가정해 현 길이의 절반으로 둔다 — 정확하지 않으므로
/// 어디까지나 베이크가 없을 때의 안전 경로다(C8).
/// </summary>
public static CrescentFrame MeasureCrescentFallback(GameObject instance, string[] preferredEmitters)
{
if (instance == null) return CrescentFrame.Invalid;
var result = CrescentFrame.Invalid;
InNeutralPose(instance.transform, () =>
{
ParticleSystem best;
float bestSize;
if (!PickCrescentEmitter(instance, preferredEmitters, out best, out bestSize)) return;
var psr = best.GetComponent<ParticleSystemRenderer>();
Bounds b;
if (psr != null && psr.renderMode == ParticleSystemRenderMode.Mesh && psr.mesh != null)
b = psr.mesh.bounds;
else
b = new Bounds(Vector3.zero, Vector3.one);
var tm = best.transform;
Vector3 size = new Vector3(
b.size.x * Mathf.Abs(tm.lossyScale.x),
b.size.y * Mathf.Abs(tm.lossyScale.y),
b.size.z * Mathf.Abs(tm.lossyScale.z)) * bestSize;
int thin = MinAxis(size);
int longest = MaxAxis(size);
if (longest == thin) longest = (thin + 1) % 3;
int mid = 3 - thin - longest;
Vector3 nLocal = AxisVec(thin);
Vector3 bLocal = AxisVec(mid); // 현에 수직인 평면 내 축 = 볼록 방향 근사
Vector3 nWorld = tm.TransformDirection(nLocal).normalized;
Vector3 bWorld = tm.TransformDirection(bLocal).normalized;
float radius = Mathf.Max(size[longest], size[mid]) * 0.5f;
result = new CrescentFrame
{
valid = true,
emitter = best.gameObject.name + " (bbox fallback)",
normal = nWorld,
center = tm.TransformPoint(b.center) - bWorld * radius * 0.5f,
bisector = bWorld,
startDir = Vector3.Cross(nWorld, bWorld).normalized,
radiusOuter = radius,
radiusInner = radius * 0.5f,
sweepDeg = 180f
};
});
return result;
}
// ─────────────────────────────────────────────────────────────────────
// 찌르기 축 실측
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 찌르기 이펙트의 진행 축·시작점·길이를 실측한다.
///
/// 정점이 필요 없다 — 활성 이미터의 **위치와 방향성 크기**만으로 충분하다.
/// 축 = 이미터 위치의 주성분(가장 퍼진 방향)
/// 부호 = 무게중심에서 가장 멀리 떨어진 이미터 쪽을 칼끝으로 본다
/// (찌르기 이펙트는 칼끝 쪽으로 길게 뻗고 꼬리는 촘촘하다)
/// 길이 = **프리팹 루트에서 앞쪽으로 뻗은 도달 거리**
///
/// 🔴 길이를 "전체 범위"로 잡으면 안 된다(실측 2026-09-07):
/// FX_Blue Stab 은 뒤로 흐르는 스피드 라인(lb·line_flash)이 커서 전체 범위가 12.78 m 인데,
/// 작가가 부착점으로 둔 루트에서 앞으로 뻗은 거리는 5.31 m 다. 전체 범위로 크기를 맞추면
/// 이펙트가 의도의 2.4 배로 작아진다. 그래서 **루트를 축에 투영한 지점 → 앞끝** 을 기준으로 쓴다.
///
/// 등방 반경이 아니라 **축에 투영한 OBB 지지 함수**로 각 이미터의 축 방향 반크기를 잰다.
/// (등방 max 로 재면 8.5 m 짜리 가로 스트리크가 축 방향 길이를 통째로 부풀린다)
///
/// 부호 판정이 틀리면 <paramref name="flipAxis"/> 로 뒤집는다(SO 데이터 · 캡처로 확정).
/// <paramref name="keepNames"/> 를 주면 그 이미터만 재고 나머지는 무시한다 —
/// 드로우 예산 때문에 이미터를 줄이면 시각 길이도 함께 변하므로 두 결정은 같은 데이터다.
/// </summary>
public static StabFrame MeasureStab(GameObject instance, bool flipAxis, string[] keepNames)
{
if (instance == null) return StabFrame.Invalid;
var result = StabFrame.Invalid;
InNeutralPose(instance.transform, () =>
{
var systems = instance.GetComponentsInChildren<ParticleSystem>(true);
int n = 0;
var pos = new Vector3[systems.Length];
var e0 = new Vector3[systems.Length]; // 월드 반변 3개 (OBB)
var e1 = new Vector3[systems.Length];
var e2 = new Vector3[systems.Length];
var nrm = new Vector3[systems.Length];
var wgt = new float[systems.Length];
for (int i = 0; i < systems.Length; i++)
{
var ps = systems[i];
var psr = ps.GetComponent<ParticleSystemRenderer>();
if (psr == null || !psr.enabled) continue;
if (keepNames != null && keepNames.Length > 0 &&
System.Array.IndexOf(keepNames, ps.gameObject.name) < 0) continue;
float size = Mathf.Max(ps.main.startSize.constantMax, 0f);
if (size <= 0f) continue;
var tm = ps.transform;
float sx = Mathf.Abs(tm.lossyScale.x), sy = Mathf.Abs(tm.lossyScale.y), sz = Mathf.Abs(tm.lossyScale.z);
Vector3 ext;
if (psr.renderMode == ParticleSystemRenderMode.Mesh && psr.mesh != null)
{
Vector3 ms = psr.mesh.bounds.size;
ext = new Vector3(ms.x * sx, ms.y * sy, ms.z * sz) * size;
}
else
{
ext = new Vector3(size * sx, size * sy, 0f);
}
pos[n] = tm.position;
e0[n] = tm.right * (ext.x * 0.5f);
e1[n] = tm.up * (ext.y * 0.5f);
e2[n] = tm.forward * (ext.z * 0.5f);
// 납작한 축 = 이 이미터 면의 법선
nrm[n] = tm.TransformDirection(AxisVec(MinAxis(ext))).normalized;
wgt[n] = Mathf.Max(ext.x, Mathf.Max(ext.y, ext.z));
n++;
}
if (n < 2) return;
// 무게중심
Vector3 c = Vector3.zero;
float wsum = 0f;
for (int i = 0; i < n; i++) { c += pos[i] * wgt[i]; wsum += wgt[i]; }
if (wsum < 1e-6f) return;
c /= wsum;
// 주성분(멱승법 3회면 충분하다 — 축이 뚜렷하다)
Vector3 axis = PrincipalAxis(pos, n, c);
if (axis.sqrMagnitude < 1e-10f) return;
axis.Normalize();
// 부호 — 무게중심에서 가장 멀리 떨어진 이미터 쪽
float far = 0f; float farT = 0f;
for (int i = 0; i < n; i++)
{
float t = Vector3.Dot(pos[i] - c, axis);
if (Mathf.Abs(t) > far) { far = Mathf.Abs(t); farT = t; }
}
if (farT < 0f) axis = -axis;
if (flipAxis) axis = -axis;
// 축 방향 범위 · 수직 두께 — OBB 지지 함수로 축에 투영한다
float tMin = float.MaxValue, tMax = float.MinValue, thick = 0f;
for (int i = 0; i < n; i++)
{
float t = Vector3.Dot(pos[i] - c, axis);
float ha = Mathf.Abs(Vector3.Dot(e0[i], axis))
+ Mathf.Abs(Vector3.Dot(e1[i], axis))
+ Mathf.Abs(Vector3.Dot(e2[i], axis));
if (t - ha < tMin) tMin = t - ha;
if (t + ha > tMax) tMax = t + ha;
Vector3 perp = (pos[i] - c) - axis * t;
float hp = (e0[i] - axis * Vector3.Dot(e0[i], axis)).magnitude
+ (e1[i] - axis * Vector3.Dot(e1[i], axis)).magnitude
+ (e2[i] - axis * Vector3.Dot(e2[i], axis)).magnitude;
float th = perp.magnitude + hp;
if (th > thick) thick = th;
}
if (tMax - tMin < 1e-4f) return;
// 기준점 = 프리팹 루트를 축에 투영한 지점(작가가 둔 부착점) · 길이 = 거기서 앞끝까지
float tRoot = Vector3.Dot(Vector3.zero - c, axis);
float len = tMax - tRoot;
if (len < 1e-4f) return;
// 이펙트 면의 법선 — 가장 큰 이미터의 납작한 축을 축에 수직으로 정사영
Vector3 planeN = Vector3.zero;
for (int i = 0; i < n; i++) planeN += nrm[i] * wgt[i];
planeN -= axis * Vector3.Dot(planeN, axis);
if (planeN.sqrMagnitude < 1e-8f) planeN = Vector3.Cross(axis, Vector3.up);
if (planeN.sqrMagnitude < 1e-8f) planeN = Vector3.Cross(axis, Vector3.right);
planeN.Normalize();
result = new StabFrame
{
valid = true,
note = string.Format("emitters={0} forward={1:F2} total={2:F2} thick={3:F2}",
n, len, tMax - tMin, thick * 2f),
axis = axis,
start = c + axis * tRoot,
length = len,
thickness = thick * 2f,
planeNormal = planeN
};
});
return result;
}
// ─────────────────────────────────────────────────────────────────────
// 내부 유틸
// ─────────────────────────────────────────────────────────────────────
/// <summary>호 본체 이미터를 고른다. 이름 지정 → 방향 정보 보유(비 View 정렬) → 큰 것 순.</summary>
private static bool PickCrescentEmitter(GameObject go, string[] preferred, out ParticleSystem best, out float bestStartSize)
{
best = null;
bestStartSize = 0f;
var systems = go.GetComponentsInChildren<ParticleSystem>(true);
float bestScore = 0f;
bool bestNamed = false, bestOriented = false;
for (int i = 0; i < systems.Length; i++)
{
var ps = systems[i];
var psr = ps.GetComponent<ParticleSystemRenderer>();
if (psr == null || !psr.enabled) continue;
float size = Mathf.Max(ps.main.startSize.constantMax, 0f);
if (size <= 0f) continue;
bool named = Contains(preferred, ps.gameObject.name);
bool oriented = psr.renderMode == ParticleSystemRenderMode.Mesh
? psr.alignment != ParticleSystemRenderSpace.View
: psr.alignment == ParticleSystemRenderSpace.Local;
Vector3 ls = ps.transform.lossyScale;
float score = size * Mathf.Max(Mathf.Abs(ls.x), Mathf.Max(Mathf.Abs(ls.y), Mathf.Abs(ls.z)));
if (psr.renderMode == ParticleSystemRenderMode.Mesh && psr.mesh != null)
{
Vector3 ms = psr.mesh.bounds.size;
score *= Mathf.Max(ms.x, Mathf.Max(ms.y, ms.z));
}
bool better;
if (named != bestNamed) better = named;
else if (oriented != bestOriented) better = oriented;
else better = score > bestScore;
if (!better) continue;
best = ps;
bestStartSize = size;
bestScore = score;
bestNamed = named;
bestOriented = oriented;
}
return best != null;
}
/// <summary>
/// 메시 정점을 읽는다. 실패하면 false — 예외를 던지지 않는다.
///
/// 🔴 <c>isReadable</c> 로 미리 거르지 않는다(실측 2026-09-07).
/// 임포트된 FBX 는 Read/Write 가 꺼져 있는 것이 기본이지만 **에디터에서는 정점을 읽을 수 있다**
/// (SlashMesh 는 isReadable=False 인데도 160 정점을 정상적으로 읽었다). 미리 거르면
/// 에디터 베이크까지 막혀 정밀 실측을 못 한다. 대신 빌드된 플레이어에서는 진짜로 실패하므로
/// 이 경로는 **에디터 베이크 전용**이고 런타임은 구워 둔 값만 읽는다.
/// </summary>
private static bool TryReadMesh(Mesh mesh, out Vector3[] verts, out int[] tris, out Vector2[] uvs)
{
verts = null; tris = null; uvs = null;
if (mesh == null) return false;
try
{
verts = mesh.vertices;
tris = mesh.triangles;
uvs = mesh.uv;
if (uvs != null && uvs.Length != verts.Length) uvs = null;
}
catch (System.Exception e)
{
Debug.LogWarning("[WL #792] 메시 읽기 실패: " + e.Message);
return false;
}
return verts != null && verts.Length >= 8;
}
/// <summary>삼각형 법선의 면적 가중 합. 가장 큰 삼각형을 기준으로 부호를 정렬해 양면 상쇄를 막는다.</summary>
private static Vector3 AreaWeightedNormal(Vector3[] pts, int[] tris)
{
if (tris == null || tris.Length < 3)
{
// 삼각형이 없으면 공분산의 최소 고유벡터 대신 "가장 퍼진 두 축의 외적"으로 근사
Vector3 c = Vector3.zero;
for (int i = 0; i < pts.Length; i++) c += pts[i];
c /= pts.Length;
Vector3 a1 = PrincipalAxis(pts, pts.Length, c);
Vector3 a2 = SecondaryAxis(pts, pts.Length, c, a1);
return Vector3.Cross(a1, a2);
}
// 기준 법선 = 면적이 가장 큰 삼각형
Vector3 refN = Vector3.zero;
float refA = 0f;
for (int i = 0; i + 2 < tris.Length; i += 3)
{
Vector3 cr = Vector3.Cross(pts[tris[i + 1]] - pts[tris[i]], pts[tris[i + 2]] - pts[tris[i]]);
float a = cr.magnitude;
if (a > refA) { refA = a; refN = cr; }
}
if (refA < 1e-12f) return Vector3.zero;
refN /= refA;
Vector3 sum = Vector3.zero;
for (int i = 0; i + 2 < tris.Length; i += 3)
{
Vector3 cr = Vector3.Cross(pts[tris[i + 1]] - pts[tris[i]], pts[tris[i + 2]] - pts[tris[i]]);
sum += Vector3.Dot(cr, refN) >= 0f ? cr : -cr;
}
return sum;
}
/// <summary>Kåsa 대수 원피팅. 정규방정식 3×3 을 크래머 공식으로 푼다.</summary>
private static bool FitCircleKasa(float[] xs, float[] ys, out float cx, out float cy)
{
cx = cy = 0f;
int n = xs.Length;
if (n < 3) return false;
double sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0, sxz = 0, syz = 0, sz = 0;
for (int i = 0; i < n; i++)
{
double x = xs[i], y = ys[i], z = x * x + y * y;
sx += x; sy += y; sz += z;
sxx += x * x; syy += y * y; sxy += x * y;
sxz += x * z; syz += y * z;
}
// [sxx sxy sx][D] [-sxz]
// [sxy syy sy][E] = [-syz]
// [sx sy n ][F] [-sz ]
double a11 = sxx, a12 = sxy, a13 = sx;
double a21 = sxy, a22 = syy, a23 = sy;
double a31 = sx, a32 = sy, a33 = n;
double b1 = -sxz, b2 = -syz, b3 = -sz;
double det = a11 * (a22 * a33 - a23 * a32) - a12 * (a21 * a33 - a23 * a31) + a13 * (a21 * a32 - a22 * a31);
if (System.Math.Abs(det) < 1e-12) return false;
double dD = b1 * (a22 * a33 - a23 * a32) - a12 * (b2 * a33 - a23 * b3) + a13 * (b2 * a32 - a22 * b3);
double dE = a11 * (b2 * a33 - a23 * b3) - b1 * (a21 * a33 - a23 * a31) + a13 * (a21 * b3 - b2 * a31);
cx = (float)(-(dD / det) * 0.5);
cy = (float)(-(dE / det) * 0.5);
return !float.IsNaN(cx) && !float.IsNaN(cy) && !float.IsInfinity(cx) && !float.IsInfinity(cy);
}
/// <summary>UV 의 u 가 각도 증가 방향과 반대로 흐르는가 (상관계수 부호).</summary>
private static bool UvRunsAgainstAngle(Vector2[] uvs, float[] ang, float startAng, float sweep)
{
int n = ang.Length;
double su = 0, sa = 0;
var uu = new double[n];
var aa = new double[n];
for (int i = 0; i < n; i++)
{
float rel = Mathf.Repeat(ang[i] - startAng, Mathf.PI * 2f);
if (rel > sweep + 1e-3f) rel -= Mathf.PI * 2f;
uu[i] = uvs[i].x;
aa[i] = rel;
su += uu[i]; sa += aa[i];
}
su /= n; sa /= n;
double cov = 0;
for (int i = 0; i < n; i++) cov += (uu[i] - su) * (aa[i] - sa);
return cov < 0;
}
/// <summary>멱승법으로 최대 분산 축을 구한다.</summary>
private static Vector3 PrincipalAxis(Vector3[] pts, int count, Vector3 center)
{
// 공분산 (대칭 3×3)
float xx = 0, xy = 0, xz = 0, yy = 0, yz = 0, zz = 0;
for (int i = 0; i < count; i++)
{
Vector3 d = pts[i] - center;
xx += d.x * d.x; xy += d.x * d.y; xz += d.x * d.z;
yy += d.y * d.y; yz += d.y * d.z; zz += d.z * d.z;
}
Vector3 v = new Vector3(1f, 0.7f, 0.3f);
for (int it = 0; it < 24; it++)
{
Vector3 w = new Vector3(
xx * v.x + xy * v.y + xz * v.z,
xy * v.x + yy * v.y + yz * v.z,
xz * v.x + yz * v.y + zz * v.z);
float m = w.magnitude;
if (m < 1e-12f) return Vector3.zero;
v = w / m;
}
return v;
}
/// <summary>주축을 제거한 뒤 다시 멱승법 — 두 번째 축.</summary>
private static Vector3 SecondaryAxis(Vector3[] pts, int count, Vector3 center, Vector3 primary)
{
var proj = new Vector3[count];
for (int i = 0; i < count; i++)
{
Vector3 d = pts[i] - center;
proj[i] = center + (d - primary * Vector3.Dot(d, primary));
}
return PrincipalAxis(proj, count, center);
}
private static bool Contains(string[] names, string value)
{
if (names == null) return false;
for (int i = 0; i < names.Length; i++) if (names[i] == value) return true;
return false;
}
private static Vector3 AxisVec(int i) { var v = Vector3.zero; v[i] = 1f; return v; }
private static int MinAxis(Vector3 s)
{
if (s.x <= s.y && s.x <= s.z) return 0;
return s.y <= s.z ? 1 : 2;
}
private static int MaxAxis(Vector3 s)
{
if (s.x >= s.y && s.x >= s.z) return 0;
return s.y >= s.z ? 1 : 2;
}
}
}