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.

Apart from tracing the scene, a ray-generation node is built like a Slang compute node: the same three files, the same launch script contract, the same binders. It cannot be a pre-compiled SPIR-V binary, so give it .slang source or a .slang-module. Refer to Compile It Yourself.

The Shape#

The entry point is marked [shader("raygeneration")], and the launch script returns one of two calls in place of slang.compute. 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:

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.compute, and the scene bound by name.

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.

Both forms declare the acceleration structure as a shader resource:

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 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. 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 nearly the same picture. 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.

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 compare. In the ray generation 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: that no ray escaped the room, 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.

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.