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.