역자의 말: 조만간 모 고객사의 섀도우 시스템을 최적화 하는 솔루션을 제공해야 하는 터라 이런 저런 레퍼런스들을 살펴보는 중입니다. 그 중에서 제키군의 실험적인 테크 포스트가 있어 소개 합니다.
저자: jackie 偶尔不帅
적용 시나리오
실시간 섀도우맵은 압축이 필요 없다. 하지만 평행광에 대해 전체 씬 규모의 오프라인 static shadowmap을 구워두면서 동시에 높은 정밀도를 요구할 경우에는 보통 VRAM 최적화가 필요하다. 예를 들어 우리 프로젝트의 2x2km 대형 씬에는 1024x1024 섀도우맵이 400장이나 있는데, 이걸 한꺼번에 전부 로드하면 상상만 해도 끔찍하다. 그래서 일반적으로는 스트리밍 인/아웃 + VT(Virtual Texturing) 방식을 쓰거나, 조금 더 단순하게 한 번에 전부 로드하되 높은 압축률 알고리즘을 적용하는 방식을 택한다. 이전 글에서 다룬 clipmap은 스트리밍 인/아웃 방식으로 구현 난이도가 꽤 높고, 안정화에 1개월 정도는 걸릴 것 같았다. 그런데 2주 후 외부 배포 버전에 static shadowmap 기능을 탑재해야 하는 상황이라, 이 기술 방안을 추가로 만들게 됐다. 1일 테스트를 거쳐 두 가지 압축 방식을 비교 구현했는데, 하나는 원신(Genshin Impact) 발표 사례를 참고한 로컬 라이트 섀도우맵 사방 트리(Quadtree) 압축이고, 다른 하나는 늘 그렇듯 직접 만든 방식이다. 그런데 비교 결과, 이번엔 놀랍게도 직접 만든 쪽이 더 우세했다.
최종 결과
0 shadowcaster로 시뮬레이션한 섀도우맵 효과 — 폴리곤 500만 개 절감
나무에 LOD를 적용하지 않았기 때문에 실제 프로젝트 오버헤드보다 테스트 오버헤드가 더 크게 나옴

본 방안 섀도우맵 — 폴리곤 300만 / 0 shadowcaster / 배치 349

기본 섀도우맵 — 폴리곤 800만 / shadowcaster 493 / 배치 841


공통 테크닉
압축 방식에 상관없이, 무효 데이터를 최대한 많이 걸러내는 것이 항상 도움이 된다. 그럼 무효 데이터가 차지하는 면적을 어떻게 늘릴 수 있을까? 대형 씬에서는 비교적 단순하다. 지형 높이 이하는 무효로 간주하고 0 또는 1 같은 특수 값으로 표시한다. 지형이 전혀 투영되지 않고 그 아래도 깔끔해서 잔여 오브젝트가 없다면, 지형을 촬영에서 숨기고 뎁스맵을 찍는 방식으로 간단히 처리할 수 있다. 더 좋은 방법은 두 번 촬영하는 것이다. 한 번은 지형만, 한 번은 씬 전체를 찍어 비교한 뒤, 동일한 부분은 0으로 처리한다.
사방 트리(Quadtree) + 블록 압축
섀도우맵 압축을 고려한다면 먼저 대형 스튜디오의 공유 사례부터 살펴보는 것이 맞다.

우리는 대형 씬 평행광 섀도우맵 사전 계산이 목적이라 block 64bits 방식은 적합하지 않아서, 2x2 블록 방식으로 구현했다.
이전에 복셀 기반 그림자 시스템을 만들면서 사방 트리 압축을 상세히 구현해 둔 게 있어서, 이번에 뎁스맵 하나를 압축하는 건 훨씬 수월했다. 라이트 공간을 따로 고려할 필요 없고(이미 뎁스맵이 라이트 공간이니까), 오브젝트 내부의 보이지 않는 복셀도 신경 쓸 필요 없고, 유향 비순환 그래프(DAG) 도 필요 없다. 트리 하나가 16x16 블록에 불과해서, 병합 후 자식 노드 간 다층 중복률이 그리 높지 않기 때문이다. 그래서 기본 버전을 약간 손봐서 적용했다.
Shader 내에서 사방 트리 압축 텍스처 구현

