Skip to content

Add subdivision surfaces via OpenSubdiv (opensubdiv-rs 0.1.2) - #127

Open
doubleailes wants to merge 2 commits into
mainfrom
claude/opensubdiv-crust-render-pncmgo
Open

Add subdivision surfaces via OpenSubdiv (opensubdiv-rs 0.1.2)#127
doubleailes wants to merge 2 commits into
mainfrom
claude/opensubdiv-crust-render-pncmgo

Conversation

@doubleailes

Copy link
Copy Markdown
Owner

Integrate the pure-Rust opensubdiv-rs port of OpenSubdiv's Far/Sdc layers
(git tag 0.1.2 — zero dependencies, forbid(unsafe_code)) as the renderer's
subdivision method, applied at USD import.

A Mesh prim authoring crust:subdivisionLevel (int, default 0, clamped to 6)
is uniformly refined and snapped to the limit surface, with smooth
per-vertex shading normals flowing through every geometry path (committed
prototypes, deferred bakes via the inverse-transpose, and the
non-invertible-transform bake, which recomputes them post-transform).
Opt-in per prim rather than triggered by subdivisionScheme, since USD's
fallback scheme is catmullClark and honouring it alone would subdivide
virtually every authored mesh; the scheme still picks the algorithm
(catmullClark/bilinear/loop, none warns and keeps the cage). USD
creases (per-run or per-edge sharpness), corners and interpolateBoundary
are honoured; holes and faceVaryingLinearInterpolation are out of scope.

Ptex stays correct under refinement: FaceMap gains optional explicit
per-triangle corner UVs mapping refined triangles back into their base
cage face's unit square (via a synthetic face-varying channel refined with
linear-everywhere rules plus composed child->parent face maps), and
check_face_count now takes the authored cage's face count by signature.

Refinement happens in mesh_source before interning, so direct, deferred
and prototype paths all see it exactly once and MeshKey dedupes on the
refined arrays. Malformed cages and refiner errors warn and degrade to
the cage. CRUST_SUBDIV=0 forces level 0 everywhere (A/B kill switch).

samples/subdivision.usda shows cubes at levels 0-3 beside a fully
edge-creased cube whose limit surface is the cage itself. Every existing
sample renders bit-identical against pre-change goldens at 16 spp.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_015Rz6Y7NCzAjsxadgfrHoxL

Integrate the pure-Rust opensubdiv-rs port of OpenSubdiv's Far/Sdc layers
(git tag 0.1.2 — zero dependencies, forbid(unsafe_code)) as the renderer's
subdivision method, applied at USD import.

A Mesh prim authoring crust:subdivisionLevel (int, default 0, clamped to 6)
is uniformly refined and snapped to the limit surface, with smooth
per-vertex shading normals flowing through every geometry path (committed
prototypes, deferred bakes via the inverse-transpose, and the
non-invertible-transform bake, which recomputes them post-transform).
Opt-in per prim rather than triggered by subdivisionScheme, since USD's
fallback scheme is catmullClark and honouring it alone would subdivide
virtually every authored mesh; the scheme still picks the algorithm
(catmullClark/bilinear/loop, none warns and keeps the cage). USD
creases (per-run or per-edge sharpness), corners and interpolateBoundary
are honoured; holes and faceVaryingLinearInterpolation are out of scope.

Ptex stays correct under refinement: FaceMap gains optional explicit
per-triangle corner UVs mapping refined triangles back into their base
cage face's unit square (via a synthetic face-varying channel refined with
linear-everywhere rules plus composed child->parent face maps), and
check_face_count now takes the authored cage's face count by signature.

Refinement happens in mesh_source before interning, so direct, deferred
and prototype paths all see it exactly once and MeshKey dedupes on the
refined arrays. Malformed cages and refiner errors warn and degrade to
the cage. CRUST_SUBDIV=0 forces level 0 everywhere (A/B kill switch).

samples/subdivision.usda shows cubes at levels 0-3 beside a fully
edge-creased cube whose limit surface is the cage itself. Every existing
sample renders bit-identical against pre-change goldens at 16 spp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Rz6Y7NCzAjsxadgfrHoxL
@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Add opt-in USD mesh subdivision via opensubdiv-rs (0.1.2) with Ptex-safe face mapping

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add opt-in USD mesh subdivision (Catmull-Clark/Bilinear/Loop) at import time.
• Preserve Ptex correctness by remapping refined faces back to base-cage IDs.
• Add smooth shading normals through instancing/baking plus new sample and tests.
Diagram

