2D 조명 시스템 구현: Signed Distance Field(SDF) 기반 실시간 그림자 연산
2D 게임에서 정밀한 조명과 자연스러운 반음영(Soft Shadow)을 표현하기 위해 Signed Distance Field(SDF)와 레이마칭 기법을 적용하는 실전 가이드입니다. 오클루더 맵 생성부터 Jump Flooding Algorithm, 셰이더 연산까지 단계별 구현 및 최적화 방법을 소개합니다.

핵심 요약
SDF 조명은 장애물 경계 시드까지의 거리를 근사하고, 점유 마스크로 장애물 안팎을 판정해 부호를 붙인 뒤 텍스처에 기록합니다. 광원에서 각 픽셀까지 스피어 트레이싱(Sphere Tracing/Raymarching)을 수행해 그림자와 반음영(Penumbra)을 근사합니다. GPU의 Jump Flooding Algorithm(JFA)은 일정한 패스 수로 거리장을 계산하지만 최근접 시드를 항상 정확히 찾지는 않으므로, 오차와 텍스처 해상도를 확인해야 합니다.
2D 게임에서 기존 그림자 캐스팅 방식은 왜 성능 병목을 일으킬까?
2D 탑다운 또는 횡스크롤 게임에서는 동적 조명과 그림자를 분위기 연출에 활용할 수 있습니다. 구현 방식의 예로는 2D 폴리곤 지오메트리 기반 섀도우 볼륨(Shadow Mesh Extrusion)과 광원 주변을 샘플링하는 1D 섀도우 맵이 있습니다.
지오메트리 기반 방식은 오클루더의 실루엣에서 그림자 형태를 만들고 래스터화합니다. CPU 비용과 렌더링 비용은 형상 갱신 빈도, 메시 복잡도, 광원과 오클루더 수, 오버드로에 따라 달라지므로 모든 프로젝트에서 매 프레임 버텍스를 다시 순회하거나 드로우콜이 급격히 증가한다고 일반화할 수는 없습니다.
광원 주변을 각도별로 샘플링하는 1D 섀도우 맵은 광원 해상도와 샘플 수에 따라 비용이 달라집니다. 샘플 간격과 필터링은 그림자 경계의 품질에 영향을 주므로 반음영과 복잡한 폐색의 표현력은 구현 및 해상도에 따라 비교해야 합니다.
| 비교 항목 | 2D 지오메트리 섀도우 메시 | 1D 원형 섀도우 맵 | SDF 기반 레이마칭 |
|---|---|---|---|
| 주요 연산 위치 | CPU (외곽선 추출) + GPU | GPU (래스터화) | GPU (Compute / Pixel Shader) |
| 주요 비용 요인 | 형상 생성·갱신 및 렌더링 비용 | 광원 해상도와 샘플링 수 | 거리장 해상도·JFA 패스 및 광원 셰이더 |
| 반음영(Soft Shadow) | 별도 필터링 또는 블러 방식에 따라 다름 | 샘플 수와 필터링 방식에 따라 다름 | 거리장과 반음영 근사식에 따라 다름 |
| 동적 스프라이트 처리 | 형상 변경 시 메시 갱신 필요 | 갱신 방식에 따라 다름 | 오클루더 마스크와 거리장을 갱신해야 함 |
이러한 문제를 다루는 한 가지 구현 방식은 SDF(Signed Distance Field)를 활용한 2D 조명 시스템입니다.

