TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

[번역] 깊이 오프셋을 활용한 나무 그림자 성능 최적화 (면 50% 감소)

jplee 2026. 2. 3. 12:25

저자 : jackie 偶尔不帅


계기

대규모 오픈월드 프로젝트를 경험한 기술자라면 나무의 높은 버텍스 수에 대해 깊은 인상을 받았을 것이다. 나에게는 특히 잊을 수 없는 부분이다. 그래서 시간이 날 때마다 어떻게 하면 면 수를 줄일 수 있을지 끊임없이 고민해왔다. 하지만 동시에 높은 면 수가 나무 표현에 얼마나 중요한지도 잘 알고 있다. 많은 TA들이 렌더링 자체부터 접하기 시작하다 보니, 오히려 렌더링의 가장 기본이 되는 정확한 지오메트리 정보를 간과하기 쉽다. 극단적으로 말하면, 지오메트리 정보가 충분히 풍부하다면 노멀맵, 하이트맵/테셀레이션 디스플레이스먼트 맵, 심지어 러프니스 맵도 필요 없다. 우리가 렌더링 기술에 각종 맵을 추가하는 것은 단지 지오메트리 정보 손실 후의 상황을 보완하기 위해서일 뿐이다. 나뭇잎들이 하나의 큰 면으로 합쳐지면, 이러한 맵들은 주로 하이라이트나 미세한 높낮이의 시차 등을 보완하는 역할을 한다. 그러나 원래 여러 잎사귀 간의 상호 그림자 투영 관계와 카메라 이동에 따른 레이어 간 시차 효과는 하나의 큰 면으로 합쳐지면 영구히 손실된다. 따라서 고품질 렌더링을 위해 나무의 면 수를 어느 정도 유지하는 것은 필수적이다. 그래서 매번 내 접근 방식은 나머지 절반인 그림자 계산(shadowcast)을 줄이는 것이었다.

Static Shadow Map과 SDF Shadow의 관계

렌더링 기술을 하기 전, 1년 넘게 TA로 일했다. 자주 마주치는 문제는 이런 것이었다 - 하나의 문제에 대한 여러 해결 방안을 찾을 수 있지만, 각 방안의 사용 상황을 명확히 알지 못하고 장단점만 어렴풋이 아는 정도였다. 모든 방안을 직접 구현해본 후에야 비로소 적용 시나리오를 정확히 알 수 있었다.

예를 들어 이전에 소개한 2가지 일반적인 방법(똑같이 나무 그림자 투영 시 전체 삼각형을 다시 그리지 않아도 되는 방식):

  • Static Shadow Map: 씬의 섀도우맵을 미리 만들어 두는 방식으로, 오프라인 또는 씬 로딩 시 생성할 수 있다. 그 다음 엔진이 2개의 섀도우맵을 계산하게 한다. 이 방법의 단점은 씬이 너무 크면 안 된다는 것이다. 그렇지 않으면 실시간 섀도우맵과 비슷한 정밀도를 보장하기 위해 필요한 텍스처 크기가 어마어마해진다.
  • SDF Shadow: UE4에 내장된 기술이다. 단점은 대략적인 윤곽의 소프트 섀도우만 얻을 수 있어서, 실시간 섀도우맵처럼 나뭇잎이 뚫린 효과를 만들기에는 적합하지 않다. 누군가 랜덤하게 뚫린 것처럼 가짜로 만드는 방법을 언급했지만, 효과는 아직 검증되지 않았다.

하지만 우리 프로젝트에 필요한 것은 정확히 이 두 방안의 사각지대였다 - 초대형 씬 + 정밀한 나뭇잎 투과. 그래서 이 방안을 생각해냈고, 다행히 우리는 고정 각도의 태양광을 사용하기 때문에 몇 가지 특수한 해법이 존재했다. 하지만 이 방안은 여전히 내가 직접 고안한 것이라, 만들고 나서 전 와우(蜗牛) CTO(엔진 대가)에게 이미 이런 방법이 존재하는지 물어봤고, 그래야 올바른 이름을 붙일 수 있을 테니까. 그런데 그의 대답은 내 아이디어가 자신의 상식을 뒤집었다는 것이었다, 하하. 그래서 일단 깊이 오프셋법(Depth Offset Method)이라고 부르기로 했다.