graph TD
A[["usd_import.rs"]] --> B["mesh_source"] --> C[["scene/subdiv.rs"]] --> D[("Refined mesh")]
D --> E["triangulate + FaceMap"] --> F["face remap"] --> G["MeshArena/World"]
B -->|"level=0 or error"| D
subgraph Legend
direction LR
_mod[[Module]] ~~~ _proc[Process] ~~~ _data[(Data)]
end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Honor USD subdivisionScheme by default (no per-prim level knob)
  • ➕ Closer to naive USD expectations (catmullClark meshes subdivide automatically).
  • ➕ No custom crust:* authoring required.
  • ➖ USD fallback is catmullClark; would subdivide most meshes unintentionally (perf + memory blowups).
  • ➖ USD lacks a standard per-prim refinement level; control would be awkward/inconsistent with Hydra’s render-setting model.
2. Do subdivision at render time (kernel-side) instead of at import
  • ➕ Could allow view-dependent/adaptive refinement and avoid exploding mesh caches upfront.
  • ➕ Would centralize shading normal generation with final transforms.
  • ➖ Much larger architectural change: BVH building, instancing, and baking paths all change.
  • ➖ Harder to dedupe geometry reliably and to keep Ptex face IDs stable without extra indirection.
3. Use C++ OpenSubdiv via FFI instead of opensubdiv-rs
  • ➕ Mature implementation with feature-adaptive options and broad USD ecosystem use.
  • ➕ Potentially closer parity with Pixar’s reference behavior.
  • ➖ Adds unsafe/FFI surface area and toolchain complexity.
  • ➖ More difficult cross-platform builds; conflicts with the repo’s safe-Rust direction.

Recommendation: The PR’s approach (safe-Rust, import-time uniform refinement with an explicit per-prim level knob and a kill-switch) is the best fit for this renderer’s architecture: it refines exactly once before interning so all geometry paths share the same refined arrays, and it keeps Ptex stable by mapping refined faces back to cage faces. Consider feature-adaptive refinement later as a separate project if performance or fidelity demands it.

Files changed (9) +1556 / -39

Enhancement (4) +1168 / -34
rt_world.rsExtend FaceMap with optional explicit per-triangle UVs for subdivided meshes +80/-0

Extend FaceMap with optional explicit per-triangle UVs for subdivided meshes

• Adds FaceMap.uvs to carry per-triangle corner UVs mapping refined triangles into the base face unit square. Updates resolve() to interpolate explicit UVs when present and adds targeted unit tests for correctness and mirror handling.

crates/crust-core/src/rt_world.rs

scene.rsRegister new subdivision module in scene crate +1/-0

Register new subdivision module in scene crate

• Adds the subdiv module to the scene namespace so USD import can call into it.

crates/crust-core/src/scene.rs

subdiv.rsImplement uniform subdivision + limit snap + smooth normals + Ptex fvar channel +665/-0

Implement uniform subdivision + limit snap + smooth normals + Ptex fvar channel

• Adds a new subdivision module using opensubdiv-rs to uniformly refine cages, snap vertices to the limit surface, and compute smooth per-vertex shading normals. Implements USD crease/corner handling, boundary interpolation, and (when requested) a synthetic face-varying channel to compute sub-face UVs and refined→base face mapping; includes extensive unit tests.

crates/crust-core/src/scene/subdiv.rs

usd_import.rsIntegrate subdivision into USD mesh import with Ptex remap and normal baking +422/-34

Integrate subdivision into USD mesh import with Ptex remap and normal baking

• Introduces crust:subdivisionLevel (clamped, CRUST_SUBDIV kill switch) and performs refinement in mesh_source() before interning/triangulation so all paths share refined arrays. Threads smooth normals through committed, instanced, and baked geometry (inverse-transpose transform; recompute on singular transforms) and remaps refined face tables back to base-cage faces for Ptex; updates face-count checks to use authored cage faces.

crates/crust-core/src/scene/usd_import.rs

Tests (1) +131 / -0
usd_scene.rsAdd integration tests for subdivision sample and scheme=none behavior +131/-0

Add integration tests for subdivision sample and scheme=none behavior

• Adds scene-level tests that load samples/subdivision.usda and probe geometry via rays to validate limit-surface sagging, crease pinning, and smooth normal behavior. Adds a regression test ensuring subdivisionScheme=none refuses refinement even when a level is authored.

crates/crust-core/tests/usd_scene.rs

Documentation (2) +36 / -5
CLAUDE.mdDocument subdivision surfaces and CRUST_SUBDIV kill switch +35/-4

Document subdivision surfaces and CRUST_SUBDIV kill switch

