Skip to content

Hybrid Rasterization and Ray Tracing

hybrid_raster_rt.png

This advanced example shows hybrid rendering that leverages both rasterization (for primary visibility and alpha blending) and ray tracing (for shadows).

  • The scene contains opaque objects, alpha-blended spheres, and a ground plane.
  • Rasterization fills a G-buffer with world positions, normals, and colors.
  • An OIT (order-independent transparency) fragment linked list handles alpha blending.
  • Ray tracing then shoots shadow rays from G-buffer positions to determine lighting.

This architecture represents modern game rendering where rasterization handles base rendering and ray tracing adds realistic lighting and shadows.

Overview

What this demonstrates:

Combining rasterization and ray tracing, G-buffer generation, OIT for alpha blending, ray traced shadows, acceleration structure rebuilds, compute particle updates, multi-pass compositing.

Use cases:

Modern game engines, hybrid pipelines, ray traced shadows/reflections on rasterized geometry, performance-quality balance.

Requirements

  • Vulkan Version: 1.2+
  • Extensions: VK_KHR_ray_tracing_pipeline, VK_KHR_acceleration_structure
  • Features: rayTracingPipeline, accelerationStructure, bufferDeviceAddress

Why Hybrid?

Rasterization: Fast primary visibility Ray Tracing: Accurate shadows/reflections Combine: Performance + Quality

The scene is composed of:

  • An Opaque Ground Plane
  • Multiple Alpha Blended Spheres
  • Multiple Opaque Spheres

Algorithm Overview

  1. Compute Animation: The sphere's positions and velocities are updated using a compute shader.
  2. GBuffer Z-Fill: A depth-only pre-pass is performed for opaque meshes.
  3. GBuffer OIT Fill: For alpha-blended meshes, we use a fragment linked list to store color and depth information.
  4. GBuffer Opaque Fill: Record WorldPosition, WorldNormals, and Color for the opaque meshes.
  5. Acceleration Structure Rebuild: AccelerationStructures are updated or rebuilt based on the new sphere positions.
  6. Ray Tracing Shadows: For each world position recorded in the GBuffer, we compute a ray to the light source. Any intersection means the point is in shadow.
  7. Compositing:
    • Final color for opaque areas is retrieved from the GBuffer.
    • Alpha fragments are sorted by depth and blended against the opaque background.
    • Shadow information from the Ray Tracing pass is applied to modify the final color.

BLAS Generation

Each distinct mesh (opaque spheres, alpha spheres, ground plane) gets its own Bottom-Level Acceleration Structure (BLAS).

Sphere BLAS (opaque and alpha):

The same sphere mesh is shared across all sphere instances.

One BLAS entry per sphere is created:

  • each referencing the shared vertex buffer but with a per-sphere transform read from blasTransformBuffer (written each frame by the compute shader).

