// ───────────────────────────────────────────────────────────────────────────── // BladeTrail.cs — 검 손잡이·검끝 두 점을 잇는 월드 공간 리본 메시 // // PD 지시 #747 (2026-09-05) 최초 이식 → **PD 지시 #785 (2026-09-06) 전면 개편** // // 왜 리본인가 (구 WL #718 교훈 · 그대로 유효): // ① 궤적 이펙트를 **무기 본의 자식으로 붙이면 안 된다** — 검과 함께 회전해 // 궤적 위에 놓이지 않는다. 그래서 메시는 부모 없는 월드 공간에 굽는다. // ② **강체 호(크레센트) 프리팹으로 임의 스윙을 맞출 수 없다** — 스폰 순간의 // 한 프레임 속도로 평면을 추정하는데, 검끝은 33ms 에 2.56m 까지 움직여 // 그 추정이 실제 호와 크게 어긋난다(구 WL PM 실측 · #761/#777 에서 재확인). // ③ 정답 = 매 프레임 손잡이·검끝 2점을 모아 그 사이를 리본으로 잇는다. // 검이 실제로 지나간 자리를 그대로 그리므로 "공격 방향에 따라 궤적이 어긋나는" // 문제가 원리적으로 생기지 않는다(#785 지시의 핵심). // // ── #785 개편점 (왜 바꿨는가) ──────────────────────────────────────────────── // ⓐ **나이 기반 롤링 버퍼**. 이전 구조는 Begin() 이 버퍼를 통째로 비웠다. // 3콤보처럼 스윙이 연달아 들어오면 1타 리본이 페이드 도중 잘려 사라진다. // 이제 샘플마다 기록 시각을 들고, 수명(fadeSeconds)이 지난 것부터 앞에서 // 빠져나간다. Begin() 은 "여기서부터 새 띠" 라는 **끊김 표시**만 남긴다. // → 메시는 여전히 **1개 · 드로우 1회**인데 앞 스윙의 잔상이 자연스럽게 녹는다. // ⓑ **그라디언트 2축**. 참고 이미지의 색 배치(안쪽 주황/노랑 → 바깥·꼬리 파랑/보라)를 // Gradient 필드 2개(잔상축 u · 칼날축 v)로 굽는다. 색은 전부 CPU 정점색이라 // 셰이더 분기가 0 이고 텍스처도 필요 없다. // ⓒ **서브스텝 보간**. 한 프레임에 검끝이 크게 움직이면 리본이 각져 보인다. // 직전 샘플과의 거리로 중간 샘플을 최대 maxSubSteps 개 끼운다. 손잡이는 선형, // 칼날 벡터는 **Slerp** 라 직선이 아니라 실제 호를 복원한다. // ⓓ **폭 = 손잡이~검끝 전체**(widthScale 1). PD 지시 "검 끝~손잡이 폭". // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것 // (Actor/PCActor 가 Assembly-CSharp 에 있다). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; namespace WL.Combat { /// /// 스윙 동안 모은 (손잡이, 검끝) 쌍으로 월드 공간 리본 메시를 굽는다. /// 자기 자신이 부모 없는 월드 오브젝트다 — 무기를 따라 끌려다니지 않는다. /// 생성은 로만 한다. /// // 실행 순서를 뒤로 밀어 **WeaponTrailDriver.LateUpdate(샘플 수집)가 끝난 뒤** 굽는다. // 두 LateUpdate 의 순서는 기본값(0)끼리는 보장되지 않아, 반대로 돌면 리본 머리가 // 한 프레임 뒤처져 칼날과 벌어져 보인다(#777 에서 PD 가 지적한 그 증상과 같은 계열). [DefaultExecutionOrder(1000)] [DisallowMultipleComponent] [RequireComponent(typeof(MeshFilter))] [RequireComponent(typeof(MeshRenderer))] public sealed class BladeTrail : MonoBehaviour { private SlashTrailSettings _settings; private Mesh _mesh; private MeshFilter _filter; private MeshRenderer _renderer; private MaterialPropertyBlock _mpb; // ── 샘플 링 (나이 기반) ────────────────────────────────────────────────── private Vector3[] _hilt; private Vector3[] _tip; private float[] _stamp; // 기록 시각(Time.time). 나이 = now - stamp private bool[] _newStrip; // true = 이 샘플부터 새 띠(앞 샘플과 잇지 않는다) private int _count; private bool _capturing; private bool _pendingBreak; // 다음 Sample 을 새 띠의 첫 점으로 찍는다 private int _layerCount = 1; // 겹별 색(그라디언트를 끈 경우에만 쓰는 단색 경로). private Color _colorAdditive = Color.white; private Color _colorAlpha = Color.white; private float _widthScale = 1f; // 메시 버퍼 — 매 프레임 새로 할당하지 않는다. private Vector3[] _verts; private Vector3[] _norms; private Vector2[] _uv0; private Color[] _cols; private int[] _tris; private int[] _triCount; // 겹별 실제 인덱스 수(끊김 때문에 최대치보다 작다) private readonly List _uv1 = new List(); private readonly List _uv2 = new List(); // ── 검증용 공개 프로퍼티 (PM CSV 샘플러가 읽는다) ────────────────────── /// 지금 궤적을 모으는 중인가. public bool IsCapturing { get { return _capturing; } } /// 살아 있는 샘플 수. public int SampleCount { get { return _count; } } /// 트레일 메시가 실제로 보이는가. public bool IsVisible { get { return _renderer != null && _renderer.enabled; } } /// i 번째 검끝 샘플(월드). public Vector3 GetTip(int i) { return (i >= 0 && i < _count) ? _tip[i] : Vector3.zero; } /// i 번째 손잡이 샘플(월드). public Vector3 GetHilt(int i) { return (i >= 0 && i < _count) ? _hilt[i] : Vector3.zero; } /// i 번째 샘플의 나이(초). public float GetAge(int i) { return (i >= 0 && i < _count) ? (Time.time - _stamp[i]) : -1f; } /// 마지막 검끝 샘플(월드). 샘플이 없으면 Vector3.zero. public Vector3 LastTip { get { return _count > 0 ? _tip[_count - 1] : Vector3.zero; } } /// 마지막 손잡이 샘플(월드). 샘플이 없으면 Vector3.zero. public Vector3 LastHilt { get { return _count > 0 ? _hilt[_count - 1] : Vector3.zero; } } /// 이번 프레임에 그린 삼각형 수(예산 검증용). public int TriangleCount { get; private set; } /// 서브스텝 보간으로 끼워 넣은 누적 샘플 수(검증용). public int InterpolatedSamples { get; private set; } /// /// 월드 공간 트레일 오브젝트를 새로 만든다. /// 부모를 두지 않는다 — 무기 본에 붙이면 궤적 위에 놓이지 않는다(구 WL #718 ①). /// public static BladeTrail Create(SlashTrailSettings settings, string objectName) { var go = new GameObject(string.IsNullOrEmpty(objectName) ? "BladeTrail" : objectName); go.transform.SetParent(null, false); go.hideFlags = HideFlags.DontSave; var trail = go.AddComponent(); trail.Configure(settings); return trail; } /// 설정을 주입하고 버퍼·머티리얼을 준비한다. 색/폭 오버라이드는 로. public void Configure(SlashTrailSettings settings) { _settings = settings; _filter = GetComponent(); _renderer = GetComponent(); int cap = _settings != null ? Mathf.Clamp(_settings.maxSamples, 4, 256) : 64; if (_hilt == null || _hilt.Length != cap) { _hilt = new Vector3[cap]; _tip = new Vector3[cap]; _stamp = new float[cap]; _newStrip = new bool[cap]; _count = 0; } if (_mesh == null) { _mesh = new Mesh(); _mesh.name = "BladeTrailMesh"; _mesh.MarkDynamic(); _filter.sharedMesh = _mesh; } _renderer.shadowCastingMode = ShadowCastingMode.Off; _renderer.receiveShadows = false; _renderer.lightProbeUsage = LightProbeUsage.Off; _renderer.reflectionProbeUsage = ReflectionProbeUsage.Off; _renderer.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion; _renderer.allowOcclusionWhenDynamic = false; _renderer.enabled = false; // 겹 = 머티리얼 수. #785 기본은 리본 전용 셰이더 1장 = 드로우 1회. var mats = new List(2); if (_settings != null && _settings.materialAdditive != null) mats.Add(_settings.materialAdditive); if (_settings != null && _settings.materialAlpha != null) mats.Add(_settings.materialAlpha); if (mats.Count > 0) _renderer.sharedMaterials = mats.ToArray(); _layerCount = Mathf.Max(mats.Count, 1); // 정점 버퍼는 **최대 크기로 1회만** 잡는다. 샘플이 늘 때마다 다시 잡으면 // 스윙 중 매 프레임 GC 할당이 생긴다(판정 기준 = 프레임당 alloc 0). int maxVerts = cap * 2 * _layerCount; int maxTris = (cap - 1) * 6 * _layerCount; if (_verts == null || _verts.Length != maxVerts) { _verts = new Vector3[maxVerts]; _norms = new Vector3[maxVerts]; _uv0 = new Vector2[maxVerts]; _cols = new Color[maxVerts]; } if (_tris == null || _tris.Length != maxTris) _tris = new int[maxTris]; if (_triCount == null || _triCount.Length != _layerCount) _triCount = new int[_layerCount]; _uv1.Capacity = maxVerts; _uv2.Capacity = maxVerts; if (_settings != null) { _colorAdditive = _settings.colorAdditive; _colorAlpha = _settings.colorAlpha; _widthScale = _settings.widthScale; } } /// 클래스 오버라이드로 색·폭을 바꾼다(SlashTrailClassOverride.overrideLook). public void SetLook(Color additive, Color alpha, float widthScale) { _colorAdditive = additive; _colorAlpha = alpha; _widthScale = Mathf.Clamp01(widthScale); } /// 연결된 머티리얼이 하나라도 있는가. 없으면 그려도 보이지 않는다. public bool HasMaterial { get { return _settings != null && (_settings.materialAdditive != null || _settings.materialAlpha != null); } } private void OnDestroy() { if (_mesh != null) { if (Application.isPlaying) Destroy(_mesh); else DestroyImmediate(_mesh); _mesh = null; } } // ── 수집 제어 ──────────────────────────────────────────────────────────── /// /// 스윙 시작. #785 이후로 **버퍼를 비우지 않는다** — 앞 스윙의 잔상이 수명대로 /// 계속 녹도록 두고, 다음 샘플에 "새 띠 시작" 표시만 남긴다(콤보 연타 대응). /// public void Begin() { _capturing = true; _pendingBreak = true; InterpolatedSamples = 0; } /// 스윙 끝 — 수집만 멈춘다. 남은 리본은 샘플 나이대로 알아서 사라진다. public void End() { _capturing = false; _pendingBreak = false; } /// 즉시 지운다(콤보 취소·피격·사망). public void Clear() { _capturing = false; _pendingBreak = false; _count = 0; TriangleCount = 0; if (_renderer != null) _renderer.enabled = false; } /// 샘플 수명(초). 이 시간이 지난 샘플은 리본에서 빠진다. private float LifeSeconds { get { return _settings != null ? Mathf.Max(_settings.fadeSeconds, 0.02f) : 0.2f; } } /// /// 한 프레임의 두 점을 보고한다. 반드시 LateUpdate(애니메이션 적용 후)에서 부른다. /// 직전 샘플과 검끝 거리가 minSampleDistance 미만이면 버린다 — 정지 중 정점이 /// 겹쳐 면이 뒤집히는 것을 막는다. 거리가 크면 서브스텝을 끼워 호를 복원한다. /// public void Sample(Vector3 hilt, Vector3 tip) { if (!_capturing || _hilt == null) return; float now = Time.time; if (_count > 0 && !_pendingBreak) { float minDist = _settings != null ? Mathf.Max(_settings.minSampleDistance, 0f) : 0.004f; if (minDist > 0f && (tip - _tip[_count - 1]).sqrMagnitude < minDist * minDist) return; // ── 서브스텝 보간 ──────────────────────────────────────────────── // 손잡이는 선형, 칼날 벡터는 Slerp — 직선 보간이면 호가 다각형이 된다. float maxSeg = _settings != null ? _settings.maxSegmentDistance : 0f; int maxSub = _settings != null ? _settings.maxSubSteps : 0; if (maxSeg > 0f && maxSub > 0) { Vector3 pHilt = _hilt[_count - 1]; Vector3 pTip = _tip[_count - 1]; float pStamp = _stamp[_count - 1]; float moved = (tip - pTip).magnitude; int extra = Mathf.Min(Mathf.CeilToInt(moved / maxSeg) - 1, maxSub); if (extra > 0) { Vector3 dPrev = pTip - pHilt; Vector3 dNow = tip - hilt; for (int k = 1; k <= extra; k++) { float t = (float)k / (extra + 1); Vector3 h = Vector3.Lerp(pHilt, hilt, t); Vector3 d = Vector3.Slerp(dPrev, dNow, t); Push(h, h + d, Mathf.Lerp(pStamp, now, t), false); InterpolatedSamples++; } } } } Push(hilt, tip, now, _pendingBreak); _pendingBreak = false; // 메시는 여기서 굽지 않는다 — LateUpdate(실행순서 1000)에서 프레임당 1회만 굽는다. } /// 샘플 1개를 링에 밀어 넣는다. 가득 차면 가장 오래된 것을 버린다. private void Push(Vector3 hilt, Vector3 tip, float stamp, bool newStrip) { if (_count >= _hilt.Length) DropOldest(1); _hilt[_count] = hilt; _tip[_count] = tip; _stamp[_count] = stamp; _newStrip[_count] = newStrip || _count == 0; _count++; } /// 앞에서 n 개를 버린다. 새 선두는 자동으로 띠의 시작이 된다. private void DropOldest(int n) { if (n <= 0) return; if (n >= _count) { _count = 0; return; } int keep = _count - n; System.Array.Copy(_hilt, n, _hilt, 0, keep); System.Array.Copy(_tip, n, _tip, 0, keep); System.Array.Copy(_stamp, n, _stamp, 0, keep); System.Array.Copy(_newStrip, n, _newStrip, 0, keep); _count = keep; if (_count > 0) _newStrip[0] = true; } private void LateUpdate() { if (_count == 0) return; // 수명이 다한 샘플을 앞에서 걷어낸다 — 꼬리부터 자연스럽게 녹는다. float life = LifeSeconds; float now = Time.time; int expired = 0; while (expired < _count && (now - _stamp[expired]) > life) expired++; if (expired > 0) DropOldest(expired); Rebuild(); } // ── 메시 생성 ──────────────────────────────────────────────────────────── /// /// 모아 둔 점들로 리본 메시를 다시 굽는다. /// 겹(서브메시)마다 정점 세트를 따로 만든다 — 겹마다 색·UV 가 다르기 때문. /// private void Rebuild() { if (_renderer == null || _mesh == null) return; // 리본 OFF 스위치(C8 롤백). 궤적 샘플링은 계속하고 그리기만 멈춘다 — // PrefabArc 레거시 경로가 이 샘플로 평면을 구하므로 수집 자체를 끄면 안 된다. if (_settings != null && !_settings.drawRibbon) { _renderer.enabled = false; TriangleCount = 0; return; } if (_count < 2) { _renderer.enabled = false; TriangleCount = 0; return; } bool useGrad = _settings == null || _settings.useGradients; float tailRatio = _settings != null ? Mathf.Clamp01(_settings.tailWidthRatio) : 1f; float tipExtend = _settings != null ? Mathf.Clamp(_settings.tipExtendRatio, 0f, 0.5f) : 0f; bool writeCustom = _settings != null && _settings.writeCustomDataChannels; float hold = _settings != null ? Mathf.Clamp(_settings.fadeHoldRatio, 0f, 0.95f) : 0.35f; float intensity = _settings != null ? Mathf.Max(_settings.colorIntensity, 0f) : 1f; float bladeW = _settings != null ? Mathf.Clamp01(_settings.bladeGradientWeight) : 0.5f; float life = LifeSeconds; float now = Time.time; // 칼날축 그라디언트는 v = 0 / 1 두 점에서만 쓰므로 프레임당 2회만 평가한다. Color bladeInner = Color.white, bladeOuter = Color.white; if (useGrad && _settings != null && _settings.gradientAlongBlade != null) { bladeInner = _settings.gradientAlongBlade.Evaluate(0f); bladeOuter = _settings.gradientAlongBlade.Evaluate(1f); } int n = _count; int layers = Mathf.Max(_layerCount, 1); int perLayer = n * 2; int totalVerts = perLayer * layers; int maxTrisPerLayer = (n - 1) * 6; // 버퍼는 Configure 에서 최대 크기로 한 번만 잡았다(스윙 중 GC alloc 0). // 방어적으로 모자랄 때만 다시 잡는다 — 정상 경로에서는 절대 타지 않는다. if (_verts == null || _verts.Length < totalVerts) { _verts = new Vector3[totalVerts]; _norms = new Vector3[totalVerts]; _uv0 = new Vector2[totalVerts]; _cols = new Color[totalVerts]; } if (_tris == null || _tris.Length < maxTrisPerLayer * layers) _tris = new int[maxTrisPerLayer * layers]; if (_triCount == null || _triCount.Length < layers) _triCount = new int[layers]; // 리본 평면의 법선 — 언릿이라 셰이딩엔 안 쓰지만 정점 스트림을 비워 두지 않는다. Vector3 nrm = Vector3.Cross(_tip[n - 1] - _tip[0], _hilt[n / 2] - _tip[n / 2]); if (nrm.sqrMagnitude < 1e-8f) nrm = Vector3.up; else nrm.Normalize(); if (writeCustom) { _uv1.Clear(); _uv2.Clear(); } for (int L = 0; L < layers; L++) { int baseV = L * perLayer; bool isAlphaLayer = (L == 1); bool fullUv = isAlphaLayer && (_settings == null || _settings.alphaLayerUvFull); float vMin = fullUv ? 0f : (_settings != null ? _settings.uvVMin : 0f); float vMax = fullUv ? 1f : (_settings != null ? _settings.uvVMax : 1f); Color flat = isAlphaLayer ? _colorAlpha : _colorAdditive; for (int i = 0; i < n; i++) { Vector3 h = _hilt[i]; Vector3 t = _tip[i]; // u = 잔상 수명. 1 = 방금(검의 현재 위치) · 0 = 소멸 직전. float aged = Mathf.Clamp01((now - _stamp[i]) / life); float u = 1f - aged; // 나이 알파 — hold 구간까지는 원색, 그 뒤 선형 소멸. float ageAlpha = aged <= hold ? 1f : 1f - (aged - hold) / (1f - hold); // 꼬리로 갈수록 얇아진다(tailWidthRatio 1 이면 폭 일정 = PD 지시 기본값). float w = _widthScale * Mathf.Lerp(tailRatio, 1f, u); Vector3 inner = Vector3.Lerp(t, h, w); Vector3 outer = t; if (tipExtend > 0f) outer = t + (t - h) * tipExtend; Color cIn, cOut; if (useGrad) { // 잔상축(u) 색과 칼날축(v) 색을 bladeGradientWeight 로 섞는다. // 곱이 아니라 Lerp 인 이유 — 두 색을 곱하면 채도가 죽고 어두워진다. Color cU = (_settings != null && _settings.gradientAlongSwing != null) ? _settings.gradientAlongSwing.Evaluate(u) : Color.white; cIn = Color.Lerp(cU, bladeInner, bladeW); cOut = Color.Lerp(cU, bladeOuter, bladeW); float baseA = cU.a * ageAlpha; cIn.r *= intensity; cIn.g *= intensity; cIn.b *= intensity; cOut.r *= intensity; cOut.g *= intensity; cOut.b *= intensity; cIn.a = baseA * bladeInner.a; // 칼날축 알파로 안/바깥 세기를 따로 준다 cOut.a = baseA * bladeOuter.a; } else { cIn = cOut = new Color(flat.r, flat.g, flat.b, flat.a * ageAlpha); } int a0 = baseV + i * 2, b0 = a0 + 1; _verts[a0] = inner; _verts[b0] = outer; _norms[a0] = nrm; _norms[b0] = nrm; // v = 0 → 안쪽(손잡이) · v = 1 → 칼끝(바깥). 셰이더가 양 끝을 페이드한다. _uv0[a0] = new Vector2(u, vMin); _uv0[b0] = new Vector2(u, vMax); _cols[a0] = cIn; _cols[b0] = cOut; if (writeCustom) { var col2 = new Vector4(cIn.r, cIn.g, cIn.b, cIn.a); var col1 = new Vector4(aged, 0f, 0f, 0f); _uv1.Add(col1); _uv1.Add(col1); _uv2.Add(col2); _uv2.Add(col2); } } // 삼각형 — 끊김(새 띠 시작) 구간은 잇지 않는다. int o = L * maxTrisPerLayer; int written = 0; for (int i = 0; i < n - 1; i++) { if (_newStrip[i + 1]) continue; int v = baseV + i * 2; _tris[o + written] = v; _tris[o + written + 1] = v + 1; _tris[o + written + 2] = v + 2; _tris[o + written + 3] = v + 1; _tris[o + written + 4] = v + 3; _tris[o + written + 5] = v + 2; written += 6; } _triCount[L] = written; } int totalIndices = 0; for (int L = 0; L < layers; L++) totalIndices += _triCount[L]; TriangleCount = totalIndices / 3; if (totalIndices == 0) { _renderer.enabled = false; return; } _mesh.Clear(); // 배열 전체가 아니라 **이번 프레임에 실제로 쓴 구간만** 넘긴다(버퍼 재할당 없음 = GC alloc 0). _mesh.SetVertices(_verts, 0, totalVerts); _mesh.SetNormals(_norms, 0, totalVerts); _mesh.SetUVs(0, _uv0, 0, totalVerts); _mesh.SetColors(_cols, 0, totalVerts); // 리본 셰이더가 정점색을 곱한다 if (writeCustom) { _mesh.SetUVs(1, _uv1); // 구 WL NamuFX Custom Data 1 (디졸브) _mesh.SetUVs(2, _uv2); // 구 WL NamuFX Custom Data 2 (색 HDR + 알파) } _mesh.subMeshCount = layers; for (int L = 0; L < layers; L++) _mesh.SetTriangles(_tris, L * maxTrisPerLayer, _triCount[L], L, false); _mesh.RecalculateBounds(); if (_settings != null && _settings.alsoApplyColorViaPropertyBlock) ApplyColorPropertyBlock(); _renderer.enabled = true; } /// 정점색이 먹지 않는 셰이더용 예비 경로. _BaseColor 에 대표 색을 넣는다. private void ApplyColorPropertyBlock() { if (_mpb == null) _mpb = new MaterialPropertyBlock(); int layers = Mathf.Max(_layerCount, 1); for (int L = 0; L < layers; L++) { Color c = (L == 1) ? _colorAlpha : _colorAdditive; _mpb.Clear(); _mpb.SetColor(ShaderIds.BaseColor, c); _renderer.SetPropertyBlock(_mpb, L); } } /// 셰이더 프로퍼티 ID 캐시(매 프레임 문자열 해싱을 피한다). private static class ShaderIds { public static readonly int BaseColor = Shader.PropertyToID("_BaseColor"); } #if UNITY_EDITOR private void OnDrawGizmos() { if (_settings == null || !_settings.drawGizmos || _count < 2) return; Gizmos.color = Color.cyan; for (int i = 1; i < _count; i++) if (!_newStrip[i]) Gizmos.DrawLine(_tip[i - 1], _tip[i]); Gizmos.color = Color.yellow; for (int i = 1; i < _count; i++) if (!_newStrip[i]) Gizmos.DrawLine(_hilt[i - 1], _hilt[i]); } #endif } }