Python: SPG Previous Frame#
An SPG node that reads the same AOV twice, once as the renderer produced it this frame and once as it was one frame ago, and publishes the difference. Whatever moved lights up; whatever held still goes dark.
Running#
uv run main.py # CUDA
uv run main.py --scene motion_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 three PNGs to _output/ and prints:
pixels changed while moving: 3281
pixels changed while still: 50
The Scene#
The whole mechanism is the :-1 on one sourceName. Highlighted: the two RenderVars the node
reads, which name the same AOV at two different times.
motion_scene.usda#def Scope "Render"
{
def RenderProduct "MotionDemo"
{
uniform int2 resolution = (960, 540)
rel camera = </World/Camera>
rel orderedVars = [ <LdrColor>, <PreviousColor>, <Motion> ]
def RenderVar "LdrColor"
{
uniform string sourceName = "LdrColor"
opaque omni:rtx:aov
}
# The same AOV, one frame back. The ":-1" suffix is the whole
# difference; everything else is an ordinary input RenderVar.
def RenderVar "PreviousColor"
{
uniform string sourceName = "LdrColor:-1"
opaque omni:rtx:aov
}
def RenderVar "Motion"
{
uniform string sourceName = "Motion"
opaque omni:rtx:aov.connect = <../MotionShader.outputs:Difference>
}
def Shader "MotionShader" (
references = @MotionKernel.usda@
)
{
opaque inputs:Current.connect = <../LdrColor.omni:rtx:aov>
opaque inputs:Previous.connect = <../PreviousColor.omni:rtx:aov>
}
}
}
The Node#
Two ordinary opaque ports. Nothing here says one of them is a past frame:
def Shader "MotionKernel"
{
uniform token info:implementationSource = "sourceAsset"
uniform asset info:spg:sourceAsset = @MotionKernel.cu@
uniform token info:spg:sourceAsset:subIdentifier = "motion"
# Two ordinary resource-inputs. Nothing here marks one of them as a past
# frame; that follows from the RenderVar the scene connects it to.
opaque inputs:Current
opaque inputs:Previous
float inputs:gain = 6.0
opaque outputs:Difference
}
-- Two resource-inputs, bound the same way. Nothing here says one of them is a
-- past frame: that is settled in the scene, by the RenderVar each is connected
-- to.
function motion(inputs, outputs)
assert(inputs["Current"].rank == 2, "Current must be a 2D image")
assert(inputs["Previous"].rank == 2, "Previous must be a 2D image")
local height = inputs["Current"].shape[1]
local width = inputs["Current"].shape[2]
outputs["Difference"] = cuda.image(width, height, cuda.uchar4)
return cuda.kernel({
args = {
cuda.int(width),
cuda.int(height),
cuda.float(inputs["gain"]),
cuda.TextureObject(inputs["Current"]),
cuda.TextureObject(inputs["Previous"]),
cuda.SurfaceObject(outputs["Difference"]),
},
})
end
What the Run Proves#
A node wired to the live AOV twice would report a near-zero count while the ball moves as well as
while it holds still. The moving half is what shows that :-1 resolved to a past frame.
The count while still is not zero, and should not be expected to be: a path-traced render keeps refining, so consecutive frames of a static scene differ slightly. Neither count repeats exactly between runs. The gap between them is what carries the result.
Refer to Read a Previous Frame for how :-N behaves for N up to 8, and for how it
differs from a stateful output.