PreferFastBuild is chosen over PreferFastTrace because the spheres move every frame.

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
        m_as.opaqueSpheresBlas = m_device.createAccelerationStructure(AccelerationStructureOptions{
                .label = "OpaqueSphereBLAS",
                .type = AccelerationStructureType::BottomLevel,
                .flags = AccelerationStructureFlagBits::PreferFastBuild,
                .geometryTypesAndCount = std::vector<AccelerationStructureOptions::GeometryTypeAndCount>(
                        OpaqueSpheresCount,
                        {
                                .geometry = AccelerationStructureGeometryTrianglesData{
                                        .vertexFormat = Format::R32G32B32_SFLOAT,
                                        .vertexStride = sizeof(Vertex),
                                        .maxVertex = static_cast<uint32_t>(m_sphereMesh.vertexCount - 1),
                                },
                                .maxPrimitiveCount = static_cast<uint32_t>(m_sphereMesh.vertexCount / 3),
                        }),
        });
        m_as.alphaSpheresBlas = m_device.createAccelerationStructure(AccelerationStructureOptions{
                .label = "AlphaSphereBLAS",
                .type = AccelerationStructureType::BottomLevel,
                .flags = AccelerationStructureFlagBits::PreferFastBuild,
                .geometryTypesAndCount = std::vector<AccelerationStructureOptions::GeometryTypeAndCount>(
                        AlphaSpheresCount,
                        {
                                .geometry = AccelerationStructureGeometryTrianglesData{
                                        .vertexFormat = Format::R32G32B32_SFLOAT,
                                        .vertexStride = sizeof(Vertex),
                                        .maxVertex = static_cast<uint32_t>(m_sphereMesh.vertexCount - 1),
                                },
                                .maxPrimitiveCount = static_cast<uint32_t>(m_sphereMesh.vertexCount / 3),
                        }),
        });

        auto buildSphereTriangleGeometries = [this](Handle<AccelerationStructure_t> dstStructure,
                                                    size_t count, size_t transformOffset = 0) {
            std::vector<AccelerationStructureGeometry> geometries;
            geometries.reserve(count);

            for (size_t i = 0; i < count; ++i) {
                geometries.emplace_back(
                        AccelerationStructureGeometryTrianglesData{
                                .vertexFormat = Format::R32G32B32_SFLOAT,
                                .vertexData = m_sphereMesh.vertexBuffer,
                                .vertexStride = sizeof(Vertex),
                                .maxVertex = static_cast<uint32_t>(m_sphereMesh.vertexCount - 1),
                                .transformData = m_particles.blasTransformBuffer,
                                .transformDataOffset = transformOffset + i * sizeof(VkTransformMatrixKHR),
                        });
            }
            return BuildAccelerationStructureOptions::BuildOptions{
                .geometries = std::move(geometries),
                .destinationStructure = dstStructure,
                .buildRangeInfos = std::vector<BuildAccelerationStructureOptions::BuildRangeInfo>(
                        count,
                        {
                                .primitiveCount = static_cast<uint32_t>(m_sphereMesh.vertexCount / 3),
                        }),
            };
        };

        m_as.opaqueSpheresASBuildOptions = {
            .buildGeometryInfos = { buildSphereTriangleGeometries(m_as.opaqueSpheresBlas, OpaqueSpheresCount, 0) },
        };

        m_as.alphaSpheresASBuildOptions = {
            .buildGeometryInfos = { buildSphereTriangleGeometries(m_as.alphaSpheresBlas, AlphaSpheresCount, OpaqueSpheresCount * sizeof(VkTransformMatrixKHR)) },
        };

Filename: hybrid_raster_rt/hybrid_raster_rt.cpp

Ground plane BLAS:

The plane is static, so its BLAS is built only once (on the first frame).

 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
31
32
33
34
        m_as.opaquePlaneBlas = m_device.createAccelerationStructure(AccelerationStructureOptions{
                .label = "PlaneBLAS",
                .type = AccelerationStructureType::BottomLevel,
                .flags = AccelerationStructureFlagBits::PreferFastBuild,
                .geometryTypesAndCount = {
                        {
                                .geometry = AccelerationStructureGeometryTrianglesData{
                                        .vertexFormat = Format::R32G32B32_SFLOAT,
                                        .vertexStride = sizeof(Vertex),
                                        .maxVertex = static_cast<uint32_t>(m_planeMesh.vertexCount - 1),
                                },
                                .maxPrimitiveCount = static_cast<uint32_t>(m_planeMesh.vertexCount / 3),
                        },
                },
        });

        m_as.opaquePlaneASBuildOptions = {
            .buildGeometryInfos = {
                    {
                            .geometries = {
                                    AccelerationStructureGeometryTrianglesData{
                                            .vertexFormat = Format::R32G32B32_SFLOAT,
                                            .vertexData = m_planeMesh.vertexBuffer,
                                            .vertexStride = sizeof(Vertex),
                                            .maxVertex = static_cast<uint32_t>(m_planeMesh.vertexCount - 1),
                                    },
                            },
                            .destinationStructure = m_as.opaquePlaneBlas,
                            .buildRangeInfos = {
                                    { .primitiveCount = static_cast<uint32_t>(m_planeMesh.vertexCount / 3) },
                            },
                    },
            },
        };

Filename: hybrid_raster_rt/hybrid_raster_rt.cpp

TLAS:

  • The Top-Level Acceleration Structure references all three BLAS as instances.
  • The two sphere BLAS use TriangleFacingCullDisable so both sides of every sphere triangle are visible.
  • The opaque spheres carry ForceOpaque (skips any-hit) while the alpha spheres carry ForceNoOpaque (enables any-hit for transparency):
 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
