저자: 유클리드 노름
【RecaNoMaho】처음부터 시작하는 체적광 렌더링

0 시작하기 전에
RecaNoMaho는 내가 만들고 있는 오픈소스 Unity URP 프로젝트다. 이 프로젝트 안에는 흔히 볼 수 있는 렌더링 효과, 조금은 별난 효과, 그리고 재미있는 렌더링 기술들을 계속 수록해 나갈 생각이다. 단순히 결과만 보여주는 것이 아니라, 소스 구현과 원리 해설까지 함께 정리해서 이 시리즈가 어느 정도 학습 가치와 참고 의미를 갖도록 하는 것이 목표다.
어느 정도의 이식성, 반복 개발 가능성, 그리고 가벼움을 보장하기 위해, 이 렌더링 효과와 기술을 구현할 때의 첫 번째 원칙은 이렇다. RenderFeature로 쓸 수 있는 것은 최대한 RenderFeature로 쓴다. 그 대가는 있다. 원래 URP 코드를 직접 고치면 아주 편하게 할 수 있는 일도 조금 돌아가야 할 수 있고, 파이프라인 레벨에서 가능한 일부 최적화 포인트를 잃을 수도 있다. 이런 최적화는 실제 프로젝트 실천 과정에서 다시 구현하면 된다. 개인 능력에는 한계가 있으니, 글 안에 틀린 부분이 있거나 더 좋은 생각이 있다면 함께 토론해 주면 좋겠다~
RecaNoMaho 프로젝트 저장소:
https://github.com/recaeee/RecaNoMaho_P
현재 사용 중인 Unity 버전: 2022.3.17f1c1
현재 사용 중인 URP 버전: 14.0.9

위 이미지는 현재 RecaNoMaho 안에서 구현한 체적광 렌더링 효과다.
1 체적광이란 무엇인가?
현실 환경에서 우리는 가끔 구름 사이로 빛줄기가 쏟아지는 것을 볼 수 있다. 또 약간의 연무가 있는 무대에서는 스포트라이트 아래에서 마치 “부피”를 가진 듯한 빛을 볼 수 있다. 이런 현상을 틴들 효과, Tyndall effect라고 부른다. 틴들 현상, 틴들 효과라고도 하며, 인터넷에서는 온갖 XXX 효과 같은 밈으로도 자주 불린다. 그 원리는 빛이 떠다니는 콜로이드 입자에 의해 산란되는 것이다. 아래 이미지에서 독일 궁정 주교좌성당 내부에 보이는 빛의 경로는 틴들 효과의 전형적인 예시다.

그렇다면 체적광은 무엇인가? 체적광은 3D 컴퓨터 그래픽스에서 렌더링된 장면에 조명 효과를 추가하기 위한 기술이다. 이 기술을 사용하면 우리는 “공간 속의 빛줄기”를 볼 수 있다. 렌더링과 게임 분야에서 널리 사용되며, 아래 이미지는 CG 기술 안에서의 체적광 렌더링 효과다.

그러면 이렇게 말할 수 있다. 체적광은 현실 환경의 틴들 효과를 시뮬레이션하기 위한 CG 기술, 혹은 미세 입자가 빛을 산란시키는 현상을 시뮬레이션하기 위한 기술이다. 따라서 체적광을 구현하려면 먼저 현실 환경에서 틴들 효과가 왜 생기는지 간단히 이해할 필요가 있다. 아래 설명은 《Real-Time Rendering, Fourth Edition》 14장의 내용을 참고해 최대한 쉽게 풀어쓴 것이다. 관심 있는 사람은 원서를 직접 보는 편이 더 좋다.
1.1 틴들 효과의 원인
먼저 생각해 보자. 틴들 효과에서 우리가 보는 “부피”를 가진 빛은 대체 무엇인가?
우리가 현실의 여러 물체 표면을 볼 수 있는 이유는, 빛이 물체 표면을 거치거나 물체 표면에서 출발해, 즉 자체 발광해, 반사와 굴절 같은 과정을 거쳐 눈에 들어오기 때문이다. 틴들 효과도 원리는 같다. 다만 차이가 있다면, 빛이 거치는 것이 “물체 표면”이 아니라 미세 입자로 가득 찬 어떤 매질, 즉 어떤 영역 혹은 공간이라는 점이다. 이것을 참여 매질, Participating Media라고 부른다.
빛이 물체 표면을 지날 때 반사와 굴절이 일어나듯이, 빛이 참여 매질을 지날 때는 산란과 흡수라는 두 가지 현상이 일어난다. 더 엄밀하게 말하면 반사와 굴절도 산란의 한 종류라고 볼 수 있다. 우리가 보는 “부피 있는 빛”의 원인은 바로 이것이다. 빛이 참여 매질을 지난 뒤 산란되어 사람 눈에 들어온다. 이것이 틴들 효과의 원인이다.
1.2 참여 매질
이제 참여 매질에 대해 이야기해 보자.
참여 매질, Participating Media는 입자로 가득 찬 체적을 설명하는 개념이다. 이 매질은 빛의 전달 과정에 참여하며, 다시 말해 산란이나 흡수를 통해 자신을 통과하는 빛에 영향을 준다. 사실 돌이나 나무 같은 물체 표면도 참여 매질이라고 볼 수 있다. 다만 그것들은 밀도가 매우 높은 참여 매질이다. 대부분의 빛은 그런 표면을 지날 때 참여 매질 속 입자와 접촉하고 산란된다. 산란 정도가 매우 높고, 주로 기하 산란에 가깝다. 사람 눈에 보이는 것은 바로 이렇게 대량으로 산란되어 들어오는 빛이다.
반대로 물, 안개, 수증기 같은 밀도가 낮은 참여 매질은 전체 입자 수가 적다. 그래서 대부분의 빛은 매질을 지나도 입자의 영향을 받지 않고 원래 방향으로 계속 나아간다. 오직 일부 빛만 입자의 영향을 받아 산란된다. 만약 우리가 빛이 오는 방향을 직접 바라보지 않는다면, 대부분의 빛은 거의 눈에 들어오지 않으므로 볼 수 없다. 하지만 분명히 일부 빛은 참여 매질 속 입자에 의해 산란되고, 이 산란광이 눈에 들어오면 우리는 물, 안개, 수증기 같은 낮은 밀도의 참여 매질 속 빛을 보게 된다.
비유하자면 아래 그림과 같다. 왼쪽의 밀도가 큰 참여 매질, 예를 들어 돌이 100%의 빛을 받는다면, 그중 99.999%의 빛은 주변 모든 방향으로 산란되고, 아마 0.001%의 빛만 원래 방향으로 투과될 것이다. 반면 오른쪽의 밀도가 작은 참여 매질, 예를 들어 구름이나 안개가 100%의 빛을 받는다면, 그중 70%의 빛은 원래 방향으로 투과되고, 동시에 30%의 빛이 다른 방향으로 산란된다. 이 산란된 빛이 눈에 들어오면, 우리는 구름이나 안개 속의 틴들 효과를 보게 된다. 여기서 좌우 그림의 산란광 편향량 차이에 너무 신경 쓸 필요는 없다. 지금 단계에서는 빛의 투과와 산란 비율만 생각하면 된다. 그림에서 매질에 따라 산란광의 편향량을 다르게 그린 것은, 뒤에서 매질 입자가 산란 방향에 어떤 영향을 주는지 직관적으로 설명하기 위한 밑밥 정도라고 보면 된다.