압축을 고려해서 달라진 주요 사항은 트리 노드에서 x, z 좌표를 삭제했다는 점이다. 자식 노드의 중심 좌표는 부모 노드 + 오프셋으로 규칙적으로 계산할 수 있기 때문이다. 최종적으로 필요한 텍스처는 두 장이다. 하나는 원본의 1/4 크기인 뎁스 텍스처로, rgba 채널에 2x2 뎁스 값을 저장하여 크기를 줄이고 트리 노드 수도 줄인다. 이 텍스처는 DX5 압축을 사용한다. 채널 하나만 쓰는 DX1은 압축률이 DX5의 절반에 불과해서(1/4가 아님) 비효율적이다. 나머지 하나는 트리 노드를 1차원 배열로 변환해 저장한 텍스처로, 각 노드에는 자식 노드와 현재 노드의 indexOffset만 저장하고, 셰이더에서 이를 기반으로 자식 노드를 탐색한다.
왜 16x16 픽셀을 트리 하나 단위로 삼느냐면, 그래야 indexOffset이 256을 넘지 않기 때문이다. 즉 r8 또는 alpha8으로 저장 가능하고, 초과하면 r16이 되어 용량이 바로 두 배가 된다. 그렇게 되면 압축률을 높이기가 매우 어려워진다. 16이면 충분한 이유는, 최대 256배까지 압축이 가능해서 실용적으로 여유가 있기 때문이다. 우리의 경우 2x2 블록을 rgba로 변환했기 때문에, 실제로는 32x32를 트리 하나 단위로 쓰고 있다.

원본 이미지와 사방 트리 데이터를 역방향 파싱한 이미지가 매우 일치하는 것을 확인할 수 있다.

원본은 DX1로 500K다. 물론 원본에도 rgba로 2x2 블록 구성 후 DX5를 적용하면 256K가 된다. 사방 트리 + 블록 방식은 215K다. 이 정도 복잡도의 이미지에서는 최적화 효과가 크지 않다. 계산식은 print(2 * allCount * 1024 / 1024 / 1024 + "K") 인데, 노드 트리가 픽셀 수에 대응하기 때문에 1024x1024 DX5 또는 alpha8 이미지의 VRAM 용량은 1M이고, 사방 트리는 텍스처가 두 장 필요해서 x2다. 그림자가 적은 뎁스맵에서는 사방 트리 압축률이 매우 높다. 값이 0인 자식 노드를 대량으로 병합할 수 있기 때문이다. 그러나 그림자가 많은 영역에서는 뚜렷한 압축 효과가 없다. 고정밀 뎁스맵에서는 인접 픽셀이 계속 달라져서 병합이 어렵기 때문이다. 이건 shadowmask처럼 넓은 범위가 동일한 값(1이거나 0이고 경계 부분만 다른 값)으로 채워지는 경우와는 다르다. 바로 이런 이유로, 그리고 셰이더 내에서 트리를 조회하려면 추가 샘플링이 5번이나 필요하다는 점 때문에, 대량의 뎁스 값이 서로 다른 이런 압축 요구에 더 적합한 방식을 생각하게 됐다.
컬링 기반 압축
이건 내가 직접 고안한 방식이라 아직 이름이 없다. 언제나처럼, 나중에 대가분이 보시고 "xxx도 이런 방식 쓴 적 있어"라고 알려주시면 그때 이름을 붙이면 된다. 내 아이디어는 이렇다. 그림자 영역이 너무 많으면 압축 자체가 별 의미 없고, 그림자 영역이 적다면 단순하게 검은 영역만 걷어내면 되지 않을까? 그래서 이렇게 접근했다. 이미지를 64x64 픽셀 단위의 블록으로 나눈다. 예를 들어 1024 이미지는 16x16 블록이 된다. 각 블록을 검사해서 0(순수 검정) 픽셀이 면적의 98%를 초과하면 해당 블록을 버린다. 남은 블록들을 새로운 작은 이미지로 재조합하고, 두 이미지의 UV 관계를 기록해둔다.