31
32
33
34
35
36
37
38
39
40
        m_as.tBlas = m_device.createAccelerationStructure(AccelerationStructureOptions{
                .label = "TBLAS",
                .type = AccelerationStructureType::TopLevel,
                .flags = AccelerationStructureFlagBits::PreferFastBuild,
                .geometryTypesAndCount = {
                        {
                                .geometry = AccelerationStructureGeometryInstancesData{},
                                .maxPrimitiveCount = 3, // 3 BLAS
                        },
                },
        });

        m_as.tlASBuildOptions = {
            .buildGeometryInfos = {
                    {
                            .geometries = {
                                    AccelerationStructureGeometryInstancesData{
                                            .data = {
                                                    AccelerationStructureGeometryInstance{
                                                            .flags = GeometryInstanceFlagBits::TriangleFacingCullDisable | GeometryInstanceFlagBits::ForceOpaque,
                                                            .accelerationStructure = m_as.opaqueSpheresBlas,
                                                    },
                                                    AccelerationStructureGeometryInstance{
                                                            .flags = GeometryInstanceFlagBits::TriangleFacingCullDisable | GeometryInstanceFlagBits::ForceNoOpaque,
                                                            .accelerationStructure = m_as.alphaSpheresBlas,
                                                    },
                                                    AccelerationStructureGeometryInstance{
                                                            .flags = GeometryInstanceFlagBits::TriangleFacingCullDisable | GeometryInstanceFlagBits::ForceOpaque,
                                                            .accelerationStructure = m_as.opaquePlaneBlas,
                                                    },
                                            },
                                    },
                            },
                            .destinationStructure = m_as.tBlas,
                            .buildRangeInfos = {
                                    { .primitiveCount = 3 }, // 3 BLAS
                            },
                    },
            },
        };

Filename: hybrid_raster_rt/hybrid_raster_rt.cpp

Shadow Ray Tracing Pipeline

The shadow pass uses four shaders and a two-hit-group SBT.

Two separate hit shaders handle the two BLAS types differently:

  • opaque geometry uses the closest-hit shader
  • alpha geometry uses the any-hit shader to accumulate a hit counter rather than short-circuit on the first intersection:
 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
    // Create raytracing shaders
    auto rayTracingGenShaderPath = KDGpuExample::assetDir().file("shaders/examples/hybrid_raster_rt/shadow.rgen.spv");
    auto rayTracingMissShaderPath = KDGpuExample::assetDir().file("shaders/examples/hybrid_raster_rt/shadow.rmiss.spv");
    auto rayTracingAnyHitShaderPath = KDGpuExample::assetDir().file("shaders/examples/hybrid_raster_rt/shadow.rahit.spv");
    auto rayTracingClosestHitShaderPath = KDGpuExample::assetDir().file("shaders/examples/hybrid_raster_rt/shadow.rchit.spv");

    auto rayTracingGenShader = m_device.createShaderModule(readShaderFile(rayTracingGenShaderPath));
    auto rayTracingMissShader = m_device.createShaderModule(readShaderFile(rayTracingMissShaderPath));
    auto rayTracingAnyHitShader = m_device.createShaderModule(readShaderFile(rayTracingAnyHitShaderPath));
    auto rayTracingClosestHitShader = m_device.createShaderModule(readShaderFile(rayTracingClosestHitShaderPath));

    // Create a pipeline layout (array of bind group layouts)
    m_shadowPass.pipelineLayout = m_device.createPipelineLayout({
            .label = "RTShadows",
            .bindGroupLayouts = { m_gbuffer.opaqueNormalDepthBindGroupLayout, m_gbuffer.shadowBindGroupLayout, m_as.tsASBindGroupLayout },
            .pushConstantRanges = { m_global.lightPosPushConstant },
    });

    // Create a raytracing pipeline
    m_shadowPass.pipeline = m_device.createRayTracingPipeline({
            .shaderStages = {
                    ShaderStage{
                            .shaderModule = rayTracingGenShader.handle(),
                            .stage = ShaderStageFlagBits::RaygenBit,
                    },
                    ShaderStage{
                            .shaderModule = rayTracingMissShader.handle(),
                            .stage = ShaderStageFlagBits::MissBit,
                    },
                    ShaderStage{
                            .shaderModule = rayTracingAnyHitShader.handle(),
                            .stage = ShaderStageFlagBits::AnyHitBit, // For Alpha BLAS
                    },
                    ShaderStage{
                            .shaderModule = rayTracingClosestHitShader.handle(),
                            .stage = ShaderStageFlagBits::ClosestHitBit, // For Opaque BLAS
                    },
            },
            .shaderGroups = {
                    // Gen
                    RayTracingShaderGroupOptions{
                            .type = RayTracingShaderGroupType::General,
                            .generalShaderIndex = 0,
                    },
                    // Miss
                    RayTracingShaderGroupOptions{
                            .type = RayTracingShaderGroupType::General,
                            .generalShaderIndex = 1,
                    },
                    // Hit
                    RayTracingShaderGroupOptions{
                            .type = RayTracingShaderGroupType::TrianglesHit,
                            .closestHitShaderIndex = 3,
                            .anyHitShaderIndex = 2,
                    },
            },
            .layout = m_shadowPass.pipelineLayout,
    });

    // Shader Binding Table
    m_shadowPass.sbt = RayTracingShaderBindingTable(&m_device, RayTracingShaderBindingTableOptions{
                                                                       .nbrMissShaders = 1,
                                                                       .nbrHitShaders = 1,
                                                               });

    m_shadowPass.sbt.addRayGenShaderGroup(m_shadowPass.pipeline, 0);
    m_shadowPass.sbt.addMissShaderGroup(m_shadowPass.pipeline, 1);
    m_shadowPass.sbt.addHitShaderGroup(m_shadowPass.pipeline, 2);

