scenario-blender-hair
Use when grooming hair or fur in Blender with hair curves, such as a character hairstyle, animal fur, procedural fur in geometry nodes, or hair cards and mesh hair for games. Also covers the Essentials hair node assets (Interpolate, Clump, Curl, Frizz, Noise, Trim), density masks
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-hair
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
git clone https://github.com/scenario-labs/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Hair and fur grooming (hair curves, Blender 5.2)
Expert grooming means a few evenly spaced guides that set direction and silhouette. Stacked, masked procedural layers add density, clumping, frizz, length variation and strays, judged in a lit render at full density and in pose. Write guides as data (no curve brush stroke can be scripted in 5.2.1) and make every look decision through the Essentials hair node-group assets. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
REQUIRED BACKGROUND: scenario-blender-expert (execution channel, review loop, 5.2 API changes).
Stance (the expert delta)
- Guides get 2 points until every direction is right (Schmidbauer). More points make combing tangle and clip. Use 3 for short fur, 8 only for long hair. Bystedt resamples long human hair to 15 points before shaping.
- Space guides evenly, and always mask Interpolate (Schmidbauer). Children spawn over the whole surface, and a child far from any guide "does whatever". Gaps turn into bald spots that grow under deformation.
- Density is counted per square meter and must be huge. Radius drops with it (Thommes). Long fur uses 1,000,000/m² at about 0.2 mm radius. Work at a Viewport Amount of 4%.
- Randomize per curve, not per point (Thommes). Give 80% of strands no frizz. Strays are 10% of curves with strong noise.
- Trim with Replace Length off plus a Random Offset (Schmidbauer, Thommes). It is the cheapest realism gain. Mask it, or short-fur regions lose their guides and go bald.
- Grow a human hairstyle on a separate hair cap split along the part (Bystedt): body topology stops mattering, interpolation stops crossing the part. The part still needs extra density and a lift.
- Shape works the same way on every asset: 1 affects the tip only, 0 the whole strand, negative the roots. Use a negative Frizz Shape so curl and clump tips survive (Thommes, Bystedt).
- Judge renders at full density, and in pose (Thommes, Schmidbauer). A harsh mesh deformation "looks ten times worse" in the hair.
Establish first
| Input | Changes | Default when the brief is silent |
|---|---|---|
| Target | Cycles strands vs EEVEE or game cards | Cycles strands (Principled Hair BSDF) |
| Subject | human hairstyle (hair cap, part) vs animal fur (body mesh, landmarks) | follow the reference |
| Length class | points per guide, clump and curl layers | short fur: 3 points, long: 8, long human: 15 |
| Deformation | animated means Hair Dynamics (Animation) stays last, and you check in pose | animated |
| Budget | render curves, card triangles | say the number: evaluated curves at full density |
| Reference | same subspecies, season, ideally the same individual (Schmidbauer) | ask for it; draw landmarks and flow first |
Finish topology and non-overlapping UVs before grooming (hair binds to UV space) and apply scale: density counts the mesh's local area (verified).
Workflow
| Task | Headless | Live session |
|---|---|---|
| Curves object, surface binding, guides as data, snap, subdivide or resample, asset modifiers, inputs, masks, counts, renders, cards | yes (scripts/bx_hair.py) |
yes, same code in Object Mode |
Enter Sculpt Curves, activate the 11 curve brushes, set brush settings, sculpt_curves.select_random |
n/a | yes (bx_gui.activate_brush("SCULPT_CURVES", "Comb")) |
| Comb, Add, Density, Grow/Shrink, Pull Tips strokes | no | no. sculpt_curves.brush_stroke has no exec, so bx_gui.stroke("SCULPT_CURVES", ...) returns PASS_THROUGH and changes nothing. Set the brush up and ask the user to stroke, or use the substitutes below. |
- Prepare the surface.
H.uv_report(surface): stacked UVs make curves ignore the rig, UDIMs are fine, an unmerged mirror seam needs UV Merge by Distance (Schmidbauer). Humans: a hair cap split into islands along the part. Animals: separate the mouth interior. Masks: vertex groups (fast) or textures (detail on low-poly meshes). GATE:overlap_faces == 0, scale applied, one mask per region. - Plan flow and objects. Turn the draw-over into regions (vertex groups) with one flow direction each. Schmidbauer: one curves object per landmark (face, cheeks, ears, neck, body, tail, feet) for performance. Thommes: one object with masks. Bystedt: split front and back where combing one disturbs the other. GATE: written list of regions, flows, objects.
- Guides.
hair = H.new_groom(surface),P, N = H.sample_roots(surface, spacing, mask=...),H.add_guides(hair, P, N, length, points=2, flow=..., flow_blend=...)(snaps, writessurface_uv_coordinate). Later:H.subdivide_guidesorH.resample_guides(hair, 15). Brush substitutes: a direction field for Comb,droopfor gravity, delete and re-add guides for Density and Add; Bystedt's "grow long, comb, shrink back" is long guides plus Trim. GATE: inH.groom_report,roots_off_surfaceandspacing_gaps_2xare 0 andlength_outliersis 0. A guides-only render shows every direction right. - Density. Add Set Hair Curve Profile first, then Interpolate Hair Curves with a Density Mask. Bystedt paints 50% gray and 100% at the part, then remaps. The 5.2 Interpolate also takes a Mask Texture image. GATE:
H.count(hair)(full density) is within budget,root_area_fractionis well under 0.5, and a render shows no bald spots. - Procedural layers, each masked, A/B-tested by toggling
show_viewportand rendering.- Long fur (Thommes, preset
H.FUR_STACK_LONG, applied withH.build_stack): Noise, Curl, strays, Frizz with negative shape, Clump, Clump 2, final Frizz (Cumulative off), Trim, Rotate. - Animal (Schmidbauer): Trim, Clump (cheeks, Tip Spread, Clump Offset), low Frizz, each with its own vertex group.
- Human (Bystedt): Noise on guides (Cumulative off, Offset per Curve on), Roll at the tip only, then per population Interpolate, Profile, Clump on the groomed guides; wide and thin layers need different seeds.
- Per-curve randomness:
H.random_curve_mask(hair, "frizz_mask", 0.8)then input"attr:frizz_mask"; root-to-tip ramps:H.along_curve_attr. GATE: each layer earns its place in an A/B render.
- Long fur (Thommes, preset
- Part and hairline (human). Leave Part by Mesh Islands on (default): in the test, 21% of children near the part crossed it with islands off and 0% with islands on. For the lift,
H.distance_weights(cap, "part_lift", "part", radius, "bump")feeds Displace Hair Curves (Surface Normal on, small distance, Shape near 0, Factorattr:part_lift). GATE: in a top view there is no scalp gap at the part and the crown is not flat. - Shading and render. Use
H.hair_material(name, "CYCLES", melanin=(root, tip))or"EEVEE"with explicit colors, thenH.setup_render(scene, engine, close_up). GATE:H.review([surface, hair], out, engine)passesreferences/critique.md. - Deformation. Key 2 or 3 extreme poses and render each frame from the same camera. GATE: no bald spots or clipping in any pose.
- Games: cards. Run
H.add_cards(hair, width, material=H.card_material(...)), then convert a copy withobject.convert(target='MESH')and export glTF. Put a textured hair cap under the cards. GATE: triangles within budget, UVs in the half tile for cards, roots on the scalp.
Numbers
| Item | Value | Relative to, source |
|---|---|---|
| Guide points | 2 while placing, 3 short fur, 8 long hair | Schmidbauer [00:20:24], [00:21:34] |
| Long human hair | resample to 15 points | Bystedt [00:08:38] |
| Add brush length | about 0.01 m for fur (default 0.3 m) | Schmidbauer [00:17:59] |
| Density, human layer | 5,000 per m² per clump population | Bystedt [00:17:28] |
| Density, long fur | 1,000,000/m², radius 0.2 mm, viewport 4% | Thommes [00:09:02], [00:16:44] |
| Density map | part 1.0, base 0.5, bald 0; ramp black stop just above 0.5 | Bystedt [00:22:04], [00:23:46] |
| Wide clumps | Shape about 0.3, reduced Factor, groomed guides | Bystedt [00:19:47] |
| Curl | Radius 2 cm, Curl Start 1, Random Offset 0.5, Guide Distance 1 cm, Guide Mask 0.2 | Thommes [00:12:04], [00:14:18] |
| Sub-clumps | Guide Distance 1 mm, Mask 0.1; layer 2: 4 mm, 0.25, Clump Offset 5 mm | Thommes [00:16:12], [00:31:01] |
| Frizz | 2 mm, negative Shape; final: -0.2, Cumulative off, on 20% of curves | Thommes [00:15:04], [00:32:08], [00:26:03] |
| Strays | 10% of curves, Noise distance 5 mm | Thommes [00:30:28] |
| Trim, Rotate | Replace Length off, Random Offset 5 mm; Rotate random 5° | Thommes [00:22:20] |
| Card subdivision | 1 per 20 cm of card length | Bystedt g_ZIOafq4QU [00:05:08] |
| Card and tube profile | arc: 1 segment flat, 3 preferred; tube: flat spiral, resolution 6, 1 turn, height 0 | Matsumoto [00:19:54], [00:22:40] |
| Card UV width | cards use half the tile of tubes | Matsumoto [00:31:27] |
| Coverage sanity | density × π × radius²: 0.13 for Thommes' fur | [added] check |
Quality gates
In code:
import sys; sys.path.append("<skills>/scenario-blender-hair/scripts"); import bx_hair as H
print(H.uv_report(surface)) # overlap_faces == 0
rep = H.groom_report(hair); print(H.verdict(rep)) # [] or a list of fixes
print(H.count(hair)) # full-density curves and points vs budget
print(H.card_stats(hair)) # cards: tris, UV range
groom_report checks attachment UVs, roots on the surface, spacing gaps, length dents (over 50% off neighbors), Hair Dynamics last, Trim Replace Length, unmasked Frizz or Noise, missing Density Mask, radius vs density, Strand display, and a Cycles hair shader under EEVEE.
Visually: H.review(objs, out, engine, views=("front", "threequarter", "top")) renders a lit full-density sheet with Thommes' rig (warm top fill, side key, cool rim, dark bluish world). Score it with references/critique.md: bald spots, part coverage, clumps, strays, length variation, tips, root-to-tip color, highlights.
Common mistakes
| Mistake | What it looks like | Fix |
|---|---|---|
| Guides at the brush default of 8 points while blocking | tangles, clipping, fights while combing | 2 points, raise later (Schmidbauer) |
| Interpolate without a Density Mask | hair in the mouth or eyes, drifting children far from guides | vertex-group mask; separate the mouth interior |
| Uneven guide spacing | bald spots, worse in pose | sample_roots spacing; check spacing_gaps_2x |
| High density at default radius | a solid mass or "a mess" | Profile first, about 0.2 mm at 1M/m² (Thommes) |
| Trim at defaults | every strand becomes 1 m long | Replace Length off (Thommes) |
| Positive Frizz on curls or clumps | curl tips destroyed, fuzzy clump tips | negative Shape, Cumulative Offset off on the final Frizz |
| Duplicated layer with the same seed | wide and thin clumps stack identically | change seed and frequency (Bystedt) |
| Straight tips | "bad CG hair" | Roll Hair Curves at the tip only (Bystedt) |
| Surface edited after grooming | floating or sunken roots | snap_to_surface(hair, "DEFORM"), or NEAREST after topology changes |
| Judging at 4% viewport density | uniform clumps and equal lengths unnoticed | render at full density |
| Principled Hair BSDF in EEVEE | dark and flat (measured 0.09 vs 0.26 center luminance) | Principled BSDF with explicit colors |
| Scripting a Comb stroke | nothing happens, PASS_THROUGH | write positions, or hand the stroke to the user |
Blender 5.2 notes
- Empty Hair and Quick Fur add a pinned Hair Dynamics modifier (Mode
AnimationorPhysics (Experimental)). It replaces the old Surface Deform and moves roots with the deforming surface (shape keys and armature verified). Keep it last. - Interpolate has no Surface or UV Map inputs; it reads the curves object's surface. Newer inputs are Part by Mesh Islands, Follow Surface Normal, Mask Texture and Resting Surface (on). With Resting Surface on, Thommes' "Mask modifier on the surface" patch preview has no effect. Use a patch vertex group as the Density Mask instead, or turn Resting Surface off while previewing.
- Modifier inputs are set with
md.properties.inputs.<id>.valueor.type = 'ATTRIBUTE'. Headless, evaluation stays stale untilobj.update_tag().H.set_inputsresolves UI names and tags for you. Background evaluation applies Viewport Amount, so useH.count(full=True). - Brushes are assets: Snake Hook is Pull Tips, Selection Paint is Select; Add defaults to 8 points, 0.3 m; settings in
brush.curves_sculpt_settings. - Factory
render.hair_typeisSTRAND: useSTRIPwithhair_subdiv >= 1. Cycles shapes:RIBBONS,THICK,THICK_LINEAR. "Curves Info" isShaderNodeHairInfo; Principled Hair offers Chiang and Huang. Hair Curves Noise writesresolution12, which curves render with since 5.0. - Cards: Curve to Mesh has a Scale input (4.5), and Arc Resolution counts points. EEVEE alpha uses
surface_render_method = 'DITHERED'. Keep the GeometrySet alive when readingobj.evaluated_geometry().mesh. - Quick Fur density scales with object size (MEDIUM on a 10 cm sphere: 80,220/m², 12 points per guide).
References
references/expert-notes.md: load for judgment calls, per expert with timestamps (human hairstyle, animal fur, procedural fur, cards, mesh hair).references/procedures.md: load before writing code. It has full tested procedures P1 to P8 with raw bpy equivalents of thebx_haircalls.references/critique.md: load when reviewing a render or report, as the scoring rubric.references/sources.md: load to cite or re-watch a source, with the best timestamps.scripts/bx_hair.py: the toolkit (tested on 5.2.1). Tests are intests/code/blender-hair/.
Files (skills)
-
references
-
critique.md 11.8 KB
# Critique rubric: judging a groom like the experts Use this after every stage: 1. Run `H.groom_report(hair)` and `H.verdict(...)`. 2. Render `H.review([surface, hair], out, engine, views=...)`. 3. Open the sheet with the image reader. 4. Mark each line **OK** or **FIX**. A FIX marked **B** (blocker) stops the next stage. **P** (polish) can wait for the polish pass. Never deliver without a full-density render; the viewport at 4% hides most of these (Thommes `[00:19:32]`). ## A. Attachment and setup (code) | Check | How | Sev | Source | | ------------------------------------------------------- | ------------------------------------------------------------------------- | --- | ---------------------------------------------- | | Surface UVs not stacked (UDIM fine), mirror seam merged | `H.uv_report(surface)["overlap_faces"] == 0` | B | Schmidbauer `[00:38:24]`, `[00:39:00]` | | Surface and UV map set, every guide attached | report `uv_map_on_surface`, `has_attachment_uv`, `attachment_uv_nan == 0` | B | Schmidbauer `[00:37:50]` | | Roots on the surface | `roots_off_surface == 0` (after surface edits: snap DEFORM or NEAREST) | B | Thommes `[00:03:04]`, Schmidbauer `[00:39:36]` | | Object scale applied | `surface.scale == (1, 1, 1)`: density counts local area | P | verified 5.2.1 | | Hair Dynamics last (animated) | `modifiers[-1] == "Hair Dynamics"` | B | 5.2 replacement of Surface Deform | | No hair inside the mouth or eyes | mouth interior separated from the surface, and Density Mask 0 there | B | Schmidbauer `[00:34:23]` | ## B. Guides (render the guides alone with a thick Profile, or Workbench) | Check | Look for | Sev | Source | | --------------------------------------------------------- | ------------------------------------------------------------- | --- | ---------------------------------------------- | | Directions per region match the flow plan and draw-over | arrows on the reference vs guide directions, region by region | B | Schmidbauer `[00:07:36]` | | Even spacing | `spacing_gaps_2x == 0`; no visible holes in the guide layer | B | Schmidbauer `[00:11:36]` | | Low point count while blocking | `points_per_guide` 2 to 3 until directions are right | P | Schmidbauer `[00:21:00]` | | Gradual lengths | `length_outliers == 0`; no short patch among long guides | B | Schmidbauer `[00:36:05]` | | No guide inside thin geometry (ears) or crossing the part | pose-free render from 3 views; part-side roots comb away | B | Schmidbauer `[00:41:56]`, Bystedt `[00:02:53]` | ## C. Density and coverage (full-density render, rest pose) | Check | Look for | Sev | Source | | -------------------------- | ------------------------------------------------------------------------------ | --- | ---------------------------------- | | No bald spots | skin showing through in patches, especially at region borders and object seams | B | Schmidbauer `[00:33:16]` | | Not a solid mass | strands readable; `root_area_fraction` well under 0.5 (Thommes' fur is 0.13) | B | Thommes `[00:16:44]` | | No blobs from over-density | lumpy shapes that show every ripple | P | Schmidbauer `[00:12:42]` | | Part and crown (human) | no scalp stripe at the part, crown not flat from the top view | B | Bystedt `[00:31:29]`, `[00:33:15]` | | Density mask edges | no hard density border unless the reference has one | P | Bystedt `[00:22:04]` | | Budget | `H.count(hair)` full curves and points vs the stated budget | B | brief | ## D. Variation: Thommes' first-render critique plus Schmidbauer's "never straight" | Check | Look for | Sev | Source | | ----------------------------------- | ---------------------------------------------------------------------------------------- | --- | ---------------------------------------------- | | Clumps not uniform | identical clump sizes or spacing: add a second clump layer (Guide Mask) and Clump Offset | P | Thommes `[00:19:32]`, `[00:31:01]` | | Strays between clumps | a few runaway hairs (about 10% of curves) | P | Thommes `[00:29:54]` | | Lengths vary | tips all ending on one surface: Trim with Replace Length off and Random Offset | B | Schmidbauer `[00:27:26]`, Thommes `[00:21:46]` | | Tips curl slightly (human) | ruler-straight tips read as CG: Roll at the tip | P | Bystedt `[00:14:32]` | | Frizz on a minority | uniform fuzz everywhere: per-curve mask, about 20% of curves | P | Thommes `[00:26:03]` | | Clumps readable but not "too crazy" | A/B the clump layer; use Tip Spread and Clump Offset if needed | P | Schmidbauer `[00:30:18]`, Bystedt `[00:21:31]` | | Duplicated layers differ | two populations moving identically: change seed and frequency | P | Bystedt `[00:29:08]` | | Curl tips preserved | frizz destroying curl shapes: negative Frizz Shape | P | Thommes `[00:15:04]` | | Every layer earns its place | toggling it off in a render makes a visible difference | P | Thommes `[00:26:37]` | ## E. Subject-specific **Animals (Schmidbauer)** - Likeness: landmark regions (ruff, chest, back, tail fan) read as in the reference of the same subspecies and season `[00:03:24]`, `[00:07:36]`. Sev B. - Each region has its own character (cheeks clumpy, chest straight), set by masks `[00:28:35]`. Sev P. - Seams between landmark objects: no density jump and no clipping `[00:13:49]`. Sev B. **Human hairstyle (Bystedt)** - Wide and thin clump populations overlap, flyaways are few, and art-directed strands are placed `[00:01:43]`. Sev P. - Hairline and nape: roots hidden, soft transition (hair cap texture or an extra system) (Bystedt g_ZIOafq4QU `[00:22:28]`, Matsumoto `[00:40:21]`). Sev P. ## F. Shading and light (Cycles or EEVEE render) | Check | Look for | Sev | Source | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --- | ------------------------------------ | | Right shader for the engine | EEVEE plus Principled Hair BSDF means dark and flat (0.09 vs 0.26 luminance in the test) | B | Thommes `[00:18:25]` | | Root to tip gradient | root darker than tip (Intercept) | P | Thommes `[00:18:25]` | | Per-strand variation | color and roughness vary between strands (Random, surface-UV noise) | P | Thommes `[00:20:04]` | | Highlights | stretched along strands, not round blobs; they move plausibly when the light moves (for cards: anisotropy plus tangent) | P | Matsumoto `[00:11:37]`, `[00:14:20]` | | Display | not Strand in EEVEE (radius invisible); Strip plus subdivisions | B | Bystedt `[00:10:23]` | | Sparkle | EEVEE strips can sparkle under a grazing rim light (seen in the tests); judge in Cycles or soften the rim | P | test observation [added] | | Review light | warm top fill, side key, cool rim, darker bluish world | P | Thommes `[00:17:16]` | ## G. Deformation (render 2 to 3 extreme poses, same camera) | Check | Look for | Sev | Source | | ----------------------- | ---------------------------------------------------------------------------------------- | --- | ------------------------ | | No gaps in pose | bald stretches at joints and folds | B | Schmidbauer `[00:44:23]` | | No clipping | strands inside the body in pose | B | Schmidbauer `[00:33:50]` | | Mesh deformation smooth | harsh mesh folds are amplified ten times in the hair: fix the rig or mesh, not the groom | B | Schmidbauer `[00:33:50]` | ## H. Cards and tubes (games, EEVEE) | Check | How / look for | Sev | Source | | ------------------------------------------- | --------------------------------------------------------------------------------------------- | --- | ---------------------------------- | | Card planes invisible from the main camera | no flat ribbons edge-on; cards lie along the scalp (root faces mean \|n · surface n\| near 1) | B | Bystedt g_ZIOafq4QU `[00:03:26]` | | Roots on the scalp, nothing inside the head | `card_stats`, root-row distance, inside test | B | Bystedt `[00:03:59]`, `[00:04:34]` | | Coverage | textured hair cap underneath; no bald look between cards | B | Bystedt `[00:22:28]` | | Budget and UVs | tris vs budget; card U in [0, 0.5], tubes [0, 1]; V root 0 to tip 1 | B | Matsumoto `[00:31:27]` | | Card and tube match | same width and texture density when switching | P | Matsumoto `[00:24:21]` | | Long cards bend smoothly | about 1 subdivision per 20 cm | P | Bystedt `[00:05:08]` | ## Delivery report (what to hand back) - Counts: guides, full-density curves and points, card triangles. - Stack: modifier list with key inputs (`H.get_inputs`). - `verdict()` output, empty or explained. - Review sheets: the rest pose, 2 to 3 poses, and a close-up if hero. - What was **not** verified: brush-only steps the user did, Physics-mode dynamics, render time at final resolution. -
expert-notes.md 24 KB
# Expert notes: hair and fur These are the principles and judgment calls of each expert, with timestamps `[hh:mm:ss]` into the source video (full list in `sources.md`). Four talks were recorded on Blender 3.5 to 4.5. Where 5.2.1 behaves differently, it is marked **5.2**. `[added]` marks my own additions. ## Daniel Bystedt: human hairstyle as a layered node graph (BCON23, `Kx88edAbiek`) **Core stance** - Grow hair on a **separate hair cap mesh**, never on the body mesh, so topology or density changes on the character never break the groom `[00:02:19]`. - Split the cap into pieces along the part (a hard edge) so interpolation does not blend hair across the part `[00:02:53]`. **5.2:** Interpolate's _Part by Mesh Islands_ (on by default) formalizes this. The test measured 21% of children near the part crossing it with islands off, 0% with islands on. - Hand grooming sets only the big shapes. Overlap, clumping, tip curl and flyaways are procedural `[00:13:24]`. - A realistic hairstyle is **stacked populations**: broad clumps, thin clumps, flyaways and a few art-directed strands, each its own branch, then joined `[00:01:43]`. - The **parting line** is the recurring weak spot of every hair system (bald-looking, flat crown) and needs dedicated fixes `[00:31:29]`, `[00:33:15]`. - Straight tips read as "bad CG hair". Real hair curves at the end "like a little whoop" `[00:14:32]`. **Grooming judgment** - Use Projected falloff to comb on a head. Sphere misses hair on the far side under the brush `[00:05:06]`. - Grow/Shrink with Scale Uniform off, so hair extends along its direction `[00:05:41]`. - Paint selection on the Curve domain, not Point `[00:07:24]`. - Resample guides to 15 points before shaping long hair `[00:08:38]`. - Groom one side of the part at a time: grow everything long, comb it into place, shrink back, "exactly how a professional hairdresser would do" `[00:09:52]`. - Display Strip plus subdivisions before judging thickness `[00:10:23]`. - Shrinkwrap Hair Curves with Above Surface 0 pushes out only points inside the cap, with an offset distance. He prefers it to sculpt collision for the offset `[00:11:33]`, `[00:12:09]`. **Procedural layers** - Noise: Cumulative Offset off, lower Scale along Curve (keeps length, less mess), Offset per Curve on for per-strand randomness `[00:13:24]` to `[00:14:32]`. - Roll Hair Curves with a short roll length so only the tip rolls, plus random orientation `[00:15:08]`, `[00:15:44]`. - Interpolate at density 5,000 per square unit for a population, then thin the strands with Set Hair Curve Profile before judging, or thousands of default-thickness strands are "a mess" `[00:17:28]`. - **Shape parameter:** 1 affects only the tip, towards 0 the whole curve, negative only the roots `[00:18:04]`. - Clump for wide clumps: Shape towards 0.3, groomed curves as guides (existing guide map), Factor reduced so clumping is "somewhat but not very much". A/B by toggling `[00:19:47]` to `[00:21:31]`. High Guide Distance gives few large clumps when guides are procedural `[00:20:20]`. - Density map: paint white at the part, 50% gray elsewhere, black where bald. Use a Color Ramp with the black stop just above 0.5 so the gray can be dialed both ways `[00:22:04]` to `[00:23:46]`. The Image Texture silently does nothing without a Named Attribute `UVMap` as its vector `[00:22:38]`. **5.2:** Interpolate also has a _Mask Texture_ image input and a fractional _Density Mask_, both verified. - Expose a Global Density input (default 1, min 0) that multiplies every branch, to thin the whole groom when it gets slow `[00:24:21]`. - Thin clumps: duplicate the branch, change the clump value, and give the duplicated Noise and Roll a **different seed** and higher frequency so layers overlap instead of stacking identically `[00:27:54]` to `[00:29:44]`. - Debug colors: Store Named Attribute (color) per branch, read by an Attribute node in the shader `[00:26:08]`. - Where groomed curves interpolate badly (the center line), add a second low-density Interpolate as a separate set of clump guides `[00:30:17]`. - Parting fixes: - Root snap: roots within a distance snap onto the part line `[00:32:06]`. - Lift: Displace Hair Curves along the surface normal with Shape near 0. Its factor is Geometry Proximity (target **Edges**, the line has no faces), then Map Range from distance 0..2 to 0..1, then a Float Curve bump shaped until the part looks natural `[00:33:51]` to `[00:41:16]`. - Exaggerate an effect first to see what it does, then dial it back `[00:35:01]`. **How he judges:** coverage at the part, volume at the crown, slightly curled tips, overlapping wide and thin clumps with no identical shapes, clumping readable but not obvious, and per-population debug colors `[00:14:32]`, `[00:21:31]`, `[00:29:44]`, `[00:32:06]`. ## Kerstin Schmidbauer: full-body animal fur (BCON25, `ZrBbNhCgGww`) **Core stance** - Grooming is like sculpting: primary shapes with the simplest setup first, detail last, many iterations `[00:01:43]`, `[00:11:01]`. - "I do my entire hair grooms with only **two points** on the guides until I got all those fur directions where I want them" `[00:21:00]`. Default 8 makes combing tangle and clip. 3 is enough for short fur, 8 only for really long hair `[00:19:44]` to `[00:21:34]`. - **Even guide spacing** matters more than count. Gaps give bald spots and children that drift or misbehave in deformation `[00:11:36]`, `[00:12:09]`. - "Don't fight the system, it works in a very predictable way": delete a bad curve and add a new one, and separate mesh parts where hair must never grow `[00:34:56]`, `[00:35:31]`. - Finish the mesh (topology and **non-overlapping UVs**) before grooming, because curves bind to UV space `[00:09:19]`, `[00:38:24]`. **Reference and planning** - Use the same subspecies and season coat, ideally many photos of one individual (Flickr albums from one location), and never mix short and long coats `[00:03:24]` to `[00:06:21]`. Examine a related pet for growth direction and density, which photos hide `[00:07:00]`. - Draw-overs mark landmarks where fur changes (neck ruff, straight chest fur, clumpy back, fanning tail) and flow arrows, before any 3D `[00:07:36]` to `[00:08:43]`. - Split into objects per landmark: face, cheeks, ears inner and outer, neck, body, tail, feet. It is better for performance and easier to work, but the seams need density and clipping cleanup. Optionally join everything at the end with Join Geometry if performance allows `[00:13:15]` to `[00:14:25]`. **Density and masks** - Interpolate spawns over the entire surface and children far from any guide "do whatever". Always use a density-mask vertex group `[00:22:07]` to `[00:25:37]`. Turn interpolation off while weight painting for speed `[00:24:27]`. - Density is hairs per square meter, so expect to raise it "by a lot" `[00:23:18]`. - Too low a density gives bald spots that grow under deformation. Too high a density gives clipping, blobs that show every ripple, and slow renders `[00:12:42]`, `[00:13:15]`. - Masks for everything: vertex groups per region (`fur` suffix for density) plus separate groups for noise, clump, length, extra clump. Extra groups cost little `[00:25:37]`, `[00:26:14]`, `[00:32:36]`. - Vertex groups are fast but limited by vertex density. Textures give detail on low-poly meshes `[00:15:00]`. **Layers** - Trim with Replace Length off and a small Random Offset is "immediately a lot more realistic". Mask it, or short-fur areas lose their guides to trimming and go bald `[00:27:26]`, `[00:28:02]`. - Clump factor from a `clump` group painted only where clumping belongs (cheeks). Clumps look "a bit too crazy" at first, so use Tip Spread and Clump Offset. More guides give smaller clumps `[00:28:35]` to `[00:30:52]`. - Noise is texture-like displacement over the whole groom. Frizz is random per point, per hair. Both are needed `[00:16:48]`, `[00:17:23]`. - Reduce Frizz strongly and mask it: right for cheeks, too much elsewhere `[00:31:27]`, `[00:32:01]`. - "The hairs are never straight or perfectly straight. There's always a little bit of clump" `[00:31:27]`. - Length changes must be gradual like density. Abrupt changes render as shadows or dents `[00:36:05]`, `[00:36:43]`. At the head she accepts Grow/Shrink brushes despite the inconsistency risk, because masking every length there is tedious `[00:36:43]`. - Fur direction fans: deselect all, add one curve, comb it, deselect, add the next. Later curves interpolate between these `[00:43:09]`. **Deformation and troubleshooting** - "If your mesh looks the deformation looks too harsh, the hair will look like 10 time worse" `[00:33:50]`. Keep a pose-mode window to check often `[00:44:23]`. - Bald spots come from uneven or low density, no guide nearby, or stretching. Add guides and check that interpolation is on `[00:33:16]`, `[00:40:47]`. - A new guide inside a thin mesh (ear) interpolates toward guides on the other side. Try Puff, or place and comb by hand `[00:41:56]`. - Hair growing inside the mouth: separate the mouth interior from the fur surface. Curves that lost their surface become invalid and must be snapped or deleted `[00:34:23]` to `[00:45:31]`. - A length dent (a short patch that looks like a texture spot) is fixed by deleting those curves and re-adding them with the Density brush, interpolation on `[00:36:05]`, `[00:47:50]`. - Invalid surface UVs (curves ignore the rig, guides refuse to appear on that island) mean overlapping UVs: "absolutely no stacking. But it is okay to use udims" `[00:37:50]`, `[00:38:24]`. Mirror plus Flip UVs can leave the center seam unmerged: fix it with UV Merge by Distance `[00:39:00]`. - Topology changed after grooming: curves stay valid but float or sink, so Snap to Nearest Surface, which works about 99% of the time `[00:39:36]`. - Shape-key-like blend: duplicate the groom, sculpt the variant, then in GN run Set Position with a Mix between the own position and the other object's position (Object Info plus Sample Index by Index), driven by a custom property. It breaks when curve counts differ `[00:50:04]` to `[00:53:28]`. Not tested here. **How she judges:** likeness to the real animal, no bald spots at rest and in pose, no abrupt length or density changes (shadow dents), every region with visible but controlled clump, noise and length variation, reviewed region by region `[00:02:17]`, `[00:31:27]`, `[00:44:23]`, `[00:47:15]`. ## Simon Thommes: procedural fur in geometry nodes (Blender Studio, `gCQN5vNgHiI`) **Core stance** - A groom can be almost entirely procedural: minimal hand grooming, then stacked assets with randomized inputs `[00:03:04]`. - **Randomize per curve, not per point.** Random Value keyed on Curve ID is the main tool for frizz, curl, noise and strays `[00:25:31]`. - Judge at **full render density**. Uniform clumps and equal lengths only show in a render `[00:16:44]`, `[00:19:32]`. - You do not need the internals of the assets. Use each as one node in a higher-level tree `[00:23:43]`. - Layer clumping at several scales with guide masks for randomness `[00:15:39]`, `[00:31:01]`. **Mechanics** - Density is per square unit of surface. Scaling the surface in Edit Mode changes the curve count, not the density `[00:02:32]`, `[00:09:02]`. **5.2:** object scale is ignored, since density counts the mesh's local area (verified). - Curves remember their surface UV: after surface edits, Snap to Deformed Surface puts them back `[00:03:04]`. - Display Strip plus subdivisions, otherwise radius changes are invisible `[00:03:48]`. - Set Hair Curve Profile Shape 0.5 means full width at the root falling linearly to 0. Higher falls off faster, negative reverses. Factor Min and Max scale the ends `[00:04:20]`. - Frizz: a per-point random offset. Cumulative Offset on looks more natural. Preserve Length makes curves crumple instead of grow `[00:05:57]` to `[00:07:04]`. - A deformer at the end of the stack plus rest position in Interpolate makes hair follow deformation. Edit Mode changes are not deformation; shape keys are `[00:07:38]`, `[00:08:16]`. **5.2:** Hair Dynamics (Animation) plus Resting Surface. Shape keys and armature were verified. - Guide maps: with Existing Guide Map on, the groomed curves are the clump centers. Turn it off for procedural guides, where Guide Distance is the average distance between guides (default 10 cm). **Guide Mask** randomly removes guides and "looks a little bit more random and less uniform" than a larger distance `[00:12:37]` to `[00:14:52]`. - Radius and density are coupled: at 1M/m² you need about 0.2 mm radius `[00:16:44]`. **The highland-cow recipe (values from the talk)** | Step | Values | Time | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | | Interpolate | Density 1,000,000 (160,000 curves on his patch), Viewport Amount 4% | `[00:09:02]`, `[00:09:35]` | | Noise | Factor from a painted `noise strength` vertex group | `[00:10:07]` | | Curl | Radius 2 cm, frequency up, Curl Start 1, Factor End "much lower", Random Offset 0.5, Existing Guide Map off, Guide Distance 1 cm, Guide Mask 0.2 | `[00:12:04]`, `[00:14:18]` | | Frizz | negative Shape, Distance 2 mm | `[00:15:04]` | | Clump | Existing Guide Map off, Guide Distance 1 mm, Guide Mask 0.1, lower Shape | `[00:16:12]` | | Profile | Radius 0.2 mm | `[00:16:44]` | | Trim | Replace Length off, Random Offset 5 mm | `[00:22:20]` | | Rotate | Random Offset 5° | `[00:22:20]` | | Frizz factor | Random Value, ID = Curve ID, Map Range From Min 0.8 (80% get none) | `[00:26:03]` | | Curl variation | per-curve random factor; frequency via Spline Parameter, Map Range low at root, high at tip ("gravity pulls more at the roots") | `[00:27:10]` to `[00:28:52]` | | Strays | Noise with Boolean random per Curve ID, probability 0.1, higher scale, Distance 5 mm, higher scale along curve, moved up the stack | `[00:29:54]`, `[00:30:28]` | | Clump 2 | Guide Distance 4 mm, Guide Mask 0.25, gentler shape, Clump Offset 5 mm | `[00:31:01]` | | Final Frizz | per-curve random, Shape -0.2, Distance 2 mm, Cumulative Offset off (clump tips stay clean) | `[00:32:08]` | **Shading and review** - "The principled hair BSDF, that works only for Cycles, so if you want to use Eevee make sure to just use the regular principled BSDF" `[00:18:25]`. **5.2:** EEVEE renders it at about 36% of the Cycles luminance (measured). The rule holds. - Curves Info Intercept goes into a color mix: darker root, brighter reddish tip `[00:18:25]`. Make the surface mesh darker with specular off `[00:18:59]`. - Color variation: Attribute `surface_uv_coordinate` into a Noise Texture, Mix Soft Light, Color Ramp. Duplicate that for per-curve variation, randomize roughness, and raise roughness a little `[00:20:04]` to `[00:21:13]`. - Review rig: square camera, warm fill from the top, small key from the side, cool rim from the other side, darker bluish world `[00:17:16]`. - First-render critique list: clumps too uniform (strays needed), all hairs the same length, shader lacks variation, curling too regular `[00:19:32]`. - A/B each variation by muting its link `[00:26:37]`. Disable viewport display of render-only layers `[00:22:53]`. - Full-density patch: a Mask modifier on the surface with a small vertex group, off for render `[00:32:51]`. **5.2:** this has no effect while Resting Surface is on. Use a patch Density Mask instead (verified). ## Daniel Bystedt: GN hair cards for games (own channel, `g_ZIOafq4QU`) This is a walkthrough of his paid generator file, so treat it as a specification of what a card generator must control. - Groom with hair curves and let cards deform along them. Card tilt aligns to the surface, so you keep grooming with curve brushes `[00:00:00]`, `[00:21:53]`. - Use a decimated proxy head for collision, normal and color sampling, so the groom stays responsive `[00:01:07]`, `[00:21:18]`. - Controls: - Build direction: cards are modeled root at the origin, tip toward the positive axis `[00:02:20]`. - Tip tilt randomize, and align to surface vs world `[00:02:53]`, `[00:03:26]`. - Root snap distance, and snap-if-inside with an offset `[00:03:59]`, `[00:04:34]`. - Subdivide by length, 1 per 20 cm `[00:05:08]`. - Normal from surface factor `[00:05:40]`. - Transfers: a `mask` vertex group removes cards. Color from a surface color attribute is sampled at the root or projected. The UV of the root's closest surface point (a dedicated `UV transfer` map) lets one texture tint regions across all cards `[00:06:53]` to `[00:09:13]`. - Parting line as a curve object: roots within a distance snap onto it, and a lift bumps hair up by distance from the line `[00:09:50]` to `[00:11:03]`. - Layers: thick cards for coverage, sparse thin cards offset along the normal to fake flyaways, and a textured, transparent **hair cap** underneath for coverage and a soft hairline `[00:19:32]` to `[00:22:28]`. - Split front and back groom objects where grooming one disturbs the other, then join them in GN `[00:17:15]`. - Texture scene: hair on planes under an orthographic camera, with hair kept inside the UV tile. AOVs and compositor outputs are normal, alpha, random per hair, specular and intercept `[00:23:04]` to `[00:24:13]`. ## Sara Matsumoto: mesh hair from hair curves (BCON24, `vXBL-oiqY7Q`) - Keep groomable hair curves and convert them to mesh cards or tubes in GN. The same groom then renders in EEVEE or Cycles, or exports to games `[00:02:14]`. She now prefers mesh hair even for Cycles ("more control", curves feel "more unstable"), and it renders fast: about 1 to 3 min per frame on her characters `[00:40:54]`, `[00:41:28]`. - Strand textures are made in Blender from real hair curves rendered to passes `[00:03:55]`. Plate rules: - UVs on the growth strip are mandatory: "if you don't do UVs the hair is never going to attach" `[00:04:28]`. - Uneven lengths `[00:05:04]`. - Hair reaching the tile **sides** (gaps become tube seams) with a **gap in the center** so cards can use half the UV `[00:09:16]`, `[00:34:27]`. - Blend Hair Curves for a fluffier, separated look `[00:07:29]`, plus a second system for flyaways `[00:08:03]`. - Passes: depth, normal, position, AO, specular. Depth comes out white, so remap it `[00:09:16]` to `[00:10:57]`. - Judge the material on a curved plane with one moving point light. A round blob highlight is wrong; push for a stretched, anisotropic one `[00:11:37]` to `[00:14:53]`. - Card material: - Alpha, normal map plus bump from depth, a little metallic. - Roughness varied around 0.5, not constant. - Position pass into Tangent, with raised Anisotropic `[00:12:10]` to `[00:14:53]`. - Root color by a vertical ramp; highlight color masked by AO `[00:15:26]` to `[00:17:06]`. - GN tree: - Resample first, because Snake Hook scrambles point order `[00:18:49]`. - Arc profile: resolution 1 is flat, and she uses 3 segments to cover roots better `[00:19:54]`. - Tubes from a flat Spiral (start radius = end radius, height 0, resolution 6, 1 rotation). A closed arc gives seams `[00:21:33]` to `[00:22:40]`. - One width value must drive both cards and tubes `[00:24:21]`. - UVs: capture Spline Parameter on the curve (length) and on the profile (width) and store them as `UVs`. Cards (3 sides) get U × 0.5 compared with tubes (6 sides), switched with the geometry `[00:25:10]` to `[00:31:27]`. **5.2:** a FLOAT2 CORNER attribute named `UVMap` works directly and converts to a UV layer (verified). - Root attach: Geometry Proximity to the scalp on the UV Y = 0 row, **last** in the tree `[00:32:09]` to `[00:33:47]`. - Interpolation "doesn't appear" until density is very high ("like a million") at her scale `[00:39:47]`. Separate systems hide roots at the nape and hairline `[00:40:21]`. ## Where they disagree, and what decides | Topic | Position A | Position B | Deciding condition | | ------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | Points per guide | Schmidbauer: 2 while placing, 3 short, 8 max `[00:20:24]` | Bystedt: resample to 15 before shaping `[00:08:38]` | Length and curvature: start low for direction, raise when shape along the strand matters | | Clump guides | Groomed curves as clump centers (Bystedt, art-directed) `[00:20:59]` | Procedural: Existing Guide Map off, Guide Distance plus Guide Mask (Thommes) `[00:13:45]` | Art-directed hairstyle vs natural fur | | Splitting the groom | One object per landmark (Schmidbauer) `[00:13:15]` | One object with masks (Thommes); front and back objects where they disturb each other (Bystedt) | Groom size, performance, overlap while combing | | Mask source | Vertex groups: fast, limited by vertex density | Textures: detail on low poly (Schmidbauer `[00:15:00]`, Bystedt `[00:22:04]`) | Mesh resolution vs needed detail | | Frizz shape | Positive (tip-weighted) default | Negative when stacked on curls or clumps (Thommes `[00:15:04]`) | Whether tips carry a shape that must survive | | Cumulative offset | On: natural frizz (Thommes `[00:07:04]`) | Off: Noise per strand (Bystedt `[00:13:24]`), final Frizz (Thommes `[00:32:08]`) | Base variation vs final fine jitter | | Strands vs mesh | Curves plus Principled Hair in Cycles (Thommes, Bystedt, Schmidbauer) | Mesh cards and tubes even for Cycles (Matsumoto), cards for games (Bystedt) | Offline Cycles vs EEVEE, realtime or export; Matsumoto also cites stability | | Length control | Grow/Shrink brush at the head (Schmidbauer) `[00:36:43]` | Trim with masks and random offset (Thommes, Schmidbauer elsewhere) | Many distinct lengths in small areas favor brushes (a human's job in 5.2.1); broad regions favor masks | | Intersection | Shrinkwrap Hair Curves with an offset (Bystedt) `[00:12:09]` | Sculpt collision option (brush-time only) | Bystedt wants an offset distance and a non-destructive fix | -
procedures.md 29.6 KB
# Tested procedures (Blender 5.2.1 LTS) Every procedure below was run on Blender 5.2.1 LTS (build 2026-08-25) with `blender --background --factory-startup --python-exit-code 1 --python <test>` unless it says GUI. Tests live in `tests/code/blender-hair/` (`run_all.sh` runs all of them). The helpers come from `<skills>/scenario-blender-hair/scripts/bx_hair.py`. Each procedure shows the helper call, then the raw bpy it wraps, so you can work without the module. ```python import sys sys.path.append("<project>/skills/scenario-blender-hair/scripts") sys.path.append("<project>/skills/scenario-blender-expert/scripts") # review() reuses bx_review import bx_hair as H ``` Units are meters. Density counts curves per square meter of the surface mesh in its local space: object scale is ignored (verified), so apply scale first. --- ## P1. Surface prep and UV check Verified on 5.2.1 by `test_01_groom_headless.py`: a default sphere has 0 overlapping faces, and a stacked grid shows 32 of 32. ```python rep = H.uv_report(surface) # {'uv_maps', 'active', 'uv_outside_0_1', 'overlap_faces', 'faces'} assert rep["overlap_faces"] == 0 ``` Raw equivalent. It needs Object Mode and the surface active. UV sync select must be off, or `uv.select_overlap` selects nothing readable: ```python import bmesh ts = bpy.context.scene.tool_settings; sync = ts.use_uv_select_sync; ts.use_uv_select_sync = False bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT"); bpy.ops.uv.select_all(action="DESELECT") bpy.ops.uv.select_overlap() bm = bmesh.from_edit_mesh(surface.data) overlap = sum(1 for f in bm.faces if all(l.uv_select_vert for l in f.loops)) # 5.x: BMLoop.uv_select_vert bpy.ops.object.mode_set(mode="OBJECT"); ts.use_uv_select_sync = sync ``` - `uv_outside_0_1 > 0` is fine for UDIMs. Stacking is not (Schmidbauer). - Mirrored UV layouts: merge the center seam (UV editor, Merge by Distance) before grooming. - For a human hairstyle, grow on a separate hair cap split along the part (see P7). For animals, separate the mouth interior from the fur surface (Edit Mode, Separate, Selection). ## P2. Groom object, even roots, guides as data, attachment Verified on 5.2.1 by `test_01_groom_headless.py`. It passes 25 checks: Poisson spacing is respected (min nearest neighbor 0.01003 m at spacing 0.01), all roots fall inside the mask, `surface_uv_coordinate` is written, roots sit on the surface with max distance 0.0, and flow_blend 0.5 gives guides at 45° to the normal. ```python hair = H.new_groom(surface, "Hair") # Empty Hair: parent, surface, UV map, Hair Dynamics P, N = H.sample_roots(surface, spacing=0.01, mask="density_fur", threshold=0.5, seed=1) H.add_guides(hair, P, N, length=0.03, points=2, flow=(0, 0, -1), flow_blend=0.5) # flow may be callable(P, N) -> (n, 3), for example "away from the part": see P7 ``` The raw equivalent (what `new_groom` and `add_guides` do): ```python for o in bpy.context.selected_objects: o.select_set(False) surface.select_set(True); bpy.context.view_layer.objects.active = surface bpy.ops.object.curves_empty_hair_add() # poll fails without an active mesh hair = bpy.context.active_object # parented, surface + surface_uv_map set, # pinned 'Hair Dynamics' (Mode 'Animation') cv = hair.data old = len(cv.points) cv.add_curves([2] * n) # n new curves of 2 points buf = np.empty(len(cv.points) * 3, np.float32) cv.attributes["position"].data.foreach_get("vector", buf) buf[old * 3:] = local_positions.ravel() # hair.matrix_world.inverted() @ world cv.attributes["position"].data.foreach_set("vector", buf) with bpy.context.temp_override(object=hair, active_object=hair, selected_objects=[hair], selected_editable_objects=[hair]): bpy.ops.curves.snap_curves_to_surface(attach_mode="NEAREST") # writes surface_uv_coordinate ``` - `hair.matrix_world` equals the surface's matrix after Empty Hair, so hair-local space is surface-local space. - Hair Dynamics' Mode menu accepts `'Animation'` or `'Physics (Experimental)'`, nothing else. - Edited the surface mesh in Edit Mode after grooming? The roots float (238 of 238 in the test). `H.snap_to_surface(hair, "DEFORM")` puts them back (0 off-surface afterwards). Use NEAREST after topology changes (Schmidbauer: works about 99% of the time). ## P3. Guide resolution and shaping Verified on 5.2.1 by `test_01_groom_headless.py`: subdivide takes 2 points to 3, resample gives 15 with the attachment kept, and the helper modifier is applied and removed. Shrinkwrap is verified by `test_04_cards.py`. ```python H.subdivide_guides(hair, cuts=1) # Edit Mode curves.subdivide, works headless (EDIT_CURVES) H.resample_guides(hair, 15) # Bystedt: 15 points for long human hair, applied H.add_guides(hair, P, N, length=0.12, points=8, flow=f, flow_blend=0.8, droop=0.3) # gravity sag sw = H.add_asset(hair, "Shrinkwrap Hair Curves") # Bystedt's non-destructive intersection fix H.set_inputs(sw, {"Surface": head, "Offset Distance": 0.004, "Above Surface": 0.0}) ``` Raw resample (what `resample_guides` builds): a GN group with Resample Curve (Count), added as a NODES modifier, moved to index 0 and applied: ```python md = hair.modifiers.new("BX Resample", "NODES"); md.node_group = ng # Group In -> Resample Curve -> Group Out md.properties.inputs.Socket_1.value = 15 # Count (identifier verified in test_09) with bpy.context.temp_override(object=hair, active_object=hair): bpy.ops.object.modifier_move_to_index(modifier=md.name, index=0) bpy.ops.object.modifier_apply(modifier=md.name) ``` Brush substitutes, none of them strokes: - Comb: a direction field in `add_guides`. - Puff: `flow_blend` toward 0. - Grow/Shrink: Trim Hair Curves with a mask, or per-curve `length`. - Density brush: re-sample roots in the gap region and add guides there. - Delete: rebuild the curves without the bad indices (Schmidbauer: "delete it and add a new one"). ## P4. Asset modifiers and inputs Verified on 5.2.1 by `test_01_groom_headless.py` and `test_02_asset_stack.py`. All 21 listed modifier assets add. The Displace object 'Surface' resolves, unknown names raise with the list of inputs, and evaluation is stale without update_tag (2,510 before the tag, 4,996 after). ```python md = H.add_asset(hair, "Interpolate Hair Curves") # lands above the pinned Hair Dynamics H.set_inputs(md, {"Density": 20000.0, "Density Mask": "attr:density_fur", "Viewport Amount": 0.1}) print(H.get_inputs(md)); print(H.count(hair)) # count(full=True): Viewport Amount forced to 1 ``` Raw equivalent: ```python with bpy.context.temp_override(object=hair, active_object=hair, selected_objects=[hair]): bpy.ops.object.modifier_add_node_group(asset_library_type="ESSENTIALS", asset_library_identifier="", relative_asset_identifier="nodes/procedural_hair_node_assets.blend/NodeTree/Interpolate Hair Curves") md = hair.modifiers.active # 2nd copy is named 'Interpolate Hair Curves.001' md.properties.inputs.Input_15.value = 20000.0 # md["Input_15"] = v raises TypeError in 5.2 md.properties.inputs.Input_14.type = "ATTRIBUTE"; md.properties.inputs.Input_14.attribute_name = "density_fur" hair.update_tag() # REQUIRED before reading the evaluated result ev = hair.evaluated_get(bpy.context.evaluated_depsgraph_get()).data print(len(ev.curves), len(ev.points)) ``` - Hair Dynamics comes from `nodes/geometry_nodes_dynamics_assets.blend`. Duplicated modifiers share one node group. - Evaluation in `--background` applies Viewport Amount. In the test, 10% gave 249 curves against 2,502 at full density. - Reorder with `bpy.ops.object.modifier_move_to_index(modifier=..., index=...)` under the same temp_override. - Quick Fur (`bpy.ops.object.quick_fur(density='MEDIUM', length=0.1, radius=0.001)`) builds Set Hair Curve Profile, Interpolate (Follow Surface Normal on), Noise, Frizz and Hair Dynamics, with 12 points per guide. Its density scales with object size: 80,220/m² on a 10 cm sphere. Input identifiers, all verified in 5.2.1 (use them when you do not use `H.set_inputs` names): | Asset | Key inputs (identifier = default) | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Interpolate Hair Curves | Resting Surface `Input_12`=True, Follow Surface Normal `Input_24`=False, Part by Mesh Islands `Input_26`=True, Interpolation Guides `Input_10`=4, Distance to Guides `Input_28`=0, Distribution Method `Socket_2`=Random, Density `Input_15`=10, Density Mask `Input_14`=1, Mask Texture `Input_19`, Viewport Amount `Input_17`=1, Seed `Input_16` | | Set Hair Curve Profile | Replace Radius `Input_6`=True, Radius `Input_3`=0.01, Shape `Input_2`=0.5, Factor Min `Input_4`=0, Factor Max `Input_5`=1 | | Clump Hair Curves | Factor `Input_7`=1, Shape `Input_6`=0.5, Tip Spread `Input_10`=0, Clump Offset `Input_13`=0, Distance Falloff `Input_12`, Distance Threshold `Input_11`, Seed `Input_14`, Preserve Length `Input_15`, Guide Index `Input_16`, Guide Distance `Input_9`=0.1, Guide Mask `Input_19`=1, Existing Guide Map `Input_8`=True | | Curl Hair Curves | Factor `Input_2`, Subdivision `Input_5`=1, Curl Start `Input_6`=0.1, Radius `Input_7`=0.1, Factor Start `Input_9`=1, Factor End `Input_10`=1, Frequency `Input_11`=1, Random Offset `Input_16`=0.25, Seed `Input_15`, Guide Distance `Input_4`=0.1, Guide Mask `Input_18`=1, Existing Guide Map `Input_3`=True | | Hair Curves Noise | Cumulative Offset `Input_10`=True, Factor `Input_3`, Distance `Input_14`=0.01, Shape `Input_2`=0.5, Scale `Input_11`=1, Scale along Curve `Input_12`=1, Offset per Curve `Input_13`=0, Seed `Input_8`, Preserve Length `Input_6` | | Frizz Hair Curves | Cumulative Offset `Input_10`=True, Factor `Input_3`, Distance `Input_11`=0.01, Shape `Input_2`=0.5, Seed `Input_8`, Preserve Length `Input_6` | | Trim Hair Curves | Scale Uniform `Input_9`=False, Length Factor `Input_5`=1, Replace Length `Input_6`=True, Length `Input_2`=1.0, Mask `Input_7`=1, Random Offset `Input_4`=0, Pin at Parameter `Input_3`, Seed `Input_8` | | Rotate Hair Curves | Factor `Input_5`, Axis `Input_3`, Angle `Input_2`=0, Random Offset `Input_6`=0.349 rad, Lock Ends `Input_8` | | Roll Hair Curves | Factor `Input_10`, Subdivision `Input_13`, Variation Level `Input_11`=10, Roll Length `Input_2`=0.1, Roll Radius `Input_3`=0.05, Roll Depth `Input_9`, Roll Taper `Input_4`, Retain Overall Shape `Input_5`, Roll Direction `Input_7`, Random Orientation `Input_6`=0.5 | | Shrinkwrap Hair Curves | Surface Input Type `Socket_0`, Surface (object) `Input_2`, Factor `Input_5`, Offset Distance `Input_4`=0, Above Surface `Input_8`=0.5, Smoothing Steps `Input_6`, Lock Roots `Input_7`=True | | Displace Hair Curves | Factor `Input_3`, Shape `Input_8`, Object Space `Input_2`, Displace Vector `Input_9`, Surface Normal toggle `Socket_2`, Surface (object) `Input_4`, Surface UV Map `Input_6` (defaults to attribute `UVMap`), Surface Normal Distance `Input_7` | | Duplicate Hair Curves | Amount `Input_2`=10, Viewport Amount `Input_4`, Radius `Input_5`=0.1, Distribution Shape `Input_7`, Tip Roundness `Input_8`, Even Thickness `Input_6`, Seed `Input_3` | | Hair Dynamics | Mode `Socket_44`=Animation, Substeps `Socket_6`=10, Bendiness `Socket_20`=0.5, Root Bendiness `Socket_35`=0.2, Surface Collision `Socket_15`=False, Gravity `Socket_22`=True | ## P5. Masks and density maps Verified on 5.2.1 by `test_01_groom_headless.py`, `test_02_asset_stack.py` and `test_07_density_maps.py`. **Vertex group as Density Mask.** 796 children with the mask against 2,502 without. The surface group reaches the children as a CURVE-domain attribute, so later assets can read it: ```python H.set_inputs(interp, {"Density Mask": "attr:density_fur"}) H.set_inputs(noise, {"Factor": "attr:noise_strength"}) # tip motion 0.042 painted side vs 0.002 unpainted ``` **Bystedt's gray density map.** A weight of 0.5 halves the count (1,247 against 2,476). A white stripe at the part on a 0.5 base adds density there only (1,418). Paint the base at 50% so you keep headroom in both directions. **Mask Texture (5.2).** This is an image in surface UV space that discards children after distribution. Black on U > 0.5 left 1,237 of 2,476, with 0.8% leaking over the border: ```python H.set_inputs(interp, {"Mask Texture": bpy.data.images["density_mask"]}) ``` **Per-curve random masks (Thommes).** This is a small GN modifier placed right after Interpolate that stores a CURVE-domain float: ```python H.random_curve_mask(hair, "frizz_mask", threshold=0.8) # 82% of curves at 0 in the test H.random_curve_mask(hair, "stray_mask", threshold=0.9, hard=True) # 9.5% of curves at 1 H.set_inputs(frizz, {"Factor": "attr:frizz_mask"}) # masked curves moved 0.0, others 0.6 mm ``` The group is Random Value (FLOAT, ID = ID node, Seed) into Map Range (From Min = Threshold), with a Compare (Greater Than) for the hard version. A Switch picks one, and Store Named Attribute (FLOAT, CURVE, Name) writes it. **Root-to-tip ramps (Thommes: curl frequency rises toward the tip).** ```python H.along_curve_attr(hair, "curl_freq", root=1.0, tip=5.0) # Spline Parameter -> Map Range -> POINT float H.set_inputs(curl, {"Frequency": "attr:curl_freq"}) ``` **Full-density preview patch.** In 5.2.1 Thommes' trick (a Mask modifier on the surface, render off) does NOT reduce the children while Interpolate's Resting Surface is on (default). Verified: 2,526 curves with and without the Mask modifier, 538 with Resting Surface off. Use one of these instead: - A temporary patch vertex group as the Density Mask (610 curves) with Viewport Amount 1. - Resting Surface off while previewing, then back on. ## P6. Procedural stacks **Long fur (Thommes).** Verified on 5.2.1 by `test_02_asset_stack.py`. On a 10 × 10 cm patch, 1M/m² gave 10,021 curves (421 in the viewport at 4%), and a full-density evaluation took 0.44 s. ```python mods = H.build_stack(hair, H.FUR_STACK_LONG) # creates stray_mask / frizz_mask after Interpolate H.set_inputs(hair.modifiers["Hair Curves Noise"], {"Factor": "attr:noise_strength"}) ``` The order is Profile (0.2 mm), then Interpolate (1M, 4%), then the masks, then Noise, Curl, strays Noise (10%, 5 mm), Frizz (negative), Clump (1 mm, 0.1), Clump (4 mm, 0.25, offset 5 mm), Frizz (-0.2, 2 mm, Cumulative off, 20%), Trim (Replace Length off, 5 mm), Rotate (5°) and Hair Dynamics. The preset marks its own guesses [added]: Curl Factor End 0.3, first Frizz Shape -0.5, Clump Shape 0.3. The talk says "much lower" or "negative" without a number there. **Human hairstyle, two populations in one tree (Bystedt).** Verified on 5.2.1 by `test_08_populations_pose.py`. Two populations joined gave 632 red and 596 blue curves, and Global Density 0.5 halved the count (1,228 to 646). ```python # guides: 15 points; guide-level shaping before interpolation H.set_inputs(H.add_asset(hair, "Hair Curves Noise"), {"Cumulative Offset": False, "Offset per Curve": 1.0, "Scale along Curve": 0.5, "Distance": 0.004}) H.set_inputs(H.add_asset(hair, "Roll Hair Curves"), {"Roll Length": 0.02, "Roll Radius": 0.004, "Random Orientation": 1.0}) # tip curl only [values added] # one GN tree: per population Interpolate -> Set Hair Curve Profile -> Clump -> Store 'pop_color'; Join ng = bpy.data.node_groups.new("Hairstyle Populations", "GeometryNodeTree") ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") gd = ng.interface.new_socket("Global Density", in_out="INPUT", socket_type="NodeSocketFloat"); gd.default_value = 1.0 ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") gi, go, join = ng.nodes.new("NodeGroupInput"), ng.nodes.new("NodeGroupOutput"), ng.nodes.new("GeometryNodeJoinGeometry") def population(density, seed, clump_shape, clump_factor, color): it = ng.nodes.new("GeometryNodeGroup"); it.node_tree = bpy.data.node_groups["Interpolate Hair Curves"] mul = ng.nodes.new("ShaderNodeMath"); mul.operation = "MULTIPLY"; mul.inputs[1].default_value = density ng.links.new(gi.outputs["Global Density"], mul.inputs[0]); ng.links.new(mul.outputs[0], it.inputs["Density"]) it.inputs["Seed"].default_value = seed ng.links.new(gi.outputs["Geometry"], it.inputs["Geometry"]) pr = ng.nodes.new("GeometryNodeGroup"); pr.node_tree = bpy.data.node_groups["Set Hair Curve Profile"] pr.inputs["Radius"].default_value = 0.0003 ng.links.new(it.outputs["Geometry"], pr.inputs["Geometry"]) cl = ng.nodes.new("GeometryNodeGroup"); cl.node_tree = bpy.data.node_groups["Clump Hair Curves"] cl.inputs["Shape"].default_value = clump_shape; cl.inputs["Factor"].default_value = clump_factor cl.inputs["Seed"].default_value = seed ng.links.new(pr.outputs["Geometry"], cl.inputs["Geometry"]) st = ng.nodes.new("GeometryNodeStoreNamedAttribute"); st.data_type = "FLOAT_COLOR"; st.domain = "CURVE" st.inputs["Name"].default_value = "pop_color"; st.inputs["Value"].default_value = color ng.links.new(cl.outputs["Geometry"], st.inputs["Geometry"]); ng.links.new(st.outputs["Geometry"], join.inputs["Geometry"]) population(5000.0, 1, 0.3, 0.5, (1, 0, 0, 1)) # wide clumps: Shape ~0.3, reduced Factor (Bystedt) population(5000.0, 2, 0.05, 0.7, (0, 0, 1, 1)) # thin clumps: different seed ng.links.new(join.outputs["Geometry"], go.inputs["Geometry"]) md = hair.modifiers.new("Hairstyle", "NODES"); md.node_group = ng H.move(hair, md, [m.name for m in hair.modifiers].index("Hair Dynamics")) ``` - The asset groups must exist locally first. `H.add_asset` on a throwaway curves object appends them, or use `bpy.data.libraries.load`. - Read `pop_color` in the shader with an Attribute node to see each population (Bystedt's debug colors). - Alternative without a tree: `twin = hair.copy()` shares the Curves data, so both objects use the same guides and each gets its own stack. Verified: the twin evaluated 362 curves. **Animal regional stack (Schmidbauer).** Verified on 5.2.1 by `test_09_animal_regions.py`. Each masked effect acted inside its painted region and barely outside it. Tip motion inside vs outside: Trim 1.0 mm vs 0.06 mm, Clump 7.3 mm vs 0.4 mm, Frizz 1.2 mm vs 0.09 mm. Tip Spread is a distance in meters: 0.2 threw 3 cm tips 19 cm. Use one curves object per landmark, and give each effect its own vertex group on the shared body mesh: ```python H.set_inputs(H.add_asset(fur, "Interpolate Hair Curves"), {"Density": 200000.0, "Density Mask": "attr:body_fur"}) # density [added] H.set_inputs(H.add_asset(fur, "Trim Hair Curves"), {"Replace Length": False, "Random Offset": 0.002, "Mask": "attr:length_fur"}) H.set_inputs(H.add_asset(fur, "Clump Hair Curves"), {"Factor": "attr:clump", "Tip Spread": 0.002, "Clump Offset": 0.001}) H.set_inputs(H.add_asset(fur, "Frizz Hair Curves"), {"Factor": "attr:frizz", "Distance": 0.001}) ``` The distances in that block are [added] starting points for a cat-size animal; the talk gives no numbers for them. Tune them in renders. ## P7. Part and hairline (Bystedt) Verified on 5.2.1 by `test_05_parting.py`. The cap splits into 2 islands. With Part by Mesh Islands off, 21% of children near the part crossed it; with it on, 0%. The parting lift raised mid-strand height near the part from 9.6 mm to 13.7 mm. ```python import bmesh bm = bmesh.new(); bm.from_mesh(cap.data) part_edges = [e for e in bm.edges if all(abs(v.co.x) < 1e-6 for v in e.verts)] # the part line bmesh.ops.split_edges(bm, edges=part_edges); bm.to_mesh(cap.data); bm.free() # 2 islands # 'part' vertex group on the line (weight 1), then guides combed away from it: flow = lambda P, N: np.stack([np.sign(P[:, 0]), np.zeros(len(P)), -0.6 * np.ones(len(P))], 1) H.add_guides(hair, P, N, length=0.12, points=8, flow=flow, flow_blend=0.85, droop=0.3) interp = H.add_asset(hair, "Interpolate Hair Curves") # Part by Mesh Islands is on by default H.distance_weights(cap, "part_lift", "part", radius=0.03, profile="bump") # 0 on the line, 1 at r/2, 0 at r disp = H.add_asset(hair, "Displace Hair Curves") H.set_inputs(disp, {"Surface Normal": True, "Surface": cap, "Surface Normal Distance": 0.006, "Shape": 0.05, "Factor": "attr:part_lift"}) ``` - Keep guides off the part line itself; the test dropped roots within 4 mm of it. - The render still shows scalp at the part (Bystedt: "at the center line it's very hard to get enough coverage"). Add density there: a white stripe in the density map (P5). - Not scripted here: Bystedt's root snap onto the part line, where roots within a distance move onto the line. As a GN sketch: Object Info of the line mesh, Geometry Proximity with target Edges, Set Position on root points mixed by a factor. Not run. ## P8. Shading, render settings, review Verified on 5.2.1 by `test_03_shading_render.py` on 37,648 curves. Render times were 4.4 s for Cycles at 24 samples and 384 px on CPU, and 1.3 to 2.3 s for EEVEE. ```python m = H.hair_material("Fur", "CYCLES", melanin=(0.85, 0.45), uv_noise=0.3) # Principled Hair (Chiang, melanin) m = H.hair_material("Fur", "EEVEE", root_color=(0.05, 0.025, 0.012), tip_color=(0.4, 0.2, 0.08)) H.assign_material(hair, m) H.setup_render(bpy.context.scene, "CYCLES", close_up=False) # STRIP + subdiv; THICK curves for close-ups sheet = H.review([surface, hair], "/abs/out", engine="CYCLES", views=("front", "threequarter", "top"), res=512) ``` Raw material graph: - Curves Info (`ShaderNodeHairInfo`) Intercept goes to a Mix (RGBA, root color to tip color) or to a Map Range for melanin. - Curves Info Random goes to a Map Range for roughness 0.25 to 0.4. - Cycles: `ShaderNodeBsdfHairPrincipled` with `parametrization='MELANIN'` or `'COLOR'`. EEVEE: `ShaderNodeBsdfPrincipled` Base Color. - Surface-mapped variation (Thommes): Attribute `surface_uv_coordinate` into a Noise Texture, blended with Mix Soft Light. - Mix node sockets must be addressed by identifier (`A_Color`, `B_Color`, `Result_Color`, `Factor_Float`). `inputs["A"]` returns the hidden float socket. Measured center luminance for the same groom: 0.255 for Principled Hair in Cycles, 0.092 for Principled Hair in EEVEE, and 0.498 for Principled BSDF in EEVEE. So use Principled BSDF with explicit colors under EEVEE. The review rig: - A warm area fill from the top, a small side key and a cool rim. - A world of RGB (0.03, 0.04, 0.06). - Energies scale with the framing distance squared. - The user's scene is left untouched. ## P9. Pose check (Schmidbauer) Verified on 5.2.1 by `test_01_groom_headless.py` and `test_08_populations_pose.py`. With a shape key, roots followed at a mean radius of 0.1558 against 0.156 expected. With Hair Dynamics disabled they stayed at 0.1199. On an armature-posed frame, roots stayed on the deformed cap within 1.7e-8. ```python scene.frame_set(10) # a keyed extreme pose sheet = H.review([body, fur], out, engine="EEVEE", views=("right",), frame=10) # review renders that frame ``` Check 2 or 3 extreme poses from the same camera, looking for bald spots, stretching gaps and clipping. Keep Hair Dynamics (Animation) last in the stack. ## P10. Hair cards and tubes for games (Bystedt, Matsumoto) Verified on 5.2.1 by `test_04_cards.py` (12 checks): - Cards: tris = guides × 11 × 3 × 2. - UVs: U 0 to 0.5, V 0 to 1. - Roots on the scalp within 1.9e-8. - Root faces along the scalp: mean |normal · surface normal| 0.957. - Tubes: tris = guides × 11 × 6 × 2, U 0 to 1. - `object.convert` keeps `UVMap` as a UV layer, and the glTF export (cards plus cap) was 301 KB. ```python mat = H.card_material("Card") # procedural strand stand-in [added], or image=baked_plate md = H.add_cards(hair, width=0.02, material=mat, segments=3, resample=12, root_scale=1.0, tip_scale=0.3) print(H.card_stats(hair)) # {'tris', 'uv_min', 'uv_max', ...} H.set_inputs(md, {"Tubes": True}) # close-ups or bald spots (Matsumoto) # export: work on a copy, drop Hair Dynamics, convert dup = hair.copy(); dup.data = hair.data.copy(); bpy.context.collection.objects.link(dup) dup.modifiers.remove(dup.modifiers["Hair Dynamics"]) for o in bpy.context.selected_objects: o.select_set(False) dup.select_set(True); bpy.context.view_layer.objects.active = dup bpy.ops.object.convert(target="MESH") bpy.ops.export_scene.gltf(filepath="/abs/cards.glb", use_selection=True, export_format="GLB") ``` What `cards_group()` builds ('BX Hair Cards'), in order: 1. Resample Curve (count). 2. Set Curve Normal with Mode `Free` and Normal from Sample Nearest Surface of the surface normals, so the card lies on the scalp (Bystedt). 3. Capture Attribute of Spline Parameter on the curve (V). 4. The profile: an arc of angle Bend centered on +X and shifted so its apex sits on the curve, or a flat Spiral for tubes (resolution 6, 1 rotation, height 0, start radius = end radius; Matsumoto). A Switch picks one, and a Capture of Spline Parameter on the profile gives U. 5. Curve to Mesh, with Scale = root-to-tip taper (the 4.5+ Scale input). 6. Store Named Attribute FLOAT2, CORNER, `UVMap` = (U × 0.5 for cards or × 1 for tubes, V). 7. Set Shade Smooth, then Set Material. 8. Set Position of the V < 1e-4 row onto Geometry Proximity (Faces) of the surface. This runs last (Matsumoto). 5.2 gotchas met while building it: - Capture Attribute has a Selection input, so address items by name. - Arc Resolution counts points (segments + 1). - `obj.evaluated_geometry().mesh` is freed when the GeometrySet is not kept in a variable. - A stored FLOAT2 CORNER attribute named `UVMap` works directly in the material UV Map node and survives conversion, so no extra output attribute is needed. Not scripted (expert features to add when needed): Bystedt's snap-if-inside push-out, normal transfer from the head, color and UV transfer from the scalp at the card root, card variants instanced per curve, and 1 subdivision per 20 cm (set `resample` from the length instead). Also not scripted: Matsumoto's strand-plate bake (orthographic camera over a groomed plane; passes normal, depth, AO, position, alpha; depth remapped). Render it with the same review tools, but it has no test here. ## P11. Live session: brush setup and hand-off (GUI) GUI only. Verified by `test_06_gui_curves_sculpt.py`, run as `blender --factory-startup --python ...` with a window and 7 checks. ```python import bx_gui as G bpy.ops.object.mode_set(mode="SCULPT_CURVES") # curves object active G.activate_brush("SCULPT_CURVES", "Add") # Add, Comb, Delete, Density, Grow/Shrink, Pinch, cs = bpy.context.scene.tool_settings.curves_sculpt.brush.curves_sculpt_settings # Puff, Pull Tips, Select, Slide, Smooth cs.points_per_curve = 2; cs.curve_length = 0.01 # Schmidbauer cs.use_length_interpolate = cs.use_shape_interpolate = cs.use_point_count_interpolate = True # Bystedt G.activate_brush("SCULPT_CURVES", "Comb") bpy.context.scene.tool_settings.curves_sculpt.brush.falloff_shape = "PROJECTED" # Bystedt: reaches the far side bpy.context.scene.tool_settings.curves_sculpt.use_symmetry_x = True # now ask the user to stroke; G.stroke("SCULPT_CURVES", ...) returns {'PASS_THROUGH'} and changes nothing ``` In 5.2.1 `sculpt_curves.brush_stroke` has no exec: the console logs "Invalid operator call". `INVOKE_DEFAULT` returns RUNNING_MODAL and waits for a real mouse. `--enable-event-simulate` with `Window.event_simulate` produced no strokes either, for curves or for mesh sculpt, so strokes need a person. What does work from code in that mode is `sculpt_curves.select_random`, `select_grow` and every Object-Mode procedure above. -
sources.md 6.8 KB
# Sources Five videos were distilled into this skill. Notes and transcripts are in the project folder: `notes/hair/<id> - <title>.md`, the batch digest `notes/hair/_digest_hair.md`, and `transcripts/<id>.txt` (auto captions). None had frame grabs, so visual details come from speech. The Blender versions on screen were 3.5 to about 4.5. `procedures.md` and the **5.2** marks in `expert-notes.md` record what changed in 5.2.1. | # | Video | Expert and credential | Date, length, version | Best for | | --- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | [Character grooming with the new hair system](https://www.youtube.com/watch?v=Kx88edAbiek) (Blender Conference 2023) | **Daniel Bystedt**, senior concept artist at Goodbye Kansas Studios (VFX, game cinematics), Blender sculpt and modeling module contributor, author of the official hair demo files | 2023-10-26, 41:30, about 3.6 or 4.0 | Human hairstyle as layered populations, hair cap, parting fixes, density map, debug colors | | 2 | [A million little curves: creating hair for animals](https://www.youtube.com/watch?v=ZrBbNhCgGww) (Blender Conference 2025) | **Kerstin Schmidbauer**, founder and technical director of Critter Crew Animation (leads the fur department, a new groom every year) | 2025-09-18, 53:55, about 4.4 or 4.5 | Animal fur: reference, 2-point guides, landmark objects, masks for everything, troubleshooting (bald spots, invalid UVs, mouth, dents) | | 3 | [How to Make Procedural Fur in Blender Geometry Nodes](https://www.youtube.com/watch?v=gCQN5vNgHiI) (Blender Studio) | **Simon Thommes**, Blender Studio technical artist, co-author of the procedural hair node assets and demo files | 2023-03-29, 34:58, 3.5 | Value-by-value procedural long-fur recipe, asset mechanics (Shape, guide maps, density units), per-curve randomness, shading and review rig | | 4 | [Geometry nodes hair card setup for Blender](https://www.youtube.com/watch?v=g_ZIOafq4QU) | **Daniel Bystedt** (as above) | 2024-03-17, 25:53, about 4.0 or 4.1 | Specification of a production hair-card generator: surface-aligned tilt, root snap, transfers, hair cap, texture scene | | 5 | [Mesh Hair with Geometry Nodes and Hair Curves](https://www.youtube.com/watch?v=vXBL-oiqY7Q) (Blender Conference 2024) | **Sara Matsumoto**, SM5 by Helan (fantasy asset store, Transhuman character creator for Blender) | 2024-10-24, 43:10, about 4.2 | Cards and tubes from curves with GN UVs, in-Blender strand texture plates, anisotropic card material | ## Best timestamps **1. Bystedt, BCON23 (`Kx88edAbiek`)** - `[00:02:19]` to `[00:02:53]`: hair cap and splitting it along the part. - `[00:05:06]` to `[00:07:24]`: brush settings (Projected falloff, Scale Uniform off, Curve-domain selection). - `[00:08:38]`: resample to 15. `[00:09:52]`: grow, comb, shrink. - `[00:11:33]` to `[00:12:09]`: Shrinkwrap Hair Curves. - `[00:13:24]` to `[00:15:44]`: Noise and Roll settings. - `[00:16:21]` to `[00:21:31]`: Interpolate, Profile, Clump for wide clumps (Shape semantics at `[00:18:04]`). - `[00:22:04]` to `[00:24:56]`: density map and Global Density. - `[00:26:08]`: debug colors. `[00:27:54]` to `[00:29:44]`: thin-clump population. - `[00:32:06]` to `[00:41:16]`: parting root snap and lift graph. **2. Schmidbauer, BCON25 (`ZrBbNhCgGww`)** - `[00:03:24]` to `[00:08:43]`: reference discipline and draw-overs. - `[00:13:15]` to `[00:14:25]`: objects per landmark. - `[00:19:44]` to `[00:21:34]`: 2-point guides. - `[00:22:07]` to `[00:26:14]`: Interpolate masks and density units. - `[00:27:26]` to `[00:32:36]`: Trim, Clump, Frizz with masks. - `[00:33:16]` to `[00:47:50]`: troubleshooting (deformation x10, mouth, dents, invalid UVs, mirror seam, snapping). - `[00:50:04]` to `[00:53:28]`: shape-key-like groom blend. **3. Thommes, Blender Studio (`gCQN5vNgHiI`)** - `[00:02:32]` to `[00:04:20]`: density per area, snap to deformed surface, Strip display, Profile Shape. - `[00:09:02]` to `[00:09:35]`: 1M density and 4% viewport. - `[00:12:04]` to `[00:16:44]`: Curl, guide maps, Frizz shape, sub-clumps, radius. - `[00:17:16]` to `[00:21:13]`: lighting, shader (Cycles vs EEVEE), color variation. - `[00:19:32]`: first-render critique. - `[00:25:31]` to `[00:32:08]`: per-curve randomness, curl frequency ramp, strays, second clump, final frizz. - `[00:32:51]`: full-density patch. **4. Bystedt, hair cards (`g_ZIOafq4QU`)** - `[00:00:31]` to `[00:06:16]`: generator inputs. - `[00:06:53]` to `[00:11:03]`: masks, color and UV transfer, parting line. - `[00:17:15]` to `[00:22:28]`: character setup (front and back grooms, thick and sparse cards, hair cap). - `[00:23:04]` to `[00:25:25]`: texture scene and outputs. **5. Matsumoto, BCON24 (`vXBL-oiqY7Q`)** - `[00:03:55]` to `[00:10:57]`: strand plate grooming and passes. - `[00:11:37]` to `[00:17:06]`: material test rig and anisotropic card material. - `[00:18:16]` to `[00:24:21]`: GN cards and tubes (Resample, Arc, Spiral, taper). - `[00:24:36]` to `[00:31:27]`: GN UVs and the card vs tube density fix. - `[00:32:09]` to `[00:33:47]`: root attach. - `[00:39:47]` to `[00:41:28]`: density scale, hairline systems, render times. ## Related skills - scenario-blender-expert: the router, execution channel and review loop. - scenario-blender-geometry-nodes: general node-group work beyond the hair assets. - scenario-blender-texturing-shading: the head and skin under the groom. - scenario-blender-lighting-rendering: final lighting of hero shots. - scenario-blender-rigging: the deformation the groom has to survive. - scenario-3d: generated characters to groom.
-
-
scripts
-
bx_hair.py 53.8 KB
""" bx_hair: headless hair-curves grooming for Blender 5.2 (tested on 5.2.1 LTS). What experts do with a tablet (place guides, comb, add procedural layers, judge a render) done from Python. Brush strokes on hair curves are NOT scriptable in 5.2.1 (sculpt_curves.brush_stroke has no exec, see SKILL.md), so guides are written as data and every look decision goes through the Essentials hair node-group assets. import sys; sys.path.append("<skill>/scripts"); import bx_hair as H hair = H.new_groom(scalp) # Empty Hair: parent, surface, UV map, Hair Dynamics P, N = H.sample_roots(scalp, spacing=0.01, mask="density_fur") H.add_guides(hair, P, N, length=0.03, points=2, flow=(0, 0, -1), flow_blend=0.6) md = H.add_asset(hair, "Interpolate Hair Curves") H.set_inputs(md, {"Density": 20000, "Density Mask": "attr:density_fur"}) H.random_curve_mask(hair, "frizz_mask", threshold=0.8) # Simon: 80% of strands get no frizz fz = H.add_asset(hair, "Frizz Hair Curves"); H.set_inputs(fz, {"Factor": "attr:frizz_mask"}) print(H.count(hair)); rep = H.groom_report(hair); print(H.verdict(rep)) H.review([scalp, hair], "/abs/out", engine="CYCLES") # lit render sheet, open it with the image reader Every function works in a live GUI session too (object mode). Values are metres. """ __version__ = "0.1" # Blender Expert Skills v0.1 (2026-09-24) import math import os import sys import bpy import numpy as np from mathutils import Vector, kdtree HAIR_LIB = "nodes/procedural_hair_node_assets.blend" DYN_LIB = "nodes/geometry_nodes_dynamics_assets.blend" DYNAMICS_ASSETS = {"Hair Dynamics", "Cloth Dynamics (Experimental)", "Collider", "Custom Force"} # Modifier assets in procedural_hair_node_assets.blend (5.2.1), by asset-browser catalog. HAIR_ASSETS = { "Generation": ["Interpolate Hair Curves", "Duplicate Hair Curves", "Generate Hair Curves"], "Guides": ["Clump Hair Curves", "Curl Hair Curves", "Braid Hair Curves", "Create Guide Index Map"], "Deformation": ["Hair Curves Noise", "Frizz Hair Curves", "Trim Hair Curves", "Rotate Hair Curves", "Roll Hair Curves", "Displace Hair Curves", "Shrinkwrap Hair Curves", "Smooth Hair Curves", "Straighten Hair Curves", "Blend Hair Curves"], "Write": ["Set Hair Curve Profile"], "Utility": ["Attach Hair Curves to Surface", "Redistribute Curve Points", "Restore Curve Segment Length"], # node-only (no modifier): Attachment Info, Curve Info, Curve Root, Curve Segment, Curve Tip } DEFORMERS = set(HAIR_ASSETS["Guides"] + HAIR_ASSETS["Deformation"]) - {"Create Guide Index Map"} # Simon Thommes' highland-cow long fur (Blender Studio, gCQN5vNgHiI), values from the talk. # Absolute metres: tuned for a real-size animal patch. Entries marked [added] are not in the talk. FUR_STACK_LONG = [ ("Set Hair Curve Profile", {"Radius": 0.0002}), # 0.2 mm at 1M/m2 ("Interpolate Hair Curves", {"Density": 1_000_000.0, "Viewport Amount": 0.04}), ("Hair Curves Noise", {}), # Factor: paint a mask ("Curl Hair Curves", {"Radius": 0.02, "Curl Start": 1.0, "Factor End": 0.3, # Factor End "much lower" [added 0.3] "Random Offset": 0.5, "Existing Guide Map": False, "Guide Distance": 0.01, "Guide Mask": 0.2}), ("Hair Curves Noise", {"Distance": 0.005, "Factor": "attr:stray_mask"}), # strays: 10% of curves ("Frizz Hair Curves", {"Distance": 0.002, "Shape": -0.5}), # negative shape (value [added]) ("Clump Hair Curves", {"Existing Guide Map": False, "Guide Distance": 0.001, "Guide Mask": 0.1, "Shape": 0.3}), # "lower Shape" [added 0.3] ("Clump Hair Curves", {"Existing Guide Map": False, "Guide Distance": 0.004, "Guide Mask": 0.25, "Clump Offset": 0.005}), ("Frizz Hair Curves", {"Shape": -0.2, "Distance": 0.002, "Cumulative Offset": False, "Factor": "attr:frizz_mask"}), ("Trim Hair Curves", {"Replace Length": False, "Random Offset": 0.005}), ("Rotate Hair Curves", {"Random Offset": math.radians(5)}), ] # ----------------------------------------------------------------------------- basics def _s(sockets, key): """Socket by identifier, else by name among enabled sockets (Mix, Capture Attribute and other dynamic nodes reuse names like 'A' or 'Result' across data types).""" for sk in sockets: if sk.identifier == key: return sk for sk in sockets: if sk.name == key and sk.enabled: return sk raise KeyError(key) def _ctx_obj(obj): return bpy.context.temp_override(object=obj, active_object=obj, selected_objects=[obj], selected_editable_objects=[obj]) def _object_mode(): if bpy.context.mode != "OBJECT": bpy.ops.object.mode_set(mode="OBJECT") def new_groom(surface, name="Hair"): """Add an empty hair curves object on `surface` (Add > Curve > Empty Hair). Sets parent, data.surface, data.surface_uv_map (active UV map) and a pinned Hair Dynamics modifier in Animation mode (moves hair with the deforming surface).""" if surface.type != "MESH" or not surface.data.uv_layers: raise ValueError("surface must be a mesh with a UV map (hair binds to UV space)") _object_mode() for o in bpy.context.selected_objects: o.select_set(False) surface.select_set(True) bpy.context.view_layer.objects.active = surface bpy.ops.object.curves_empty_hair_add() hair = bpy.context.active_object hair.name = hair.data.name = name bpy.context.view_layer.update() return hair def _mesh_arrays(surface): me = surface.data me.calc_loop_triangles() tris = np.empty(len(me.loop_triangles) * 3, np.int32) me.loop_triangles.foreach_get("vertices", tris) co = np.empty(len(me.vertices) * 3, np.float32) me.vertices.foreach_get("co", co) vn = np.empty(len(me.vertices) * 3, np.float32) me.vertex_normals.foreach_get("vector", vn) return tris.reshape(-1, 3), co.reshape(-1, 3), vn.reshape(-1, 3) def group_weights(obj, group): """Per-vertex weights of a vertex group as a numpy array (0 where unassigned).""" vg = obj.vertex_groups[group] w = np.zeros(len(obj.data.vertices), np.float32) for v in obj.data.vertices: for g in v.groups: if g.group == vg.index: w[v.index] = g.weight return w def sample_roots(surface, spacing, mask=None, threshold=0.5, seed=0, max_roots=200_000): """Evenly spaced guide roots on the surface (Poisson-disk dart throwing). Kerstin: even spacing matters more than count; gaps become bald spots. Returns (P, N) world-space positions and smooth normals, numpy (n, 3).""" tris, co, vn = _mesh_arrays(surface) M = np.array(surface.matrix_world, dtype=np.float64) R = M[:3, :3] cw = co @ R.T + M[:3, 3] nrm_m = np.linalg.inv(R).T nw = vn @ nrm_m.T a, b, c = cw[tris[:, 0]], cw[tris[:, 1]], cw[tris[:, 2]] area = 0.5 * np.linalg.norm(np.cross(b - a, c - a), axis=1) total = float(area.sum()) n_cand = int(min(max_roots * 6, max(64, 6 * total / (spacing * spacing)))) rng = np.random.default_rng(seed) ti = rng.choice(len(tris), n_cand, p=area / total) s = np.sqrt(rng.random(n_cand)) r2 = rng.random(n_cand) u, v, w = 1 - s, s * (1 - r2), s * r2 t = tris[ti] P = a[ti] * u[:, None] + b[ti] * v[:, None] + c[ti] * w[:, None] N = nw[t[:, 0]] * u[:, None] + nw[t[:, 1]] * v[:, None] + nw[t[:, 2]] * w[:, None] N /= np.linalg.norm(N, axis=1)[:, None] + 1e-12 if mask: wv = group_weights(surface, mask) wp = wv[t[:, 0]] * u + wv[t[:, 1]] * v + wv[t[:, 2]] * w keep = wp >= threshold P, N = P[keep], N[keep] # dart throwing on a hash grid, cell = spacing grid, keep = {}, [] inv = 1.0 / spacing s2 = spacing * spacing for i, p in enumerate(P): k = (int(math.floor(p[0] * inv)), int(math.floor(p[1] * inv)), int(math.floor(p[2] * inv))) ok = True for dx in (-1, 0, 1): for dy in (-1, 0, 1): for dz in (-1, 0, 1): for j in grid.get((k[0] + dx, k[1] + dy, k[2] + dz), ()): d = P[j] - p if d @ d < s2: ok = False break if not ok: break if not ok: break if not ok: break if ok: grid.setdefault(k, []).append(i) keep.append(i) if len(keep) >= max_roots: break keep = np.array(keep, dtype=np.int64) return P[keep], N[keep] def add_guides(hair, roots, normals, length, points=2, flow=None, flow_blend=0.0, droop=0.0, snap=True): """Write guide curves (the tablet-free replacement for the Add brush). roots, normals: (n, 3) world space (from sample_roots). length: float or (n,). points: Kerstin grooms with 2 points until every direction is right, 3 for short fur, up to 8 for long hair; Bystedt resamples long human hair to 15. flow: None, a world vector, or callable(P, N) -> (n, 3) flow directions; it is projected on the surface tangent plane and blended with the normal by flow_blend (0 = straight out, 1 = lying on the surface). droop: tip sag along -Z as a fraction of length (needs points >= 3 to bend). snap: snap_curves_to_surface(NEAREST), which writes surface_uv_coordinate (the attachment). Returns the number of curves added.""" _object_mode() P = np.asarray(roots, np.float64) N = np.asarray(normals, np.float64) n = len(P) if n == 0: return 0 L = np.broadcast_to(np.asarray(length, np.float64), (n,)) d = N.copy() if flow is not None and flow_blend > 0: F = flow(P, N) if callable(flow) else np.broadcast_to(np.asarray(flow, np.float64), (n, 3)) T = F - (F * N).sum(1)[:, None] * N T /= np.linalg.norm(T, axis=1)[:, None] + 1e-12 d = (1 - flow_blend) * N + flow_blend * T d /= np.linalg.norm(d, axis=1)[:, None] + 1e-12 t = np.linspace(0.0, 1.0, points) pts = P[:, None, :] + d[:, None, :] * (L[:, None, None] * t[None, :, None]) if droop: pts[:, :, 2] -= droop * L[:, None] * t[None, :] ** 2 inv = np.array(hair.matrix_world.inverted(), dtype=np.float64) loc = pts.reshape(-1, 3) @ inv[:3, :3].T + inv[:3, 3] cv = hair.data old = len(cv.points) cv.add_curves([points] * n) buf = np.empty(len(cv.points) * 3, np.float32) cv.attributes["position"].data.foreach_get("vector", buf) buf[old * 3:] = loc.ravel() cv.attributes["position"].data.foreach_set("vector", buf) cv.update_tag() if snap: snap_to_surface(hair, "NEAREST") return n def snap_to_surface(hair, mode="NEAREST"): """NEAREST: move each curve so its root sits on the closest surface point and store the attachment UV. DEFORM: re-place curves from their stored UV after the surface was edited (Simon: Snap to Deformed Surface).""" _object_mode() with _ctx_obj(hair): return bpy.ops.curves.snap_curves_to_surface(attach_mode=mode) def subdivide_guides(hair, cuts=1): """Add points to every guide (Edit Mode curves.subdivide, works headless).""" _object_mode() with _ctx_obj(hair): bpy.ops.object.mode_set(mode="EDIT") bpy.ops.curves.select_all(action="SELECT") bpy.ops.curves.subdivide(number_cuts=cuts) bpy.ops.object.mode_set(mode="OBJECT") return len(hair.data.points) // max(1, len(hair.data.curves)) def resample_guides(hair, count=15): """Bystedt: resample guides to a fixed point count, then apply (bakes the new points).""" ng = bpy.data.node_groups.get("BX Resample Curves") or _resample_group() md = hair.modifiers.new("BX Resample", "NODES") md.node_group = ng set_inputs(md, {"Count": int(count)}) with _ctx_obj(hair): bpy.ops.object.modifier_move_to_index(modifier=md.name, index=0) bpy.ops.object.modifier_apply(modifier=md.name) return len(hair.data.points) // max(1, len(hair.data.curves)) def _resample_group(): ng = bpy.data.node_groups.new("BX Resample Curves", "GeometryNodeTree") ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") s = ng.interface.new_socket("Count", in_out="INPUT", socket_type="NodeSocketInt") s.default_value, s.min_value = 15, 2 ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") gi, go = ng.nodes.new("NodeGroupInput"), ng.nodes.new("NodeGroupOutput") rs = ng.nodes.new("GeometryNodeResampleCurve") ng.links.new(gi.outputs["Geometry"], rs.inputs["Curve"]) ng.links.new(gi.outputs["Count"], rs.inputs["Count"]) ng.links.new(rs.outputs["Curve"], go.inputs["Geometry"]) return ng # ----------------------------------------------------------------------------- assets def add_asset(hair, name, label=None, index=None): """Add an Essentials hair (or dynamics) node-group asset as a modifier. New modifiers land above the pinned Hair Dynamics. Returns the modifier.""" _object_mode() lib = DYN_LIB if name in DYNAMICS_ASSETS else HAIR_LIB with _ctx_obj(hair): bpy.ops.object.modifier_add_node_group( asset_library_type="ESSENTIALS", asset_library_identifier="", relative_asset_identifier=f"{lib}/NodeTree/{name}") md = hair.modifiers.active if label: md.name = label if index is not None: move(hair, md, index) return md def move(hair, md, index): with _ctx_obj(hair): bpy.ops.object.modifier_move_to_index(modifier=md.name, index=index) def _input_items(md): out = {} for it in md.node_group.interface.items_tree: if it.item_type == "SOCKET" and it.in_out == "INPUT" and it.socket_type != "NodeSocketGeometry": out.setdefault(it.name, []).append(it) return out def _resolve(md, key, value): ins = md.properties.inputs if key.startswith(("Input_", "Socket_")) and hasattr(ins, key): return key items = _input_items(md).get(key) if not items: raise KeyError(f"{md.name}: no input '{key}'. Inputs: {sorted(_input_items(md))}") if len(items) > 1: # e.g. Displace/Shrinkwrap 'Surface' exists as Object and Geometry want = "NodeSocketObject" if isinstance(value, bpy.types.Object) else None for it in items: if (want and it.socket_type == want) or (not want and it.socket_type != "NodeSocketObject"): return it.identifier return items[0].identifier def set_inputs(md, values): """Set modifier inputs by their UI name (or Input_/Socket_ identifier). "attr:NAME" binds the input to an attribute (a surface vertex group reaches the interpolated children as an attribute). Tags the object: in background the evaluated result stays stale without update_tag().""" ins = md.properties.inputs for key, val in values.items(): p = getattr(ins, _resolve(md, key, val)) if isinstance(val, str) and val.startswith("attr:"): p.type = "ATTRIBUTE" p.attribute_name = val[5:] else: if hasattr(p, "type") and p.type == "ATTRIBUTE": p.type = "VALUE" p.value = val md.id_data.update_tag() return md def get_inputs(md): """{UI name: value} with 'attr:NAME' for attribute-bound inputs.""" ins, out = md.properties.inputs, {} for name, items in _input_items(md).items(): for it in items: p = getattr(ins, it.identifier, None) if p is None: continue if getattr(p, "type", "VALUE") == "ATTRIBUTE": v = "attr:" + p.attribute_name elif not hasattr(p, "value"): # matrix / bundle sockets have no stored value continue else: v = p.value if isinstance(v, bpy.types.ID): v = v.name elif hasattr(v, "__len__") and not isinstance(v, str): v = tuple(round(x, 5) for x in v) elif isinstance(v, float): v = round(v, 6) out[name if len(items) == 1 else f"{name} ({it.socket_type[10:]})"] = v return out def build_stack(hair, spec, masks=True): """Add a list of (asset name, {inputs}) in order. With masks=True, the random curve masks referenced as attr:stray_mask / attr:frizz_mask are created first.""" mods = [] used = {v[5:] for _, vals in spec for v in vals.values() if isinstance(v, str) and v.startswith("attr:")} for name, vals in spec: md = add_asset(hair, name) set_inputs(md, vals) mods.append(md) if name == "Interpolate Hair Curves" and masks: if "stray_mask" in used: mods.append(random_curve_mask(hair, "stray_mask", threshold=0.9, hard=True, seed=3)) if "frizz_mask" in used: mods.append(random_curve_mask(hair, "frizz_mask", threshold=0.8, seed=7)) return mods # ----------------------------------------------------------------------------- per-curve fields def _attr_group(kind): name = {"random": "BX Random Curve Mask", "along": "BX Along Curve Ramp"}[kind] ng = bpy.data.node_groups.get(name) if ng: return ng ng = bpy.data.node_groups.new(name, "GeometryNodeTree") I = ng.interface I.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") I.new_socket("Name", in_out="INPUT", socket_type="NodeSocketString") gi, go = ng.nodes.new("NodeGroupInput"), ng.nodes.new("NodeGroupOutput") st = ng.nodes.new("GeometryNodeStoreNamedAttribute") st.data_type = "FLOAT" if kind == "random": I.new_socket("Threshold", in_out="INPUT", socket_type="NodeSocketFloat").default_value = 0.8 I.new_socket("Seed", in_out="INPUT", socket_type="NodeSocketInt") I.new_socket("Hard", in_out="INPUT", socket_type="NodeSocketBool") st.domain = "CURVE" rnd = ng.nodes.new("FunctionNodeRandomValue") rnd.data_type = "FLOAT" idn = ng.nodes.new("GeometryNodeInputID") ng.links.new(idn.outputs["ID"], rnd.inputs["ID"]) ng.links.new(gi.outputs["Seed"], rnd.inputs["Seed"]) mr = ng.nodes.new("ShaderNodeMapRange") # soft: 0 below threshold, ramps to 1 ng.links.new(rnd.outputs["Value"], mr.inputs["Value"]) ng.links.new(gi.outputs["Threshold"], mr.inputs["From Min"]) cmp = ng.nodes.new("FunctionNodeCompare") # hard: 1 above threshold cmp.data_type, cmp.operation = "FLOAT", "GREATER_THAN" ng.links.new(rnd.outputs["Value"], cmp.inputs[0]) ng.links.new(gi.outputs["Threshold"], cmp.inputs[1]) sw = ng.nodes.new("GeometryNodeSwitch") sw.input_type = "FLOAT" ng.links.new(gi.outputs["Hard"], sw.inputs["Switch"]) ng.links.new(mr.outputs["Result"], sw.inputs["False"]) ng.links.new(cmp.outputs[0], sw.inputs["True"]) val = sw.outputs["Output"] else: I.new_socket("Root", in_out="INPUT", socket_type="NodeSocketFloat").default_value = 0.0 I.new_socket("Tip", in_out="INPUT", socket_type="NodeSocketFloat").default_value = 1.0 st.domain = "POINT" sp = ng.nodes.new("GeometryNodeSplineParameter") mr = ng.nodes.new("ShaderNodeMapRange") ng.links.new(sp.outputs["Factor"], mr.inputs["Value"]) ng.links.new(gi.outputs["Root"], mr.inputs["To Min"]) ng.links.new(gi.outputs["Tip"], mr.inputs["To Max"]) val = mr.outputs["Result"] I.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") ng.links.new(gi.outputs["Geometry"], st.inputs["Geometry"]) ng.links.new(gi.outputs["Name"], st.inputs["Name"]) ng.links.new(val, st.inputs["Value"]) ng.links.new(st.outputs["Geometry"], go.inputs["Geometry"]) return ng def random_curve_mask(hair, attr, threshold=0.8, seed=0, hard=False, after="Interpolate Hair Curves"): """Store a per-CURVE random float attribute to drive an asset input with "attr:<attr>". Simon: randomize per curve, not per point. Soft: 0 for `threshold` of the curves, ramping to 1 (frizz on ~20% of strands). Hard: 1 on (1 - threshold) of the curves (stray hairs: threshold 0.9). Placed right after `after` (the children exist there).""" md = hair.modifiers.new(f"BX {attr}", "NODES") md.node_group = _attr_group("random") set_inputs(md, {"Name": attr, "Threshold": float(threshold), "Seed": int(seed), "Hard": bool(hard)}) _place_after(hair, md, after) return md def along_curve_attr(hair, attr, root=0.0, tip=1.0, after="Interpolate Hair Curves"): """Store a per-POINT root-to-tip ramp (Simon: curl frequency rises toward the tip).""" md = hair.modifiers.new(f"BX {attr}", "NODES") md.node_group = _attr_group("along") set_inputs(md, {"Name": attr, "Root": float(root), "Tip": float(tip)}) _place_after(hair, md, after) return md def _place_after(hair, md, after): names = [m.name for m in hair.modifiers] idx = next((i for i, n in enumerate(names) if after and n.startswith(after)), None) if idx is not None: move(hair, md, idx + 1) elif "Hair Dynamics" in names: move(hair, md, max(0, names.index("Hair Dynamics"))) def distance_weights(surface, group, source, radius, profile="bump"): """Write vertex group `group` from the distance to `source` vertices (a vertex group name or index list). profile 'bump': 0 on the line, 1 at radius/2, 0 at radius (Bystedt's parting lift curve); 'falloff': 1 on the line to 0 at radius. Use it as an asset Factor ("attr:<group>") after Interpolate.""" me = surface.data if isinstance(source, str): w = group_weights(surface, source) src = np.nonzero(w > 0.5)[0] else: src = np.asarray(source) kd = kdtree.KDTree(len(src)) for i in src: kd.insert(me.vertices[int(i)].co, int(i)) kd.balance() vg = surface.vertex_groups.get(group) or surface.vertex_groups.new(name=group) for v in me.vertices: _, _, d = kd.find(v.co) x = min(1.0, d / radius) wv = math.sin(math.pi * x) if profile == "bump" else 1.0 - x * x * (3 - 2 * x) if x >= 1.0: wv = 0.0 vg.add([v.index], float(wv), "REPLACE") return vg # ----------------------------------------------------------------------------- measure def count(hair, full=True): """Evaluated curve/point counts. full=True evaluates with every 'Viewport Amount' at 1.0 (render density): background evaluation uses the viewport amount.""" saved = [] if full: for md in hair.modifiers: if md.type == "NODES" and md.node_group and "Viewport Amount" in _input_items(md): p = getattr(md.properties.inputs, _resolve(md, "Viewport Amount", 1.0)) if getattr(p, "type", "VALUE") == "VALUE": saved.append((p, p.value)) p.value = 1.0 hair.update_tag() ev = hair.evaluated_get(bpy.context.evaluated_depsgraph_get()).data res = {"curves": len(ev.curves), "points": len(ev.points)} for p, v in saved: p.value = v hair.update_tag() return res def _curve_arrays(cv): n = len(cv.curves) off = np.empty(n + 1, np.int32) cv.curve_offset_data.foreach_get("value", off) pos = np.empty(len(cv.points) * 3, np.float32) cv.attributes["position"].data.foreach_get("vector", pos) return off, pos.reshape(-1, 3) def uv_report(surface): """UV health of the growth surface: hair binds to UV space, so overlapping (stacked) UVs make curves ignore deformation (Kerstin). UDIM tiles are fine.""" me = surface.data rep = {"uv_maps": [u.name for u in me.uv_layers], "active": me.uv_layers.active.name if me.uv_layers else None} if not me.uv_layers: rep["overlap_faces"] = None return rep uv = np.empty(len(me.loops) * 2, np.float32) me.uv_layers.active.data.foreach_get("uv", uv) uv = uv.reshape(-1, 2) rep["uv_outside_0_1"] = int(((uv < -1e-4) | (uv > 1 + 1e-4)).any(1).sum()) view_layer = bpy.context.view_layer prev_active, prev_sel = view_layer.objects.active, list(bpy.context.selected_objects) _object_mode() for o in prev_sel: o.select_set(False) surface.select_set(True) view_layer.objects.active = surface ts = bpy.context.scene.tool_settings sync = ts.use_uv_select_sync ts.use_uv_select_sync = False import bmesh bpy.ops.object.mode_set(mode="EDIT") try: bpy.ops.mesh.select_all(action="SELECT") bpy.ops.uv.select_all(action="DESELECT") bpy.ops.uv.select_overlap() bm = bmesh.from_edit_mesh(me) rep["overlap_faces"] = sum(1 for f in bm.faces if all(l.uv_select_vert for l in f.loops)) finally: bpy.ops.object.mode_set(mode="OBJECT") ts.use_uv_select_sync = sync surface.select_set(False) for o in prev_sel: o.select_set(True) view_layer.objects.active = prev_active rep["faces"] = len(me.polygons) return rep def groom_report(hair, k=6): """Objective groom checks (guides, attachment, spacing, length dents, stack order, evaluated counts, radius vs density, render settings, shader vs engine).""" cv, rep = hair.data, {"hair": hair.name} surf = cv.surface rep["surface"] = surf.name if surf else None rep["surface_uv_map"] = cv.surface_uv_map rep["uv_map_on_surface"] = bool(surf and cv.surface_uv_map in surf.data.uv_layers) n = len(cv.curves) rep["guides"] = n rep["has_attachment_uv"] = "surface_uv_coordinate" in cv.attributes if n: off, pos = _curve_arrays(cv) ppc = np.diff(off) rep["points_per_guide"] = [int(ppc.min()), int(np.median(ppc)), int(ppc.max())] M = np.array(hair.matrix_world, dtype=np.float64) pw = pos @ M[:3, :3].T + M[:3, 3] roots = pw[off[:-1]] seg = np.linalg.norm(np.diff(pw, axis=0), axis=1) csum = np.concatenate([[0.0], np.cumsum(seg)]) lengths = csum[off[1:] - 1] - csum[off[:-1]] rep["guide_length"] = [round(float(lengths.min()), 5), round(float(np.median(lengths)), 5), round(float(lengths.max()), 5)] if surf: SI = np.array(surf.matrix_world.inverted(), dtype=np.float64) SM = surf.matrix_world dists = [] for r in roots: lr = Vector(SI[:3, :3] @ r + SI[:3, 3]) ok, loc, _nrm, _ = surf.closest_point_on_mesh(lr) dists.append(((SM @ loc) - Vector(r)).length if ok else float("inf")) dists = np.array(dists) tol = max(1e-5, 0.02 * float(np.median(lengths))) rep["roots_off_surface"] = int((dists > tol).sum()) rep["root_dist_max"] = round(float(dists.max()), 6) if n >= 3: kd = kdtree.KDTree(n) for i, r in enumerate(roots): kd.insert(r, i) kd.balance() nn, ratio = [], [] kk = min(k + 1, n) for i, r in enumerate(roots): found = kd.find_n(r, kk) nn.append(found[1][2]) neigh = [f[1] for f in found[1:]] ratio.append(lengths[i] / (np.mean(lengths[neigh]) + 1e-12)) nn, ratio = np.array(nn), np.array(ratio) med = float(np.median(nn)) rep["spacing_nn"] = [round(med, 5), round(float(nn.max()), 5)] rep["spacing_gaps_2x"] = int((nn > 2 * med).sum()) rep["length_outliers"] = int((np.abs(ratio - 1) > 0.5).sum()) if rep["has_attachment_uv"]: a = cv.attributes["surface_uv_coordinate"] uvs = np.empty(len(a.data) * 2, np.float32) a.data.foreach_get("vector", uvs) rep["attachment_uv_nan"] = int(np.isnan(uvs).sum()) # stack mods = [(m.name, m.node_group.name if m.type == "NODES" and m.node_group else m.type, m.show_viewport, m.show_render) for m in hair.modifiers] rep["modifiers"] = [m[0] for m in mods] groups = [m[1] for m in mods] notes = [] if "Hair Dynamics" in groups and groups[-1] != "Hair Dynamics": notes.append("Hair Dynamics is not last: modifiers after it do not follow the deforming surface") if "Interpolate Hair Curves" in groups: ii = groups.index("Interpolate Hair Curves") pre = [g for g in groups[:ii] if g in DEFORMERS] if pre: notes.append(f"before Interpolate (shape guides only, children inherit): {pre}") md = hair.modifiers[mods[ii][0]] vals = get_inputs(md) rep["density"] = vals.get("Density") rep["density_mask"] = vals.get("Density Mask") rep["viewport_amount"] = vals.get("Viewport Amount") for m in hair.modifiers: if m.type == "NODES" and m.node_group and m.node_group.name == "Trim Hair Curves": if get_inputs(m).get("Replace Length") is True: notes.append(f"{m.name}: Replace Length on (replaces every length with 'Length', 1 m default)") if m.type == "NODES" and m.node_group and m.node_group.name in ("Frizz Hair Curves", "Hair Curves Noise"): f = get_inputs(m).get("Factor") if f == 1.0: notes.append(f"{m.name}: Factor 1.0 unmasked over the whole groom") rep["stack_notes"] = notes # evaluated try: rep["evaluated_viewport"] = count(hair, full=False) rep["evaluated_full"] = count(hair, full=True) ev = hair.evaluated_get(bpy.context.evaluated_depsgraph_get()).data rep["evaluated_attrs"] = sorted(a.name for a in ev.attributes if not a.name.startswith(".")) if "radius" in ev.attributes and len(ev.points): r = np.empty(len(ev.points), np.float32) ev.attributes["radius"].data.foreach_get("value", r) rep["radius_max"] = round(float(r.max()), 6) dens = rep.get("density") if isinstance(dens, (int, float)): rep["root_area_fraction"] = round(float(dens * math.pi * r.max() ** 2), 4) except Exception as e: # never let a report crash the caller rep["evaluated_error"] = str(e) sc = bpy.context.scene rep["engine"] = sc.render.engine rep["hair_type"], rep["hair_subdiv"] = sc.render.hair_type, sc.render.hair_subdiv rep["cycles_curve_shape"] = sc.cycles_curves.shape shaders = set() for slot in hair.material_slots: if slot.material and slot.material.node_tree: shaders |= {n.bl_idname for n in slot.material.node_tree.nodes if "Bsdf" in n.bl_idname} rep["shaders"] = sorted(shaders) return rep def verdict(rep): """Problems worth fixing, as plain sentences (empty list = nothing flagged).""" out = [] if not rep.get("surface"): out.append("no attachment surface") if rep.get("surface") and not rep.get("uv_map_on_surface"): out.append("surface_uv_map not found on the surface mesh") if rep.get("guides") and not rep.get("has_attachment_uv"): out.append("guides have no surface_uv_coordinate: run snap_to_surface(hair)") if rep.get("roots_off_surface"): out.append(f"{rep['roots_off_surface']} guide roots off the surface (snap NEAREST, or DEFORM after surface edits)") if rep.get("spacing_gaps_2x"): out.append(f"{rep['spacing_gaps_2x']} guides with a neighbour gap > 2x median spacing (bald-spot risk)") if rep.get("length_outliers"): out.append(f"{rep['length_outliers']} guides differ > 50% from neighbour lengths (renders as a dent)") if rep.get("attachment_uv_nan"): out.append("invalid attachment UVs (NaN)") out += rep.get("stack_notes", []) if rep.get("guides") and "Interpolate Hair Curves" not in " ".join(rep.get("modifiers", [])): out.append("no Interpolate Hair Curves: only guides render") if rep.get("density_mask") in (None, 1.0) and rep.get("density") is not None: out.append("Interpolate has no Density Mask: children spawn over the whole surface") if rep.get("root_area_fraction", 0) > 0.5: out.append(f"radius too thick for the density (root area fraction {rep['root_area_fraction']}): solid-mass look") if rep.get("hair_type") == "STRAND" and rep.get("engine") != "CYCLES": out.append("render hair shape is Strand: radius changes invisible (use STRIP + hair_subdiv >= 1)") if rep.get("engine") == "BLENDER_EEVEE" and "ShaderNodeBsdfHairPrincipled" in rep.get("shaders", []): out.append("Principled Hair BSDF under EEVEE renders dark and flat: use Principled BSDF with explicit color") return out # ----------------------------------------------------------------------------- shading def hair_material(name="Hair", engine="CYCLES", root_color=(0.05, 0.025, 0.012), tip_color=(0.35, 0.17, 0.07), melanin=None, roughness=(0.25, 0.4), random_color=0.1, uv_noise=0.0): """Strand material with a root-to-tip gradient (Curves Info Intercept) and per-strand roughness variation (Curves Info Random). engine CYCLES: Principled Hair BSDF (Chiang); melanin=(root, tip) switches to melanin parametrization. engine EEVEE: Principled BSDF with explicit colors (Simon: Principled Hair BSDF is a Cycles shader). uv_noise > 0 adds Simon's surface-UV noise (Attribute surface_uv_coordinate).""" mat = bpy.data.materials.new(name) nt = mat.node_tree for n in list(nt.nodes): if n.type != "OUTPUT_MATERIAL": nt.nodes.remove(n) out = next(n for n in nt.nodes if n.type == "OUTPUT_MATERIAL") info = nt.nodes.new("ShaderNodeHairInfo") rough = nt.nodes.new("ShaderNodeMapRange") nt.links.new(info.outputs["Random"], rough.inputs["Value"]) rough.inputs["To Min"].default_value, rough.inputs["To Max"].default_value = roughness col = nt.nodes.new("ShaderNodeMix") col.data_type = "RGBA" _s(col.inputs, "A_Color").default_value = (*root_color, 1.0) _s(col.inputs, "B_Color").default_value = (*tip_color, 1.0) nt.links.new(info.outputs["Intercept"], _s(col.inputs, "Factor_Float")) color_out = _s(col.outputs, "Result_Color") if uv_noise > 0: attr = nt.nodes.new("ShaderNodeAttribute") attr.attribute_name = "surface_uv_coordinate" noise = nt.nodes.new("ShaderNodeTexNoise") noise.inputs["Scale"].default_value = 8.0 nt.links.new(attr.outputs["Vector"], noise.inputs["Vector"]) soft = nt.nodes.new("ShaderNodeMix") soft.data_type, soft.blend_type = "RGBA", "SOFT_LIGHT" _s(soft.inputs, "Factor_Float").default_value = uv_noise nt.links.new(color_out, _s(soft.inputs, "A_Color")) nt.links.new(noise.outputs["Color"], _s(soft.inputs, "B_Color")) color_out = _s(soft.outputs, "Result_Color") if engine == "CYCLES": b = nt.nodes.new("ShaderNodeBsdfHairPrincipled") b.model = "CHIANG" if melanin is not None: b.parametrization = "MELANIN" mm = nt.nodes.new("ShaderNodeMapRange") nt.links.new(info.outputs["Intercept"], mm.inputs["Value"]) mm.inputs["To Min"].default_value, mm.inputs["To Max"].default_value = melanin nt.links.new(mm.outputs["Result"], b.inputs["Melanin"]) b.inputs["Random Color"].default_value = random_color else: b.parametrization = "COLOR" nt.links.new(color_out, b.inputs["Color"]) nt.links.new(rough.outputs["Result"], b.inputs["Roughness"]) else: b = nt.nodes.new("ShaderNodeBsdfPrincipled") nt.links.new(color_out, b.inputs["Base Color"]) nt.links.new(rough.outputs["Result"], b.inputs["Roughness"]) nt.links.new(b.outputs[0], out.inputs["Surface"]) return mat def assign_material(obj, mat): obj.data.materials.clear() obj.data.materials.append(mat) def setup_render(scene=None, engine="CYCLES", close_up=False): """Strip display with subdivisions (radius visible in EEVEE/viewport); Cycles curve shape THICK for close-ups [added], RIBBONS (default, faster) otherwise.""" sc = scene or bpy.context.scene sc.render.engine = "CYCLES" if engine == "CYCLES" else "BLENDER_EEVEE" sc.render.hair_type = "STRIP" sc.render.hair_subdiv = 2 if close_up else 1 sc.cycles_curves.shape = "THICK" if close_up else "RIBBONS" return sc # ----------------------------------------------------------------------------- review def eval_bbox(objs): """World bbox of what renders: evaluated curve points and any mesh a node tree outputs (bound_box of a curves object misses generated card meshes).""" for o in objs: o.update_tag() dg = bpy.context.evaluated_depsgraph_get() pts = [] for o in objs: eo = o.evaluated_get(dg) M = np.array(eo.matrix_world, dtype=np.float64) local = [] if o.type == "CURVES" and len(eo.data.points): p = np.empty(len(eo.data.points) * 3, np.float32) eo.data.attributes["position"].data.foreach_get("vector", p) local.append(p.reshape(-1, 3)) try: gs = eo.evaluated_geometry() me = gs.mesh if me is not None and len(me.vertices): p = np.empty(len(me.vertices) * 3, np.float32) me.vertices.foreach_get("co", p) local.append(p.reshape(-1, 3)) except Exception: pass if not local: local.append(np.array([list(c) for c in eo.bound_box], np.float32)) allp = np.concatenate(local) pts.append(allp @ M[:3, :3].T + M[:3, 3]) allw = np.concatenate(pts) return Vector(allw.min(0)), Vector(allw.max(0)) def _review_mod(): here = os.path.dirname(os.path.abspath(__file__)) p = os.path.normpath(os.path.join(here, "..", "..", "scenario-blender-expert", "scripts")) if p not in sys.path: sys.path.append(p) import bx_review return bx_review def review(objs, out_dir, engine="CYCLES", views=("front", "threequarter", "right"), res=512, samples=32, close_up=False, name="hair_review", zoom=1.0, frame=None): """Lit render sheet of a groom in a temporary scene (user scene untouched). Simon's review rig: warm fill from the top, small key from the side, cool rim from the other side, darker bluish world; square camera. Renders at full density (render depsgraph ignores Viewport Amount). Renders the current frame of the active scene unless `frame` is given (pose checks). Returns the sheet path.""" R = _review_mod() os.makedirs(out_dir, exist_ok=True) src_frame = bpy.context.scene.frame_current if frame is None else frame sc = bpy.data.scenes.new("BX_HairReview") for o in objs: sc.collection.objects.link(o) sc.frame_set(src_frame) setup_render(sc, engine, close_up) sc.render.resolution_x = sc.render.resolution_y = res sc.render.image_settings.media_type = "IMAGE" sc.render.image_settings.file_format = "PNG" if engine == "CYCLES": sc.cycles.samples = samples sc.cycles.device = "CPU" world = bpy.data.worlds.new("BX_HairReview_World") world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.03, 0.04, 0.06, 1) world.node_tree.nodes["Background"].inputs["Strength"].default_value = 1.0 sc.world = world lo, hi = eval_bbox(objs) center, size = (lo + hi) / 2, max((hi - lo).length, 1e-3) made = [] def light(name, kind, energy, color, offset, sz): ld = bpy.data.lights.new(name, kind) ld.energy, ld.color = energy, color if kind == "AREA": ld.size = sz ob = bpy.data.objects.new(name, ld) sc.collection.objects.link(ob) ob.location = center + offset R._look_at(ob, center, offset.normalized(), offset.length) made.append((ob, ld)) d = size * 1.5 k = d * d # keep irradiance constant with scale light("BX_Fill_Top", "AREA", 35 * k, (1.0, 0.85, 0.7), Vector((0, 0, d)), size) light("BX_Key_Side", "AREA", 90 * k, (1.0, 0.95, 0.9), Vector((-d, -d * 0.6, d * 0.3)), size * 0.25) light("BX_Rim_Cool", "AREA", 120 * k, (0.6, 0.75, 1.0), Vector((d * 0.9, d, d * 0.4)), size * 0.5) cam_data = bpy.data.cameras.new("BX_HairReview_Cam") cam = bpy.data.objects.new("BX_HairReview_Cam", cam_data) sc.collection.objects.link(cam) sc.camera = cam corners = [Vector((x, y, z)) for x in (lo.x, hi.x) for y in (lo.y, hi.y) for z in (lo.z, hi.z)] paths, labels = [], [] try: for view in views: dvec = R.VIEW_DIRS[view] half, depth = R._extent(corners, center, dvec) cam_data.type, cam_data.lens = "PERSP", 50 fov = 2 * math.atan(cam_data.sensor_width / (2 * cam_data.lens)) dist = (half * 1.1 / math.tan(fov / 2) + depth * 0.6) / zoom cam_data.clip_start, cam_data.clip_end = dist * 0.01, dist * 10 R._look_at(cam, center, dvec, dist) p = os.path.join(out_dir, f"{name}_{engine.lower()}_{view}.png") sc.render.filepath = p bpy.ops.render.render(write_still=True, scene=sc.name) paths.append(p) labels.append(f"{engine} / {view}") sheet = R._tile(paths, 1, len(paths), os.path.join(out_dir, f"{name}_{engine.lower()}.png"), labels) finally: for ob, ld in made: bpy.data.objects.remove(ob) bpy.data.lights.remove(ld) bpy.data.objects.remove(cam) bpy.data.cameras.remove(cam_data) bpy.data.scenes.remove(sc) bpy.data.worlds.remove(world) return sheet # ----------------------------------------------------------------------------- cards def cards_group(): """GN group 'BX Hair Cards': hair curves -> card or tube mesh with UVs (Sara Matsumoto's BCON24 layout, Bystedt's surface-aligned tilt and root snap). Resample -> curve normal from the surface normal (card lies on the scalp) -> Curve to Mesh with a bent arc (cards) or a flat 1-turn spiral (tubes) -> UVMap (U across the profile, halved on cards; V root 0 to tip 1) -> material -> roots snapped onto the surface.""" ng = bpy.data.node_groups.get("BX Hair Cards") if ng: return ng ng = bpy.data.node_groups.new("BX Hair Cards", "GeometryNodeTree") I = ng.interface I.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") I.new_socket("Surface", in_out="INPUT", socket_type="NodeSocketObject") s = I.new_socket("Width", in_out="INPUT", socket_type="NodeSocketFloat"); s.default_value = 0.01 I.new_socket("Tubes", in_out="INPUT", socket_type="NodeSocketBool") s = I.new_socket("Card Segments", in_out="INPUT", socket_type="NodeSocketInt"); s.default_value, s.min_value = 3, 1 s = I.new_socket("Bend", in_out="INPUT", socket_type="NodeSocketFloat"); s.default_value = math.radians(90) s = I.new_socket("Resample", in_out="INPUT", socket_type="NodeSocketInt"); s.default_value, s.min_value = 12, 2 s = I.new_socket("Root Scale", in_out="INPUT", socket_type="NodeSocketFloat"); s.default_value = 1.0 s = I.new_socket("Tip Scale", in_out="INPUT", socket_type="NodeSocketFloat"); s.default_value = 0.3 I.new_socket("Material", in_out="INPUT", socket_type="NodeSocketMaterial") s = I.new_socket("Snap Roots", in_out="INPUT", socket_type="NodeSocketBool"); s.default_value = True I.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") N, L = ng.nodes, ng.links gi, go = N.new("NodeGroupInput"), N.new("NodeGroupOutput") rs = N.new("GeometryNodeResampleCurve") L.new(gi.outputs["Geometry"], rs.inputs["Curve"]) L.new(gi.outputs["Resample"], rs.inputs["Count"]) oi = N.new("GeometryNodeObjectInfo"); oi.transform_space = "RELATIVE" L.new(gi.outputs["Surface"], oi.inputs["Object"]) nrm = N.new("GeometryNodeInputNormal") sns = N.new("GeometryNodeSampleNearestSurface"); sns.data_type = "FLOAT_VECTOR" L.new(oi.outputs["Geometry"], sns.inputs["Mesh"]) L.new(nrm.outputs["Normal"], sns.inputs["Value"]) scn = N.new("GeometryNodeSetCurveNormal") scn.inputs["Mode"].default_value = "Free" L.new(rs.outputs["Curve"], scn.inputs["Curve"]) L.new(sns.outputs["Value"], scn.inputs["Normal"]) # capture length parameter on the hair curve sp_c = N.new("GeometryNodeSplineParameter") cap_c = N.new("GeometryNodeCaptureAttribute"); cap_c.domain = "POINT" cap_c.capture_items.new("FLOAT", "V") L.new(scn.outputs["Curve"], cap_c.inputs["Geometry"]) L.new(sp_c.outputs["Factor"], _s(cap_c.inputs, "V")) # taper (Curve to Mesh Scale, 4.5+) root->tip taper = N.new("ShaderNodeMapRange") L.new(sp_c.outputs["Factor"], taper.inputs["Value"]) L.new(gi.outputs["Root Scale"], taper.inputs["To Min"]) L.new(gi.outputs["Tip Scale"], taper.inputs["To Max"]) half_w = N.new("ShaderNodeMath"); half_w.operation = "MULTIPLY"; half_w.inputs[1].default_value = 0.5 L.new(gi.outputs["Width"], half_w.inputs[0]) # card profile: arc of angle Bend centred on +X (curve normal = surface normal), shifted so # the apex sits on the curve and the edges hang toward the scalp arc = N.new("GeometryNodeCurveArc") # Resolution = points, so segments + 1 seg1 = N.new("ShaderNodeMath"); seg1.operation = "ADD"; seg1.inputs[1].default_value = 1.0 L.new(gi.outputs["Card Segments"], seg1.inputs[0]) L.new(seg1.outputs[0], arc.inputs["Resolution"]) sin_h = N.new("ShaderNodeMath"); sin_h.operation = "MULTIPLY"; sin_h.inputs[1].default_value = 0.5 L.new(gi.outputs["Bend"], sin_h.inputs[0]) sin = N.new("ShaderNodeMath"); sin.operation = "SINE" L.new(sin_h.outputs[0], sin.inputs[0]) rad = N.new("ShaderNodeMath"); rad.operation = "DIVIDE" L.new(half_w.outputs[0], rad.inputs[0]); L.new(sin.outputs[0], rad.inputs[1]) L.new(rad.outputs[0], arc.inputs["Radius"]) neg = N.new("ShaderNodeMath"); neg.operation = "MULTIPLY"; neg.inputs[1].default_value = -1.0 L.new(sin_h.outputs[0], neg.inputs[0]) L.new(neg.outputs[0], arc.inputs["Start Angle"]) L.new(gi.outputs["Bend"], arc.inputs["Sweep Angle"]) shift = N.new("ShaderNodeCombineXYZ") nrad = N.new("ShaderNodeMath"); nrad.operation = "MULTIPLY"; nrad.inputs[1].default_value = -1.0 L.new(rad.outputs[0], nrad.inputs[0]); L.new(nrad.outputs[0], shift.inputs["X"]) tr = N.new("GeometryNodeTransform") L.new(arc.outputs["Curve"], tr.inputs["Geometry"]) L.new(shift.outputs["Vector"], tr.inputs["Translation"]) # tube profile: flat spiral, start = end radius, height 0, resolution 6, 1 rotation (Sara) spi = N.new("GeometryNodeCurveSpiral") spi.inputs["Resolution"].default_value = 6 spi.inputs["Rotations"].default_value = 1.0 spi.inputs["Height"].default_value = 0.0 L.new(half_w.outputs[0], spi.inputs["Start Radius"]); L.new(half_w.outputs[0], spi.inputs["End Radius"]) swp = N.new("GeometryNodeSwitch"); swp.input_type = "GEOMETRY" L.new(gi.outputs["Tubes"], swp.inputs["Switch"]) L.new(tr.outputs["Geometry"], swp.inputs["False"]); L.new(spi.outputs["Curve"], swp.inputs["True"]) sp_p = N.new("GeometryNodeSplineParameter") cap_p = N.new("GeometryNodeCaptureAttribute"); cap_p.domain = "POINT" cap_p.capture_items.new("FLOAT", "U") L.new(swp.outputs["Output"], cap_p.inputs["Geometry"]) L.new(sp_p.outputs["Factor"], _s(cap_p.inputs, "U")) c2m = N.new("GeometryNodeCurveToMesh") L.new(cap_c.outputs["Geometry"], c2m.inputs["Curve"]) L.new(cap_p.outputs["Geometry"], c2m.inputs["Profile Curve"]) L.new(taper.outputs["Result"], c2m.inputs["Scale"]) # UV: cards (3-ish sides) use half the texture width so density matches tubes (Sara) uscale = N.new("GeometryNodeSwitch"); uscale.input_type = "FLOAT" uscale.inputs["False"].default_value, uscale.inputs["True"].default_value = 0.5, 1.0 L.new(gi.outputs["Tubes"], uscale.inputs["Switch"]) umul = N.new("ShaderNodeMath"); umul.operation = "MULTIPLY" L.new(_s(cap_p.outputs, "U"), umul.inputs[0]); L.new(uscale.outputs["Output"], umul.inputs[1]) uv = N.new("ShaderNodeCombineXYZ") L.new(umul.outputs[0], uv.inputs["X"]); L.new(_s(cap_c.outputs, "V"), uv.inputs["Y"]) st = N.new("GeometryNodeStoreNamedAttribute"); st.data_type = "FLOAT2"; st.domain = "CORNER" st.inputs["Name"].default_value = "UVMap" L.new(c2m.outputs["Mesh"], st.inputs["Geometry"]); L.new(uv.outputs["Vector"], st.inputs["Value"]) smooth = N.new("GeometryNodeSetShadeSmooth") L.new(st.outputs["Geometry"], smooth.inputs[0]) sm = N.new("GeometryNodeSetMaterial") L.new(smooth.outputs[0], sm.inputs["Geometry"]); L.new(gi.outputs["Material"], sm.inputs["Material"]) # root snap: vertices of the root row (V == 0) onto the closest surface point, last prox = N.new("GeometryNodeProximity"); prox.target_element = "FACES" L.new(oi.outputs["Geometry"], prox.inputs["Target"]) cmp = N.new("FunctionNodeCompare"); cmp.data_type = "FLOAT"; cmp.operation = "LESS_THAN" cmp.inputs[1].default_value = 1e-4 L.new(_s(cap_c.outputs, "V"), cmp.inputs[0]) band = N.new("FunctionNodeBooleanMath"); band.operation = "AND" L.new(cmp.outputs[0], band.inputs[0]); L.new(gi.outputs["Snap Roots"], band.inputs[1]) setp = N.new("GeometryNodeSetPosition") L.new(sm.outputs["Geometry"], setp.inputs["Geometry"]) L.new(band.outputs[0], setp.inputs["Selection"]) L.new(prox.outputs["Position"], setp.inputs["Position"]) L.new(setp.outputs["Geometry"], go.inputs["Geometry"]) return ng def add_cards(hair, surface=None, width=0.01, tubes=False, material=None, segments=3, bend=math.radians(90), resample=12, root_scale=1.0, tip_scale=0.3, label="BX Hair Cards"): """Turn the groom into card (or tube) meshes at the end of the stack (before Hair Dynamics, so dynamics still act on curves: move it last if you want the cards to follow the simulated curves). Returns the modifier.""" md = hair.modifiers.new(label, "NODES") md.node_group = cards_group() vals = {"Surface": surface or hair.data.surface, "Width": float(width), "Tubes": bool(tubes), "Card Segments": int(segments), "Bend": float(bend), "Resample": int(resample), "Root Scale": float(root_scale), "Tip Scale": float(tip_scale)} if material: vals["Material"] = material set_inputs(md, vals) names = [m.name for m in hair.modifiers] if "Hair Dynamics" in names: move(hair, md, names.index("Hair Dynamics")) return md def card_material(name="HairCard", root_color=(0.04, 0.02, 0.01), tip_color=(0.3, 0.15, 0.06), strands=24.0, anisotropic=0.5, image=None): """Alpha card material on the 'UVMap' written by cards_group(). With `image` (a baked strand plate: color + alpha, Sara's workflow) it samples that; without, a procedural strand stand-in [added] (Wave bands across U, tip fade) for layout and silhouette review. Tangent from the UV map + Anisotropic stretches highlights along strands (Sara). EEVEE: Render Method Dithered (4.2+ replacement for Blend Mode).""" mat = bpy.data.materials.new(name) nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] uvn = nt.nodes.new("ShaderNodeUVMap") uvn.uv_map = "UVMap" sep = nt.nodes.new("ShaderNodeSeparateXYZ") nt.links.new(uvn.outputs["UV"], sep.inputs["Vector"]) if image is not None: tex = nt.nodes.new("ShaderNodeTexImage") tex.image = image nt.links.new(uvn.outputs["UV"], tex.inputs["Vector"]) nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) nt.links.new(tex.outputs["Alpha"], bsdf.inputs["Alpha"]) else: wave = nt.nodes.new("ShaderNodeTexWave") wave.wave_type, wave.bands_direction = "BANDS", "X" wave.inputs["Scale"].default_value = strands wave.inputs["Distortion"].default_value = 3.0 nt.links.new(uvn.outputs["UV"], wave.inputs["Vector"]) band = nt.nodes.new("ShaderNodeMapRange") band.inputs["From Min"].default_value, band.inputs["From Max"].default_value = 0.35, 0.65 nt.links.new(wave.outputs["Fac"], band.inputs["Value"]) fade = nt.nodes.new("ShaderNodeMapRange") # 1 at the root row, 0 at the tip fade.inputs["From Min"].default_value, fade.inputs["From Max"].default_value = 0.6, 1.0 fade.inputs["To Min"].default_value, fade.inputs["To Max"].default_value = 1.0, 0.0 nt.links.new(sep.outputs["Y"], fade.inputs["Value"]) alpha = nt.nodes.new("ShaderNodeMath") alpha.operation = "MULTIPLY" nt.links.new(band.outputs["Result"], alpha.inputs[0]) nt.links.new(fade.outputs["Result"], alpha.inputs[1]) nt.links.new(alpha.outputs[0], bsdf.inputs["Alpha"]) col = nt.nodes.new("ShaderNodeMix") col.data_type = "RGBA" _s(col.inputs, "A_Color").default_value = (*root_color, 1.0) _s(col.inputs, "B_Color").default_value = (*tip_color, 1.0) nt.links.new(sep.outputs["Y"], _s(col.inputs, "Factor_Float")) nt.links.new(_s(col.outputs, "Result_Color"), bsdf.inputs["Base Color"]) tan = nt.nodes.new("ShaderNodeTangent") tan.direction_type, tan.uv_map = "UV_MAP", "UVMap" nt.links.new(tan.outputs["Tangent"], bsdf.inputs["Tangent"]) bsdf.inputs["Anisotropic"].default_value = anisotropic bsdf.inputs["Roughness"].default_value = 0.4 mat.surface_render_method = "DITHERED" return mat def card_stats(hair): """Triangles, UV range and root distance of the evaluated card mesh.""" hair.update_tag() dg = bpy.context.evaluated_depsgraph_get() # evaluates pending updates eo = hair.evaluated_get(dg) gs = eo.evaluated_geometry() me = gs.mesh if me is None: return {"mesh": False} tri = sum(len(p.vertices) - 2 for p in me.polygons) out = {"mesh": True, "verts": len(me.vertices), "faces": len(me.polygons), "tris": tri} if "UVMap" in me.attributes: a = me.attributes["UVMap"] uv = np.empty(len(a.data) * 2, np.float32) a.data.foreach_get("vector", uv) uv = uv.reshape(-1, 2) out["uv_min"] = [round(float(x), 4) for x in uv.min(0)] out["uv_max"] = [round(float(x), 4) for x in uv.max(0)] return out
-
-
SKILL.md 18.3 KB
--- name: scenario-blender-hair description: "Use when grooming hair or fur in Blender with hair curves, such as a character hairstyle, animal fur, procedural fur in geometry nodes, or hair cards and mesh hair for games. Also covers the Essentials hair node assets (Interpolate, Clump, Curl, Frizz, Noise, Trim), density masks, bald spots, parting lines, strand shading in Cycles or EEVEE, and slow grooms. Use it too when a hair script misbehaves headless, for example stale modifier inputs or curve sculpt brushes that do nothing." license: MIT --- # Hair and fur grooming (hair curves, Blender 5.2) Expert grooming means a few evenly spaced guides that set direction and silhouette. Stacked, masked procedural layers add density, clumping, frizz, length variation and strays, judged in a lit render at full density and in pose. Write guides as data (no curve brush stroke can be scripted in 5.2.1) and make every look decision through the Essentials hair node-group assets. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap. **REQUIRED BACKGROUND:** scenario-blender-expert (execution channel, review loop, 5.2 API changes). ## Stance (the expert delta) - **Guides get 2 points until every direction is right** (Schmidbauer). More points make combing tangle and clip. Use 3 for short fur, 8 only for long hair. Bystedt resamples long human hair to 15 points before shaping. - **Space guides evenly, and always mask Interpolate** (Schmidbauer). Children spawn over the whole surface, and a child far from any guide "does whatever". Gaps turn into bald spots that grow under deformation. - **Density is counted per square meter and must be huge. Radius drops with it** (Thommes). Long fur uses 1,000,000/m² at about 0.2 mm radius. Work at a Viewport Amount of 4%. - **Randomize per curve, not per point** (Thommes). Give 80% of strands no frizz. Strays are 10% of curves with strong noise. - **Trim with Replace Length off plus a Random Offset** (Schmidbauer, Thommes). It is the cheapest realism gain. Mask it, or short-fur regions lose their guides and go bald. - **Grow a human hairstyle on a separate hair cap split along the part** (Bystedt): body topology stops mattering, interpolation stops crossing the part. The part still needs extra density and a lift. - **Shape works the same way on every asset**: 1 affects the tip only, 0 the whole strand, negative the roots. Use a negative Frizz Shape so curl and clump tips survive (Thommes, Bystedt). - **Judge renders at full density, and in pose** (Thommes, Schmidbauer). A harsh mesh deformation "looks ten times worse" in the hair. ## Establish first | Input | Changes | Default when the brief is silent | | ------------ | -------------------------------------------------------------------------- | ------------------------------------------------ | | Target | Cycles strands vs EEVEE or game cards | Cycles strands (Principled Hair BSDF) | | Subject | human hairstyle (hair cap, part) vs animal fur (body mesh, landmarks) | follow the reference | | Length class | points per guide, clump and curl layers | short fur: 3 points, long: 8, long human: 15 | | Deformation | animated means Hair Dynamics (Animation) stays last, and you check in pose | animated | | Budget | render curves, card triangles | say the number: evaluated curves at full density | | Reference | same subspecies, season, ideally the same individual (Schmidbauer) | ask for it; draw landmarks and flow first | Finish topology and non-overlapping UVs before grooming (hair binds to UV space) and apply scale: density counts the mesh's local area (verified). ## Workflow | Task | Headless | Live session | | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Curves object, surface binding, guides as data, snap, subdivide or resample, asset modifiers, inputs, masks, counts, renders, cards | yes ([`scripts/bx_hair.py`](scripts/bx_hair.py)) | yes, same code in Object Mode | | Enter Sculpt Curves, activate the 11 curve brushes, set brush settings, `sculpt_curves.select_random` | n/a | yes (`bx_gui.activate_brush("SCULPT_CURVES", "Comb")`) | | Comb, Add, Density, Grow/Shrink, Pull Tips strokes | no | **no**. `sculpt_curves.brush_stroke` has no exec, so `bx_gui.stroke("SCULPT_CURVES", ...)` returns PASS_THROUGH and changes nothing. Set the brush up and ask the user to stroke, or use the substitutes below. | 1. **Prepare the surface.** `H.uv_report(surface)`: stacked UVs make curves ignore the rig, UDIMs are fine, an unmerged mirror seam needs UV Merge by Distance (Schmidbauer). Humans: a hair cap split into islands along the part. Animals: separate the mouth interior. Masks: vertex groups (fast) or textures (detail on low-poly meshes). **GATE:** `overlap_faces == 0`, scale applied, one mask per region. 2. **Plan flow and objects.** Turn the draw-over into regions (vertex groups) with one flow direction each. Schmidbauer: one curves object per landmark (face, cheeks, ears, neck, body, tail, feet) for performance. Thommes: one object with masks. Bystedt: split front and back where combing one disturbs the other. **GATE:** written list of regions, flows, objects. 3. **Guides.** `hair = H.new_groom(surface)`, `P, N = H.sample_roots(surface, spacing, mask=...)`, `H.add_guides(hair, P, N, length, points=2, flow=..., flow_blend=...)` (snaps, writes `surface_uv_coordinate`). Later: `H.subdivide_guides` or `H.resample_guides(hair, 15)`. Brush substitutes: a direction field for Comb, `droop` for gravity, delete and re-add guides for Density and Add; Bystedt's "grow long, comb, shrink back" is long guides plus Trim. **GATE:** in `H.groom_report`, `roots_off_surface` and `spacing_gaps_2x` are 0 and `length_outliers` is 0. A guides-only render shows every direction right. 4. **Density.** Add Set Hair Curve Profile first, then Interpolate Hair Curves with a Density Mask. Bystedt paints 50% gray and 100% at the part, then remaps. The 5.2 Interpolate also takes a Mask Texture image. **GATE:** `H.count(hair)` (full density) is within budget, `root_area_fraction` is well under 0.5, and a render shows no bald spots. 5. **Procedural layers, each masked, A/B-tested by toggling `show_viewport` and rendering.** - Long fur (Thommes, preset `H.FUR_STACK_LONG`, applied with `H.build_stack`): Noise, Curl, strays, Frizz with negative shape, Clump, Clump 2, final Frizz (Cumulative off), Trim, Rotate. - Animal (Schmidbauer): Trim, Clump (cheeks, Tip Spread, Clump Offset), low Frizz, each with its own vertex group. - Human (Bystedt): Noise on guides (Cumulative off, Offset per Curve on), Roll at the tip only, then per population Interpolate, Profile, Clump on the groomed guides; wide and thin layers need different seeds. - Per-curve randomness: `H.random_curve_mask(hair, "frizz_mask", 0.8)` then input `"attr:frizz_mask"`; root-to-tip ramps: `H.along_curve_attr`. **GATE:** each layer earns its place in an A/B render. 6. **Part and hairline (human).** Leave Part by Mesh Islands on (default): in the test, 21% of children near the part crossed it with islands off and 0% with islands on. For the lift, `H.distance_weights(cap, "part_lift", "part", radius, "bump")` feeds Displace Hair Curves (Surface Normal on, small distance, Shape near 0, Factor `attr:part_lift`). **GATE:** in a top view there is no scalp gap at the part and the crown is not flat. 7. **Shading and render.** Use `H.hair_material(name, "CYCLES", melanin=(root, tip))` or `"EEVEE"` with explicit colors, then `H.setup_render(scene, engine, close_up)`. **GATE:** `H.review([surface, hair], out, engine)` passes [`references/critique.md`](references/critique.md). 8. **Deformation.** Key 2 or 3 extreme poses and render each frame from the same camera. **GATE:** no bald spots or clipping in any pose. 9. **Games: cards.** Run `H.add_cards(hair, width, material=H.card_material(...))`, then convert a copy with `object.convert(target='MESH')` and export glTF. Put a textured hair cap under the cards. **GATE:** triangles within budget, UVs in the half tile for cards, roots on the scalp. ## Numbers | Item | Value | Relative to, source | | --------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------ | | Guide points | 2 while placing, 3 short fur, 8 long hair | Schmidbauer [00:20:24], [00:21:34] | | Long human hair | resample to 15 points | Bystedt [00:08:38] | | Add brush length | about 0.01 m for fur (default 0.3 m) | Schmidbauer [00:17:59] | | Density, human layer | 5,000 per m² per clump population | Bystedt [00:17:28] | | Density, long fur | 1,000,000/m², radius 0.2 mm, viewport 4% | Thommes [00:09:02], [00:16:44] | | Density map | part 1.0, base 0.5, bald 0; ramp black stop just above 0.5 | Bystedt [00:22:04], [00:23:46] | | Wide clumps | Shape about 0.3, reduced Factor, groomed guides | Bystedt [00:19:47] | | Curl | Radius 2 cm, Curl Start 1, Random Offset 0.5, Guide Distance 1 cm, Guide Mask 0.2 | Thommes [00:12:04], [00:14:18] | | Sub-clumps | Guide Distance 1 mm, Mask 0.1; layer 2: 4 mm, 0.25, Clump Offset 5 mm | Thommes [00:16:12], [00:31:01] | | Frizz | 2 mm, negative Shape; final: -0.2, Cumulative off, on 20% of curves | Thommes [00:15:04], [00:32:08], [00:26:03] | | Strays | 10% of curves, Noise distance 5 mm | Thommes [00:30:28] | | Trim, Rotate | Replace Length off, Random Offset 5 mm; Rotate random 5° | Thommes [00:22:20] | | Card subdivision | 1 per 20 cm of card length | Bystedt g_ZIOafq4QU [00:05:08] | | Card and tube profile | arc: 1 segment flat, 3 preferred; tube: flat spiral, resolution 6, 1 turn, height 0 | Matsumoto [00:19:54], [00:22:40] | | Card UV width | cards use half the tile of tubes | Matsumoto [00:31:27] | | Coverage sanity | density × π × radius²: 0.13 for Thommes' fur | [added] check | ## Quality gates In code: ```python import sys; sys.path.append("<skills>/scenario-blender-hair/scripts"); import bx_hair as H print(H.uv_report(surface)) # overlap_faces == 0 rep = H.groom_report(hair); print(H.verdict(rep)) # [] or a list of fixes print(H.count(hair)) # full-density curves and points vs budget print(H.card_stats(hair)) # cards: tris, UV range ``` `groom_report` checks attachment UVs, roots on the surface, spacing gaps, length dents (over 50% off neighbors), Hair Dynamics last, Trim Replace Length, unmasked Frizz or Noise, missing Density Mask, radius vs density, Strand display, and a Cycles hair shader under EEVEE. Visually: `H.review(objs, out, engine, views=("front", "threequarter", "top"))` renders a lit full-density sheet with Thommes' rig (warm top fill, side key, cool rim, dark bluish world). Score it with `references/critique.md`: bald spots, part coverage, clumps, strays, length variation, tips, root-to-tip color, highlights. ## Common mistakes | Mistake | What it looks like | Fix | | ------------------------------------------------------ | ------------------------------------------------------------ | -------------------------------------------------------------------- | | Guides at the brush default of 8 points while blocking | tangles, clipping, fights while combing | 2 points, raise later (Schmidbauer) | | Interpolate without a Density Mask | hair in the mouth or eyes, drifting children far from guides | vertex-group mask; separate the mouth interior | | Uneven guide spacing | bald spots, worse in pose | `sample_roots` spacing; check `spacing_gaps_2x` | | High density at default radius | a solid mass or "a mess" | Profile first, about 0.2 mm at 1M/m² (Thommes) | | Trim at defaults | every strand becomes 1 m long | Replace Length off (Thommes) | | Positive Frizz on curls or clumps | curl tips destroyed, fuzzy clump tips | negative Shape, Cumulative Offset off on the final Frizz | | Duplicated layer with the same seed | wide and thin clumps stack identically | change seed and frequency (Bystedt) | | Straight tips | "bad CG hair" | Roll Hair Curves at the tip only (Bystedt) | | Surface edited after grooming | floating or sunken roots | `snap_to_surface(hair, "DEFORM")`, or NEAREST after topology changes | | Judging at 4% viewport density | uniform clumps and equal lengths unnoticed | render at full density | | Principled Hair BSDF in EEVEE | dark and flat (measured 0.09 vs 0.26 center luminance) | Principled BSDF with explicit colors | | Scripting a Comb stroke | nothing happens, PASS_THROUGH | write positions, or hand the stroke to the user | ## Blender 5.2 notes - Empty Hair and Quick Fur add a pinned **Hair Dynamics** modifier (Mode `Animation` or `Physics (Experimental)`). It replaces the old Surface Deform and moves roots with the deforming surface (shape keys and armature verified). Keep it last. - Interpolate has no Surface or UV Map inputs; it reads the curves object's surface. Newer inputs are Part by Mesh Islands, Follow Surface Normal, Mask Texture and Resting Surface (on). With Resting Surface on, Thommes' "Mask modifier on the surface" patch preview has no effect. Use a patch vertex group as the Density Mask instead, or turn Resting Surface off while previewing. - Modifier inputs are set with `md.properties.inputs.<id>.value` or `.type = 'ATTRIBUTE'`. Headless, evaluation stays stale until `obj.update_tag()`. `H.set_inputs` resolves UI names and tags for you. Background evaluation applies Viewport Amount, so use `H.count(full=True)`. - Brushes are assets: Snake Hook is **Pull Tips**, Selection Paint is **Select**; Add defaults to 8 points, 0.3 m; settings in `brush.curves_sculpt_settings`. - Factory `render.hair_type` is `STRAND`: use `STRIP` with `hair_subdiv >= 1`. Cycles shapes: `RIBBONS`, `THICK`, `THICK_LINEAR`. "Curves Info" is `ShaderNodeHairInfo`; Principled Hair offers Chiang and Huang. Hair Curves Noise writes `resolution` 12, which curves render with since 5.0. - Cards: Curve to Mesh has a Scale input (4.5), and Arc Resolution counts points. EEVEE alpha uses `surface_render_method = 'DITHERED'`. Keep the GeometrySet alive when reading `obj.evaluated_geometry().mesh`. - Quick Fur density scales with object size (MEDIUM on a 10 cm sphere: 80,220/m², 12 points per guide). ## References - [`references/expert-notes.md`](references/expert-notes.md): load for judgment calls, per expert with timestamps (human hairstyle, animal fur, procedural fur, cards, mesh hair). - [`references/procedures.md`](references/procedures.md): load before writing code. It has full tested procedures P1 to P8 with raw bpy equivalents of the `bx_hair` calls. - `references/critique.md`: load when reviewing a render or report, as the scoring rubric. - [`references/sources.md`](references/sources.md): load to cite or re-watch a source, with the best timestamps. - `scripts/bx_hair.py`: the toolkit (tested on 5.2.1). Tests are in `tests/code/blender-hair/`.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.