SDF(Signed Distance Field)의 수학적 원리
정확한 SDF는 2차원 평면 위의 좌표 에서 가장 가까운 장애물 경계까지의 부호 있는 유클리드 거리를 나타내는 스칼라 장(Scalar Field)입니다. JFA로 텍스처에서 계산한 값은 최근접 시드의 근사일 수 있으므로, 이하에서는 SDF처럼 사용하는 근사 거리장도 포함해 설명합니다.
수학적으로 장애물 영역을 , 그 경계선을 라고 할 때 점 에서의 부호화 거리 함수(SDF) 는 다음과 같이 정의됩니다.
거리장이 광선 위치마다 보수적인 경계까지의 거리를 제공하면 광원 위치 에서 대상 픽셀 로 향하는 광선을 고정 간격 대신 그 거리만큼 전진시키는 스피어 트레이싱(Sphere Tracing)을 사용할 수 있습니다. JFA 오차, 텍스처 필터링, 양자화가 보수성을 깨뜨리면 표면을 건너뛸 수 있으므로 충돌 오차와 그림자 품질을 검사해야 합니다.
flowchart LR
A["1. Occluder Map 렌더링<br/>(장애물 알파 마스크)"] --> B["2. Jump Flooding (JFA)<br/>(2D 거리 텍스처 생성)"]
B --> C["3. SDF Lighting Shader<br/>(Sphere Tracing & Shadow)"]
C --> D["4. Final Composite<br/>(씬 텍스처와 곱셈 합성)"]
SDF 기반 2D 그림자는 어떻게 단계별로 구현할까?
실시간 2D SDF 조명 시스템은 3단계 렌더 패스로 구성됩니다.
1단계: 오클루더 맵(Occluder Map) 렌더링
빛을 차단하는 2D 스프라이트, 타일맵, 오브젝트의 실루엣을 점유 마스크(R8)에 렌더링합니다. 장애물이 존재하는 영역은 1, 빈 공간은 0으로 기록하고, JFA가 시작할 수 있도록 실루엣 경계 픽셀에는 별도의 seed 텍스처에 해당 픽셀 좌표를 기록합니다.
2단계: 점프 플러딩 알고리즘(JFA)을 통한 실시간 SDF 텍스처 생성
화면의 각 픽셀에서 모든 경계 시드를 검사하는 완전 탐색은 픽셀과 최악의 경우 개 시드를 비교하므로 해상도에서 이 됩니다. 이를 줄이는 GPU 기반 근사법으로 Jump Flooding Algorithm(JFA)을 사용할 수 있습니다.
정사각형이고 해상도 이 2의 거듭제곱인 텍스처에서는 스텝 크기 로 8개 이웃의 최근접 경계 좌표 시드(Seed)를 교환하며 거리를 전파합니다. 여기서 구한 거리는 부호 없는 근사 거리입니다. 점유 마스크로 안팎을 판정해 내부 거리를 음수로 만들고, 셰이더와 같은 좌표 척도에서 거리 값을 기록해야 합니다. 비정사각형 텍스처의 UV 거리를 그대로 유클리드 거리로 비교하면 축마다 척도가 달라지므로 픽셀 또는 월드 좌표 척도를 반영해야 합니다. 예를 들어 대상 플랫폼에서 부호 있는 부동소수점 R16F 포맷의 지원을 확인한 뒤 사용하면 셰이더에서 .r의 부호로 내부와 외부를 구분할 수 있습니다. 2의 거듭제곱 해상도에서 기본 패스 수는 이며 JFA 결과는 근사치입니다.
3단계: 스피어 트레이싱 및 반음영(Penumbra) 셰이더 연산
라이트 셰이더는 광원 에서 대상 픽셀 방향으로 레이마칭을 수행합니다. 광선이 장애물에 닿았는지 여부뿐만 아니라 광선 경로상에서 기록된 광선과 장애물 사이의 최소 상대 거리를 누적하여 빛의 부드러운 번짐 정도(Penumbra)를 수학적으로 산출합니다.
점 에서의 광선 위치를 (는 정규화된 방향 벡터)라 할 때, 펜엄브라 계수 는 다음과 같은 수식으로 근사 계산됩니다.
여기서 는 광원의 크기와 부드러움을 조절하는 양수 파라미터입니다. 샘플 지점 는 0보다 커야 하며, 이 근사식에서 값이 0에 가까우면 어두운 영역, 1에 가까우면 가려지지 않은 영역을 뜻합니다. 완전한 하드 섀도우는 별도 광선-장애물 교차 판정으로 처리해야 합니다.