Filename: hybrid_raster_rt/hybrid_raster_rt.cpp

Per-Frame Render Passes

Compute Animation

The sphere's positions and velocities are updated using a compute shader.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
            commandRecorder.beginDebugLabel(DebugLabelOptions{
                    .label = "Compute - Particles Update",
                    .color = { 0.0f, 1.0f, 0.0f, 1.0f },
            });

            auto computePass = commandRecorder.beginComputePass();
            computePass.setPipeline(m_particles.computePipeline);
            computePass.setBindGroup(0, m_particles.particleBindGroup);
            constexpr size_t LocalWorkGroupXSize = 256;
            computePass.dispatchCompute(ComputeCommand{ .workGroupX = ParticlesCount / LocalWorkGroupXSize + 1 });
            computePass.end();

            commandRecorder.endDebugLabel();

Filename: hybrid_raster_rt/hybrid_raster_rt.cpp

Acceleration Structure Rebuild

After the compute pass writes new per-sphere transforms into blasTransformBuffer, a buffer barrier ensures the AS build sees the updated data.

Dynamic BLAS are rebuilt every frame; the static plane BLAS is only built once:

 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
31
32
33
34
35
36
37
            commandRecorder.beginDebugLabel(DebugLabelOptions{
                    .label = "Acceleration Structures Rebuild",
                    .color = { 1.0f, 0.0f, 0.0f, 1.0f },
            });

            commandRecorder.bufferMemoryBarrier(KDGpu::BufferMemoryBarrierOptions{
                    .srcStages = KDGpu::PipelineStageFlagBit::ComputeShaderBit,
                    .srcMask = KDGpu::AccessFlagBit::ShaderWriteBit,
                    .dstStages = KDGpu::PipelineStageFlagBit::AccelerationStructureBuildBit,
                    .dstMask = KDGpu::AccessFlagBit::AccelerationStructureReadBit,
                    .buffer = m_particles.blasTransformBuffer,
            });

            commandRecorder.buildAccelerationStructures(m_as.opaqueSpheresASBuildOptions);
            commandRecorder.buildAccelerationStructures(m_as.alphaSpheresASBuildOptions);

            if (!m_as.hasBuiltStaticBlas) {
                // Only needs to be done once
                commandRecorder.buildAccelerationStructures(m_as.opaquePlaneASBuildOptions);
                m_as.hasBuiltStaticBlas = true;
            }

            // Wait for the BLAS to have been built prior to building the TLAS
            commandRecorder.memoryBarrier(MemoryBarrierOptions{
                    .srcStages = PipelineStageFlags(PipelineStageFlagBit::AccelerationStructureBuildBit),
                    .dstStages = PipelineStageFlags(PipelineStageFlagBit::AccelerationStructureBuildBit),
                    .memoryBarriers = {
                            {
                                    .srcMask = AccessFlags(AccessFlagBit::AccelerationStructureWriteBit),
                                    .dstMask = AccessFlags(AccessFlagBit::AccelerationStructureReadBit),
                            },
                    },
            });

            commandRecorder.buildAccelerationStructures(m_as.tlASBuildOptions);

            commandRecorder.endDebugLabel();

Filename: hybrid_raster_rt/hybrid_raster_rt.cpp

Shadow Ray Tracing Pass

For each pixel, the ray generation shader reads the G-Buffer world position and fires a shadow ray towards the light.

