본문으로 건너뛰기

© 2026 Molayo

YouTube요약2026. 06. 10. 13:03

코딩 어드벤처: 레이 트레이싱 (Ray-Tracing) 유리 구현

요약

Sebastian Lague의 레이 트레이싱 프로젝트 에피소드로, 스넬의 법칙을 활용하여 유리의 굴절과 반사를 구현하는 과정을 다룹니다. 재귀를 지원하지 않는 셰이더 환경에서 스택을 사용하여 광선의 분기를 처리하는 기술적 해결책을 제시합니다.

핵심 포인트

  • 스넬의 법칙을 이용한 빛의 굴절 및 반사 구현
  • 매질에 따른 빛의 속도 차이와 굴절률 적용
  • 재귀 미지원 셰이더를 위한 스택 기반 광선 처리 방식
  • 유리 렌더링을 통한 커스틱(Caustics) 효과 구현

비디오: 코딩 어드벤처: 레이 트레이싱 (Ray-Tracing) 유리 구현
채널: Sebastian Lague
재생 시간: 41분 17초
출처: 자막 (수동, en-GB)

안녕하세요 여러분, 코딩 어드벤처의 새로운 에피소드에 오신 것을 환영합니다. 오늘은 유리와 관련된 기능들을 추가하고, 멋진 커스틱 (caustics)을 렌더링하는 데 조금 더 집중하기 위해 레이 트레이싱 (ray tracing) 프로젝트로 다시 돌아와 보려고 합니다. 프로젝트를 열어보면, 간단한 장면이 설정되어 있는데, 렌더러를 빠르게 실행해서 어떻게 보이는지 확인해 보겠습니다… 분명히, 이 방 어디에도 유리가 눈에 띄게 부족하네요. 그러니 어서 작업을 시작해야겠습니다!

하지만 솔직히 말씀드리면, 우리에게 필요한 많은 것들을 유체 렌더링 (fluid rendering)에 관한 이전 에피소드에서 그대로 가져올 예정입니다. 만약 그 영상을 보셨다면, 우리가 시도했던 레이마칭 (raymarching) 접근 방식을 기억하실 수도 있을 텐데, 거기서 우리는 스넬의 법칙 (Snell’s law)을 고려했습니다. 이 법칙은 빛이 물속으로 뛰어들 때, 그리고 반대편으로 나올 때 방향이 어떻게 변하는지를 설명합니다. 우리는 이것이 서로 다른 매질에서 빛이 이동할 수 있는 속도에 따라 어떻게 달라지는지에 대한 수학적 원리를 조금 살펴보았습니다. 예를 들어 물속에서는 공기에 비해 빛의 속도가 약 25% 정도 느려지도록 강제됩니다. 그리고 유리는 — 믿기 어려우시겠지만 — 빛이 통과하기 훨씬 더 어려워서, 무려 33%나 빛을 느리게 만듭니다. 유리 종류에 따라 조금씩 다르긴 하지만요.

어쨌든, 여기에는 CalculateReflectionAndRefraction 함수가 있습니다. 이 함수를 통해 입사광의 방향과 빛이 충돌하려는 표면이 향하고 있는 방향을 지정할 수 있습니다. 그리고 마지막으로, 진공 상태에서의 빛의 속도에 대한 비율로서 표면 양쪽에서의 빛의 속도를 설명하는 두 개의 굴절률 (refractive indices)이 있습니다.

이것은 빛이 갈 수 있는 두 가지 방향, 즉 표면에서 반사 (reflecting)되거나 표면을 통해 굴절 (refracting)되는 방향을 담고 있는 작은 LightResponse 구조체를 우리에게 돌려줍니다.

혹은 두 가지 모두일 수도 있습니다. 빛의 일부는 한 방향으로 가고, 나머지는 다른 방향으로 가게 할 수 있습니다. 이러한 빛의 분기 (bifurcation)는 특히 실시간 렌더링 (real-time rendering)에서는 다소 번거로운 문제입니다. 우리가 이 상호작용형 유체 렌더링 (interactive fluid rendering)을 위해, 대부분의 빛이 이동하는 경로만을 따라가는 것과 같이 상당한 양보를 해야 했던 것처럼 말이죠.