이제 우리는 참여 매질이 무엇인지, 그리고 밀도가 다른 참여 매질이 빛을 서로 다른 정도로 산란시킨다는 것을 알았다. 여기에 한 가지를 더 덧붙이자. 사람이 일반적으로 보는 틴들 효과는 두 종류의 빛을 포함한다고 볼 수 있다. 하나는 위에서 말한 광원이 낮은 밀도의 참여 매질을 지나며 산란된 빛이다. 우리가 예쁘다고 느끼는 바로 그 빛줄기다. 다른 하나는 시선 뒤쪽에 있는 다른 참여 매질 혹은 물체 표면에서 나온 빛이 참여 매질을 투과해 들어온 것이다. 즉 틴들 효과 너머로 보이는 물체 표면, 예를 들어 구름 뒤의 산 같은 것이다. 아래 그림이 이 관계를 보여준다. 낮은 밀도 참여 매질은 입자 수가 적기 때문에 산란되는 빛의 총량이 적고, 그래서 우리는 그 뒤의 물체도 볼 수 있다.

1.3 빛의 산란과 흡수
좋다. 참여 매질 이야기를 했으니 이제 빛의 산란과 흡수를 이야기해 보자.
빛이 매질을 통과하다가 매질 속 입자에 의해 반사되는 사건을 보통 Light Scattering, 즉 빛의 산란이라고 부른다. 여기서 말하는 반사는 출사 방향과 입사 방향 사이의 각도가 반드시 커야 한다는 뜻은 아니다. 어떤 각도로든 출사될 수 있고, 그것을 반사 혹은 산란으로 볼 수 있다. 모든 것은 산란을 일으킨다. 일반적인 의미의 표면 반사와 굴절도 모두 빛의 산란에 속한다.
빛의 산란 원리를 자세히 이야기하려면 사실 매우 복잡하다. 하지만 체적광 렌더링에서는 그렇게까지 많이 알 필요는 없다. 우리는 이것만 알면 된다. 빛이 참여 매질 안의 아주 작은 영역을 지날 때, 일정 확률로 원래 방향과 다른 방향으로 산란된다. 이것이 바로 체적광을 지탱하는 이론적 기반이다. 정량 모델, 즉 각 방향으로 얼마나 많은 빛이 산란되는지를 어떻게 정의하는지, 어떤 파라미터가 영향을 주는지는 2장에서 다시 이야기하겠다.
참여 매질 속 입자가 대체 어떤 방식으로 빛을 꺾는지까지는 깊게 알 필요가 없다. 물론 관심 있는 사람은 RTR4의 9장을 따로 보면 된다.
동시에 우리는 이것도 알아야 한다. 빛, 혹은 광자는 참여 매질을 지날 때 일부가 흡수되어 열이나 다른 형태의 에너지로 전환된다. 이것 역시 체적광 렌더링에서 고려해야 하는 요소다.
1.4 산란에 영향을 주는 요소
왜 우리는 현실에서 틴들 효과를 가끔, 혹은 특정 환경에서만 볼 수 있을까?
먼저 어떤 상황에서 틴들 효과를 보기 쉬운지 생각해 보자. 먼지가 가득한 교회, 아침 안개가 낀 숲, 비가 막 그친 뒤 맑아진 하늘. 이런 환경의 공통점은 참여 매질의 밀도가 어느 정도에 도달했다는 것이다. 즉 참여 매질의 밀도가 클수록 그 안의 입자가 많고, 입자가 많을수록 더 많은 산란이 일어난다. 산란 정도가 더 강해진다고 말할 수도 있다.
동시에 참여 매질 속 단일 입자의 크기도 산란에 영향을 준다. 빛이 참여 매질 속 단일 입자를 지날 때, 각 방향으로 산란될 확률은 입자 반경과 관련이 있다. 이것이 뒤에서 말할 Phase Function이다. 직관적으로는 이해하기 조금 어려운 부분이지만, 실제로 체적광 렌더링에서 중요한 파라미터다. 현실의 틴들 효과는 물, 구름, 안개, 먼지 낀 방 등 여러 참여 매질에서 생길 수 있다. 모두 틴들 효과라고 부르지만, 실제 현상은 조금씩 다르다. 그 이유 중 하나가 바로 각 장면의 참여 매질 입자가 다르고, 따라서 입자 반경도 다르기 때문이다.
또 하나, 입사광의 파장도 산란에 영향을 준다. 파장이 짧은 빛일수록 더 쉽게 산란된다. 파란색 빛은 파장이 짧기 때문에 더 쉽게 산란되며, 이것이 하늘이 파란 이유 중 하나다. 하지만 실시간 렌더링 분야에서는 이 요소를 거의 고려하지 않는 편이다. 물리 시뮬레이션을 매우 엄밀하게 해야 하는 경우, 예를 들어 대기 렌더링의 Rayleigh Scattering 같은 경우가 아니라면 말이다.
정리하면, 체적광 렌더링에 반드시 필요한 산란 영향 요소는 주로 두 가지다.
- 참여 매질 밀도: 밀도가 클수록 산란을 일으키는 입자가 많고, 산란 정도가 강해진다.
- 참여 매질을 구성하는 입자의 반경: 입자 반경이 다르면 각 방향으로 산란될 확률도 달라진다. 이를 통해 물속 체적광, 연기 속 체적광 같은 서로 다른 환경을 구분할 수 있다.
주의할 점은, 빛의 파장이 산란에 미치는 영향은 여기서는 무시한다는 것이다. 실시간 렌더링 환경에서 이 요소까지 고려하면 복잡도가 꽤 높아진다.
2 체적광의 정량 모델
2.1 빛의 전파와 매질 통과에 영향을 주는 4가지 사건
1절에서 우리는 틴들 효과의 원인이, 빛이 참여 매질을 지난 뒤 산란되어 눈에 들어오는 것임을 알았다. 그리고 정성적으로 참여 매질이 빛의 산란에 어떤 영향을 주는지도 설명했다. 이제는 체적광의 정량 모델 차례다. 말하자면 물리 법칙을 수학 공식으로 바꾸고, 이후 렌더링 실천에서는 그 수학 공식을 다시 코드로 바꾸는 단계다.
이 절 역시 《Real-Time Rendering, Fourth Edition》을 참고한다. 먼저 우리는 실시간 렌더링에서 3D 모델을 렌더링할 때, 예를 들어 PBR에서는 보통 빛이 광원에서 출발해 물체 표면에 닿고, 반사되어 카메라의 각 픽셀에 들어오는 RGB 값을 계산한다. 원서 표현을 빌리면 더 엄밀하게는 표면 셰이딩 지점에서 카메라 위치까지의 radiance를 계산하는 것이다. 체적광 렌더링도 비슷하다. 우리는 빛이 광원에서 출발해 참여 매질에 닿고, 그 뒤 산란되어 카메라의 각 픽셀로 들어오는 RGB 값을 계산해야 한다. 더 엄밀하게 말하면 빛이 참여 매질을 통과해 카메라 위치까지 전파되는 경로상의 radiance를 계산해야 한다.
그러면 문제는 이 radiance를 어떻게 계산하느냐다. 답은 원서를 참고하면 된다. 여기서는 단일 산란만 고려한다. 관심 있는 사람은 원서 14.1.1절을 직접 보면 좋다. 지면 관계상 여기서는 개괄 위주로 설명한다. 원서가 훨씬 더 자세하다.
매질을 따라 전파되는 radiance에 영향을 줄 수 있는 사건은 4종류가 있다. 아래 그림은 이 함수들의 도식적 설명이다.

