3D Preview 결과물

 

위와 같이 상점에서 잠금 상태의 캐릭터나 맵을 실제 3D Object를 보여주는 Preview를 만들고 싶었습니다.

왜냐하면 어떤 느낌인지 대략적으로 알 수 있지만 Material을 검정색으로 처리해 호기심을 유발하고 싶었기 때문입니다.

 

Preview를 만들기 위해서 Render Texture를 사용했었는데 Render Texture를 쉽게 말하면 카메라가 렌더링한 결과를 화면이 아니라 텍스처에 저장하는 기능입니다.

 

보통 카메라는 Game View에 바로 그리지만, 위에 경우에는 previewCamera를 따로 둬서 previewCamera.targetTexture를 미리 생성해둔 Render Texture로 연결해두면 이 카메라가 그린 결과는 Game View가 아니라 연결해둔 Render Texture 안에 저장됩니다.

 

그리고 이 Render Texture를 RawImage의 texture에 연결하면 UI 안에서 위와 같은 3D preview를 보여줄 수 있습니다.

NOTE) RenderTexture는 Sprite가 아니라 Texture이기 때문에, UI 출력에는 일반 Image 대신 RawImage를 사용합니다.

 

1. 3D 프리팹을 프리뷰 카메라가 촬영 -> 2. RenderTexture에 그림 저장 -> 3. RawImage가 그 텍스처를 UI에 표시

이게 상점 Preview 시스템의 핵심 흐름입니다.

 

Popup Preview 관련 Inspector

 

Preview는 Shop Preview Stage Sciprt를 통해 Show(ShopItemDefinition item, bool lockedVisual)에서 구현했고, 순서는 아래와 같습니다.

public void Show(ShopItemDefinition item, bool lockedVisual)
{
    Clear();
    EnsureRig();
    EnsureRenderTexture();

    if (item == null || item.prefab == null) return;

    rotationEnabled = item.allowRotate;

    // Pivot 초기화
    pivot.localPosition = Vector3.zero;
    pivot.localRotation = Quaternion.identity;

    // 프리팹 생성
    instance = Instantiate(item.prefab, pivot);
    instance.transform.localPosition = Vector3.zero;
    instance.transform.localRotation = Quaternion.identity;
    instance.transform.localScale = Vector3.one;

    // 프리팹 원점이 제각각이어도 중심이 화면 중앙에 오도록 보정
    Bounds b = CalculateBounds(instance);
    if (b.size == Vector3.zero) b = new Bounds(instance.transform.position, Vector3.one);

    Vector3 centerLocal = pivot.InverseTransformPoint(b.center);
    instance.transform.localPosition -= (centerLocal + item.previewPivotOffset);

    // 보정 후 bounds 다시 계산
    b = CalculateBounds(instance);
    if (b.size == Vector3.zero) b = new Bounds(pivot.position, Vector3.one);

    // 아이템 설정에 맞게 카메라 배치
    ApplyCamera(item, b);

    // 맵 카테고리는 잠금 상태여도 머티리얼 검정 처리하지 않음
    if (lockedVisual && item.category != ShopCategory.Map)
        ApplyLockedMaterial(instance);

    // 수동 렌더링 1회 수행
    previewCamera.Render();
}

 

1. 이전 preview 제거

먼저 Clear()를 호출해서 기존에 띄워놨던 preview object를 정리합니다.

public void Clear()
{
    if (instance != null)
    {
        RestoreMaterials();
        Destroy(instance);
        instance = null;
    }

    originalMaterials.Clear();
}

 

2. preview용 카메라/조명/피벗 준비

EnsureRig()에서 pivot, previewCamera, keyLight가 없으면 자동 생성합니다. 즉, Scene에서 미리 다 배치하지 않더라도 기본 구성을 알아서 만듭니다.

// 프리뷰용 Pivot / Camera / Light가 없으면 자동 생성
private void EnsureRig()
{
    if (pivot == null)
    {
        var pivotGO = new GameObject("Pivot");
        pivotGO.transform.SetParent(transform, false);
        pivot = pivotGO.transform;
    }

    if (previewCamera == null)
    {
        var camGO = new GameObject("PreviewCamera");
        camGO.transform.SetParent(transform, false);
        previewCamera = camGO.AddComponent<Camera>();
        previewCamera.fieldOfView = 30f;
    }

    previewCamera.clearFlags = CameraClearFlags.SolidColor;
    previewCamera.backgroundColor = backgroundColor;

    // 현재 카메라 기본값 저장
    baseFov = previewCamera.fieldOfView;
    baseOrtho = previewCamera.orthographic;
    baseOrthoSize = previewCamera.orthographicSize;

    if (keyLight == null)
    {
        var lightGO = new GameObject("KeyLight");
        lightGO.transform.SetParent(transform, false);
        keyLight = lightGO.AddComponent<Light>();
        keyLight.type = LightType.Directional;
        keyLight.intensity = 1.2f;
        keyLight.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
    }
}

 

