feat: mesh materials and mesh shadows - #425
Merged
oarriaga merged 5 commits intoAug 9, 2026
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.