Repository navigation
Strand hair rendering
The strand path uses feathered ribbons with stochastic sample coverage. A kept sample writes opaque depth. A rejected sample reveals the surface behind it. This is an approximation of transparency.
It does not use sorted alpha blending. Issue 298 stays open until the required device and visual qualification is complete.
Use add_groom to grow and attach a humanoid groom. Use add_strands to attach a HairGroom that already exists. Both return HairStrands and create one geometry, one material and one child node. The child starts on its parent's current layers.
Its layers can then be set separately.
Pass a LineWidth to add_strands. It can name pixels or a physical length. The default is one output pixel with opacity 0.65. Opacity must be finite and between zero and one.
A width in pixels has that same width in a light map's texels. Use world units when the cast width must shrink with distance from a light.
Call hair.shade(assets, lights, camera, ambient) after motion or a lighting change. Give the lights and camera in the groom's local frame. HairStrands retains an interleaved position and color buffer. After the first installation, an update keeps the geometry and material ids and the buffer allocation.
A changed light count can resize the optical-depth workspace. A changed strand topology needs a new groom. A replaced geometry or a different store is refused before the retained buffer is written.
The renderer still builds camera-facing raster ribbons each frame. These generated raster vertices are not a persistent GPU strand simulation buffer. Use a persistent GpuRenderer for repeated GPU draws. Resource destruction remains subject to the scene and asset-store lifetime APIs.
STRAND_LINE_COVERAGE is an opt-in LineCoverage value. SOLID_LINE_COVERAGE is the ordinary LineSegments2 default. No ordinary line, cap, dash or alpha hash changes its rule.
The strand ribbon has a one-output-pixel linear feather. The cross-section is the box-filtered strip profile: its integral is the requested projected width. A subpixel ribbon has a lower plateau instead of an opaque center. Round caps use the same inner and outer radii as a radial approximation.
This cap rule reuses the strip's peak coverage. It is not an analytic filter of the physical disc area. A very short subpixel strand can therefore appear heavier than an area-preserving cap filter. The caps and neighboring segments can overlap at joints.
The approximation does not evaluate exact pixel-area intersections.
A fixed unsigned-integer spatial hash selects accepted samples. It uses world-position derivatives to set the cell size. The seed does not change with frame number or submission order. The grid repeats every 65536 cells.
Nearby fibers can share a threshold. Changes in projection or a cell boundary can change the noise during motion. There is no temporal accumulation or guaranteed noise-free silhouette.
The strand material must use opaque blending, depth testing and depth writes. It cannot use dashes. The coverage rule supplies the alpha hash itself. Opaque foreground geometry occludes accepted strand samples.
For distinct depths, reversing strand submission order leaves the image unchanged. Coincident surfaces retain the renderer's ordinary depth-tie rule. The feather can extend beyond the centerline bound, so the strand path does not use that bound to reject a visible ribbon.
The CPU and GPU call the same threshold function. Its integer operations wrap modulo 2 to the power 32. This removes sine-hash amplification. It does not prove that two devices interpolate coordinates or alpha identically.
The device parity tests include partial alpha, subpixel widths, crossing strands and camera motion. A compile-only result does not mean those device tests ran.
LineSegments2.cast_shadow is off by default. add_strands turns it on. Directional, spot and point light maps include these ribbons when the node is visible on the camera's layers. They use the same feather, material opacity and integer coverage hash.
Zero opacity writes no shadow depth. A hidden node or a disabled caster writes none. The normal renderer shadow-map path is used by CPU frames and by the maps uploaded for GPU frames. This is a binary stochastic depth-map approximation, not a deep opacity map.
HairDensity supplies direct-light self-shadow in strand space. Each cell stores segment length times fiber diameter divided by cell volume. The current positions rebuild the grid before shading. A ray toward each distant light integrates that density, and Beer-Lambert attenuation uses the result.
The default grid has 24 cells on each axis. The permitted range is four through 64. The default physical fiber diameter is 80 micrometers. The diameter must be finite and positive.
Construction, rebuilding and optical-depth queries check both settings. A query checks its finite point and light direction first, even when the grid is empty. After changing either setting, rebuild the grid before querying it. Invalid current settings now raise an error before a query reads the grid, including an empty grid. Earlier queries did not check these settings.
Rebuilding checks the rounded sample count before conversion to signed Int. A nonfinite count or a positive count outside that integer range raises an error. Earlier conversions had no defined result for these values. Finite counts at most one use one sample. This check adds no fixed sample cap and preserves healthy rebuild results.
This is a coarse volume approximation. It is not strand-exact visibility. The ray skips one cell near its origin to reduce self-occlusion from its own fiber. That bias also misses nearby fibers.
The existing HairLook.shadows limit still bounds the darkness. The scalp-facing term remains a head-occlusion approximation. The unlit strand material does not evaluate the scene's arbitrary receiver-shadow shader.
HairSimulation.step reuses its particle arrays. It does not allocate a work list for each strand on every step. By default every follower has its own simulated particles. With guides_only=True the step lays each follower from its moved guide. No GPU dynamics are used.
write checks the strand topology before changing the groom. It rotates each rest normal with the shortest rotation of the local tangent. A reversed tangent uses a deterministic half-turn axis. The scalp-depth value is a rest-surface proxy: outward displacement reduces it, and inward displacement increases it.
This proxy is not the moving density volume. The ambient term uses that proxy. Direct self-shadow uses the rebuilt density field.
The acceptance scenes are procedural. They add no scanned reference data.
- Fine silhouettes: horizontal subpixel fibers with zero, half and full opacity
- Crossing strands: two colors at distinct depths, reversed submission order and repeated frames
- Opaque occlusion: a foreground surface covering all strand samples
- Backlight: the existing Marschner TT and TRT controls with dynamic optical-depth attenuation
- Moving long hair: retained positions, changing tangents and shading fields, with a rebuilt density volume
- Cast shadows: directional, spot and point light maps with opacity, layer and visibility controls
test_hair_coverage checks image and depth behavior. Its short-strand control checks visibility, bounded sample energy and the response to width. It does not assert analytic cap-area conservation. test_hair_dynamic_fields checks projected-area conservation, moving shadow direction, motion fields and retained allocations.
test_strand_hash checks fixed unsigned-integer witnesses and state packing. The original wide-line, material-flag, hair and simulation suites remain compatibility controls. test_gpu includes device-only partial-coverage and cast-shadow parity fixtures.
The selected open-source targets remain the existing Frostbitten Hair WebGPU shading and dynamics model and TressFX guide/follower construction. They guide the visual comparisons. This implementation does not reproduce their complete renderer. The existing pinned asset revisions and licenses remain in Converted assets and THIRD-PARTY-NOTICES.md.
The new coverage, density grid and procedural scenes add no third-party asset or implementation dependency.
bench/hair_cost_bench.mojo times growth, the first upload, the four stages of shading and the simulation step and write at several groom sizes. With the runtime allocation hook it also counts the allocations of each stage on the calling thread. The cost report records the results of two machines and the decisions they support.
The report decides two things. Guide-only interpolation stays: a laid follower costs about one seventh of a simulated follower. The hair simulation and shading stay on the CPU: strand rasterization costs about 50 to 110 times more than all the hair stages together. The GPU work is strand drawing and its device upload. On the calling thread, no per-frame allocation grows with the groom.
Pass guides_only=True to HairSimulation to step the guides only. Each follower then keeps its groomed offsets in its moved guide's frame, and the step with its write costs about a third as much. Each follower must name a guide strand with as many points. The constructor refuses a groom when a follower does not, or when the strand starts or shading fields do not match the points.
Shading, its self-shadow depths, the simulation step, the follower layout and the write all run on every logical core. Each task owns a disjoint run of strands, so the results equal the serial arithmetic bit for bit. A measured guides-only frame of the default groom takes about 7.3 milliseconds on a 12-core desktop. It takes about 64 milliseconds on a shared four-core cloud machine. That is the hair alone, not a whole 60 frames-per-second frame.
bench/hair_strand_raster_bench.mojo renders body triangles and the default groom at 1920 by 1080 on the CPU. It doubles the worker count from one up to every logical core. It stops with an error when a worker count's warm-up image CRC-32 differs from one worker. It does not compare pixels or depths directly, or checksum the timed frames. The mixed frame report records the original author's measurements on one machine. It does not qualify the later combined branch.
A one-pixel strand has an inner feather radius of zero. Its middle strip and two of the three triangles in each cap step then have two corners at the same place. Neither rasterizer draws a triangle of zero area. So the renderer does not emit an uncut triangle with two corners at the same pixel coordinates. The image and the depths do not change. The default groom's frame holds 60 percent fewer corners.
Each strand corner is projected once and shared by the triangles that use it. A draw with a polygon offset projects each triangle again, so its depths do not change. test_strand_triangles checks the triangle counts, the offset path and a strand cut by the image edge.
On that machine a whole render at 4 workers fell from about 1.8 seconds to about 0.95 seconds. Rasterization and resolve now take most of it.
bench/hair_frame_bench.mojo times a whole 1920 by 1080 frame by stage. The frame report finds that the simulation and the shading take about 7 milliseconds. Preparing and drawing the strand triangles takes about 0.78 seconds on the CPU.
The GPU rasterizer is slower for this scene. Its kernel bins the triangles by tile and takes about 38 milliseconds at 1920 by 1080. Packing 2.1 million corners on the host takes about 1.4 seconds. See the GPU report.
A 1920 by 1080 frame at 60 frames per second has a 16.667 millisecond budget. A claim must name the CPU, GPU, driver, compiler, image size, visible strand count, points per strand, coverage width, opacity, density resolution and worker count. It must include warm-up, simulation, density and shading, geometry preparation, GPU submission and transfer, rasterization and readback costs where those stages apply. Count allocations separately from uninstrumented timings.
Measure a mixed scene with body triangles and many thin strands at several worker counts. Require the same image before comparing worker throughput or pending-vertex flush cost. A result from a smaller image, a smaller groom or a compile-only check cannot qualify this target. The physical-device frame-time and selected-reference visual comparisons are still required.
Tutorials
How-to
Reference
- Scene graph
- Rotations
- Cameras
- Geometry
- Geometry addons
- Lofts and clipping groups
- Sculptor
- Procedural generators
- Meshes and assets
- Lines
- Points and sprites
- Helpers
- Scene objects
- Animated objects
- Raycasting
- Curves and paths
- Animation
- Skinning
- Materials
- Node materials
- TSL functions
- Lights
- Lighting addons
- Fog
- Textures
- Renderer
- Renderer hooks and material flags
- Rasterization
- GPU backend
- Render target
- Post-processing
- GPU computation
- Compute nodes
- Progressive light map
- Gaussian splats
- Voxel global illumination
- SVG renderer
- Image files
- Image decode queue
- Model files
- More model files
- Exporters
- Scene JSON
- Windowing and controls
- Math
- Norm consumers
- Math addons
- Units
- Coverage tool
- Commands
- Examples
- Benchmarks
- Extensions
- Shared physics
- Physics query snapshots
- Continuous collision
- Tick force restoration
- Water
- Animals
- Buildings
- CARLA
- Femur
- Tibia
- Fibula
- Patella
- Knee
- Muscles
- Vessels
- Lymph
- Nerves
- Integument
- Leg
- Foot
- Pelvis
- Torso
- Arm
- Hand
- Head
- Genome
- Game humanoid
- Humanoid fidelity
- Audio-aligned game faces
- Converted assets
- Mesh quality
- Segment inertia
Explanation
- Software rasterizer
- Scene graph as an array
- Linear color
- Normals are not colors
- Perspective correction
- Fixed-point coverage
- Per-fragment shading
- Mipmaps
- Sorted transparency
- Types and checks
- Render hooks as a trait
- Shared CPU and GPU code
- Bands and threads
- PNG structure
- Extensions beside the port
- CUDA teardown hang
- Mojo compiler hang