코딩 어드벤처: 레이 트레이싱 심화!
요약
본 기사는 레이 트레이싱의 비효율성을 분석하고 개선하는 과정을 다룹니다. 특히, 광선 추적 과정에서 발생하는 삼각형 테스트 횟수를 시각화하여 성능 저하의 원인을 명확히 보여줍니다. 바운스(bounce) 증가나 픽셀당 광선 수 증가가 테스트 부하를 급격히 늘리는 것을 실험적으로 입증합니다.
핵심 포인트
- 레이 트레이싱은 비효율적이며, 삼각형 테스트 횟수가 성능에 큰 영향을 미칩니다.
- 바운스 단계(bounces)나 픽셀당 광선 수(rays per pixel)가 증가하면 부하가 기하급수적으로 늘어납니다.
- 성능 개선을 위해 복잡한 모델 대신 디시메이트 모디파이어 등으로 삼각형 수를 줄이는 것이 중요합니다.
안녕하세요 여러분, 또 다른 코딩 어드벤처 에피소드에 오신 것을 환영합니다. 오늘은 좀 더 레이 트레이싱을 해보고 싶습니다. 약 1년 전 우리가 어디까지 했는지 말씀드리자면, 이 장면이나 이런 장면 같은 작은 장면들을 렌더링할 수 있었습니다. 그리고 이것들은 상당히 멋져 보입니다—그림자나 반사광, 스페큘러 하이라이트(specular highlights), 심지어 피사계 심도(depth of field)까지 많은 기본 요소들이 구현되었습니다. 하지만 레이 트레이서가 현재 엄청나게 비효율적이기 때문에 매우 단순한 장면에서만 이것들을 가지고 놀 수 있습니다.
그래서 제가 시작하고 싶은 것은 단순히 이 비효율성을 시각화하는 것입니다. 그래야 우리가 개선을 시도하는 과정을 추적할 수 있기 때문입니다. 이제 우리 레이 트레이서의 핵심에는 RayCollision 함수가 있는데, 이 함수는 장면 속 모든 모델들을 순회하며, 레이가 전체 바운딩 박스(bounding box)를 벗어나는 모델은 건너뛰지만, 그렇지 않은 경우에는 모델 메시(mesh)의 모든 삼각형을 순회하면서 레이가 그 삼각형에 부딪히는지 아닌지를 확인합니다. 그리고 셰이더(shader)는 대부분의 시간을 이 작업을 하는 데 사용하므로, 당연히 우리가 해야 할 테스트 횟수가 적을수록 더 빠르게 실행될 것입니다.
이를 모니터링하기 위해, 저는 여기에 카운터를 추가했습니다. 이 코드는 레이가 장면 주변에서 실제로 반사되는 것을 처리하는 Trace 함수에 의해 호출되며, 저는 이 카운터 값이 모든 바운스(bounce)를 거쳐 추적되도록 설정했고, 마지막에는 현재 픽셀의 색상으로 그 개수를 출력하도록 했습니다. 따라서 테스트가 0번이면 검은색으로 표시되고, 제가 지금 설정한 대로 2000번이면 흰색으로 표시됩니다.
만약 그 값이 디스플레이 임계값을 초과하면, 저는 그것이 명확한 지표가 되도록 빨간색만 출력하도록 요청했습니다. 좋습니다... 그럼 이 작은 장면을 불러와 보겠습니다. 중앙에 있는 저 폴리곤의 기사 모델은 456개의 삼각형을 가지고 있고, 벽 등에도 몇 개 더 있습니다. 우리의 새로운 디버그 뷰를 살펴봅시다. 현재 저는 광선(ray)을 0번 바운스(bounce)로 제한했습니다. 즉, 직접적인 충돌만 고려하고 있으므로 기사의 경계 상자(bounding box)가 보이는 것이 전부이며, 이것이 초당 약 370프레임으로 실행되고 있음을 알 수 있습니다. 이제 바운스 한계를 3번으로 늘려봅시다. 성능에 상당히 큰 영향을 미쳤다는 것을 볼 수 있으며, 초당 50프레임까지 떨어졌습니다. 이제 많은 픽셀이 광선이 장면을 따라 반사되면서 천 개가 넘는 삼각형을 테스트해야 하는 것 같습니다.
참고로 이것은 픽셀당 하나의 광선만 사용했을 때의 결과이며, 이는 매우 노이즈가 심한 결과를 보여줍니다. 그러니 픽셀당 두 개의 광선을 사용해 봅시다. 이 경우 당연히 우리가 수행하는 삼각형 테스트 횟수가 대략 두 배가 됩니다. 그리고 두 개의 광선으로는 깨끗한 이미지를 얻기에는 아쉽게도 턱없이 부족합니다. 이 매우 단순한 장면에서도 최소 수백 개는 필요합니다. 따라서 오늘 저의 목표는 이 작은 기사 모델로 고군분투하는 것에서, 적어도 871개의 삼각형을 포함하고 있는 장엄한 용의 3D 스캔이라는 이 클래식 테스트 모델을 렌더링할 수 있도록 하는 것입니다.
만약 여러분이 숫자를 안다면, 이 값은 456보다 훨씬 클 겁니다. 그 정도까지 올라가려면 준비해야 할 것 같으니, Blender의 유용한 디시메이트(decimate) 모디파이어를 사용해서 이걸 약 8000개의 삼각형으로 줄여보겠습니다. 이게 더 다루기 쉬운 시작점이 될 거예요! 자, 이제 기사 대신 용으로 바꿔서 어떻게 되는지 봅시다! 바운스(bounces)를 3개로 하면, 약 35000개의 삼각형을 테스트하는 많은 픽셀들이 돌아다니는 것 같고, 제 컴퓨터가 정말 힘들어하네요.
그다음엔 이 값을 픽셀당 레이(rays per pixel)로 높여보겠습니다... 아, 잠깐만요. 뭔가 폭발하기 전에 이걸 꺼야겠어요. 좋습니다. 자, 이걸 속도 높이려면 이런 방식의 기술을 시도해 보고 싶습니다... 이미 하고 있는 것처럼, 먼저 오브젝트 주변에 바운딩 박스(bounding box)를 설정하고, 이 레이가 그 박스와 충돌하는지 테스트해서 모든 삼각형을 테스트할 필요가 있는지 확인합니다. 하지만 이제 우리가 테스트해야 할 삼각형 수를 줄이려면, 만약 박스에 충돌한다면 메쉬(mesh)가 실제로 반으로 분할되어 있으면 정말 좋을 겁니다.
그리고 이 절반 각각에 자신만의 바운딩 박스를 가지고 있어서, 그걸 다시 우리의 레이와 비교 테스트할 수 있다면요. 레이는 그중 하나에만 충돌할 가능성이 높기 때문에, 나머지 전체 메쉬는 아예 무시해도 됩니다. 그리고 같은 작업을 계속하는 겁니다. 그래서 이걸 또 다른 두 개의 절반으로 나눌 수도 있고, 이렇게 할 수도 있고, 그리고 그 바운딩 박스들을 테스트합니다... 그리고 남은 모든 삼각형을 확인할 시간이 될 때까지 이 과정을 반복하는 거죠.
기본적으로 이 아이디어는 바운딩 박스(bounding boxes) 안에 있는 바운딩 박스로 귀결됩니다. 또는 더 형식적으로 말하자면, 이는 계층적인 바운딩 볼륨(bounding volumes)입니다. 줄여서 BVH라고 합니다. 자, 그럼 구현을 시도해 봅시다! 제가 지금까지 가진 것은 이 작은 BoundingBox 클래스뿐인데, 이 클래스는 한 점(point)을 입력받으면 그 점을 포함하도록 단순히 성장합니다. 메쉬(mesh)의 경계(bounds)를 계산하는 것은 모든 정점(vertices)을 반복하면서 바운딩 박스에 넣어주면 됩니다. 그리고 재미 삼아, 실제로 어떻게 작동하는지 보여드리겠습니다.
좋습니다. 이제 이 모델을 재귀적으로 반으로 분할하고 싶고, 우리의 광선(ray)이 바운딩 박스 중 하나와 충돌했을 때, 그것이 분할된 두 개의 자식 박스에 접근할 수 있도록 무언가를 추적해야 합니다. 이를 위해 저는 BoundingBox뿐만 아니라 그 안에 포함된 모든 삼각형(triangles), 그리고 이 두 잠재적인 자식 노드에 대한 참조까지 담고 있는 Node 클래스를 만들었습니다. 그런 다음 BVH를 구축하기 시작하려면, 전체 메쉬의 바운딩 박스를 먼저 계산하고, 삼각형들을 설정한 다음, 마지막으로 루트 노드를 생성하여 이 Split 함수에 전달하는 코드를 작성했습니다.
이 안에서는 노드에 두 개의 자식이 할당되고, 그 후 모든 삼각형을 반복하면서 해당 삼각형의 중심(centre)이 바운딩 박스의 왼쪽 절반 또는 오른쪽 절반에 있는지 여부를 기반으로 그것들이 childA 또는 childB 중 어느 부분에 속해야 할지 결정합니다. 삼각형들을 추가함에 따라 우리는 또한 자식들의 바운딩 박스를 성장시키고, 이것이 완료되면 단순히 자식들을 재귀적으로 반으로 분할합니다. 이게 영원히 계속되지 않도록 하기 위해, 저는 일단 최대 깊이에 도달하면 멈추도록 설정했습니다. 현재는 그 값을 10으로 설정해 두었습니다.
이것들이 모두 예상대로 작동하는지 확인하기 위해 간단한 디버그 코드가 있습니다. 이 코드는 단순히 루트 노드를 받아 그 경계 상자(bounding box)를 그리고, 원하는 깊이까지 자식들의 경계 상자도 재귀적으로 그립니다. 그럼 한번 보시죠! 현재는 깊이가 0이니, 여기서 시각화 단계를 하나 증가시켜 보겠습니다. 그러면 예쁘게 반으로 나뉜 것이 보이네요... 그럼 한 단계 더 깊이 가보겠습니다.
또 한 번요. 그리고 또 한 번! 괜찮은 것 같습니다만, 현재는 항상 마치 식빵처럼 수직 슬라이스로 잘리고 있어서 적절한 방법 같지는 않습니다. 경계 상자를 항상 가장 긴 축을 따라 분할하는 것이 더 합리적일 것 같아요. 그래야 이렇게 너무 얇은 조각들이 생기는 것을 막을 수 있습니다. 예를 들어, 여기서는 경계가 세로로 가장 길기 때문에 직관적으로는 이렇게 반으로 나누어야 한다고 생각합니다.
이를 위해 함수를 약간 수정하여 가장 긴 축의 인덱스(0, 1, 또는 2)를 계산하도록 했습니다. 이는 각각 x, y, z에 해당하며, 이 값을 사용하여 해당 축을 따라 경계 상자의 중심을 찾고 삼각형들을 분할하는 데 사용할 수 있습니다. 좋습니다, 다시 한번 시도해 보겠습니다. 훨씬 합리적으로 보이네요. 이제 기본 버전이 갖춰졌으니, 이것을 어떻게 사용해서 우리의 삼각형 테스트를 가속화할지 알아내야 할 것 같습니다.
그래서 제가 작업해 온 작은 함수를 보여드릴게요. 이건 아직 셰이더 언어에 있는 건 아니에요. CPU에서 테스트하고 디버깅하는 게 더 쉬워서요. 나중에 이걸 변환해야 할 거예요. 작동 방식은 먼저 광선(ray)이 현재 노드의 경계 상자(bounding box)와 충돌했는지 확인합니다. 이 노드는 물론 루트 노드부터 시작하죠. 그리고 만약 그렇다면, 그 노드가 리프 노드(leaf node)인지 확인해요. 이건 단순히 노드에 자식이 없다는 뜻이에요. 이런 경우, 더 깊이 갈 수 없기 때문에, 우리는 그냥 모든 삼각형을 테스트하기만 하면 돼요.
하지만 리프 노드가 아니라면, 우리는 재귀적으로 그 자식들을 테스트합니다. 좋습니다. 이제 이 함수를 사용해서 드래곤 메쉬에 광선을 쏴보고 어떻게 되는지 봅시다. 현재는 루트 노드의 경계 상자만 테스트하고 있어서, 그걸 놓치고 다음 단계로 진행하지 못해요. 하지만 광선이 충돌하도록 조준하면, 두 자식들을 테스트하는 것을 볼 수 있지만, 그것마저도 놓치고 다시 멈춰요. 그다음엔 드래곤의 꼬리 부분을 맞춰서 광선을 쏴보면, 우리가 원하는 삼각형을 포함하는 노드로 빠르게 범위를 좁혀나가는 것을 볼 수 있어요.
좋아요, 이거를 좀 더 테스트해 보고 싶은데, 작동하는 것 같아요. 적어도 제가 알기로는 정확한 삼각형이 초록색으로 표시되고 있고, 빨간색의 삼각형들에서 볼 수 있듯이 전체 메쉬 중 아주 작은 부분만 테스트하고 있네요. 물론 이걸 개선할 수 있는 것이 부족하지 않다는 건 확실하지만, 이 BVH 데이터를 GPU로 보내서 실제 레이 트레이싱 셰이더에 사용할 방법을 알아내는 게 너무 기대돼요.
지금 모든 데이터는 이 노드들(nodes)에 저장되어 있지만, 여기에는 몇 가지 문제가 있습니다. 첫째로, 각 노드는 자식 노드(child nodes)에 대한 참조를 포함합니다. 따라서 내부적으로 C#은 우리가 그것들이 메모리 어디에 저장되었는지 추적하고, 우리가 생각할 필요 없이 그냥 접근할 수 있게 해줍니다. 하지만 우리의 셰이더 언어(shader language)는 이런 종류의 작업을 해주지 않기 때문에, 대신 우리가 생성하는 모든 노드들의 큰 목록을 유지해야 하고, 이 참조들을 자식들이 저장된 그 목록에서의 인덱스(index)로 대체해야 합니다.
자, 그래서 저는 우리의 Split 함수를 이 변경 사항에 맞게 빠르게 수정했습니다. 기본적으로 이 함수는 두 개의 새로운 자식 노드를 모든 노드들의 이 목록의 끝에 추가하고, 첫 번째 노드의 인덱스를 부모 노드에 저장합니다. 당연히 그들은 바로 옆에 순서대로 저장되어 있기 때문에, 우리는 그 인덱스에 1을 더하는 것만으로 두 번째 자식을 찾을 수 있습니다. 이제 우리는 이러한 변경 사항들을 처리하기 위해 우리의 BVH 탐색(traversal) 코드도 수정해야 합니다. 따라서 자식들이 null인 개념은 더 이상 의미가 없으며, 대신 이 노드의 자식 인덱스(child index)가 설정되지 않았다면—즉, 여전히 0이라면—이것은 리프 노드(leaf node)라고 말할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 YouTube Sebastian Lague (절차적 생성)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기