Choose a Resource’s Backing#

Goal. Decide whether a resource is texture-backed or buffer-backed, and bind it with a binder that agrees.

Before you start. The Lua Launch Script.

The Shape#

Describe what the output has to be. image asks for a texture, empty asks for a buffer.

outputs["Picture"] = cuda.image(width, height, cuda.uchar4)
outputs["Values"]  = cuda.empty({ count }, cuda.float)
outputs["Picture"] = slang.image(width, height, slang.uchar4)
outputs["Values"]  = slang.empty({ count }, slang.float)

How It Works#

Five things hold whichever language the node is written in.

Backing is fixed at creation. A resource is either texture-backed or buffer-backed, and that choice cannot be revisited. Describe it as what it needs to be, and bind it with a binder that agrees.

Shapes are height-first. shape[1] is height and shape[2] is width, while the image functions take width first. That reversal is the most common cause of a wrongly proportioned result.

A texture has one, two or three dimensions. Anything else is buffer-backed.

Not every dtype can back a texture. A texture element needs a hardware format, and the three-component types narrower than 32 bits have none: half3, short3, ushort3, char3 and uchar3. Asking for one is refused where it is asked for. Use the four-component form and leave the fourth channel unread, or make the resource buffer-backed, where any dtype goes.

Normalisation is where the two languages differ. A uchar4 texture’s hardware format is RGBA8_UNORM, alone among the 8-bit element types, so the hardware maps its bytes to and from the range 0.0 to 1.0 on every access. A Slang shader writes float4 values in that range and the hardware rounds them to bytes; a CUDA kernel writes the bytes itself through surf2Dwrite and nothing rounds. The same intended colour can therefore land one least significant bit apart in the two languages. Where a test has to match byte for byte, pick colours that are exact in 8 bits.

The names, and what the GPU code receives, depend on the language.

Allocate with cuda.image(width, height, dtype) for a texture and cuda.empty(shape, dtype) for a buffer. To create one from data you wrote in Lua, use cuda.array(luaTable, dtype); refer to Upload Your Own Data.

Bind with cuda.TextureObject(r) to read a texture, cuda.SurfaceObject(r) to write one, and cuda.array(r) for a buffer. The full set is in The cuda Lua Table.

A buffer arrives as a pointer. Nothing constrains how the kernel reads it: element size, stride and interpretation are yours. A texture instead arrives as a cudaTextureObject_t or cudaSurfaceObject_t, and the hardware handles addressing and format conversion.

The grayscale node works on textures throughout. Highlighted: the allocation, then the two binders.