요약하면 다음과 같다.
- 흡수 Absorption — 광자가 매질에 흡수되어 열이나 다른 형태의 에너지로 전환된다.
- 외산란 Out-scattering — 광자가 매질 속 입자에 의해 튕겨 나가 현재 경로 밖으로 산란된다. 이 사건이 일어날 확률은 빛의 반사 방향 분포를 설명하는 Phase Function에 의해 결정된다.
- 발광 Emission — 매질이 높은 온도에 도달했을 때, 예를 들어 화염의 흑체 복사처럼 매질에서 빛을 방출할 수 있다. 보통 렌더링에서는 이 부분을 고려하지 않아도 된다.
- 내산란 In-scattering — 어떤 방향에서 온 광자든, 매질 입자에 의해 튕긴 뒤 현재 광로 안으로 산란되어 최종 radiance에 기여할 수 있다. 주어진 방향으로 얼마나 많은 빛이 내산란되는지도 해당 빛 방향의 Phase Function에 의해 결정된다. 외산란과 대응되는 개념이라고 보면 어렵지 않다. 현재 경로에서 빛이 밖으로 산란될 수 있다면, 당연히 다른 경로의 빛도 현재 경로 안으로 산란될 수 있다.
이 네 가지 사건을 기준으로 보면, 어떤 경로에 광자를 더하는 것은 내산란과 발광, 보통은 무시 가능, 의 함수다. 즉 광로상의 RGB 값을 증가시키는 항이다. 반대로 광자를 제거하는 것은 Extinction, 소광 계수의 함수이며, 이는 흡수와 외산란을 대표한다.
2.2 최종 RGB 값을 어떻게 계산할 것인가
체적광 렌더링에서 최종 radiance, 즉 카메라의 한 픽셀 RGB 값은 두 부분으로 이루어진다. 하나는 물체 표면에서 반사된 빛이 매질을 지나 카메라 위치까지 도달한 radiance이고, 다른 하나는 정확한 광원에서 온 산란광이 매질을 지나 카메라 위치까지 도달한 radiance다. 공식은 아래와 같다. 보기에는 복잡하지만, 사실 이해 자체는 그렇게 어렵지 않다. 수학 공식이라는 게 정말 간결하고 추상적이라 감탄이 나올 정도다.

