fix(hair): cards follow the scalp normal, three overlapping rows

The first card build anchored every card on a fixed outward vector, so the silhouette
was a spiked crown. Each card is now oriented from the scalp normal at its anchor: the
length runs along the downward tangent, the width across it, and a small tip lift gives
the cut volume. Three overlapping rows (7/9/10) replace the single ring; overlap is
what stops cards reading as separate spikes.

26 cards, 401 verts, 224 quads, 468 tris, still all quads on one head bone.

Recorded as still open: the skin renders darker than the baked diffuse suggests, and
the generated pore and strand textures remain unwired because the material-editing
tool segfaults in the headless commandlet.
This commit is contained in:
zwitschi committed 2026-11-09 13:25:40 +01:00
1 parent 39f07b0f5f
commit 1fc5835327
39 files changed
+84 -68

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -107,6 +107,7 @@ What this shot is evidence for:
- The pawn carries a visible skeletal body with the warm skin material, and a spring-arm camera with collision testing. The **V** toggle swaps first and third person; the shipped default stays first person and the review passes `-thirdperson`.
- **The idle clip drives the body and the arms hang at the sides**, with the hands at the hips and the feet flat. Earlier captures of this shot showed a bind A-pose.
- **Hair is present** as a short card system hugging the skull (three overlapping rows), not the spiked crown of the first attempt.
Still open, tracked in `TODO.md`:
@@ -175,6 +176,8 @@ Every issue is tracked in `TODO.md` under the version that will fix it; an issue
| Retail parapet has no moulded profile, and wall lettering built as a plate does not read at 40 m | `0.9.0` → 6800 Lee Drug and 6806–6820, second pass | Open (moved out of `0.1.6`) |
| 6822 Budget is a bare blockout volume — no photograph exists to model it from | `0.9.0` → photography gap | Deferred (moved out of `0.1.6`) |
| Camera collision is implemented but never exercised against a wall | `0.2.0` → camera collision | Open |
| Skin renders darker than the baked diffuse suggests — the material graph may have been half-rebuilt by a crashed commandlet run | `0.2.2` → skin and hair tuning | Open |
| The generated pore and strand textures are not wired: the material-editing tool segfaults in the headless commandlet | `0.2.2` → skin and hair tuning | Open |
| The walk clip has never been captured in motion — it imports and its curves carry the stride, but no shot shows it | `0.2.0` → verify animation plays correctly | Open |
| Between kerb and building line the walkable surface is the terrain mesh, not a modelled pavement | `0.6.0` → model the pavement between kerb and building line | Open |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -19,7 +19,8 @@ Actionable task list for this project. This file is the single, central place fo
- `Tools/comfyui_generate.py` drives the local ComfyUI instance (`127.0.0.1:8188`) for textures, writing a JSON sidecar with the model, prompt, seed and full workflow so anything generated can be regenerated or rejected. Installed models include `juggernautXL_ragnarok.safetensors` and `sd-v1-5-inpainting.ckpt`.
- **Stable Diffusion is not usable for flat skin swatches.** Three attempts on two models produced an anatomical close-up twice and a leather-scale surface once; SDXL has no reliable notion of "texture without anatomy". Skin detail is therefore generated procedurally instead (`Tools/generate_character_textures.py`: tileable octave noise for pores plus a strand-sheet texture for hair).
- **Hair is now its own asset** (`Tools/character_hair_build.py` → `SM_Char_Hair` → `/Game/Test/Character/CharacterHair`), which is what the `character-artist` skill asks for: cards are a separate mesh, not geometry joined into the body. It imports with its own material slot and is attached in C++ as a second `USkeletalMeshComponent` on `SetLeaderPoseComponent`, so it follows the head bone without a second animation. 321 verts, 192 quads, 404 tris.
- **The card layout is still wrong.** The cards are anchored pointing outward, so the silhouette reads as a spiked crown instead of a short cut. Next iteration: orient each card tangentially along the skull, add a second overlapping row, and drive the shape from the scalp normal rather than a fixed outward vector.
- **The card layout now hugs the skull.** Each card is oriented by the scalp normal at its anchor — the length runs along the downward tangent, the width across it, and a small lift at the tip gives the cut volume. Three overlapping rows (7/9/10 cards, 26 total, 468 tris) replaced the single outward-pointing ring that read as a spiked crown. Overlap is what stops cards reading as separate spikes.
- **Skin still needs work:** it renders darker than the baked diffuse suggests — possibly because the crashed (or half-run) material tool rebuilt the graph — and the generated pore/strand textures are still unwired.
- `Tools/ue_character_materials.py` (wiring the generated textures into the skin and hair materials) **segfaults in the headless commandlet** when it edits the material graph. Not wired; the hair keeps the dark base colour it carries from Blender. Needs either an editor-session run or a material asset built from scratch instead of edited in place.
- [ ] Tag `v0.2.2` and write `CHANGELOG.md` entry; then remove this section.
+33 -21
View File
@@ -69,8 +69,14 @@ def hair_material() -> bpy.types.Material:
return mat
def card(name, anchor, outward, up, width, height, curl, segments=4):
"""One curved card: a strip of quads that arcs away from the scalp."""
def card(name, anchor, along, across, normal, width, height, lift, segments=4):
"""A card that lies ALONG the scalp rather than sticking out of it.
`along` is the direction the hair falls over the skull (the downward tangent at
the anchor), `across` the card's width direction and `normal` the scalp normal.
The first version passed a fixed global up and an outward vector, which made every
card stand away from the head - the spiked crown in the review shot.
"""
mesh = bpy.data.meshes.new(name)
obj = bpy.data.objects.new(name, mesh)
bpy.context.scene.collection.objects.link(obj)
@@ -79,13 +85,11 @@ def card(name, anchor, outward, up, width, height, curl, segments=4):
faces = []
for i in range(segments + 1):
t = i / segments
# Taper to a point at the tip and curl outward as the strand falls.
w = width * (1.0 - 0.75 * t)
h = height * t
bulge = curl * math.sin(t * math.pi * 0.85)
base = anchor + up * h + outward * bulge
left = base - outward.cross(up).normalized() * w
right = base + outward.cross(up).normalized() * w
w = width * (1.0 - 0.65 * t)
# Follow the skull, then lift a little at the tip so the cut has volume.
base = anchor + along * (height * t) + normal * (lift * math.sin(t * math.pi * 0.7))
left = base - across * w
right = base + across * w
verts.append(left)
verts.append(right)
for i in range(segments):
@@ -133,21 +137,29 @@ def main(argv=None) -> int:
material = hair_material()
pieces = [scalp_cap(Vector((centre.x, centre.y, centre.z + 0.012)), radius, material)]
up = Vector((0.0, 0.0, 1.0))
rings = ((CARDS_INNER, 1.00, 0.70), (CARDS_MIDDLE, 0.90, 0.85), (CARDS_OUTER, 0.78, 1.0))
down = Vector((0.0, 0.0, -1.0))
# Two rows: an inner row that covers the crown and an outer row that overlaps it
# down to the hairline. Overlap is what stops the cards reading as separate spikes.
rings = (
# count, radius scale, anchor height, length, width, lift
(7, 0.55, 0.80, 0.085, 0.30, 0.25),
(9, 0.72, 0.62, 0.070, 0.26, 0.18),
(10, 0.86, 0.42, 0.055, 0.24, 0.12),
)
index = 0
for ring, (count, radius_scale, length_scale) in enumerate(rings):
for ring, (count, radius_scale, height_scale, length, width_scale, lift_scale) in enumerate(rings):
for i in range(count):
angle = math.tau * (i + 0.5 * ring) / count
outward = Vector((math.cos(angle), math.sin(angle), 0.22)).normalized()
anchor = Vector((
centre.x + outward.x * radius * radius_scale * 0.92,
centre.y + outward.y * radius * radius_scale * 0.92,
centre.z + radius * (0.52 - 0.16 * ring),
))
pieces.append(card(f"HairCard_{index}", anchor, outward, up,
width=radius * 0.30, height=CARD_HEIGHT * length_scale,
curl=radius * 0.34))
horizontal = Vector((math.cos(angle), math.sin(angle), 0.0))
normal = (horizontal * radius_scale + Vector((0.0, 0.0, height_scale))).normalized()
anchor = Vector((centre.x, centre.y, centre.z)) + normal * radius
# Downward tangent: the part of -Z that lies in the scalp's tangent plane.
along = (down - normal * down.dot(normal)).normalized()
across = along.cross(normal).normalized()
pieces.append(card(f"HairCard_{index}", anchor, along, across, normal,
width=radius * width_scale,
height=radius * length * 10.0,
lift=radius * lift_scale))
index += 1
log(f"built {len(pieces)} pieces: 1 scalp cap + {index} cards")