Trace the Scene#

Slang only.

Goal. Have a node render the scene itself rather than transform an AOV.

Before you start. A Slang node that runs, and Types and Values.

A ray-generation node traces the scene itself, so its output is an image it rendered rather than one it read and transformed.

It is a Slang node in every other respect: the same three files, the same launch script contract, the same binders.

The Shape#

The entry point is marked [shader("raygeneration")], and the launch script returns one of two calls in place of slang.dispatch. Either way the ray grid is the output’s shape, one invocation per output element, so there is no numthreads.

Which of the two you return decides where the shading happens, and nothing else:

slang.rayQuery

slang.traceRays

Traversal

Inline, inside the ray-generation shader

A ray-tracing pipeline with a shader binding table

Shading

In the ray-generation shader

In separate entry points the table dispatches to

Extra entry points

None

One miss and one hit group, in the same source file

Extra launch fields

None

miss, hit, payloadSize, attributeSize

RaygenCornellBox.slang.lua, from the runnable ray generation example#
function rayGenCornellBox(inputs, outputs)
    local n = 256
    outputs["image"] = slang.image(n, n, slang.uchar4) -- RGBA image; one primary ray per pixel

    return slang.rayQuery({
        bind = {
            slang.ParameterBlock(
                slang.float4x4(inputs["sceneTransform"]),      -- auto-provided from the scene binding
                slang.float3(inputs["lightPosCamera"]),
                slang.float(inputs["sceneRenderScaleFactor"]), -- auto-provided unit factor
                slang.float(inputs["tanHalfFov"]),
                slang.float(inputs["maxRange"]),
                slang.float(inputs["shadowFactor"]),
                slang.float(inputs["ambient"]),
                slang.float(inputs["normalEpsilon"])
            ),
            slang.binding("scene", inputs["scene"]), -- scene TLAS, implicit from the RenderProduct
            slang.RWTexture2D(outputs["image"]),
        },
    })
end

slang.rayQuery in place of slang.dispatch, and the scene bound by name. That is the whole difference from a compute node.

RaygenCornellBoxPipeline.slang.lua, from the runnable ray generation example#
function rayGenCornellBoxPipeline(inputs, outputs)
    local n = 256
    outputs["image"] = slang.image(n, n, slang.uchar4) -- RGBA image; one primary ray per pixel

    return slang.traceRays({
        -- The entry points the shader binding table dispatches to. They live in the
        -- same source file as the ray-generation entry point above.
        miss = { "missCornellBox" },
        hit = { { closesthit = "closestHitCornellBox" } },
        payloadSize = 16,    -- sizeof(HitPayload): one float and three uints
        attributeSize = 8,   -- built-in triangle barycentrics: two floats
        bind = {
            slang.ParameterBlock(
                slang.float4x4(inputs["sceneTransform"]),      -- auto-provided from the scene binding
                slang.float3(inputs["lightPosCamera"]),
                slang.float(inputs["sceneRenderScaleFactor"]), -- auto-provided unit factor
                slang.float(inputs["tanHalfFov"]),
                slang.float(inputs["maxRange"]),
                slang.float(inputs["shadowFactor"]),
                slang.float(inputs["ambient"]),
                slang.float(inputs["normalEpsilon"])
            ),
            slang.binding("scene", inputs["scene"]), -- scene TLAS, implicit from the RenderProduct
            slang.RWTexture2D(outputs["image"]),
        },
    })
end

The entry points are named here, not discovered. payloadSize and attributeSize are byte sizes: the payload is the struct a hit or miss writes back, and the attributes are the built-in triangle barycentrics.

The acceleration structure is declared like any other resource, in both:

RaygenCornellBox.slang, from the runnable ray generation example#
[[vk::binding(0, 1)]] ParameterBlock<Params> g_Params;
[[vk::binding(1, 1)]] RaytracingAccelerationStructure g_Scene; // scene TLAS, implicit from the RenderProduct
[[vk::binding(2, 1)]] RWTexture2D<float4> g_Image;             // NxN output: the rendered Cornell box

How It Works#

What the Node Receives#

A shader living under a RenderProduct is given two things with no authored input and no connection in the scene:

What

How it arrives

The scene acceleration structure

A resource, bound with slang.binding("scene", inputs["scene"]).

That product’s camera, and its unit factors

The value-inputs sceneTransform, sceneRenderScaleFactor and sceneMetersPerRenderUnit.