위에서 말했듯이, 앞의 항은 관찰 방향의 반대 방향에서 물체 표면이 반사한 빛이 매질을 지나 카메라 위치까지 도달한 radiance다. 뒤의 항은 관찰 방향의 반대 방향에서 정확한 광원으로부터 산란된 빛이 매질을 지나 카메라 위치까지 도달한 radiance다. 여기서 T는 주어진 점과 카메라 위치 사이의 투과율이고, Lscat은 관찰 광선을 따라 주어진 점 x에서 산란된 빛이다. 공식의 각 계산 부분은 아래 그림처럼 볼 수 있다.

이제 이 공식에 들어가는 관련 개념을 간단히 이야기하겠다. 이 개념들은 코드 구현에서도 중요한 파라미터다. 지면도 제한되어 있고, 나 자신의 이해도 제한되어 있으며, 내용 자체도 꽤 난해해서 설명하기 쉽지 않다. 부족한 부분은 양해 바란다. 더 자세한 내용은 원서를 참고하면 좋다.
2.3 투과율
투과율은 빛이 일정 거리 안에서 매질을 통과할 수 있는 비율을 의미한다. 쉽게 말하면, 빛은 매질을 지날 때 거리에 따라 감쇠한다. 수학적 정의는 아래와 같다.

이 관계는 Beer-Lambert 법칙이라고도 부른다. 식 안의 Optical Depth, 광학 깊이는 단위가 없으며 빛의 감쇠량을 나타낸다. Extinction, 소광 계수나 전파 거리가 커질수록 광학 깊이도 커진다. 이는 매질을 통과하는 빛이 줄어든다는 뜻이다. 앞에서 말했듯이, 투과율은 흡수와 외산란의 영향을 동시에 받는다.

2.4 산란 사건
장면 안의 주어진 위치 x, 즉 Ray Marching 샘플 지점과 방향 v, 즉 관찰 방향의 반대 방향에 대해, 정확한 광원의 내산란 사건은 다음과 같이 적분할 수 있다.

식에서 l은 광원의 수, p는 Phase Function, V는 Visibility Function, wi는 i번째 광원의 방향 벡터, pi는 i번째 광원의 위치다.
보기에는 복잡하지만 사실 간단하다. 단일 광원만 고려한다면, 공간의 한 점에서의 내산란 강도는 Phase Function, 즉 특정 방향으로 산란될 확률, 광원이 그 점에서 보이는지 여부, 즉 그림자와 매질 체적 감쇠, 그리고 광원에서 그 점까지 오는 radiance의 거리 감쇠와 관련 있다고 이해하면 된다.
Visibility Function은 광원 위치에서 나온 빛이 최종적으로 위치 x에 도달하는 비율을 나타낸다. 수학적 형태는 아래와 같다.

ShadowMap은 이해하기 쉽다. 해당 점이 그림자 안에 있는지 아닌지를 뜻한다. volShad, 체적 그림자 항은 광원 위치에서 샘플 지점까지의 투과율을 의미한다. 2.3절에서 이야기한 흡수와 외산란 사건에 해당한다.
2.5 Phase Function
드디어 마지막 이론 부분이다. 힘내자!!
1.4절에서 말했듯이, 참여 매질 속 단일 입자의 크기도 산란에 영향을 준다. 빛이 참여 매질 속 단일 입자를 지날 때, 서로 다른 방향으로 산란될 확률은 입자 반경과 관련이 있다. 즉 입자 크기는 주어진 방향으로 빛이 산란될 확률에 영향을 준다.
내산란 사건을 평가할 때는 Phase Function을 사용할 수 있다. 이는 거시적인 관점에서 산란 방향의 확률 분포를 설명하는 함수다. 다시 말해, 하나의 입자에 대해 입사 방향이 주어졌을 때, 출사 방향이 모든 방향에 대해 어떤 확률 분포를 갖는지 정의하는 함수다. 이 함수는 단위 구 위에서 적분했을 때 반드시 1이 되어야 한다. 아래 그림은 이 개념을 보여준다. 이해하기 쉽다. 여기서 θ는 빛의 전방 진행 경로, 파란색과 외산란 방향, 초록색 사이의 각도를 뜻한다.

