Structured light rendering (offline)

This tutorial renders a structured-light measurement of the Cornell box with StructuredLightPathTracerInline, with antithetic sampling.

A projector next to the camera shows fine vertical stripes, \(\cos(2\pi\,u / \lambda)\) with \(\lambda = 0.01\) in projector coordinates: 100 periods across its field of view. The image shows only the indirect light (computeDirect off), which is what corrupts real structured-light measurements. With such fine stripes most of it cancels; what is left varies slowly, except near the edges, where light bounces between nearby surfaces.

It uses the same scene file as the other tutorials (see Modulated light).

Indirect structured-light measurement of the Cornell box

The three images below show what this measurement keeps. With the modulation shifted to \([0, 1]\) (unsignedModulation), the full measurement (left) is dominated by the direct light, which shows the projected stripes clearly; its indirect part alone (middle) is smooth and dim. The signed, zero-mean measurement of this tutorial (right, as above) removes the constant part of the modulation, so only the indirect light that does not cancel is left. Each image has 8,192 samples per pixel; the script is modulated_components.py (python modulated_components.py structured_light).

Structured light with the pattern in [0, 1] (direct + indirect, indirect) and in [-1, 1] (indirect)

1. Load the scene

import matplotlib
matplotlib.use("Agg")  # no window
import matplotlib.pyplot as plt
import numpy as np
import falcorcomp as falcor

testbed = falcor.Testbed(create_window=False)
testbed.load_scene("cornell-box/scene-v4-nolight.pbrt")
testbed.resize_frame_buffer(512, 512)
testbed.scene.camera.aspectRatio = 1.0
testbed.clock.pause()

2. Build the render graph

VBufferRT finds the primary hits; the projector is set on the tracer itself, so no light pass is needed:

  • projectorPosition, projectorDirection, projectorFov, projectorIntensity: a pinhole projector 0.4 to the right of the camera, looking the same way, with a 30-degree field of view, wider than the camera’s 19.5, so it covers the whole view. Without a position, the projector sits at the camera itself (projectorCollocated).

  • pattern, waveform, patternAxis, patternWavelength: sinusoidal stripes along the projector’s \(u\) axis (vertical stripes), with a period of 0.01 in projector coordinates. Gray, XOR, checkerboard and arbitrary binary codes are also available (see the plugin reference).

  • samplingMethod = antithetic: pair every sampled path with the point lit through the mirror image of its stripe position, where the pattern has the opposite sign (see Antithetic sampling).

  • useSingleChannel: keep the luminance, as a single signed value per pixel.

AccumulatePass averages the frames.

graph = testbed.create_render_graph("StructuredLight")
graph.create_pass("VBuffer", "VBufferRT", {"samplePattern": "Center", "sampleCount": 1})
graph.create_pass("Tracer", "StructuredLightPathTracerInline", {
    "samplesPerPixel": 8, "maxBounces": 3,
    "computeDirect": False,  # indirect light only
    # A projector 0.4 to the right of the camera, with a wider field of view (the camera's is 19.5).
    "projectorPosition": [0.4, 1.0, 6.8], "projectorDirection": [0.0, 0.0, -1.0],
    "projectorFov": [30.0, 30.0], "projectorIntensity": [10.0, 10.0, 10.0],
    # Vertical cos stripes, 100 periods across the projector's field of view.
    "pattern": "periodic", "waveform": "cos", "patternAxis": "u", "patternWavelength": 0.01,
    "samplingMethod": "antithetic",
    "useSingleChannel": True, "singleChannel": "luminance",
})
graph.create_pass("Accumulate", "AccumulatePass", {"precisionMode": "SingleCompensated"})

graph.add_edge("VBuffer.vbuffer", "Tracer.vbuffer")
graph.add_edge("VBuffer.viewW", "Tracer.viewW")
graph.add_edge("Tracer.color", "Accumulate.input")
graph.mark_output("Accumulate.output")
testbed.render_graph = graph

3. Render

for _ in range(1024):  # 1024 frames x 8 spp = 8192 spp
    testbed.frame()

4. Save the image

The measurement is signed, so it is saved as a NumPy array and shown with a diverging colormap whose range is the 95th percentile of its magnitude.

image = graph.get_output("Accumulate.output").to_numpy()[..., 0]  # one channel (luminance)
np.save("structured_light.npy", image)
# The measurement is signed: show it with a diverging colormap, 0 in white.
limit = np.percentile(np.abs(image), 95)
plt.imsave("structured_light.png", image, cmap="bwr", vmin=-limit, vmax=limit)

The full script: structured_light_offline.py.

For an equal-time comparison of antithetic and naive sampling, and how it changes with the pattern, see Antithetic sampling for modulated light.