sceneTransform is the camera-to-trace-space transform. Rays are therefore authored in ordinary scene-unit camera space, the camera at the origin looking down -Z, and placed by that matrix, so the shader never needs to know where the camera is in the world.

The transform folds in the render scale, so ray TMax values are scaled by sceneRenderScaleFactor and returned distances divided by it.

Shading Through the Table#

slang.traceRays only. With an inline RayQuery the ray-generation shader reads the traversal result directly. With a pipeline it never sees the traversal, so everything it needs travels back in a payload struct that the hit and miss shaders write:

RaygenCornellBoxPipeline.slang, from the runnable ray generation example#
// What a hit or a miss reports back to the ray-generation shader. With an inline
// RayQuery the raygen shader reads the result directly; with a pipeline it never
// sees the traversal, so everything it needs travels in this struct.
struct HitPayload
{
    float t;          // hit distance, 0 on a miss
    uint  instanceId; // which instance was hit
    uint  primIndex;  // which triangle of it
    uint  hit;        // 0 = the ray reached tMax without hitting anything
};

[shader("closesthit")]
void closestHitCornellBox(inout HitPayload p, in BuiltInTriangleIntersectionAttributes attr)
{
    p.t = RayTCurrent();
    p.instanceId = InstanceID();
    p.primIndex = PrimitiveIndex();
    p.hit = 1;
}

[shader("miss")]
void missCornellBox(inout HitPayload p)
{
    p.t = 0.0f;
    p.instanceId = 0;
    p.primIndex = 0;
    p.hit = 0;
}

Exactly one hit group is supported. hit is an array, but more than one entry is rejected by name: the dispatch uses a zero-stride hit-group table, so every scene hit invokes group 0 and a second group would never run. miss may hold several, and the index you pass to TraceRay selects among them.

A hit group may also name an any-hit shader. { closesthit = "...", anyhit = "..." }. closesthit is required; anyhit is optional and, when present, is compiled into the same group. It is where a hit is inspected and possibly ignored, alpha-tested geometry being the usual reason, rather than shaded.

payloadSize and attributeSize are byte counts you work out yourself. Nothing derives them from the struct. The payload above is one float and three uint, so 16; the built-in triangle attributes are two floats, so 8. Each is a whole number from 0 to 65535, and anything else is rejected by name.

The two produce the same picture. The example ships the same Cornell box both ways, and both pass the same checks. Choose the pipeline when a hit needs to do work the ray-generation shader cannot express inline, not because it renders differently.

No Geometry Is Exposed#

A hit yields the distance and the surface’s identity, its instance and primitive index, and nothing else. There are no vertex buffers, no index buffers and no material data. This is a deliberate boundary, not an oversight.

Anything else a shader needs has to be derived from tracing. The ray generation example does this for surface normals: a triangle is flat, so three points on it define its plane exactly. The primary ray gives one point and the surface’s identity; two probe rays fired parallel to it, offset sideways, give two more, and the cross product of the two edge vectors is the exact geometric normal. It costs two extra rays per shaded hit, is re-derived every frame so it works on moving and tessellated geometry, and degrades only on triangles smaller than the probe offset.

Refer to Python: SPG Ray Generation, which renders a Cornell box this way, including hard ray-traced shadows from a second trace toward the light.

Verify It Worked#

Render the same scene both ways and check that the assertions hold for each. In the example the two agree on 99.85% of pixels: 101 of 65536 differ, deterministically, so a rerun of either form reproduces its own image exactly. Expect close agreement rather than an identical image.

Check the render against what the shader encodes rather than against a reference image. The ray generation example asserts four things: that no ray escaped the box, that each wall’s dominant colour channel matches its normal, that off-axis normals are present in quantity, and that both shadowed and lit pixels exist. Those four fail separately if the trace, the shading or the light is wrong, and none of them needs a golden image.

A pre-compiled SPIR-V binary cannot be used. A ray-tracing pipeline is assembled from shader-database handles, which byte code has none of, so a ray-generation node takes .slang source or a .slang-module. Refer to Compile It Yourself. Everything else that constrains a Slang node applies here too, including the Vulkan requirement.

When It Goes Wrong#

  • Nothing is traced: the scene binding is missing, or the node is not under a RenderProduct with a camera.

  • Everything is black: rays are being authored in the wrong space. Place them with sceneTransform rather than assuming world coordinates.