Files
XRLib/Assets/Scripts/SHI/modal/NW/NWModelVIew.cs
2025-12-02 21:09:37 +09:00

837 lines
30 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using GLTFast;
using UnityEngine;
using UnityEngine.UIElements;
using UVC.Util;
namespace SHI.Modal.NW
{
/// <summary>
/// glTF/glb 3D 모델을 로드하고 UI Toolkit에서 실시간 렌더링하는 뷰 컴포넌트입니다.
///
/// <para><b>개요:</b></para>
/// <para>
/// glTFast 라이브러리를 사용하여 3D 모델을 비동기 로드하고,
/// 전용 카메라로 오프스크린 렌더링하여 RenderTexture를 UI Toolkit의 배경으로 출력합니다.
/// ISOPModelView와 동일한 기능을 제공하며, NWModal에서 사용됩니다.
/// </para>
///
/// <para><b>주요 기능:</b></para>
/// <list type="bullet">
/// <item>glTF/glb 모델 비동기 로드 (GLTFast)</item>
/// <item>오프스크린 렌더링 (전용 카메라 + RenderTexture)</item>
/// <item>마우스 조작: 가운데 버튼=이동, 오른쪽 버튼=회전, 휠=확대/축소</item>
/// <item>와이어프레임 모드 토글</item>
/// <item>항목 하이라이트 및 가시성 제어</item>
/// <item>TreeList와 연동되는 계층 구조 생성</item>
/// </list>
///
/// <para><b>사용 예시:</b></para>
/// <code>
/// var modelView = root.Q&lt;NWModelView&gt;();
/// var items = await modelView.LoadModelAsync(gltfPath, cancellationToken);
/// treeList.SetData(items.ToList());
/// modelView.FocusItemById(itemId);
/// </code>
///
/// <para><b>렌더링 구조:</b></para>
/// <list type="bullet">
/// <item>NWModelViewRig - 카메라와 조명을 담는 부모 GameObject</item>
/// <item>NWModelViewCamera - 전용 렌더링 카메라 (Layer 6만 렌더링)</item>
/// <item>NWModelViewRoot - 로드된 모델의 루트 GameObject</item>
/// </list>
/// </summary>
[UxmlElement]
public partial class NWModelView : VisualElement, IDisposable
{
#region IDisposable
private bool _disposed = false;
#endregion
#region (Public Events)
/// <summary>
/// 뷰 내부에서 항목이 선택될 때 발생합니다.
/// TreeList와의 선택 동기화에 사용됩니다.
/// </summary>
public Action<TreeListItemData>? OnItemSelected;
/// <summary>
/// 모델 뷰 확장 버튼이 클릭될 때 발생합니다.
/// NWModal에서 모델 뷰를 전체 화면으로 확장하는 데 사용됩니다.
/// </summary>
public Action? OnExpand;
#endregion
#region (Constants and Configuration)
/// <summary>UXML 파일 경로 (Resources 폴더 기준)</summary>
private const string UXML_PATH = "SHI/Modal/NW/NWModelView";
/// <summary>카메라 배경색 (흰색)</summary>
private Color cameraBackgroundColor = new Color(1f, 1f, 1f, 1f);
/// <summary>모델이 렌더링되는 레이어 (Layer 6)</summary>
private int modelLayer = 6;
/// <summary>기본 조명 생성 여부</summary>
private bool createDefaultLight = true;
/// <summary>이동 속도 (가운데 마우스 드래그)</summary>
private float panSpeed = 1.0f;
/// <summary>회전 감도 (픽셀당 회전 각도)</summary>
private float rotateDegPerPixel = 0.2f;
/// <summary>확대/축소 속도 (마우스 휠)</summary>
private float zoomSpeed = 5f;
/// <summary>와이어프레임 모드 활성화 여부</summary>
private bool wireframeMode = true;
#endregion
private Camera? _viewCamera;
private RenderTexture? _rt;
private Material? _wireframeMat;
private bool _wireframeApplied;
// Orbit controls state
private Vector3 _orbitTarget;
private float _orbitDistance = 5f;
private float _yaw = 0f;
private float _pitch = 20f;
// Drag state
private bool _mmbDragging;
private bool _rmbDragging;
private Vector3 _mmbLastPos;
private Vector3 _rmbStartPos;
private float _yawStart;
private float _pitchStart;
private Quaternion _modelStartRot;
private Vector3 _modelStartPos;
private Vector3 _rmbPivot;
private readonly Dictionary<int, GameObject> _idToObject = new Dictionary<int, GameObject>();
private readonly Dictionary<Renderer, UnityEngine.Material[]> _originalSharedByRenderer = new Dictionary<Renderer, UnityEngine.Material[]>();
private GameObject? _root;
private int? _focusedId;
// UI Element for rendering
private VisualElement? _renderContainer;
private Button? _expandBtn;
private int itemIdSeed = 1;
public NWModelView()
{
// UXML 로드
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
if (visualTree != null)
{
visualTree.CloneTree(this);
}
else
{
Debug.LogError($"Failed to load UXML at path: {UXML_PATH}");
}
// 렌더링 컨테이너 생성
_renderContainer = this.Q<VisualElement>("render-container");
_expandBtn = this.Q<Button>("expand-btn");
if(_expandBtn != null)
{
_expandBtn.clicked += OnExpandBtnClicked;
}
// 마우스 이벤트 등록
RegisterCallback<MouseDownEvent>(OnMouseDown);
RegisterCallback<MouseUpEvent>(OnMouseUp);
RegisterCallback<MouseMoveEvent>(OnMouseMove);
RegisterCallback<WheelEvent>(OnWheel);
RegisterCallback<GeometryChangedEvent>(OnGeometryChanged);
}
private void OnExpandBtnClicked()
{
OnExpand?.Invoke();
}
/// <summary>
/// 주어진 경로들의 glTF 모델을 비동기로 로드하고, UI 트리에 사용할 계층 항목을 생성합니다.
/// </summary>
/// <param name="paths">로드할 glTF/glb 파일 경로 목록</param>
/// <param name="ct">취소 토큰</param>
/// <returns>로드된 모델들의 계층 항목 목록</returns>
public async UniTask<IEnumerable<TreeListItemData>> LoadModelAsync(List<string> paths, CancellationToken ct)
{
Debug.Log($"NWModelView.LoadModelAsync: {paths?.Count ?? 0} files");
CleanupForReload();
await UniTask.DelayFrame(1);
EnsureCameraAndTargetTexture();
var items = new List<TreeListItemData>();
if (paths == null || paths.Count == 0)
{
Debug.LogWarning("NWModelView.LoadModelAsync: No paths provided");
return items;
}
if (_root == null) _root = new GameObject("NWModelViewRoot");
_root.layer = modelLayer;
// 각 파일을 순차적으로 로드
foreach (var path in paths)
{
if (string.IsNullOrEmpty(path)) continue;
Debug.Log($"NWModelView.LoadModelAsync: Loading {path}");
var gltf = new GltfImport();
var success = await gltf.Load(path, new ImportSettings(), ct);
if (!success)
{
Debug.LogError($"glTFast Load failed: {path}");
continue;
}
var sceneOk = await gltf.InstantiateMainSceneAsync(_root.transform);
if (!sceneOk)
{
Debug.LogError($"InstantiateMainSceneAsync failed: {path}");
continue;
}
}
SetLayerRecursive(_root, modelLayer);
// 로드된 모든 자식에서 TreeListItemData 생성
if (_root != null)
{
for (int i = 0; i < _root.transform.childCount; i++)
{
var child = _root.transform.GetChild(i);
var topItem = BuildItemRecursive(child, _root.transform);
if (topItem != null)
{
items.Add(topItem);
}
}
}
var bounds = CalculateBounds(_root);
FrameToBounds(bounds);
TryLoadWireframeMaterial();
if (wireframeMode && _wireframeMat != null)
{
ApplyWireframeMaterialToRoot();
}
return items;
}
private TreeListItemData BuildItemRecursive(Transform node, Transform root)
{
var data = new TreeListItemData
{
id = itemIdSeed++,
name = node.name,
isExpanded = true
};
_idToObject[data.id] = node.gameObject;
CacheOriginalMaterials(node.gameObject);
for (int i = 0; i < node.childCount; i++)
{
var child = node.GetChild(i);
var childData = BuildItemRecursive(child, root);
data.Add(childData);
}
return data;
}
private void TryLoadWireframeMaterial()
{
if (_wireframeMat == null)
{
_wireframeMat = Resources.Load<Material>("SHI/Shader/BasicWireframe");
if (_wireframeMat == null)
{
Debug.LogWarning("BasicWireframe material not found at Resources/SHI/Shader/BasicWireframe.");
}
}
}
private void ApplyWireframeMaterialToRoot()
{
if (_root == null || _wireframeMat == null) return;
var rends = _root.GetComponentsInChildren<Renderer>(true);
foreach (var r in rends)
{
if (r == null) continue;
var count = Mathf.Max(1, r.sharedMaterials.Length);
var arr = new Material[count];
for (int i = 0; i < count; i++) arr[i] = _wireframeMat;
r.sharedMaterials = arr;
}
_wireframeApplied = true;
}
private void RestoreAllOriginalMaterials()
{
foreach (var kv in _originalSharedByRenderer)
{
var r = kv.Key;
if (r == null) continue;
var originals = kv.Value;
r.sharedMaterials = originals;
}
_wireframeApplied = false;
}
private void EnsureCameraAndTargetTexture()
{
if (_viewCamera == null)
{
var rig = new GameObject("NWModelViewRig");
rig.layer = modelLayer;
rig.transform.SetParent(null, false);
var camGo = new GameObject("NWModelViewCamera");
camGo.layer = modelLayer;
camGo.transform.SetParent(rig.transform, false);
_viewCamera = camGo.AddComponent<UnityEngine.Camera>();
_viewCamera.clearFlags = CameraClearFlags.SolidColor;
_viewCamera.backgroundColor = cameraBackgroundColor;
_viewCamera.nearClipPlane = 0.01f;
_viewCamera.farClipPlane = 5000f;
_viewCamera.cullingMask = (modelLayer >= 0 && modelLayer <= 31) ? (1 << modelLayer) : ~0;
_viewCamera.targetDisplay = 1;
_viewCamera.enabled = false;
}
if (createDefaultLight && _viewCamera.transform.parent != null && _viewCamera.transform.parent.Find("NWModelViewLight") == null)
{
var lightGo = new GameObject("NWModelViewLight");
lightGo.layer = modelLayer;
lightGo.transform.SetParent(_viewCamera.transform.parent, false);
lightGo.transform.localPosition = Vector3.zero;
lightGo.transform.localRotation = Quaternion.Euler(50f, -30f, 0f);
var light = lightGo.AddComponent<Light>();
light.type = LightType.Directional;
light.intensity = 1.1f;
light.shadows = LightShadows.Soft;
}
EnsureRenderTargetSize();
}
// 고정 RenderTexture 크기 (컨테이너 크기 변경에 영향받지 않음)
private const int FIXED_RT_WIDTH = 1920;
private const int FIXED_RT_HEIGHT = 1080;
private void EnsureRenderTargetSize()
{
if (_viewCamera == null || _renderContainer == null)
{
Debug.LogWarning($"[NWModelView] EnsureRenderTargetSize: camera={_viewCamera != null}, container={_renderContainer != null}");
return;
}
// 고정 크기 사용
int w = FIXED_RT_WIDTH;
int h = FIXED_RT_HEIGHT;
if (_rt == null)
{
_rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32)
{
name = "NWModelViewRT",
antiAliasing = 2
};
_viewCamera.targetTexture = _rt;
_viewCamera.aspect = (float)w / h;
_viewCamera.enabled = true;
// UIElements에 렌더텍스처 적용 (크기 고정, 넘치면 클리핑)
_renderContainer.style.backgroundImage = new StyleBackground(Background.FromRenderTexture(_rt));
// 컨테이너 너비에 맞추고 높이는 비율 유지 (넘치면 아래가 잘림)
_renderContainer.style.backgroundSize = new BackgroundSize(Length.Percent(100), Length.Auto());
_renderContainer.style.backgroundPositionX = new BackgroundPosition(BackgroundPositionKeyword.Center);
_renderContainer.style.backgroundPositionY = new BackgroundPosition(BackgroundPositionKeyword.Top);
_renderContainer.style.overflow = Overflow.Hidden;
}
else
{
if (_viewCamera.targetTexture != _rt)
{
_viewCamera.targetTexture = _rt;
}
if (!_viewCamera.enabled) _viewCamera.enabled = true;
}
}
private void OnGeometryChanged(GeometryChangedEvent evt)
{
EnsureRenderTargetSize();
}
/// <summary>
/// 와이어프레임 모드를 토글합니다.
/// </summary>
public void SetWireframe(bool on)
{
wireframeMode = on;
TryLoadWireframeMaterial();
if (_wireframeMat != null)
{
if (on) ApplyWireframeMaterialToRoot(); else RestoreAllOriginalMaterials();
}
}
/// <summary>
/// 지정한 항목을 포커스(하이라이트)합니다.
/// </summary>
public void FocusItem(TreeListItemData data)
{
if (data == null) return;
FocusItemById(data.id);
}
public void FocusItemById(int id)
{
_focusedId = id;
if (_idToObject.TryGetValue(id, out var go))
{
Highlight(go, true);
_orbitTarget = go.transform.position;
}
}
public void UnfocusItem()
{
if (_focusedId.HasValue && _idToObject.TryGetValue(_focusedId.Value, out var go))
{
Highlight(go, false);
}
_focusedId = null;
}
public void Export(int id)
{
if (_idToObject.TryGetValue(id, out var go))
{
UVC.GLTF.GLTFExporter.ExportNodeByExplorer(go);
}
}
public void SetVisibility(int id, bool on)
{
if (_idToObject.TryGetValue(id, out var go))
{
go.SetActive(on);
}
}
private static readonly string[] ColorProps = new[] {
"_WireframeColor", "_WireColor",
"_BaseColor", "_Color", "_LineColor", "_TintColor"
};
private bool TrySetColor(Material mat, Color c)
{
for (int i = 0; i < ColorProps.Length; i++)
{
var prop = ColorProps[i];
if (mat != null && mat.HasProperty(prop)) { mat.SetColor(prop, c); return true; }
}
try { mat.color = c; return true; } catch { /* ignore */ }
return false;
}
private bool TryGetColor(Material mat, out Color c)
{
for (int i = 0; i < ColorProps.Length; i++)
{
var prop = ColorProps[i];
if (mat != null && mat.HasProperty(prop)) { c = mat.GetColor(prop); return true; }
}
try { c = mat.color; return true; } catch { /* ignore */ }
c = ColorUtil.FromHex("#888888"); return false;
}
private bool TryGetDefaultWireColor(out Color c)
{
if (_wireframeMat != null && TryGetColor(_wireframeMat, out c)) return true;
c = ColorUtil.FromHex("#888888"); return false;
}
private void Highlight(GameObject go, bool on)
{
var rends = go.GetComponentsInChildren<Renderer>(true);
if (rends == null || rends.Length == 0) return;
for (int i = 0; i < rends.Length; i++)
{
var r = rends[i];
if (r == null) continue;
var mats = r.materials;
for (int m = 0; m < mats.Length; m++)
{
if (on)
{
TrySetColor(mats[m], ColorUtil.FromHex("#888814"));
}
else
{
bool matIsWire = mats[m] != null && (mats[m].HasProperty("_WireframeColor") || mats[m].HasProperty("_WireColor"));
if (_wireframeApplied || matIsWire)
{
Color baseWire;
if (TryGetDefaultWireColor(out baseWire))
TrySetColor(mats[m], baseWire);
else
TrySetColor(mats[m], ColorUtil.FromHex("#888888"));
}
else
{
Color orig;
UnityEngine.Material[] originals;
if (_originalSharedByRenderer.TryGetValue(r, out originals)
&& m < originals.Length && originals[m] != null
&& TryGetColor(originals[m], out orig))
{
TrySetColor(mats[m], orig);
}
else
{
TrySetColor(mats[m], ColorUtil.FromHex("#888888"));
}
}
}
}
r.materials = mats;
}
}
public void RaiseSelected(TreeListItemData data)
{
OnItemSelected?.Invoke(data);
}
// 마우스 이벤트 처리
private void OnMouseDown(MouseDownEvent evt)
{
if (evt.button == 2) // Middle mouse button
{
_mmbDragging = true;
_mmbLastPos = evt.mousePosition;
evt.StopPropagation();
}
else if (evt.button == 1) // Right mouse button
{
_rmbDragging = true;
_rmbStartPos = evt.mousePosition;
_yawStart = _yaw;
_pitchStart = _pitch;
if (_root != null)
{
_modelStartRot = _root.transform.rotation;
_modelStartPos = _root.transform.position;
}
_rmbPivot = _orbitTarget;
evt.StopPropagation();
}
}
private void OnMouseUp(MouseUpEvent evt)
{
if (evt.button == 2)
{
_mmbDragging = false;
evt.StopPropagation();
}
else if (evt.button == 1)
{
_rmbDragging = false;
evt.StopPropagation();
}
}
private void OnMouseMove(MouseMoveEvent evt)
{
if (_viewCamera == null) return;
// 가운데 버튼: 모델 이동
if (_mmbDragging && _root != null)
{
Vector3 cur = evt.mousePosition;
Vector2 dp = (Vector2)(cur - _mmbLastPos);
_mmbLastPos = cur;
float wPix = Mathf.Max(1f, _renderContainer?.resolvedStyle.width ?? 1f);
float hPix = Mathf.Max(1f, _renderContainer?.resolvedStyle.height ?? 1f);
float halfV = Mathf.Tan(_viewCamera.fieldOfView * Mathf.Deg2Rad * 0.5f) * _orbitDistance;
float halfH = halfV * _viewCamera.aspect;
float worldPerPixelX = (halfH * 2f) / wPix * panSpeed;
float worldPerPixelY = (halfV * 2f) / hPix * panSpeed;
Vector3 deltaModel = _viewCamera.transform.right * (dp.x * worldPerPixelX) + _viewCamera.transform.up * (-dp.y * worldPerPixelY);
_root.transform.position += deltaModel;
_orbitTarget += deltaModel;
evt.StopPropagation();
}
// 오른쪽 버튼: 모델 회전
if (_rmbDragging && _root != null)
{
Vector3 cur = evt.mousePosition;
Vector2 dpAbs = (Vector2)(cur - _rmbStartPos);
float yaw = -dpAbs.x * rotateDegPerPixel;
float pitch = -dpAbs.y * rotateDegPerPixel;
Quaternion yawQ = Quaternion.AngleAxis(yaw, _viewCamera.transform.up);
Quaternion pitchQ = Quaternion.AngleAxis(pitch, _viewCamera.transform.right);
Quaternion r = yawQ * pitchQ;
Vector3 startVec = _modelStartPos - _rmbPivot;
_root.transform.position = _rmbPivot + r * startVec;
_root.transform.rotation = r * _modelStartRot;
evt.StopPropagation();
}
}
private void OnWheel(WheelEvent evt)
{
if (_viewCamera == null || _root == null) return;
float scroll = -evt.delta.y * 0.01f;
if (Mathf.Abs(scroll) > 1e-5f)
{
var forward = _viewCamera.transform.forward;
Vector3 deltaZ = forward * (-scroll * zoomSpeed);
_root.transform.position += deltaZ;
_orbitTarget += deltaZ;
evt.StopPropagation();
}
}
/// <summary>
/// 모델 재로드를 위한 정리 (UI 참조는 유지)
/// </summary>
private void CleanupForReload()
{
// 머티리얼 정리 (인스턴스 머티리얼 삭제)
foreach (var kv in _originalSharedByRenderer)
{
var r = kv.Key;
if (r == null) continue;
var originals = kv.Value;
var mats = r.materials;
for (int m = 0; m < mats.Length; m++)
{
if (mats[m] != null) UnityEngine.Object.Destroy(mats[m]);
}
r.materials = originals;
}
_originalSharedByRenderer.Clear();
_idToObject.Clear();
// 모델 루트 삭제
if (_root != null) UnityEngine.Object.Destroy(_root);
_root = null;
_focusedId = null;
_wireframeApplied = false;
// 카메라 및 렌더텍스처 정리
if (_viewCamera != null)
{
if (_rt != null && _viewCamera.targetTexture == _rt)
{
_viewCamera.targetTexture = null;
}
_viewCamera.enabled = false;
}
if (_rt != null)
{
_rt.Release();
UnityEngine.Object.Destroy(_rt);
_rt = null;
}
// 드래그 상태 초기화
_mmbDragging = false;
_rmbDragging = false;
// ID 시드 초기화
itemIdSeed = 1;
// 게임오브젝트 정리
var rigGo = GameObject.Find("NWModelViewRig");
if (rigGo != null) UnityEngine.Object.Destroy(rigGo);
_viewCamera = null;
var rootGo = GameObject.Find("NWModelViewRoot");
if (rootGo != null) UnityEngine.Object.Destroy(rootGo);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 버튼 이벤트 해제
if (_expandBtn != null)
{
_expandBtn.clicked -= OnExpandBtnClicked;
}
// 마우스 이벤트 해제
UnregisterCallback<MouseDownEvent>(OnMouseDown);
UnregisterCallback<MouseUpEvent>(OnMouseUp);
UnregisterCallback<MouseMoveEvent>(OnMouseMove);
UnregisterCallback<WheelEvent>(OnWheel);
UnregisterCallback<GeometryChangedEvent>(OnGeometryChanged);
// 외부 이벤트 정리
OnItemSelected = null;
OnExpand = null;
// 머티리얼 정리 (인스턴스 머티리얼 삭제)
foreach (var kv in _originalSharedByRenderer)
{
var r = kv.Key;
if (r == null) continue;
var originals = kv.Value;
var mats = r.materials;
for (int m = 0; m < mats.Length; m++)
{
if (mats[m] != null) UnityEngine.Object.Destroy(mats[m]);
}
r.materials = originals;
}
_originalSharedByRenderer.Clear();
_idToObject.Clear();
// 모델 루트 삭제
if (_root != null) UnityEngine.Object.Destroy(_root);
_root = null;
_focusedId = null;
_wireframeApplied = false;
_wireframeMat = null; // Resources에서 로드한 것은 Destroy하지 않음
// 카메라 및 렌더텍스처 정리
if (_viewCamera != null)
{
if (_rt != null && _viewCamera.targetTexture == _rt)
{
_viewCamera.targetTexture = null;
}
_viewCamera.enabled = false;
}
_viewCamera = null;
if (_rt != null)
{
_rt.Release();
UnityEngine.Object.Destroy(_rt);
_rt = null;
}
// 드래그 상태 초기화
_mmbDragging = false;
_rmbDragging = false;
// ID 시드 초기화
itemIdSeed = 1;
// UI 참조 정리
_renderContainer = null;
_expandBtn = null;
// 게임오브젝트 정리
var rigGo = GameObject.Find("NWModelViewRig");
if (rigGo != null) UnityEngine.Object.Destroy(rigGo);
var rootGo = GameObject.Find("NWModelViewRoot");
if (rootGo != null) UnityEngine.Object.Destroy(rootGo);
}
// 유틸리티 메서드
private static void SetLayerRecursive(GameObject go, int layer)
{
if (layer < 0 || layer > 31) return;
go.layer = layer;
foreach (Transform c in go.transform) SetLayerRecursive(c.gameObject, layer);
}
private static Bounds CalculateBounds(GameObject root)
{
var rends = root.GetComponentsInChildren<Renderer>(true);
var has = false;
var bounds = new Bounds(root.transform.position, Vector3.zero);
foreach (var r in rends)
{
if (r == null) continue;
if (!has) { bounds = r.bounds; has = true; }
else bounds.Encapsulate(r.bounds);
}
if (!has) bounds = new Bounds(root.transform.position, Vector3.one);
return bounds;
}
private void FrameToBounds(Bounds b)
{
if (_viewCamera == null) return;
var center = b.center;
var extents = b.extents;
float radius = Mathf.Max(extents.x, Mathf.Max(extents.y, Mathf.Max(extents.z, 0.001f)));
float fovRad = _viewCamera.fieldOfView * Mathf.Deg2Rad;
float dist = radius / Mathf.Sin(fovRad * 0.5f);
dist = Mathf.Clamp(dist, 1f, 1e4f);
_viewCamera.transform.position = center + new Vector3(1, 0.5f, 1).normalized * dist;
_viewCamera.transform.LookAt(center);
_orbitTarget = center;
_orbitDistance = Vector3.Distance(_viewCamera.transform.position, _orbitTarget);
var dir = (_viewCamera.transform.position - _orbitTarget).normalized;
_yaw = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg;
_pitch = Mathf.Asin(dir.y) * Mathf.Rad2Deg;
}
private string BuildFullPath(Transform node, Transform root)
{
var stack = new Stack<string>();
var current = node;
while (current != null && current != root)
{
stack.Push(current.name);
current = current.parent;
}
return "/" + string.Join('/', stack);
}
private void CacheOriginalMaterials(GameObject go)
{
var rends = go.GetComponentsInChildren<Renderer>(true);
if (rends.Length == 0) return;
for (int i = 0; i < rends.Length; i++)
{
var r = rends[i];
if (r == null) continue;
if (_originalSharedByRenderer.ContainsKey(r)) continue;
_originalSharedByRenderer[r] = r.sharedMaterials;
}
}
}
}