티스토리 뷰

이번 시간에는 RayTracing Shader를 사용해서 RayTracing을 구현하는 예시를 보도록 하겠습니다.

먼저 결과 화면은 아래와 같습니다.

개요

Ray Tracing에는, Forward Ray TracingBackward Ray Tracing이 있습니다.

Forward Ray Tracing실제 빛이 광원에서 출발해 물체를 거쳐 눈에 들어오는 과정을 그대로 따라가는 것이고, Backward Ray Tracing카메라(혹은 눈)에서부터 반대로 광선을 추적하는 방식입니다.

빛이 광원에서 출발해 퍼지는 경로는 이론상 무한이므로, 컴퓨터 그래픽스에서는 보통 Forward Ray Tracing 대신 Backward Ray Tracing을 사용합니다.
Backward Ray Tracing은 최종적으로 화면에서 보게 되는 픽셀을 기준으로, 그 픽셀에 도달했을 법한 광선의 경로를 거꾸로 따라가면서 색을 계산하는 방식이라고 볼 수 있습니다.

이번 예제에서도 Backward Ray Tracing을 사용했습니다.

 

여기서 중요한 점은, Ray Tracing은 빛의 이동 경로를 어떻게 추적할 것인가에 대한 기법이라는 것입니다.
즉, 이것은 광선이 어떤 경로로 진행되고, 어디에 부딪히고, 반사되거나 굴절되는지를 시뮬레이션하는 방법이지, 표면을 어떤 셰이딩 모델로 표현할지를 결정하는 개념은 아닙니다.
그래서 Ray Tracing을 사용한다고 해서 반드시 특정 셰이딩 방식을 써야 하는 것은 아니며, Phong Shading을 사용할 수도 있고 PBR Shading을 사용할 수도 있습니다.

 

Ray Tracing의 큰 장점은 반사(Reflection)굴절(Refraction) 같은 효과를 매우 자연스럽고 정확하게 표현할 수 있다는 점입니다.
빛이 실제 공간 안에서 어떻게 튕기고 통과하는지를 기반으로 계산하기 때문에, 기존의 근사 방식보다 훨씬 설득력 있는 결과를 얻을 수 있습니다.
다만 그만큼 계산 비용이 크다는 단점도 있습니다.

 

또한 Ray Tracing 자체는 꼭 전용 하드웨어에서만 가능한 것은 아닙니다.
원리적으로는 일반적인 Pixel Shader만으로도 구현할 수 있습니다.
하지만 이 경우 광선과 기하 교차 판정이나 가속 구조 탐색 같은 연산을 모두 범용 셰이더로 처리해야 해서 비효율적일 수 있습니다.
그래서 이러한 연산을 더 효율적으로 처리하고 하드웨어 가속의 이점을 얻기 위해, 현대 GPU에서는 별도의 Ray Tracing Shader와 전용 파이프라인이 도입되었습니다.

 

예제에서는 DirectX12Ray Tracing Pipeline을 이용해서 Ray Tracing을 구현했습니다.


DXR(DirectX RayTracing) 파이프 라인 전체 구조

1) 먼저 CPU에서 Ray Tracing Pipeline을 위한 요소들을 셋팅합니다.

Ray Tracing State Object, Top Level Acceleration Structure, Bottom Level Acceleration Structure, Shader Table, Root Signature 등 Pipeline에서 사용되는 데이터들을 준비해둡니다.

 

2) 모든 요소들이 셋팅된 이후에 DispatchRays 함수를 호출합니다.

이 호출은 일반적인 그래픽스 파이프라인에서 Draw를 호출하는 것과 비슷하게, 레이 트레이싱 작업을 시작하라는 명령이라고 보면 됩니다.

 

3) DispatchRays 함수가 호출되면 GPU에서는 Ray Generation Shader가 호출됩니다.
이 셰이더는 말 그대로 각 레이를 어떻게 만들 것인지 정의하는 셰이더입니다.
예를 들어 현재 픽셀 위치를 기준으로 카메라 원점과 방향을 계산하고, 그 방향으로 광선을 하나 발사할 수 있습니다.

 

4) 레이가 발사되면, GPU 하드웨어는 내부적으로 Acceleration Structure Traversal을 수행하고 결과에 따라 Hit ShaderMiss Shader가 호출 됩니다.
즉, 레이가 장면 안에서 어떤 오브젝트와 충돌할 가능성이 있는지 가속 구조를 따라 내려가며 탐색합니다.
이 과정은 DXR의 핵심 중 하나로, 모든 삼각형을 전부 검사하지 않고, 가능성이 있는 후보만 빠르게 좁혀서 교차 판정을 진행합니다.

