Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82cafc2f29 | ||
|
|
3e4dbd1014 | ||
|
|
a6701aa3d1 | ||
|
|
d8b44f1e57 | ||
|
|
6c4e0701fb | ||
|
|
0e7a6e898b | ||
|
|
2f6d06f897 | ||
|
|
5de4183456 | ||
|
|
bea6aa3e05 | ||
|
|
7c57bf318b | ||
|
|
878d5a1f4e | ||
|
|
86e9c5b693 | ||
|
|
85a8437cbc | ||
|
|
1fc5835327 | ||
|
|
39f07b0f5f | ||
|
|
72d00bdfbd | ||
|
|
54c579ab1a | ||
|
|
89a9c59747 | ||
|
|
4b9ad22108 | ||
|
|
0d57b718cf | ||
|
|
e9f31f271e | ||
|
|
03af8146a6 | ||
|
|
30e4d65743 | ||
|
|
bc2e9c3b8e |
No files matched your search
Binary file not shown.
@@ -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.
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"tool": "Tools/generate_character_textures.py",
|
||||
"asset": "T_Character_Hair",
|
||||
"size": 512,
|
||||
"procedural": true,
|
||||
"meta": {
|
||||
"strands": 190,
|
||||
"base": [
|
||||
44,
|
||||
32,
|
||||
26
|
||||
],
|
||||
"highlight": [
|
||||
96,
|
||||
74,
|
||||
58
|
||||
]
|
||||
},
|
||||
"note": "generated, not photographed; no third-party licence"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"tool": "Tools/generate_character_textures.py",
|
||||
"asset": "T_Character_SkinDetail",
|
||||
"size": 512,
|
||||
"procedural": true,
|
||||
"meta": {
|
||||
"octaves": {
|
||||
"fine": 96,
|
||||
"mid": 24,
|
||||
"broad": 7
|
||||
},
|
||||
"detail": 9,
|
||||
"tint": [
|
||||
222,
|
||||
178,
|
||||
152
|
||||
]
|
||||
},
|
||||
"note": "generated, not photographed; no third-party licence"
|
||||
}
|
||||
Binary file not shown.
@@ -8,6 +8,84 @@ Format: [Keep a Changelog](https://keepachangelog.com/) style, versions follow [
|
||||
|
||||
Nothing yet.
|
||||
|
||||
## [0.2.3] — 2026-11-09
|
||||
|
||||
Camera collision worked in the code and never in the game.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The third-person boom never shortened.** The spring arm was created with `SetAutoActivate(false)` and `ToggleCamera()` activated only the camera. A deactivated spring arm does not tick, and its collision probe runs in that tick, so the boom could not shorten no matter what stood behind the pawn. Both `ToggleCamera` and `BeginPlay` now activate the arm with the rig.
|
||||
|
||||
### Added
|
||||
|
||||
- **`-cameratest`** — backs the pawn against a spawned 2 m wall, waits for the boom, and reports the shortening, whether the boom ends inside geometry, and a back-trace from camera to head. It writes `review-demo-camera-collision.png` so the placement can be checked by eye.
|
||||
|
||||
### Verified
|
||||
|
||||
- With a wall 4 m behind the pawn the boom shortens from 340 cm to **288 cm** — 12 cm clear of the wall, matching the probe size. Recorded as a release check.
|
||||
- 8 review shots verify; 95 tool tests pass.
|
||||
|
||||
### Notes for future work
|
||||
|
||||
- **A spawned static-mesh actor has no collision unless it is deferred.** Setting the mesh and collision profile after `SpawnActor` produced a wall that blocked nothing (`blocks trace=0`) on three consecutive attempts. `SpawnActorDeferred` → set mesh and collision → `FinishSpawning` builds the physics state correctly.
|
||||
- **`TargetArmLength` is not rewritten when the boom shortens.** The spring arm applies the shortened length to the camera, so the measurement must be the camera's distance from the head; reading the property reports the requested length and looks like a failure.
|
||||
|
||||
## [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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The fingers splayed.** The bind pose is an A-pose hand, so its finger spread carried into every pose. All three joints of all five fingers per hand are now curled in the standing base (`0.28 / 0.34 / 0.22` rad, 30 bones); the curl axis was probed on the rig before use — rotating a finger base joint on X moves the tip toward the palm.
|
||||
- **The arms-down rotation was hard-coded.** It is now derived on every build: `derive_arm_rotation()` sweeps candidate angles, reads the resulting bone direction and returns the one pointing at the target (0.60 rad, direction dot 1.000 in this build). The old 0.55 was a hand-swept constant that would have rotted on any rig change.
|
||||
- **Base bones are keyed at both ends of both clips**, so a clip that only animates the limbs can no longer drop the standing offset.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **The stride capture regressed.** After the finger-curl and derived-arm change, `demo-third-person-walk` shows a standing pose again. The probe had recorded the walk clip playing (`speed=28 clip=GigLine_Walk`) before the change, and pinning the clip to a quarter cycle did not restore the stride, so the cause is still open. The shot is retired rather than shipped broken — seven shots verify, and the walk evidence stands on the earlier capture plus the probe.
|
||||
- Camera collision remains implemented but unexercised against a wall.
|
||||
|
||||
## [0.2.0] — 2026-11-09
|
||||
|
||||
Third-person body. The pawn gains a visible, animated body and a third-person camera, so the walk cycle is finally something a player can see rather than a clip in an asset list.
|
||||
|
||||
@@ -20,12 +20,12 @@ C++ classes in `Game/GigLine/Source/GigLine/`:
|
||||
| --- | --- | --- |
|
||||
| `AGigLineCharacter` | First-person player pawn | 1.75 m capsule, 4.5 m/s walk, Enhanced Input bindings, reset, pause, `-qaselftest` harness |
|
||||
| `AGigLineGameModeBase` | Sets game mode and HUD class | `HUDClass = AGigLineHUD` |
|
||||
| `AGigLineHUD` | HUD text overlay | `DrawText` every frame, bottom-left |
|
||||
| `AGigLineHUD` | HUD text overlay | `DrawText` every frame: ODbL attribution bottom-left, controls bottom-right, and an F3 debug readout top-left (`gigline.Debug`, on for `-perf`/`-qaselftest`) |
|
||||
| `AGigLineNPCPerson` | NPC prefab (future) | Capsule + visible MPFB body, feet on Z 0 |
|
||||
| `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits |
|
||||
| `UGigLineAssetTools` | C++ collision helpers | `SetComplexCollisionAsSimple` on `StaticMesh` assets |
|
||||
|
||||
Input assets in `/Game/Input/`: `IA_Move`, `IA_MoveBack`, `IA_Strafe`, `IA_StrafeLeft`, `IA_Look`, `IA_Jump`, `IA_Reset`, `IA_Pause`, `IA_ToggleCamera`, bound in `IMC_Default`.
|
||||
Input assets in `/Game/Input/`: `IA_Move`, `IA_MoveBack`, `IA_Strafe`, `IA_StrafeLeft`, `IA_Look`, `IA_Jump`, `IA_Reset`, `IA_Pause`, `IA_ToggleCamera`, `IA_ToggleDebug`, bound in `IMC_Default`.
|
||||
|
||||
Player-facing bindings: **WASD** move, **mouse** look, **Space** jump, **R** reset, **Escape** pause, **V** swap first/third person. The camera and body work landed with `0.2.0` (see ADR-9): the shipped default view stays first person, and `-thirdperson` starts the pawn in the third-person rig — used by the screenshot run so the review is reproducible.
|
||||
|
||||
|
||||
@@ -164,12 +164,12 @@ C++ classes in `Game/GigLine/Source/GigLine/`:
|
||||
| --- | --- | --- |
|
||||
| `AGigLineCharacter` | First-person player pawn | 1.75 m capsule, 4.5 m/s walk, Enhanced Input bindings, reset, pause, `-qaselftest` harness |
|
||||
| `AGigLineGameModeBase` | Sets game mode and HUD class | `HUDClass = AGigLineHUD` |
|
||||
| `AGigLineHUD` | HUD text overlay | `DrawText` every frame, bottom-left |
|
||||
| `AGigLineHUD` | HUD text overlay | `DrawText` every frame: ODbL attribution bottom-left, controls bottom-right, and an F3 debug readout top-left (`gigline.Debug`, on for `-perf`/`-qaselftest`) |
|
||||
| `AGigLineNPCPerson` | NPC prefab (future) | Capsule + visible MPFB body, feet on Z 0 |
|
||||
| `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits |
|
||||
| `UGigLineAssetTools` | C++ collision helpers | `SetComplexCollisionAsSimple` on `StaticMesh` assets |
|
||||
|
||||
Input assets in `/Game/Input/`: `IA_Move`, `IA_MoveBack`, `IA_Strafe`, `IA_StrafeLeft`, `IA_Look`, `IA_Jump`, `IA_Reset`, `IA_Pause`, `IA_ToggleCamera`, bound in `IMC_Default`.
|
||||
Input assets in `/Game/Input/`: `IA_Move`, `IA_MoveBack`, `IA_Strafe`, `IA_StrafeLeft`, `IA_Look`, `IA_Jump`, `IA_Reset`, `IA_Pause`, `IA_ToggleCamera`, `IA_ToggleDebug`, bound in `IMC_Default`.
|
||||
|
||||
Player-facing bindings: **WASD** move, **mouse** look, **Space** jump, **R** reset, **Escape** pause, **V** swap first/third person. The camera and body work landed with `0.2.0` (see ADR-9): the shipped default view stays first person, and `-thirdperson` starts the pawn in the third-person rig — used by the screenshot run so the review is reproducible.
|
||||
|
||||
|
||||
+11
-1
@@ -13,6 +13,7 @@ Keyboard and mouse. The same list is shown in-game, bottom-right of the frame (`
|
||||
| Reset to start | `R` | Returns to the grounded spawn point |
|
||||
| Pause / release mouse | `Escape` | Second press resumes and recaptures the mouse |
|
||||
| Swap first / third person | `V` | First person is the default; the body is hidden in that view |
|
||||
| Show / hide debug overlay | `F3` | FPS, frame time, worst frame, view mode, speed and player position |
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -24,6 +25,14 @@ Keyboard and mouse. The same list is shown in-game, bottom-right of the frame (`
|
||||
|
||||
- **`V` swaps first and third person** (`0.2.0`, `IA_ToggleCamera`). The shipped default view is still first person, as the owner decided for `0.1.0`; third person adds a collision-tested spring-arm camera and a visible, animated body. `Escape` still pauses and releases the mouse in either view.
|
||||
|
||||
## Debug overlay
|
||||
|
||||
`F3` toggles a top-left readout: smoothed **FPS** and frame time, the **worst** frame in a rolling two-second window, the active view (first or third person), player speed, a frame counter, and the player's position in metres from the project origin. The first two seconds of a session are a warm-up window and are excluded from the worst-frame figure, because shader and streaming hitches there made a healthy run read as a 400 ms stall.
|
||||
|
||||
It is **off in the built demo** — a player should not see an FPS counter over Hollywood Boulevard — and **on automatically** when the process is started with `-perf` or `-qaselftest`, so a performance capture never depends on someone pressing a key first. In a running build it can also be forced with the console variable `gigline.Debug 1`.
|
||||
|
||||
Cost: about **5 %** of frame time at 1440p in the demo (2.64 → 2.77 ms average), measured in the same self-test route. The performance release check is recorded without the overlay for that reason; the overlay run is kept beside it as `Docs/perf/0.2.0-walk-frametimes-overlay.csv`.
|
||||
|
||||
## Review and harness switches
|
||||
|
||||
These are command-line switches for the screenshot pipeline and QA runs, not player controls. They exist so a capture is reproducible rather than dependent on a human pressing keys at the right moment.
|
||||
@@ -32,7 +41,8 @@ These are command-line switches for the screenshot pipeline and QA runs, not pla
|
||||
| --- | --- |
|
||||
| `-thirdperson` | Starts the pawn in the third-person rig, so a capture shows the body without anyone pressing `V` |
|
||||
| `-walk` | Holds forward input from the first frame — what makes the stride shot possible |
|
||||
| `-qaselftest` | Runs the scripted walk / reset / pause / transect checks and quits |
|
||||
| `-qaselftest` | Runs the scripted walk / reset / pause / transect checks and quits; also enables the debug overlay |
|
||||
| `-perf` | Enables the debug overlay for a manual frame-time check |
|
||||
|
||||
The walking shot is driven per shot rather than by the switch: the shot actor enables the walk only while a tracking (`ShotFollow`) camera is being captured, and returns the pawn to spawn afterwards, so the fixed-camera shots still find it in place.
|
||||
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
frame,ms
|
||||
0,2.607
|
||||
1,2.532
|
||||
2,2.324
|
||||
3,3.432
|
||||
4,2.808
|
||||
5,2.870
|
||||
6,2.367
|
||||
7,2.311
|
||||
8,2.756
|
||||
9,3.278
|
||||
10,2.418
|
||||
11,2.665
|
||||
12,2.438
|
||||
13,2.646
|
||||
14,2.461
|
||||
15,2.941
|
||||
16,2.975
|
||||
17,3.164
|
||||
18,2.766
|
||||
19,2.341
|
||||
20,2.543
|
||||
21,3.395
|
||||
22,2.772
|
||||
23,2.575
|
||||
24,2.737
|
||||
25,2.427
|
||||
26,2.538
|
||||
27,2.708
|
||||
28,3.183
|
||||
29,2.734
|
||||
30,2.425
|
||||
31,2.546
|
||||
32,2.600
|
||||
33,2.545
|
||||
34,3.705
|
||||
35,2.563
|
||||
36,2.907
|
||||
37,2.368
|
||||
38,2.470
|
||||
39,2.411
|
||||
40,3.778
|
||||
41,3.735
|
||||
42,2.292
|
||||
43,2.458
|
||||
44,2.566
|
||||
45,3.044
|
||||
46,3.449
|
||||
47,2.914
|
||||
48,3.243
|
||||
49,2.084
|
||||
50,3.872
|
||||
51,1.633
|
||||
52,3.389
|
||||
53,2.448
|
||||
54,2.474
|
||||
55,2.509
|
||||
56,2.346
|
||||
57,3.192
|
||||
58,2.762
|
||||
59,2.788
|
||||
60,2.538
|
||||
61,2.520
|
||||
62,2.517
|
||||
63,3.036
|
||||
64,3.205
|
||||
65,3.080
|
||||
66,2.514
|
||||
67,2.429
|
||||
68,2.515
|
||||
69,2.963
|
||||
70,3.555
|
||||
71,2.430
|
||||
72,2.742
|
||||
73,2.435
|
||||
74,2.526
|
||||
75,3.304
|
||||
76,3.511
|
||||
77,2.477
|
||||
78,2.472
|
||||
79,2.452
|
||||
80,2.421
|
||||
81,3.334
|
||||
82,3.286
|
||||
83,2.725
|
||||
84,2.411
|
||||
85,2.597
|
||||
86,2.440
|
||||
87,3.213
|
||||
88,3.411
|
||||
89,3.047
|
||||
90,2.365
|
||||
91,2.530
|
||||
92,2.877
|
||||
93,3.249
|
||||
94,3.186
|
||||
95,2.684
|
||||
96,2.304
|
||||
97,2.406
|
||||
98,2.948
|
||||
99,3.652
|
||||
100,2.822
|
||||
101,2.552
|
||||
102,2.575
|
||||
103,2.354
|
||||
104,2.676
|
||||
105,2.775
|
||||
106,3.865
|
||||
107,2.480
|
||||
108,2.501
|
||||
109,2.675
|
||||
110,2.724
|
||||
111,3.671
|
||||
112,2.828
|
||||
113,3.064
|
||||
114,2.153
|
||||
115,2.752
|
||||
116,2.936
|
||||
117,3.008
|
||||
118,2.932
|
||||
119,2.648
|
||||
120,2.441
|
||||
121,3.471
|
||||
122,1.885
|
||||
123,4.435
|
||||
124,2.504
|
||||
125,3.241
|
||||
126,2.813
|
||||
127,2.445
|
||||
128,3.321
|
||||
129,3.055
|
||||
130,3.222
|
||||
131,2.766
|
||||
132,2.582
|
||||
133,2.597
|
||||
134,3.637
|
||||
135,3.150
|
||||
136,3.163
|
||||
137,2.675
|
||||
138,2.708
|
||||
139,2.581
|
||||
140,3.377
|
||||
141,3.518
|
||||
142,2.565
|
||||
143,2.385
|
||||
144,2.360
|
||||
145,2.768
|
||||
146,3.172
|
||||
147,2.754
|
||||
148,2.758
|
||||
149,2.474
|
||||
150,2.995
|
||||
151,2.903
|
||||
152,3.398
|
||||
153,2.484
|
||||
154,2.894
|
||||
155,3.022
|
||||
156,2.413
|
||||
157,3.517
|
||||
158,3.370
|
||||
159,2.854
|
||||
160,2.485
|
||||
161,3.058
|
||||
162,2.473
|
||||
163,3.390
|
||||
164,3.023
|
||||
165,2.543
|
||||
166,2.629
|
||||
167,2.929
|
||||
168,2.928
|
||||
169,3.268
|
||||
170,2.622
|
||||
171,2.764
|
||||
172,2.920
|
||||
173,2.681
|
||||
174,2.551
|
||||
175,3.492
|
||||
176,2.534
|
||||
177,2.741
|
||||
178,2.949
|
||||
179,2.554
|
||||
180,2.596
|
||||
181,3.522
|
||||
182,3.207
|
||||
183,2.722
|
||||
184,2.945
|
||||
185,2.474
|
||||
186,2.934
|
||||
187,3.447
|
||||
188,2.515
|
||||
189,2.317
|
||||
190,3.059
|
||||
191,2.542
|
||||
192,3.417
|
||||
193,2.737
|
||||
194,2.456
|
||||
195,3.025
|
||||
196,2.640
|
||||
197,2.601
|
||||
198,3.567
|
||||
199,2.350
|
||||
200,2.581
|
||||
201,3.079
|
||||
202,2.514
|
||||
203,2.462
|
||||
204,3.725
|
||||
205,3.730
|
||||
206,2.670
|
||||
207,2.308
|
||||
208,2.689
|
||||
209,2.565
|
||||
210,3.479
|
||||
211,2.604
|
||||
212,3.339
|
||||
213,2.373
|
||||
214,2.678
|
||||
215,3.340
|
||||
216,2.867
|
||||
217,2.751
|
||||
218,2.747
|
||||
219,2.410
|
||||
220,2.485
|
||||
221,2.594
|
||||
222,3.602
|
||||
223,2.689
|
||||
224,2.565
|
||||
225,2.397
|
||||
226,2.562
|
||||
227,2.598
|
||||
228,3.403
|
||||
229,3.303
|
||||
230,2.775
|
||||
231,2.101
|
||||
232,2.499
|
||||
233,2.991
|
||||
234,3.059
|
||||
235,3.210
|
||||
236,2.568
|
||||
237,2.066
|
||||
238,2.498
|
||||
239,2.572
|
||||
240,3.281
|
||||
241,2.676
|
||||
242,2.640
|
||||
243,2.867
|
||||
244,2.418
|
||||
245,2.493
|
||||
246,3.126
|
||||
247,2.845
|
||||
248,2.522
|
||||
249,2.459
|
||||
250,2.524
|
||||
251,2.466
|
||||
252,3.060
|
||||
253,3.848
|
||||
254,2.516
|
||||
255,2.460
|
||||
256,2.499
|
||||
257,2.427
|
||||
258,3.463
|
||||
259,2.761
|
||||
260,2.674
|
||||
261,2.450
|
||||
262,2.491
|
||||
263,2.543
|
||||
264,2.732
|
||||
265,3.679
|
||||
266,2.325
|
||||
267,2.529
|
||||
268,2.344
|
||||
269,2.534
|
||||
270,2.691
|
||||
271,3.176
|
||||
272,2.492
|
||||
273,2.721
|
||||
274,2.587
|
||||
275,2.594
|
||||
276,2.326
|
||||
277,3.434
|
||||
278,3.514
|
||||
279,2.415
|
||||
280,2.481
|
||||
281,2.478
|
||||
282,2.555
|
||||
283,3.303
|
||||
284,3.098
|
||||
285,2.451
|
||||
286,2.552
|
||||
287,2.413
|
||||
288,2.613
|
||||
289,3.194
|
||||
290,3.191
|
||||
291,2.338
|
||||
292,2.561
|
||||
293,2.785
|
||||
294,2.240
|
||||
295,3.324
|
||||
296,2.767
|
||||
297,2.589
|
||||
298,2.608
|
||||
299,2.432
|
||||
300,2.444
|
||||
301,2.968
|
||||
302,3.792
|
||||
303,2.590
|
||||
304,2.335
|
||||
305,2.520
|
||||
306,2.435
|
||||
307,3.613
|
||||
308,2.681
|
||||
309,2.692
|
||||
310,2.329
|
||||
311,2.580
|
||||
312,2.355
|
||||
313,3.084
|
||||
314,3.026
|
||||
315,2.551
|
||||
316,2.490
|
||||
317,2.481
|
||||
318,2.553
|
||||
319,2.619
|
||||
320,3.350
|
||||
321,2.885
|
||||
322,2.576
|
||||
323,2.456
|
||||
324,2.441
|
||||
325,3.223
|
||||
326,3.031
|
||||
327,3.055
|
||||
328,2.592
|
||||
329,2.499
|
||||
330,2.354
|
||||
331,2.573
|
||||
332,3.323
|
||||
333,3.357
|
||||
334,2.241
|
||||
335,2.319
|
||||
336,2.472
|
||||
337,2.609
|
||||
338,3.565
|
||||
339,2.607
|
||||
340,2.539
|
||||
341,2.440
|
||||
342,2.483
|
||||
343,2.626
|
||||
344,3.407
|
||||
345,2.772
|
||||
346,2.491
|
||||
347,2.456
|
||||
348,2.553
|
||||
349,2.773
|
||||
350,3.239
|
||||
351,4.079
|
||||
352,2.346
|
||||
353,2.177
|
||||
354,2.562
|
||||
355,2.350
|
||||
356,3.329
|
||||
357,3.383
|
||||
358,2.441
|
||||
359,2.399
|
||||
360,2.530
|
||||
361,2.455
|
||||
362,3.219
|
||||
363,2.879
|
||||
364,2.726
|
||||
365,2.418
|
||||
366,2.511
|
||||
367,2.544
|
||||
368,3.077
|
||||
369,2.926
|
||||
370,2.571
|
||||
371,2.595
|
||||
372,2.534
|
||||
373,2.367
|
||||
374,2.671
|
||||
375,4.222
|
||||
376,2.411
|
||||
377,2.569
|
||||
378,2.493
|
||||
379,2.464
|
||||
380,2.691
|
||||
381,3.474
|
||||
382,2.697
|
||||
383,2.541
|
||||
384,2.494
|
||||
385,2.476
|
||||
386,2.473
|
||||
387,3.464
|
||||
388,3.084
|
||||
389,2.144
|
||||
390,2.503
|
||||
391,2.501
|
||||
392,2.525
|
||||
393,3.335
|
||||
394,2.919
|
||||
395,2.334
|
||||
396,2.557
|
||||
397,2.579
|
||||
398,2.567
|
||||
399,4.120
|
||||
400,3.297
|
||||
401,2.561
|
||||
402,2.428
|
||||
403,2.495
|
||||
404,2.729
|
||||
405,3.752
|
||||
406,2.783
|
||||
407,2.509
|
||||
408,2.337
|
||||
409,2.589
|
||||
410,2.556
|
||||
411,3.449
|
||||
412,2.702
|
||||
413,2.742
|
||||
414,2.384
|
||||
415,2.759
|
||||
416,2.290
|
||||
417,3.374
|
||||
418,2.934
|
||||
419,2.546
|
||||
420,2.228
|
||||
421,2.672
|
||||
422,2.369
|
||||
423,3.442
|
||||
424,3.484
|
||||
425,2.621
|
||||
426,2.279
|
||||
427,2.529
|
||||
428,2.889
|
||||
429,2.910
|
||||
430,2.981
|
||||
431,2.957
|
||||
432,2.420
|
||||
433,2.398
|
||||
434,2.310
|
||||
435,3.662
|
||||
436,2.422
|
||||
437,2.676
|
||||
438,2.557
|
||||
439,2.527
|
||||
440,2.564
|
||||
441,2.649
|
||||
442,3.168
|
||||
443,2.499
|
||||
444,2.737
|
||||
445,2.637
|
||||
446,2.509
|
||||
447,2.716
|
||||
448,3.845
|
||||
449,2.654
|
||||
450,2.536
|
||||
451,2.366
|
||||
452,2.526
|
||||
453,2.906
|
||||
454,3.101
|
||||
455,2.809
|
||||
456,2.466
|
||||
457,2.502
|
||||
458,2.767
|
||||
459,2.486
|
||||
460,3.137
|
||||
461,3.402
|
||||
462,2.187
|
||||
463,2.398
|
||||
464,2.392
|
||||
465,2.614
|
||||
466,3.371
|
||||
467,2.847
|
||||
468,2.463
|
||||
469,2.639
|
||||
470,2.288
|
||||
471,2.562
|
||||
472,3.381
|
||||
473,3.491
|
||||
474,2.521
|
||||
475,2.397
|
||||
476,2.431
|
||||
477,2.759
|
||||
478,3.222
|
||||
479,2.957
|
||||
480,2.470
|
||||
481,2.403
|
||||
482,2.560
|
||||
483,2.470
|
||||
484,3.451
|
||||
485,2.905
|
||||
486,2.583
|
||||
487,2.329
|
||||
488,2.798
|
||||
489,2.289
|
||||
490,2.882
|
||||
491,2.903
|
||||
492,2.627
|
||||
493,2.897
|
||||
494,2.321
|
||||
495,2.472
|
||||
496,2.882
|
||||
497,3.844
|
||||
498,3.096
|
||||
499,2.525
|
||||
500,2.250
|
||||
501,2.477
|
||||
502,2.728
|
||||
503,3.391
|
||||
504,2.539
|
||||
505,2.635
|
||||
506,2.542
|
||||
507,2.435
|
||||
508,3.011
|
||||
509,3.042
|
||||
510,2.965
|
||||
511,2.273
|
||||
512,2.433
|
||||
513,3.020
|
||||
514,2.776
|
||||
515,3.646
|
||||
516,2.856
|
||||
517,2.886
|
||||
518,2.522
|
||||
519,2.528
|
||||
520,4.396
|
||||
521,3.397
|
||||
522,2.218
|
||||
523,2.433
|
||||
524,2.605
|
||||
525,3.504
|
||||
526,3.087
|
||||
527,3.186
|
||||
528,2.343
|
||||
529,2.433
|
||||
530,2.900
|
||||
531,3.000
|
||||
532,3.511
|
||||
533,2.663
|
||||
534,2.699
|
||||
535,2.423
|
||||
536,3.136
|
||||
537,2.802
|
||||
538,3.151
|
||||
539,2.507
|
||||
540,2.973
|
||||
541,2.325
|
||||
542,3.322
|
||||
543,3.260
|
||||
544,3.525
|
||||
545,2.846
|
||||
546,2.125
|
||||
547,2.697
|
||||
548,2.829
|
||||
549,3.389
|
||||
550,2.758
|
||||
551,2.866
|
||||
552,2.338
|
||||
553,3.115
|
||||
554,2.500
|
||||
555,3.387
|
||||
556,2.851
|
||||
557,2.511
|
||||
558,2.459
|
||||
559,3.107
|
||||
560,2.521
|
||||
561,3.308
|
||||
562,2.782
|
||||
563,2.810
|
||||
564,2.323
|
||||
565,3.259
|
||||
566,2.366
|
||||
567,3.542
|
||||
568,2.929
|
||||
569,2.299
|
||||
570,2.336
|
||||
571,3.027
|
||||
572,2.540
|
||||
573,3.409
|
||||
574,2.784
|
||||
575,2.444
|
||||
576,2.437
|
||||
577,2.734
|
||||
578,2.591
|
||||
579,3.845
|
||||
580,3.212
|
||||
581,2.802
|
||||
582,2.370
|
||||
583,2.704
|
||||
584,2.624
|
||||
585,4.023
|
||||
586,2.360
|
||||
587,2.759
|
||||
588,2.511
|
||||
589,2.608
|
||||
590,2.922
|
||||
591,3.081
|
||||
592,3.271
|
||||
593,2.471
|
||||
594,2.516
|
||||
595,2.474
|
||||
596,2.814
|
||||
597,3.127
|
||||
598,2.537
|
||||
599,3.080
|
||||
600,2.341
|
||||
601,3.027
|
||||
602,2.766
|
||||
603,2.984
|
||||
604,2.450
|
||||
605,2.922
|
||||
606,2.481
|
||||
607,2.616
|
||||
608,2.541
|
||||
609,3.462
|
||||
610,2.971
|
||||
611,2.955
|
||||
612,2.258
|
||||
613,2.682
|
||||
614,2.770
|
||||
615,3.119
|
||||
616,2.792
|
||||
617,2.598
|
||||
618,2.260
|
||||
619,2.441
|
||||
620,2.536
|
||||
621,3.425
|
||||
622,2.958
|
||||
623,2.722
|
||||
624,2.099
|
||||
625,2.400
|
||||
626,2.570
|
||||
627,3.295
|
||||
628,2.919
|
||||
629,2.737
|
||||
630,2.311
|
||||
631,2.408
|
||||
632,2.514
|
||||
633,3.325
|
||||
634,2.767
|
||||
635,2.724
|
||||
636,2.288
|
||||
637,2.680
|
||||
638,2.347
|
||||
639,2.902
|
||||
640,3.058
|
||||
641,2.770
|
||||
642,2.522
|
||||
643,2.406
|
||||
644,2.539
|
||||
645,2.753
|
||||
646,3.215
|
||||
647,2.636
|
||||
648,2.639
|
||||
649,2.666
|
||||
650,2.392
|
||||
651,2.408
|
||||
652,3.515
|
||||
653,2.687
|
||||
654,2.589
|
||||
655,2.428
|
||||
656,2.375
|
||||
657,2.619
|
||||
658,3.308
|
||||
659,2.664
|
||||
660,2.752
|
||||
661,2.456
|
||||
662,2.365
|
||||
663,2.533
|
||||
664,3.354
|
||||
665,2.812
|
||||
666,2.684
|
||||
667,2.349
|
||||
668,2.441
|
||||
669,2.714
|
||||
670,2.769
|
||||
671,3.368
|
||||
672,2.624
|
||||
673,2.308
|
||||
674,2.365
|
||||
675,2.634
|
||||
676,2.617
|
||||
677,3.350
|
||||
678,2.733
|
||||
679,2.623
|
||||
680,2.462
|
||||
681,2.494
|
||||
682,2.460
|
||||
683,3.463
|
||||
684,2.537
|
||||
685,2.628
|
||||
686,2.674
|
||||
687,2.381
|
||||
688,2.516
|
||||
689,3.586
|
||||
690,2.681
|
||||
691,2.676
|
||||
692,2.289
|
||||
693,2.726
|
||||
694,2.303
|
||||
695,2.934
|
||||
696,3.339
|
||||
697,2.606
|
||||
698,2.187
|
||||
699,2.687
|
||||
700,2.328
|
||||
701,3.062
|
||||
702,2.930
|
||||
703,2.871
|
||||
704,2.313
|
||||
705,2.582
|
||||
706,2.464
|
||||
707,2.521
|
||||
708,3.304
|
||||
709,2.492
|
||||
710,2.723
|
||||
711,2.587
|
||||
712,2.608
|
||||
713,2.574
|
||||
714,4.002
|
||||
715,3.011
|
||||
716,2.227
|
||||
717,2.374
|
||||
718,2.343
|
||||
719,2.541
|
||||
720,3.580
|
||||
721,2.854
|
||||
|
@@ -42,7 +42,9 @@ Measured at 1440p on the development workstation: Bazzite, Ryzen 7 7700X, 64 GB
|
||||
| `0.1.0` | Placement vs references | PASS (with deliberate simplifications) | Section below, 2026-11-08 | 2026-11-08 |
|
||||
| `0.1.0` | Collision and packaging | PASS — no blocking collision, no invisible walls, no seam gaps (see audit note) | `-qaselftest` on the packaged build, 2026-11-08 | 2026-11-08 |
|
||||
| `0.1.0` | 60 FPS at 1440p | PASS — 2.42 ms avg (414 fps), p99 3.01 ms at 2560×1440, TSR on, VSync off, no FG | `-qaselftest` perf capture, `Docs/perf/0.1.0-walk-frametimes.csv` | 2026-11-08 |
|
||||
| `0.2.3` | Third-person camera does not clip through geometry | PASS — with a 2 m wall spawned 4 m behind the pawn the boom shortens from 340 cm to **288 cm** (12 cm clear of the wall, matching the probe size). `-cameratest` logs it and captures the placement | `UnrealEditor-Cmd <uproject> /Game/Hollywood/DemoMap -game -thirdperson -cameratest`, `Saved/Logs/GigLine.log` | 2026-11-09 |
|
||||
| `0.2.0` | 60 FPS at 1440p | PASS — 2.64 ms avg (379 fps), p50 2.54, p95 3.39, p99 3.76, max 4.05 ms at 2560×1440, TSR on, VSync off, no FG | `-qaselftest` perf capture, `Docs/perf/0.2.0-walk-frametimes.csv` | 2026-11-09 |
|
||||
| `0.2.0` | Debug overlay cost | PASS — same route with the F3 overlay drawn: 2.77 ms avg (361 fps), p50 2.64, p95 3.52, p99 3.87, max 4.43 ms; about **5 %** heavier, still 6× the floor. The overlay is on for `-perf`/`-qaselftest`, off in the built demo | `Docs/perf/0.2.0-walk-frametimes-overlay.csv` | 2026-11-09 |
|
||||
| `0.1.0` | Fresh checkout build | PASS — clean clone of `main` (`4bc41e9`), `git lfs pull` + `check-lfs.sh` OK (130 LFS files), both `GigLineEditor` and `GigLine` targets built with the documented engine | `/tmp/fresh-check`, 2026-11-08 | 2026-11-08 |
|
||||
| `0.1.0` | Packaged Linux build | PASS — packaged Development build launches straight into DemoMap, no missing-asset/linker errors; controls shown in-game and in `Docs/controls.md` | measurement recorded 2026-11-08; the capture was removed from the repository 2026-11-09 | 2026-11-08 |
|
||||
|
||||
|
||||
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.
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.
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.
@@ -15,6 +15,10 @@
|
||||
#include "Engine/World.h"
|
||||
#include "TimerManager.h"
|
||||
#include "Containers/Ticker.h"
|
||||
#include "Camera/PlayerCameraManager.h"
|
||||
#include "Engine/StaticMeshActor.h"
|
||||
#include "UnrealClient.h"
|
||||
#include "HAL/IConsoleManager.h"
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
#include "Misc/CommandLine.h"
|
||||
#include "Misc/Parse.h"
|
||||
@@ -59,7 +63,7 @@ AGigLineCharacter::AGigLineCharacter()
|
||||
// clips resolve without a retarget step; it is hidden while the first-person
|
||||
// camera is active and shown in third person.
|
||||
static ConstructorHelpers::FObjectFinder<USkeletalMesh> BodyFinder(
|
||||
TEXT("/Game/Test/Character/CharacterV2.CharacterV2"));
|
||||
TEXT("/Game/Test/Character/CharacterV6.CharacterV6"));
|
||||
if (BodyFinder.Succeeded())
|
||||
{
|
||||
GetMesh()->SetSkeletalMesh(BodyFinder.Object);
|
||||
@@ -73,13 +77,13 @@ AGigLineCharacter::AGigLineCharacter()
|
||||
GetMesh()->SetMaterial(0, SkinFinder.Object);
|
||||
}
|
||||
static ConstructorHelpers::FObjectFinder<UAnimSequence> IdleFinder(
|
||||
TEXT("/Game/Test/Character/CharacterV2Human_rig_GigLine_Idle.CharacterV2Human_rig_GigLine_Idle"));
|
||||
TEXT("/Game/Test/Character/CharacterV6Human_rig_GigLine_Idle.CharacterV6Human_rig_GigLine_Idle"));
|
||||
if (IdleFinder.Succeeded())
|
||||
{
|
||||
IdleAnimation = IdleFinder.Object;
|
||||
}
|
||||
static ConstructorHelpers::FObjectFinder<UAnimSequence> WalkFinder(
|
||||
TEXT("/Game/Test/Character/CharacterV2Human_rig_GigLine_Walk.CharacterV2Human_rig_GigLine_Walk"));
|
||||
TEXT("/Game/Test/Character/CharacterV6Human_rig_GigLine_Walk.CharacterV6Human_rig_GigLine_Walk"));
|
||||
if (WalkFinder.Succeeded())
|
||||
{
|
||||
WalkAnimation = WalkFinder.Object;
|
||||
@@ -89,6 +93,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"));
|
||||
@@ -132,6 +187,8 @@ AGigLineCharacter::AGigLineCharacter()
|
||||
TEXT("/Game/Input/IA_Jump.IA_Jump"));
|
||||
static ConstructorHelpers::FObjectFinder<UInputAction> ResetF(
|
||||
TEXT("/Game/Input/IA_Reset.IA_Reset"));
|
||||
static ConstructorHelpers::FObjectFinder<UInputAction> DebugF(
|
||||
TEXT("/Game/Input/IA_ToggleDebug.IA_ToggleDebug"));
|
||||
static ConstructorHelpers::FObjectFinder<UInputAction> CamF(
|
||||
TEXT("/Game/Input/IA_ToggleCamera.IA_ToggleCamera"));
|
||||
static ConstructorHelpers::FObjectFinder<UInputAction> PauseF(
|
||||
@@ -146,6 +203,7 @@ AGigLineCharacter::AGigLineCharacter()
|
||||
JumpAction = JumpF.Object;
|
||||
ResetAction = ResetF.Object;
|
||||
ToggleCameraAction = CamF.Object;
|
||||
ToggleDebugAction = DebugF.Object;
|
||||
PauseAction = PauseF.Object;
|
||||
|
||||
if (!MappingContext || !MoveAction || !MoveBackAction || !StrafeAction || !StrafeLeftAction
|
||||
@@ -256,7 +314,152 @@ 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.
|
||||
if (ThirdPersonArm)
|
||||
{
|
||||
ThirdPersonArm->SetActive(bThirdPerson);
|
||||
}
|
||||
|
||||
// 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"));
|
||||
bCameraTest = FParse::Param(FCommandLine::Get(), TEXT("cameratest"));
|
||||
|
||||
// `-perf` starts the overlay on: the performance capture and any manual frame
|
||||
// check should not depend on someone pressing F3 first. The built demo has
|
||||
// neither switch, so the counter stays hidden for players.
|
||||
if (FParse::Param(FCommandLine::Get(), TEXT("perf")) || FParse::Param(FCommandLine::Get(), TEXT("qaselftest")))
|
||||
{
|
||||
if (IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug")))
|
||||
{
|
||||
Var->Set(1, ECVF_SetByConsole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float AGigLineCharacter::GetThirdPersonArmLength() const
|
||||
{
|
||||
return ThirdPersonArm ? ThirdPersonArm->TargetArmLength : 0.f;
|
||||
}
|
||||
|
||||
FVector AGigLineCharacter::GetThirdPersonCameraLocation() const
|
||||
{
|
||||
return ThirdPersonCamera ? ThirdPersonCamera->GetComponentLocation() : GetActorLocation();
|
||||
}
|
||||
|
||||
void AGigLineCharacter::UpdateCameraCollisionTest(float DeltaSeconds)
|
||||
{
|
||||
// Deterministic on purpose. Finding a wall in the street wasted four runs: the
|
||||
// probe hit the floor, then faced the pawn into the wall, then the actor rotation
|
||||
// was overwritten by the controller, and even when placement looked right both
|
||||
// traces came back clear. A spawned wall behind the pawn removes every one of
|
||||
// those variables.
|
||||
CameraTestElapsed += DeltaSeconds;
|
||||
UWorld* World = GetWorld();
|
||||
if (!World)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CameraTestPlaced)
|
||||
{
|
||||
CameraTestPlaced = true;
|
||||
// A 6 m cube, 1.6 m behind the pawn: close enough that a 340 cm boom must hit
|
||||
// it, far enough that the capsule is clear of it.
|
||||
const FVector Behind = -GetActorForwardVector();
|
||||
const FVector Where = GetActorLocation() + Behind * 400.f - FVector(0.f, 0.f, 90.f);
|
||||
FActorSpawnParameters Params;
|
||||
Params.ObjectFlags |= RF_Transient;
|
||||
// Static mesh cube, 2 m thick, 4 m behind. The earlier failures had the pawn
|
||||
// INSIDE a 6 m cube centred 1.6 m back, so every trace started inside geometry
|
||||
// and reported no hit - the wall existed, the measurement was inside it.
|
||||
// Deferred spawn: the mesh and collision must be set BEFORE the actor is
|
||||
// registered, otherwise the physics state is built for an empty component and
|
||||
// the wall exists visually while blocking nothing (every earlier run reported
|
||||
// "blocks trace=0" for exactly that reason).
|
||||
CameraTestWall = World->SpawnActorDeferred<AStaticMeshActor>(
|
||||
AStaticMeshActor::StaticClass(), FTransform(Where), this, nullptr,
|
||||
ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
|
||||
if (CameraTestWall)
|
||||
{
|
||||
UStaticMeshComponent* Comp = CameraTestWall->GetStaticMeshComponent();
|
||||
Comp->SetMobility(EComponentMobility::Movable);
|
||||
Comp->SetStaticMesh(LoadObject<UStaticMesh>(
|
||||
nullptr, TEXT("/Engine/BasicShapes/Cube.Cube")));
|
||||
Comp->SetCollisionProfileName(TEXT("BlockAll"));
|
||||
CameraTestWall->SetActorScale3D(FVector(6.f, 2.f, 6.f));
|
||||
CameraTestWall->FinishSpawning(FTransform(Where));
|
||||
}
|
||||
UE_LOG(LogTemp, Warning, TEXT("GigLine CAMTEST wall spawned behind pawn at (%.0f,%.0f,%.0f)"),
|
||||
Where.X, Where.Y, Where.Z);
|
||||
return;
|
||||
}
|
||||
|
||||
if (CameraTestElapsed < 2.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const float Rest = ThirdPersonBoomLength;
|
||||
const float Arm = GetThirdPersonArmLength();
|
||||
const FVector CameraLoc = GetThirdPersonCameraLocation();
|
||||
const FVector Head = GetActorLocation() + FVector(0.f, 0.f, 60.f);
|
||||
const float Dist = FVector::Dist(Head, CameraLoc);
|
||||
|
||||
// The spring arm shortens internally and applies the result to the camera; it does
|
||||
// NOT rewrite TargetArmLength, so the arm length is always the requested one. The
|
||||
// measurement that matters is the camera's distance from the head.
|
||||
const bool bShortened = (Rest - Dist) > 40.f;
|
||||
// Prove the spawned wall has collision at all before blaming the arm.
|
||||
FHitResult WallHit;
|
||||
FCollisionQueryParams QW(TEXT("GigLineCameraWall"), false, this);
|
||||
const bool bWallBlocks = World->LineTraceSingleByChannel(
|
||||
WallHit, GetActorLocation(), GetActorLocation() - GetActorForwardVector() * 600.f,
|
||||
ECC_Visibility, QW);
|
||||
UE_LOG(LogTemp, Warning, TEXT("GigLine CAMTEST spawned wall blocks trace=%d dist=%.0f"),
|
||||
bWallBlocks ? 1 : 0, bWallBlocks ? WallHit.Distance : -1.f);
|
||||
|
||||
UE_LOG(LogTemp, Warning,
|
||||
TEXT("GigLine CAMTEST wall behind pawn: boom %.0f cm (requested %.0f, shortened %.0f) -> %s"),
|
||||
Dist, Rest, Rest - Dist,
|
||||
bShortened ? TEXT("PASS (boom shortened)") : TEXT("FAIL (no shortening)"));
|
||||
|
||||
FScreenshotRequest::RequestScreenshot(TEXT("review-demo-camera-collision"), true, false);
|
||||
if (CameraTestWall)
|
||||
{
|
||||
CameraTestWall->Destroy();
|
||||
CameraTestWall = nullptr;
|
||||
}
|
||||
bCameraTest = false;
|
||||
}
|
||||
|
||||
void AGigLineCharacter::ToggleDebugOverlay()
|
||||
{
|
||||
static IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug"));
|
||||
if (Var)
|
||||
{
|
||||
const bool bNowOn = Var->GetInt() == 0;
|
||||
Var->Set(bNowOn ? 1 : 0, ECVF_SetByConsole);
|
||||
UE_LOG(LogTemp, Log, TEXT("GigLine: debug overlay %s"), bNowOn ? TEXT("on") : TEXT("off"));
|
||||
}
|
||||
}
|
||||
|
||||
void AGigLineCharacter::SetReviewWalk(bool bEnable)
|
||||
@@ -658,6 +861,10 @@ void AGigLineCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputCo
|
||||
{
|
||||
Input->BindAction(ToggleCameraAction, ETriggerEvent::Started, this, &AGigLineCharacter::ToggleCamera);
|
||||
}
|
||||
if (ToggleDebugAction)
|
||||
{
|
||||
Input->BindAction(ToggleDebugAction, ETriggerEvent::Started, this, &AGigLineCharacter::ToggleDebugOverlay);
|
||||
}
|
||||
if (JumpAction)
|
||||
{
|
||||
Input->BindAction(JumpAction, ETriggerEvent::Started, this, &AGigLineCharacter::StartJump);
|
||||
@@ -750,6 +957,11 @@ void AGigLineCharacter::Tick(float DeltaSeconds)
|
||||
Super::Tick(DeltaSeconds);
|
||||
// Speed-driven animation state: correct whether the player walked, was pushed,
|
||||
// or fell, and independent of which input produced the movement.
|
||||
if (bCameraTest)
|
||||
{
|
||||
UpdateCameraCollisionTest(GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.f);
|
||||
}
|
||||
|
||||
WalkForever();
|
||||
UpdateBodyAnimation();
|
||||
|
||||
@@ -764,7 +976,27 @@ void AGigLineCharacter::ToggleCamera()
|
||||
bThirdPerson = !bThirdPerson;
|
||||
FirstPersonCamera->SetActive(!bThirdPerson);
|
||||
ThirdPersonCamera->SetActive(bThirdPerson);
|
||||
// The ARM must be active too, not only the camera. It is created inactive to keep
|
||||
// the first-person path free, but a deactivated spring arm does not tick - and its
|
||||
// collision probe runs in that tick. Leaving it off was why the boom never
|
||||
// shortened: with a 6 m wall spawned 1.6 m behind the pawn the camera still sat at
|
||||
// its full 340 cm.
|
||||
if (ThirdPersonArm)
|
||||
{
|
||||
ThirdPersonArm->SetActive(bThirdPerson);
|
||||
}
|
||||
GetMesh()->SetHiddenInGame(!bThirdPerson);
|
||||
if (HairMesh)
|
||||
{
|
||||
HairMesh->SetHiddenInGame(!bThirdPerson);
|
||||
}
|
||||
for (USkeletalMeshComponent* Piece : Clothing)
|
||||
{
|
||||
if (Piece)
|
||||
{
|
||||
Piece->SetHiddenInGame(!bThirdPerson);
|
||||
}
|
||||
}
|
||||
if (bThirdPerson)
|
||||
{
|
||||
UpdateBodyAnimation();
|
||||
|
||||
@@ -42,6 +42,9 @@ protected:
|
||||
void TogglePause();
|
||||
|
||||
public:
|
||||
/** True while the third-person rig is the active view (for the debug overlay). */
|
||||
bool IsThirdPerson() const { return bThirdPerson; }
|
||||
|
||||
/** Review harness: hold or release forward input from the screenshot actor. */
|
||||
void SetReviewWalk(bool bEnable);
|
||||
|
||||
@@ -152,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;
|
||||
|
||||
@@ -172,6 +196,28 @@ protected:
|
||||
/** Applies the `-walk` forward input when the flag is set and the pawn is grounded. */
|
||||
void WalkForever();
|
||||
|
||||
/** Debug-overlay action (F3). */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> ToggleDebugAction;
|
||||
|
||||
/** Current boom length and camera position of the third-person rig. */
|
||||
float GetThirdPersonArmLength() const;
|
||||
FVector GetThirdPersonCameraLocation() const;
|
||||
|
||||
/** `-cameratest`: back the pawn against a wall and check the boom does not clip. */
|
||||
void UpdateCameraCollisionTest(float DeltaSeconds);
|
||||
|
||||
UPROPERTY()
|
||||
TObjectPtr<class AStaticMeshActor> CameraTestWall;
|
||||
|
||||
FVector LastWallNormal = FVector::ZeroVector;
|
||||
bool bCameraTest = false;
|
||||
bool CameraTestPlaced = false;
|
||||
float CameraTestElapsed = 0.0f;
|
||||
|
||||
/** Flips the gigline.Debug console variable that the HUD reads. */
|
||||
void ToggleDebugOverlay();
|
||||
|
||||
/** Toggle-camera action (V). */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> ToggleCameraAction;
|
||||
|
||||
@@ -2,11 +2,61 @@
|
||||
|
||||
#include "Engine/Canvas.h"
|
||||
#include "Engine/Engine.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "GigLineCharacter.h"
|
||||
#include "HAL/IConsoleManager.h"
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
|
||||
// Console variable so a packaged build can be checked without a rebuild:
|
||||
// gigline.Debug 1
|
||||
// The HUD reads it every frame; the F3 binding toggles it.
|
||||
static TAutoConsoleVariable<int32> CVarGigLineDebug(
|
||||
TEXT("gigline.Debug"),
|
||||
0,
|
||||
TEXT("Draw the performance and state overlay (0=off, 1=on). F3 toggles it."),
|
||||
ECVF_Cheat);
|
||||
|
||||
void AGigLineHUD::UpdateFrameStats(float DeltaSeconds)
|
||||
{
|
||||
if (DeltaSeconds <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
++FramesDrawn;
|
||||
const float Ms = DeltaSeconds * 1000.0f;
|
||||
if (WarmupSeconds > 0.0f)
|
||||
{
|
||||
WarmupSeconds -= DeltaSeconds;
|
||||
SmoothedFrameMs = FMath::Lerp(SmoothedFrameMs, Ms, 0.1f);
|
||||
WorstFrameMs = Ms;
|
||||
WorstWindowSeconds = 0.0f;
|
||||
return;
|
||||
}
|
||||
// A 0.9/0.1 EMA settles in a few frames and still reacts to a stall.
|
||||
SmoothedFrameMs = FMath::Lerp(SmoothedFrameMs, Ms, 0.1f);
|
||||
WorstFrameMs = FMath::Max(WorstFrameMs, Ms);
|
||||
// Rolling worst-frame window: a spike stays visible for two seconds, so a
|
||||
// hitch is not missed between glances (and not averaged away).
|
||||
WorstWindowSeconds += DeltaSeconds;
|
||||
if (WorstWindowSeconds >= 2.0f)
|
||||
{
|
||||
WorstWindowSeconds = 0.0f;
|
||||
WorstFrameMs = Ms;
|
||||
}
|
||||
}
|
||||
|
||||
void AGigLineHUD::DrawHUD()
|
||||
{
|
||||
Super::DrawHUD();
|
||||
|
||||
// Console variable is authoritative; the UPROPERTY is the per-instance default
|
||||
// for tests that set it before BeginPlay.
|
||||
if (IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug")))
|
||||
{
|
||||
bShowDebug = Var->GetInt() != 0;
|
||||
}
|
||||
UpdateFrameStats(GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.0f);
|
||||
|
||||
if (!Canvas || AttributionLine.IsEmpty())
|
||||
{
|
||||
return;
|
||||
@@ -37,4 +87,35 @@ void AGigLineHUD::DrawHUD()
|
||||
DrawText(ControlsLine, Shadow, CX + 1.0f, CY + 1.0f, nullptr, TextScale * 0.82f);
|
||||
DrawText(ControlsLine, Ink, CX, CY, nullptr, TextScale * 0.82f);
|
||||
}
|
||||
|
||||
if (!bShowDebug)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Performance and state readout, top-left. Everything here is cheap enough to
|
||||
// leave on during a capture: four formatted lines and no additional passes.
|
||||
const APawn* Pawn = UGameplayStatics::GetPlayerPawn(this, 0);
|
||||
const AGigLineCharacter* Character = Cast<AGigLineCharacter>(Pawn);
|
||||
const float Fps = SmoothedFrameMs > 0.0f ? 1000.0f / SmoothedFrameMs : 0.0f;
|
||||
const FVector Where = Pawn ? Pawn->GetActorLocation() / 100.0f : FVector::ZeroVector;
|
||||
const float Speed = Pawn ? Pawn->GetVelocity().Size2D() / 100.0f : 0.0f;
|
||||
|
||||
const FString Header = FString::Printf(TEXT("%.0f FPS %.2f ms worst %.2f ms"), Fps, SmoothedFrameMs, WorstFrameMs);
|
||||
const FString State = FString::Printf(TEXT("%s %.2f m/s frames %lld"),
|
||||
Character && Character->IsThirdPerson() ? TEXT("third person") : TEXT("first person"),
|
||||
Speed, FramesDrawn);
|
||||
const FString Place = FString::Printf(TEXT("x %.1f y %.1f z %.1f m"), Where.X, Where.Y, Where.Z);
|
||||
const FString Hint = TEXT("F3 hide");
|
||||
|
||||
const FLinearColor DebugInk(0.85f, 1.0f, 0.55f, 0.95f);
|
||||
const FLinearColor DebugShadow(0.0f, 0.0f, 0.0f, 0.6f);
|
||||
const float DX = MarginPx;
|
||||
float DY = MarginPx;
|
||||
for (const FString& Line : { Header, State, Place, Hint })
|
||||
{
|
||||
DrawText(Line, DebugShadow, DX + 1.0f, DY + 1.0f, nullptr, TextScale * 0.9f);
|
||||
DrawText(Line, DebugInk, DX, DY, nullptr, TextScale * 0.9f);
|
||||
DY += 22.0f * TextScale * 0.9f;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,19 @@ public:
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
|
||||
FString LicenceLine = TEXT("Data \u00A9 OpenStreetMap contributors, ODbL 1.0 \u2014 openstreetmap.org/copyright");
|
||||
|
||||
/**
|
||||
* Performance and state readout, top-left, off by default.
|
||||
*
|
||||
* Toggle in game with **F3**, or start with it on by passing `-perf` (the
|
||||
* performance capture does). The built demo ships with it hidden so a player
|
||||
* does not see an FPS counter over Hollywood Boulevard.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Debug")
|
||||
bool bShowDebug = false;
|
||||
|
||||
/** Smoothed FPS and a rolling frame-time window, refreshed every frame. */
|
||||
void UpdateFrameStats(float DeltaSeconds);
|
||||
|
||||
/** Screen-space margin in pixels. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
|
||||
float MarginPx = 18.0f;
|
||||
@@ -47,4 +60,25 @@ public:
|
||||
/** Text scale relative to the default canvas font. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
|
||||
float TextScale = 1.45f;
|
||||
|
||||
private:
|
||||
/** Exponential moving average of frame time, in milliseconds. */
|
||||
float SmoothedFrameMs = 16.7f;
|
||||
|
||||
/** Worst frame in the rolling window, so a hitch is visible without a log. */
|
||||
float WorstFrameMs = 0.0f;
|
||||
|
||||
/** Seconds until the rolling worst-frame window resets. */
|
||||
float WorstWindowSeconds = 0.0f;
|
||||
|
||||
/** Frames drawn, so the overlay can show that the counter is live. */
|
||||
int64 FramesDrawn = 0;
|
||||
|
||||
/**
|
||||
* Seconds left in the warm-up window. The first frames of a session include
|
||||
* shader and streaming hitches of several hundred milliseconds; without this
|
||||
* the "worst" figure read 400 ms for the whole first two seconds and made a
|
||||
* healthy run look broken.
|
||||
*/
|
||||
float WarmupSeconds = 2.0f;
|
||||
};
|
||||
@@ -92,6 +92,17 @@ void AGigLineShotActor::UpdateFollowCamera()
|
||||
if (AGigLineCharacter* Character = Cast<AGigLineCharacter>(Pawn))
|
||||
{
|
||||
Character->SetReviewWalk(true);
|
||||
// Park the walk clip at a quarter cycle the first frame of the shot, so the
|
||||
// capture lands mid-stride. Without this the phase is whatever happened to be
|
||||
// playing when the settle timer expired, and a passing pose looks like a still.
|
||||
if (!bFollowPhaseSet)
|
||||
{
|
||||
bFollowPhaseSet = true;
|
||||
if (USkeletalMeshComponent* Body = Character->GetMesh())
|
||||
{
|
||||
Body->SetPosition(0.25f, /*bFireNotifies=*/false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Offset in the pawn's frame: to its right, slightly above, looking at the
|
||||
// pelvis. Right-of-pawn gives a profile view, which is where a stride reads.
|
||||
@@ -143,6 +154,7 @@ void AGigLineShotActor::NextShot()
|
||||
// the pawn only holds forward input while a follow shot is being taken, so the
|
||||
// fixed-camera shots later in the list still find it at the spawn point.
|
||||
const bool bFollowThisShot = ShotFollow.IsValidIndex(CurrentShot) && ShotFollow[CurrentShot];
|
||||
bFollowPhaseSet = false;
|
||||
if (AGigLineCharacter* Character = Cast<AGigLineCharacter>(UGameplayStatics::GetPlayerPawn(this, 0)))
|
||||
{
|
||||
Character->SetReviewWalk(bFollowThisShot);
|
||||
|
||||
@@ -72,6 +72,9 @@ private:
|
||||
/** Re-places the active camera at the follow offset behind the player pawn. */
|
||||
void UpdateFollowCamera();
|
||||
|
||||
/** True once the follow shot has parked the walk clip at a chosen phase. */
|
||||
bool bFollowPhaseSet = false;
|
||||
|
||||
UPROPERTY()
|
||||
TObjectPtr<class APlayerController> PlayerController;
|
||||
|
||||
|
||||
@@ -486,3 +486,29 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
|
||||
- **Workaround that works: import under a new name.** `Tools/ue_character_anim_import.py` now uses `PREFIX = "CharacterV2"`, which creates `CharacterV2`, `CharacterV2_Skeleton` and `CharacterV2Human_rig_GigLine_{Idle,Walk}` fresh; the same quaternion probe then returned `(+0.439, +0.898)`, i.e. the 0.55 rad arm rotation had finally landed.
|
||||
- **Check the asset, not the importer.** A pose or material change that does not alter the probed asset data did not happen, whatever the log says. Probing a clip's bone quaternion takes one commandlet and settles it in seconds.
|
||||
- **The arms-down rotation belongs in the clips, not in the bind.** Every bind experiment (bake pose to rest, clear shape keys, apply the armature modifier, re-bind) was either impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. `Tools/character_anim_build.py` now carries a measured base offset in the clip keys (`BASE`: 0.55 rad on `upperarm01`, chosen by sweeping the rotation in Blender and reading the bone direction), and the character stands with its arms at its sides.
|
||||
|
||||
## 2026-11-09 — Textures: ComfyUI works, Stable Diffusion fails for flat swatches
|
||||
|
||||
- **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
@@ -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 |
|
||||
| --- | --- |
|
||||
|
||||
+18
-10
@@ -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,16 @@ 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.**
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
@@ -135,13 +144,11 @@ 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 under `-walk`
|
||||
- **Expected content:** the body mid-stride in profile — legs apart, arms counter-swinging
|
||||
- **Verified:** ☑ 2026-11-09 · **Approved:** ☐ pending
|
||||
- **View:** tracking camera, 4.2 m to the pawn's right, captured while the pawn walks south
|
||||
- **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 it needed new harness:** a fixed camera cannot photograph a pawn moving at 4.5 m/s. Three attempts framed it differently every run and two missed it entirely. The shot actor now has a per-shot `ShotFollow` mode that re-places the camera every frame at a fixed offset in the pawn's frame, and the walk is driven per shot, so the capture is deterministic.
|
||||
|
||||
**What it proves:** the walk clip plays and advances. The earlier captures of this shot showed one frozen pose because the sequencer's `BeginPlay` ran before the player pawn existed, so the cast that enables walking silently found nothing and `speed` stayed at zero — the probe read `walk=0 speed=0 clip=Idle` every frame. Moving that call into the per-frame follow update fixed it; the capture now logs `walk=1 speed=28 clip=GigLine_Walk` and shows a stride.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -174,8 +181,9 @@ Every issue is tracked in `TODO.md` under the version that will fix it; an issue
|
||||
| Nothing may stand in front of the hero façade that blocks it — no awning, no temporary structure | `0.9.0` → per-building models, hero façade | Open |
|
||||
| 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`) |
|
||||
| Third-person fingers splay rather than relax — the bind's finger spread is carried into the pose | `0.2.2` → skin and animation tuning | Open |
|
||||
| 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.
@@ -9,27 +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.1 — Rig fixes (patch)
|
||||
|
||||
### Patch tasks
|
||||
|
||||
- [ ] Fix skeletal/rigging defects reported after `0.2.0`.
|
||||
- [ ] Tag `v0.2.1` and write `CHANGELOG.md` entry; then remove this section.
|
||||
|
||||
## 0.2.2 — Skin and hair tuning (patch)
|
||||
|
||||
### Patch tasks
|
||||
|
||||
- [ ] Tune skin shading and hair materials on the player character.
|
||||
- [ ] Tag `v0.2.2` and write `CHANGELOG.md` entry; then remove this section.
|
||||
|
||||
## 0.2.3 — Camera clipping fixes (patch)
|
||||
|
||||
### Patch tasks
|
||||
|
||||
- [ ] Fix remaining camera clipping cases in `0.2.x`.
|
||||
- [ ] Tag `v0.2.3` and write `CHANGELOG.md` entry; then remove this section.
|
||||
|
||||
## 0.3.0 — Chaos vehicle, enter/exit, closed driving route (weeks 10–13)
|
||||
|
||||
### Vehicle production
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -55,6 +55,51 @@ ELBOW = 0.18
|
||||
|
||||
PARTS: list[dict] = []
|
||||
|
||||
# The .blend stores the raw A-pose bind: `mpfb_character_export.py --pose idle`
|
||||
# bakes the arms down only in the exported FBX, not in the file. A clip that
|
||||
# animates only swing therefore leaves the arms out in an A — which is exactly how
|
||||
# the first third-person render looked. These are the same deltas `IDLE_POSE` uses,
|
||||
# applied as a base that every key sits on top of.
|
||||
# Finger curl, in radians per joint. The bind pose holds the fingers spread (it is
|
||||
# an A-pose hand), which reads as a stretched claw once the arms hang. Curling the
|
||||
# three joints of every finger by a fixed amount gives a relaxed hand without
|
||||
# authoring a pose per finger. The axis was probed: rotating a finger base joint on
|
||||
# X moves the tip mostly downward, which is the curl toward the palm.
|
||||
FINGER_CURL = (0.28, 0.34, 0.22)
|
||||
|
||||
PARTS: list[dict] = []
|
||||
|
||||
|
||||
def derive_arm_rotation(arm, target_dir=(0.12, 0.0, -1.0), candidates=None):
|
||||
"""Find the upper-arm rotation that points the arm at `target_dir`.
|
||||
|
||||
The 0.2.0 value (0.55 rad) was found by hand-sweeping and hard-coded, which is
|
||||
exactly the kind of magic constant that rots when the rig changes. This does the
|
||||
same sweep on every build and returns the best candidate, so the number is
|
||||
derived from the rig that is actually being exported.
|
||||
"""
|
||||
from mathutils import Euler, Vector
|
||||
|
||||
candidates = candidates or [0.35 + 0.05 * i for i in range(11)]
|
||||
target = Vector(target_dir).normalized()
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
upper = arm.pose.bones["upperarm01.L"]
|
||||
upper.rotation_mode = "XYZ"
|
||||
best, best_dot = 0.55, -2.0
|
||||
for z in candidates:
|
||||
upper.rotation_euler = Euler((0.0, 0.0, -z))
|
||||
bpy.context.view_layer.update()
|
||||
direction = (upper.matrix.to_3x3() @ Vector((0.0, 1.0, 0.0))).normalized()
|
||||
dot = direction.dot(target)
|
||||
if dot > best_dot:
|
||||
best, best_dot = z, dot
|
||||
upper.rotation_euler = Euler((0.0, 0.0, 0.0))
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
log(f"arm rotation derived: {best:.2f} rad (direction dot {best_dot:.3f})")
|
||||
return best
|
||||
PARTS: list[dict] = []
|
||||
|
||||
# The .blend stores the raw A-pose bind: `mpfb_character_export.py --pose idle`
|
||||
# bakes the arms down only in the exported FBX, not in the file. A clip that
|
||||
# animates only swing therefore leaves the arms out in an A — which is exactly how
|
||||
@@ -82,6 +127,54 @@ def log(msg):
|
||||
print(f"[anim] {msg}")
|
||||
|
||||
|
||||
HAIR_MATERIAL = "M_Character_Hair"
|
||||
|
||||
|
||||
def add_hair_cap(arm, mesh):
|
||||
"""Add a short-hair cap conforming to the head, skinned to the head bone.
|
||||
|
||||
The character exported from MPFB is bald, and a bald head reads as a mannequin
|
||||
before anything else does. A cap plus a strand texture is enough at review
|
||||
distance; hair cards and a groomed cut are a later pass.
|
||||
"""
|
||||
head = arm.data.bones.get("head")
|
||||
if head is None or mesh is None:
|
||||
log("hair: no head bone or no mesh - skipped")
|
||||
return None
|
||||
|
||||
centre = (head.head_local + head.tail_local) * 0.5
|
||||
radius = max(0.085, (head.tail_local - head.head_local).length * 0.62)
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=14, radius=radius,
|
||||
location=(centre.x, centre.y, centre.z + 0.012))
|
||||
cap = bpy.context.active_object
|
||||
cap.name = "HairCap"
|
||||
# Keep the upper hemisphere plus a little: a full sphere reads as a helmet.
|
||||
cut_z = centre.z - radius * 0.12
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
bpy.ops.mesh.select_all(action="SELECT")
|
||||
bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, cut_z), plane_no=(0.0, 0.0, 1.0),
|
||||
clear_inner=True, use_fill=False)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
# Slightly ovoid, wider than deep, sitting back on the skull.
|
||||
cap.scale = (1.06, 1.10, 0.92)
|
||||
|
||||
hair_mat = bpy.data.materials.get(HAIR_MATERIAL) or bpy.data.materials.new(HAIR_MATERIAL)
|
||||
hair_mat.use_nodes = True
|
||||
bsdf = hair_mat.node_tree.nodes.get("Principled BSDF")
|
||||
if bsdf:
|
||||
bsdf.inputs["Base Color"].default_value = (0.055, 0.038, 0.030, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 0.42
|
||||
cap.data.materials.append(hair_mat)
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
cap.select_set(True)
|
||||
mesh.select_set(True)
|
||||
bpy.context.view_layer.objects.active = mesh
|
||||
bpy.ops.object.join()
|
||||
log(f"hair: cap added at head centre, radius {radius:.3f} m, material {HAIR_MATERIAL}")
|
||||
return mesh
|
||||
|
||||
|
||||
def clear_animation(arm):
|
||||
"""Drop any pre-existing actions so a rerun does not stack clips."""
|
||||
for action in list(bpy.data.actions):
|
||||
@@ -154,6 +247,27 @@ def apply_base_pose(arm):
|
||||
log("base pose applied to rest: arms down before any clip is authored")
|
||||
|
||||
|
||||
BASE = {}
|
||||
|
||||
|
||||
def fill_base(arm):
|
||||
"""Populate the standing base: derived arm angle plus the finger curl."""
|
||||
arm_z = derive_arm_rotation(arm)
|
||||
BASE.update({
|
||||
"upperarm01.L": (0.0, 0.0, -arm_z),
|
||||
"upperarm01.R": (0.0, 0.0, arm_z),
|
||||
"lowerarm01.L": (0.0, 0.0, -0.12),
|
||||
"lowerarm01.R": (0.0, 0.0, 0.12),
|
||||
})
|
||||
for side in ("L", "R"):
|
||||
for finger in range(1, 6):
|
||||
for joint, curl in enumerate(FINGER_CURL, start=1):
|
||||
sign = 1.0 if side == "L" else -1.0
|
||||
BASE[f"finger{finger}-{joint}.{side}"] = (sign * curl, 0.0, 0.0)
|
||||
log(f"standing base: {len(BASE)} bones (arm {arm_z:.2f} rad, "
|
||||
f"finger curl {FINGER_CURL})")
|
||||
|
||||
|
||||
def key_euler(pb, frame, x=0.0, y=0.0, z=0.0):
|
||||
"""Key a pose-bone rotation, offset by the standing base.
|
||||
|
||||
@@ -186,6 +300,15 @@ def clip_idle(arm, frames):
|
||||
key_euler(bones["upperarm01.L"], frame, x=0.020 * breathe)
|
||||
key_euler(bones["upperarm01.R"], frame, x=-0.020 * breathe)
|
||||
|
||||
# Key the standing base on every bone it touches, at both ends of the clip, so
|
||||
# the arms and fingers hold their offset instead of snapping back to the bind.
|
||||
for name in BASE:
|
||||
bone = bones.get(name)
|
||||
if bone is None:
|
||||
continue
|
||||
key_euler(bone, 1)
|
||||
key_euler(bone, frames)
|
||||
|
||||
action.use_fake_user = True
|
||||
log(f"Idle: {frames} frames, spine/neck/head/arms, feet planted")
|
||||
return action
|
||||
@@ -199,7 +322,10 @@ def clip_walk(arm, frames):
|
||||
|
||||
for frame in range(1, frames + 1):
|
||||
t = 2.0 * math.pi * (frame - 1) / frames
|
||||
swing = math.sin(t)
|
||||
# cos, not sin: the clip starts at maximum stride. A sine starts and crosses
|
||||
# zero twice per cycle, and the review capture kept landing on those passing
|
||||
# frames - legs together, which reads as a still. The cycle is unchanged.
|
||||
swing = math.cos(t)
|
||||
# Hip swing, opposite phase per leg; negative X is forward.
|
||||
key_euler(bones["upperleg01.L"], frame, x=-HIP * swing)
|
||||
key_euler(bones["upperleg01.R"], frame, x=HIP * swing)
|
||||
@@ -221,6 +347,13 @@ def clip_walk(arm, frames):
|
||||
key_euler(bones["spine04"], frame, z=0.08 * swing)
|
||||
key_euler(bones["neck01"], frame, z=-0.04 * swing)
|
||||
|
||||
for name in BASE:
|
||||
bone = bones.get(name)
|
||||
if bone is None:
|
||||
continue
|
||||
key_euler(bone, 1)
|
||||
key_euler(bone, frames)
|
||||
|
||||
action.use_fake_user = True
|
||||
log(f"Walk: {frames} frames, stride {HIP:.2f} rad hip / {KNEE:.2f} knee / {ARM:.2f} arm")
|
||||
return action
|
||||
@@ -232,6 +365,10 @@ def main(argv=None) -> int:
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--out", default="/tmp/gigline/character-anim.fbx")
|
||||
ap.add_argument("--report", default=None)
|
||||
# Hair is built but not exported yet: Unreal's import collapses the second
|
||||
# material slot, so the cap renders with the skin material and reads as a bald
|
||||
# skull. Off until the hair is a separate asset (see TODO.md).
|
||||
ap.add_argument("--hair", action="store_true")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
|
||||
@@ -243,9 +380,22 @@ def main(argv=None) -> int:
|
||||
|
||||
bpy.context.scene.render.fps = FPS
|
||||
clear_animation(arm)
|
||||
fill_base(arm)
|
||||
idle = clip_idle(arm, CLIPS["Idle"])
|
||||
walk = clip_walk(arm, CLIPS["Walk"])
|
||||
|
||||
# Hair before export so the cap is part of the skinned mesh; a separate asset
|
||||
# would need its own bone attachment and a second material assignment.
|
||||
verts_before = len(mesh.data.vertices) if mesh else 0
|
||||
if args.hair:
|
||||
add_hair_cap(arm, mesh)
|
||||
else:
|
||||
log("hair: skipped (--hair to include; blocked on material slots, see TODO.md)")
|
||||
if mesh and len(mesh.data.vertices) > verts_before:
|
||||
group = mesh.vertex_groups.get("head") or mesh.vertex_groups.new(name="head")
|
||||
group.add(list(range(verts_before, len(mesh.data.vertices))), 1.0, "REPLACE")
|
||||
log(f"hair: {len(mesh.data.vertices) - verts_before} vertices weighted to 'head'")
|
||||
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
# Export armature + mesh together: the preset's character flags carry the
|
||||
# bake_anim settings that make Unreal create AnimSequences.
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user