Time-gated ReSTIR (online)¶
In online rendering, every frame is rendered once within a fixed time budget and shown, as in an
interactive application, instead of being averaged with other frames. This tutorial sweeps the
time gate through the Cornell box: it moves the gate center every frame, from 16.75 to 17.331 over
100 frames, with a narrow 0.01-wide gate. It renders the sweep with TimeGatedPathTracerInline
and with TimeGatedReSTIRInline at equal frame time, and compares every frame with a reference.
TimeGatedReSTIRInline uses temporal reuse here: every frame keeps the paths it found in earlier
frames and shifts them to the new gate with the path-length-aware shift mapping, so it keeps
improving while the gate moves. The path tracer starts from nothing in every frame.
It uses the same scene file as the other tutorials (see ToF rendering), renders at
1024 x 1024, and needs ffmpeg to write the videos.
Method |
Samples per pixel |
Frame time |
Mean relMSE |
|---|---|---|---|
|
37 |
38.4 ms |
15.1 |
|
32 |
40.0 ms |
0.465 |
At the same frame time, ReSTIR’s mean error over the 100 frames is 33 times lower. Its first frame has no history yet (relMSE 5.5); by its third frame the error is below 1. These numbers were measured on an NVIDIA GeForce RTX 3090 with Vulkan; frame times and the matched sample count depend on the GPU.
1. Load the scene and set up the sweep¶
The gate centers are one per frame, from the first to the last. The ReSTIR options add temporal
reuse to those of the offline tutorial: useTemporalReuse with a
temporalHistoryLength of 10 frames, and one round of spatial reuse with 3 neighbors, which is
cheaper per frame than the three rounds used offline.
import csv
from pathlib import Path
import shutil
import subprocess
import tempfile
from time import perf_counter
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import falcorcomp as falcor
SIZE = 1024
testbed = falcor.Testbed(create_window=False)
testbed.load_scene("cornell-box/scene-v4-nolight.pbrt")
testbed.resize_frame_buffer(SIZE, SIZE)
testbed.scene.camera.aspectRatio = 1.0
testbed.clock.pause()
# The gate moves from 16.75 to 17.331 over 100 frames, one frame per gate center.
FRAMES = 100
GATE_CENTERS = np.linspace(16.75, 17.331213307240706, FRAMES)
GATE = {"timeGateMode": "box", "timeGateWindow": 0.01, "timeCenter": float(GATE_CENTERS[0])}
PT = {"samplesPerPixel": 32, "maxBounces": 6, **GATE}
RESTIR = {**PT,
"spatialReuseIteration": 1, "spatialReuseNeighborCount": 3,
"useTemporalReuse": True, "temporalHistoryLength": 10.0, "isSceneDynamic": False,
"shiftMappingMethod": "local_tangent", "gaugeMode": "avg_grad",
"reconnectionRoughnessThreshold": 0.05}
SKIP_FRAMES = 10 # frames left out of the timing: shader compilation, history warm-up
TOLERANCE = 0.05 # accepted frame-time difference between the two tracers
REFERENCE = Path("reference") # frame_0000.npy ... frame_0099.npy; rendered below if missing
REFERENCE_SPP = 32768
2. Build the render graph¶
Each frame is shown on its own, so the graph has no AccumulatePass: the tracer’s color output
is the frame.
def create_graph(tracer, properties):
graph = testbed.create_render_graph(tracer)
graph.create_pass("VBuffer", "VBufferRT", {"samplePattern": "Center", "sampleCount": 1})
graph.create_pass("Laser", "LaserLight", {
"laserPosition": [0.0, 1.7, 6.8], "laserDirection": [0.0, 0.0, -1.0],
"laserPower": [170.0, 120.0, 40.0], "laserAngle": 0.0,
})
graph.create_pass("Tracer", tracer, properties)
graph.add_edge("VBuffer.vbuffer", "Tracer.vbuffer")
graph.add_edge("VBuffer.viewW", "Tracer.viewW")
graph.add_edge("Laser", "Tracer")
graph.mark_output("Tracer.color") # one frame per gate: no accumulation
return graph
3. Render the sequence¶
set_time_gate_info(center, center, 1) moves the gate before every frame. For ReSTIR this keeps
the history, which temporal reuse shifts to the new gate. Each frame is timed with a wait for the
GPU, and the first 10 frames, which include shader compilation and ReSTIR’s first frames without
history, are left out of the mean frame time. Images are read back only when a callback asks for
them, so the timing runs measure rendering alone.
def render_sequence(tracer, properties, on_frame=None):
"""Returns the mean frame time in ms. on_frame(frame, image) receives each frame's image;
images are read back only when it is given, so timing runs have no readback."""
graph = create_graph(tracer, properties) # a new graph starts with an empty ReSTIR history
testbed.render_graph = graph
tracer_pass = graph.get_pass("Tracer")
times = []
for frame, center in enumerate(GATE_CENTERS):
# Moving the gate keeps ReSTIR's history, which temporal reuse shifts to the new gate.
tracer_pass.set_time_gate_info(float(center), float(center), 1)
start = perf_counter()
testbed.frame()
testbed.device.wait() # include the frame's GPU time
times.append(perf_counter() - start)
if on_frame is not None:
on_frame(frame, graph.get_output("Tracer.color").to_numpy()[..., :3])
return 1000.0 * float(np.mean(times[SKIP_FRAMES:]))
4. Match the frame time¶
ReSTIR renders 32 initial samples per pixel. The path tracer starts at the same count and is then given the sample count that fits the same frame time: its sample count is scaled by the ratio of the frame times until they are within 5 %, with at most three adjustments.
restir_ms = render_sequence("TimeGatedReSTIRInline", RESTIR)
print(f"TG ReSTIR: {RESTIR['samplesPerPixel']} spp, {restir_ms:.1f} ms/frame")
trials = {}
pt_spp = PT["samplesPerPixel"]
for attempt in range(4): # the equal-spp run, then up to three adjustments
trials[pt_spp] = render_sequence("TimeGatedPathTracerInline", {**PT, "samplesPerPixel": pt_spp})
print(f"TGPT: {pt_spp} spp, {trials[pt_spp]:.1f} ms/frame")
if abs(trials[pt_spp] / restir_ms - 1.0) <= TOLERANCE:
break
pt_spp = max(1, round(pt_spp * restir_ms / trials[pt_spp]))
if pt_spp in trials:
break
pt_spp = min(trials, key=lambda spp: abs(trials[spp] - restir_ms))
pt_ms = trials[pt_spp]
5. Compare with the reference¶
Both sequences are rendered again, this time reading every frame back, and each frame is compared
with the reference frame of the same gate by its relative mean squared error. The reference is a
path-traced sequence with 32,768 samples per pixel per frame. If the reference folder does not
exist, the script renders it first, which takes roughly an hour on an RTX 3090 (about 35 s per
frame).
if not REFERENCE.exists():
print(f"Rendering the reference with TGPT at {REFERENCE_SPP} spp per frame (slow)")
REFERENCE.mkdir()
def save_reference(frame, image):
np.save(REFERENCE / f"frame_{frame:04d}.npy", image)
render_sequence("TimeGatedPathTracerInline", {**PT, "samplesPerPixel": REFERENCE_SPP},
on_frame=save_reference)
def relative_mse(image, reference):
return float(np.mean((image - reference) ** 2) / np.mean(reference ** 2))
def to_display(image):
image = np.maximum(image, 0.0)
image = image / (1.0 + image) # Reinhard
image = np.where(image <= 0.0031308, 12.92 * image, 1.055 * image ** (1 / 2.4) - 0.055) # sRGB
return (np.clip(image, 0, 1) * 255).astype(np.uint8)
def record(tracer, properties):
"""Renders the sequence and returns each frame's display image and relMSE."""
frames, errors = [], []
def on_frame(frame, image):
errors.append(relative_mse(image, np.load(REFERENCE / f"frame_{frame:04d}.npy")))
frames.append(to_display(image))
render_sequence(tracer, properties, on_frame)
return frames, errors
pt_frames, pt_errors = record("TimeGatedPathTracerInline", {**PT, "samplesPerPixel": pt_spp})
restir_frames, restir_errors = record("TimeGatedReSTIRInline", RESTIR)
print(f"Mean relMSE: TGPT {np.mean(pt_errors):.3f}, TG ReSTIR {np.mean(restir_errors):.3f}")
with open("errors.csv", "w", newline="") as file:
writer = csv.writer(file)
writer.writerow(["frame", "gate_center", "tgpt_relmse", "tg_restir_relmse"])
writer.writerows(zip(range(FRAMES), GATE_CENTERS, pt_errors, restir_errors))
6. Save the videos¶
Every frame is tone mapped the same way (Reinhard, then sRGB), labeled with the sample count, the frame time and its error, and encoded at 15 frames per second.
def save_video(path, name, spp, frame_ms, frames, errors, fps=15):
font = ImageFont.load_default(size=28)
with tempfile.TemporaryDirectory() as folder:
for frame, (image, error) in enumerate(zip(frames, errors)):
canvas = Image.new("RGB", (SIZE, SIZE + 80), "black")
canvas.paste(Image.fromarray(image), (0, 80))
draw = ImageDraw.Draw(canvas)
title = f"{name}: {spp} spp, {frame_ms:.1f} ms/frame"
draw.text((16, 6), title, fill="white", font=font)
draw.text((16, 42), f"frame {frame:3d} gate {GATE_CENTERS[frame]:.3f} "
f"relMSE {error:.3f}", fill="white", font=font)
canvas.save(f"{folder}/frame_{frame:04d}.png")
subprocess.run([shutil.which("ffmpeg"), "-y", "-loglevel", "error", "-framerate", str(fps),
"-i", f"{folder}/frame_%04d.png", "-c:v", "libx264", "-crf", "20",
"-pix_fmt", "yuv420p", path], check=True)
save_video("time_gated_tgpt_online.mp4", "TGPT", pt_spp, pt_ms, pt_frames, pt_errors)
save_video("time_gated_restir_online.mp4", "TG ReSTIR", RESTIR["samplesPerPixel"], restir_ms,
restir_frames, restir_errors)
The full script: time_gated_restir_online.py. With
the reference in place it takes about two minutes. It also saves each frame’s error to
errors.csv.