The pipeline waits for both the TLAS rebuild and the G-Buffer opaque fill before issuing the trace:

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
            commandRecorder.beginDebugLabel(DebugLabelOptions{
                    .label = "Shadow RT",
                    .color = { 0.5f, 1.0f, 0.5f, 1.0f },
            });

            // Await TLAS rebuild
            commandRecorder.memoryBarrier(MemoryBarrierOptions{
                    .srcStages = PipelineStageFlags(PipelineStageFlagBit::AccelerationStructureBuildBit),
                    .dstStages = PipelineStageFlags(PipelineStageFlagBit::RayTracingShaderBit),
                    .memoryBarriers = {
                            {
                                    .srcMask = AccessFlagBit::AccelerationStructureWriteBit,
                                    .dstMask = AccessFlagBit::AccelerationStructureReadBit,
                            },
                    },
            });

            // Await GBuffer World Pos filling
            commandRecorder.textureMemoryBarrier(KDGpu::TextureMemoryBarrierOptions{
                    .srcStages = KDGpu::PipelineStageFlags(KDGpu::PipelineStageFlagBit::ColorAttachmentOutputBit),
                    .srcMask = KDGpu::AccessFlagBit::ColorAttachmentWriteBit,
                    .dstStages = KDGpu::PipelineStageFlags(KDGpu::PipelineStageFlagBit::RayTracingShaderBit),
                    .dstMask = KDGpu::AccessFlagBit::ShaderReadBit,
                    .oldLayout = KDGpu::TextureLayout::ColorAttachmentOptimal,
                    .newLayout = KDGpu::TextureLayout::ShaderReadOnlyOptimal,
                    .texture = m_gbuffer.posTexture,
                    .range = {
                            .aspectMask = KDGpu::TextureAspectFlagBits::ColorBit,
                            .levelCount = 1,
                    },
            });

            // Transition Shadow Image to General Layout
            commandRecorder.textureMemoryBarrier(TextureMemoryBarrierOptions{
                    .srcStages = KDGpu::PipelineStageFlags(KDGpu::PipelineStageFlagBit::TopOfPipeBit),
                    .srcMask = KDGpu::AccessFlagBit::None,
                    .dstStages = KDGpu::PipelineStageFlags(KDGpu::PipelineStageFlagBit::RayTracingShaderBit),
                    .dstMask = KDGpu::AccessFlagBit::ShaderStorageReadBit | KDGpu::AccessFlagBit::ShaderStorageWriteBit,
                    .oldLayout = m_gbuffer.shadowTextureLayout,
                    .newLayout = KDGpu::TextureLayout::General,
                    .texture = m_gbuffer.shadowTexture,
                    .range = {
                            .aspectMask = KDGpu::TextureAspectFlagBits::ColorBit,
                            .levelCount = 1,
                    },
            });
            m_gbuffer.shadowTextureLayout = KDGpu::TextureLayout::General;

            auto rtPass = commandRecorder.beginRayTracingPass();
            rtPass.setPipeline(m_shadowPass.pipeline);
            rtPass.pushConstant(m_global.lightPosPushConstant, &m_global.lightPos);
            rtPass.setBindGroup(0, m_gbuffer.opaqueNormalDepthBindGroup);
            rtPass.setBindGroup(1, m_gbuffer.shadowBindGroup);
            rtPass.setBindGroup(2, m_as.tsASBindGroup);

            // Issue RT Trace call using the SBT table we previously filled

            // Do note one thing:
            // Opaque BLAS will use closest hit shader (the any hit shader is disabled for BLAS marked opaque)
            // ALPHA BLAS will use the any hit shader

            rtPass.traceRays(RayTracingCommand{
                    .raygenShaderBindingTable = m_shadowPass.sbt.rayGenShaderRegion(),
                    .missShaderBindingTable = m_shadowPass.sbt.missShaderRegion(),
                    .hitShaderBindingTable = m_shadowPass.sbt.hitShaderRegion(),
                    .extent = {
                            .width = m_swapchainExtent.width,
                            .height = m_swapchainExtent.height,
                            .depth = 1,
                    },
            });
            rtPass.end();

            commandRecorder.endDebugLabel();

Filename: hybrid_raster_rt/hybrid_raster_rt.cpp

Shadow Shaders

All shadow shaders are under assets/shaders/examples/hybrid_raster_rt/.

Ray Generation — shadow.rgen

