Skip to content

Commit 2da144e

Browse files
ladzinmeta-codesync[bot]
authored andcommitted
Fix inverse light transform in convertLightsToEyeSpace (#1571)
Summary: Pull Request resolved: #1571 The software rasterizer shades each pixel with `dot(light_dir_eye, normal_eye)`, so lights and surface normals must live in the same eye/camera space. Surface normals are taken to eye space with `camera.eyeFromWorld()` (world->eye) via `_normalMatrix` and `_worldToEye_rotation` in `arvr/libraries/momentum/rasterizer/utility.h`, but `convertLightsToEyeSpace` in `arvr/libraries/pymomentum/renderer/rasterizer_utility.h` transformed lights with `camera.worldFromEye()` — the INVERSE (eye->world) transform. Lights were therefore rotated by `R^T` while normals were rotated by `R`, so the shading dot product was only correct when the camera view rotation was symmetric (identity / equator). With any tilted, from-below, or polar camera, a world-space camera "headlight" (light direction = camera forward) left the camera-facing surface DARK, even though that surface was fully facing both the camera and the light. The fix is one line: transform lights with `camera.eyeFromWorld()` so lights and normals use the same world->eye rotation. `convertLightsToEyeSpace` is the single point where every Python-facing render entry point converts world-space lights to eye space (7 call sites in `software_rasterizer.cpp`, 1 in `rasterizer_primitives.cpp`), so this corrects the whole rasterizer. The local variable was already named `worldToEyeXF`, which now correctly matches what it holds. Directional lights are transformed by `xf.linear()` and point lights by the full affine `xf` (`transformLight`), both correct once `xf` is the world->eye transform. The `Light.transform()` pybind takes a caller-supplied matrix and is unaffected. The existing `test_lighting` never rotated the camera (identity extrinsics, where `worldFromEye() == eyeFromWorld()`), which is why the bug went unnoticed. Reviewed By: cstollmeta Differential Revision: D111853949 fbshipit-source-id: b9af875fa4e6dde5a0d8bac731ea80875fe30e2f
1 parent 1a0ad7f commit 2da144e

2 files changed

Lines changed: 81 additions & 1 deletion

File tree

‎pymomentum/renderer/rasterizer_utility.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ inline std::vector<momentum::rasterizer::Light> convertLightsToEyeSpace(
302302
return {};
303303
}
304304

305-
const Eigen::Affine3f worldToEyeXF = camera.worldFromEye();
305+
const Eigen::Affine3f worldToEyeXF = camera.eyeFromWorld();
306306
std::vector<momentum::rasterizer::Light> result;
307307
result.reserve(lights->size());
308308
for (const auto& l : *lights) {

‎pymomentum/test/test_renderer.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,86 @@ def mean_absolute_difference(
111111
self.assertLess(np.mean(right_lighting[:, : image_width // 2]), 1e-5)
112112
self.assertGreater(np.mean(right_lighting[:, image_width // 2 :]), 0.1)
113113

114+
def test_headlight_lit_under_camera_rotation(self) -> None:
115+
"""A camera-forward "headlight" must light the camera-facing surface at
116+
any camera orientation, not just at the equator.
117+
118+
Regression test for an inverse-transform bug in
119+
``convertLightsToEyeSpace`` (``pymomentum/renderer/rasterizer_utility.h``):
120+
lights were transformed to eye space by ``worldFromEye`` (eye->world),
121+
the INVERSE of the ``eyeFromWorld`` (world->eye) transform used for the
122+
surface normals. The shading ``dot(light, normal)`` was therefore only
123+
correct when the camera view rotation was symmetric (identity/equator);
124+
at tilted or polar cameras the camera-facing surface went dark.
125+
126+
A headlight-lit sphere looks identical from every direction, so rotating
127+
the camera around it must produce an equally-bright image.
128+
"""
129+
image_width, image_height = 30, 30
130+
intrinsics = pym_camera.PinholeIntrinsicsModel(
131+
image_width=image_width, image_height=image_height
132+
)
133+
134+
far_clip = np.float32(3.4028234663852886e38)
135+
136+
def headlight_brightness(camera: pym_camera.Camera) -> float:
137+
z_buffer = pym_renderer.create_z_buffer(camera)
138+
rgb_buffer = pym_renderer.create_rgb_buffer(camera)
139+
pym_renderer.rasterize_spheres(
140+
center=np.array([[0, 0, 0]], dtype=np.float32),
141+
lights=[
142+
pym_renderer.Light.create_directional_light(
143+
camera.world_space_principle_axis
144+
)
145+
],
146+
radius=np.array([5], dtype=np.float32),
147+
camera=camera,
148+
rgb_buffer=rgb_buffer,
149+
z_buffer=z_buffer,
150+
)
151+
rendered = z_buffer < far_clip
152+
self.assertGreater(int(rendered.sum()), 0, "sphere was not rendered")
153+
return float(np.mean(rgb_buffer[rendered]))
154+
155+
distance = 20.0
156+
base = pym_camera.Camera(intrinsics)
157+
origin = np.array([0, 0, 0], dtype=np.float32)
158+
159+
# Baseline: camera on the equator, looking along +Z at the sphere.
160+
equator_brightness = headlight_brightness(
161+
base.look_at(
162+
position=np.array([0, 0, -distance], dtype=np.float32),
163+
target=origin,
164+
)
165+
)
166+
self.assertGreater(equator_brightness, 0.05, "equator headlight is not lit")
167+
168+
# (camera position, up vector) for a range of tilted / polar poses. The
169+
# ``up`` vector is chosen non-parallel to the view direction.
170+
posed_cameras = [
171+
([0.0, -distance, 0.0], [0.0, 0.0, 1.0]), # straight up (90 deg)
172+
([0.0, distance, 0.0], [0.0, 0.0, 1.0]), # straight down (90 deg)
173+
([0.0, -14.14, -14.14], [0.0, 1.0, 0.0]), # 45 deg elevation
174+
([-distance, 0.0, 0.0], [0.0, 1.0, 0.0]), # 90 deg azimuth
175+
([-14.14, -14.14, 0.0], [0.0, 1.0, 0.0]), # azimuth + elevation
176+
]
177+
for position, up in posed_cameras:
178+
camera = base.look_at(
179+
position=np.array(position, dtype=np.float32),
180+
target=origin,
181+
up=np.array(up, dtype=np.float32),
182+
)
183+
brightness = headlight_brightness(camera)
184+
self.assertAlmostEqual(
185+
brightness,
186+
equator_brightness,
187+
delta=0.05,
188+
msg=f"headlight brightness at camera position {position} "
189+
f"({brightness:.4f}) differs from equator baseline "
190+
f"({equator_brightness:.4f}); the camera-facing surface should "
191+
f"be lit identically from every direction",
192+
)
193+
114194
def test_subdivide_uneven(self) -> None:
115195
"""Check that we can do uneven subdivision where only some edges are split."""
116196

0 commit comments

Comments
 (0)