물리적인 이유 때문에, 서로 다른 크기 스케일의 입자 반경에 따라 Phase Function은 크게 달라진다.
- 빛의 파장에 비해 매우 작은 입자는 Rayleigh Scattering을 일으킨다. 예를 들면 공기다.
- 상대 크기가 1에 가까운 입자는 Mie Scattering을 일으킨다. 흔한 예로 안개 속 스포트라이트, 태양 방향의 구름 등이 있다. 이것이 바로 체적광에 대응하는 Phase Function이다.
- 입자 크기가 빛의 파장보다 명확히 크면 기하 산란이 일어난다.
체적광 렌더링에서는 Mie Scattering의 Phase Function이 필요하다. 물리학자들의 연구 덕분에, Mie Scattering을 설명하는 데 자주 쓰이는 Phase Function 중 하나가 Henyey-Greenstein, HG Phase Function이다. 연기, 안개, 먼지 같은 참여 매질을 표현하는 데 사용할 수 있다. HG 함수의 수학적 형태는 아래와 같다.

여기서 θ는 입사광과 출사광 사이의 각도다. 파라미터 g는 [-1, 1] 범위의 값이며, 전방 산란과 후방 산란의 비율을 제어한다.
이 함수의 그래프는 아래와 같다.

또한 HG 함수와 비슷한 결과를 내지만 더 빠른 근사 Phase Function인 Schlick Phase Function도 있다.

OK, 이론 부분이 드디어 끝났다. 사실 복잡하다고 하면 복잡하고, 안 복잡하다고 하면 또 안 복잡하다. 이 부분은 설명하기 꽤 어려운 구간이고, 간결함과 자세함 사이의 균형을 맞춰야 한다. 설명이 부족했다면 양해 바란다. 시간이 더 있다면 RTR4 14장을 읽어보는 것을 추천한다. 이제 실전으로 들어가자.
3 RecaNoMaho 체적광 실전
위 내용을 읽었거나 《Real-Time Rendering, Fourth Edition》 14장 “Volume and Translucency Rendering”의 Light Scattering 이론 부분을 읽었다면, 이제 틴들 효과의 원인, 체적광의 정성적 모델과 정량적 모델, 즉 체적광의 이론 부분을 대략 이해했을 것이다. 이제 Unity URP에서 체적광을 어떻게 구현하는지 실천해 보자.
실패 확률을 줄이고, 렌더링 효과가 빠르게 나오는 즐거움을 얻기 위해, 일단 머릿속에서 꿈틀거리는 여러 아이디어는 잠시 접어두자. 먼저 인터넷에 이미 공개된 고수들의 성공 사례를 참고하는 편이 좋다. 이론 모델을 알고 있다면 처음부터 체적광을 직접 구현하는 것도 대체로 가능하긴 하다. 하지만 분명 많은坑을 밟을 것이다. 물론 한 번 직접 해 보면 체적광에 대한 이해는 훨씬 깊어지겠지만, 그 대가는 더 많은 시간과 에너지, 그리고 벽에 부딪힌 뒤 포기할 가능성이다. 그래서 개인적으로는 10일 동안 처음부터 혼자 들이받기보다는, 잠깐 바보가 된 셈 치고 고수들이 공유한 실천을 먼저 참고하는 편이 낫다고 생각한다. 현자가 걸어간 길을 따라 걷는 것이다. 게다가 남의 실천을 참고한 뒤에도, 우리는 얼마든지 자기 아이디어를 계속 열어갈 수 있다.
RecaNoMaho에서는 URP를 렌더링 파이프라인으로 사용하며, RenderFeature 형태로 체적광 렌더링을 구현했다. 덕분에 쉽게 이식하고 사용할 수 있다. 이번 실전의 대부분 코드는 SardineFish 님의 글 《Unity에서 체적광 렌더링 구현하기》를 참고했다. 아낌없이 공유해 주신 것에 감사드린다. 해당 작성자는 《Real-Time Rendering, Fourth Edition》 14장과 GDC 2016의 《Low Complexity, High Fidelity - INSIDE Rendering》 발표를 주로 참고해 체적광 구현을 완성했다. 또한 그 코드는 작성자가 직접 SRP 기반으로 구현한 파이프라인 안에 들어 있다. 그래서 현재 내 코드의 상당 부분은 그 내용을 URP 파이프라인으로 이식한 것에 가깝다. 다시 한 번 공유에 감사드리며, 앞으로도 더 많은 자료를 참고해 RecaNoMaho의 체적광을 계속 보완하고 개선할 생각이다. 원 작성자의 효과 이미지는 아래와 같다.