• Adds user-facing documentation for opt-in subdivision via crust:subdivisionLevel, supported USD attributes (scheme/creases/corners/boundary), Ptex behavior, and the CRUST_SUBDIV=0 A/B switch. Updates Moana/Ptex notes to clarify subdivision is opt-in.

CLAUDE.md

embree_comparison.mdUpdate feature matrix to reflect new subdivision support +1/-1

Update feature matrix to reflect new subdivision support

• Updates documentation to note opt-in uniform subdivision at USD import (with current limitations) while keeping the kernel triangle-based.

docs/embree_comparison.md

Other (2) +221 / -0
Cargo.tomlAdd opensubdiv-rs git dependency pinned to tag 0.1.2 +5/-0

Add opensubdiv-rs git dependency pinned to tag 0.1.2

• Introduces opensubdiv-rs as a git dependency (tag-pinned) to provide Far/Sdc topology refinement in safe Rust. Documents rationale for tag pinning due to no Cargo.lock check-in.

crates/crust-core/Cargo.toml

subdivision.usdaAdd subdivision showcase scene with levels 0–3 and fully creased cube +216/-0

Add subdivision showcase scene with levels 0–3 and fully creased cube

• Adds a new sample USD scene demonstrating crust:subdivisionLevel behavior across levels, including a fully edge-creased cube that remains sharp. Includes materials, camera, lighting, and render settings to make behavior visually obvious.

samples/subdivision.usda

@qodo-code-review

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Loop Ptex IDs wrong 🐞 Bug ≡ Correctness
Description
For subdivisionScheme=loop meshes with a per-face (Ptex) texture, the importer triangulates the
*refined* mesh with want_faces=true but cannot remap refined face IDs back to authored cage face
IDs because Loop refinement does not produce SubdivFaces. This makes hits report incorrect
face_ids (some alias other cage faces; others exceed num_faces and fall back), while
check_face_count still passes because it compares against the authored cage face count.
Code

crates/crust-core/src/scene/usd_import.rs[R909-913]