핵심 HLSL 레이마칭 셰이더 코드
다음은 Unity URP 또는 커스텀 렌더 파이프라인에서 사용할 수 있는 2D SDF 기반 소프트 섀도우 레이마칭 함수 구현체입니다.
// SDF 텍스처 샘플링을 통한 2D 소프트 섀도우 계산
Texture2D _SDFTexture;
SamplerState sampler_SDFTexture;
float Calculate2DSoftShadow(
float2 lightUV, // 광원의 UV 좌표 (0~1)
float2 pixelUV, // 렌더링 대상 픽셀의 UV 좌표 (0~1)
float lightRadiusUV, // 광원의 최대 도달 반경 (UV 단위)
float shadowSoftness, // 반음영 계수 (k_soft)
int maxSteps // 최대 레이마칭 스텝 수 (권장: 16~32)
)
{
float2 rayDir = pixelUV - lightUV;
float maxDist = length(rayDir);
// 광원 범위를 벗어난 경우 그림자 연산 생략
if (maxDist > lightRadiusUV || maxDist <= 0.0001)
{
return 1.0;
}
float2 dir = normalize(rayDir);
float traveledDist = 0.002; // 자기 자신(셀프 섀도잉) 오클루전 방지 오프셋
float shadowFactor = 1.0;
[loop]
for (int i = 0; i < maxSteps; i++)
{
if (traveledDist >= maxDist)
break;
float2 currentPosUV = lightUV + dir * traveledDist;
// 부호를 적용해 R16F에 저장한 SDF 샘플링 (거리 단위: UV)
float distToObstacle = _SDFTexture.SampleLevel(sampler_SDFTexture, currentPosUV, 0).r;
// 장애물 내부(거리 <= 0)에 도달한 경우 완전한 암흑 처리
if (distToObstacle < 0.0005)
{
return 0.0;
}
// 양수 softness는 소프트 섀도우 근사, 0 이하는 하드 섀도우
if (shadowSoftness > 0.0)
{
float penumbra = (shadowSoftness * distToObstacle) / traveledDist;
shadowFactor = min(shadowFactor, penumbra);
}
// 스피어 트레이싱: 거리장 값만큼 전진
traveledDist += distToObstacle;
}
return saturate(shadowFactor);
}
2D SDF 조명 최적화 전략
실제 환경에서는 다음 최적화 후보를 목표 기기와 장면에서 측정해 선택합니다. 이 방법들이 특정 프레임률을 보장하지는 않습니다.
- 하프 해상도(Half-Resolution) JFA 연산
SDF 맵과 JFA 패스는 메인 프레임버퍼의 1/2 또는 1/4 해상도 렌더 텍스처에서 수행할 수 있습니다. 최종 합성에서 업샘플링하면 비용을 낮출 수 있지만, 얇은 장애물이나 작은 간격이 사라지거나 경계가 이동할 수 있으므로 해당 해상도에서 결과를 검증합니다. - 조명 범위별 타일드/클러스터드 컬링 적용
전체 화면에 대해 모든 조명의 레이마칭을 수행하는 대신, 포인트 라이트의 유효 반경에 해당하는 바운딩 박스(Quad) 영역만 래스터화하여 픽셀 셰이더 호출 범위를 최소화합니다. - 정적 배경 SDF 캐싱 및 동적 맵 블렌딩
움직이지 않는 정적 타일맵은 씬 로드 시 SDF 텍스처를 구워두고 움직이는 요소는 별도 동적 맵으로 갱신할 수 있습니다.min(staticSDF, dynamicSDF)으로 합치려면 두 거리값이 같은 좌표계와 단위를 사용해야 하며, 근사값과 마스크 경계에서 생기는 오차도 확인해야 합니다.
자주 묻는 질문 (FAQ)
Q1. SDF 텍스처 생성 시 정적 베이크와 실시간 JFA 중 무엇이 유리한가요?
배경 지형이나 벽체처럼 변하지 않는 환경은 오프라인 또는 씬 진입 시 베이크하면 매 프레임 재생성 비용을 줄일 수 있지만 텍스처 메모리를 사용합니다. 움직이는 요소는 동적 맵으로 분리해 갱신할 수 있으며, 적절한 해상도와 갱신 빈도는 장면과 성능 목표에 따라 측정해야 합니다.
Q2. 레이마칭 스텝 수를 줄이면 그림자에 밴딩(Banding) 현상이 생기는데 해결책이 있나요?
레이마칭 스텝 수가 적을 때 생기는 계단 현상은 시작 지점이나 광선 방향을 노이즈로 분산해 완화할 수 있지만, 노이즈 종류와 적용 방식은 구현에 맞게 검증해야 합니다. TAA나 블러는 시간적 잔상과 경계 번짐을 만들 수 있으므로 특정 스텝 수(예: 12~16회)가 충분하다고 일반화하지 말고 목표 해상도와 장면에서 측정합니다.
Q3. 유니티 URP 2D Renderer의 내장 2D Light와 비교했을 때 어떤 장점이 있나요?
URP의 Shadow Caster 2D는 그림자 캐스팅에 사용할 형상과 메시를 관리합니다. SDF 방식도 오클루더를 점유 마스크에 그리는 비용, 텍스처 해상도, JFA 패스, 광원별 셰이더 적용 비용을 사용하므로 오클루더 개수와 무관한 고정 비용이라고 할 수 없습니다. 어느 방식이 유리한지와 반음영 품질은 목표 기기와 장면에서 비교 측정해야 합니다.
요약
SDF 기반 2D 조명 시스템은 기하학적 복잡도를 텍스처 거리 장 정보로 변환하여 GPU의 병렬 처리 성능을 극대화하는 렌더링 아키텍처입니다.
오클루더 렌더링, Jump Flooding, 스피어 트레이싱 셰이더로 이어지는 3단계 파이프라인을 구축하면 대규모 2D 오브젝트가 등장하는 환경에서도 일정한 성능과 정교한 실시간 그림자 효과를 달성할 수 있습니다.