RecaNoMaho에서 체적광을 초보적으로 구현한 뒤의 효과는 아래와 같다. 4회 샘플링이고, 후처리 같은 추가 최적화는 없다. 개인적으로 이런 입자감이 꽤 마음에 들어서 노이즈를 일부 제거하긴 했지만, 여전히 일부는 남겨 두었다. 그래야 네가 렌더링하고 있는 게 체적광이라는 걸 알 수 있으니까, bushi.

샘플 횟수를 늘리면 아래와 같은 렌더링 결과가 나온다. 물론 따라오는 대가는 성능이다.

이제 실전에서 중요한 몇 가지 포인트를 이야기하겠다. 자세한 내용은 RecaNoMaho 소스 코드를 직접 참고하면 된다.
3.1 Ray Marching 기반 체적광 렌더링
체적광의 실시간 렌더링은 현재 업계에 이미 다양한 구현 방식이 있다. 예를 들면 다음과 같다.
구현은 매우 간단하고 성능도 매우 좋지만, 효과가 많이 제한되는 Billboard 기반 “가짜” 체적광.

마찬가지로 성능은 좋지만 적용 범위가 좁은 후처리 기반 Radial Blur 체적광.

그리고 Volume Rendering 기반 체적광도 있다. 예를 들어 Voxel Volume 기반 체적 안개 같은 구현이다.
마지막으로 이번 절의 주인공인 Ray Marching 기반 체적광이 있다. 효과가 비교적 좋고, 적용 범위도 넓은 체적광 구현 방식이다.
실시간 렌더링에서는 보통 Ray Marching, 즉 광선步进 방식으로 체적광을 구현한다. 광로 위의 여러步进 샘플 지점을 샘플링함으로써 산란광과 올바른 투과율을 적분하는 것이다. 이는 2.2, 2.3, 2.4절에서 설명한 계산식에 해당한다. Ray Marching 기반 체적광 렌더링의 도식은 아래와 같다.

《Unity에서 체적광 렌더링 구현하기》에서는 실시간 Ray Marching의 비용이 매우 크기 때문에 모델을 단순화할 수 있다고 말한다.
- 광원에서 매질로 입사하는 경로의 Transmittance 적분을 생략한다.
- 입사광이 지나간 거리와 매질의 평균 감쇠율을 추정한다. 즉 균일 매질을 가정한다.
- 효과 필요에 따라 그림자를 고려할지 결정한다.
체적광에서 광로상의 산란광을 적분하는 과정은 기본적으로 아래 코드처럼 표현할 수 있다.
// 관찰 방향 ray. near에서 far까지 Ray Marching으로 산란광을 샘플링하고 적분한다.
float3 scattering(float3 ray, float near, float far, out float3 transmittance)
{
transmittance = 1;
float3 totalLight = 0;
float stepSize = (far - near) / _Steps;
// [UNITY_LOOP]
for (int i = 1; i <= _Steps; i++)
{
float3 pos = _WorldSpaceCameraPos + ray * (near + stepSize * i);
// 시점에서 매질 내부 x 지점까지의 투과율.
// 여러 번 적분하는 대신 누적으로 곱해 계산한다.
transmittance *= exp(-stepSize * extinctionAt(pos));
float3 lightDir;
// 산란광 = 매질 내부 x에서 시점까지의 투과율
// * 광원에서 매질 내부 x까지 도달한 산란광
// * step 가중치
// * 매질 내부 x에서 시점 방향으로의 Phase Function
// (입자 지름이 산란 방향에 주는 영향)
totalLight += transmittance * lightAt(pos, lightDir) * stepSize * Phase(lightDir, -ray);
}
return totalLight;
}
// 매질 내부 x 지점에서 받은 빛 RGB와, x에서 광원으로 향하는 방향을 반환한다.
float3 lightAt(float3 pos, out float3 lightDir)
{
// _LightPosition.w = 1이면 SpotLight
lightDir = normalize(_LightPosition.xyz - pos * _LightPosition.w);
float lightDistance = lerp(_DirLightDistance, distance(_LightPosition.xyz, pos), _LightPosition.w);
// 매질 내부 x 지점에서 시점까지의 소광 계수.
// 여러 번 적분하는 대신 누적으로 곱해 계산한다.
float transmittance = lerp(1, exp(-lightDistance * extinctionAt(pos)), _IncomingLoss);
float3 lightColor = _LightColor.rgb;
// 광원 방향과 픽셀에서 광원으로 향하는 방향 사이의 각도에 따른 에너지 손실을 고려한다.
lightColor *= step(_LightCosHalfAngle, dot(lightDir, _LightDirection.xyz));
// 그림자를 고려한다.
lightColor *= shadowAt(pos);
// 투과율에 의한 감쇠.
lightColor *= transmittance;
// 산란 계수 = 소광 계수 - 흡수 계수.
// 여기서는 파라미터를 비율로 단순화하여, 산란 계수 = 소광 계수 * (1 - _Absorption)으로 둔다.
lightColor *= extinctionAt(pos, _BrightIntensity) * (1 - _Absorption);
return lightColor;
}
여기서 extinctionAt은 2.1절에서 말한 소광 계수다. 즉 흡수와 외산란 사건이 현재 광로상의 산란광에 주는 손실을 의미한다. 이것은 균일 매질이라면 상수값으로 표현할 수 있고, 아니면 3D Texture를 샘플링할 수도 있다. Phase Function은 2.5절에 대응하며, HG 함수나 Schlick 함수 같은 서로 다른 모델을 사용할 수 있다. shadowAt은 ShadowMap을 샘플링해 구현할 수 있다.
원 글에서는 시점에서 매질 내부 x 지점까지의 투과율 계산을 누적 곱 방식으로 처리해 여러 번의 적분 계산을 피할 수 있다고 설명한다. 즉 지수 부분을 곱셈 항으로 쪼개는 방식이다.
3.2 Ray Marching의 시작점과 끝점 정하기
코드를 보면, 우리는 관찰 광선의 near 깊이부터 Ray Marching을 시작해 far 깊이까지 계산한다. 실제 장면을 생각해 보자. 예를 들어 스포트라이트가 비추는 장면에서, 스포트라이트가 비추는 범위 자체는 하나의 원뿔이다. 만약 카메라부터 바로 샘플링을 시작하면, 샘플 지점이 스포트라이트 원뿔 밖에 놓이기 쉽다. 그러면 해당 샘플 지점의 조명 결과는 당연히 0이다. 광원에서 그 지점으로 들어오는 빛이 처음부터 0이기 때문이다. 이렇게 되면 많은 샘플 지점이 무효가 되고, 성능만 낭비하며 최종 효과도 좋지 않다.
그래서 첫 번째로 흔한 최적화 포인트는 더 정확한 Ray Marching 시작점과 끝점, 즉 코드의 near와 far를 정하는 것이다.
스포트라이트의 원뿔 범위를 예로 들면, 우리는 관찰 광선이 원뿔 범위에 들어가는 시작점과, 원뿔 범위를 빠져나가는 끝점만 계산하면 된다. 이 구간에서만 Ray Marching하면 충분하다.
원 글에서는 투영 행렬을 기반으로 투영 평두체 6개 평면을 빠르게 얻는 방법을 소개한다. 이를 사용하면 스포트라이트의 조사 범위 평두체, 주의할 점은 원뿔이 아니라 평두체라는 것, 를 빠르게 계산할 수 있다. 참고 문헌은 《Fast Extraction of Viewing Frustum Planes from the WorldView-Projection Matrix》다. 구체 원리는 여기서 펼치지 않겠다. SardineFish 님의 글에서 이 방법의 사고방식을 간단히 소개하고 있다.
3.3 노이즈 적용
Ray Marching 기반 체적광 렌더링에서 노이즈, 즉 jitter sampling을 적용하는 것은 정말 정말 정말 중요하다. 왜 이렇게 중요한지는 그냥 그림으로 보자.
아래는 노이즈를 적용하지 않고 16회 샘플링한 결과다. 그림자 영역이 덩어리져 보이고, 결함도 많으며, 효과가 제대로 나오지 않는 부분도 있다.