For each launched pixel the shader samples the G-Buffer world position. If the position is valid (non-zero), it fires a shadow ray toward the light source. The tMin is set to the maximum possible sphere diameter (4.0) to avoid self-intersection against the sphere the world position lies on. The result is the accumulated hitCounter value written to the shadow image:

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#version 460 core
#extension GL_EXT_ray_tracing : enable

layout(location = 0) rayPayloadEXT uint hitCounter;

layout(set = 0, binding = 0) uniform sampler2D gBufferWorldPositions;
layout(set = 0, binding = 1) uniform sampler2D gBufferWorldNormals;
layout(set = 0, binding = 2) uniform sampler2D gBufferColors;
layout(set = 0, binding = 3) uniform sampler2D gBufferDepth;

layout(set = 1, binding = 0, r32f) uniform writeonly image2D shadows;

layout(set = 2, binding = 0) uniform accelerationStructureEXT topLevelAS;

layout(push_constant) uniform PushConstants
{
    vec3 lightWorldPos;
}
pushConstants;

void main()
{
    const vec2 pixelCenter = vec2(gl_LaunchIDEXT.xy) + vec2(0.5);
    const vec2 uv = pixelCenter / vec2(gl_LaunchSizeEXT.xy);

    // We want to issue a ray that start at each world pos we have recorded for the opaque geometries
    // and goes back to the light source. Any intersection along the ray means we have shadows

    // Ray
    vec3 origin = texture(gBufferWorldPositions, uv).xyz;
    vec3 direction = normalize(pushConstants.lightWorldPos - origin);

    uint rayFlags = gl_RayFlagsNoneEXT;
    // Note: most of our meshes are spheres.
    // Given a world position on a sphere could be anywhere even underneath,
    // we want to avoid finding intersections against that same sphere. To do so, we set tMin at a value that is the largest possible sphere diameter
    float tMin = 4.0;
    float tMax = 1000.0;

    hitCounter = 0;

    if (origin != vec3(0.0, 0.0, 0.0)) { // Only bother tracing if we have a valid world pos
        traceRayEXT(topLevelAS, // acceleration structure
                    rayFlags, // rayFlags
                    0xFF, // cullMask
                    0, // sbtRecordOffset
                    0, // sbtRecordStride
                    0, // missIndex
                    origin, // ray origin
                    tMin, // ray min range
                    direction, // ray direction
                    tMax, // ray max range
                    0 // hitCounter (location = 0)
        );
    }

    imageStore(shadows, ivec2(gl_LaunchIDEXT.xy), vec4(float(hitCounter)));
}

Filename: hybrid_raster_rt/shadow.rgen

Closest Hit — shadow.rchit

For opaque geometry the closest-hit shader is invoked on the first solid intersection. It sets hitCounter to 3 (a non-zero sentinel meaning "fully in shadow") to signal that the ray was blocked:

1
2
3
4
5
6
7
8
9
#version 460 core
#extension GL_EXT_ray_tracing : enable

layout(location = 0) rayPayloadInEXT uint hitCounter;

void main()
{
    hitCounter = 3;
}

Filename: hybrid_raster_rt/shadow.rchit

Any Hit — shadow.rahit

For alpha geometry the any-hit shader fires on every intersected triangle rather than only the nearest one. It increments hitCounter each time, allowing the compositing pass to use the count as a shadow intensity:

1
2
3
4
5
6
7
8
9
#version 460 core
#extension GL_EXT_ray_tracing : enable

layout(location = 0) rayPayloadInEXT uint hitCounter;

void main()
{
    hitCounter = hitCounter + 1;
}

Filename: hybrid_raster_rt/shadow.rahit

Miss — shadow.rmiss

If the ray reaches tMax without hitting anything, the miss shader fires and resets hitCounter to 0 (fully lit):

1
2
3
4
5
6
7
8
9
#version 460 core
#extension GL_EXT_ray_tracing : enable

layout(location = 0) rayPayloadInEXT uint hitCounter;

void main()
{
    hitCounter = 0;
}

Filename: hybrid_raster_rt/shadow.rmiss

Performance Characteristics

Rasterization phase: Linear in triangle count, excellent cache locality, highly optimized on all GPUs.

Ray tracing phase: Relatively predictable cost - one ray per pixel for shadows. More complex for reflections (many rays per pixel).

Hybrid benefits: Rasterization provides fast primary shading; ray tracing adds quality without per-pixel overhead of full ray tracing.

Vulkan Specifications

Key extensions and features used:

Further Reading

See Also


Updated on 2026-03-31 at 00:02:07 +0000