Research: prior art on brush surfaces, CSG topology, microbevels and SDF bricks

Eyes pass 2026-10-10: Chisel surfaces and UVs, hg_sdf blends, Dreams
(full slides), Claybook, Keeter MPR and Fidget, mesh distance fields, and
this crate's current state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 02:27:15 +02:00
commit df89568c32

View file

@ -0,0 +1,94 @@
# Brush surfaces, CSG topology, microbevels, and SDF bricks/tapes: prior art
Eyes pass, 2026-10-10, before a campaign target for the collaborative Blender brush loop (operator: brushes carry UVs, structured trees, microbevels at cuts "like smin in sdf land", marching optimised tapes in a brick map; "Dreams had issues with this approach because their artists wanted long lists of edits. We are going for a structured approach. The tape should be pretty short after simplification."). Facts only; tags mark FETCHED, SEARCH and KNOWLEDGE (unverified). Related: CultLib docs/research/inverse-procedural-modelling-prior-art.md.
# Prior art: brush surfaces/UVs, CSG topology, SDF microbevels, SDF bricks and tapes (Eyes, 2026-10-10)
Tags: FETCHED = read in full or in part this pass from the primary source; SEARCH = search-result snippet only; KNOWLEDGE = memory, unverified.
Local copies of fetched PDFs/sources: scratchpad\eyes-brush-sdf\dl\ (evans.txt, mpr.txt, clay.txt, hg.glsl, gst.cs).
Not found / not read: Iq's smin article (fetch 404, search snippets only), TrenchBroom/Valve 220 spec (403/not surfaced), RealtimeCSG closed kernel, Houdini Boolean SOP docs, Blender bevel docs, Dreams later talks (not looked at), Lumen/Nanite talks.
## 0. Our substrate (FETCHED, local files read)
- Primitive enum (brush.rs): Box{bounds Aabb}, OrientedBox{center,size,rotation}, CylinderZ{center,radius,depth,segments}, DomeCapZ{center,radius,height,rings,segments}, FloretArm{anchor,direction,length,root_width,tip_width,thickness,tip_lift}. BrushOp = Add | Subtract | Intersect. Brush carries id, name, op, primitive, material: MaterialId, rotation Quat, generation.
- assembler.rs: BuildWarning::SubtractIgnoredForNonBox / IntersectIgnoredForNonBox (lines 14-15, 804, 837); BrushOp::Intersect returns None at lines 370, 411 in some paths. Doctrine doc says kernel is "still a carver; target is a classifier" (docs/realtime-csg-doctrine.md).
- mesh.rs TriangleMesh: positions, normals, uvs, indices, triangle_materials (per-triangle MaterialId). append_quad assigns uvs (0,0),(1,0),(1,1),(0,1) to every quad (full unit square per quad); append_triangle takes caller uvs. No per-face smoothing group, no tangent, no brush-id/surface-id per triangle.
- field.rs: Primitive::distance(p): Box/OrientedBox/CylinderZ exact; DomeCapZ half-ellipsoid bound (ellipsoid_bound .max(base plane)); FloretArm max-of-planes bound. Fold: Add=min, Subtract=max(d,-b), Intersect=max. Honours every op on every primitive (unlike polygon kernel). No smooth blend exists.
- tree.rs has CsgTree/CsgBranchOp/CsgNode arena; cult_geometry.rs holds CultCache records incl. CultGeometryTriangleMesh.
## A. Surfaces/UVs/materials through CSG
### Chisel (RadicalCSG/Chisel.Prototype, successor to RealtimeCSG; Sander van Rossen) - FETCHED source
- Per-polygon surface = ChiselSurface { ChiselMaterial(material + metadata), SurfaceDetails } (Core/1.Input/Surfaces/ChiselSurface.cs). SurfaceDetails = { SmoothingGroup (uint, 0 = none), SurfaceDetailFlags (TextureIsInWorldSpace, VertexPainted), UVMatrix UV0 } (SurfaceDetails.cs).
- UVMatrix = 2x4 matrix U,V (Vector4 each): UV = M * (position,1) - planar projection per polygon; "more UVMatrices can be added" for more channels. Comment: SmoothingGroup "should be part of postprocessing data"; normals smoothed only when generated per VertexChannelFlags.
- GenerateSurfaceTrianglesJob.cs lines 201-312: UV0 matrix from baseSurfaces[surfaceIndex] is converted tree-space -> brush-local -> plane-space (math.mul(UV0.ToFloat4x4(), treeSpaceToPlaneSpace)) and applied to each emitted vertex position (uv0 = mul(uv0Matrix, float4(position,1)).xy). I.e. UVs are evaluated from the final vertex positions at output time, not interpolated through splits. If interiorCategory is ReverseAligned the normal is negated (line 293). Tangents/binormals computed afterwards from uv deltas (ComputeTangents, line 415+, double precision).
- Each emitted polygon carries the surfaceIndex of the base polygon of the brush that owns the plane; the cut face of a subtractive brush is that cutter's own base polygon, categorised ReverseAligned and rendered with the cutter's surface (inferred from categories + baseSurfaces[surfaceIndex] usage; not stated in a doc).
- MergeTouchingBrushVerticesJob.cs exists but its body is commented out (/* at line 13). HashedVertices container exists in 2.Processing/Containers (vertex welding by hash). Pipeline job list includes CreateIntersectionLoopsJob, PerformCSGJob, FindLoopOverlapIntersectionJob.
### Quake/Hammer/Valve 220
- SEARCH: Hammer "component texture lock" keeps UV representation when vertices/faces move (stretching possible). TrenchBroom feature lists: supports Quake Standard and Valve 220 formats, "precise texture lock for all brush editing operations", 2/3-point clip tool. DarkRadiant bug report (SEARCH): subtract gives newly exposed target faces the cutter's texture; reporter says that is usually undesired.
- KNOWLEDGE (unverified): Valve 220 face line stores two texture axis vectors with offsets plus scale per face (`( x y z off ) ( x y z off ) rot sx sy`), world-space planar projection, so splitting a brush leaves the same projection on both pieces; Quake standard derives axes from the face normal (6 axis-aligned cases).
- SEARCH: Valve wiki says axes should define a plane parallel to the face else stretching.
### Blender / Houdini / Unity
- Blender Boolean modifier (FETCHED, 5.2 manual): solvers Float (no overlap, no Intersect on collections), Exact (overlap, slower), Manifold (fastest, manifold meshes only). Exact-solver Materials option: Index Based (map slot i to slot i; higher index falls to first) or Transfer (use source mesh's materials, add slots). The extracted Boolean page text contains no statement about UV or attribute interpolation (checked for "uv": none).
- Houdini (SEARCH only): SideFX forum - new Boolean SOP transfers shop_materialpath and uv attributes from a cutter; Boolean Fracture SOP has "Copy Cutting Surface Attributes", Recompute Normals, Exterior Cusp Angle; forum: Houdini does not auto-fix UVs on new openings, use pinned seams / UV Fuse.
- ProBuilder, Unity-side texgen docs: NOT researched.
### Seam placement on curved primitives
- NOT researched with sources (cube-sphere vs UV sphere vs octahedral, torus, sweep). Evidence-bearing only for our own code: CylinderZ{segments}, DomeCapZ{rings,segments} are tessellated, mesh.rs quad uv = unit square per quad.
## B. Topology from CSG
- Chisel (FETCHED): output is per-surface loops of edges (CreateIntersectionLoopsJob -> PerformCSGJob) triangulated in GenerateSurfaceTrianglesJob (polygon loops per base surface -> triangle index list; indexRemap per surface). Vertices are shared via HashedVertices (hash welding). Not found: explicit T-junction handling statement in code read.
- Our doctrine doc names the RealtimeCSG pipeline "split only at crossing planes, classify through tree, emit visible polygon pieces" (docs/realtime-csg-doctrine.md, local).
- Systems that regenerate clean topology from a parametric tree: none found this pass. KNOWLEDGE (unverified): libfive/Curv dual-contouring meshers produce triangle soup without UV (see section E).
- Prior doc already gathered (not repeated): CultLib\docs\research\inverse-procedural-modelling-prior-art.md.
## C. Microbevels at cuts
### hg_sdf (Mercury) - FETCHED source mercury.sexy/hg_sdf/hg_sdf.glsl lines 696-725, 823-830
- fOpUnionChamfer(a,b,r) = min(min(a,b), (a - r + b)*sqrt(0.5)); fOpIntersectionChamfer = max(max(a,b),(a + r + b)*sqrt(0.5)); fOpDifferenceChamfer = IntersectionChamfer(a,-b,r).
- fOpUnionRound(a,b,r): u = max(vec2(r-a, r-b), 0); return max(r, min(a,b)) - length(u) (quarter-circle join); fOpIntersectionRound: min(-r, max(a,b)) + length(max(vec2(r+a,r+b),0)); fOpDifferenceRound = IntersectionRound(a,-b,r).
- fOpGroove(a,b,ra,rb) = max(a, min(a+ra, rb-abs(b))); fOpTongue(a,b,ra,rb) = min(a, max(a-ra, abs(b)-rb)).
- Header (lines 5, 101-108, 670): library "for building signed distance bounds"; rule "Stay Lipschitz continuous"; never multiply distances to "fix" it; comment at line 670 says its operators "produce correct distances or distance bounds, unlike" smooth-min-style ones (line ~655: min() replacement "does not preserve Lipschitz").
- Radius-limited: all join ops affect only where both fields are within r.
### Dreams soft blend (FETCHED, Evans slides 30-31): soft_min(a,b,r){ e=max(r-abs(a-b),0); return min(a,b)-e*e*0.25/r; } credit Dave Smith (Media Molecule). "Equals hard min after distance r"; "Soft blend breaks ALL THE CULLING": must revert to hard min beyond r, and culling must account for the "future soft blend" range; visualisation shows blend increases per-voxel edit counts over a large area.
### Quilez smin (SEARCH/ fetch failed)
- SEARCH: iquilezles.org/articles/smin rewritten March 2024 (tweet by iq): adds normalization, kernels, circular smooth-min, analysis of quadratic/cubic/quartic/circular/exponential/sigmoid families. Exponential variant is order independent; polynomial and root variants are not (SEARCH). Polynomial: h = clamp(0.5+0.5(a-b)/k,0,1); mix(a,b,h) - k*h*(1-h) (FETCHED via vgracianos blog, matches).
- SEARCH: a Shadertoy comment states smooth-min is not SDF-preserving (distorts distance near the surface). Exact/bound classification of each variant: NOT confirmed from the article.
### Mesh-side (SEARCH only)
- Hard Ops/Boxcutter docs: Sharpen marks sharp edges for bevel; Bevel adds a Bevel modifier; Normal Transfer from stashes needs booleans applied; Smart Apply applies booleans/mirrors preserving bevels; community tip to reduce bevel-modifier stress (profile 1, 3 segments). Hardsurface Doctor (third party): store bevel weights, apply booleans, safe repair.
- Weighted normals, baking bevel normals from SDF onto a mesh: no source found. Keeter MPR computes normals by forward-mode autodiff of the tape (below), evaluated at 32-bit float then quantised to 8 bit.
## D. SDF bricks and tapes
### Dreams, Evans SIGGRAPH 2015 "Learning from Failure" (FETCHED, evans.txt from advances.realtimerendering.com/s2015/AlexEvans_SIGGRAPH-2015-sml.pdf)
- Representation: "simple list, not tree" of CSG edits ("right leaning"); add/subtract/colour-only; models "anything from 1 to 100,000 edits"; domain deformation and non-local effects (blur) excluded. Core idea "lists of simple SDF elements ... still how all sculptures are made in Dreams ... the opposite of a failure".
- First PC prototype: compound SDF in 83^3 fp16 volume-texture blocks, incrementally updated per edit, each block meshed by marching cubes in compute (histopyramids); abandoned for storage/animation: "terrible for loading and animation", UGC must be small so SDF cannot be stored, "we need a fast evaluator".
- Evaluator ("CS of doom"): edit list -> sparse SDF; pipeline 40+ compute shaders, a few 3000+ instruction shaders. Brute force = 100k edits x ~1000^3 grid = "100 billion evaluations, which is too many". Anton's hierarchical scheme: start 4x4x4 coarse grid, per-cell list of edits that can overlap, split by 4x4x4 (fits 64-wide wave), shortening lists per child; empty/full cells marked early; split heuristics hard (too little split = gaps, needs 1 to 1.5 voxels valid each side of surface; too much = orders of magnitude extra work; seamless splits required). Max-norm (d = max|x|,|y|,|z|) used because many primitives' distance is simpler (quadratics) and cubes match tree nodes (ref "Efficient Max-Norm Distance Computation and Reliable Voxelization"). Interval arithmetic added for extra culling ("because our distance fields are good quality").
- Dispatch counts for "crystal's dad": eval 60, sweep 91, points 459, bricker 73.
- Stats table (slide 32-33; columns elements, blocks, block evals, voxels, evaluator time, clusters, bricks): crystals_dad 8,274 elements, 5.24M voxels, evaluator 0.091 (units not labelled, likely s), 99.90% culling; big_mech 850 el, 4.12M vox, 0.0151, 99.78%; sphere_woman 24,407 el, 0.85M vox, 0.342, 99.22%; head_lion 53,976 el, 4.68M vox, 0.227, 99.96%; head40 22,484 el, 2.46M vox, 0.143, 99.93%; bolt_lock 1,830 el, 1.02M vox, 0.0217, 98.30%; whirlwind 791 el, 10.4M vox, 0.0344. Per-prim-per-block column ~1.7e-8 to 9e-8.
- Slide text: "range of edits from 600 - 53000 (the worst is around 120k, but that's atypical)"; "culling rates compared to brute force are well over 99%. we get 10m - 100m voxels evaluated per second on a ps4, from a model with tens of thousands of edits." Slide caption: 8,274 edits "crystal's dad"; 22k edits head40.
- Does the talk attribute brick-evaluation/raymarch cost to edit count? Evaluator cost is discussed vs edits/voxels (culling rate, soft blend enlarging edit-per-voxel counts, "orders of magnitude too much work" from over-splitting). The render-side rejection reasons in the talk are NOT edit count: Volumetric Billboards "too much fillrate"; Gigavoxels "single object"; rasterised-brick hybrid "too hard to render OIT overlaps"; froxel refinement "too slow" (~4x-10x too slow, ran ~30 fps "but only just", lit every leaf voxel, load imbalance from unbounded edge depth); gigavoxel brick memory struggled; art direction: rendered SDF "looked like untextured poly engine" yet slower; "thin feature aliasing" listed as an unsolved problem. No per-brick or per-pixel cost figure is given as a function of edit count.
- Brick engine (Engine 2, slides 67-70): Gigavoxels-style brick tree with LOD cut chosen by view distance; each 8x8x8 brick rasterised as a cube in an atlas; pixel shader raymarches from cube face to 0-crossing ("tiny POM/raymarch inside each 8x8x8 cube"), writes depth; vertices skinnable; "main engine artists used for a couple of years" ("hard" variant, forward lit).
- Shipped: splat engine (Engine 4). Evaluator output is a point cloud; Hilbert-sorted 4^3 bricks, clusters of ~256 points, LOD target ~1 splat/pixel; clusters splatted by compute with 64-bit atomic min (z+id) into a 1080p buffer, stochastic transparency + TAA; perf stat: 28.2M splats in 4.38 ms (~640M single-pixel splats/s) on PS4. Dreams still evaluates edit lists on GPU each load (UGC stores edits, not fields).
### Claybook, Aaltonen GDC 2018 (FETCHED clay.txt from media.gdcvault.com/gdc2018/presentations/Aaltonen_Sebastian_GPU_Based_Clay.pdf)
- World SDF: volume texture 1024x1024x512, 8-bit signed, 586 MB with 5 mips, distance clamped [-4,+4] voxels, 256 values/8 voxels = 1/32 voxel precision; max step doubles per mip.
- World built by combining N brushes (offline-baked volume textures 32^3 to 128^3 = 32 kB to 2 MB, each with translation, rotation, uniform scale) in compute; "Runtime performance not dependent on brush count" (grid cull per tile); sparse mip generation; "only ~10% of mip0 8x8x8 tiles used" on one slide.
- Rendering: sphere-trace with mip hopping; cone-trace pre-pass; timings Xbox One base @720p / Vega @4K: primary+AO rays 0.2 ms / 0.2 ms; cone pre-pass 1.5/1.6; shadow rays 1.7/1.9; material+gbuffer 0.8/1.0; 60 fps target all consoles. Cache note: "8 MB data accessed (512 MB), 99.85% cache hit rate". Failed: wave-ballot ray load balancing, random rollback; noise from mip sampling.
- No microbevel/thin-feature statement read.
### Keeter, MPR (SIGGRAPH 2020) (FETCHED, mpr.txt, mattkeeter.com/research/mpr/keeter_mpr20.pdf)
- Hierarchy: 64^2 pixel (64^3 voxel) tiles, 8^2 (8^3) subtiles, 4^3 microtiles, evaluated by interval arithmetic; tile classed inside/outside/ambiguous; a shortened tape is built per ambiguous tile (pruneTape walks the tape in reverse, tracking live slots; min/max clauses whose interval choice is unambiguous drop the losing branch; CHOICE_BOTH kept).
- Pruning figures: 2D text benchmark, full tape 6,056 clauses (2,354 min/max): 64x64 tiles average 356 +- 125 clauses (17x reduction); 8x8 subtiles average 28 +- 13 (216x reduction). Abstract: complexity "decreases by two orders of magnitude".
- Tape sizes of models (Table 3): architectural model 961 clauses (465 CSG), bear head sculpt 541 (27 CSG, smooth blends via exp/log), text 6,056 (2,354), gears 1,735 (374). Slot counts after register allocation: 26/22/75/84 slots (Table 2).
- Cost: interpreter brute force 58 ms/MP vs compiled 3 ms/MP (19x overhead); full algorithm beats compiled kernel from ~1536^2 and stays under 8 ms/frame at 4096^2 (2D). 3D (GTX 1080 Ti, perspective heightmap): 256^3 11.3 ms, 1024^3 41.1 ms, 2048^3 191.0 ms (GT 750M: 111.3 / 503.6 / 2352.1; V100 20.3 / 228.3 ... as tabled, columns partially garbled by text extraction). Tape generation + upload on PC: 7 ms for text benchmark, 1-2 ms others. Most effort is walking the tapes, "limited by local memory bandwidth (reads/writes of slots)". Scratch for tapes: 1 GB+ buffer, 64-clause chunks claimed atomically.
- Normals: forward-mode autodiff over the (shortened) tape, float32 then 8-bit; shortened tape from the smallest region containing the pixel. Bear sculpt (smooth blends, few CSG clauses) is "less perfectly matched" to pruning: exp/log blends expensive and cannot be pruned as well (paraphrase of paper passage near Table 3). Only C0 required; non-Lipschitz fine.
- Per-tile cost vs tape length after pruning: the paper gives lengths (above) and the frame times, not a per-tile ms vs length curve.
### Fidget (FETCHED README, mattkeeter.com/projects/fidget / github mkeeter/fidget)
- Rust; tape simplification from interval evaluation; JIT (aarch64/x86_64) for point, interval, SIMD (f32x4 ARM, f32x8 x86), gradient. Model rasterised, ms: 1024^3: libfive 66.8, MPR (GTX 1080 Ti) 22.6, Fidget VM 61.7, Fidget JIT 23.6; 1536^3: 127 / 39.3 / 112 / 45.4; 2048^3: 211 / 60.6 / 184 / 77.4 (M1 Max CPU for libfive/Fidget). README gives no tape lengths.
### Tape lengths for structured hard-surface models
- Only data points found: MPR architectural model 961 clauses (465 CSG) -> slots 75; gears 1,735; Dreams used 600 to 120k edits (typical 8k-54k) for organic sculpts. No published tape length for CSG-tree hard-surface models with ~10-100 brushes. Our substrate's own count: none measured.
### Unreal distance fields (SEARCH, Epic docs)
- Mesh Distance Field max volume per mesh 128^3 / 8 MB; Eight Bit option halves memory but may artifact on large or thin meshes; thin surfaces may not be representable, light leaking; Voxel Density project setting; non-uniform scale unsupported; generated offline (WPO deformation unknown to it). Global Distance Field = camera-centred clipmaps composited from mesh fields. A blog (unverified) says ~10 cm minimum thickness for Lumen. GigaVoxels, NanoVDB: not read (KNOWLEDGE only: GigaVoxels = brick tree w/ view-driven LOD, which Evans cites as basis of his brick engine).
### Microbevel thinner than a voxel (observations from sources)
- Claybook stores 1/32 voxel precision within a +-4 voxel band at 1024x1024x512 (FETCHED); Dreams required 1 to 1.5 voxels valid each side of surface (FETCHED) and lists "thin feature aliasing" unsolved in the refinement renderer; MPR/Fidget evaluate the closed-form tape per pixel/voxel, with no stored resolution, normals by autodiff (FETCHED). No source here measured bevel normal quality below voxel size for stored bricks.
## E. Hybrid: tree as source, mesh with UVs + SDF-derived detail
- No shipped example found that keeps a CSG/SDF tree as source and emits UV'd mesh plus SDF-derived normals/AO from the same tree.
- Pieces found (SEARCH): libfive/Curv manifold dual contouring for sharp-feature meshing; Unreal Geometry Script boolean + auto-UV/atlas; Prowl.Unwrapper (LSCM charts + pack); SDF Modeler 0.5.3 exports PLY only (artists decimate/UV elsewhere); Uniform 1.4 article title mentions SDF/UVs (content not read); SD5 Blender addon (CSG/SDF via libfive; content not read). Dreams ships point clouds from the edit list, not meshes (FETCHED). Claybook renders SDF directly, no mesh (FETCHED). Teardown (Aaltonen tweet, SEARCH): SDF converted to triangles for Jolt physics.
## Gaps for a follow-up pass
Iq smin article body; Valve 220/TrenchBroom manual; ProBuilder; Houdini/Blender attribute interpolation docs for Boolean; sphere/torus/sweep UV parameterisations with sources; Chisel T-junction handling; bevel normal baking; NanoVDB/GigaVoxels numbers; Dreams later talks (e.g. 2016-2020).