아래는 노이즈를 적용하고 4회 샘플링한 결과다. 체적광 효과가 매우 연속적으로 보이고, 결과도 올바르다.

노이즈를 적용하지 않으면 16회 샘플링을 해도 노이즈를 적용한 4회 샘플링보다 못하다. 이걸 보면 체적광 렌더링에서 노이즈가 얼마나 중요한지 바로 알 수 있다.
Ray Marching 기반 체적광에서 노이즈는 광선步进의 시작점과 끝점, 즉 near와 far를 관찰 방향으로 약간 오프셋하는 데 사용한다. 주의할 점은 관찰 방향으로 오프셋한다는 것이다. 동시에 이 오프셋량은 한 번의 Ray Marching step 길이 안으로 제한해야 한다.

노이즈의 출처는 여러 가지일 수 있다. 랜덤 함수로 생성한 White Noise를 쓸 수도 있고, 오프라인에서 생성한 Noise Texture를 쓸 수도 있다. 《Inside》 개발자들은 GDC에서 Blue Noise를 jitter sampling에 사용하면 더 좋은 렌더링 결과를 얻을 수 있다고 제안했다.
Blue Noise는 고주파 랜덤 노이즈이며, 실시간 생성은 어렵다. Christoph Peters의 Blog에는 Blue Noise에 대한 깊은 소개가 있고, 다양한 크기와 타입의 Blue Noise Texture도 제공된다. 다만 URP Package 안에도 3가지 크기의 Blue Noise Texture가 들어 있으므로, 나는 그냥 그걸 가져다 썼다.
또한 체적광 렌더링에서는 노이즈를 적용하는 동시에 Temporal Anti-Aliasing, TAA를 함께 쓰면 효과가 매우 좋다. 내가 사용하는 URP14에는 아직 URP 기본 TAA가 없다. URP15부터 기본 TAA가 제공된다. 예전 실제 프로젝트에서는 URP15의 TAA를 낮은 버전으로 이식해 본 적도 있고, 여러 TAA 방안도 구현해 본 적이 있다. 이후 RecaNoMaho에도 추가할 예정이다. 또 구덩이를 파는 중이다~
그리고 SardineFish 님의 구현을 참고하면, 체적광의 이론 모델에는 사람이 직관적으로 다루기 어려운 파라미터가 많다. 이런 파라미터를 그대로 조정하면 아티스트가 직관적으로 느끼기 어렵다. 그래서 좀 더 사람 친화적인 조정 파라미터를 제공한다. 이 부분은 여기서 자세히 펼치지 않겠다. SardineFish 님의 글을 직접 보면 된다.
3.4 더 스타일라이즈하기
원신 콘솔판 렌더링 기술 공유 영상에서는 매우 물리적인 체적광 효과는 보통 그렇게 강렬하지 않다고 언급한다. 예를 들어 아래 그림 같은 효과다.