GrayscaleKernel.cu.lua, from the runnable grayscale example#
-- Launch script for GrayscaleKernel.cu. SPG calls this once per frame.
-- The function name must equal the subIdentifier ("grayscale").
function grayscale(inputs, outputs)
    assert(#inputs["LdrColor"].shape == 2, "Input must be a 2D image")
    assert(inputs["LdrColor"].dtype == cuda.uchar4, "Input must be uchar4")

    -- shape is 1-indexed: [1] = height (rows), [2] = width (columns).
    local height = inputs["LdrColor"].shape[1]
    local width  = inputs["LdrColor"].shape[2]

    -- Allocate the output AOV before launching.
    outputs["LdrGrayscale"] = cuda.image(width, height, cuda.uchar4)

    return cuda.kernel({
        -- args order/types must match the C signature exactly.
        args = {
            cuda.int(width),                             -- -> int width
            cuda.int(height),                            -- -> int height
            cuda.TextureObject(inputs["LdrColor"]),      -- -> cudaTextureObject_t inputLdrColor
            cuda.SurfaceObject(outputs["LdrGrayscale"]), -- -> cudaSurfaceObject_t outputLdrGrayscale
        },
        block = { 32, 32 },
        grid  = { math.ceil(width / 32), math.ceil(height / 32) },
    })
end

A node routinely mixes the two. Here a texture output is allocated and two buffer inputs are bound alongside it, in one list. coordinates and counts are buffer-backed resources this node was given; where they came from is Read a Sensor Composite.

RangeHistogramKernel.cu.lua, from the runnable composite AOV example#
outputs["Histogram"] = cuda.image(WIDTH, HEIGHT, cuda.uchar4)

return cuda.kernel({
    args = {
        cuda.int(WIDTH),                          -- -> int width
        cuda.int(HEIGHT),                         -- -> int height
        cuda.int(maxPoints),                      -- -> int maxPoints
        cuda.int(numBins),                        -- -> int numBins
        cuda.float(inputs["maxRange"]),           -- -> float maxRange
        cuda.float(inputs["maxCount"]),           -- -> float maxCount
        cuda.array(coordinates),                  -- -> const float* coordinates
        cuda.array(counts),                       -- -> const int* counts
        cuda.SurfaceObject(outputs["Histogram"]), -- -> cudaSurfaceObject_t histogram
    },
    -- One thread per output column, each counting its own bin.
    block = { THREADS },
    grid  = { math.ceil(WIDTH / THREADS) },
})

Allocate with slang.image(width, height, dtype) for a texture and slang.empty(shape, dtype) for a buffer. To create one from data you wrote in Lua, use slang.array(luaTable, dtype); refer to Upload Your Own Data.

Bind with slang.Texture2D(r) to read a texture, slang.RWTexture2D(r) to write one, and slang.StructuredBuffer(r) for a buffer. Slang names its binders after the types they bind and checks what it is given, so the binder has to match how the shader declares the parameter. The full set is in The slang Lua Table.

slang.StructuredBuffer is one point in a small space, not a name to memorise. It is read-only, storage-backed and addressed by element, and each of those three has an alternative. Storage or typed decides whether the shader interprets the bytes itself or the hardware converts on access, and that one is fixed when the buffer is created. Read-only or read/write is the RW prefix, and follows from whether the resource is an input or an output. By element or by byte offset costs nothing to change, because both are the same descriptor and only the shader’s addressing differs.

Typed and storage buffers are different resources. The binder that declares an output is what describes it, so slang.RWBuffer is what asks for a typed buffer, taking its element format from the output’s dtype. Every node that reads that buffer must then read it with slang.Buffer. A buffer created any other way must be read with slang.StructuredBuffer or slang.ByteAddressBuffer. Reading one as the kind it was not created as is rejected by name, in both directions, with the fix in the message.

Two allocations are refused where they are asked for: an element type that names no format, and an element count past the device’s typed-buffer limit.

The grayscale node works on textures throughout. Highlighted: the allocation, then the two binders.

GrayscaleKernel.slang.lua, from the runnable grayscale example#
-- Launch script for GrayscaleKernel.slang. SPG calls this once per frame.
-- The function name must equal the subIdentifier ("grayscale").
--
-- Same job as GrayscaleKernel.cu.lua: validate the input, allocate the output,
-- describe the launch. The differences are the `slang` table (in place of
-- `cuda`) and that resources are bound as a list instead of kernel arguments.
function grayscale(inputs, outputs)
    assert(inputs["LdrColor"].rank == 2, "Input must be a 2D image")

    -- Allocate the output AOV. Grayscale keeps the input's shape and dtype.
    outputs["LdrGrayscale"] = slang.image(inputs["LdrColor"].shape, inputs["LdrColor"].dtype)

    return slang.dispatch({
        -- bind order must match the resource declaration order in the shader.
        bind = {
            slang.Texture2D(inputs["LdrColor"]),        -- -> Texture2D<float4> g_InLdrColor
            slang.RWTexture2D(outputs["LdrGrayscale"]), -- -> RWTexture2D<float4> g_OutLdrGrayscale
        },
    })
end

A node routinely mixes the two. Here a texture output is allocated and two buffer inputs are bound alongside it, in one list. coordinates and counts are buffer-backed resources this node was given; where they came from is Read a Sensor Composite.

RangeHistogramKernel.slang.lua, from the runnable composite AOV example#
outputs["Histogram"] = slang.image(WIDTH, HEIGHT, slang.uchar4)

return slang.dispatch({
    bind = {
        slang.ParameterBlock(
            slang.int(maxPoints),               -- -> int   maxPoints
            slang.int(numBins),                 -- -> int   numBins
            slang.float(inputs["maxRange"]),    -- -> float maxRange
            slang.float(inputs["maxCount"])     -- -> float maxCount
        ),
        slang.StructuredBuffer(coordinates),    -- -> StructuredBuffer<float> g_Coordinates
        slang.StructuredBuffer(counts),         -- -> StructuredBuffer<int>   g_Counts
        slang.RWTexture2D(outputs["Histogram"]),-- -> RWTexture2D<float4>     g_Histogram
    },
    -- One thread per output column, each counting its own bin. The
    -- derived grid would follow the output image, so state it: the
    -- iteration domain is the columns, not the pixels.
    grid = { math.ceil(WIDTH / THREADS), 1, 1 },
})

Verify It Worked#

Write a known pattern into the resource and read it back on the host. A round trip that survives exactly proves both the backing and the binder. A result that looks plausible but shifted usually means the stride or the shape order is wrong, not the binding.

When It Goes Wrong#

  • The values are shifted or interleaved wrongly: the kernel’s element size or stride does not match how the buffer was allocated. Nothing checks this for you.

  • Wrong pixels rather than none: refer to Wrong Pixels Rather Than No Pixels.

  • A binder rejects the resource: the message names the port and what it expected. That is a Lua-side check, so it fires before anything reaches the GPU.

  • A buffer is rejected as the wrong kind: it was created typed and read as storage, or the reverse. The message says which, and the fix is to match the creating binder.

  • Wrong pixels rather than none: refer to Wrong Pixels Rather Than No Pixels.