+        // A subdivided face table numbers *refined* faces; rewrite it to the
+        // base-cage ids Ptex actually indexes before anything caches it.
+        let faces = match (&src.subdiv_faces, faces) {
+            (Some(sub), Some(map)) => Some(remap_subdivided_faces(map, sub)),
+            (_, faces) => faces,
Relevance

●●● Strong

Correctness bug affecting Ptex shading/face_id under Loop subdivision; aligns with repo’s focus on
Ptex correctness.

PR-#123

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The importer only remaps triangulated face tables when src.subdiv_faces exists; Loop refinement
does not produce it, so refined face indices become HitRecord.face_id even though Ptex expects
authored cage face IDs. Since the host Ptex sampler returns a fallback color on out-of-range face
IDs, this becomes silent wrong shading; and because check_face_count compares to the cage face
count, no warning is emitted when the texture matches the cage as authored.

crates/crust-core/src/scene/subdiv.rs[132-135]
crates/crust-core/src/scene/usd_import.rs[1246-1310]
crates/crust-core/src/scene/usd_import.rs[909-915]
crates/crust-core/src/scene/usd_import.rs[1378-1393]
crates/crust-render/src/main.rs[276-281]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Loop subdivision intentionally does not generate refined-face → base-cage-face remap metadata (`SubdivFaces`) and per-child param UVs, but the importer still builds a `FaceMap` from the refined topology when a material has `face_texture()`. This causes `HitRecord.face_id` to be interpreted as a Ptex face index even though it refers to refined faces, leading to wrong texturing (aliasing and/or fallback sampling) without a warning.

### Issue Context
- Loop subdivision is allowed (for all-triangle cages), and `want_face_uvs` is passed through from `want_faces`.
- In the remap step, the face table is only rewritten when `src.subdiv_faces` is `Some(_)`. For Loop it is `None`, so the refined IDs leak through.
- `check_face_count` now compares Ptex `num_faces()` against the authored *cage* face count, so a correct Ptex file for the cage will not warn even though runtime `face_id`s are wrong.

### Fix Focus Areas
- crates/crust-core/src/scene/subdiv.rs[132-135]
- crates/crust-core/src/scene/usd_import.rs[1246-1310]
- crates/crust-core/src/scene/usd_import.rs[909-915]

### Suggested fix
Implement one of these (prefer #1 if you want correctness over honoring Loop refinement):
1) **If `scheme == Loop` and `want_faces == true`, warn and degrade to cage** (return `cage(points, counts, indices)` in `mesh_source`). This preserves correct Ptex indexing and avoids silent wrong shading.
2) Alternatively, **disable face-table creation** for Loop subdivision (call `triangulate(..., want_faces=false)` or drop `faces` before caching) and warn that Ptex on Loop subdivision is unsupported; this avoids incorrect sampling but loses per-face texturing.
3) Full feature fix: implement a Loop-compatible base-face mapping + sub-triangle UV mapping (likely via a synthetic face-varying channel seeded with triangle-corner UVs and refined linearly, plus a composed child→parent face map), then plumb through a `remap_subdivided_faces` equivalent for triangles.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context used
✅ Compliance rules (platform): 121 rules

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +909 to +913
// A subdivided face table numbers *refined* faces; rewrite it to the
// base-cage ids Ptex actually indexes before anything caches it.
let faces = match (&src.subdiv_faces, faces) {
(Some(sub), Some(map)) => Some(remap_subdivided_faces(map, sub)),
(_, faces) => faces,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Loop ptex ids wrong 🐞 Bug ≡ Correctness

For subdivisionScheme=loop meshes with a per-face (Ptex) texture, the importer triangulates the
*refined* mesh with want_faces=true but cannot remap refined face IDs back to authored cage face
IDs because Loop refinement does not produce SubdivFaces. This makes hits report incorrect
face_ids (some alias other cage faces; others exceed num_faces and fall back), while
check_face_count still passes because it compares against the authored cage face count.
Agent Prompt
### Issue description
Loop subdivision intentionally does not generate refined-face → base-cage-face remap metadata (`SubdivFaces`) and per-child param UVs, but the importer still builds a `FaceMap` from the refined topology when a material has `face_texture()`. This causes `HitRecord.face_id` to be interpreted as a Ptex face index even though it refers to refined faces, leading to wrong texturing (aliasing and/or fallback sampling) without a warning.

### Issue Context
- Loop subdivision is allowed (for all-triangle cages), and `want_face_uvs` is passed through from `want_faces`.
- In the remap step, the face table is only rewritten when `src.subdiv_faces` is `Some(_)`. For Loop it is `None`, so the refined IDs leak through.
- `check_face_count` now compares Ptex `num_faces()` against the authored *cage* face count, so a correct Ptex file for the cage will not warn even though runtime `face_id`s are wrong.

### Fix Focus Areas
- crates/crust-core/src/scene/subdiv.rs[132-135]
- crates/crust-core/src/scene/usd_import.rs[1246-1310]
- crates/crust-core/src/scene/usd_import.rs[909-915]

### Suggested fix
Implement one of these (prefer #1 if you want correctness over honoring Loop refinement):
1) **If `scheme == Loop` and `want_faces == true`, warn and degrade to cage** (return `cage(points, counts, indices)` in `mesh_source`). This preserves correct Ptex indexing and avoids silent wrong shading.
2) Alternatively, **disable face-table creation** for Loop subdivision (call `triangulate(..., want_faces=false)` or drop `faces` before caching) and warn that Ptex on Loop subdivision is unsupported; this avoids incorrect sampling but loses per-face texturing.
3) Full feature fix: implement a Loop-compatible base-face mapping + sub-triangle UV mapping (likely via a synthetic face-varying channel seeded with triangle-corner UVs and refined linearly, plus a composed child→parent face map), then plumb through a `remap_subdivided_faces` equivalent for triangles.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Two measurement layers, both repeatable:

- An allocation-counting probe (test-only #[global_allocator] wrapping
  System) at the subdivide() boundary, run explicitly as an ignored test.
  Measured on a 64x64 quad cage at levels 1-4: ~313 B of transient
  requested bytes per refined face (the refiner retains every level - a
  x4/3 series - plus the position copies at the tail), ~556-592 B/face
  when the Ptex sub-face UV channel is on (the synthetic fvar channel is
  a full parallel hierarchy), and 48 / 84 B/face resident in the returned
  mesh. The probe asserts ceilings ~25% above those values, so a
  regression that starts retaining extra per-level data fails loudly.

- scripts/gen_subdiv_stress.py generates a measurable quad-cage scene
  (1.18 M refined quads at level 3, bake + instanced paths) for the
  existing --stats methodology, A/B'd with CRUST_SUBDIV=0: traverse-phase
  peak +310 MiB (within 6% of the probe's model) and kernel-resident
  memory scaling exactly x4 per level (34.67 MiB at level 1 -> 2.17 GiB
  at level 4).

The whole-process peak on subdivided scenes is the pre-existing SBVH
build transient, not opensubdiv (commit peak 2.19 GiB vs traversal peak
1.16 GiB at level 4); recorded in CLAUDE.md next to the existing build
transient note, along with the numbers and re-run commands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Rz6Y7NCzAjsxadgfrHoxL
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.

2 participants