여기서는 비교 기준 통일을 위해 BC4 압축 단채널 기준으로 비교했고, 실제로는 2x2 블록 + DX5를 적용하면 이 절반이 되지만 비율에는 영향이 없다. 보다시피, 이렇게 잘라낸 후 비어있지 않은 블록만 재조립하면 10배의 압축을 달성할 수 있다. UV 조회용 샘플링이 한 번 추가되는 것 외에는 성능도 더 좋다. 알고리즘도 단순하고 직관적이라 유지보수도 쉽다. 코드가 100줄도 안 되니 이것저것 설명하는 것보다 마지막에 코드를 직접 올리는 게 더 명확할 것 같다.
알고리즘 압축률 비교
두 알고리즘을 세 가지 상황(매우 드문 경우, 중간 정도, 매우 가득 찬 경우)에 대해 비교했다. 완전히 비어있는 경우는 테스트 불필요 — 둘 다 대량 컬링하므로 몇 배 차이나도 1K 이하의 차이라 비율은 높더라도 절대량이 작아 실질적 의미가 없다.
중간 정도 이미지 비교 — 원본 256K(BC4 기준 절반 계산, 2x2 블록 후 절반), 내 압축 방식은 173K. 이미지에 392/2 = 196K로 표시된 이유는 상단 파란 박스 영역이 활용되지 않아서다. 최종적으로 모든 1024 뎁스 이미지를 하나의 아틀라스로 합치거나 64x64 픽셀의 Texture2DArray로 만들면 이런 낭비 공간은 없어진다. 참고로 이 이미지에서 사방 트리는 215K였다.
비교 결과 원본 : 사방 트리 : 신규 방안 = 100 : 84 : 68

뎁스 값 영역이 가장 많은 경우 비교

원본 : 사방 트리 : 신규 방안 = 256K : 414K : 248K, 즉 100 : 162 : 97
연속적인 뎁스 값이 없는 경우에는 두 방식 모두 유효한 최적화를 하지 못하지만, 사방 트리는 각 노드마다 인덱스를 저장해야 해서 오히려 오버헤드가 크게 늘어났다.
뎁스 값 영역이 적은 경우 비교