그 결과 레이가 어떤 기하와 충돌하면 Hit Shader 계열이 실행되고, 아무것도 맞지 않으면 Miss Shader가 실행됩니다.

 

5) 최종적으로 계산된 결과는 Render Target이나 UAV(Unordered Access View) 에 기록하면 됩니다.
즉, DXR 역시 결과적으로는 “각 픽셀에 어떤 색을 쓸지 계산해서 버퍼에 저장한다”는 점에서는 일반 렌더링과 같습니다.
차이는 그 색을 계산하는 방법이 Rasterization 기반이 아니라, 광선을 추적하는 방식이라는 점입니다.


Ray Tracing Shader 3종 (Ray Generation Shader, Closest Hit Shader, Miss Shader)

Ray Tracing Shader 관련해서 자주 쓰이는 Shader 3종이 있습니다.

 

1) RayGeneration Shader

Ray Tracing의 시작점입니다.
각 픽셀 또는 각 작업 단위에 대해 어떤 레이를 어디서 어떤 방향으로 쏠지를 결정합니다.

보통 카메라 위치를 원점으로 하고, 화면상의 픽셀 좌표를 역변환해서 방향 벡터를 계산한 뒤, TraceRay를 호출해서 충돌점을 찾습니다.

 

2) Closest Hit Shader

레이가 어떤 기하와 충돌했을 때, 그중 가장 가까운 교차 지점에 대해 호출되는 셰이더입니다.

보통 이 셰이더에서 표면의 재질과 조명을 바탕으로 shading을 수행하고, 필요하면 반사/굴절 ray를 추가로 생성합니다.

 

참고로 hit 관련 셰이더에는 교차 후보를 걸러내는 Any Hit Shader, 절차적 기하의 교차를 직접 계산하는 Intersection Shader 가 있으며, 이들은 함께 Hit Group 으로 묶여 사용됩니다.
본 예제에서는 가장 단순한 Closest Hit Shader 만 사용합니다.

 

3) Miss Shader

레이가 아무 오브젝트에도 맞지 않았을 때 호출됩니다.

여기서는 보통 배경색을 반환하거나, 환경 맵을 샘플링하는 작업을 하게 됩니다.


BLAS(Bottom Level Acceleration Structure) & TLAS(Top Level Acceleration Structure)

Ray Tracing에서 가장 중요한 연산 중 하나는 Ray와 삼각형의 교차 판정입니다.
하지만 모든 레이에 대해 모든 삼각형과 교차 테스트를 하는 것은 비효율적입니다.

그래서 Ray Tracing에서는 미리 기하 구조를 감싸는 Bounding Volume들을 계층적으로 구성해 두고, 레이가 닿을 가능성이 없는 영역은 빠르게 제외합니다.
이 역할을 하는 것이 바로 Acceleration Structure입니다.

 

BLAS(Bottom Level Acceleration Structure) 는 개별 기하 정보에 대한 가속 구조입니다.

예를 들어 하나의 Cube Mesh가 있다면, 그 Cube를 구성하는 삼각형들에 대해 BLAS를 만들 수 있습니다.

 

TLAS(Top Level Acceleration Structure) 는 씬 전체를 기준으로 구성되는 상위 가속 구조입니다.

TLAS 안에는 실제 삼각형 데이터가 직접 들어가는 것이 아니라, BLAS를 참조하는 인스턴스들이 들어갑니다.

각 인스턴스는 보통 다음과 같은 정보를 가집니다.

  • 어떤 BLAS를 참조하는지
  • 자신의 Transform
  • Instance ID

즉, TLAS는 “씬 안에 어떤 오브젝트가 어디에 몇 개 배치되어 있는가”를 관리하는 구조라고 볼 수 있습니다.

 

예를 들어 처음 보였던 씬 같은 경우에는 BLAS가 총 3가지입니다.

  • 벽 Cube
  • 삼각형

다만, TLAS의 인스턴스는 5개입니다.

  • 왼쪽 벽
  • 오른쪽 벽
  • 아래 벽
  • 삼각형

Ray와 기하의 충돌 검사는 먼저 TLAS부터 시작합니다.
TLAS는 씬 전체 인스턴스에 대한 상위 가속 구조이므로, 각 노드의 Bounding VolumeRay의 교차 여부를 검사하면서 불필요한 인스턴스 영역을 빠르게 제거합니다.