하지만 다행히도, 우리의 레이 트레이서 (ray-tracer)는 이미 실시간과는 거리가 멀기 때문에 — 여러분이 상상력으로 많은 빈칸을 채우는 것에 만족하지 않는 한 — 저는 이를 더 느리게 만드는 것에 대해 딱히 거리낌이 없습니다.

그럼 이제 그 코드로 들어가 봅시다. 여기에는 카메라에서 발사되는 각 광선 (ray)의 위치와 방향 같은 것들을 저장하기 위한 Ray 구조체가 있습니다. 그리고 이제 서로 다른 방향으로 갈라지는 이 광선들을 처리하기 위해, 저는 보통 재귀적 (recursive) 접근 방식을 사용하겠지만, 안타깝게도 여기서 사용하는 셰이더 (shader) 언어는 이를 지원하지 않습니다.

하지만 우리는 이 프로젝트를 작업할 때, 충돌을 감지하는 효율적인 방법으로서 경계 상자 (bounding boxes) 계층 구조를 재귀적으로 탐색하고자 했을 때 정확히 이와 똑같은 문제에 직면한 적이 있습니다.

따라서 우리는 그 해결책을 그대로 재사용할 수 있는데, 그것은 바로 우리가 여전히 탐색하고자 하는 모든 것들을 스택 (stack)에 단순히 저장하는 것이었습니다. 지난번에는 경계 상자였고, 이번에는 광선입니다. 물론 카메라에서 나오는 초기 광선부터 시작해서 말이죠.

그다음 우리는 스택이 비워질 때까지 스택의 상단에서 광선 (ray)을 계속 꺼내는 루프를 실행합니다. 그리고 꺼낸 각 광선에 대해 메인 경로 추적 (path tracing) 루프를 실행하는데, 이 루프는 기본적으로 해당 경로를 따라 얼마나 많은 빛이 카메라에 도달할 수 있는지 알아내기 위해 광선을 세계 곳곳으로 튕겨 보냅니다. 만약 광선이 유리로 만들어진 물체에 부딪힌다면 우리는 이 동작을 변경하고 싶을 것입니다. 물체의 재질 (material) 속성을 확인하여 이를 테스트할 수 있는데, 현재는 어떤 벽에 이 단순한 체커보드 패턴을 적용할지 지정하는 데에만 사용되고 있지만, 서로 다른 유형의 재질을 정의하기 위한 플래그 (flag)가 있으므로 이제 2라는 값을 유리 같은 재질로 지정하기로 정하면 됩니다. 만약 우리가 부딪힌 것이 유리라면, 광선이 외부에서 부딪히고 있는지 아니면 내부에서 부딪히고 있는지를 알아야 하며, 이는 삼각형의 방향 (orientation)을 통해 알 수 있습니다. 그러면 우리는 해당 표면의 양쪽 면에 대한 적절한 굴절률 (index of refraction)을 얻을 수 있습니다. 적어도 지금은 물체 외부가 빈 공기라고 단순히 가정하고 말이죠. 그리고 이를 통해 반사 (reflection) 및 굴절 (refraction) 방향을 계산하고, 빛의 대부분이 어느 경로를 택할지 파악한 뒤, 현재의 광선이 그 경로를 따라가도록 재지정할 수 있습니다. 하지만 이번에는 덜 선택되는 경로를 따라 흐르는 빛의 양도 고려할 것입니다. 빛이 아주 조금이라도 있고 스택에 이를 저장할 공간이 남아 있는 한, 그 방향을 가리키는 새로운 광선을 생성하여 나중에 확인하기 위해 스택에 푸시 (push)할 것입니다.

이것으로 거의 다 끝났습니다 — 제가 거의 잊을 뻔한 것이 있는데, 이 후면 (back-face) 값이 아직 실제로 존재하지 않는다는 점입니다. 그래서 구조체 (structure)에 이 값을 추가하겠습니다. 그런 다음 삼각형 교차 (triangle intersections)를 감지하는 부분에서 내적 (dot product)을 사용하여, 광선이 진행하는 방향과 삼각형이 마주하는 방향 사이의 각도가 90도 미만인지 확인하면 됩니다. 만약 그렇다면 광선이 뒷면에서 충돌하고 있다는 것을 의미합니다.

