Bind Resources and Descriptor Spaces#
Slang only.
Goal. Place resources into specific descriptor sets, and state a constant buffer’s layout yourself rather than leaving it to reflection.
Reflection here means the binding layout SPG reads back out of a compiled Slang shader: which space and slot each resource sits in, and where each value sits in the constant buffer.
Before you start. Types and Values, and a Slang node that runs.
The Shape#
Every resource needs two things settled: which descriptor set it belongs to, the space, and
which slot it occupies within that set. A flat bind = { ... } states neither and relies
on reflection for both. Grouping states them from the script:
return slang.dispatch({
slang.bind({
slang.ParameterBlock(
slang.float(inputs["strength"]) -- -> slot 0, the constant buffer
),
slang.Texture2D(inputs["Image"]), -- -> slot 1
slang.RWTexture2D(outputs["Result"]), -- -> slot 2
}, 1),
slang.bind({
slang.StructuredBuffer(weights), -- -> space 2, slot 0
}, 2),
numthreads = { 8, 8, 1 },
grid = { math.ceil(width / 8), math.ceil(height / 8), 1 },
})
Each entry’s slot is its position in the group, counting from zero.
How It Works#
Form |
What it states |
|---|---|
|
Neither space nor slot. Both come from reflection, matched shader-global per descriptor kind. |
|
Slots, but not the space. The space comes from reflection; if the shader uses more than one, the load fails naming the spaces it found. |
|
Both. Space |
Two or more groups must each state a space. There is no positional default and no ordering assumption, so groups may be listed in any order. Grouping also scopes the matching: within a group, resources are matched against the reflected resources of that space alone.
Lua rejects two ParameterBlock s in one group, a duplicate space across groups, and a space
that is negative or not an integer.
The With-Reflection Case#
Every Slang example takes the first form. The shader states the space and the slot for each
resource, and the launch script states neither: its bind list is purely positional and is
matched against what reflection found.
InvertKernel.slang, from the runnable pipeline example#// Second pass of the pipeline: invert an image. Slang counterpart of
// InvertKernel.cu. The input is whatever upstream shader is connected to
// inputs:Image -- here, the grayscale output.
//
// Value-inputs are not passed as arguments like in CUDA. They are packed into
// a constant buffer described by a struct, which the shader reads through a
// ParameterBlock. The struct field order must match the order the launch
// script lists the values in slang.ParameterBlock(...).
struct Params
{
float strength;
};
[[vk::binding(0, 1)]] ParameterBlock<Params> g_Params;
[[vk::binding(1, 1)]] Texture2D<float4> g_InImage;
[[vk::binding(2, 1)]] RWTexture2D<float4> g_OutInverted;
[shader("compute")]
[numthreads(32, 32, 1)]
void invert(uint3 tid : SV_DispatchThreadID)
{
uint width = 0, height = 0;
g_InImage.GetDimensions(width, height);
if (tid.x >= width || tid.y >= height)
return;
float4 pixel = g_InImage.Load(int3(tid.xy, 0));
// lerp(original, inverted, strength) per RGB channel. The CUDA kernel does
// the same blend on 0..255 integers; here the channels are normalized
// floats, so full inversion is 1.0 - value.
float3 inverted = lerp(pixel.rgb, 1.0 - pixel.rgb, g_Params.strength);
g_OutInverted[tid.xy] = float4(inverted, pixel.a);
}
InvertKernel.slang.lua, from the runnable pipeline example#-- Launch script for InvertKernel.slang. The function name must equal the
-- subIdentifier ("invert").
function invert(inputs, outputs)
assert(inputs["Image"].rank == 2, "Input must be a 2D image")
outputs["Inverted"] = slang.image(inputs["Image"].shape, inputs["Image"].dtype)
return slang.dispatch({
-- The bind list is positional. The parameter block comes first and
-- carries the value-inputs in the order the shader's Params struct
-- declares them; the resources follow in declaration order.
bind = {
slang.ParameterBlock(
slang.float(inputs["strength"]) -- -> float strength
),
slang.Texture2D(inputs["Image"]), -- -> Texture2D<float4> g_InImage
slang.RWTexture2D(outputs["Inverted"]), -- -> RWTexture2D<float4> g_OutInverted
},
-- The shader's [numthreads(32, 32, 1)] places the dispatch; the grid
-- follows from the output's shape.
})
end
The highlighted blocks are one contract seen from both ends. The Params struct fixes the
field order the ParameterBlock call has to follow, and the vk::binding declarations fix
the order of everything after it.
Stating the Layout#
Each dtype constructor takes an optional second argument saying where its value sits in the block. A bare number is a byte offset; the long form also states how a matrix is stored:
slang.ParameterBlock(
slang.uint(inputs["key"], 0),
slang.float4x4(inputs["xform"], { offset = 16, order = slang.column_major, stride = 16 })
)
order takes slang.row_major or slang.column_major. A bare number on a matrix is
rejected, because a matrix needs its storage order as well as its offset.
Offsets are derived when every value in the block is a single four-byte number. They become required as soon as any vector or matrix appears, since alignment then decides the packing.
What Reflection Fills In#
.slang source and a pre-compiled .slang-module both carry reflection. A .spv, which is
SPIR-V byte code, does not. Refer to Compile It Yourself. The rule between them is
one line: the launch script is the source of truth, and reflection is the check on it. What
the script states, reflection verifies; what it leaves out, reflection supplies; where the two
disagree, SPG reports it and binds what the shader declares.
With reflection |
Without reflection ( |
|
|---|---|---|
Descriptor space |
From the shader, or stated |
Must be stated, with |
Binding slot |
From the shader, or by position in a group |
By position in a group, counting from zero |
Parameter-block offsets |
From the shader, or stated |
Must be stated, unless every value is a single four-byte number |
Matrix order and stride |
From the shader, or stated |
Must be stated |
Thread group size |
The shader’s |
Must be stated with |
Entry point |
|
Read from the binary |
|
Derived from the output’s shape and the thread group size |
The same, once a thread group size exists |
A flat bind = { ... } therefore cannot work without reflection: nothing states a space, so
the node reports that the shader exposes no descriptor space and does not run.
Verify It Worked#
Bind a value with an exact, reversible effect and check it byte for byte. An XOR key is the usual choice: every output byte must equal the input byte XOR the key, with no exceptions. This matters because a node whose parameter block never bound reads zeros, and XOR with zero is the identity, so a broken binding renders an untouched copy rather than an error.
When It Goes Wrong#
“Shader exposes no descriptor space”: a flat
bindwith a.spv. Group withslang.bind.A value lands in the wrong field: the order does not match the struct. A wrong count is an error, a wrong order only a warning.