대략적인 아이디어

먼저 정상적인 그림자 투영에서 어떤 데이터가 생성되어야 하는지 살펴보자. 이것은 섀도우맵 기초 지식으로, 이 나무의 깊이 값을 얻게 된다. 쉽게 생각할 수 있는 것은, 만약 하나의 면으로 나무 그림자를 대체한다면 면 수가 절반으로 줄어든다는 것이다(shadowcast pass가 단 2개의 삼각형만 가지게 됨). 하지만 효과도 맞지 않게 되므로, 효과를 되찾는 방법을 찾아야 한다.

나무의 방향도 고정되어 있다고 가정하면(실제 요구사항은 360도로 8개의 고정 방향을 만들 수 있음), 이 면이 나무의 실제 깊이와의 차이를 알기만 하면 된다. 차이를 계산한 후에는 나무가 어디에 있든, 면 자체의 깊이 + 상대적 나무 깊이를 이용하면 실제 깊이를 얻을 수 있다.

깊이 오프셋 계산 코드는 마지막에 있다. 아이디어는 면의 UV 각 픽셀에서 태양광 방향으로 레이를 발사하고, 깊이 값과 현재 픽셀의 태양광 방향상 깊이 값의 차이를 검출하는 것이다. 투명 영역에서 레이가 통과할 수 있어야 하므로 goto 루프를 사용했고, 양면 머티리얼 효과는 지원하지 않는다. 일반적인 렌더링에서도 나무의 양면 그림자 렌더링을 선택하지 않고, 모델링 순서가 적절하면 단면 효과도 괜찮기 때문이다.

정상적인 그림자 투영은 나무를 한 번 렌더링하여 깊이를 섀도우맵에 기록한다

하나의 면으로 그림자 투영을 대체

면 그림자 투영 시 깊이 오프셋 보정을 적용한 그림자

깊이 오프셋 맵

면 shadowcast 알고리즘, 여기서 그림자 오프셋 범위를 면 앞뒤 10미터로 제한

테스트 결과

면 수 50% 감소

프레임 레이트 향상 (i7-9700 CPU + RTX 2060 기준 1.7ms)

이것은 버텍스 수가 병목이 아닐 때 테스트한 데이터라서 기술적으로 체감이 강하지 않을 수 있다. 다른 방식으로 설명하자면, 아티스트의 모델 스펙을 면 수 2배로 늘려도 성능이 동일하게 유지될 수 있다는 것이다. 이것은 대부분의 프로젝트 아티스트들에게 매우 매력적인 점이다.

데모에 필요한 코드

PlaneShadowcaster.shader

Shader "Unlit/PlaneShadowcaster"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Pass {
            Name "Caster"
            Tags { "LightMode" = "ShadowCaster" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            #pragma multi_compile_shadowcaster
            #pragma multi_compile_instancing // allow instanced shadow pass for most of the shaders
            #include "UnityCG.cginc"

            struct v2f {
                V2F_SHADOW_CASTER;
                float2  uv : TEXCOORD1;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            uniform float4 _MainTex_ST;

            v2f vert(appdata_base v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
                return o;
            }

            uniform sampler2D _MainTex;

            float frag(v2f i) : SV_DEPTH
            {
                fixed4 texcol = tex2D(_MainTex, i.uv);
                clip(texcol.r - 0.2);
                float disToPlane = (texcol.r * 2 - 1) * 10;
                return i.pos.z + mul(UNITY_MATRIX_P, float4(0, 0, 1, 0)).z * disToPlane;
                //SHADOW_CASTER_FRAGMENT(i)
            }
            ENDCG
        }
    }
}

LightDirDepthTex.cs

using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;