아래는 원신 실제 게임에서 사용된 체적광 효과다.

분명히 실제 게임 속 체적광은 더 선명하고 밝으며, 명암 대비도 뚜렷하다. 원신을 참고해 God Ray를 분석한 글에서도 말하듯, 이렇게 선명하고 밝은 광로가 나타난다면 공기 중 입자 밀도는 꽤 높아야 한다. 예를 들면 짙은 안개나 많은 먼지 같은 상황이다. 하지만 현실에서 이런 장면의 체적광은 오히려 더 흐릿해야 한다. 따라서 이 효과는 현실과 맞지 않는다. 하지만 게임에서는 원하는 효과다.
체적광 이론 모델 관점에서 보면, 배경이 선명하다는 것은 Extinction, 즉 흡수와 외산란 사건이 작다는 뜻이다. 동시에 강한 산란이 필요하므로 Albedo가 매우 커야 한다. 이 개념은 위에서 자세히 언급하지 않았지만 RTR4 14장에 나온다.
따라서 이런 스타일라이즈된 체적광을 구현하는 한 방법은 흡수를 매우 낮게 설정하고, 동시에 투과율을 높이는 것이다.
다른 방법은 광원에서 매질까지의 투과율은 높이고, 매질에서 카메라까지의 투과율은 낮추는 것이다. 이 방법은 매우 비물리적이지만, 효과는 아주 좋다.
그래서 RecaNoMaho에서는 현재 후자의 방법으로 체적광 효과를 스타일라이즈했다. 아직 거칠게 만든 상태라, 앞으로 개선할 예정이다.
스타일라이즈 조정을 적용하지 않은 효과는 아래와 같다.

스타일라이즈를 적용한 뒤의 효과는 아래와 같다. 뭐, 그럭저럭 봐줄 만하다. 추가로 샘플 횟수도 조금 늘렸는데, 이 방법은 노이즈가 눈에 띄게 많아지는 문제가 있다.


4 마무리
좋다. RecaNoMaho 첫 번째 글, 《처음부터 시작하는 체적광 렌더링》은 일단 여기까지 실천해 보았다. 사실 지금까지 한 것은 0에서 0.5까지의 체적광 렌더링 정도다. 체적광 효과를 초보적으로 구현했다고밖에 말할 수 없다. 억지로 보면 볼 수 있는 정도고, 현재는 Spot Light에만 적용된다. 실제 운용까지는 아직 갈 길이 멀다. 그러니 이건 상편이라고 해야 하나? 일단 여기서 p를 나누는 이유는 두 가지다. 하나는 이번 글의 내용이 이미 꽤 많아졌기 때문이다. 너무 힘들다! 다른 하나는 내가 너무 오랫동안 아무것도 생산하지 않았기 때문이다. 물고기 잡는 삶에 타락하면 안 된다!
사실 인터넷에는 이미 체적광에 관한 공유와 참고 자료가 많이 있다. 내가 쓴 내용의 대부분도 기존 공유 내용과 내 개인적인 체감에 가깝다. 이후에도 더 많은 자료를 참고해 코드를 최적화하고, 렌더링 효과와 성능 측면을 계속 확장할 생각이다. 함께 토론해 주면 좋겠다~
마지막으로 체적광 속 Miku를 몇 장 공유한다~ Miku의 카툰 렌더링 쪽에 대한 태클은 너무 신경 쓰지 말아 달라 555. 나중에 카툰 렌더링도 제대로 탐구해 보겠다. 구덩이 매립 중이다.



참고
- Unity 체적광 렌더링 구현 글
- Real-Time Rendering 4th 중국어 번역 저장소
- Low Complexity, High Fidelity - INSIDE Rendering
- 틴들 효과 관련 자료
- 체적광 관련 자료
- 게임 개발 관련 실시간 렌더링 기술: 체적광
- Fast Extraction of Viewing Frustum Planes from the WorldView-Projection Matrix
- Blue Noise 자료
- 원신 콘솔판 렌더링 기술 공유
- God Ray 분석 글
- 추가 참고 자료
원글
(73 封私信 / 59 条消息) 【RecaNoMaho】从零开始的体积光渲染 - 知乎
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] 텐센트 델타포스 대형 월드 RVT 지형 렌더링 분석과 재현 (0) | 2026.06.18 |
|---|---|
| [번역] Unity 프레임 캡처로 원신 공중 신전 전시장 복각하기 (0) | 2026.06.17 |
| [번역] 싱글 Pass 체적 수체 Shader(계속 업데이트 중) (0) | 2026.06.16 |
| [번역] FastGeo Documentation 5.8 한국어 버전 (0) | 2026.06.14 |
| [번역] Unity로 Nanite 구현하기 (0) | 2026.06.11 |