원본 : 사방 트리 : 신규 방안 = 256K : 52K : 48K, 즉 100 : 20 : 19
총정리: 고주파 변화가 넓게 분포한 뎁스맵에서는 사방 트리가 원리적으로도, 실제 테스트 결과로도 적합하지 않다. 다만 저주파 변화의 블러를 허용할 수 있다면 사방 트리가 꽤 유리하다. 우리 프로젝트는 가까운 거리의 나무에도 static shadowmap을 적용해서 100만 폴리 수준의 나무 투영 오버헤드를 줄이고자 한다. 그래서 현재 비교 결과를 바탕으로 2주 내에 신규 압축 방안을 적용할 계획이다.
관련 코드
BQuadTree.cs — 사방 트리 방안 코드
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
public class BQuadTree : MonoBehaviour
{
class Node
{
public uint flag;// depth
public Node parent;
public Node[] children;
public int index;
internal void insert(int vx, int vz,uint value,int size, int cellX = 0, int cellZ = 0)
{
if (size <= 1) {
flag = value;
return; }
if (children == null)
{
children = new Node[4];
children[0] = new Node() { parent = this };
children[1] = new Node() { parent = this };
children[2] = new Node() { parent = this };
children[3] = new Node() { parent = this };
}
int offset = 0;
if (vx > cellX + size / 2) offset++;
if (vz > cellZ + size / 2) offset += 2;
cellX += (offset % 2) * size / 2;
cellZ += (offset / 2) * size / 2;
size /= 2;
children[offset].insert(vx, vz,value,size, cellX, cellZ);
}
private void calCount(ref int count)
{
count += 1;
if (children != null)
{
foreach (var item in children)
{
item.calCount(ref count);
}
}
}
public void clipSameNode(bool recursion = true)
{
if (children == null) return;
int dataCount = 0;
var c1flag= children[0].flag;
foreach (var item in children)
{
if (item.children==null&& item.flag == c1flag)
{
dataCount++;
}
}
if (dataCount == 4)
{
flag = c1flag;
children = null;
if (parent != null) parent.clipSameNode(false);
}
else if (recursion)
{
foreach (var item in children)
{
item.clipSameNode();
}
}
}
public List<Node> getAllNodes()
{
List<Node> nodes = new List<Node>();
nodes.Add(this);
int startIndex = 0;
int endIndex = nodes.Count;
while (startIndex != endIndex)
{
for (int i = startIndex; i < endIndex; i++)
{
if (nodes[i].children != null)
{
nodes.AddRange(nodes[i].children);
}
}
startIndex = endIndex;
endIndex = nodes.Count;
}
return nodes;
}
public void printCount()
{
int count = 0;
calCount(ref count);
// print(count);
}
public uint find(int fx, int fz,int size,int cellX=0,int cellZ=0)
{
size /= 2;
if (children == null)
{
return flag ;
}
int offset = 0;
if (fx > cellX + size / 2) offset++;
if (fz > cellZ + size / 2) offset += 2;
cellX += (offset%2) * size / 2;
cellZ += (offset/2) * size / 2;
return children[offset].find(fx, fz,size, cellX, cellZ);
}
}
public Texture2D srcTex;
public Texture2D debugOutTex;
const int BLOCK = 32;
// Use this for initialization
void Start()
{
initShadowData();
}
Node root;
List<Node> quadTrees = new List<Node>();
Node cerateQuadTree(Color[] colorsSrc,int size) {
var tree = new Node();
for (int i = 0; i < size / 2; i++)
{
for (int j = 0; j < size / 2; j++)
{
uint r = (uint)(colorsSrc[i * 2 * size + j * 2].r * 255);
uint g = (uint)(colorsSrc[i * 2 * size + j * 2 + 1].r * 255);
uint b = (uint)(colorsSrc[(i * 2 + 1) * size + j * 2].r * 255);
uint a = (uint)(colorsSrc[(i * 2 + 1) * size + j * 2 + 1].r * 255);
uint v = r * 255 * 255 * 255 + g * 255 * 255 + b * 255 + a;
tree.insert(j, i, v, size / 2);
}
}
tree.clipSameNode();
var nodes = tree.getAllNodes();
for (int i = 0; i < nodes.Count; i++)
{
nodes[i].index = i;
}
int maxIndex = 0; ;
for (int i = 0; i < nodes.Count; i++)
{
maxIndex = Mathf.Max(maxIndex, nodes[i].children != null ? nodes[i].children[0].index - nodes[i].index : 0);
}
return tree;
}
private void initShadowData()
{
int allCount = 0;
for (int i = 0; i < srcTex.height / BLOCK; i++)
{
for (int j = 0; j < srcTex.width / BLOCK; j++)
{
var colorsSrc = srcTex.GetPixels(j * BLOCK, i * BLOCK, BLOCK, BLOCK);
root = cerateQuadTree(colorsSrc, BLOCK);
root.printCount();
var nodes = root.getAllNodes();
quadTrees.Add(root);
allCount += nodes.Count;
}
}
print(Mathf.CeilToInt(Mathf.Sqrt(allCount)));
print( 2*allCount*1024/1024/1024 +"K");
debugToTex();
}
void debugToTex() {
debugOutTex = new Texture2D(srcTex.width, srcTex.height, TextureFormat.RGB24, false, true);
var colors = new Color32[srcTex.width* srcTex.height];
for (int i = 0; i < debugOutTex.height; i++)
{
for (int j = 0; j < debugOutTex.width; j++)
{
root = quadTrees[i / BLOCK * (debugOutTex.width / BLOCK) + j / BLOCK];
uint flag = root.find((j % BLOCK) / 2, (i % BLOCK) / 2, BLOCK);
uint[] rgba = new uint[] { (flag / 255 / 255 / 255) % 255, (flag / 255 / 255) % 255, (flag / 255) % 255, flag % 255 };
byte c= (byte)rgba[(i % 2) * 2 + j % 2];
colors[i * debugOutTex.width + j] = new Color32(c, c, c, 1);
}
}
debugOutTex.SetPixels32(colors);
debugOutTex.Apply();
}
}
신규 방안 코드 SkipTile.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
public class SkipTile : MonoBehaviour {
public Texture2D srcTex;
public Texture2D indexTex;
const int SplitBlockSize = 64;
int getPs(Texture2D tex) {
var colors = tex.GetPixels32();
int tileIndex = 0;
for (int i = 0; i < tex.height / SplitBlockSize; i++)
{
for (int j = 0; j < tex.width / SplitBlockSize; j++)
{
bool isEmpty = isEmptyTile(colors, j, i, SplitBlockSize,tex.width);
if (isEmpty == false) tileIndex++;
}
}
return tileIndex* SplitBlockSize * SplitBlockSize;
}
public Texture2D[] calTexs;
[ContextMenu("calAllPs")]
void calAllPs() {
long count = 0;
foreach (var item in calTexs)
{
count += getPs(item);
}
print(count / 4096+"k");// 같은 포맷의 1024x1024 이미지 VRAM은 256K이므로 count/1024/1024 x256k = count/4096 k
}
[ContextMenu("split")]
void split()
{
int blockCol = srcTex.width / SplitBlockSize;
indexTex = new Texture2D(blockCol, blockCol, TextureFormat.Alpha8, false, true);
indexTex.filterMode = FilterMode.Point;
var indexColors = indexTex.GetPixels32();
var colors = srcTex.GetPixels32();
var indexArray = new int[blockCol* blockCol];
int tileIndex = 0;
for (int i = 0; i < srcTex.height / SplitBlockSize; i++)
{
for (int j = 0; j < srcTex.width / SplitBlockSize; j++)
{
bool isEmpty = isEmptyTile(colors, j, i, SplitBlockSize,srcTex.width);
indexArray[j + i * blockCol] = isEmpty ? -1 : tileIndex;
indexColors[j + i * blockCol].a = (byte)indexArray[j + i * blockCol];
if (isEmpty == false) tileIndex++;
}
}
indexTex.SetPixels32(indexColors);
indexTex.Apply();
print(tileIndex);
var atlasC = Mathf.CeilToInt(Mathf.Sqrt(tileIndex));
var desTex = new Texture2D(SplitBlockSize * atlasC, SplitBlockSize * atlasC, TextureFormat.RGBA32, false, true);
for (int i = 0; i < srcTex.height / SplitBlockSize; i++)
{
for (int j = 0; j < srcTex.width / SplitBlockSize; j++)
{
if (indexArray[j + i * blockCol] == -1) continue;
int x = indexArray[j + i * blockCol] % atlasC;
int y = indexArray[j + i * blockCol] / atlasC;
Graphics.CopyTexture(srcTex, 0, 0, j * SplitBlockSize, i * SplitBlockSize, SplitBlockSize, SplitBlockSize, desTex, 0, 0, x * SplitBlockSize, y * SplitBlockSize);
}
}
File.WriteAllBytes("c:/temp/tempAtlars.png", desTex.EncodeToPNG());
Shader.SetGlobalTexture("SkipTileAtlars", desTex);
Shader.SetGlobalTexture("SkipTileIndexTex", indexTex);
Shader.SetGlobalInt("SkipTileCol", atlasC);
}
private bool isEmptyTile(Color32[] colors, int x, int y, int count,int texWidth)
{
int emptyCount = 0;
for (int i = 0; i < count; i++)
{
for (int j = 0; j < count; j++)
{
if (colors[(y * count + i) * texWidth + x * count + j].r == 0) emptyCount++;
}
}
return (emptyCount * 100.0f / count / count > 98);//데이터 없는 면적이 98% 초과면 블록 전체 버림
}
}
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] 고성능 지형 텍스처 반복감 개선 (0) | 2026.02.20 |
|---|---|
| [번역] 뉴럴 렌더링 탐험기 (0) | 2026.02.19 |
| [번역] Call of Dragons》 렌더링 프레임 캡처 분석과 미스터리 (0) | 2026.02.18 |
| [번역] Impostors 상세 해설 — 종이 한 장으로 만들어낸 아름다운 환상 (0) | 2026.02.17 |
| [번역] Unity CSM 셰도우 개조하기 (0) | 2026.02.13 |