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

bind = { ... }

Neither space nor slot. Both come from reflection, matched shader-global per descriptor kind.

slang.bind({...}), no space

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.

slang.bind({...}, N)

Both. Space N, slots by position. Respected unconditionally.

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 (.spv)

Descriptor space

From the shader, or stated

Must be stated, with slang.bind(resources, N)

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 [numthreads], which overrides a stated one

Must be stated with numthreads, or grid stated in its place. With neither, the node reports what is missing and does not run.

Entry point

subIdentifier, or the prim name

Read from the binary

grid

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 bind with a .spv. Group with slang.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.