3. RenderTexture 준비

EnsureRenderTexture()에서 출력용 RenderTexture를 준비합니다. 마찬가지로 Scene에서 미리 다 배치하지 않은 경우에는 내부에서 새 RenderTexture를 생성합니다.

그리고 여기서 중요한 부분이 previewCamera.targetTexture = rt로 연결함으로써 카메라가 화면이 아니라 텍스처에 렌더링하게 됩니다.

// 출력용 RenderTexture 준비
// targetTexture가 있으면 그것을 사용하고, 없으면 내부에서 새로 생성
private void EnsureRenderTexture()
{
    if (targetTexture != null)
    {
        rt = targetTexture;
        if (!rt.IsCreated()) rt.Create();
    }
    else
    {
        rt = new RenderTexture(textureSize, textureSize, 24, RenderTextureFormat.ARGB32);
        rt.antiAliasing = 1;
        rt.Create();
    }

    previewCamera.targetTexture = rt;
}

 

4. 프리팹 생성

상점에서 선택한 item의 prefab을 pivot 아래로 생성해줍니다.

생성 직후 localPosition, localRotation, localScale을 초기화해서 preview 기준으로 다시 맞춰줍니다.

 

5. Bounds 계산 후 중심 정렬

prefab마다 원점이 다르기 때문에 CalculateBounds()로 전체 렌더러 범위를 구한 다음, 중심점을 기준으로 object 위치를 보정해서 정렬해줍니다. 이 함수 덕분에 prefab pivot이 제각각이어도 화면 중앙에 보기 좋게 맞출 수 있습니다.

// 프리팹 전체 Renderer를 기준으로 Bounds 계산
private static Bounds CalculateBounds(GameObject go)
{
    var renderers = go.GetComponentsInChildren<Renderer>(true);
    if (renderers == null || renderers.Length == 0)
        return new Bounds(go.transform.position, Vector3.zero);

    Bounds b = renderers[0].bounds;
    for (int i = 1; i < renderers.Length; i++)
        b.Encapsulate(renderers[i].bounds);

    return b;
}

 

6. 카메라 자동 배치

ApplyCamera(item, b)를 호출해 아이템 설정에 맞게 카메라 위치, 회전, 클리핑 범위, 정사영 여부 등을 결정합니다. 

 

Auto Fit은 캐릭터처럼 프리팹 크기와 비율이 제각각인 대상을 상점 프리뷰 안에 자동으로 맞추기 위한 카메라 배치 방식이다. 먼저 Renderer Bounds를 기준으로 모델 중심을 보정한 뒤, 해당 Bounds의 extents를 사용해 카메라 크기와 거리를 계산한다. 정사영 모드에서는 화면 비율을 고려해 orthographicSize를 자동 계산하고, 원근 모드에서는 대상 반지름과 시야각(FOV)을 이용한 dist = radius / tan(FOV / 2) 공식을 사용해 카메라 거리를 구한다. 마지막으로 previewEuler를 기준으로 카메라 회전 방향을 정하고, minDistance / maxDistance / fitPadding으로 최종 결과를 안정적으로 보정한다.