예를 들어 위와 같은 상황에서 A 영역을 빠르게 스킵하고,

B 영역 안에서도 4번 인스턴스는 볼 필요가 없고, 5번 인스턴스6번 인스턴스만 확인하면 됩니다.

 

위 과정을 통해 교차 가능성이 있는 인스턴스를 좁혀 나가고, 특정 인스턴스에 도달하면 그 인스턴스가 참조하는 BLAS를 탐색합니다. 이때는 해당 인스턴스의 Transform을 반영한 공간에서 BLAS 순회가 이루어집니다.
BLAS 내부에서도 마찬가지로 군집한 삼각형 끼리 묶인 계층적 Bounding Volume을 따라가며 후보 영역을 계속 줄여 나가고, 최종적으로 하나의 삼각형 같은 기하와의 교차 검사를 수행합니다.

 

이렇게 찾아낸 교차 결과들 중에서 조건에 맞는 가장 가까운 hit point가 최종 결과로 선택됩니다.


RayTracing State Object, Shader Table, Root Signature

일반 그래픽스 파이프라인이 Graphics PSO를 만들 듯이 DXR 에서는 RayTracing State Object를 만들어야 합니다.

Graphics PSO Vertex Shader, Pixel Shader, Blend State, Rasterizer State, Depth State를 묶듯이

Ray Tracing State Object Ray Gen Shader, Miss Shader, Hit Group, Shader Config, Pipeline Config, Global / Local Root Signature 같은 요소들을 묶습니다.

즉, Ray Tracing State Object는 “레이트레이싱 파이프라인을 어떤 셰이더와 어떤 설정으로 돌릴 것인가”를 정의하는 객체입니다.

 

하지만 State Object만 있다고 해서 곧바로 레이트레이싱이 실행되는 것은 아닙니다.

State Object “사용 가능한 셰이더 집합과 설정”을 정의하는 객체라면, Shader Table은 그중에서 “실제로 어떤 셰이더를 어떤 데이터와 함께 실행할지”를 GPU가 읽을 수 있는 형태로 정리해 둔 테이블입니다.

이때 Shader Table을 구성하는 한 개의 엔트리를 Shader Record라고 부르며, 각 Record에는 보통 Shader Identifier와 그 셰이더가 사용할 Local Root Arguments가 함께 들어갑니다.

 

 

DispatchRays 함수를 호출할 때는 D3D12_DISPATCH_RAYS_DESC 타입 구조체가 필요한데, 이 구조체에는 다음과 같은 내용이 있습니다.

  • RayGenerationShaderRecord
  • MissShaderTable
  • HitGroupTable
  • CallableShaderTable
  • Width, Height, Depth

여기서 RayGenerationShader는 Record로 전달되고, Miss Shader와 Hit Group은 Table로 전달됩니다.

DispatchRays 호출이 시작점으로 사용할 Ray Generation Shader Record는 하나인 반면 miss/hit 쪽은 실행 도중 여러 record 중 하나가 선택될 수 있기 때문입니다.

 

DXR의 Root Signature는 셰이더가 사용할 리소스를 어떤 방식으로 연결할지 정하는 규칙이며, 크게 Global Root Signature Local Root Signature로 나뉩니다.

 

Global Root Signature는 레이트레이싱 파이프라인 전체에서 공통으로 접근하는 리소스를 바인딩하는 데 사용됩니다.

반면 Local Root Signature Shader Record 단위로 적용되는 파라미터 레이아웃을 정의하며, 실제 값은 Shader Record 안의 Local Root Arguments로 전달됩니다.

 

예제에서는 Global Root Signature에 Rendering 결과를 담을 UAV, TLAS, Light CBV, Camera CBV를 정의하였고

Local Root Signature Mesh Vertex Buffer, Index Buffer를 담는 SRV를 정의하여 사용했습니다.


Ray Generation Shader 구현 코드

