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:
|
|
|
|---|---|---|
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 |
Extra launch fields |
None |
|
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 |
That product’s camera, and its unit factors |
The value-inputs |
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
sceneTransformrather than assuming world coordinates.