public class LightDirDepthTex : MonoBehaviour {
    public Light mainLight;
    public Texture2D depthTex;
    public Renderer treeRenderer;
 
    void Start () {
        //GetComponent<Renderer>().sharedMaterial.mainTexture = depthTex;
    }
    
    [ContextMenu("generateDepthTex")]
    void generateDepthTex() {
     
        Vector3 startPos = transform.TransformPoint(new Vector3(-0.5f, -0.5f, 0));
        Vector3 endXPos = transform.TransformPoint(new Vector3(0.5f, -0.5f, 0));
        Vector3 endYPos = transform.TransformPoint(new Vector3(-0.5f, 0.5f, 0));
        
        int texSize = 512;
        var stepXPos = (endXPos - startPos) / texSize;
        var stepYPos = (endYPos - startPos) / texSize;
        depthTex = new Texture2D(texSize, texSize, TextureFormat.RGB24, false, true);
        var colors = depthTex.GetPixels();
        var treeTex0 = treeRenderer.sharedMaterials[0].mainTexture as Texture2D;
        var treeColors0 = treeTex0.GetPixels();
        var treeTex1 = treeRenderer.sharedMaterials[1].mainTexture as Texture2D;
        var treeColors1 = treeTex1.GetPixels();
        var submesh0Vts = treeRenderer.GetComponent<MeshFilter>().sharedMesh.GetTriangles(0).Length / 3;
         
        for (int i = 0; i < texSize; i++)
        {
            for (int j = 0; j < texSize; j++)
            {
                //Gizmos.DrawRay(startPos + stepXPos * j + stepYPos * i, -transform.forward);
                RaycastHit info;
                float rayDis = 20;
                int transparentReset = 0;
                Vector3 rayStart = startPos + stepXPos * j + stepYPos * i + -mainLight.transform.forward * rayDis / 2;
                resetRaycast:
                if (Physics.Raycast(rayStart, mainLight.transform.forward, out info, rayDis)){
                    //colors[j, i] =
                    var treeTex = info.triangleIndex < submesh0Vts ? treeTex0 : treeTex1;
                    var treeColors = info.triangleIndex < submesh0Vts ? treeColors0 : treeColors1;
                    int hitX = Mathf.Clamp((int)(Mathf.Clamp01(info.textureCoord.x) * treeTex.width), 0, treeTex.width - 1);
                    int hitY = Mathf.Clamp((int)(Mathf.Clamp01(info.textureCoord.y) * treeTex.height), 0, treeTex.height - 1);

                    
                    if (treeColors[hitY * treeTex.width + hitX].a < 0.5 && transparentReset < 3) {
                        transparentReset++;
                        rayStart = info.point + mainLight.transform.forward * 0.01f;
                        rayDis -= info.distance;
                        goto resetRaycast;
                    }
                    if (treeColors[hitY * treeTex.width + hitX].a < 0.5)
                    {
                        colors[j + texSize * i] = Color.black;
                    }
                    else {
                        colors[j + texSize * i] = Color.white * (Vector3.Dot(startPos + stepXPos * j + stepYPos * i - info.point, mainLight.transform.forward) / 10 * 0.5f + 0.5f);
                    }
                }
                else {
                    colors[j + texSize * i] = Color.black;
                }
            }
        }
        depthTex.SetPixels(colors);
        depthTex.Apply(false);
        GetComponent<Renderer>().sharedMaterial.mainTexture = depthTex;
        File.WriteAllBytes(Application.dataPath + "/Assets/depthTex.png", depthTex.EncodeToPNG());
    }

    void OnDrawGizmos() {
        //Gizmos.DrawSphere(transform.TransformPoint(new Vector3(-0.5f, -0.5f, 0)), 1f);
        //Gizmos.DrawSphere(transform.TransformPoint(new Vector3(0.5f, 0.5f, 0)), 1f);
    }
}

 

원문

(60 封私信 / 36 条消息) 利用深度偏移优化树的阴影性能(减50%面) - 知乎