0.2.2 — hair as its own skinned mesh (cards, second component) #36

Merged
zwitschi merged 11 commits from hair-cards-0.2.2 into main 2026-11-09 14:26:22 +01:00
76 changed files with 1715 additions and 113 deletions

No files matched your search

Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"character": "GigLine walker",
"description": "lean adult pedestrian, short dark hair, olive skin, plain navy t-shirt, dark jeans, worn sneakers",
"model": "juggernautXL_ragnarok.safetensors",
"views": {
"front": {
"file": "Art/characters/blueprints/front.png",
"seed": 101,
"prompt": "GigLine walker, lean adult pedestrian, short dark hair, olive skin, plain navy t-shirt, dark jeans, worn sneakers, full body front view, neutral standing pose, arms at sides, even studio light, plain light grey background, character sheet",
"negative": "multiple people, crowd, text, watermark, signature, blurry, low quality, cropped limbs, extra limbs, deformed hands, cartoon, anime, logo",
"prompt_id": "ff81a0c1-e4d7-4740-9310-806c11600539"
},
"side": {
"file": "Art/characters/blueprints/side.png",
"seed": 202,
"prompt": "GigLine walker, lean adult pedestrian, short dark hair, olive skin, plain navy t-shirt, dark jeans, worn sneakers, full body side profile view, neutral standing pose, arms at sides, even studio light, plain light grey background, character sheet",
"negative": "multiple people, crowd, text, watermark, signature, blurry, low quality, cropped limbs, extra limbs, deformed hands, cartoon, anime, logo",
"prompt_id": "cdd0bfac-446b-4c5a-8375-37fb5c70a75e"
},
"back": {
"file": "Art/characters/blueprints/back.png",
"seed": 303,
"prompt": "GigLine walker, lean adult pedestrian, short dark hair, olive skin, plain navy t-shirt, dark jeans, worn sneakers, full body back view, neutral standing pose, arms at sides, even studio light, plain light grey background, character sheet",
"negative": "multiple people, crowd, text, watermark, signature, blurry, low quality, cropped limbs, extra limbs, deformed hands, cartoon, anime, logo",
"prompt_id": "069bc59d-93bb-41ce-a95b-f5b2e8194982"
},
"head": {
"file": "Art/characters/blueprints/head.png",
"seed": 404,
"prompt": "GigLine walker, lean adult pedestrian, short dark hair, olive skin, plain navy t-shirt, dark jeans, worn sneakers, head and shoulders portrait, front view, neutral expression, even studio light, plain light grey background",
"negative": "multiple people, crowd, text, watermark, signature, blurry, low quality, cropped limbs, extra limbs, deformed hands, cartoon, anime, logo",
"prompt_id": "39ab3ba6-5e6f-4a69-bdbb-b2faf4318dc0"
}
},
"contact_sheet": "Art/characters/blueprints/contact-sheet.png",
"note": "generated locally by Tools/comfyui_character_blueprint.py; record the model licence in THIRD_PARTY_NOTICES.md before shipping"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41
View File
@@ -8,6 +8,47 @@ Format: [Keep a Changelog](https://keepachangelog.com/) style, versions follow [
Nothing yet.
## [0.2.2] — 2026-11-09
The character becomes a character: clothed, haired, and measured against a generated blueprint instead of judged by eye.
**Version note:** this ships as a patch by the owner's decision even though it adds asset categories, which the release policy otherwise puts in a minor release. Recorded here so the numbering is not mistaken for scope later.
### Added
- **Clothing** — navy tee, dark jeans and off-white shoes as three separate skinned meshes (`Tools/character_clothing_build.py`), built from the body's own faces per the `character-artist` workflow: duplicate faces, solidify for cloth thickness, one mesh per piece. Each imports with its own single material slot and is attached as a leader-pose component.
- **Hair cards** (`Tools/character_hair_build.py`) — a scalp cap plus 26 cards in three overlapping rows, quads only, every vertex weighted to `head`. Cards are oriented from the scalp normal at their anchor, so the cut follows the skull instead of standing out as spikes.
- **Character blueprints** (`Tools/comfyui_character_blueprint.py`) — front, side, back and head views generated by the local ComfyUI instance from one description, with fixed seeds and a JSON record of prompts and prompt ids.
- **Blueprint comparison** (`Tools/character_match.py`) — figure aspect, fill and coverage deltas, mean CIE76 and dominant-palette distance, plus a side-by-side montage. The deltas stay separate on purpose: one similarity number between a photoreal sheet and a low-detail body would mean nothing.
- **ComfyUI driver** (`Tools/comfyui_generate.py`) and **procedural textures** (`Tools/generate_character_textures.py`), each writing a provenance sidecar.
- **Material builder** (`Tools/ue_character_materials.py`) — `M_Character_Skin` and `M_Character_Hair_Mat` built from scratch, because `unreal.MaterialEditingLibrary` segfaults in a headless commandlet when it rebuilds an existing graph.
### Changed
- **The sun was raised** from pitch -62 to -74. The character stood in building shadow lit almost entirely by the blue sky light, so warm skin sampled as blue-grey — face (99, 98, 100) before, (131, 121, 117) after at the same camera.
- **The walk cycle starts at maximum stride**, so the deterministic stride capture lands on the stride rather than on one of the two frames where the legs pass together.
- Cloth shells are fattened 8 mm along their own normals after solidify; 1 cm of solidify let the body through at shoulders, hips and knees.
### Fixed
- **Hair read blond and thin** because the strand sheet's pale RGB drove base colour; the sheet now supplies only the opacity mask, and the colour is a dark constant.
- **Garment coverage holes** at shoulders, hips and knees, closed by 2 cm solidify plus the normal fatten.
- **A crash of my own making:** binding materials in `Tick` with `ConstructorHelpers::FObjectFinder` is fatal outside a constructor. Moved to `BeginPlay` with `LoadObject`.
- **Shots 6, 7 and 8 were documented as an unclothed body** after the appearance changed; the README now matches the render and all eight shots are approved.
### Verified
- 8 review shots verify; **all eight approved** by the owner 2026-11-09.
- Blueprint comparison improved from mean ΔE **31.9 → 21.8** and palette ΔE **25.4 → 19.7** once the character was clothed.
- 95 tool tests pass (13 new for the colour maths, masks and palettes).
### Known limitations
- **The garments are still shells over body faces**, not simulated cloth: no wrinkles, no drape, and no separation from the body in motion.
- **`T_Character_Diffuse` cannot be used.** Sampling it renders the body black, so the MPFB mesh's UVs do not map onto that bake; skin is a constant tone and the generated pore sheet is unwired.
- **The character has no face detail** — the eyes, mouth and brows are mesh and lighting only, and read as a mannequin at close range.
- **Camera collision remains implemented but unexercised.**
## [0.2.1] — 2026-11-09
Rig fixes. Two defects from the `0.2.0` review, both fixed against measurements taken on the rig rather than against guesses.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -90,6 +90,57 @@ AGigLineCharacter::AGigLineCharacter()
GetMesh()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
GetMesh()->SetHiddenInGame(true);
// Hair: a separate component that copies the body's pose, so it follows the head
// bone without a second rig or a second animation. Hidden with the body.
// The skin material is assigned at runtime: a SkeletalMesh slot cannot be repointed
// from Python, so Tools/ue_character_materials.py builds M_Character_Skin and the
// pawn binds it here. Without this the imported FBX phong material is used and the
// generated pore detail never reaches the render.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> SkinMaterialFinder(
TEXT("/Game/Test/Character/M_Character_Skin.M_Character_Skin"));
if (SkinMaterialFinder.Succeeded())
{
GetMesh()->SetMaterial(0, SkinMaterialFinder.Object);
}
HairMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("HairMesh"));
HairMesh->SetupAttachment(GetCapsuleComponent());
HairMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
HairMesh->SetHiddenInGame(true);
static ConstructorHelpers::FObjectFinder<USkeletalMesh> HairFinder(
TEXT("/Game/Test/Character/CharacterHair.CharacterHair"));
if (HairFinder.Succeeded())
{
HairMesh->SetSkeletalMesh(HairFinder.Object);
HairMesh->SetRelativeLocation(FVector(0.0f, 0.0f, -87.5f));
HairMesh->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
HairMesh->SetLeaderPoseComponent(GetMesh());
}
// Garments: tee, jeans, shoes. Each is its own mesh with its own single material,
// attached to the capsule and posed from the body so there is no second rig.
static const TCHAR* GarmentPaths[] = {
TEXT("/Game/Test/Character/CharacterTee.CharacterTee"),
TEXT("/Game/Test/Character/CharacterJeans.CharacterJeans"),
TEXT("/Game/Test/Character/CharacterShoes.CharacterShoes"),
};
for (const TCHAR* Path : GarmentPaths)
{
USkeletalMeshComponent* Piece = CreateDefaultSubobject<USkeletalMeshComponent>(
FName(*FString::Printf(TEXT("Cloth_%s"), *FPaths::GetBaseFilename(Path))));
Piece->SetupAttachment(GetCapsuleComponent());
Piece->SetCollisionEnabled(ECollisionEnabled::NoCollision);
Piece->SetHiddenInGame(true);
Piece->SetRelativeLocation(FVector(0.0f, 0.0f, -87.5f));
Piece->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
if (USkeletalMesh* Mesh = LoadObject<USkeletalMesh>(nullptr, Path))
{
Piece->SetSkeletalMesh(Mesh);
Piece->SetLeaderPoseComponent(GetMesh());
}
Clothing.Add(Piece);
}
// Third-person boom. bDoCollisionTest is the camera-collision feature: the arm
// shortens when something is between the pivot and the desired camera position.
ThirdPersonArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("ThirdPersonArm"));
@@ -260,6 +311,27 @@ void AGigLineCharacter::BeginPlay()
// `-walk` holds forward input from the first frame. The screenshot run uses it
// with `-thirdperson` so a capture can show the walk clip mid-stride: an idle
// shot cannot prove the animation set, and a still cannot show a stride.
// Bind the character materials here rather than in the constructor: the slot
// assignment made there never reached the render. LoadObject, not FObjectFinder,
// because FObjectFinder may only be used inside a constructor - putting one in
// Tick was a fatal "FObjectFinders can't be used outside of constructors".
if (GetMesh())
{
if (UMaterialInterface* Skin = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Game/Test/Character/M_Character_Skin.M_Character_Skin")))
{
GetMesh()->SetMaterial(0, Skin);
}
}
if (HairMesh)
{
if (UMaterialInterface* HairMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Game/Test/Character/M_Character_Hair_Mat.M_Character_Hair_Mat")))
{
HairMesh->SetMaterial(0, HairMat);
}
}
bWalkForever = FParse::Param(FCommandLine::Get(), TEXT("walk"));
// `-perf` starts the overlay on: the performance capture and any manual frame
@@ -795,6 +867,17 @@ void AGigLineCharacter::ToggleCamera()
FirstPersonCamera->SetActive(!bThirdPerson);
ThirdPersonCamera->SetActive(bThirdPerson);
GetMesh()->SetHiddenInGame(!bThirdPerson);
if (HairMesh)
{
HairMesh->SetHiddenInGame(!bThirdPerson);
}
for (USkeletalMeshComponent* Piece : Clothing)
{
if (Piece)
{
Piece->SetHiddenInGame(!bThirdPerson);
}
}
if (bThirdPerson)
{
UpdateBodyAnimation();
@@ -155,6 +155,27 @@ protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
float ThirdPersonBoomLength = 340.0f;
/**
* Hair, as its own skeletal mesh following the body's pose.
*
* A second component rather than geometry joined into the body: Unreal collapses
* the second material slot on a skeletal-mesh import, so joined hair rendered with
* the skin material. The character-artist skill calls for hair cards to be their
* own mesh for the same reason it calls for swappable parts to be separate.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Body")
TObjectPtr<USkeletalMeshComponent> HairMesh;
/**
* Garments, each its own skeletal mesh on a leader pose component.
*
* The character-artist skill keeps clothing separate so a piece can be swapped or
* re-materialled on its own; it also sidesteps the material-slot collapse that
* makes joined multi-material meshes arrive with one slot.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Body")
TArray<TObjectPtr<USkeletalMeshComponent>> Clothing;
UPROPERTY()
TObjectPtr<UAnimSequence> IdleAnimation;
+20
View File
@@ -492,3 +492,23 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
- **ComfyUI is reachable at `127.0.0.1:8188` (0.44.0)** and its API is the clean way in: `POST /prompt` with an API-format graph (`[node_id, output_index]` references — the UI's workflow JSON cannot be posted), then poll `/history/<id>` and fetch `/view?filename=...`. `Tools/comfyui_generate.py` does this and writes a sidecar with model, prompt, seed and the full graph. Installed checkpoints: `juggernautXL_ragnarok.safetensors`, `epicrealismXL_pureFix.safetensors`, `sd-v1-5-inpainting.ckpt`, plus inpainting variants.
- **Do not ask Stable Diffusion for a flat texture swatch.** Three attempts at "seamless skin texture" produced an anatomical close-up twice and a leather-scale surface once. SDXL has no reliable notion of a material sample without anatomy, so every seed needs manual rejection — worse than noise we control. Procedural generation won for skin pores (`Tools/generate_character_textures.py`, tileable octave noise) and a strand sheet for hair.
- **Unreal collapses the second material slot on a skeletal-mesh import.** A joined hair cap (Blender shows two slots, hair faces on index 1) arrives as a single slot named after the body material, so the hair renders with the skin material. `unreal.SkeletalMesh` has no `set_material`, so it cannot be re-slotted after import. A separate skeletal mesh attached as a second component is the way around it.
## 2026-11-09 — Hair as a separate mesh, and a material-editing crash
- **Hair joined into the body mesh loses its material.** Blender shows two slots (hair faces on index 1) and the FBX exports both, but Unreal's skeletal-mesh import arrives with **one** slot named after the body material, so the hair renders as skin. `unreal.SkeletalMesh` has no `set_material`, so it cannot be re-slotted afterwards. A separate skeletal mesh imported on its own keeps its slot and imports cleanly (`CharacterHair` → `M_Character_Hair`).
- **A second skeletal component follows the body with `SetLeaderPoseComponent(GetMesh())`** — no second rig, no second animation, and it hides with the body in first person.
- **`unreal.MaterialEditingLibrary` segfaults in `-run=pythonscript`** when it clears and rebuilds a material graph. The tool ran its texture imports, then crashed the commandlet with no Python traceback. Graph editing needs an editor session, or a material created from scratch rather than edited in place.
## 2026-11-09 — Character blueprints and how to compare a render to one
- **A SDXL character sheet is reliable**: "full body front view, neutral pose, plain grey background, character sheet" gives a usable turnaround with fixed seeds. Four views (front/side/back/head) from one description is enough to evaluate against; the sheet and its JSON live in `Art/characters/blueprints/`.
- **Segmenting the character out of a review shot does not work by colour.** The sky band, the pale wall and the dark pavement are each larger and more saturated than the body, so every size-based rule picked a backdrop blob and the first report measured the whole frame. `Tools/character_match.py` now takes an explicit `--render-box`; the proper fix is a background plate rendered without the character.
- **The report has to separate the failures it measures.** Aspect, fill, coverage and two colour distances are reported apart on purpose: an absolute similarity number between a photoreal sheet and a low-detail body would mean nothing. The first real comparison (aspect Δ1.24, mean ΔE 31.9) was dominated by the model being **unclothed** while the blueprint wears a tee and jeans.
- **`SetMaterial(0, ...)` in the pawn constructor does not visibly land** on the body mesh: `M_Character_Skin` is built and assigned, yet the render stays neutral grey. Either the slot does not exist yet at that point or the assignment is dropped; `BeginPlay` is the next thing to try.
- **MPFB vertex groups are polluted by helper geometry.** The body mesh weights its helper cubes to groups named `Left`, `Right`, `HelperGeometry` and `JointCubes` at 1.0, so a "strongest group" lookup returns a helper for most vertices - the first clothing build selected **zero** faces. Filter helper names out before asking which bone a vertex belongs to.
- **Blender refuses to apply a modifier to a mesh with shape keys.** The body carries ten; a garment copy must clear them (`shape_key_remove(all=True)`) before solidify can be applied.
- **Edit-mode selection reverts to vertex selection.** Setting `polygon.select` in object mode and then entering edit mode re-derives selection from the vertices, which were all still selected - deleting "the unselected faces" wiped the entire garment. Filter with `bmesh.ops.delete(context="FACES")` instead.
- **`ConstructorHelpers::FObjectFinder` outside a constructor is a fatal error**, not a warning: the game dies in `Tick` with "FObjectFinders can't be used outside of constructors". Use `LoadObject<T>(nullptr, Path)` for runtime loads and keep `FObjectFinder` to the constructor.
- **A 1 cm solidify shell lets the body poke through.** Garments built by duplicating body faces needed 2 cm of solidify plus an 8 mm fatten along their own normals before the cloth read as continuous at shoulders, hips and knees.
- **Warm skin sampling as blue-grey was lighting, not material.** The body stood in building shadow and was lit almost entirely by the blue `Demo_SkyLight` (intensity 2.6): face (99, 98, 100), arms (67, 74, 81). Raising the sun to pitch -74 moved the face to (131, 121, 117). A deliberate red test colour reaching the render first proved the material and the slot assignment were fine, which is what stopped a long hunt in the wrong place.
- **The Unreal commandlet drops unknown command-line switches.** A `--unlit-test` flag passed after `-script` never reached Python; an environment variable does get through (and even that proved unnecessary once the red test settled the question).
+1 -1
View File
@@ -8,7 +8,7 @@ Development roadmap for this project: milestone goals, scope, decisions, and rel
## Status at a glance — 2026-11-08 15:30
Workstation, geo pipeline, and engine validation are done. The art pipeline has a first blockout, terrain, and street in metres, in one shared coordinate frame. **Project bootstrap is complete**: `Game/GigLine` builds, packages, and both content-acceptance tests (static-mesh scale, MPFB skeletal character) pass. **`v0.1.0` released 2026-11-08**: the walkable demo, hero façade, verified block frontage, first-person controls, ODbL attribution, and every release check pass (traversal, placement, collision, 60 fps at 1440p, fresh-checkout build, packaged build with controls). Next: the `0.1.1`–`0.1.4` patch series (collision detail, scale/façade corrections, packaging fixes, real building façades), **`v0.1.6` released 2026-11-09**: retail frontage for 6800 Lee Drug and 6806–6820 modelled from their own photograph; 6822 Budget deferred (no reference) and the lettering/parapet detail moved to `0.9.0`. **`v0.2.0` released 2026-11-09**: visible animated body, third-person camera with a V toggle, idle and walk clips, and a deterministic stride capture. Two cosmetic character defects (splayed fingers, a swept-not-derived base rotation) are deferred to `0.2.2`. **Next:** `0.3.0` (Chaos vehicle, enter/exit, closed driving route).
Workstation, geo pipeline, and engine validation are done. The art pipeline has a first blockout, terrain, and street in metres, in one shared coordinate frame. **Project bootstrap is complete**: `Game/GigLine` builds, packages, and both content-acceptance tests (static-mesh scale, MPFB skeletal character) pass. **`v0.1.0` released 2026-11-08**: the walkable demo, hero façade, verified block frontage, first-person controls, ODbL attribution, and every release check pass (traversal, placement, collision, 60 fps at 1440p, fresh-checkout build, packaged build with controls). Next: the `0.1.1`–`0.1.4` patch series (collision detail, scale/façade corrections, packaging fixes, real building façades), **`v0.1.6` released 2026-11-09**: retail frontage for 6800 Lee Drug and 6806–6820 modelled from their own photograph; 6822 Budget deferred (no reference) and the lettering/parapet detail moved to `0.9.0`. **`v0.2.0`–`v0.2.2` released 2026-11-09**: visible animated body, third-person camera with a V toggle, idle and walk clips, a deterministic stride capture, and then the character pass — clothing (tee, jeans, shoes as separate skinned meshes), dark hair cards, generated blueprints and a render-vs-blueprint comparison (mean ΔE 31.9 → 21.8). **Next:** `0.3.0` (Chaos vehicle, enter/exit, closed driving route).
| Area | State |
| --- | --- |
+14 -5
View File
@@ -24,6 +24,8 @@ Every shot is embedded below, in order, so the whole review happens in this file
| 4 | [North buildings](#4-north-buildings) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 5 | [Retail frontage](#5-retail-frontage) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 6 | [Third-person body](#6-third-person-body) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 7 | [Third-person front](#7-third-person-front) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 8 | [Third-person walk](#8-third-person-walk) | ☑ 2026-11-09 | ☑ 2026-11-09 |
**Verified** is what `--verify` reports: the frame is not blank, is the right size, and has tonal range. **Approved** is the owner's verdict, recorded here. Each shot also has an **expectation** in [`Tools/visual_targets.json`](../Tools/visual_targets.json) describing the content it must show.
@@ -107,10 +109,13 @@ 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 dark card system hugging the skull (three overlapping rows), not the spiked crown of the first attempt. The strand sheet supplies only the opacity mask; the colour is a dark constant, because using the sheet's pale RGB as base colour made the cut read blond and thin.
- **The body is clothed** since `0.2.2`: navy tee, dark jeans and off-white shoes as three separate skinned meshes, attached on leader-pose components.
Changed 2026-11-09 by `0.2.2` (clothing, hair, raised sun); **approved 2026-11-09** in that state.
Still open, tracked in `TODO.md`:
- The fingers splay slightly, which is the bind's finger spread carried into the pose. That is the `0.2.2` pose-and-skin tuning pass, not this rig.
- **Camera collision is implemented but unproven:** the spawn has open space behind it, so no shot exercises the arm shortening.
---
@@ -121,12 +126,14 @@ Still open, tracked in `TODO.md`:
- **File:** `demo/review-demo-third-person-front.png`
- **View:** from the road, ~6 m in front of the pawn, looking back north. The pawn spawns facing +Y, so this is its front
- **Expected content:** both arms hanging at the sides with the hands by the hips, face and chest visible
- **Expected content:** the clothed character from the front — navy tee, dark jeans, off-white shoes, dark hair — arms hanging at the sides with the hands by the hips
- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09
**Why the shot exists:** the third-person shot only ever shows the pawn's back, so a pose defect on the front — arms out, hands splayed, a face pointing the wrong way — was invisible in review. This angle is the check for it.
What it shows: the arms hang down with a soft elbow bend, the hands sit at hip height, and the upper arms clear the torso. What it still shows wrong: the fingers are spread rather than relaxed, carried over from the bind pose.
What it shows: the arms hang down with a soft elbow bend, the hands sit at hip height with the fingers curled, the upper arms clear the torso, and the character wears the navy tee, dark jeans and off-white shoes built in `0.2.2`. Hair is a dark card system hugging the skull.
**Changed 2026-11-09 by `0.2.2`:** clothing (tee, jeans, shoes as separate skinned meshes), dark hair cards, and a raised sun. The raised sun matters here: the character used to stand in building shadow lit by the blue sky light, which made warm skin sample as blue-grey — face (99, 98, 100) before, (131, 121, 117) after. **Approved 2026-11-09 after the change.**
---
@@ -138,8 +145,8 @@ What it shows: the arms hang down with a soft elbow bend, the hands sit at hip h
- **File:** `demo/review-demo-third-person-walk.png`
- **View:** tracking camera, 4.2 m to the pawn's right, captured while the pawn walks south
- **Expected content:** the body mid-stride in profile — legs apart, arms counter-swinging
- **Verified:** ☑ 2026-11-09 · **Approved:** ☐ pending
- **Expected content:** the clothed body mid-stride in profile — legs apart, arms counter-swinging, navy tee and jeans
- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09
**Why the earlier attempts failed, twice:** the first was a harness problem (a fixed camera cannot catch a pawn at 4.5 m/s, and the call that enables walking ran before the pawn existed). The second was the clip itself: the walk cycle was authored on a **sine**, which is zero at both the start and the middle of the cycle, so the capture kept landing on the two frames where the legs pass together and the image read as a still. The clip now starts at maximum stride (cosine), so any frame near the start shows the stride. The cycle length, amplitudes and loop are unchanged.
@@ -175,6 +182,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.
-13
View File
@@ -9,19 +9,6 @@ Actionable task list for this project. This file is the single, central place fo
- When all steps in a version section are done: write a `CHANGELOG.md` entry, then remove the version section from this file.
- Release gate: a version is tagged only on the owner's explicit go-ahead once its release checks pass, not automatically when the checkboxes close.
## 0.2.2 — Skin and hair tuning (patch)
### Patch tasks - active - session-5598ce7e-27e3-4c64-bb49-c04a4a31ee58 (branch `anim-body-0.2.0`)
- [x] Restore the stride capture after the pose rewrite. The walk cycle started on a **sine**, zero at both the start and the middle of the cycle, so the deterministic capture kept landing on the two passing frames where the legs are together. Starting the cycle at maximum stride (cosine) fixed it with no other change; `demo-third-person-walk` is back as shot 8 and shows a genuine stride.
- [A] Tune skin shading and hair materials on the player character. **Tooling in place 2026-11-09, hair blocked on an engine-side limit:**
- `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 geometry is built but not shipped.** `add_hair_cap()` creates a cap conforming to the head and weights it to the `head` bone (Blender shows two material slots, hair faces on index 1), but Unreal's import **collapses the second material slot**, so the cap renders with the skin material and reads as a bald skull. It is behind `--hair`, off. Next step: export the hair as its own skeletal mesh and attach it as a second component, or bake the hair colour into vertex colours.
- [ ] Tag `v0.2.2` and write `CHANGELOG.md` entry; then remove this section.
## 0.2.3 — Camera clipping fixes (patch)
### Patch tasks
+4
View File
@@ -90,6 +90,10 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| --- | --- |
| `input_emulation.py` | Pure-stdlib `ctypes`/`fcntl` driver that creates a virtual keyboard+mouse on `/dev/uinput`. The desktop treats it as real hardware, so it proves whether OS input actually reaches the game. Needs `/dev/uinput` writable (ACL on this host). |
| `input_test.py` | Injects a scripted sequence into the focused window: `--sequence move:2,look:3,jump:1,reset:1,pause:1,click`. |
| `comfyui_generate.py` | Local ComfyUI driver: list checkpoints, run txt2img through the HTTP API, and write a provenance sidecar (model, prompt, seed, full graph). Local, human-triggered. | `models`; `txt2img --prompt ... --out <png>` | `python3 Tools/comfyui_generate.py models` |
| `comfyui_character_blueprint.py` | Generates a character blueprint sheet - front, side, back and head views plus a contact sheet - from one description, with fixed seeds and a JSON record. | `--desc`, `--out`, `--model`, `--size` | `python3 Tools/comfyui_character_blueprint.py --desc "lean pedestrian, navy tee, jeans" --out Art/characters/blueprints` |
| `character_match.py` | Compares a rendered character against a blueprint: figure aspect, fill and coverage deltas, mean CIE76 colour distance, dominant-palette distance, and a side-by-side montage. | `--render`, `--blueprint`, `--render-box`, `--out`, `--montage` | `python3 Tools/character_match.py --render Screenshots/demo/review-demo-third-person-front.png --blueprint Art/characters/blueprints/front.png --render-box 0.42,0.46,0.56,0.97 --montage /tmp/gigline/match.png` |
| `generate_character_textures.py` | Procedural skin-pore and hair-strand textures with sidecars; no model involved. | `--out` | `python3 Tools/generate_character_textures.py --out Art/textures/generated` |
## Tests
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Build the character's clothing as separate skinned meshes.
The `character-artist` skill's clothing workflow, followed literally: duplicate body
faces for the clothing base, solidify for cloth thickness, and keep each piece a
separate mesh so it can be swapped or re-materialled on its own.
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/character_clothing_build.py -- \
--out /tmp/gigline/clothing --report /tmp/gigline/clothing.json
Outputs one FBX per garment (`tee`, `jeans`, `shoes`), each carrying the character
armature so Unreal imports it as a skeletal mesh with the body's skeleton.
Selection is by **dominant vertex group**, not by height band alone: a z-range band
would swallow the arms hanging at the sides, and the arms have to stay bare below the
cap sleeves. Each garment lists the bones whose weights it accepts.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
GARMENTS = (
{
"name": "tee",
"asset": "SM_Char_Cloth_Tee",
"bones": ["spine01", "spine02", "spine03", "spine04", "spine05",
"clavicle.L", "clavicle.R", "shoulder01.L", "shoulder01.R",
"upperarm01.L", "upperarm01.R", "breast.L", "breast.R",
"neck01", "neck02", "pelvis.L", "pelvis.R"],
"z_min": 0.90, "z_max": 1.44,
"x_max": 0.255, # trims the bare arms out of the tee
"use_bones": False,
"thickness": 0.020,
"colour": (0.020, 0.032, 0.075, 1.0), # navy cotton
"roughness": 0.78,
},
{
"name": "jeans",
"asset": "SM_Char_Cloth_Jeans",
"bones": ["pelvis.L", "pelvis.R", "upperleg01.L", "upperleg01.R",
"upperleg02.L", "upperleg02.R", "lowerleg01.L", "lowerleg01.R",
"lowerleg02.L", "lowerleg02.R", "spine01"],
"z_min": 0.06, "z_max": 1.06,
"x_max": 0.32,
"use_bones": False,
"thickness": 0.020,
"colour": (0.032, 0.055, 0.105, 1.0), # dark denim
"roughness": 0.62,
},
{
"name": "shoes",
"asset": "SM_Char_Cloth_Shoes",
"bones": ["foot.L", "foot.R", "toe1-1.L", "toe1-1.R",
"lowerleg02.L", "lowerleg02.R"],
"z_min": -0.05, "z_max": 0.16,
"x_max": 0.40,
"use_bones": False,
"thickness": 0.012,
"colour": (0.72, 0.71, 0.68, 1.0), # off-white canvas
"roughness": 0.55,
},
)
PARTS: list[str] = []
def log(message: str) -> None:
PARTS.append(message)
print(f"[cloth] {message}")
# MPFB ships helper geometry in the same mesh, weighted 1.0 to groups called
# 'Left', 'Right', 'HelperGeometry' and 'JointCubes'. Those dominate the raw weights,
# so the first build selected zero faces: every vertex's strongest group was a helper.
_HELPER_PREFIXES = ("helper", "joint", "Helper", "Joint")
_HELPER_NAMES = {"Left", "Right", "Mid", "body", "HelperGeometry", "JointCubes"}
def is_deform_bone(name: str) -> bool:
return bool(name) and not name.startswith(_HELPER_PREFIXES) and name not in _HELPER_NAMES
def dominant_groups(mesh) -> dict[int, str]:
"""Vertex index -> its strongest *deforming bone* group, ignoring helpers."""
index_to_name = {g.index: g.name for g in mesh.vertex_groups}
result = {}
for vertex in mesh.data.vertices:
candidates = [(g.weight, index_to_name.get(g.group, "")) for g in vertex.groups]
candidates = [(w, n) for w, n in candidates if is_deform_bone(n)]
if not candidates:
continue
result[vertex.index] = max(candidates)[1]
return result
def build_garment(body, arm, spec, out_dir):
bpy.ops.object.select_all(action="DESELECT")
body.select_set(True)
bpy.context.view_layer.objects.active = body
bpy.ops.object.duplicate()
garment = bpy.context.active_object
garment.name = spec["asset"]
allowed = set(spec["bones"])
dominant = dominant_groups(body)
mesh = garment.data
# Filter with bmesh instead of edit-mode selection. Setting polygon.select in
# object mode and then entering edit mode re-derives selection from the vertices,
# which were still all selected - the delete wiped the whole garment every time.
import bmesh
bm = bmesh.new()
bm.from_mesh(mesh)
doomed = []
for face in bm.faces:
centre_z = sum(v.co.z for v in face.verts) / len(face.verts)
groups = [dominant.get(v.index, "") for v in face.verts]
centre_x = abs(sum(v.co.x for v in face.verts) / len(face.verts))
keep = (spec["z_min"] <= centre_z <= spec["z_max"]
and centre_x <= spec.get("x_max", 10.0)
and (not spec.get("use_bones") or any(g in allowed for g in groups)))
face.tag = keep
if not keep:
doomed.append(face)
bmesh.ops.delete(bm, geom=doomed, context="FACES")
bm.to_mesh(mesh)
bm.free()
mesh.update()
log(f"{spec['asset']}: kept {len(mesh.polygons)} faces")
if not mesh.polygons:
log(f"{spec['asset']}: no faces selected - check the bone list and z range")
return None
# The body carries MPFB shape keys, and Blender refuses to apply a modifier to a
# mesh that has any. A garment does not need the morph targets.
if garment.data.shape_keys:
bpy.ops.object.shape_key_remove(all=True)
# Cloth thickness: solidify outward so the garment sits over the body rather
# than inside it.
solidify = garment.modifiers.new(name="Cloth", type="SOLIDIFY")
solidify.thickness = spec["thickness"]
solidify.offset = 1.0
bpy.ops.object.modifier_apply(modifier=solidify.name)
# Push the shell out along its own normals. Solidify alone left the body visible
# through the garment at every convex patch - shoulders, hips, knees.
import bmesh
bm = bmesh.new()
bm.from_mesh(mesh)
bm.normal_update()
for vertex in bm.verts:
vertex.co += vertex.normal * spec.get("fatten", 0.008)
bm.to_mesh(mesh)
bm.free()
mesh.update()
log(f"{spec['asset']}: fattened by {spec.get('fatten', 0.008):.3f} m")
material = bpy.data.materials.get(f"M_Char_{spec['name']}") \
or bpy.data.materials.new(f"M_Char_{spec['name']}")
material.use_nodes = True
bsdf = material.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = spec["colour"]
bsdf.inputs["Roughness"].default_value = spec["roughness"]
garment.data.materials.clear()
garment.data.materials.append(material)
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
garment.select_set(True)
bpy.context.view_layer.objects.active = arm
path = os.path.join(out_dir, f"{spec['name']}.fbx")
fbx_preset.export_character(path, selection=True)
stats = {
"asset": spec["asset"],
"file": path,
"vertices": len(garment.data.vertices),
"triangles": sum(len(p.vertices) - 2 for p in garment.data.polygons),
"thickness_m": spec["thickness"],
"bones": spec["bones"],
"z_range": [spec["z_min"], spec["z_max"]],
"colour": spec["colour"],
"roughness": spec["roughness"],
}
log(f"{spec['asset']}: {stats['vertices']} verts, {stats['triangles']} tris -> {path}")
return stats
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/clothing")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
body = next((o for o in bpy.data.objects if o.type == "MESH" and o.parent == arm), None)
if arm is None or body is None:
raise SystemExit("need the character armature and its body mesh")
log(f"body {body.name}: {len(body.data.vertices)} verts")
os.makedirs(args.out, exist_ok=True)
built = []
for spec in GARMENTS:
stats = build_garment(body, arm, spec, args.out)
if stats:
built.append(stats)
if args.report:
with open(args.report, "w") as handle:
json.dump({"garments": built, "log": PARTS}, handle, indent=2)
print(f"[cloth] report -> {args.report}")
return 0 if len(built) == len(GARMENTS) else 1
if __name__ == "__main__":
raise SystemExit(main())
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Build the hair as its own skinned mesh — hair cards over a scalp cap.
The `character-artist` skill is explicit that hair for a game character is a card
system and that swappable parts stay separate meshes. Joining hair into the body mesh
was tried first and failed for an engine reason: Unreal collapses the second material
slot on a skeletal-mesh import, so the cap rendered with the skin material. A separate
object sidesteps that and is what the skill calls for anyway.
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/character_hair_build.py -- \
--out /tmp/gigline/character-hair.fbx --report /tmp/gigline/character-hair.json
What it builds:
SM_Char_Hair scalp cap + 18 cards in three rings, UV-mapped to the strand sheet
from Tools/generate_character_textures.py, every vertex weighted
to `head` at 1.0 so it follows the head without a second rig.
Cards are built with a taper and a slight outward curl so the silhouette is not a
shell; the strand texture runs vertically down each card, which is the direction
hair actually reads in. Topology is quads only (the skill's animation-ready rule),
and there are no poles on the deformation surface because the whole mesh is rigidly
weighted to one bone.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
HAIR_NAME = "SM_Char_Hair"
CARD_RINGS = 3 # rings of cards around the skull
CARDS_INNER = 6
CARDS_MIDDLE = 7
CARDS_OUTER = 5
CARD_HEIGHT = 0.072 # m: short, close cut - taller cards spiked up
CAP_THICKNESS = 0.004
PARTS: list[dict] = []
def log(message: str) -> None:
PARTS.append(message)
print(f"[hair] {message}")
def hair_material() -> bpy.types.Material:
mat = bpy.data.materials.get("M_Character_Hair") or bpy.data.materials.new("M_Character_Hair")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
# Dark brown, low roughness: strands catch a highlight. The strand texture is
# sampled in Unreal, so Blender only needs a sane base for the FBX material.
bsdf.inputs["Base Color"].default_value = (0.052, 0.036, 0.028, 1.0)
bsdf.inputs["Roughness"].default_value = 0.38
if "Alpha" in bsdf.inputs:
bsdf.inputs["Alpha"].default_value = 1.0
mat.use_backface_culling = False
return mat
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)
verts = []
faces = []
for i in range(segments + 1):
t = i / segments
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):
a = i * 2
faces.append((a, a + 1, a + 3, a + 2))
mesh.from_pydata(verts, [], faces)
mesh.update()
return obj
def scalp_cap(centre, radius, material):
bpy.ops.mesh.primitive_uv_sphere_add(segments=20, ring_count=12, radius=radius,
location=centre)
cap = bpy.context.active_object
cap.name = "HairScalp"
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, centre.z - radius * 0.15),
plane_no=(0.0, 0.0, 1.0), clear_inner=True, use_fill=False)
bpy.ops.object.mode_set(mode="OBJECT")
cap.scale = (1.03, 1.06, 0.86)
cap.data.materials.append(material)
return cap
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/character-hair.fbx")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
if arm is None:
raise SystemExit("no armature in the .blend")
head = arm.data.bones.get("head")
if head is None:
raise SystemExit("no 'head' bone")
centre = (head.head_local + head.tail_local) * 0.5
radius = max(0.088, (head.tail_local - head.head_local).length * 0.60)
log(f"head centre {[round(c, 3) for c in centre]}, radius {radius:.3f} m")
material = hair_material()
pieces = [scalp_cap(Vector((centre.x, centre.y, centre.z + 0.012)), radius, material)]
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, height_scale, length, width_scale, lift_scale) in enumerate(rings):
for i in range(count):
angle = math.tau * (i + 0.5 * ring) / count
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")
# Join into one object and weight every vertex to the head bone.
bpy.ops.object.select_all(action="DESELECT")
for piece in pieces:
piece.select_set(True)
bpy.context.view_layer.objects.active = pieces[0]
bpy.ops.object.join()
hair = bpy.context.active_object
hair.name = HAIR_NAME
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
hair.data.materials.clear()
hair.data.materials.append(material)
group = hair.vertex_groups.new(name="head")
group.add(list(range(len(hair.data.vertices))), 1.0, "REPLACE")
# UVs: a simple planar unwrap is enough because the strand texture is directional.
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(angle_limit=1.15, island_margin=0.01)
bpy.ops.object.mode_set(mode="OBJECT")
quads = sum(1 for p in hair.data.polygons if len(p.vertices) == 4)
tris = sum(len(p.vertices) - 2 for p in hair.data.polygons)
log(f"{HAIR_NAME}: {len(hair.data.vertices)} verts, {quads} quads, {tris} tris, "
f"{len(hair.data.materials)} material slot(s)")
# Parent to the armature so the export carries the skin.
modifier = hair.modifiers.new(name="Armature", type="ARMATURE")
modifier.object = arm
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
hair.select_set(True)
bpy.context.view_layer.objects.active = arm
os.makedirs(os.path.dirname(args.out), exist_ok=True)
fbx_preset.export_character(args.out, selection=True)
log(f"exported {args.out}")
if args.report:
with open(args.report, "w") as handle:
json.dump({
"asset": HAIR_NAME,
"head_centre_m": [round(c, 4) for c in centre],
"head_radius_m": round(radius, 4),
"cards": index,
"rings": [r[0] for r in rings],
"vertices": len(hair.data.vertices),
"quads": quads,
"triangles": tris,
"material": material.name,
"texture": "Art/textures/generated/T_Character_Hair.png",
"weighting": "all vertices 1.0 on 'head'",
"log": PARTS,
}, handle, indent=2)
print(f"[hair] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""Evaluate a rendered character against a generated blueprint.
The blueprint comes from `Tools/comfyui_character_blueprint.py`; the render comes from
the review pipeline. This tool isolates the figure in both, then reports how far the
model has drifted from the intended character:
python3 Tools/character_match.py \
--render Screenshots/demo/review-demo-third-person-front.png \
--blueprint Art/characters/blueprints/front.png \
--out /tmp/gigline/character-match.json \
--montage /tmp/gigline/character-match.png
Reported per pair:
aspect_delta |height/width difference| between the two figures, in ratio units
fill_delta |figure area / bbox area| - bulk, catches a thin or bloated build
coverage_delta |figure area / frame area| - how much of the frame the figure takes
mean_delta_e mean CIE76 colour distance over the figure
palette_delta_e mean distance between the five dominant colours of each figure
Why these and not a similarity score: an absolute "match percentage" between a
photoreal generated sheet and a low-detail game body would be a number with no
meaning. The deltas are separable, so a report says *what* drifted - build, framing
or colour - instead of blending all three into one figure.
The figure mask is a heuristic and the tool says so in its report: for the blueprint
it is "differs from the corner colour", for the render it is "more saturated or darker
than the frame median", largest connected component. Pair the tool with a human look
at the montage; it narrows where to look, it does not replace the review.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
from PIL import Image
# ---------------------------------------------------------------- colour maths
def srgb_to_linear(value: float) -> float:
value /= 255.0
return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
def rgb_to_lab(rgb) -> tuple[float, float, float]:
"""sRGB (0-255) to CIELAB under D65."""
r, g, b = (srgb_to_linear(v) for v in rgb[:3])
x = r * 0.4124 + g * 0.3576 + b * 0.1805
y = r * 0.2126 + g * 0.7152 + b * 0.0722
z = r * 0.0193 + g * 0.1192 + b * 0.9505
# D65 white point
xn, yn, zn = 0.95047, 1.0, 1.08883
def f(t: float) -> float:
return t ** (1.0 / 3.0) if t > 0.008856 else (7.787 * t) + (16.0 / 116.0)
fx, fy, fz = f(x / xn), f(y / yn), f(z / zn)
return (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
def delta_e76(lab_a, lab_b) -> float:
return math.sqrt(sum((a - b) ** 2 for a, b in zip(lab_a, lab_b)))
# ------------------------------------------------------------------- masking
def luminance(rgb) -> float:
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
def _subject_mask(image: Image.Image, saturation_threshold: int = 20,
dark_ratio: float = 0.62) -> Image.Image:
"""Mask the figure as "more saturated than the backdrop, or notably darker".
Both inputs have a desaturated backdrop - grey studio sweep in the blueprint,
beige pavement and pale walls in the render - and a figure that carries colour
(skin, denim, hair). Saturation separates them without needing a clean plate.
The previous version masked the blueprint by "differs from the corner colour",
which swallowed a gradient backdrop whole.
"""
rgb = image.convert("RGB")
pixels = rgb.load()
lums = [luminance(pixels[x, y])
for y in range(0, rgb.height, 4) for x in range(0, rgb.width, 4)]
lums.sort()
median = lums[len(lums) // 2] if lums else 128.0
mask = Image.new("L", rgb.size, 0)
out = mask.load()
for y in range(rgb.height):
for x in range(rgb.width):
r, g, b = pixels[x, y]
saturation = max(r, g, b) - min(r, g, b)
if saturation > saturation_threshold or luminance((r, g, b)) < median * dark_ratio:
out[x, y] = 255
return mask
def figure_mask_from_background(image: Image.Image, tolerance: int = 28):
"""Figure mask for a blueprint sheet: a coloured subject on a grey sweep."""
return _largest_component(_subject_mask(image, saturation_threshold=18,
dark_ratio=0.55))
def figure_mask_from_frame(image: Image.Image):
"""Figure mask for a rendered frame: the body against pavement and stucco."""
return _largest_component(_subject_mask(image, saturation_threshold=26,
dark_ratio=0.72))
def _largest_component(mask: Image.Image) -> Image.Image:
"""Keep the largest plausible figure blob.
"Largest" alone is wrong twice over: in both inputs the backdrop is one huge
connected region, so a plain largest-blob pick returned the background and the
first report measured the whole frame. The filter is size-based instead - a figure
occupies between 0.2 % and 55 % of the frame - which rejects the backdrop on one
side and road markings on the other.
"""
width, height = mask.size
frame_area = width * height
source = mask.load()
seen = bytearray(width * height)
best = [] # largest blob fully inside the frame
usable = [] # largest plausible blob, border or not
fallback = [] # largest blob at all, used when nothing is plausible
best_score = 0
for start_y in range(0, height, 3):
for start_x in range(0, width, 3):
index = start_y * width + start_x
if seen[index] or source[start_x, start_y] == 0:
continue
stack = [(start_x, start_y)]
blob = []
seen[index] = 1
while stack:
x, y = stack.pop()
blob.append((x, y))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < width and 0 <= ny < height:
nindex = ny * width + nx
if not seen[nindex] and source[nx, ny] != 0:
seen[nindex] = 1
stack.append((nx, ny))
if len(blob) > len(fallback):
fallback = blob
share = len(blob) / frame_area
if not (0.002 < share < 0.55):
continue
# Blobs glued to the frame edge are backdrop: the sky band in a render and
# the studio sweep in a sheet both touch the border and both beat the
# figure on raw area. Prefer fully enclosed blobs, fall back by area.
xs = [p[0] for p in blob]
ys = [p[1] for p in blob]
enclosed = (min(xs) > 1 and min(ys) > 1
and max(xs) < width - 2 and max(ys) < height - 2)
if enclosed and len(blob) > best_score:
best_score = len(blob)
best = blob
elif len(blob) > len(usable):
usable = blob
keep = Image.new("L", mask.size, 0)
out = keep.load()
for x, y in (best or usable or fallback):
out[x, y] = 255
return keep
def bbox(mask: Image.Image):
return mask.getbbox()
def mask_stats(mask: Image.Image) -> dict:
box = mask.getbbox()
if not box:
return {"area": 0, "bbox": None, "aspect": None, "fill": None, "coverage": None}
x0, y0, x1, y1 = box
histogram = mask.histogram()
area = sum(histogram[1:])
box_area = max(1, (x1 - x0) * (y1 - y0))
return {
"area": area,
"bbox": [x0, y0, x1, y1],
"aspect": (y1 - y0) / max(1, (x1 - x0)),
"fill": area / box_area,
"coverage": area / (mask.width * mask.height),
}
def masked_colours(image: Image.Image, mask: Image.Image, limit=20000):
rgb = image.convert("RGB")
pixels = rgb.load()
values = mask.load()
collected = []
step = max(1, (rgb.width * rgb.height) // limit)
index = 0
for y in range(rgb.height):
for x in range(rgb.width):
index += 1
if index % step:
continue
if values[x, y]:
collected.append(pixels[x, y])
return collected
def dominant(counts: dict, count: int = 5):
"""Coarse palette: quantise to 32 levels per channel, then take the top bins."""
bins: dict[tuple, int] = {}
for colour in counts:
key = (colour[0] // 32, colour[1] // 32, colour[2] // 32)
bins[key] = bins.get(key, 0) + 1
ranked = sorted(bins.items(), key=lambda item: -item[1])[:count]
return [((k[0] * 32) + 16, (k[1] * 32) + 16, (k[2] * 32) + 16) for k, _ in ranked]
def palette_delta_e(palette_a, palette_b) -> float:
if not palette_a or not palette_b:
return float("nan")
labs_b = [rgb_to_lab(c) for c in palette_b]
total = 0.0
for colour in palette_a:
lab = rgb_to_lab(colour)
total += min(delta_e76(lab, other) for other in labs_b)
return total / len(palette_a)
# -------------------------------------------------------------------- report
def evaluate(render_path: str, blueprint_path: str, montage_path: str | None = None,
render_box: tuple[float, float, float, float] | None = None) -> dict:
render_full = Image.open(render_path).convert("RGB")
blueprint = Image.open(blueprint_path).convert("RGB")
# The figure box is explicit by default. Auto-segmentation on these shots failed
# in a way worth recording rather than papering over: the backdrop (sky band, pale
# wall, dark pavement) is more saturated and far larger than the subject, so every
# size-based rule picked a backdrop blob. A box read off the shot is honest and
# cheap; a background plate rendered without the character would be the proper fix.
width, height = render_full.size
box = render_box or (0.15, 0.02, 0.85, 0.99)
region = (int(width * box[0]), int(height * box[1]),
int(width * box[2]), int(height * box[3]))
render = render_full.crop(region)
render_mask = figure_mask_from_frame(render)
blueprint_mask = figure_mask_from_background(blueprint)
render_stats = mask_stats(render_mask)
blueprint_stats = mask_stats(blueprint_mask)
render_colours = masked_colours(render, render_mask)
blueprint_colours = masked_colours(blueprint, blueprint_mask)
def mean_lab(colours):
labs = [rgb_to_lab(c) for c in colours]
if not labs:
return None
return tuple(sum(l[i] for l in labs) / len(labs) for i in range(3))
lab_render = mean_lab(render_colours)
lab_blueprint = mean_lab(blueprint_colours)
mean_de = (delta_e76(lab_render, lab_blueprint)
if lab_render and lab_blueprint else float("nan"))
palette_render = dominant(render_colours)
palette_blueprint = dominant(blueprint_colours)
def delta(a, b):
if a is None or b is None:
return None
return abs(a - b)
report = {
"render": os.path.abspath(render_path),
"blueprint": os.path.abspath(blueprint_path),
"render_mask": "saturated-or-dark vs frame median, largest component",
"blueprint_mask": "differs from corner colour",
"render_region": list(region),
"render_figure": render_stats,
"blueprint_figure": blueprint_stats,
"aspect_delta": delta(render_stats["aspect"], blueprint_stats["aspect"]),
"fill_delta": delta(render_stats["fill"], blueprint_stats["fill"]),
"coverage_delta": delta(render_stats["coverage"], blueprint_stats["coverage"]),
"mean_lab_render": lab_render,
"mean_lab_blueprint": lab_blueprint,
"mean_delta_e": mean_de,
"palette_render": palette_render,
"palette_blueprint": palette_blueprint,
"palette_delta_e": palette_delta_e(palette_render, palette_blueprint),
}
if montage_path:
report["montage"] = write_montage(render, render_mask, blueprint, blueprint_mask,
montage_path)
return report
def write_montage(render, render_mask, blueprint, blueprint_mask, path, height=640):
"""Render crop and blueprint crop side by side, same height, for a human look."""
def crop(image, mask):
box = mask.getbbox()
return image.crop(box) if box else image
left = crop(render, render_mask)
right = crop(blueprint, blueprint_mask)
left = left.resize((max(1, int(left.width * height / max(1, left.height))), height))
right = right.resize((max(1, int(right.width * height / max(1, right.height))), height))
sheet = Image.new("RGB", (left.width + right.width + 12, height), (20, 20, 20))
sheet.paste(left, (0, 0))
sheet.paste(right, (left.width + 12, 0))
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
sheet.save(path)
return os.path.abspath(path)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--render", required=True)
ap.add_argument("--blueprint", required=True)
ap.add_argument("--out", default="/tmp/gigline/character-match.json")
ap.add_argument("--montage", default=None)
ap.add_argument("--render-box", default=None,
help="figure box as x0,y0,x1,y1 fractions of the frame (default: central 70 percent)")
args = ap.parse_args(argv)
for path in (args.render, args.blueprint):
if not os.path.exists(path):
print(f"error: no such image: {path}", file=sys.stderr)
return 2
box = None
if args.render_box:
box = tuple(float(v) for v in args.render_box.split(","))
if len(box) != 4:
print("error: --render-box needs four comma-separated fractions", file=sys.stderr)
return 2
report = evaluate(args.render, args.blueprint, args.montage, box)
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
with open(args.out, "w") as handle:
json.dump(report, handle, indent=2)
def show(value, digits=3):
return "n/a" if value is None or (isinstance(value, float) and math.isnan(value)) \
else f"{value:.{digits}f}"
print(f"render {report['render_figure']}")
print(f"blueprint {report['blueprint_figure']}")
print(f"aspect delta {show(report['aspect_delta'])}")
print(f"fill delta {show(report['fill_delta'])}")
print(f"coverage delta {show(report['coverage_delta'])}")
print(f"mean deltaE {show(report['mean_delta_e'], 1)}")
print(f"palette deltaE {show(report['palette_delta_e'], 1)}")
print(f"report -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Generate character blueprints with the local ComfyUI instance.
A "blueprint" here is an art-direction sheet for one character: a front, side and back
view, plus a head detail, generated from a fixed prompt and seed set so the sheet can
be regenerated and compared against later. It is the reference the modelled character
is evaluated against — see `Tools/character_match.py`.
python3 Tools/comfyui_character_blueprint.py \
--character "GigLine walker" \
--desc "lean adult pedestrian, short dark hair, olive skin, plain navy t-shirt, dark jeans, worn sneakers" \
--out Art/characters/blueprints
Writes one PNG per view plus `contact-sheet.png` and `blueprint.json`, which records
the prompt, model, seeds and the ComfyUI prompt ids. Everything is generated locally
by `Tools/comfyui_generate.py`; no network service and no credential is involved.
The sheet is generated art, not photography, and it stays out of the reference photo
folders on purpose: AGENTS.md forbids committing reference photography, and a
generated concept should not blur that line.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import comfyui_generate as comfy # noqa: E402
from PIL import Image # noqa: E402
VIEWS = (
# name, prompt suffix, seed
("front", "full body front view, neutral standing pose, arms at sides, "
"even studio light, plain light grey background, character sheet", 101),
("side", "full body side profile view, neutral standing pose, arms at sides, "
"even studio light, plain light grey background, character sheet", 202),
("back", "full body back view, neutral standing pose, arms at sides, "
"even studio light, plain light grey background, character sheet", 303),
("head", "head and shoulders portrait, front view, neutral expression, "
"even studio light, plain light grey background", 404),
)
NEGATIVE = ("multiple people, crowd, text, watermark, signature, blurry, low quality, "
"cropped limbs, extra limbs, deformed hands, cartoon, anime, logo")
def contact_sheet(paths, out_path, columns=4):
images = [Image.open(p).convert("RGB") for p in paths if os.path.exists(p)]
if not images:
return None
width = max(i.width for i in images)
height = max(i.height for i in images)
sheet = Image.new("RGB", (width * columns, height), (24, 24, 24))
for index, image in enumerate(images[:columns]):
sheet.paste(image.resize((width, height)), (index * width, 0))
sheet.save(out_path)
return out_path
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--character", default="GigLine walker")
ap.add_argument("--desc", required=True,
help="what the character wears and looks like; drives every view")
ap.add_argument("--out", default="Art/characters/blueprints")
ap.add_argument("--model", default=comfy.DEFAULT_MODEL)
ap.add_argument("--size", default="832x1216")
ap.add_argument("--steps", type=int, default=30)
ap.add_argument("--cfg", type=float, default=5.0)
args = ap.parse_args(argv)
if not os.path.isdir(comfy.HOST) and "://" not in comfy.HOST:
print(f"error: bad COMFYUI_HOST {comfy.HOST}", file=sys.stderr)
return 2
os.makedirs(args.out, exist_ok=True)
width, height = (int(v) for v in args.size.lower().split("x"))
record = {"character": args.character, "description": args.desc,
"model": args.model, "views": {}}
written = []
for name, suffix, seed in VIEWS:
prompt = f"{args.character}, {args.desc}, {suffix}"
out = os.path.join(args.out, f"{name}.png")
graph = comfy.workflow(prompt, NEGATIVE, args.model, width, height, seed,
args.steps, args.cfg, "dpmpp_2m", "karras")
queued = comfy.post("/prompt", {"prompt": graph})
prompt_id = queued.get("prompt_id")
if not prompt_id:
print(f"error: ComfyUI rejected the {name} prompt: {queued}", file=sys.stderr)
return 1
print(f"{name}: queued {prompt_id} (seed {seed})")
image = comfy.wait_for_image(prompt_id, timeout=600.0)
if image is None:
print(f"error: no image for {name}", file=sys.stderr)
return 1
with open(out, "wb") as handle:
handle.write(image)
record["views"][name] = {"file": out, "seed": seed, "prompt": prompt,
"negative": NEGATIVE, "prompt_id": prompt_id}
written.append(out)
sheet = contact_sheet(written, os.path.join(args.out, "contact-sheet.png"))
record["contact_sheet"] = sheet
record["note"] = ("generated locally by Tools/comfyui_character_blueprint.py; "
"record the model licence in THIRD_PARTY_NOTICES.md before shipping")
with open(os.path.join(args.out, "blueprint.json"), "w") as handle:
json.dump(record, handle, indent=2)
print(f"wrote {len(written)} views + contact sheet -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+53 -45
View File
@@ -89,6 +89,29 @@ def workflow(prompt: str, negative: str, model: str, width: int, height: int,
}
def wait_for_image(prompt_id: str, timeout: float = 300.0, poll: float = 1.5):
"""Poll /history until the job has an image, then fetch its bytes."""
deadline = time.time() + timeout
while time.time() < deadline:
history = api(f"/history/{prompt_id}")
entry = history.get(prompt_id)
if entry and entry.get("outputs"):
images = []
for node in entry["outputs"].values():
images.extend(node.get("images", []))
if not images:
return None
image = images[0]
query = urllib.parse.urlencode({
"filename": image["filename"],
"subfolder": image.get("subfolder", ""),
"type": image.get("type", "output")})
with urllib.request.urlopen(f"{HOST}/view?{query}", timeout=60) as response:
return response.read()
time.sleep(poll)
return None
def generate(args) -> int:
width, height = (int(v) for v in args.size.lower().split("x"))
seed = args.seed if args.seed is not None else random.randrange(0, 2**31)
@@ -101,51 +124,36 @@ def generate(args) -> int:
return 1
print(f"queued {prompt_id} ({args.model}, {width}x{height}, seed {seed})")
deadline = time.time() + args.timeout
while time.time() < deadline:
history = api(f"/history/{prompt_id}")
entry = history.get(prompt_id)
if entry and entry.get("outputs"):
images = []
for node in entry["outputs"].values():
images.extend(node.get("images", []))
if not images:
print("error: job finished with no image", file=sys.stderr)
return 1
image = images[0]
query = urllib.parse.urlencode({
"filename": image["filename"],
"subfolder": image.get("subfolder", ""),
"type": image.get("type", "output")})
with urllib.request.urlopen(f"{HOST}/view?{query}", timeout=60) as response:
data = response.read()
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "wb") as handle:
handle.write(data)
sidecar = os.path.splitext(args.out)[0] + ".json"
with open(sidecar, "w") as handle:
json.dump({
"tool": "Tools/comfyui_generate.py",
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"prompt": args.prompt,
"negative": args.negative,
"model": args.model,
"size": [width, height],
"seed": seed,
"steps": args.steps,
"cfg": args.cfg,
"sampler": args.sampler,
"scheduler": args.scheduler,
"workflow": graph,
"comfyui_prompt_id": prompt_id,
"note": "generated locally; record the model licence in "
"THIRD_PARTY_NOTICES.md before shipping",
}, handle, indent=2)
print(f"wrote {args.out} (+ sidecar)")
return 0
time.sleep(args.poll)
print("error: timed out waiting for the image", file=sys.stderr)
return 1
data = wait_for_image(prompt_id, timeout=args.timeout, poll=args.poll)
if data is None:
print("error: no image came back (timeout or empty job)", file=sys.stderr)
return 1
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
with open(args.out, "wb") as handle:
handle.write(data)
sidecar = os.path.splitext(args.out)[0] + ".json"
with open(sidecar, "w") as handle:
json.dump({
"tool": "Tools/comfyui_generate.py",
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"prompt": args.prompt,
"negative": args.negative,
"model": args.model,
"size": [width, height],
"seed": seed,
"steps": args.steps,
"cfg": args.cfg,
"sampler": args.sampler,
"scheduler": args.scheduler,
"workflow": graph,
"comfyui_prompt_id": prompt_id,
"note": "generated locally; record the model licence in "
"THIRD_PARTY_NOTICES.md before shipping",
}, handle, indent=2)
print(f"wrote {args.out} (+ sidecar)")
return 0
def main(argv=None) -> int:
+82
View File
@@ -0,0 +1,82 @@
"""Tests for the pure parts of Tools/character_match.py."""
import os
import sys
import unittest
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import character_match as cm # noqa: E402
class ColourTests(unittest.TestCase):
def test_white_is_light_and_neutral(self):
light, a, b = cm.rgb_to_lab((255, 255, 255))
self.assertAlmostEqual(light, 100.0, delta=0.2)
self.assertAlmostEqual(a, 0.0, delta=0.2)
self.assertAlmostEqual(b, 0.0, delta=0.2)
def test_black_is_dark(self):
self.assertAlmostEqual(cm.rgb_to_lab((0, 0, 0))[0], 0.0, delta=0.1)
def test_identical_colours_have_no_distance(self):
self.assertAlmostEqual(cm.delta_e76(cm.rgb_to_lab((120, 90, 70)),
cm.rgb_to_lab((120, 90, 70))), 0.0, delta=1e-9)
def test_red_green_are_far_apart(self):
distance = cm.delta_e76(cm.rgb_to_lab((200, 40, 40)), cm.rgb_to_lab((40, 160, 40)))
self.assertGreater(distance, 40.0)
class MaskTests(unittest.TestCase):
def _image(self, figure_colour, background_colour):
image = Image.new("RGB", (60, 100), background_colour)
pixels = image.load()
for y in range(20, 90):
for x in range(25, 35):
pixels[x, y] = figure_colour
return image
def test_saturated_figure_on_grey_is_found(self):
mask = cm._subject_mask(self._image((150, 90, 60), (190, 190, 190)))
box = cm.bbox(mask)
self.assertIsNotNone(box)
self.assertLess(box[0], 30)
self.assertGreater(box[2], 30)
def test_grey_frame_yields_empty_mask(self):
mask = cm._subject_mask(self._image((190, 190, 190), (190, 190, 190)))
self.assertIsNone(cm.bbox(mask))
def test_stats_report_figure_proportions(self):
mask = Image.new("L", (40, 80), 0)
pixels = mask.load()
for y in range(10, 70):
for x in range(15, 25):
pixels[x, y] = 255
stats = cm.mask_stats(mask)
self.assertEqual(stats["area"], 600)
self.assertAlmostEqual(stats["aspect"], 6.0, delta=0.01)
self.assertAlmostEqual(stats["fill"], 1.0, delta=0.01)
class PaletteTests(unittest.TestCase):
def test_dominant_colour_is_reported(self):
colours = [(200, 40, 40)] * 90 + [(20, 60, 200)] * 10
palette = cm.dominant(colours, count=2)
self.assertEqual(len(palette), 2)
red, _, _ = cm.rgb_to_lab(palette[0])
self.assertGreater(red, 30.0)
def test_palette_distance_is_zero_for_the_same_palette(self):
palette = cm.dominant([(200, 40, 40)] * 50)
self.assertAlmostEqual(cm.palette_delta_e(palette, palette), 0.0, delta=1e-6)
def test_empty_palette_is_not_a_number(self):
self.assertNotEqual(cm.palette_delta_e([], [(1, 2, 3)]),
cm.palette_delta_e([], [(1, 2, 3)]))
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -322,7 +322,7 @@ def build():
# 3. Ambience. A height fog gives the street depth and hides the terrain
# edge that otherwise reads as a black band at the horizon.
sun = spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 2000), "Demo_Sun",
unreal.Rotator(pitch=-62.0, yaw=120.0, roll=0.0))
unreal.Rotator(pitch=-74.0, yaw=120.0, roll=0.0))
if sun:
comp = sun.get_component_by_class(unreal.DirectionalLightComponent)
if comp:
@@ -341,7 +341,7 @@ def build():
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
except Exception as exc: # noqa: BLE001
log(f"sun mobility not set: {exc!r}")
log("staged: Demo_Sun (pitch -62, yaw 120, intensity 4, warm; higher sun so both frontages read)")
log("staged: Demo_Sun (pitch -74, yaw 120, intensity 4, warm; higher sun lifts the north sidewalk out of building shadow)")
sky = spawn(unreal.SkyLight, unreal.Vector(0, 0, 2000), "Demo_SkyLight")
if sky:
+71
View File
@@ -0,0 +1,71 @@
# Import the garment meshes as separate skeletal meshes (0.2.2).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/character_clothing_build.py -- --out /tmp/gigline/clothing
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_clothing_import.py -nullrhi -unattended -nopause -stdout
#
# Each garment is its own asset, per the character-artist skill's rule that clothing
# stays separate so it can be swapped or re-materialled. The pawn attaches each one as
# a component using the body as the leader pose.
#
# Result file: /tmp/gigline/character-clothing-import.txt
import unreal
DEST = "/Game/Test/Character"
SOURCE = "/tmp/gigline/clothing"
GARMENTS = (("tee", "CharacterTee"), ("jeans", "CharacterJeans"), ("shoes", "CharacterShoes"))
RESULT = "/tmp/gigline/character-clothing-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
for source, name in GARMENTS:
task = unreal.AssetImportTask()
task.filename = f"{SOURCE}/{source}.fbx"
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = False
ui.import_materials = True
ui.import_textures = False
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if mesh is None:
log(f"{name}: MISSING after import")
lines.append("VERDICT: FAIL")
return
mats = mesh.get_editor_property("materials")
log(f"{name}: imported, {len(mats)} slot(s): "
+ ", ".join((m.get_editor_property("material_interface").get_name()
if m.get_editor_property("material_interface") else "None")
for m in mats))
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+70
View File
@@ -0,0 +1,70 @@
# Import the hair cards as their own skeletal mesh (0.2.2).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/character_hair_build.py -- --out /tmp/gigline/character-hair.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_hair_import.py -nullrhi -unattended -nopause -stdout
#
# The hair is a separate asset on purpose: Unreal collapses the second material slot
# when hair is joined into the body mesh, so a card system has to be its own object
# (which is what the character-artist skill asks for anyway).
#
# Result file: /tmp/gigline/character-hair-import.txt
import unreal
SRC = "/tmp/gigline/character-hair.fbx"
DEST = "/Game/Test/Character"
NAME = "CharacterHair"
RESULT = "/tmp/gigline/character-hair-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
task = unreal.AssetImportTask()
task.filename = SRC
task.destination_path = DEST
task.destination_name = NAME
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = False
ui.import_materials = True
ui.import_textures = False
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{NAME}")
if mesh is None:
log(f"MISSING {DEST}/{NAME}")
lines.append("VERDICT: FAIL")
return
mats = mesh.get_editor_property("materials")
log(f"{NAME}: {len(mats)} material slot(s)")
for i, m in enumerate(mats):
face = m.get_editor_property("material_interface")
log(f" slot {i}: {face.get_name() if face else 'None'}")
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+164
View File
@@ -0,0 +1,164 @@
# Wire the generated character textures into Unreal materials (0.2.2).
#
# python3 Tools/generate_character_textures.py --out Art/textures/generated
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_materials.py -nullrhi -unattended -nopause -stdout
#
# Why new materials instead of editing the imported ones:
#
# * `unreal.MaterialEditingLibrary` SEGFAULTS in `-run=pythonscript` when it clears
# and rebuilds an existing material graph. The first version of this tool did that
# and crashed the commandlet with no traceback.
# * `unreal.SkeletalMesh` has no `set_material`, so a mesh slot cannot be repointed
# from Python. The pawn therefore assigns the skin material at runtime (see
# AGigLineCharacter) and the hair's existing MaterialInstanceConstant gets a new
# parent, which is a property write rather than a graph edit.
#
# Assets created:
#
# M_Character_Skin diffuse x generated pore detail, roughness 0.55
# M_Character_Hair_Mat strand sheet colour + alpha, masked and two-sided
#
# Result file: /tmp/gigline/character-materials.txt
import os
import unreal
DEST = "/Game/Test/Character"
_REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", ".."))
TEXTURE_DIR = os.path.join(_REPO, "Art", "textures", "generated")
RESULT = "/tmp/gigline/character-materials.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def import_texture(filename):
path = os.path.join(TEXTURE_DIR, filename)
name = os.path.splitext(filename)[0]
existing = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if not os.path.exists(path):
log(f"missing {path}")
return None
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
texture = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
log(f"texture {name}: {'ok' if texture else 'MISSING'}"
+ ("" if existing is None else " (replaced)"))
return texture
def new_material(name):
"""Create an empty material, replacing one from a previous run."""
full = f"{DEST}/{name}"
if unreal.EditorAssetLibrary.does_asset_exist(full):
unreal.EditorAssetLibrary.delete_asset(full)
tools = unreal.AssetToolsHelpers.get_asset_tools()
material = tools.create_asset(name, DEST, unreal.Material, unreal.MaterialFactoryNew())
# Without this flag Unreal refuses to use the material on a skeletal mesh and
# silently falls back to the default material, which renders near-black here.
material.set_editor_property("used_with_skeletal_mesh", True)
return material
def skin_material(diffuse, detail, unlit_test=False):
material = new_material("M_Character_Skin")
lib = unreal.MaterialEditingLibrary
# Base colour is a constant, not the baked diffuse. Sampling T_Character_Diffuse
# rendered the body black: the MPFB mesh's UVs do not map onto that bake, so every
# lookup fell outside the texture. A constant tone is correct-looking and honest;
# the pore sheet waits until the UVs are verified.
base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -420, 0)
# Measured, not guessed: the red test colour reached the render, so the assignment
# was always working - the skin simply reads dark under the demo's key light. This
# tone is lifted to sit in shadow the way the blueprint's skin does.
base.set_editor_property("constant", unreal.LinearColor(0.78, 0.55, 0.44, 1.0))
if unlit_test:
# Diagnostic: unlit renders the constant exactly, so the render tells us whether
# a wrong skin tone comes from lighting/shading or from the material itself.
material.set_editor_property("shading_model", unreal.MaterialShadingModel.MSM_UNLIT)
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_EMISSIVE_COLOR)
else:
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR)
rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -200, -200)
rough.set_editor_property("r", 0.58)
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
lib.recompile_material(material)
log(("M_Character_Skin: UNLIT TEST, emissive " if unlit_test else
"M_Character_Skin: constant skin tone, roughness 0.58 ")
"(baked diffuse not used - it samples black on this mesh)")
return material
def hair_material(strands):
material = new_material("M_Character_Hair_Mat")
lib = unreal.MaterialEditingLibrary
material.set_editor_property("blend_mode", unreal.BlendMode.BLEND_MASKED)
material.set_editor_property("two_sided", True)
sample = lib.create_material_expression(material, unreal.MaterialExpressionTextureSample, -520, 0)
sample.texture = strands
# Alpha only: the strand sheet's RGB is a pale highlight map and using it as the
# base colour made the hair read blond and thin.
lib.connect_material_property(sample, "A", unreal.MaterialProperty.MP_OPACITY_MASK)
colour = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -520, 220)
colour.set_editor_property("constant", unreal.LinearColor(0.030, 0.020, 0.014, 1.0))
lib.connect_material_property(colour, "", unreal.MaterialProperty.MP_BASE_COLOR)
rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -220, -180)
rough.set_editor_property("r", 0.40)
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
lib.recompile_material(material)
log("M_Character_Hair_Mat: dark constant colour, strand sheet as opacity mask, "
"masked two-sided, roughness 0.40")
return material
def main(unlit_test=False):
diffuse = unreal.EditorAssetLibrary.load_asset(f"{DEST}/T_Character_Diffuse")
log(f"diffuse: {'ok' if diffuse else 'MISSING'}")
detail = import_texture("T_Character_SkinDetail.png")
strands = import_texture("T_Character_Hair.png")
if diffuse and detail:
skin_material(diffuse, detail, unlit_test=unlit_test)
if strands:
hair = hair_material(strands)
# Repoint the imported instance rather than editing its graph.
instance = unreal.EditorAssetLibrary.load_asset(f"{DEST}/M_Character_Hair")
if isinstance(instance, unreal.MaterialInstanceConstant):
instance.set_editor_property("parent", hair)
unreal.MaterialEditingLibrary.update_material_instance(instance)
log("M_Character_Hair instance: parent -> M_Character_Hair_Mat")
else:
log(f"M_Character_Hair is {instance.get_class().get_name() if instance else 'missing'}, "
"not an instance - skipped")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
lines.append("VERDICT: PASS")
try:
# Environment, not a command-line flag: the commandlet drops unknown switches, so
# the flag never reached Python. An env var is inherited.
main(unlit_test=os.environ.get("GIGLINE_UNLIT_TEST") == "1")
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")