[shader("raygeneration")]
void RayGen()
{
    // 현재 dispatch 중인 픽셀 좌표
    uint2 id = DispatchRaysIndex().xy;

    // 전체 dispatch 해상도
    uint2 dim = DispatchRaysDimensions().xy;

    // 픽셀 중심 좌표를 0 ~ 1 UV로 변환
    float2 uv = (float2(id) + 0.5) / float2(dim);

    // UV -> NDC(-1 ~ 1)
    float2 ndc = uv * 2.0 - 1.0;

    // 화면 좌표계와 NDC의 y축 방향 차이를 보정
    ndc.y = -ndc.y;

    // NDC 상의 near/far 점 구성
    // z=0, z=1을 사용해서 투영 역변환 후 광선 방향을 계산
    float4 clipNear = float4(ndc.x, ndc.y, 0.0, 1.0);
    float4 clipFar = float4(ndc.x, ndc.y, 1.0, 1.0);

    // Clip -> View 공간 복원
    float4 viewNear = mul(clipNear, g_InvProjection);
    viewNear /= viewNear.w;

    float4 viewFar = mul(clipFar, g_InvProjection);
    viewFar /= viewFar.w;

    // View -> World 공간 복원
    float4 worldNear = mul(viewNear, g_InvView);
    float4 worldFar = mul(viewFar, g_InvView);

    // 카메라에서 장면으로 향하는 primary ray 생성
    RayDesc ray;
    ray.Origin = g_EyePosition.xyz;
    ray.Direction = normalize(worldFar.xyz - worldNear.xyz);
    ray.TMin = 0.001; // self-intersection / near-plane 문제 완화
    ray.TMax = 1e5;   // 충분히 먼 최대 거리

    // 초기 payload
    Payload p;
    p.color = float4(0, 0, 0, 1);
    p.depth = 0;

    // TLAS를 대상으로 레이 추적 시작
    TraceRay(
        g_TLAS,         // 가속구조
        RAY_FLAG_NONE,  // 레이 플래그
        0xFF,           // 인스턴스 마스크
        0,              // RayContributionToHitGroupIndex
        1,              // MultiplierForGeometryContributionToHitGroupIndex
        0,              // MissShaderIndex
        ray,
        p
    );

    // 추적 결과를 출력 텍스처에 저장
    g_Output[id] = p.color;
}

 

Closest Hit Shader 구현 코드