// 아이템 설정값과 Bounds를 기준으로 카메라 위치/회전/클리핑 결정
private void ApplyCamera(ShopItemDefinition item, Bounds b)
{
    // 이전 아이템 설정이 남지 않도록 카메라 기본값 초기화
    previewCamera.fieldOfView = baseFov;
    previewCamera.orthographic = baseOrtho;
    previewCamera.orthographicSize = baseOrthoSize;

    if (item.fovOverride > 0.01f)
        previewCamera.fieldOfView = item.fovOverride;

    // 1) FixedPose
    // SO에 저장된 카메라 위치/회전을 그대로 적용
    // 주로 맵처럼 구도를 고정해서 보여줄 때 사용
    if (item.cameraMode == PreviewCameraMode.FixedPose)
    {
        previewCamera.orthographic = item.useOrthographic;

        if (previewCamera.orthographic)
        {
            if (item.orthoSizeOverride > 0.01f)
                previewCamera.orthographicSize = item.orthoSizeOverride;
            else
            {
                // override가 없으면 bounds 기반으로 자동 계산
                float aspect = Mathf.Max(0.0001f, previewCamera.aspect);
                float halfH = b.extents.y;
                float halfW = b.extents.x / aspect;
                previewCamera.orthographicSize = Mathf.Max(halfH, halfW) * Mathf.Max(1.01f, item.fitPadding);
            }
        }

        previewCamera.transform.localPosition = item.cameraLocalPosition;
        previewCamera.transform.localEulerAngles = item.cameraLocalEuler;

        previewCamera.nearClipPlane = 0.01f;
        previewCamera.farClipPlane = 5000f;
        return;
    }

    // 2) AutoFit
    // 주로 캐릭터처럼 대상 크기에 따라 카메라를 자동으로 맞출 때 사용
    Quaternion rot = Quaternion.Euler(item.previewEuler.x, item.previewEuler.y, 0f);

    if (item.useOrthographic)
    {
        previewCamera.orthographic = true;

        float size;
        if (item.orthoSizeOverride > 0.01f) size = item.orthoSizeOverride;
        else
        {
            float aspect = Mathf.Max(0.0001f, previewCamera.aspect);
            float halfH = b.extents.y;
            float halfW = b.extents.x / aspect;
            size = Mathf.Max(halfH, halfW) * item.fitPadding;
        }

        previewCamera.orthographicSize = Mathf.Max(0.01f, size);

        Vector3 dir = rot * Vector3.back;
        float dist = Mathf.Clamp(b.extents.magnitude * 2f, item.minDistance, item.maxDistance);

        previewCamera.transform.position = pivot.position + dir * dist;
        previewCamera.transform.rotation = rot;

        previewCamera.nearClipPlane = 0.01f;
        previewCamera.farClipPlane = Mathf.Max(200f, dist + b.extents.magnitude * 20f);
    }
    else
    {
        previewCamera.orthographic = false;

        float radius = b.extents.magnitude * item.fitPadding;
        float fovRad = Mathf.Max(0.0001f, previewCamera.fieldOfView * Mathf.Deg2Rad);
        float dist = radius / Mathf.Tan(fovRad * 0.5f);
        dist = Mathf.Clamp(dist, item.minDistance, item.maxDistance);

        Vector3 dir = rot * Vector3.back;
        previewCamera.transform.position = pivot.position + dir * dist;
        previewCamera.transform.rotation = rot;

        float clipPad = b.extents.magnitude * 10f;
        previewCamera.nearClipPlane = Mathf.Max(0.01f, dist - clipPad);
        previewCamera.farClipPlane = dist + clipPad;
    }
}

 

7. 잠금 상태 시 검정 처리

캐릭터의 경우 locked일 때 검정색으로 처리하게 때문에 아래의 조건일 때 ApplyLockedMaterial(instance)를 호출해서 캐릭터 preview를 검정 Material로 바꿔줍니다.

// 맵 카테고리는 잠금 상태여도 머티리얼 검정 처리하지 않음
if (lockedVisual && item.category != ShopCategory.Map)
    ApplyLockedMaterial(instance);
    
// 잠금 상태일 때 모든 Renderer의 머티리얼을 검정 머티리얼로 교체
private void ApplyLockedMaterial(GameObject root)
{
    if (lockedMaterial == null)
    {
        Shader s = Shader.Find("Universal Render Pipeline/Unlit");
        if (s == null) s = Shader.Find("Unlit/Color");

        lockedMaterial = new Material(s);
        lockedMaterial.color = Color.black;

        // 양면 렌더링 지원
        if (lockedMaterial.HasProperty("_Cull"))
            lockedMaterial.SetFloat("_Cull", 0f);

        if (lockedMaterial.HasProperty("_CullMode"))
            lockedMaterial.SetFloat("_CullMode", 0f);
    }

    var renderers = root.GetComponentsInChildren<Renderer>(true);
    foreach (var r in renderers)
    {
        if (r == null) continue;

        // 원래 머티리얼 캐싱
        if (!originalMaterials.ContainsKey(r))
            originalMaterials[r] = r.sharedMaterials;

        var mats = new Material[r.sharedMaterials.Length];
        for (int i = 0; i < mats.Length; i++)
            mats[i] = lockedMaterial;

        r.sharedMaterials = mats;
    }
}

 

8. 카메라 렌더링 실행

마지막에 previewCamera.Render()를 호출해서 현재 상태를 RenderTexture에 한 번 렌더링합니다.