From df89568c32a06adb4b5867cde66c8f25bbf1e97d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:27:15 +0200 Subject: [PATCH] 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 --- ...brush-surfaces-and-sdf-bricks-prior-art.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/research/brush-surfaces-and-sdf-bricks-prior-art.md diff --git a/docs/research/brush-surfaces-and-sdf-bricks-prior-art.md b/docs/research/brush-surfaces-and-sdf-bricks-prior-art.md new file mode 100644 index 0000000..89eef2a --- /dev/null +++ b/docs/research/brush-surfaces-and-sdf-bricks-prior-art.md @@ -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).