Python: SPG Separable Blur#
Two chained SPG nodes blur LdrColor, first along x and then along y, and publish the result as
LdrBlurred. The tap weights are built in the launch script rather than on the GPU, because they
depend on nothing but the radius.
The radius is a USD attribute the run rewrites between renders, which is what makes the checks
exact: at radius 0 the weight table is {1.0}, the blur is the identity, and the published AOV
has to equal LdrColor byte for byte.
Running#
uv run main.py # CUDA
uv run main.py --scene blur_scene_slang.usda # Slang
The first step compiles the kernel and can take up to a minute on a cold shader cache. A successful
run writes _output/input.png and _output/blurred.png and prints:
radius 0, pixels differing from the input: 0
radius 8, sharpest edge: 122 -> 24
radius 8, sharpest edge after one pass: 55
radius 8, mean brightness drift: 0.003
weight tables built: 2 (radii 8, 0)
The Weights#
Built once per distinct radius rather than once per frame. The warning is what makes that
countable: over some fifty rendered frames it appears twice.
-- One row of Gaussian taps for the stated radius, normalised so the blur keeps
-- the image's brightness. It depends on nothing but the radius, so it is built
-- here rather than recomputed by every GPU thread.
--
-- The warning is what makes the caching visible: this line appears once per
-- distinct radius over a run, not once per frame.
local function gaussianWeights(radius)
warning("blur: building the weight table for radius " .. radius)
if radius == 0 then
return cuda.array({ 1.0 }, cuda.float)
end
local sigma = radius / 3.0
local taps, total = {}, 0.0
for t = -radius, radius do
local w = math.exp(-0.5 * (t / sigma) ^ 2)
taps[#taps + 1] = w
total = total + w
end
for i = 1, #taps do
taps[i] = taps[i] / total
end
return cuda.array(taps, cuda.float)
end
The Two Launches#
The horizontal pass takes one thread per output pixel, which is what SPG derives when the launch script states nothing:
function blurHorizontal(inputs, outputs)
assert(inputs["Image"].rank == 2, "Input must be a 2D image")
local height = inputs["Image"].shape[1]
local width = inputs["Image"].shape[2]
local radius = inputs["radius"].value
outputs["Blurred"] = cuda.image(width, height, cuda.uchar4)
return cuda.kernel({
args = {
cuda.int(width),
cuda.int(height),
cuda.int(radius),
-- Built once rather than on every frame: cuda.static caches the
-- call against its arguments, and this script runs per frame.
cuda.static(gaussianWeights, radius),
cuda.TextureObject(inputs["Image"]),
cuda.SurfaceObject(outputs["Blurred"]),
},
-- No block and no grid. One thread per output pixel is what SPG derives
-- from the output's shape, and that is the mapping this kernel wants.
})
end
The vertical pass gives each thread a whole column to walk, so the iteration domain is the width alone and the geometry has to be stated:
function blurVertical(inputs, outputs)
assert(inputs["Image"].rank == 2, "Input must be a 2D image")
local height = inputs["Image"].shape[1]
local width = inputs["Image"].shape[2]
local radius = inputs["radius"].value
outputs["Blurred"] = cuda.image(width, height, cuda.uchar4)
return cuda.kernel({
args = {
cuda.int(width),
cuda.int(height),
cuda.int(radius),
cuda.static(gaussianWeights, radius),
cuda.TextureObject(inputs["Image"]),
cuda.SurfaceObject(outputs["Blurred"]),
},
-- One thread per column, so the domain is the width alone. The derived
-- geometry would cover width x height and launch a thread for every
-- pixel, each of which would then walk the whole column.
block = { BLOCK },
grid = { math.ceil(width / BLOCK) },
})
end
Reading Back the Half-Finished Blur#
The first pass feeds the second pass and a RenderVar of its own, so all three stages are readable from the host. The run checks that the sharpest edge in the image falls at each step, which only holds if the fan-out reached both consumers. The three figures move with the render; their order does not.
Changing the Radius#
inputs:radius is connected to nothing, so writing it on the Shader prim takes effect on the
next step with no reset:
def set_radius(stage, ordinal: int, radius: int) -> None:
"""Write inputs:radius on both blur nodes and publish the edit.
The attribute is connected to nothing, so the value on the prim is the value
the node reads. Each node is written separately because each carries its own
copy of the attribute.
"""
value = np.array([radius], dtype=np.int32)
tensor = ovstage.make_dltensor(
value, dtype=ovstage.numpy_to_dldatatype(value.dtype, lanes=1), shape=[1], ndim=1
)
with ovstage.PathDictionary(stage) as paths:
attribute = paths.intern_token("inputs:radius")
for node in BLUR_NODES:
path_list = paths.create_path_list_from_strings([node])
with stage.query_from_path_list(path_list) as query:
stage.write_attribute(
query, attribute, ordinal=ordinal, tensors=tensor, is_array=False
).wait()
paths.destroy_path_list(path_list)
stage.advance_write_floor(ordinal, ovstage.Scope.ALL).wait()
Refer to Upload Your Own Data, Cache Work Across Frames, Control the Launch Geometry and Change Values and Rewire for the same material in prose.