또한 이러한 후면 (backfaces)을 실제로 감지할지 여부를 결정하는 파라미터 (parameter)를 추가해야 합니다. 만약 감지하기를 원한다면, 교차 (intersection)가 항상 유효한 것으로 간주되도록 여기에서 절댓값 (absolute value)을 사용하면 됩니다 — 단, 광선이 삼각형과 거의 평행에 가까울 경우는 제외합니다. 이 경우 제한된 부동 소수점 정밀도 (floating point precision) 때문에 결과를 신뢰할 수 없기 때문입니다.

처음에 이것을 실행했을 때 흥미롭고 글리치 (glitchy)가 있는 패턴들이 나타났습니다 — 광선이 제가 약간 잊고 있었던 투명한 벽의 후면 (backface)에 실제로 충돌하고 있었습니다.
패턴이 왼쪽에서 오른쪽으로 갈수록 약간씩 달라지고, 상단 절반에서는 전혀 나타나지 않는다는 점은 참 신비롭습니다. 가끔은 GPU가 그 안에서 도대체 무엇을 하고 있는지 정말 궁금해질 정도입니다...
[Singing trololo]
제가 하려는 작업은 유리 재질의 객체 (objects)에 대해서만 후면 (backface) 충돌을 고려하도록 설정하여, 광선이 다시 우리의 단면 벽 (one-sided wall)을 그대로 통과해 미끄러지듯 지나가게 만드는 것입니다.

그럼 여기 있는 세 개의 객체를 유리로 표시하고, 굴절률 (refractive index)을 1.5 정도로 설정한 뒤 어떻게 되는지 살펴봅시다!
오, 제 컴퓨터가 죽었네요.

음, 짧은 강령술 (necromancy) 과정을 거치고,
이 비교 코드를 원래 있어야 할 괄호 안에 쏙 집어넣은 뒤에 — 제가 이걸 알아차리는 데 얼마나 오래 걸렸는지는 굳이 언급하고 싶지 않네요 — 이제 다시 실행해 볼 수 있습니다.
아아아아, 그리고 그렇게 해서 우리는 흑요석 (obsidian)을 만드는 데 성공했습니다! 그것도 유리의 일종이죠, 그렇죠?
좋아요, 완벽합니다. 그럼 이제 다 끝난 것 같네요!
자, 음, 적어도 제가 깨달은 한 가지는 여기서 계산하고 있는 법선 벡터 (normal vector)가 만약 광선 (ray)이 부딪히는 뒷면 (backface)이라면 실제로 뒤집혀야 한다는 것입니다. 그래야 우리의 반사 (reflection) 및 굴절 (refraction) 함수가 기대하는 것처럼 충돌 방향으로부터 멀어지는 방향을 가리키게 되니까요.
아아아아, 그리고 그것을 수정했으니, 마침내 유리를 투과해서 볼 수 있게 되었습니다. 뭐, 어느 정도는요. 적어도 진전은 있습니다.
하지만 이전의 뒷벽에서 보았던 그 링 패턴들이 여전히 보입니다. 제 생각에 이 문제의 해결책은, 표면에서 반사되는지 혹은 굴절되는지에 따라 법선 (normal)을 따라 앞이나 뒤로 아주 미세한 양만큼 광선의 충돌 위치를 오프셋 (offset) 시키는 것입니다. 수치적인 혼란을 피하기 위해 광선이 명확하게 한쪽 면 혹은 다른 쪽 면에 있도록 말이죠.
좋아요, 그 수정 사항을 적용해서 렌더러 (renderer)를 다시 실행해 봅시다. 제 생각에는 이제 훨씬 더 유리처럼 보입니다.
하아아지만 여전히 뭔가 딱 맞는 느낌은 아니네요...
공정하게 말하자면, 유리는 상당히 혼란스러운 대상이고, 제가 결과물을 봤을 때 무엇이 제대로 작동하고 있는지 완전히 확신할 수 있을지도 모르겠습니다. 이제 슬슬 약간의 기교를 부려야 할 때가 된 것 같네요...
아름답지 않나요? 또한 이것은 왼쪽의 빨간 벽이 유리의 오른쪽에 어떻게 보여야 하는지, 그리고 초록색의 경우 그 반대로 어떻게 보여야 하는지를 매우 명확하게 보여줍니다. 이전의 렌더링에서는 그 방향이 반대로 되어 있었죠.

다행히도 문제를 찾는 데 오래 걸리지는 않았습니다. 코드에서 뒷면 (back-face) 조건을 앞뒤가 바뀌게 작성했더군요. 그래서 컴퓨터는 공기가 유리로 되어 있고, 유리는 공기로 되어 있다고 생각했던 것입니다.

