ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • DirectX Raytracing
    개인 프로젝트 2026. 5. 25. 13:00

    개요

    이 글에서는 Direct3D 12 기반의 샘플 프로그램을 활용하여 DirectX Raytracing(이하 DXR)의 전체적인 사용 흐름을 살펴보겠습니다. 그리고 이를 바탕으로 DXR을 활용한 Ambient Occlusion 렌더링 방식인 Raytraced Ambient Occlusion(이하 RTAO)을 직접 구현하는 과정을 단계별로 소개하겠습니다.

    DXR

    DXR은 실시간 레이 트레이싱을 지원하기 위한 Direct3D 12의 확장 기술입니다. 실시간 레이 트레이싱은 BVH 순회 및 광선과 기하 간 교차 계산 등 높은 연산량을 요구하기 때문에 이를 효율적으로 처리하기 위해 전용 하드웨어(NVIDIA의 RT Core, AMD의 Ray Accelerator)가 탑재된 GPU에서 높은 성능을 발휘합니다.

    다만 DXR 자체는 API 기능이므로 전용 하드웨어가 없어도 지원되는 GPU에서는 소프트웨어 기반으로 동작할 수 있습니다. 따라서 실제 성능과는 별개로 DXR 기능의 사용 가능 여부를 확인하는 과정이 필요합니다.

    Direct3D 12에서 DXR의 지원 여부는 다음과 같이 ID3D12Device의 CheckFeatureSupport를 통해 확인할 수 있습니다.

    D3D12_FEATURE_DATA_D3D12_OPTIONS5 featureOption5 = {};
    hr = m_device->CheckFeatureSupport( D3D12_FEATURE_D3D12_OPTIONS5, &featureOption5, sizeof( featureOption5 ) );
    
    if ( FAILED( hr ) )
    {
        return false;
    }
    
    m_raytracingAvailable = ( featureOption5.RaytracingTier != D3D12_RAYTRACING_TIER_NOT_SUPPORTED );

    위 코드는 D3D12_FEATURE_D3D12_OPTIONS5 를 통해 기능 지원 여부를 조회하고 RaytracingTier 값이 D3D12_RAYTRACING_TIER_NOT_SUPPORTED가 아니면 DXR API 사용이 가능하다고 판단합니다.

    Bottom Level Acceleration Structure

    먼저 Bottom Level Acceleration Structure(이하 BLAS)에 대해 살펴보겠습니다. BLAS는 레이 트레이싱에서 광선과 기하 간의 교차 계산을 가속하기 위한 구조로 물리 엔진의 개념으로 보면 좁은 단계(Narrow Phase)의 충돌 검사에 해당한다고 볼 수 있습니다.

    BLAS는 정점 버퍼와 인덱스 버퍼를 통해 삼각형과 같은 프리미티브를 가속을 위한 자료구조에 저장합니다. 명확하게 어떤 자료구조를 사용하는지는 Microsoft의 공식 스펙에서 찾을 수 없었지만 일반적으로 Bounding Volume Hierarchy(이하 BVH)가 사용되는 만큼 이와 유사한 구조로 구성될 것으로 추측할 수 있습니다.

    이제 DXR에서 BLAS를 생성하기 위해 필요한 요소들을 살펴보겠습니다. 가장 먼저 해야 할 것은 D3D12_RAYTRACING_GEOMETRY_DESC 구조체를 초기화하는 것입니다.

    void D3D12BLAS::InitResource()
    {
        auto vertexBuffer = static_cast<D3D12Buffer*>( m_desc.m_vertexBuffer.Get() );
        auto indexBuffer = static_cast<D3D12Buffer*>( m_desc.m_indexBuffer.Get() );
    
        D3D12_RAYTRACING_GEOMETRY_DESC geometryDesc = {
            .Type = D3D12_RAYTRACING_GEOMETRY_TYPE_TRIANGLES,
            .Flags = D3D12_RAYTRACING_GEOMETRY_FLAG_OPAQUE,
            .Triangles = {
                .IndexFormat = indexBuffer ? indexBuffer->GetFormat() : DXGI_FORMAT_UNKNOWN,
                .VertexFormat = vertexBuffer->GetFormat(),
                .IndexCount = indexBuffer ? indexBuffer->GetDesc().m_count : 0,
                .VertexCount = vertexBuffer->GetDesc().m_count,
            }
        };
    
    // ...
    }

    위 코드는 빌드에 필요한 최소한의 멤버만 초기화한 구성입니다. 모든 멤버를 초기화하지 않은 이유는 실제 빌드 전에 BLAS의 결과 버퍼 크기와 빌드 과정에서 사용되는 임시 버퍼인 스크래치 버퍼의 크기를 계산하기에는 이 정도로도 충분합니다.

    그럼 각 멤버 변수를 순서대로 살펴보겠습니다.

    • Type : BLAS를 구성하는 기하 구조의 종류를 나타냅니다. 여기서는 삼각형을 의미하는 D3D12_RAYTRACING_GEOMETRY_TYPE_TRIANGLES 가 사용되었습니다.
    • Flags : 기하 구조에 대한 옵션을 지정합니다. D3D12_RAYTRACING_GEOMETRY_FLAG_OPAQUE 를 사용하여 불투명한 기하 구조로 설정하였습니다.
    • Triangles : Type을 삼각형으로 지정하였기 때문에 삼각형에 대한 기하 구조 정보를 설정해야 합니다.
      • IndexFormat : 인덱스 버퍼의 포맷을 설정합니다. 인덱스 버퍼는 사용하지 않을 수도 있으므로 사용하지 않는 경우 DXGI_FORMAT_UNKNOWN을 설정합니다.
      • VertexFormat : 버텍스 버퍼의 포맷을 설정합니다. 여기에 설정할 수 있는 포맷은 제한되어 있으므로 세부 내용은 공식 문서를 참고 바랍니다.
      • IndexCount : 인덱스 버퍼의 인덱스 개수를 설정합니다.
      • VertexCount : 버텍스 버퍼의 정점 개수를 설정합니다.

    다음으로 D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_INPUTS 를 초기화합니다.

    D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_INPUTS accelerationStructureInputs = {
        .Type = D3D12_RAYTRACING_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL,
        .Flags = D3D12_RAYTRACING_ACCELERATION_STRUCTURE_BUILD_FLAG_NONE,
        .NumDescs = 1,
        .DescsLayout = D3D12_ELEMENTS_LAYOUT_ARRAY,
        .pGeometryDescs = &geometryDesc,
    };
    • Type : 빌드할 가속 구조의 종류를 나타냅니다. BLAS를 빌드하려고 하므로 D3D12_RAYTRACING_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL 가 사용되었습니다.
    • Flags : 빌드 옵션을 지정합니다. 여기서는 아무런 옵션도 설정하지 않았습니다.
    • NumDescs : 빌드에 사용할 Descriptor의 개수를 지정합니다.
    • DescsLayout : Descriptor를 참조하는 방식을 지정합니다. 여기서는 연속된 배열을 뜻하는 D3D12_ELEMENTS_LAYOUT_ARRAY 를 사용하였습니다.
    • pGeometryDescs : D3D12_RAYTRACING_GEOMETRY_DESC 의 주소를 전달합니다.

    이를 이용하여 GetRaytracingAccelerationStructurePrebuildInfo 함수를 호출하면 BLAS의 결과 버퍼 크기와 스크래치 버퍼의 크기를 얻을 수 있습니다.

    D3D12_RAYTRACING_ACCELERATION_STRUCTURE_PREBUILD_INFO prebuildInfo = {};
    D3D12Device().GetRaytracingAccelerationStructurePrebuildInfo( &accelerationStructureInputs, &prebuildInfo );

    BLAS를 위한 버퍼 크기는 prebuildInfo.ResultDataMaxSizeInBytes , 스크래치 버퍼의 크기는 prebuildInfo.ScratchDataSizeInBytes 에 담겨져 반환됩니다.

    이제 버퍼를 생성할 수 있습니다.

    BufferDesc blasBufferDesc = {
        .m_stride = 1,
        .m_count = static_cast<uint32>( prebuildInfo.ResultDataMaxSizeInBytes ),
        .m_access = ResourceAccess::Default,
        .m_bindType = ResourceBindType::RandomAccess,
        .m_miscFlag = ResourceMisc::WithoutViews,
        .m_format = ResourceFormat::Unknown,
    };
    
    m_blas = RefStaticCast<D3D12Buffer>( Buffer::Create( blasBufferDesc, m_debugName.CStr(), ResourceState::RaytracingAccelerationStructure ) );

    D3D12_RESOURCE_STATE_RAYTRACING_ACCELERATION_STRUCTURE를 초기 상태로 설정하여 Blas 버퍼를 생성합니다. 이어서 스크래치 버퍼를 다음과 같이 생성합니다.

    BufferDesc scratchBufferDesc = {
        .m_stride = 1,
        .m_count = static_cast<uint32>( m_scratchDataSizeInBytes ),
        .m_access = ResourceAccess::Default,
        .m_bindType = ResourceBindType::RandomAccess,
        .m_miscFlag = ResourceMisc::WithoutViews,
        .m_format = ResourceFormat::Unknown,
    };
    
    auto scratchBuffer = RefStaticCast<D3D12Buffer>( Buffer::Create( scratchBufferDesc, "BLAS.Scratch", ResourceState::UnorderedAccess ) );
    

    초기 상태는 D3D12_RESOURCE_STATE_UNORDERED_ACCESS 입니다.

    이제 BLAS를 빌드할 준비가 끝났습니다. D3D12_RAYTRACING_GEOMETRY_DESC 를 제대로 초기화합니다.

    D3D12_RAYTRACING_GEOMETRY_DESC geometryDesc = {
        .Type = D3D12_RAYTRACING_GEOMETRY_TYPE_TRIANGLES,
        .Flags = D3D12_RAYTRACING_GEOMETRY_FLAG_OPAQUE,
        .Triangles = {
            .IndexFormat = indexBuffer ? indexBuffer->GetFormat() : DXGI_FORMAT_UNKNOWN,
            .VertexFormat = vertexBuffer->GetFormat(),
            .IndexCount = indexBuffer ? indexBuffer->GetDesc().m_count : 0,
            .VertexCount = vertexBuffer->GetDesc().m_count,
            .IndexBuffer = indexBuffer ? indexBuffer->Resource()->GetGPUVirtualAddress() : D3D12_GPU_VIRTUAL_ADDRESS(),
            .VertexBuffer = {
                .StartAddress = vertexBuffer->Resource()->GetGPUVirtualAddress(),
                .StrideInBytes = vertexBuffer->GetDesc().m_stride,
            },
        }
    };
    

    정점 버퍼, 인덱스 버퍼에 대한 GPU 주소를 초기화하는 부분이 추가되었습니다.

    • Triangles
      • IndexBuffer : 인덱스 버퍼의 GPU 주소를 전달합니다.
      • VertexBuffer
        • StartAddress : 정점 버퍼의 GPU 주소를 전달합니다.
        • StrideInBytes : 정점 구조체 하나의 크기를 전달합니다.

    다음으로 D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_DESC 구조체를 초기화합니다.

    D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_DESC buildDesc = {
        .DestAccelerationStructureData = m_blas->Resource()->GetGPUVirtualAddress(),
        .Inputs = {
            .Type = D3D12_RAYTRACING_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL,
            .Flags = D3D12_RAYTRACING_ACCELERATION_STRUCTURE_BUILD_FLAG_NONE,
            .NumDescs = 1,
            .DescsLayout = D3D12_ELEMENTS_LAYOUT_ARRAY,
            .pGeometryDescs = &geometryDesc,
        },
        .ScratchAccelerationStructureData = scratchBuffer->Resource()->GetGPUVirtualAddress(),
    };
    
    • DestAccelerationStructureData : BLAS의 결과가 기록될 버퍼의 GPU 주소를 전달합니다.
    • Inputs : 앞에서 보았던 D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_INPUTS 구조체입니다.
    • ScratchAccelerationStructureData : BLAS 빌드 중 임시로 사용될 스크래치 버퍼의 GPU 주소를 전달합니다.

    이후 ID3D12GraphicsCommandList4의 BuildRaytracingAccelerationStructure 함수를 호출하는 것으로 BLAS가 생성됩니다.

    CommandList().BuildRaytracingAccelerationStructure( &desc, 0, nullptr );

    BLAS 생성은 GPU에서 수행되는 연산 작업이므로 BuildRaytracingAccelerationStructure 함수 호출 후 UAV 베리어를 추가해야 합니다.

    UavBarrier uavBarrier = {
    	.m_pResource = m_blas->Resource()
    };
    d3d12CommandList.AddUavBarrier( uavBarrier );

    UAV 베리어가 없으면 생성이 완료되지 않은 BLAS에 접근하게 되어 다양한 버그가 발생할 수 있습니다.

    Top Level Acceleration Structure

    Top Level Acceleration Structure(이하 TLAS)는 광선과의 교차 검사 대상이 되는 기하 후보를 빠르게 검출하는 데 사용되는 가속 구조입니다.

    TLAS는 BLAS의 집합으로 직접 기하 데이터를 가지지 않고 BLAS를 참조하는 방식으로 구성되어 있습니다. 각 참조 인스턴스로 표현되며 변환 행렬과 결합하여 하나의 BLAS를 여러 인스턴스에서 서로 다른 변환으로 재활용할 수 있습니다.

    TLAS의 생성 과정은 앞서 살펴본 BLAS와 거의 유사합니다.

    void D3D12TLAS::InitResource()
    {
    	auto numInstances = static_cast<uint32>( m_desc.instanceDescs.size() );
    
    	D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_INPUTS accelerationStructureInputs = {
    		.Type = D3D12_RAYTRACING_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL,
    		.Flags = D3D12_RAYTRACING_ACCELERATION_STRUCTURE_BUILD_FLAG_NONE,
    		.NumDescs = numInstances,
    		.DescsLayout = D3D12_ELEMENTS_LAYOUT_ARRAY,
    	};
    
    	D3D12_RAYTRACING_ACCELERATION_STRUCTURE_PREBUILD_INFO prebuildInfo = {};
    	D3D12Device().GetRaytracingAccelerationStructurePrebuildInfo( &accelerationStructureInputs, &prebuildInfo );
    • Type : TLAS를 지정하는 D3D12_RAYTRACING_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL 이 사용되었습니다.
    • NumDescs : TLAS를 이루는 인스턴스의 개수로 초기화합니다.
    • pGeometryDescs : TLAS에서는 필요하지 않습니다.

    BLAS와 마찬가지로 필요한 버퍼의 용량을 아는데는 모든 멤버를 초기화하지 않아도 됩니다. 결과 버퍼와 스크래치 버퍼를 생성하는 과정도 동일합니다.

    다음으로 D3D12_RAYTRACING_INSTANCE_DESC구조체를 이용하여 인스턴스의 정보를 전달합니다. D3D12_RAYTRACING_INSTANCE_DESC 구조체의 멤버를 살펴보면 다음과 같습니다.

    • Transform : 인스턴스의 월드 변환을 나타내는 3x4 행렬입니다.
    • InstanceID : 셰이더의 내장 함수를 통해서 접근할 수 있는 아이디 값입니다.
    • InstanceMask : 인스턴스에 할당된 마스크로 인스턴스 그룹을 포함 / 거부 하는데 사용됩니다. 0이면 포함되지 않으므로 0보다는 커야 합니다.
    • InstanceContributionToHitGroupIndex : Hit Group(이하 히트 그룹)을 선택하는데 사용될 Index에 추가될 기여도를 나타내는 값입니다.
    • Flags : 인스턴스에 적용할 플래그로 강제로 불투명 취급하는 등의 설정을 지정할 수 있습니다.
    • AccelerationStructure : BLAS의 GPU 주소입니다.

    D3D12_RAYTRACING_INSTANCE_DESC 구조체의 값은 GPU에서 사용해야 하므로 GPU Upload 버퍼에 기록되어야 합니다.

    void D3D12TLAS::Build( IComputeCommandList& commandList )
    {
        // ...
    
        auto numInstances = static_cast<uint32>( m_desc.instanceDescs.size() );
    
        BufferDesc instanceDescBufferDesc = {
            .m_stride = sizeof( D3D12_RAYTRACING_INSTANCE_DESC ),
            .m_count = numInstances,
            .m_access = ResourceAccess::Upload,
            .m_bindType = ResourceBindType::None,
            .m_miscFlag = ResourceMisc::Intermediate,
            .m_format = ResourceFormat::Unknown,
        };
    
        auto instanceDescBuffer = RefStaticCast<D3D12Buffer>( Buffer::Create( instanceDescBufferDesc, "TLAS.InstanceDesc" ) );
    
        auto dest = static_cast<D3D12_RAYTRACING_INSTANCE_DESC*>( GetInterface<IAgl>()->Lock( instanceDescBuffer.Get() ).m_data );
        for ( size_t i = 0; i < numInstances; ++i )
        {
            const auto& instanceDesc = m_desc.instanceDescs[i];
            auto d3d12BLAS = RefStaticCast<D3D12BLAS>( instanceDesc.m_blas );
    
            std::memcpy( &dest[i].Transform, &instanceDesc.m_worldTransform, sizeof( Matrix3X4 ) );
            dest[i].InstanceID = instanceDesc.m_instanceId;
            dest[i].InstanceMask = 0xFF;
            dest[i].InstanceContributionToHitGroupIndex = 0;
            dest[i].Flags = D3D12_RAYTRACING_INSTANCE_FLAG_NONE;
            dest[i].AccelerationStructure = d3d12BLAS->Resource()->GetGPUVirtualAddress();
        }
        GetInterface<IAgl>()->UnLock( instanceDescBuffer.Get() );
    
    	// ...    
    }

    위와 같이 Instance 버퍼를 만들고 D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_DESC 의 Inputs.InstanceDescs를 GPU 주소로 초기화하여 BuildRaytracingAccelerationStructure 를 호출하면 TLAS의 빌드도 완료됩니다.

    D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_DESC buildDesc = {
        .DestAccelerationStructureData = m_tlas->Resource()->GetGPUVirtualAddress(),
        .Inputs = {
            .Type = D3D12_RAYTRACING_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL,
            .Flags = D3D12_RAYTRACING_ACCELERATION_STRUCTURE_BUILD_FLAG_NONE,
            .NumDescs = numInstances,
            .DescsLayout = D3D12_ELEMENTS_LAYOUT_ARRAY,
            .InstanceDescs = instanceDescBuffer->Resource()->GetGPUVirtualAddress()
        },
        .ScratchAccelerationStructureData = scratchBuffer->Resource()->GetGPUVirtualAddress(),
    };
    
    // ...
    
    auto& d3d12CommandList = static_cast<D3D12ComputeCommandList&>( commandList );
    d3d12CommandList.BuildRaytracingAccelerationStructure( buildDesc );
    
    UavBarrier uavBarrier = {
        .m_pResource = m_tlas->Resource()
    };
    d3d12CommandList.AddUavBarrier( uavBarrier );

    지금까지의 과정을 통해 생성된 TLAS와 BLAS의 전체 구조를 도식화하면 다음과 같습니다.

    https://dl.acm.org/doi/pdf/10.1145/3664475.3665157

    Shader Library

    DXR에서는 레이 트레이싱을 위해 6가지의 셰이더가 추가되었습니다.

    1. Any Shader : 광선과 물체가 교차했을 때, 해당 교차가 불투명(opaque)으로 처리되지 않는 경우에 실행되며 충돌을 수락하거나 무시할지 선택할 수 있습니다.
    2. Callable Shader : CallShader 내장 함수를 통해 다른 셰이더에서 호출되는 셰이더입니다.
    3. Closest Hit Shader : 광선 경로 상에서 가장 가까운 유효한 교차 지점이 확정되었을 때 실행됩니다.
    4. Intersection Shader : 광선이 특정 바운딩 볼륨과 연결된 사용자 정의 프리미티브와 교차하는지 직접 계산하기 위한 셰이더입니다.
    5. Miss Shader : 광선이 어떤 물체와도 교차하지 않은 경우 실행됩니다.
    6. Ray Generation Shader : 레이 트레이싱의 시작점으로 광선 추적을 위한 광선을 생성하고 전체 레이 트레이싱 과정을 시작합니다.

    DXR의 새로운 6가지 셰이더는 DXC의 Shader Library(이하 셰이더 라이브러리) 기능을 통해 컴파일됩니다. 셰이더 라이브러리는 기존처럼 단일 엔트리 포인트 함수 기준으로 컴파일되던 기존의 셰이더와 달리 여러 개의 함수들을 라이브러리 형태로 컴파일할 수 있고 필요한 함수를 Export하여 사용할 수 있습니다.

    Shader Library 형태로 셰이더를 컴파일하기 위해서는 lib_6_x 타겟 프로파일을 사용합니다.

    const wchar_t* Direct3D12::GetShaderProfile( ShaderType type ) const
    {
    	// ...
    	else if ( IsRaytracingShader( type ) )
    	{
    		switch ( m_shaderModel.HighestShaderModel )
    		{
    		case D3D_SHADER_MODEL_6_5:
    			return L"lib_6_5";
    		case D3D_SHADER_MODEL_6_6:
    			return L"lib_6_6";
    		case D3D_SHADER_MODEL_6_7:
    			return L"lib_6_7";
    		case D3D_SHADER_MODEL_6_8:
    			return L"lib_6_8";
    		case D3D_SHADER_MODEL_6_9:
    			return L"lib_6_9";
    		}
    	}
    
    	assert( false && "Invalid shader type" );
    	return L"";
    }

    그리고 샘플 프로그램에서는 기존의 단일 엔트리 포인트에 해당하는 함수를 명시적으로 노출하기 위하여 -exports <함수명> 을 추가하였습니다.

    또한 명시적으로 지정되지 않은 리소스 바인딩에 대해 register space를 자동으로 할당하기 위해 -auto-binding-space 0 옵션을 사용하였습니다. 이를 통해 셰이더에서 참조하는 리소스들은 기본적으로 space 0을 기준으로 바인딩 포인트가 자동 할당됩니다.

    // entry point
    if ( IsRaytracingShader( type ) )
    {
    	args.push_back( L"-auto-binding-space 0" );
    	args.push_back( L"-exports" );
    }
    else
    {
    	args.push_back( L"-E" );
    }
    
    wchar_t wEntryPoint[64] = {};
    {
    	ToWideChar( wEntryPoint, std::extent_v<decltype( wEntryPoint )>, entryPoint );
    }
    args.push_back( wEntryPoint );
    

    Shader Table

    DXR을 사용한 실시간 레이 트레이싱에서 GPU는 미리 정해둔 규칙에 따라 실행할 셰이더를 선택해야 합니다. 예를 들면 불투명 표면에 부딪혔을 경우에는 Closest Hit, 아무런 표면에도 부딪히지 않았을 경우에는 Miss 셰이더를 실행해야 합니다.

    Shader Table(이하 셰이더 테이블)은 상황에 따라 GPU가 실행해야할 셰이더와 해당 셰이더에 필요한 데이터를 모아 놓은 참조 테이블입니다. 셰이더 테이블의 각 항목을 Shader Record(이하 셰이더 레코드) 라고 부르는데 GPU는 미리 정의된 인덱싱 규칙을 통해 셰이더 테이블에서 적절한 레코드를 찾아 실행합니다.

    셰이더 레코드는 총 4가지 종류로 나뉘는데

    • Ray Generation : Ray Generation 셰이더를 가리키며 셰이더 테이블에서 한 개만 존재합니다.
    • Hit Group : 충돌시 호출될 셰이더의 묶음입니다. 하나의 레코드 안에 다음과 같은 셰이더 조합이 들어갑니다.
      • ClosestHit : 필수적으로 들어가야 합니다.
      • AnyHit : 선택적으로 들어갈 수 있습니다.
      • Intersection : 사용자 정의 프리미티브를 사용할 때 필요합니다.
    • Miss : Miss 셰이더를 가리키며 셰이더 테이블에서 여러 개 존재할 수 있습니다.
    • Callable : 다른 셰이더에서 호출할 수 있는 Callable 셰이더를 가리킵니다.

    또한 각 레코드는 셰이더별 고유 데이터를 포함할 수 있으며, 해당 데이터는 Local Root Signature(이하 로컬 루트 시그니쳐)를 통해 바인딩됩니다. 따라서 전체적인 셰이더 테이블의 구조는 다음과 같이 구성됩니다.

    https://dl.acm.org/doi/pdf/10.1145/3664475.3665157

    셰이더 테이블을 구성하기 위해서는 우선 State Object(이하 스테이트 오브젝트)를 생성해야 합니다. 이에 대한 실제 생성 과정은 이후 스테이트 오브젝트 부분에서 자세히 다루겠습니다.

    Root Signature

    DXR에서 루트 시그니쳐는 적용 범위에 따라 Global Root Signature (이하 글로벌 루트 시그니쳐)와 로컬 루트 시그니쳐를 사용합니다.

    글로벌 루트 시그니쳐는 말 그대로 레이 트레이싱 파이프라인 전체에서 공통으로 사용되는 루트 시그니쳐로 TLAS, 출력 버퍼, 공통 상수나 리소스를 바인딩하는데 사용됩니다. 컴퓨트 파이프라인에서 지금까지 사용한 루트 시그니쳐와 유사하다고 생각하면 됩니다. DXR이 컴퓨트 커맨드 리스트 위에서 동작하기 때문에 SetComputeRootSignature 함수를 통해 파이프라인에 설정하는 것도 동일합니다.

    로컬 루트 시그니쳐는 셰이더 레코드 별로 고유하게 사용되는 데이터를 바인딩하기 위한 루트 시그니쳐로 글로벌 루트 시그니쳐와 달리 별도의 Set 함수를 통해 직접적으로 설정되지 않습니다. 다만 셰이더 테이블 항목에서 다뤘던 것처럼 셰이더 레코드의 식별자 뒤에 위치한 리소스를 자동으로 셰이더 실행 컨텍스트에 바인딩합니다.

    여기서 한 가지 짚고 넘어갈 부분이 있는데 예제 프로그램에서는 로컬 루트 시그니쳐를 사용하지 않습니다. 로컬 루트 시그니쳐를 사용하면 재질별 텍스쳐 데이터 등을 자동으로 바인딩할 수 있으나 재질마다 셰이더 레코드를 관리하고 업데이트해야 하는 부담이 있습니다. 이러한 부분이 매력적이지 않기 때문에 로컬 루트 시그니쳐를 사용하지 않기로 결정하였고 대신 Bindless Resource를 사용하여 이 부분을 처리할 생각입니다. 따라서 이후 로컬 루트 시그니쳐를 어떻게 설정하는지 등 자세한 내용은 다루지 않을 것입니다.

    State Object

    State Object(이하 스테이트 오브젝트)는 개념적으로 래스터 렌더링 파이프라인의 파이프라인 스테이트 오브젝트에 상응하는 상태 객체로 레이 트레이싱 파이프라인에 대한 설정을 담고 있는 객체입니다. 파이프라인 스테이트 오브젝트가 정해진 셰이더 스테이지 구조(예: 정점 셰이더, 픽셀 셰이더 등)를 기반으로 비교적 고정된 구조를 가진 것에 반해 스테이트 오브젝트는 레이 트레이싱의 특성상 경우에 따라 여러 셰이더를 조합해야 하므로 좀 더 유연한 구조를 가질 필요가 있습니다. 이를 위해 스테이트 오브젝트는 필요한 구성 요소를 Subobject(이하 서브 오브젝트)로 나누고 이를 조합하여 생성됩니다.

    서브 오브젝트는 D3D12_STATE_SUBOBJECT 구조체를 통해 설정하는데 다음과 같이 선언되어 있습니다.

    typedef struct D3D12_STATE_SUBOBJECT
        {
        D3D12_STATE_SUBOBJECT_TYPE Type;
        const void *pDesc;
        }   D3D12_STATE_SUBOBJECT;
    

    Type 을 통해 전달된 서브 오브젝트의 종류에 따라 pDesc의 설명자의 메모리를 참조하는 식입니다. 예제 프로그램에서는 서브 오브젝트의 설정을 쉽게 하기 위하여 다음과 같은 Helper 클래스를 작성하여 사용하였습니다.

    class D3D12StateSubobjects
    {
    public:
        void AddDXIL( const void* byteCode, size_t byteCodeSize, const wchar_t* exportName );
        void AddHitGroup( const D3D12HitGroup& hitGroup );
        void AddShaderConfig( uint32 maxPayloadSizeInBytes, uint32 maxAttributeSizeInBytes );
        void AddPipelineConfig( uint32 maxTraceRecursionDepth );
        void AddGlobalRootSignature( const D3D12RootSignature& rootSignature );
    
        D3D12_STATE_OBJECT_DESC GetSubobjectDesc();
    
    private:
        template <typename T>
        T& AllocDesc()
        {
            return *reinterpret_cast<T*>( m_transientAllocator.allocate( sizeof( T ) ) );
        }
    
        wchar_t* AllocWCharBuffer( uint32 size );
    
        RenderFrameArray<D3D12_STATE_SUBOBJECT> m_subobjects;
        TransientAllocator<uint8, ThreadType::RenderThread> m_transientAllocator;
    };
    

    이제 각 함수를 살펴보면서 스테이트 오브젝트를 생성하기 위해 어떤 서브 오브젝트가 필요한지 알아 보겠습니다.

    DXIL 설정

    DirectX Intermediate Language은 Direct3D 12 에서 셰이더 프로그램을 정의하는데 사용되는 공식 바이트코드 포맷입니다. AddDXIL 함수는 컴파일 된 셰이더 라이브러리에서 레이 트레이싱에 사용될 셰이더 바이트코드를 등록하는 함수입니다.

    void D3D12StateSubobjects::AddDXIL( const void* byteCode, size_t byteCodeSize, const wchar_t* exportName )
    {
        auto& desc = AllocDesc<D3D12_DXIL_LIBRARY_DESC>();
        desc.DXILLibrary.pShaderBytecode = byteCode;
        desc.DXILLibrary.BytecodeLength = byteCodeSize;
        desc.NumExports = 1;
    
        auto& exportDesc = AllocDesc<D3D12_EXPORT_DESC>();
        exportDesc.Name = exportName;
        exportDesc.ExportToRename = nullptr;
        exportDesc.Flags = D3D12_EXPORT_FLAG_NONE;
    
        desc.pExports = &exportDesc;
    
        D3D12_STATE_SUBOBJECT& subobject = m_subobjects.emplace_back();
        subobject.Type = D3D12_STATE_SUBOBJECT_TYPE_DXIL_LIBRARY;
        subobject.pDesc = &desc;
    }
    

    이 과정에서 사용되는 Direct3D 12의 구조체는 D3D12_DXIL_LIBRARY_DESC과 D3D12_EXPORT_DESC입니다.D3D12_DXIL_LIBRARY_DESC 구조체를 먼저 살펴보겠습니다.

    • DXILLibrary : 셰이더 바이트코드를 설정합니다.
      • pShaderBytecode : 셰이더 라이브러리 바이트코드의 시작 주소를 설정합니다.
      • BytecodeLength : 셰이더 라이브러리 바이트코드의 크기를 설정합니다.
    • NumExports : 내보낼 함수의 갯수를 지정합니다.
    • pExports : 내보낼 함수의 정보가 담긴 D3D12_EXPORT_DESC 의 시작 주소를 설정합니다.

    다음은D3D12_EXPORT_DESC 입니다.

    • Name : 내보낼 함수의 이름을 설정합니다.
    • ExportToRename : 내보낼 함수의 이름을 변경할 때 사용합니다. 셰이더 라이브러리에서 정의한 함수의 이름을 그대로 사용할 경우 nullptr로 설정할 수 있습니다.
    • Flags : 내보낼 때 사용할 플래그로 현재는 D3D12_EXPORT_FLAG_NONE만 설정할 수 있습니다.

    HitGroup 설정

    앞에서 등록한 DXIL을 이용하여 충돌에 관련된 셰이더를 하나의 그룹으로 묶어 서브 오브젝트에 설정합니다.

    void D3D12StateSubobjects::AddHitGroup( const D3D12HitGroup& hitGroup )
    {
        auto& desc = AllocDesc<D3D12_HIT_GROUP_DESC>();
        desc = {};
        desc.HitGroupExport = hitGroup.GetExportName();
        desc.Type = D3D12_HIT_GROUP_TYPE_TRIANGLES;
    
        if ( D3D12IntersectionShader* intersection = hitGroup.GetIntersection() )
        {
            desc.IntersectionShaderImport = intersection->GetExportName();
        }
    
        if ( D3D12ClosestHitShader* closestHit = hitGroup.GetClosestHit() )
        {
            desc.ClosestHitShaderImport = closestHit->GetExportName();
        }
    
        if ( D3D12AnyHitShader* anyHit = hitGroup.GetAnyHit() )
        {
            desc.AnyHitShaderImport = anyHit->GetExportName();
        }
    
        D3D12_STATE_SUBOBJECT& subobject = m_subobjects.emplace_back();
        subobject.Type = D3D12_STATE_SUBOBJECT_TYPE_HIT_GROUP;
        subobject.pDesc = &desc;
    }
    

    여기서는 D3D12_HIT_GROUP_DESC가 사용됩니다.

    • HitGroupExport : 히트 그룹을 내보내는데 사용할 이름을 설정합니다.
    • Type : 히트 그룹의 종류를 설정합니다. 삼각형( D3D12_HIT_GROUP_TYPE_TRIANGLES )과 사용자 정의 프리미티브(D3D12_HIT_GROUP_TYPE_PROCEDURAL_PRIMITIVE)를 설정할 수 있습니다.
    • IntersectionShaderImport : 히트 그룹에 속할 Intersection Shader를 설정합니다. 앞 과정에서 등록한 내보내기 이름이 사용되어야 합니다.
    • ClosestHitShaderImport : 히트 그룹에 속할 Closest Hit Shader를 설정합니다. 앞 과정에서 등록한 내보내기 이름이 사용되어야 합니다.
    • AnyHitShaderImport : 히트 그룹에 속할 Any Hit Shader를 설정합니다. 앞 과정에서 등록한 내보내기 이름이 사용되어야 합니다.

    Global RootSignature 설정

    리소스 바인딩을 위한 글로벌 루트 시그니쳐를 설정합니다.

    void D3D12StateSubobjects::AddGlobalRootSignature( const D3D12RootSignature& rootSignature )
    {
        auto& desc = AllocDesc<D3D12_GLOBAL_ROOT_SIGNATURE>();
        desc.pGlobalRootSignature = rootSignature.Resource();
    
        D3D12_STATE_SUBOBJECT& subobject = m_subobjects.emplace_back();
        subobject.Type = D3D12_STATE_SUBOBJECT_TYPE_GLOBAL_ROOT_SIGNATURE;
        subobject.pDesc = &desc;
    }
    

    D3D12_GLOBAL_ROOT_SIGNATURE 가 사용되며 설정할 변수는 단 하나 입니다.

    • pGlobalRootSignature : 글로벌 루트 시그니쳐로 사용할 ID3D12RootSignature 포인터를 설정합니다.

    Shader Config

    셰이더 함수에서 사용할 Payload(이하 페이로드)와 Attribute(이하 어트리뷰트)의 최대 크기를 설정합니다.

    페이로드는 레이 트레이싱 셰이더 간에 값을 교환할 때 사용되는 변수입니다. 흔히 색상과 같은 정보를 담습니다.

    어트리뷰트는 광선의 충돌결과로 생기는 임시 데이터로 삼각형의 경우 무게중심 좌표의 정보가 담깁니다. 삼각형 충돌을 사용하는 경우 미리 정의된 BuiltInTriangleIntersectionAttributes를 사용하며 이 값의 크기는 8byte(float 2개) 입니다.

    이처럼 레이 트레이싱 파이프라인에서는 성능과 메모리 레이아웃 때문에 페이로드와 어트리뷰트의 크기를 사전에 고정합니다.

    void D3D12StateSubobjects::AddShaderConfig( uint32 maxPayloadSizeInBytes, uint32 maxAttributeSizeInBytes )
    {
        auto& desc = AllocDesc<D3D12_RAYTRACING_SHADER_CONFIG>();
        desc.MaxPayloadSizeInBytes = maxPayloadSizeInBytes;
        desc.MaxAttributeSizeInBytes = maxAttributeSizeInBytes;
    
        D3D12_STATE_SUBOBJECT& subobject = m_subobjects.emplace_back();
        subobject.Type = D3D12_STATE_SUBOBJECT_TYPE_RAYTRACING_SHADER_CONFIG;
        subobject.pDesc = &desc;
    }
    
    • MaxPayloadSizeInBytes : 페이로드의 최대 크기
    • MaxAttributeSizeInBytes : 어트리뷰트의 최대 크기

    Pipeline Config

    마지막은 파이프라인 설정입니다. 여기서는 딱 하나만 설정하는데 바로 최대 광선 추적 재귀 횟수 입니다.

    레이 트레이싱에서는 광선이 기하 구조와 충돌했다고 해서 바로 추적이 종료되지 않습니다. 충돌 지점에서 반사나 굴절 등의 효과를 계산하기 위해, 추가적인 광선을 계속 추적할 수 있습니다.

    void D3D12StateSubobjects::AddPipelineConfig( uint32 maxTraceRecursionDepth )
    {
        auto& desc = AllocDesc<D3D12_RAYTRACING_PIPELINE_CONFIG>();
        desc.MaxTraceRecursionDepth = maxTraceRecursionDepth;
    
        D3D12_STATE_SUBOBJECT& subobject = m_subobjects.emplace_back();
        subobject.Type = D3D12_STATE_SUBOBJECT_TYPE_RAYTRACING_PIPELINE_CONFIG;
        subobject.pDesc = &desc;
    }
    
    • MaxTraceRecursionDepth : 최대 광선 추적 재귀 횟수를 지정합니다.

    State Object 생성

    D3D12_STATE_SUBOBJECT 를 모두 채우고 나면 스테이트 오브젝트를 생성할 수 있습니다. 먼저 다음과 같이 D3D12_STATE_OBJECT_DESC 를 초기화 합니다.

    D3D12_STATE_OBJECT_DESC D3D12StateSubobjects::GetSubobjectDesc()
    {
    	D3D12_STATE_OBJECT_DESC subobjectDesc = {
    		.Type = D3D12_STATE_OBJECT_TYPE_RAYTRACING_PIPELINE,
    		.NumSubobjects = static_cast<uint32>( m_subobjects.size() ),
    		.pSubobjects = m_subobjects.data(),
    	};
    
    	return subobjectDesc;
    }
    
    • Type : 스테이트 오브젝트의 종류를 지정합니다. 레이 트레이싱 파이프라인의 경우 D3D12_STATE_OBJECT_TYPE_RAYTRACING_PIPELINE 를 사용하면 됩니다.
    • NumSubobjects : D3D12_STATE_SUBOBJECT 배열을 크기입니다.
    • pSubobjects : D3D12_STATE_SUBOBJECT 배열의 시작 주소입니다.

    그리고 ID3D12Device5::CreateStateObject 함수를 호출하여 스테이트 오브젝트를 생성합니다.

    D3D12_STATE_OBJECT_DESC subobjectDesc = stateSubObjects.GetSubobjectDesc();
    
    HRESULT hr = D3D12Device().CreateStateObject( &subobjectDesc, IID_PPV_ARGS( m_stateObject.GetAddressOf() ) );
    assert( SUCCEEDED( hr ) );
    

    Shader Table 생성

    스테이트 오브젝트를 생성했으므로 셰이더 테이블의 레코드를 채울 수 있습니다. 셰이더 테이블은 GPU에서 직접 참조되기 때문에 TLAS의 인스턴스 버퍼와 마찬가지로 별도의 버퍼를 생성해야 합니다.

    void D3D12RaytracingShaderTable::InitResource()
    {
        m_rootSignature->Init();
    
        auto shaderRecordCount = static_cast<uint32>( 1/*RayGen*/ + m_hitGroups.size() + m_misses.size() );
    
        BufferDesc desc = {
            .m_stride = ShaderRecordSize,
            .m_count = shaderRecordCount,
            .m_access = ResourceAccess::Upload,
            .m_bindType = ResourceBindType::None,
            .m_miscFlag = ResourceMisc::None,
            .m_format = ResourceFormat::Unknown
        };
    
        m_shaderRecords = RefStaticCast<D3D12Buffer>( Buffer::Create( desc, "ShaderRecords", ResourceState::NonPixelShaderResource ) );
    }
    

    위 코드는 하나의 Ray Generation 셰이더와 여러 개의 히트 그룹 그리고 Miss 셰이더들로 구성된 셰이더 테이블을 위한 버퍼를 생성하는 예시입니다.

    여기서 ShaderRecordSize는 다음과 같이 미리 계산된 상수입니다.

    static const uint32 ShaderRecordSize = CalcAlignment( D3D12_SHADER_IDENTIFIER_SIZE_IN_BYTES, D3D12_RAYTRACING_SHADER_TABLE_BYTE_ALIGNMENT );
    

    이번 구현에서는 로컬 루트 시그니처를 사용하지 않으므로 Shader Identifier(이하 셰이더 식별자)만 기록하면 됩니다. 따라서 셰이더 레코드의 기본 크기는 D3D12_SHADER_IDENTIFIER_SIZE_IN_BYTES가 됩니다. 또한 셰이더 테이블은 D3D12_RAYTRACING_SHADER_TABLE_BYTE_ALIGNMENT 단위로 정렬된 GPU 가상 주소를 가져야 하므로 이를 만족하도록 정렬된 레코드 크기를 계산해 사용합니다.

    다음으로 스테이트 오브젝트에서 셰이더의 식별자를 가져오기 위하여 ID3D12StateObjectProperties 인스턴스를 다음과 같은 방법으로 얻도록 합니다.

    Microsoft::WRL::ComPtr<ID3D12StateObjectProperties> stateObjectProps;
    m_stateObject->QueryInterface( IID_PPV_ARGS( stateObjectProps.GetAddressOf() ) );
    

    ID3D12StateObjectProperties 는 스테이트 오브젝트의 속성을 가져오고 설정하는 함수들을 제공하는 인터페이스로 ID3D12StateObjectProperties::GetShaderIdentifier 함수를 통해 셰이더의 식별자를 얻을 수 있습니다.

    void D3D12RaytracingShaderTable::WriteShaderRecords( ID3D12StateObjectProperties& properties ) const
    {
    	LockedResource lockedResource = m_shaderRecords->Lock( 0, ResourceLockFlag::WriteDiscard );
    	assert( lockedResource.m_data );
    
    	auto lockedData = static_cast<uint8*>( lockedResource.m_data );
    
    	{
    		void* identifier = properties.GetShaderIdentifier( m_rayGeneration->GetExportName() );
    		std::memcpy( lockedData, identifier, D3D12_SHADER_IDENTIFIER_SIZE_IN_BYTES );
    	}
    
    	for ( const auto& hitGroup : m_hitGroups )
    	{
    		lockedData += ShaderRecordSize;
    
    		void* identifier = properties.GetShaderIdentifier( hitGroup->GetExportName() );
    		std::memcpy( lockedData, identifier, D3D12_SHADER_IDENTIFIER_SIZE_IN_BYTES );
    	}
    
    	for ( const auto& miss : m_misses )
    	{
    		lockedData += ShaderRecordSize;
    
    		void* identifier = properties.GetShaderIdentifier( miss->GetExportName() );
    		std::memcpy( lockedData, identifier, D3D12_SHADER_IDENTIFIER_SIZE_IN_BYTES );
    	}
    
    	m_shaderRecords->UnLock();
    }
    

    GetShaderIdentifier 의 매개 변수로는 이전에 스테이트 오브젝트에 DXIL과 히트 그룹을 설정할 때 사용한 내보내기 이름을 사용해야 하는 점 참고하시기 바랍니다.

    Dispatch Ray

    레이 트레이싱을 위한 준비 과정이 모두 끝났습니다. 이제 남은 것은 광선을 발사하는 일뿐입니다.

    광선 발사에는 ID3D12GraphicsCommandList4::DispatchRays 함수가 사용됩니다. 해당 함수는 D3D12_DISPATCH_RAYS_DESC 구조체를 인자로 받는데 다음과 같은 멤버 변수로 이뤄져 있습니다.

    typedef struct D3D12_DISPATCH_RAYS_DESC
        {
        D3D12_GPU_VIRTUAL_ADDRESS_RANGE RayGenerationShaderRecord;
        D3D12_GPU_VIRTUAL_ADDRESS_RANGE_AND_STRIDE MissShaderTable;
        D3D12_GPU_VIRTUAL_ADDRESS_RANGE_AND_STRIDE HitGroupTable;
        D3D12_GPU_VIRTUAL_ADDRESS_RANGE_AND_STRIDE CallableShaderTable;
        UINT Width;
        UINT Height;
        UINT Depth;
        }   D3D12_DISPATCH_RAYS_DESC;
    
    • RayGenerationShaderRecord : 셰이더 테이블에 기록된 Ray Generation 셰이더 레코드의 GPU 시작 주소를 설정합니다.
    • MissShaderTable : 셰이더 테이블에 기록된 Miss 셰이더 레코드의 GPU 시작 주소, 전체 레코드의 크기, 요소의 크기를 설정합니다.
    • HitGroupTable : 셰이더 테이블에 기록된 히트 그룹 레코드의 GPU 시작 주소, 전체 레코드의 크기, 요소의 크기를 설정합니다.
    • CallableShaderTable : 셰이더 테이블에 기록된 Callable 셰이더 레코드의 GPU 시작 주소, 전체 레코드의 크기, 요소의 크기를 설정합니다.
    • Width : 생성 스레드의 넓이입니다.
    • Height : 생성 스레드의 높이입니다.
    • Depth : 생성 스레드의 깊이입니다.

    D3D12_DISPATCH_RAYS_DESC 를 초기화하는 전체 코드는 다음과 같습니다.

    D3D12_DISPATCH_RAYS_DESC D3D12RaytracingShaderTable::GetDispatchRaysDesc( uint32 width, uint32 height, uint32 depth ) const
    {
    	D3D12_GPU_VIRTUAL_ADDRESS shaderRecordAddress = m_shaderRecords->Resource()->GetGPUVirtualAddress();
    	D3D12_GPU_VIRTUAL_ADDRESS hitShaderRecordStart = shaderRecordAddress + ShaderRecordSize;
    	D3D12_GPU_VIRTUAL_ADDRESS missShaderRecordStart = hitShaderRecordStart + ShaderRecordSize * m_hitGroups.size();
    
    	return D3D12_DISPATCH_RAYS_DESC{
    		.RayGenerationShaderRecord = {
    			.StartAddress = shaderRecordAddress,
    			.SizeInBytes = ShaderRecordSize
    		},
    		.MissShaderTable = {
    			.StartAddress = missShaderRecordStart,
    			.SizeInBytes = ShaderRecordSize * m_misses.size(),
    			.StrideInBytes = ShaderRecordSize,
    		},
    		.HitGroupTable = {
    			.StartAddress = hitShaderRecordStart,
    			.SizeInBytes = ShaderRecordSize * m_hitGroups.size(),
    			.StrideInBytes = ShaderRecordSize,
    		},
    		.CallableShaderTable = {},
    		.Width = width,
    		.Height = height,
    		.Depth = depth,
    	};
    }
    

    마지막으로 DispatchRays 를 호출하는 전체 과정을 살펴보겠습니다. 우선 스테이트 오브젝트의 설정과 글로벌 루트 시그니쳐를 설정해야 합니다.

    commandList.SetPipelineState1( stateObject );
    commandList.SetComputeRootSignature( rootSignature );
    

    스테이트 오브젝트의 설정에는 ID3D12GraphicsCommandList4::SetPipelineState1 함수가 사용됩니다. 글로벌 루트 시그니쳐의 설정은 컴퓨트 셰이더 파이프라인의 루트 시그니쳐 설정 함수인 SetComputeRootSignature 가 그대로 사용됩니다.

    이후 리소스를 바인딩하는 과정은 컴퓨트 셰이더 파이프라인과 동일합니다. 모든 준비가 끝나면 다음과 같이 DispatchRays 함수를 호출합니다.

    D3D12_DISPATCH_RAYS_DESC desc = d3d12RaytracingPipelineState->GetDispatchRaysDesc( width, height, depth );
    CommandList().DispatchRays( &desc );
    

    RTAO

    DXR을 사용하기 위한 모든 준비가 끝났으니 이를 이용하여 새로운 렌더링 기능을 구현해 보겠습니다. RTAO는 이름 그대로 레이 트레이싱 기반으로 주변광 차폐를 표현하는 기법입니다.

    주변광 차폐는 물체의 구석이나 틈새, 서로 맞닿은 면처럼 주변광이 도달하기 어려운 영역이 더 어둡게 보이는 현상을 의미합니다. 게임에서는 이러한 효과를 주로 화면 공간 기반인 Screen Space Ambient Occlusion (이하 SSAO) 을 통해 구현합니다.

    SSAO는 화면에 보이는 픽셀을 기준으로 실시간 계산하여 비교적 효율적이지만 화면 밖의 사물을 계산하지 못하는 단점이 있습니다. 반면 RTAO는 TLAS로 구성된 전체 장면을 기반으로 광선을 추적하기 때문에 화면 밖에 존재하는 사물에 의한 주변광 차폐까지 표현할 수 있는 장점을 가지고 있습니다.

    예시 프로그램의 RTAO의 구현에는 Ray Generation, Closest Hit, Miss의 총 3가지 셰이더가 사용되었습니다. 지금부터 각 셰이더의 구현을 차례대로 살펴보면서 어떤 방식으로 코드를 작성해야 하는지 알아 보겠습니다.

    먼저 가장 간단한 셰이더인 Miss 셰이더부터 코드를 보겠습니다.

    [shader("miss")]
    void Miss( inout PayLoad payLoad )
    {
        payLoad.hit = false;
    }
    

    셰이더 라이브러리의 셰이더는 [shader(”shadertype”)] 속성을 통해 셰이더를 식별합니다. 여기서는 Miss 셰이더를 의미하는 miss 타입을 사용하고 있습니다.

    본문은 페이로드의 hit 멤버 변수를 false로 하는 간단한 코드로 작성되어 있습니다.

    struct [raypayload] PayLoad
    {
        bool hit : read( caller ) : write( closesthit, miss );
    };
    

    [raypayload] 속성을 통해 페이로드 구조체를 선언합니다. 또한 멤버를 선언할 때는 Payload Access Qualifiers 를 이용하여 페이로드의 읽기 / 쓰기 범위를 지정해야 합니다. 페이로드를 읽는 셰이더 단계는 read에, 페이로드를 쓰는 셰이더 단계는 write에 기재합니다. Miss 셰이더는 hit 변수에 값을 쓰고 있으므로 write에 적혀있는 것을 확인할 수 있습니다.

    다음은 Closest Hit 셰이더 입니다.

    [shader("closesthit")]
    void ClosestHit( inout PayLoad payLoad, in BuiltInTriangleIntersectionAttributes attr )
    {
        payLoad.hit = true;
    }
    

    삼각형의 무게좌표가 담긴 BuiltInTriangleIntersectionAttributes 를 매개변수로 하는 셰이더 함수의 시그니쳐를 확인할 수 있습니다.

    주변광 차폐는 광선의 차폐 여부만이 중요하기 때문에 광선 충돌에 연관된 Closest Hit와 Miss 셰이더에서는 별다른 작업을 수행하지 않습니다. 따라서 대부분의 로직은 광선을 생성하는 Ray Generation 셰이더에 집중되어 있습니다.

    [shader("raygeneration")]
    void RayGen()
    {
        float2 jitter = HALTON_SEQUENCE[FrameCount % MAX_HALTON_SEQUENCE].xy;
        float2 uv = ( DispatchRaysIndex().xy + jitter ) / DispatchRaysDimensions().xy;
    
        float3 packedNormal = WorldNormal.SampleLevel( BlackBorderSampler, uv, 0 ).yzw;
        float3 worldNormal = SignedOctDecode( packedNormal );
        float3x3 tbn = CreateTBN( worldNormal );
    
        float viewSpaceDistance = ViewSpaceDistance.SampleLevel( BlackBorderSampler, uv, 0 ).x;
        if ( viewSpaceDistance <= 0.f )
        {
            return;
        }
    
        float3 worldPosition = GetWorldPosition( uv, viewSpaceDistance );
    
        float ao = 0.f;
    
        [loop]
        for ( int i = 0; i < SampleCount; ++i )
        {
            RayDesc ray;
            ray.Origin = worldPosition;
            ray.TMin = 0.001f;
            ray.Direction = SampleAODirection( uv, i, FrameCount, tbn );
            ray.TMax = AORadius;
    
            PayLoad payload;
            payload.hit = false;
            TraceRay( AccelerationStructure, RAY_FLAG_CULL_BACK_FACING_TRIANGLES, 0xFF, 0, 0, 0, ray, payload );
    
            ao += payload.hit ? 0.f : 1.f;
        }
    
        ao /= SampleCount;
        ao = ao / ( ao + (1.0f - ao) * AOIntensity );
    
        AmbientOcclusion[DispatchRaysIndex().xy] = ao.xxxx;
    }
    

    물체의 표면에서 광선을 생성하기 위해서 현재 프레임에 렌더링한 화면 공간 법선 벡터 텍스쳐와 위치 텍스쳐를 사용합니다. 이를 위해 먼저 다음과 같이 화면에 대한 UV 좌표를 계산합니다.

    float2 jitter = HALTON_SEQUENCE[FrameCount % MAX_HALTON_SEQUENCE].xy;
    float2 uv = ( DispatchRaysIndex().xy + jitter ) / DispatchRaysDimensions().xy;
    

    여기서 DXR 셰이더의 내장 함수를 사용합니다.

    • DispatchRaysIndex : DispatchRaysDimensions 시스템 값을 기준으로, 현재 실행 중인 광선의 위치(너비, 높이, 깊이 인덱스)를 반환합니다.
    • DispatchRaysDimensions : DispatchRays 호출 시 D3D12_DISPATCH_RAYS_DESC 구조체를 통해 지정한 전체 너비, 높이, 깊이 값을 반환합니다.

    한편 RTAO를 단일 프레임 기준으로 수행하면 노이즈가 심한 AO 텍스쳐가 생성됩니다. 이를 완화하기 위한 시간 필터 (Temporal Filter)를 적용하기 위해 할톤 시퀀스를 통해 미리 생성한 저불일치 오프셋을 추가하였습니다.

    UV 좌표를 이용하여 화면 공간 텍스쳐들을 샘플링합니다.

    float3 packedNormal = WorldNormal.SampleLevel( BlackBorderSampler, uv, 0 ).yzw;
    float3 worldNormal = SignedOctDecode( packedNormal );
    float3x3 tbn = CreateTBN( worldNormal );
    
    float viewSpaceDistance = ViewSpaceDistance.SampleLevel( BlackBorderSampler, uv, 0 ).x;
    if ( viewSpaceDistance <= 0.f )
    {
    	return;
    }
    
    float3 worldPosition = GetWorldPosition( uv, viewSpaceDistance );
    

    법선 벡터를 기준으로 한 반구를 샘플링하기 위해 TBN(Tangent, Bitangent, Normal) 행렬을 미리 계산해 두면 주변광 차폐 계산할 준비가 완료됩니다.

    광선을 생성하는 TraceRay 함수를 호출하기 위해서는 RayDesc 내장 구조체를 멤버 변수를 초기화해야 합니다. 구조체의 멤버는 다음과 같이 이뤄져 있습니다.

    struct RayDesc
    {
        float3 Origin;
        float  TMin;
        float3 Direction;
        float  TMax;
    };
    
    • Origin : 광선의 시작 위치
    • TMin : 광선의 최소 범위
    • Direction : 광선의 방향
    • TMax : 광선의 최대 범위

    샘플 코드를 다시 살펴보면 다음과 같이 RayDesc를 초기화하는 것을 확인할 수 있습니다.

    RayDesc ray;
    ray.Origin = worldPosition;
    ray.TMin = 0.001f;
    ray.Direction = SampleAODirection( uv, i, FrameCount, tbn );
    ray.TMax = AORadius;
    

    자기 충돌을 피하기 위해 광선의 최소 범위 값을 0.001f로 설정하는 점이 중요합니다. 또한 광선의 방향은 TBN 행렬을 이용해 코사인 가중 반구 샘플링(cosine-weighted hemisphere sampling)을 수행함으로써 물체 표면의 법선을 기준으로 한 방향 벡터를 계산하였습니다.

    PayLoad를 초기화하고 TraceRay 함수를 호출하면 광선이 생성되고 사물과의 충돌 결과에 따라 Closest Hit, Miss 셰이더가 호출됩니다.

    PayLoad payload;
    payload.hit = false;
    TraceRay( AccelerationStructure, RAY_FLAG_CULL_BACK_FACING_TRIANGLES, 0xFF, 0, 0, 0, ray, payload );
    

    TraceRay 함수의 시그니쳐는 다음과 같습니다.

    Template<payload_t>
    void TraceRay(RaytracingAccelerationStructure AccelerationStructure,
                  uint RayFlags,
                  uint InstanceInclusionMask,
                  uint RayContributionToHitGroupIndex,
                  uint MultiplierForGeometryContributionToHitGroupIndex,
                  uint MissShaderIndex,
                  RayDesc Ray,
                  inout payload_t Payload);
    
    • AccelerationStructure : 광선 추적에 사용될 TLAS입니다.
    • RayFlags : 광선에 적용할 플래그 값입니다. 여기서는 백페이스 컬링된 삼각형 충돌을 수행하기 위하여 RAY_FLAG_CULL_BACK_FACING_TRIANGLES 를 사용하였습니다.
    • InstanceInclusionMask : 광선 추적에 포함될 인스턴스를 필터링하기 위한 마스크입니다. TLAS 생성시 D3D12_RAYTRACING_INSTANCE_DESC 에 지정한 InstanceMask와 연관되어 있습니다.
    • RayContributionToHitGroupIndex : 히트 그룹 선택시 인덱스에 더해질 값입니다. 예제 프로그램의 RTAO 구현에서는 하나의 히트 그룹만 사용하므로 0으로 사용합니다.
    • MultiplierForGeometryContributionToHitGroupIndex : BLAS 내부의 각 기하 구조가 어떤 히트 그룹을 사용할지 계산할 때 쓰이는 가중치 값입니다. 예제 프로그램의 경우 BLAS를 하나의 기하 구조로 구성했기 때문에 0으로 사용합니다.
    • MissShaderIndex : 광선이 충돌하지 않았을 경우 호출될 Miss 셰이더를 지정하기 위한 인덱스 값입니다. 예제 프로그램의 RTAO 구현은 하나의 Miss 셰이더를 사용하고 있기 때문에 0으로 사용합니다.
    • Ray : 추적할 광선에 대한 RayDesc입니다.
    • Payload : 셰이더 간 값 교환에 사용될 페이로드입니다.

    광선 생성 이후 충돌 여부에 따라 각 셰이더가 호출되고 그 결과는 페이로드에 저장된 상태로 TraceRay 함수가 반환됩니다. 이 결과를 모아 샘플링 횟수로 나누어 정규화한 뒤 텍스쳐에 기록하면 RTAO 계산이 완료됩니다.

    ao += payload.hit ? 0.f : 1.f;
    
    // 반복문 종료 후
    
    ao /= SampleCount;
    ao = ao / ( ao + (1.0f - ao) * AOIntensity );
    
    AmbientOcclusion[DispatchRaysIndex().xy] = ao.xxxx;
    

    앞서 언급한대로 RTAO의 결과 다음과 같이 노이즈가 심한 텍스쳐를 얻게 됩니다.

    따라서 노이즈를 줄이기 위한 디노이징(Denoising) 단계가 필요합니다. 디노이징에서는 Screen Space Global Illumination 에서 다루었던 쌍방필터 + 시간 필터 기반의 디노이저를 그대로 사용하였습니다. 구현이 궁금하시다면 해당 문서를 참고 부탁드립니다. 디노이징한 RTAO의 결과 텍스쳐는 다음과 같습니다.

    이후 조명 적용 과정에서 주변광 차폐 텍스쳐를 샘플링해 결과에 곱해주면 RTAO의 적용이 완료됩니다.

    float ao = AmbientOcclusion.SampleLevel( LinearSampler, geometry.screenUV, 0 );
    cColor.m_diffuse.rgb *= ao;
    cColor.m_specular.rgb *= ao;
    

    마치며

    준비한 내용은 여기까지입니다. 마지막으로 예제 프로그램의 Github 링크를 첨부합니다. 세부 코드를 확인하고 싶으신 분은 참고 바랍니다.

    GitHub - xtozero/SSR at DXR

    연관된 파일은 다음과 같습니다.

    • CPP
      • Source\RenderCore\Private\RenderFeature\RTAORendering.cpp : RTAO 렌더링 패스
    • Shader
      • Source\Shaders\Private\Raytracing\RTAO.fx : RTAO 셰이더

    Reference

    '개인 프로젝트' 카테고리의 다른 글

    Visibility Buffer Rendering Appendix  (0) 2026.03.29
    Visibility Buffer Rendering  (0) 2026.02.08
    GPU Prefix Sum  (0) 2025.11.24
    Render Graph  (0) 2025.10.17
    Async Compute  (0) 2025.10.06
Designed by Tistory.