[shader("closesthit")]
void CHS(inout Payload p, in BuiltInTriangleIntersectionAttributes attr)
{
    // 현재 맞은 삼각형의 primitive index
    uint primIdx = PrimitiveIndex();

    // 삼각형은 인덱스 3개로 구성되므로 16-bit 인덱스 3개를 읽음
    uint i0 = LoadIndex16(primIdx * 3 + 0);
    uint i1 = LoadIndex16(primIdx * 3 + 1);
    uint i2 = LoadIndex16(primIdx * 3 + 2);

    // barycentric 좌표 복원
    // attr.barycentrics는 보통 두 값만 주어지므로
    // 나머지 한 축은 1 - x - y 로 계산
    float3 bary = float3(
        1.0 - attr.barycentrics.x - attr.barycentrics.y,
        attr.barycentrics.x,
        attr.barycentrics.y
    );

    // 버텍스 노멀을 barycentric으로 보간하여 object space normal 계산
    float3 normalOS = normalize(
        g_Vertices[i0].normal * bary.x +
        g_Vertices[i1].normal * bary.y +
        g_Vertices[i2].normal * bary.z
    );

    // object space normal -> world space normal
    // row-vector 관점에서 inverse 쪽 행렬을 곱하는 방식
    // 실제 프로젝트에서는 비균일 스케일까지 고려해 normal transform을 엄밀히 다루기도 함
    float3 normal = normalize(mul(normalOS, (float3x3) WorldToObject3x4()));

    // 버텍스 컬러도 barycentric으로 보간
    float3 vertexColor =
        g_Vertices[i0].color * bary.x +
        g_Vertices[i1].color * bary.y +
        g_Vertices[i2].color * bary.z;

    // ----------------------------
    // 로컬 조명 계산 (Phong)
    // ----------------------------
    float3 N = normal;
    float3 L = normalize(-g_LightDirWS);
    float3 ambient = 0.15 * vertexColor;

    // Lambert diffuse
    float NdotL = max(dot(N, L), 0.0);
    float3 diffuse = NdotL * g_LightColor * vertexColor;

    // Phong/Blinn-Phong specular
    float3 V = normalize(-WorldRayDirection());
    float3 H = normalize(L + V);
    float NdotH = max(dot(N, H), 0.0);
    float3 specular = pow(NdotH, 32.0) * g_LightColor * 0.5;

    // 로컬 조명 결과
    float3 localColor = ambient + diffuse + specular;

    // 현재 인스턴스의 재질 파라미터 획득
    MaterialParams mat = GetMaterial(InstanceID());

    if (p.depth < 1 && (mat.reflectionScale > 0.001 || mat.refractionScale > 0.001))
    {
        // 현재 히트 위치
        float3 hitPos = WorldRayOrigin() + WorldRayDirection() * RayTCurrent();

        // 현재 입사 광선 방향
        float3 incidentDir = normalize(WorldRayDirection());

        // 입사 방향과 노멀의 관계
        float cosI = dot(-incidentDir, normal);

        // faceNormal:
        // 광선이 바깥->안으로 들어오는지, 안->밖으로 나가는지에 따라
        // 굴절 계산에 사용할 법선을 정리
        float3 faceNormal = normal;

        float n1 = 1.0;     // 기본적으로 공기
        float n2 = mat.ior; // 물체의 굴절률

        // 표면 안쪽에서 바깥으로 나가는 경우
        if (cosI < 0.0)
        {
            faceNormal = -normal;
            cosI = -cosI;

            // 들어오는/나가는 매질을 서로 교환
            float tmp = n1;
            n1 = n2;
            n2 = tmp;
        }

        float3 reflectionColor = float3(0, 0, 0);
        float3 refractionColor = float3(0, 0, 0);

        // ----------------------------
        // 반사 레이
        // ----------------------------
        if (mat.reflectionScale > 0.001)
        {
            float3 reflDir = reflect(incidentDir, faceNormal);

            RayDesc reflRay;
            // 법선 방향으로 살짝 띄워서 자기 자신과 다시 충돌하는 문제 방지
            reflRay.Origin = hitPos + faceNormal * 0.001;
            reflRay.Direction = reflDir;
            reflRay.TMin = 0.001;
            reflRay.TMax = 1e5;

            Payload reflPayload;
            reflPayload.color = float4(0, 0, 0, 1);
            reflPayload.depth = p.depth + 1;

            TraceRay(g_TLAS, RAY_FLAG_NONE, 0xFF, 0, 1, 0, reflRay, reflPayload);

            // 반사 색에 반사 스케일 적용
            reflectionColor = reflPayload.color.rgb * mat.reflectionScale;
        }

        // ----------------------------
        // 굴절 레이
        // ----------------------------
        if (mat.refractionScale > 0.001)
        {
            // Snell's law에서 쓰이는 굴절률 비율
            float eta = n1 / n2;

            // 굴절 방향 계산
            float3 refrDir = refract(incidentDir, faceNormal, eta);

            // length가 거의 0이면 전반사로 간주
            if (length(refrDir) > 0.001)
            {
                RayDesc refrRay;

                // 굴절 광선은 표면 반대쪽으로 조금 밀어 넣어서 시작
                refrRay.Origin = hitPos - faceNormal * 0.001;
                refrRay.Direction = normalize(refrDir);
                refrRay.TMin = 0.001;
                refrRay.TMax = 1e5;

                Payload refrPayload;
                refrPayload.color = float4(0, 0, 0, 1);
                refrPayload.depth = p.depth + 1;

                TraceRay(g_TLAS, RAY_FLAG_NONE, 0xFF, 0, 1, 0, refrRay, refrPayload);

                // 굴절 색에 굴절 스케일 적용
                refractionColor = refrPayload.color.rgb * mat.refractionScale;
            }
            else
            {
                // 전반사 발생 시 간단히 반사 결과를 대신 사용
                refractionColor = reflectionColor;
            }
        }

        // 로컬 조명과 반사/굴절 결과를 단순 혼합
        float3 finalColor = lerp(localColor, reflectionColor + refractionColor, 0.2);
        p.color = float4(finalColor, 1.0);
    }
    else
    {
        // 반사/굴절이 없는 재질이면 로컬 조명만 사용
        p.color = float4(localColor, 1.0);
    }
}

 

지금까지 DirectX 12에서 Ray Tracing Pipeline을 구성하고 사용하는 방법에 대해 알아보았습니다.

파이프라인을 구성하기 위해 필요한 설정 항목은 많지만, 관련 기능을 모두 직접 구현하는 것에 비하면 훨씬 효율적이며 많은 이점을 제공합니다.

이번 예제에서는 ReflectionRefraction만 간단히 다루었지만, Ray Tracing Pipeline은 이 외에도 다양한 효과와 기능으로 확장하여 활용할 수 있습니다.

다음에 기회가 된다면 Any Hit Shader나 그림자 등도 구현해볼 예정입니다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함