저자 : 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);
}
}
원문
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] 어떤 신입의 바이트덴스 게임 엔진 파트 인터뷰 경험담 (2) | 2026.02.04 |
|---|---|
| [INDEX] SIGGRAPH 2025 ADAVANCED REALTIME RENDERING (0) | 2026.01.08 |
| [발표 번역 2부] Siggraph 2025 Ray tracing the world of Assessin’s Creed Shadow. (2) | 2026.01.08 |
| [발표 번역 1부] Siggraph 2025 Ray tracing the world of Assessin’s Creed Shadow. (1) | 2026.01.07 |
| [번역] Unity에서 스킨드 메시의 GPU Driven 렌더링 구현 (0) | 2026.01.03 |