그 혼란을 해결했으니, 이제 오른쪽에는 빨간색이, 왼쪽에는 초록색이 보여야 하며, 실제로 그렇게 보입니다. 하지만 참조 이미지 (reference)를 다시 살펴보니 또 다른 불일치가 있습니다. 큐브 (cube)의 바닥을 따라 벽면이 매우 선명하게 보이는데, 우리의 렌더링 (render)에서는 그렇지 않기 때문입니다.

하지만 물체들을 아주 약간만 공중에 띄움으로써 이 문제를 해결할 수 있을 것 같습니다. 본질적으로는 빛이 바닥에 직접 충돌하지 않고 실제로 유리의 바닥면에 먼저 닿을 수 있도록 약간의 공기 간극 (air-gap)을 만드는 것입니다.

좋습니다, 효과가 있는지 확인해 보죠! 음, 유망해 보입니다. 상자의 바닥 부분에 뒤쪽의 파란색 벽이 반사되는 것을 확실히 볼 수 있고, 참조 이미지에서도 보였던 것처럼 바닥 주변으로 빛이 약간 새어 나오는 것도 볼 수 있습니다.

빛이 어떻게 투과되는지 더 잘 파악할 수 있도록, 이 물체들을 공중으로 더 높이 들어 올려 보겠습니다.

좋습니다, 이제 우리가 원하는 대로 작동하는 것 같습니다. 하지만 장면을 참조 이미지와 실제로 일치시켜서 조금 더 잘 테스트해 볼 수 있을 것 같습니다. 약 45도 각도로 놓인 단 하나의 유리 큐브 (glass cube)를 배치하고, 실제와 맞추기 위해 모서리에 베벨 (bevel)도 약간 주겠습니다.

이제 참조 이미지를 확인하고, 우리의 레이 트레이서 (ray-tracer) 결과와 직접 비교해 보겠습니다.

꽤 좋아 보이는 것 같네요!

회전하는 모습을 살펴봅시다. 얇은 가장자리를 따라 약간의 깜빡임 (flickering)이 관찰되는데, 언젠가 이 부분을 조사해 봐야 할 것 같습니다. 하지만 저는 결과에 상당히 만족합니다.

여기 있는 거대한 드래곤 버전에 다양한 굴절률 (refraction indices)을 테스트해 보고 싶습니다. 우선 1부터 시작해 보죠. 광선 (rays)이 아무런 편차 없이 그대로 통과하여, 드래곤이 완전히 투명하게 렌더링됩니다.

음, 갑자기 여기저기서 나타나는 수상한 점들 (speckles)만 제외한다면 말이죠. 제 추측으로는 이것들이 바로 사악한 NaN (NaNs)인 것 같습니다. 기본적으로 수학 계산을 잘못했을 때 발생하는 '숫자가 아님 (non numbers)'을 의미하죠.

우리가 반환하는 빛 값 중 하나라도 NaN인지 확인하고, 만약 그렇다면 대신 아주 밝은 초록색을 반환하도록 함으로써 이를 빠르게 검증할 수 있습니다. 그렇게 하면 단 한 프레임이라도 NaN이 나타날 경우 누적된 렌더링 (accumulated render) 결과에 명확하게 드러날 것입니다. 따라서 곧 그것들이 나타나는 것을 볼 수 있을 것입니다.

이제 곧 나타날 겁니다…. 아니, 됐습니다. 다른 문제인 것 같네요.

잠시만요, 예전에 컴파일러가 NaN이 존재하지 않는 것처럼 가장하는 것을 읽은 기억이 납니다. 그래야 GPU가 이를 확인하고 적절히 처리하는 데 시간을 소비하지 않아도 되니까요. 그 결과, 우리가 사용해 온 NaN 테스트는 항상 '거짓 (false)'인 것으로 최적화되어 사라져 버립니다. 그래서 방금 NaN이 실제로 어떻게 표현되는지 빠르게 찾아보았습니다. 기본적으로 이 8개의 지수 비트 (exponent bits)가 모두 1로 설정되어야 하며, 오른쪽에 남은 23개는 그 외의 어떤 것이든 상관없을 수 있...

AI 자동 생성 콘텐츠

본 콘텐츠는 YouTube Sebastian Lague (절차적 생성)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0