Skip to content

feat: mesh materials and mesh shadows - #425

Merged
oarriaga merged 5 commits into
refactor/renderer-stylefrom
feat/mesh-shadows-and-materials
Aug 9, 2026
Merged

feat: mesh materials and mesh shadows#425
oarriaga merged 5 commits into
refactor/renderer-stylefrom
feat/mesh-shadows-and-materials

Conversation

@oarriaga

@oarriaga oarriaga commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Stacked on #424, so review that one first. Three things meshes could not do,
plus the restructure that made room for them.

Meshes now honour reflective/transparency/refractive_index, cast shadows, and
receive them. All three were already half-present: scene.stack_materials
compiled the material fields and the renderer substituted a hardcoded
(0, 0, 1); shadow rays only ever intersected shapes; and compute_triangle_colors
shaded without an occlusion term, so a mesh-only scene ignored the shadows flag
entirely. select_meshes also never got shadow_mask, so CompiledScene gains
triangle_shadow_mask and the "does not cast" control now works for meshes
instead of being silently ignored.

renderer.py is now a package, one module per pipeline stage: rays (the bounce
loop), tiling, intersect, shade, shadow, optics, material. The loop reads
intersect -> shade -> advance. "trace" is gone, since in a JAX-first library it
already means jit tracing. Four shape-rendering examples patched
renderer.compute_soft_occlusion, which the split would have silently turned
into a no-op; they patch renderer.shadow now.

Verification: 258 passed, 1 skipped on GPU across paz/graphics/. Every new
capability has a test that fails before its commit. Benchmarked min-of-30 on a
quiet GPU against 481f4f3, forward and backward:

  • shape-only, 8 cases: -19.3% to +5.1%, gradients bit-identical
  • mesh/mixed with shadows off: within +/-3%
  • mesh/mixed with shadows on: +17% to +55% forward, +49% to +96% backward

The last row is the cost of shadows meshes never computed before. Mixed scenes
pay the most because occlusion is computed twice per light, once for shape
receivers and once for triangle receivers -- the mixed-scene duplication the
blend_hit_colors TODO describes, now extended to shadows. Unifying the
candidate table would remove it and is the obvious follow-up.

TRIANGLE_SELF_HIT_EPSILON is 1e-3 from measurement, not taste: on a
self-shadowing sphere mesh, values at or below 1e-5 leak self-hits, the
correction saturates at 1e-3, and 1e-3 to 5e-2 are flat.

renderer.py becomes paz/graphics/renderer/, one module per stage of the
pipeline: rays (the bounce loop), tiling, intersect, shade, shadow,
optics, material. The loop body now reads intersect -> shade -> advance
instead of burying the scatter step inside update_state.

Avoids "trace", which in a JAX-first library already means jit tracing:
trace_bounces is rays.render, trace_chunks is rays.render_chunks.

The four shape-rendering examples patched
paz.graphics.renderer.compute_soft_occlusion, which the split would have
silently turned into a no-op; they now patch renderer.shadow directly.
The tiling module is not called "tiles" because render() takes a public
tiles= keyword that examples pass by name.

No behaviour change. 106 tests pass in renderer_test.py and mesh_test.py
including all snapshots; all 16 benchmark gradients are bit-identical to
the pre-split values.
scene.stack_materials already compiles reflective, transparency and
refractive_index for every mesh, and both Material and
CookTorranceMaterial declare all three. The renderer threw them away and
substituted a hardcoded (0.0, 0.0, 1.0) row for all triangles, so a
mirror or glass mesh rendered as matte and opaque.

Gather the real values per primitive via triangle_hit.primitive and
select between shape and triangle values on the closest hit.

Meshes that leave these fields at their defaults render identically, so
existing scenes are unaffected. Two new mesh_test cases cover a
reflective and a transparent mesh; both fail before this change.
Shadow rays only ever intersected shapes, so a mesh lit from above cast
nothing onto the floor beneath it. Shadow rays now also traverse the
triangles and join the shape blockers as one more caster row before the
soft-occlusion reduction.

select_meshes never received shadow_mask, so the public "this node does
not cast" control had no mesh equivalent. CompiledScene gains
triangle_shadow_mask and scene.compile fills it, which keeps that
control working for meshes instead of silently ignoring it.

The shape path rejects a shadow ray re-hitting its own surface by shape
identity. Triangles have no such index, so they use a distance
threshold instead; meshes do not receive shadows yet, so today the
receiver is always a shape and the threshold only guards shape
receivers close to a mesh.

Two mesh_test cases cover casting and the mask suppressing it; both fail
before this change.
The invented TRIANGLE_SELF_HIT_EPSILON was unexercised: sweeping it over
five orders of magnitude across three scene layouts produced
byte-identical renders, because with only shape receivers a shadow ray
can reach a triangle at near-zero depth only if the shape is coincident
with the mesh. Use the EPSILON the shape path already applies to
non-self hits instead of a new magic number.
compute_triangle_colors shaded with compute_colors and no occlusion, so
a mesh was never darkened by anything and a mesh-only scene had no
shadows at all regardless of the shadows flag. Triangles now take the
same per-light occlusion the shape path uses.

compute_occlusion takes a Receiver (points, normals, shape indices)
instead of a Hit, so triangle hit points can be fed in; a triangle
receiver uses NO_SHAPE so the shape self-hit rejection never matches it.
Shape blockers are now skipped when a scene has no shapes, which a
mesh-only scene with shadows would previously have crashed on.

Reinstates TRIANGLE_SELF_HIT_EPSILON, this time with evidence: on a
sphere mesh self-shadowing at its terminator, values at or below 1e-5
leak self-hits, the effect saturates at 1e-3, and 1e-3 to 5e-2 are flat.
The spurious self-shadow it removes is worth up to 9 grey levels.
@oarriaga
oarriaga merged commit d00f6a6 into refactor/renderer-style Aug 9, 2026
@oarriaga
oarriaga deleted the feat/mesh-shadows-and-materials branch August 9, 2026 05:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant