Skip to content

added interior mapping example with equirectangular samples#2951

Open
codesavory wants to merge 3 commits into
AcademySoftwareFoundation:mainfrom
codesavory:issue-2526-interior-mapping
Open

added interior mapping example with equirectangular samples#2951
codesavory wants to merge 3 commits into
AcademySoftwareFoundation:mainfrom
codesavory:issue-2526-interior-mapping

Conversation

@codesavory

@codesavory codesavory commented May 25, 2026

Copy link
Copy Markdown
Contributor

Adds a basic example for the issue: #2526

Reference Renders

room.png (brick room):
screenshot_room

cornell.png (Cornell box):
screenshot_cornell

You can read more about the design, architecture and reasoning here:
interior-mapping-design.md

Design Notes

Interior Mapping for MaterialX — Design Notes

Issue: #2526 — Example graph for interior mapping
Branch: issue-2526-interior-mapping
Files: resources/Materials/TestSuite/nprlib/interior_mapping.mtlx, room.png, cornell.png


Background

Interior mapping is a shading technique that fakes 3D rooms behind flat window surfaces — no room geometry required. Each UV tile becomes a separate room instance. The core idea: cast a ray from the camera through the surface into a virtual box, find which interior wall the ray hits, sample a texture at that point.

The ask (from jstone-lucasfilm) is simple: add a working .mtlx example to the nprlib test suite. Tagged good first issue. No new node definition required for a first contribution — a bare nodegraph like starfield.mtlx is sufficient.


Existing nprlib Pattern

Four examples establish the range:

File Lines Pattern
edge_brighten.mtlx 18 bare nodegraph, no nodedef
starfield.mtlx 25 bare nodegraph, uses viewdirection — closest precedent
gooch_shade.mtlx 30 instantiates externally-defined node
toon_shade.mtlx 151 full nodedef + nodegraph + material instance, namespace

Interior mapping targets the starfield.mtlx pattern: bare nodegraph, viewdirection-based, no nodedef.


Three Reference Implementations

1. Godot GLSL (~90 lines)

  • Works in object space — vertex shader passes vertex and camera positions directly, ray direction = obj_vertex − obj_cam, no matrix math
  • Ray origin: actual camera position in object space, offset by delta; ceil() for tiled room boundaries
  • Intersection: per-axis with ceil() + step()
  • Face detection: if/else branching — determines which face was hit, picks UV swizzle (.zy, .xy, .xz), maps into 3×2 face atlas
  • Depth: scales ceiling/floor distance by room_depth × 2

2. Pablode (MaterialX XML, 155 lines)

  • Works in tangent space — builds TBN matrix from surface T/B/N, transposes it, transforms viewdirection through it; portable across any mesh orientation, costs ~6 extra nodes
  • Ray origin: from fractional UV (frac(u)×2−1, frac(v)×2−1, +1) — Z=+1 places origin on front face of [−1,1]³ cube; frac() gives per-tile tiling free
  • Intersection: vectorized AABB slab method — dist = |1/dir| − origin·(1/dir), then two min nodes; fully branchless
  • Face detection: none needed — normalizes hit position → spherical coordinates → equirectangular UV via atan2(x, z) + asin(y). Continuous, no per-face branching
  • Texture: single equirectangular panorama
  • Depth: multiply view direction Z by 1/depth

3. Jakethorn MXSL (~50 lines)

  • Works in object space via viewdirection("object"), negated explicitly
  • Ray origin: vec3(uv×2−1, −1) — same math as pablode, Z=−1 convention (opposite sign)
  • Intersection: explicit slab — dn = (−origin−1)/dir, dp = (−origin+1)/dir, max(dn, dp) per component
  • Face detection: if/else branching — checks which axis of hit position is closest to ±1, samples one of 5 separate image() calls
  • Texture: 5 separate images, one per visible face

Comparison Table

Godot Pablode Jakethorn
Coordinate space Object Tangent Object
Ray origin Camera pos Fractional UV Fractional UV
Branching Yes (6-way) No Yes (5-way)
Texture format 3×2 face atlas Equirectangular 5 separate images
Seam risk Face edges Poles Face edges
Textures needed 1 1 5

Why It Matters for MaterialX

MaterialX nodegraphs are pure dataflow — no if/else.

The Godot and jakethorn face-detection approaches require 6-way or 5-way branching. Expressing that branchlessly in MaterialX needs ~60 extra step()-based masking nodes to select and blend face UVs — defeats the "simple example" goal entirely.

Pablode's equirectangular approach was designed with this constraint in mind. The pipeline normalize → atan2/asin → image is fully branchless and maps cleanly to MaterialX dataflow nodes.


Design Decisions for This Implementation

Decision 1: Equirectangular projection (pablode approach)

Chosen because it's the only approach expressible without branching in MaterialX's dataflow model. Single texture, continuous UV mapping, ~30 nodes.

Decision 2: Tangent space (not object space)

Pablode's TBN approach is more portable. Object-space approaches only work correctly when the mesh is axis-aligned with the world — tangent space works on any oriented mesh surface.

Decision 3: No explicit negation needed — viewdirection is already inward

MaterialX viewdirection is documented as "from shading point toward camera," but HwViewDirectionNode.cpp (line 69) emits normalize(positionWorld − viewPosition) — surface point minus camera position — which is the camera → surface (inward) direction.

In tangent space, viewDir_tangent.z < 0 for a front-facing surface. The slab formula |1/dir| − origin·(1/dir) works correctly with this inward direction:

  • inv_dir.z = 1/dz where dz < 0, so inv_dir.z < 0
  • abs_inv_dir.z = |1/dz| > 0
  • dist.z = |1/dz| − 1·(1/dz) = |1/dz| − (negative) = 2/|dz| > 0

For a straight-on view (dz = −1), dist.z = 2, hitting the back wall at t = 2. For angled views min_dist < 2 and the ray hits a side wall. No negation node is needed.

Decision 4: atan2 seam fix — negate both dx and dz

Vanilla atan2(dx, dz) has its ±π discontinuity at dz < 0 (the back wall of the box). That's the most-viewed face — a visible seam artifact.

Fix: atan2(−dx, −dz) shifts the discontinuity to dz > 0 (the front face, +Z), which is never visible in interior mapping (the ray origin is on the front face, so the ray never hits it). Back wall, side walls, floor, and ceiling are all seam-free.

Decision 5: Rotation offset 0.25

After negating dx and dz, atan2=0 → lon = 0.5 (back wall center). Adding 0.25 keeps the same texture mapping as the unnegated version: back wall center → U = 0.75.

Decision 6: Precise spherical projection constants

Constant Value Source
Longitude scale −0.15915 −1/(2π), converts radians → [0,1]
Latitude scale 0.31831 1/π

Decision 7: Filter mode

filtertype="closest" is deliberate — bilinear filtering bleeds across the 0/1 UV wrap boundary, producing a visible horizontal seam artifact at the equirectangular face boundaries.


Node Graph Summary

viewdirection ─┐
tangent ────────┤
bitangent ──────┤─► creatematrix(TBN) ─► transpose ─► transformmatrix ─► viewDir_tangent
normal ─────────┘

texcoord ─► floor ─► subtract(frac) ─► ×2 ─► convert(vec3) ─► +(−1,−1,+1) ─► ray_origin

viewDir_tangent ─► 1/dir ─► |·| ─► abs_inv_dir ─┐
ray_origin ──────────────► ×(1/dir) ──────────────┤─► subtract(dist) ─► min3 ─► min_dist

ray_origin + viewDir_tangent × min_dist ─► hit_pos

hit_pos ─► normalize ─► separate3
  ├─ outx → ×(−1) = neg_dx ─┐
  ├─ outy → asin ─► ×(1/π) ─► +0.5 ─► latitude ──────────────────► combine2 ─► map_uv
  └─ outz → ×(−1) = neg_dz ─┴─► atan2(neg_dx, neg_dz) ─► ×(−1/2π) ─► +0.5 ─► +0.25 ─► longitude_rotated ┘

map_uv ─► image(room.png, periodic, closest) ─► out

Total: ~35 nodes. No branching. One texture.


What Was Ruled Out

  • Per-face UV + branching (Godot/jakethorn style): requires step()-based masking, ~60 extra nodes, defeats simplicity goal
  • Object-space approach: mesh-orientation-dependent, less portable
  • 5-texture variant (jakethorn): 5 image samples, more complex texture authoring requirements
  • Nodedef + surfacematerial wrapper (toon_shade style): deferred — maintainer asked for simple example first; formal node definition is a follow-up PR

Open questions

  1. Should we be worried about copyright of the images?
  2. What is the right place for the images?
  3. Should we add support for multi-textures variant?

I will create a separate PR for a node-def version for interior-mapping
@jstone-lucasfilm, @jakethorn, @pablode Please review and share feedback if you have a chance!

Attribution: Support of Claude-Code!


Changes since PR open

Commit 40e07cef
  • closest filter comment — added inline note that filtertype="closest" is deliberate: bilinear filtering bleeds across the 0/1 UV wrap boundary, producing a visible horizontal seam
  • Decision 3 rewrite — replaced hand-wavy "abs_inv_dir absorbs the sign" with a sourced proof: HwViewDirectionNode.cpp:69 emits normalize(positionWorld − viewPosition) (camera→surface, inward), so viewDir_tangent.z < 0 for front-facing surfaces and the slab formula gives dist.z = 2/|dz| > 0 without any negation node needed
  • Reference renders — see screenshots below (drag-dropped via GitHub web UI)

Texture provenanceroom.png and cornell.png are equirectangular panoramas placed alongside the .mtlx. Please advise if source documentation is required for ASWF compliance.

codesavory and others added 3 commits May 24, 2026 22:31
…ce renders

- Add comment to closest filtertype: explains bilinear bleed risk at UV seam
- Rewrite Decision 3 in design notes: replaces hand-wavy "abs_inv_dir absorbs
  the sign" with a sourced proof — HwViewDirectionNode.cpp:69 emits
  normalize(positionWorld - viewPosition), so viewdirection is camera-to-surface
  (inward), making viewDir_tangent.z < 0 and the slab formula correct as-is
- Add preview_room.png and preview_cornell.png: rendered reference screenshots
  for PR visual verification (plane.obj, 1280x720, MaterialXView --captureFilename)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codesavory

Copy link
Copy Markdown
Contributor Author

What is the review process, does this require more changes or should I add someone manually to the reviewers list?

@jstone-lucasfilm

Copy link
Copy Markdown
Member

@codesavory No additional changes needed before we can review, and we just need to find the time and focus!

@pablode

pablode commented May 27, 2026

Copy link
Copy Markdown
Contributor

Hi @codesavory, thanks for this contribution and some notes from my side:

  • I created the cube maps and put the whole repo under public domain, so they are fine to use. The textures seen in room.png are originally from Polyhaven and also licensed under CC0.
  • However I'm not sure if it makes sense to include them in this repository, given their large file sizes (20 MB and 10 MB) respectively. Perhaps they should be downsized, or a cubemap with solid colors could be good enough to showcase the effect.
  • The original implementation lead to the contribution of two new MaterialX nodes, <latlongimage> and <fract>. Perhaps it would make sense to use them to optimize the graph?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants