Event rendering with denoising (offline)¶
This tutorial renders the event camera of Event camera (offline), moving forward through the Cornell box, at a few samples per pixel per frame over 30 frames, and compares five methods with a reference:
Path tracer (4 spp): each frame with its own random numbers.
Correlated path tracer (2 + 2 spp): each frame rendered twice, reusing the previous frame’s seed.
OptiX denoiser and SVGF (2 spp): each frame’s image denoised, then differenced.
EventSVGF (1 + 1 spp): the correlated difference, denoised.
The sample counts follow the paper, where they take about the same time per frame; at 1024 × 1024 on an RTX 3090, the whole graph took about 6.9, 7.4, 7.5, 5.1 and 4.6 ms per frame. It uses the scene described in Event camera rendering.
1. Load the scene¶
import falcorcomp as falcor
import numpy as np
import matplotlib
matplotlib.use("Agg") # no window
import matplotlib.pyplot as plt
testbed = falcor.Testbed(create_window=False)
testbed.load_scene("cornell-box/scene-v4.pbrt") # with its area light
testbed.resize_frame_buffer(512, 512)
testbed.scene.camera.aspectRatio = 1.0
testbed.clock.pause()
# The camera moves forward at 1000 frames per second.
camera = testbed.scene.camera
START = np.array([camera.position.x, camera.position.y, camera.position.z])
FORWARD = np.array([camera.target.x, camera.target.y, camera.target.z]) - START
FPS, FRAMES = 1000, 30
THRESHOLD = 0.2 # event threshold C on the brightness change
def move_camera(frame):
position = START + 2.0 * FORWARD * frame / FPS
camera.position = falcor.float3(*position)
camera.target = falcor.float3(*(position + FORWARD))
2. Build the render graphs¶
All methods share GBufferRT and EventGenerator:
the path tracers feed
EventDifferencedirectly;the primal denoisers take one
PathTracer’s render with its albedo, normals and motion vectors, and their output feedsEventDifference(independent);EventSVGFtakes the two correlated renders, the albedo, emission, depth, normal and motion vectors, and replacesEventDifference.
The denoisers are temporal: they reuse earlier frames along the motion vectors, so the script renders a sequence.
def build_graph(method, spp, subframes=1):
graph = testbed.create_render_graph(method)
# Primary hits at the pixel centers, shared by the path tracers.
graph.create_pass("GBuffer", "GBufferRT", {"samplePattern": "Center", "sampleCount": 1})
tracers = ["TracerA", "TracerB"] if method in ["correlated", "eventsvgf"] else ["TracerA"]
for tracer in tracers:
graph.create_pass(tracer, "PathTracer", {"samplesPerPixel": spp, "fixedSeed": 0})
graph.add_edge("GBuffer.vbuffer", f"{tracer}.vbuffer")
if method == "eventsvgf":
graph.create_pass("Difference", "EventSVGF", {})
inputs = {"TracerA.color": "color1", "TracerB.color": "color2", "TracerA.albedo": "albedo",
"GBuffer.emissive": "emission", "GBuffer.linearZ": "linearZ", "GBuffer.guideNormalW": "normal",
"GBuffer.mvec": "mvec"}
elif method in ["optix", "svgf"]:
# A primal denoiser: denoise each frame's image, then take the difference of the denoised images.
if method == "optix":
graph.create_pass("Denoiser", "OptixDenoiser", {})
denoiser_inputs = {"TracerA.color": "color", "TracerA.albedo": "albedo", "TracerA.guideNormal": "normal",
"GBuffer.mvec": "mvec"}
denoised = "Denoiser.output"
else:
graph.create_pass("Denoiser", "SVGFPass", {"Iterations": 4, "FeedbackTap": 1, "PhiColor": 10.0,
"PhiNormal": 128.0, "Alpha": 0.1, "MomentsAlpha": 0.2})
denoiser_inputs = {"TracerA.color": "Color", "TracerA.albedo": "Albedo", "GBuffer.emissive": "Emission",
"GBuffer.posW": "WorldPosition", "GBuffer.guideNormalW": "WorldNormal",
"GBuffer.pnFwidth": "PositionNormalFwidth", "GBuffer.linearZ": "LinearZ",
"GBuffer.mvec": "MotionVec"}
denoised = "Denoiser.Filtered image"
for source, target in denoiser_inputs.items():
graph.add_edge(source, f"Denoiser.{target}")
graph.create_pass("Difference", "EventDifference", {"sampling": "independent"})
inputs = {denoised: "color1"}
else:
sampling = "independent" if method == "path" else "correlated"
graph.create_pass("Difference", "EventDifference", {"sampling": sampling, "subframes": subframes})
inputs = {"TracerA.color": "color1"} if method == "path" else {"TracerA.color": "color1", "TracerB.color": "color2"}
for source, target in inputs.items():
graph.add_edge(source, f"Difference.{target}")
graph.create_pass("Events", "EventGenerator", {"mode": "probabilistic", "threshold": THRESHOLD})
graph.add_edge("Difference.deltaL", "Events.deltaL")
for output in ["Difference.primal", "Difference.deltaI", "Difference.deltaL", "Events.events"]:
graph.mark_output(output)
return graph
3. Render the camera path¶
Every method renders the same 30 frames; the seeds are set before every frame, as in Event camera (offline).
def set_seeds(graph, correlated, frame, subframe=0, subframes=1):
seed = lambda f: (f + 1) * subframes + subframe # seeds are non-negative
if correlated:
# TracerA reuses the previous frame's seed, TracerB takes the current one.
graph.get_pass("TracerA").fixedSeed = seed(frame - 1)
graph.get_pass("TracerB").fixedSeed = seed(frame)
else:
graph.get_pass("TracerA").fixedSeed = seed(frame)
def read(graph):
luminance = np.array([0.2126, 0.7152, 0.0722])
return {
"primal": graph.get_output("Difference.primal").to_numpy()[..., :3],
"deltaI": graph.get_output("Difference.deltaI").to_numpy()[..., :3] @ luminance,
"deltaL": graph.get_output("Difference.deltaL").to_numpy(),
"events": graph.get_output("Events.events").to_numpy(),
}
def render(method, spp):
graph = build_graph(method, spp)
testbed.render_graph = graph
for frame in range(FRAMES):
move_camera(frame)
set_seeds(graph, method in ["correlated", "eventsvgf"], frame)
testbed.frame()
return read(graph)
# Sample counts that take about the same time per frame.
results = {
"Path tracer (4 spp)": render("path", 4), # new random numbers every frame
"Correlated path tracer\n(2 + 2 spp)": render("correlated", 2), # the previous frame's seed reused
}
try:
results["OptiX denoiser (2 spp)"] = render("optix", 2)
except Exception: # the falcorcomp package leaves out OptixDenoiser (OptiX SDK license)
print("OptixDenoiser is not available: skipping it.")
results["SVGF (2 spp)"] = render("svgf", 2)
results["EventSVGF (1 + 1 spp)"] = render("eventsvgf", 1) # the difference denoised
4. Reference¶
The correlated difference of the last two frames with 4096 + 4096 samples per pixel.
SUBFRAMES = 256
graph = build_graph("correlated", spp=16, subframes=SUBFRAMES)
testbed.render_graph = graph
for frame in [FRAMES - 2, FRAMES - 1]:
move_camera(frame)
for subframe in range(SUBFRAMES):
set_seeds(graph, True, frame, subframe, SUBFRAMES)
testbed.frame()
results["Reference"] = read(graph)
5. Compare¶
Each row shows the last frame’s image, \(\Delta I\), \(\Delta L\) and the events, with the number of events per pixel where the reference has none:
Path tracer: noise everywhere (5.5 false events per pixel).
Correlated path tracer: the change is visible, with rare large differences where a bounce with the same random numbers reaches a different object in the next frame (0.042).
OptiX denoiser: the images are clean, but their residual errors change from frame to frame, and the difference shows them as large blobs; the false events trace the box and wall edges (0.096).
SVGF: smaller, smoother blobs, and noise along the walls (0.062).
EventSVGF: close to the reference, with 0.0079 false events per pixel. Its image is gray because it filters the luminance only.
reference = results["Reference"]
range_I = np.percentile(np.abs(reference["deltaI"]), 99.5)
range_L = np.percentile(np.abs(reference["deltaL"]), 99.5)
figure, axes = plt.subplots(len(results), 4, figsize=(13, 3.2 * len(results)))
for row, (name, result) in enumerate(results.items()):
panels = [
(np.clip(result["primal"], 0, 1) ** (1 / 2.2), None, None, "primal"),
(result["deltaI"], "RdBu_r", range_I, "ΔI"),
(result["deltaL"], "RdBu_r", range_L, "ΔL"),
(np.clip(result["events"], -1, 1), "bwr", 1, "events (red +, blue −)"),
]
for column, (image, cmap, limit, title) in enumerate(panels):
axis = axes[row, column]
axis.imshow(image, cmap=cmap, vmin=-limit if limit else None, vmax=limit)
axis.set_xticks([])
axis.set_yticks([])
if row == 0:
axis.set_title(title)
if name != "Reference":
# Events per pixel where the reference has none.
false_events = np.mean(np.abs(result["events"]) * (reference["events"] == 0))
axes[row, 3].text(0.02, 0.97, f"false events {false_events:.4f}", transform=axes[row, 3].transAxes,
va="top", fontsize=9, bbox=dict(facecolor="white", alpha=0.8, edgecolor="none"))
axes[row, 0].set_ylabel(name)
figure.tight_layout()
figure.savefig("event_denoising.png", dpi=80)
The full script: event_denoising_offline.py. It takes about 90
seconds, most of it for the reference.