8 Commits
Author SHA1 Message Date
zwitschi a1f5989cbb fix(anim): the clip now drives the body — two causes, both measured
The body stood in an A-pose while the log said the clip was playing. Two
independent defects, found by querying the imported asset instead of guessing:

1. PlayAnimation needs the component in AnimationSingleNode with a live
   single-node instance. The body is hidden at construction and only unhidden by
   the camera toggle, so the first call could land before the component was ready,
   and the Wanted == PlayingAnimation guard then returned forever. UpdateBodyAnimation
   now sets the mode, calls SetAnimation + Play, and re-asserts while the instance
   is missing.
2. The arms-down offsets lived only in the clip keys. The exporter writes clip data
   relative to the rest pose, so they arrived as a ±20 degree swing around an
   A-posed bind - which is exactly what the pose probe measured. apply_base_pose now
   bakes them into the rest pose before any clip is authored, the same trick
   mpfb_character_export.py --pose idle uses. The table is applied with inverted
   signs: as written, the FBX axis mapping produced arms raised in a V.

Shot 6 confirms it: the arms hang at the sides. Still open and recorded - the idle
pose reads awkwardly (arms forward, hands splayed) and the walk clip has no shot in
motion; both are marked in TODO.md and Screenshots/README.md.

Instrumentation removed; the diagnostics that remain are behind
gigline.SetupLogs.
2026-11-09 10:56:43 +01:00
zwitschi d0f94dff4e memory: record the bind-pose defect and the FBX that is not the cause
Re-importing the exported FBX gives two actions with 1379 f-curves over 137 bones,
so the animation is baked; the defect is engine-side. Records that PlayAnimation
needs AnimationSingleNode mode and that the clip-name log line proves intent, not
effect.
2026-11-09 10:45:07 +01:00
zwitschi 50e023f15d merge: bring main (v0.1.6) into anim-body-0.2.0
Conflicts resolved by union, not by picking a side:

- AGENTS.md keeps the stronger visual-review rule (README updated with every visual
  change; a stale section is a defect).
- render_screenshots.sh keeps the absolute engine path from main and adds
  -thirdperson from this branch - both were needed and neither alone worked.
- Screenshots/README.md now lists six shots, retail and third-person both embedded;
  the known-issues tables merged, with the animation defect and unproven camera
  collision added to main's rows.
- visual_targets.json carries both expectations.
- Architecture docs: ADR-8 (realistic buildings) and ADR-9 (third-person body) both
  present and in numeric order; ADR-8 was mis-placed before the Quality Requirements
  heading in the combined document by an earlier edit, now fixed.
- Binary conflicts resolved to main's content, since the retail trim only exists
  there.

Checks: 82 tool tests, prettier clean, six embeds resolve.
2026-11-09 10:43:00 +01:00
zwitschi e487655e2f Merge pull request #33: 0.1.6 — retail frontage (Lee Drug, 6806–6820)
Patch release v0.1.6: retail frontage modelled from its own photograph, 0.9.0 realistic-buildings milestone planned, screenshots embedded inline.
2026-11-09 10:41:10 +01:00
zwitschi 304c1e9afd docs(review): embed every demo shot inline, record the rule in AGENTS
Screenshots/README.md on this branch still used the path-only table, so a reviewer
had to open six PNGs to judge a pass. Every shot now has its own section with the
image embedded, its view, expected content and both verdicts, plus a status table
at the top that links to them.

The known-issues list becomes a table of issue -> version and item that owns it:
pale hero stone, hero entrance doors, the bind-pose animation defect, unproven
camera collision and the terrain-as-pavement strip all point at their TODO owner.
Two stale entries removed: the theatre marquee exists (six trim actors staged) and
'materials not yet created' contradicted the scene conventions.

AGENTS.md now says the README is updated with every visual change, that the image
is embedded inline, and that approval ticks both the section and the table row.

Also removed an orphan: review-demo-retail-frontage.png had been re-copied into
this branch by the capture step although its geometry only exists on
facade-retail-0.1.6.
2026-11-09 10:35:44 +01:00
zwitschi 05c1488c00 feat(camera): third-person rig, V toggle, visible body
One pawn, both rigs (ADR-9): a skeletal body hidden in first person, a
collision-tested spring arm, and a toggle that swaps the active camera and the
body's visibility. Shipped default stays first person; -thirdperson starts the
pawn in the third-person rig so the review shot is reproducible, and the
screenshot run passes it.

Three defects found and two fixed:
- The skin material had no SkeletalMesh usage flag, so Unreal silently substituted
  the default grey. That is why the body rendered as clay.
- render_screenshots.sh still called ./Engine/... on this branch (the fix lives on
  the facade branch and never reached main); applied here so the shots generate.
- OPEN: the idle clip is bound and PlayAnimation is called - the run logs the clip
  name - but the body still renders in bind pose, arms out. Recorded with that
  evidence in TODO.md rather than called done.

Shot 5 (demo-third-person) added and documented; camera collision is implemented
but unproven, since the spawn has open space behind it.
2026-11-09 10:33:06 +01:00
zwitschi 96238bdb6a feat(anim): idle and walk clips on the MPFB rig
Two parametric cycles authored at 30 fps instead of hand-keyed poses, so the loop
closes by construction: GigLine_Idle (60 f, breathing sway, feet planted) and
GigLine_Walk (30 f, one stride, hip 0.45 rad / knee 0.55 / arm 0.32, arms
counter-phased, torso counter-rotation).

The limb axes were measured, not assumed: a probe rotated upperleg01.L by 0.5 rad
on each axis and read foot.L, which moved -0.022 -> +0.362 in Y on X, twisted on Y
and abducted on Z. Using the wrong axis here would have produced a character
scissoring sideways, which no import error would have caught.

The character export preset now bakes animation. Without bake_anim the exporter
writes a static bind pose and Unreal imports a mesh with no AnimSequence and no
error, so the importer asserts a clip exists and prints VERDICT: PASS/FAIL.

Verified in /Game/Test/Character: Idle 1.967 s and Walk 0.967 s, both AnimSequences,
skeleton intact. Playback on a possessed pawn is not yet verified: the demo pawn has
no visible body, so that check belongs with the third-person camera item.
2026-11-09 10:14:05 +01:00
zwitschi 817313aabd todo: claim 0.2.0 character setup on branch anim-body-0.2.0 2026-11-09 10:11:10 +01:00
58 changed files with 781 additions and 163 deletions

No files matched your search

+1 -1
View File
@@ -210,7 +210,7 @@ Any change that affects the game's appearance — meshes, materials, lighting, l
1. Run `Tools/render_screenshots.sh` (rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs the game on it, captures the fixed camera list, copies the PNGs into `Screenshots/`). 1. Run `Tools/render_screenshots.sh` (rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs the game on it, captures the fixed camera list, copies the PNGs into `Screenshots/`).
2. Optional pre-check with a vision model: `Tools/judge_screenshot.py --image <shot.png> --expect "<what it should show>"` (or `--shots` to run every shot in the README) flags likely-missing content before a human looks. It reads `OPENROUTER_API_KEY` from `.env` (ignored). Small helper only — it never replaces the human verdict. 2. Optional pre-check with a vision model: `Tools/judge_screenshot.py --image <shot.png> --expect "<what it should show>"` (or `--shots` to run every shot in the README) flags likely-missing content before a human looks. It reads `OPENROUTER_API_KEY` from `.env` (ignored). Small helper only — it never replaces the human verdict.
3. Update `Screenshots/README.md`: the image is **embedded inline** (`![alt](demo/<shot>.png)`) with its view, expected content, verification and approval state, plus one row in the status table at the top. The point is that the review happens in the file; a path alone is not a review. Describe any new shots or scene changes in the same change. 3. Update `Screenshots/README.md` **in the same change as any visual work** — every change that affects appearance, not only new shots: the image is **embedded inline** (`![alt](demo/<shot>.png)`) with its view, expected content, verification and approval state, plus one row in the status table at the top. The review happens in the file; a path alone is not a review, and a shot whose README section is stale is a defect.
4. The PNGs **are committed** so reviewers see exactly what the build rendered (`.gitignore` does not exclude them). Keep them in sync with this README: drop retired shots in the same change. 4. The PNGs **are committed** so reviewers see exactly what the build rendered (`.gitignore` does not exclude them). Keep them in sync with this README: drop retired shots in the same change.
5. Wait for the user's approval before treating a visual change as done; record approval by ticking both the shot's section and its row in the status table in `Screenshots/README.md`. 5. Wait for the user's approval before treating a visual change as done; record approval by ticking both the shot's section and its row in the status table in `Screenshots/README.md`.
+6 -1
View File
@@ -25,7 +25,9 @@ C++ classes in `Game/GigLine/Source/GigLine/`:
| `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits | | `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits |
| `UGigLineAssetTools` | C++ collision helpers | `SetComplexCollisionAsSimple` on `StaticMesh` assets | | `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`, 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`, 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.
### Site ### Site
@@ -55,6 +57,9 @@ Python scripts in `Tools/` drive the content pipeline:
| Script group | Purpose | | Script group | Purpose |
| --- | --- | | --- | --- |
| `export_*`, `hero_facade_build` | Blender headless export (FBX) |
| `ue_import_*`, `ue_hero_facade_import` | Unreal headless import + placement |
| `character_anim_build`, `ue_character_anim_import` | Character animation clips (`GigLine_Idle`, `GigLine_Walk`) and their import |
| `export_*`, `hero_facade_build`, `retail_frontage_build`, `theatre_trim_build`, `facade_kit_build` | Blender headless export (FBX); trim builders work in world coordinates | | `export_*`, `hero_facade_build`, `retail_frontage_build`, `theatre_trim_build`, `facade_kit_build` | Blender headless export (FBX); trim builders work in world coordinates |
| `ue_import_*`, `ue_hero_facade_import`, `ue_import_retail_detail` | Unreal headless import + placement | | `ue_import_*`, `ue_hero_facade_import`, `ue_import_retail_detail` | Unreal headless import + placement |
| `ue_build_demo_map`, `ue_build_review_map` | Map assembly (lighting, meshes, player start) | | `ue_build_demo_map`, `ue_build_review_map` | Map assembly (lighting, meshes, player start) |
@@ -63,3 +63,11 @@
- **Decision**: Building realism gets its own minor milestone, `0.9.0 — Realistic buildings`, placed before the feature freeze. Its scope is the façade system (orders, profile library, storefront types, signage as decals), per-building photo-matched models, a measured material library with a texel-density rule, and LODs plus collision proxies. Patches keep fixes and refinements only. Renumbered by this decision: the former `0.9.0` freeze/beta/audit milestone becomes `0.10.0`, its patches `0.10.1`–`0.10.3`, and `1.0.0` moves to weeks 39–44. - **Decision**: Building realism gets its own minor milestone, `0.9.0 — Realistic buildings`, placed before the feature freeze. Its scope is the façade system (orders, profile library, storefront types, signage as decals), per-building photo-matched models, a measured material library with a texel-density rule, and LODs plus collision proxies. Patches keep fixes and refinements only. Renumbered by this decision: the former `0.9.0` freeze/beta/audit milestone becomes `0.10.0`, its patches `0.10.1`–`0.10.3`, and `1.0.0` moves to weeks 39–44.
- **Consequences**: Façade work now has a release gate — every building measured against its own photograph, every building with a collision proxy and a LOD, no placeholder materials — so "modelled" means verified, not merely present. The price is that realism work stops being sliced into patches, and the schedule shifts by one minor cycle. Blockouts without a photograph stay blockouts and are listed as such, rather than being dressed up to fill the milestone. - **Consequences**: Façade work now has a release gate — every building measured against its own photograph, every building with a collision proxy and a LOD, no placeholder materials — so "modelled" means verified, not merely present. The price is that realism work stops being sliced into patches, and the schedule shifts by one minor cycle. Blockouts without a photograph stay blockouts and are listed as such, rather than being dressed up to fill the milestone.
## ADR-9: Third-person body is an attachment of the first-person pawn, not a second pawn
- **Context**: `0.2.0` needs a visible body and a third-person camera, while `0.1.0` shipped a first-person pawn with no body and the owner's decision was first person for that release. Two separate pawn classes would duplicate movement, input and reset logic, which is the code most likely to drift.
- **Decision**: one pawn, both rigs. `AGigLineCharacter` carries a skeletal mesh (hidden in first person), a collision-tested spring-arm camera, and a **V** toggle that swaps the active camera and the body's visibility. The shipped default view stays first person; `-thirdperson` starts the pawn in the third-person rig and is what the screenshot run passes, so the review shot is reproducible without a human pressing a key.
- **Consequences**: movement, reset, pause and the QA self-test stay in one place. The body's animation state is driven from `Tick` by speed rather than from input, so being pushed or falling animates correctly. The cost is that the camera swap is not a separate actor, so a future cinematic camera needs its own path rather than reusing the toggle.
+16 -3
View File
@@ -169,7 +169,9 @@ C++ classes in `Game/GigLine/Source/GigLine/`:
| `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits | | `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits |
| `UGigLineAssetTools` | C++ collision helpers | `SetComplexCollisionAsSimple` on `StaticMesh` assets | | `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`, 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`, 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.
### Site ### Site
@@ -199,6 +201,9 @@ Python scripts in `Tools/` drive the content pipeline:
| Script group | Purpose | | Script group | Purpose |
| --- | --- | | --- | --- |
| `export_*`, `hero_facade_build` | Blender headless export (FBX) |
| `ue_import_*`, `ue_hero_facade_import` | Unreal headless import + placement |
| `character_anim_build`, `ue_character_anim_import` | Character animation clips (`GigLine_Idle`, `GigLine_Walk`) and their import |
| `export_*`, `hero_facade_build`, `retail_frontage_build`, `theatre_trim_build`, `facade_kit_build` | Blender headless export (FBX); trim builders work in world coordinates | | `export_*`, `hero_facade_build`, `retail_frontage_build`, `theatre_trim_build`, `facade_kit_build` | Blender headless export (FBX); trim builders work in world coordinates |
| `ue_import_*`, `ue_hero_facade_import`, `ue_import_retail_detail` | Unreal headless import + placement | | `ue_import_*`, `ue_hero_facade_import`, `ue_import_retail_detail` | Unreal headless import + placement |
| `ue_build_demo_map`, `ue_build_review_map` | Map assembly (lighting, meshes, player start) | | `ue_build_demo_map`, `ue_build_review_map` | Map assembly (lighting, meshes, player start) |
@@ -453,8 +458,6 @@ Rule: place a building only against a neighbour or street mesh, never in isolati
- **Consequences**: Scripts are version-controlled and repeatable. No live Blender session needed for CI. - **Consequences**: Scripts are version-controlled and repeatable. No live Blender session needed for CI.
# Quality Requirements
## ADR-8: Realistic building façades are a minor milestone, not a patch ## ADR-8: Realistic building façades are a minor milestone, not a patch
- **Context**: `0.1.4` carried the first façade pass as a _patch_, while the release policy says patches contain fixes and refinements and never new scope. The `0.1.6` retail pass then showed what that costs: a façade built in a patch has no release gate of its own, so a first pass that does not yet read as the building (`Screenshots/README.md`, 0.1.6 gaps) can sit inside a "done" patch series. Realism is also the project's leading quality goal, which a patch cannot carry. - **Context**: `0.1.4` carried the first façade pass as a _patch_, while the release policy says patches contain fixes and refinements and never new scope. The `0.1.6` retail pass then showed what that costs: a façade built in a patch has no release gate of its own, so a first pass that does not yet read as the building (`Screenshots/README.md`, 0.1.6 gaps) can sit inside a "done" patch series. Realism is also the project's leading quality goal, which a patch cannot carry.
@@ -463,6 +466,16 @@ Rule: place a building only against a neighbour or street mesh, never in isolati
- **Consequences**: Façade work now has a release gate — every building measured against its own photograph, every building with a collision proxy and a LOD, no placeholder materials — so "modelled" means verified, not merely present. The price is that realism work stops being sliced into patches, and the schedule shifts by one minor cycle. Blockouts without a photograph stay blockouts and are listed as such, rather than being dressed up to fill the milestone. - **Consequences**: Façade work now has a release gate — every building measured against its own photograph, every building with a collision proxy and a LOD, no placeholder materials — so "modelled" means verified, not merely present. The price is that realism work stops being sliced into patches, and the schedule shifts by one minor cycle. Blockouts without a photograph stay blockouts and are listed as such, rather than being dressed up to fill the milestone.
## ADR-9: Third-person body is an attachment of the first-person pawn, not a second pawn
- **Context**: `0.2.0` needs a visible body and a third-person camera, while `0.1.0` shipped a first-person pawn with no body and the owner's decision was first person for that release. Two separate pawn classes would duplicate movement, input and reset logic, which is the code most likely to drift.
- **Decision**: one pawn, both rigs. `AGigLineCharacter` carries a skeletal mesh (hidden in first person), a collision-tested spring-arm camera, and a **V** toggle that swaps the active camera and the body's visibility. The shipped default view stays first person; `-thirdperson` starts the pawn in the third-person rig and is what the screenshot run passes, so the review shot is reproducible without a human pressing a key.
- **Consequences**: movement, reset, pause and the QA self-test stay in one place. The body's animation state is driven from `Tick` by speed rather than from input, so being pushed or falling animates correctly. The cost is that the camera swap is not a separate actor, so a future cinematic camera needs its own path rather than reusing the toggle.
# Quality Requirements
## Quality Requirements Overview ## Quality Requirements Overview
Ordered by priority: realism first, performance last. The order decides design trade-offs; it does not license dropping a lower item silently (see the trade-off rule below the table). Ordered by priority: realism first, performance last. The order decides design trade-offs; it does not license dropping a lower item silently (see the trade-off rule below the table).
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.
@@ -1,6 +1,9 @@
#include "GigLineCharacter.h" #include "GigLineCharacter.h"
#include "Animation/AnimSequence.h"
#include "Camera/CameraComponent.h" #include "Camera/CameraComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Components/CapsuleComponent.h" #include "Components/CapsuleComponent.h"
#include "EnhancedInputComponent.h" #include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h" #include "EnhancedInputSubsystems.h"
@@ -28,7 +31,7 @@ static TAutoConsoleVariable<int32> CVarGigLineSetupLogs(
AGigLineCharacter::AGigLineCharacter() AGigLineCharacter::AGigLineCharacter()
{ {
PrimaryActorTick.bCanEverTick = false; PrimaryActorTick.bCanEverTick = true;
// 1.75 m tall, 0.42 m wide, human scale in Unreal units (1 m = 100 uu). // 1.75 m tall, 0.42 m wide, human scale in Unreal units (1 m = 100 uu).
GetCapsuleComponent()->InitCapsuleSize(42.0f, 87.5f); GetCapsuleComponent()->InitCapsuleSize(42.0f, 87.5f);
@@ -51,9 +54,59 @@ AGigLineCharacter::AGigLineCharacter()
FirstPersonCamera->SetRelativeLocation(FVector(0.0f, 0.0f, 72.5f)); // eye height 1.60 m FirstPersonCamera->SetRelativeLocation(FVector(0.0f, 0.0f, 72.5f)); // eye height 1.60 m
FirstPersonCamera->bUsePawnControlRotation = true; FirstPersonCamera->bUsePawnControlRotation = true;
// An eye-level mesh is not needed for 0.1.0; the body is a capsule with a camera. // Visible body (0.2.0). The animated import carries its own skeleton, so the
GetMesh()->SetHiddenInGame(true); // 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/Character_Anim.Character_Anim"));
if (BodyFinder.Succeeded())
{
GetMesh()->SetSkeletalMesh(BodyFinder.Object);
}
// The animated import carries no material of its own; without this the body is
// grey clay. The warm skin material was created by the character import.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> SkinFinder(
TEXT("/Game/Test/Character/M_Character_Export.M_Character_Export"));
if (SkinFinder.Succeeded())
{
GetMesh()->SetMaterial(0, SkinFinder.Object);
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> IdleFinder(
TEXT("/Game/Test/Character/Character_AnimHuman_rig_GigLine_Idle.Character_AnimHuman_rig_GigLine_Idle"));
if (IdleFinder.Succeeded())
{
IdleAnimation = IdleFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> WalkFinder(
TEXT("/Game/Test/Character/Character_AnimHuman_rig_GigLine_Walk.Character_AnimHuman_rig_GigLine_Walk"));
if (WalkFinder.Succeeded())
{
WalkAnimation = WalkFinder.Object;
}
GetMesh()->SetRelativeLocation(FVector(0.0f, 0.0f, -87.5f));
GetMesh()->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
GetMesh()->SetCollisionEnabled(ECollisionEnabled::NoCollision); GetMesh()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
GetMesh()->SetHiddenInGame(true);
// 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"));
ThirdPersonArm->SetupAttachment(GetCapsuleComponent());
ThirdPersonArm->SetRelativeLocation(FVector(0.0f, 0.0f, 60.0f));
ThirdPersonArm->TargetArmLength = ThirdPersonBoomLength;
ThirdPersonArm->bUsePawnControlRotation = true;
ThirdPersonArm->bDoCollisionTest = true;
ThirdPersonArm->ProbeSize = 12.0f;
ThirdPersonArm->ProbeChannel = ECollisionChannel::ECC_Camera;
ThirdPersonArm->bEnableCameraLag = true;
ThirdPersonArm->CameraLagSpeed = 8.0f;
ThirdPersonArm->SetAutoActivate(false);
ThirdPersonCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("ThirdPersonCamera"));
ThirdPersonCamera->SetupAttachment(ThirdPersonArm, USpringArmComponent::SocketName);
ThirdPersonCamera->bUsePawnControlRotation = false;
ThirdPersonCamera->SetAutoActivate(false);
ThirdPersonCamera->SetFieldOfView(90.0f);
// Hard-reference the Enhanced Input assets with ConstructorHelpers so the // Hard-reference the Enhanced Input assets with ConstructorHelpers so the
// cooker always includes them and they load during CDO construction. Runtime // cooker always includes them and they load during CDO construction. Runtime
@@ -78,6 +131,8 @@ AGigLineCharacter::AGigLineCharacter()
TEXT("/Game/Input/IA_Jump.IA_Jump")); TEXT("/Game/Input/IA_Jump.IA_Jump"));
static ConstructorHelpers::FObjectFinder<UInputAction> ResetF( static ConstructorHelpers::FObjectFinder<UInputAction> ResetF(
TEXT("/Game/Input/IA_Reset.IA_Reset")); TEXT("/Game/Input/IA_Reset.IA_Reset"));
static ConstructorHelpers::FObjectFinder<UInputAction> CamF(
TEXT("/Game/Input/IA_ToggleCamera.IA_ToggleCamera"));
static ConstructorHelpers::FObjectFinder<UInputAction> PauseF( static ConstructorHelpers::FObjectFinder<UInputAction> PauseF(
TEXT("/Game/Input/IA_Pause.IA_Pause")); TEXT("/Game/Input/IA_Pause.IA_Pause"));
@@ -89,6 +144,7 @@ AGigLineCharacter::AGigLineCharacter()
LookAction = LookF.Object; LookAction = LookF.Object;
JumpAction = JumpF.Object; JumpAction = JumpF.Object;
ResetAction = ResetF.Object; ResetAction = ResetF.Object;
ToggleCameraAction = CamF.Object;
PauseAction = PauseF.Object; PauseAction = PauseF.Object;
if (!MappingContext || !MoveAction || !MoveBackAction || !StrafeAction || !StrafeLeftAction if (!MappingContext || !MoveAction || !MoveBackAction || !StrafeAction || !StrafeLeftAction
@@ -138,6 +194,23 @@ void AGigLineCharacter::BeginPlay()
StartLocation = GetActorLocation(); StartLocation = GetActorLocation();
StartRotation = GetActorRotation(); StartRotation = GetActorRotation();
// `-thirdperson` starts the pawn in the third-person rig. The first-person view
// stays the default (owner decision for 0.1.0), but the screenshot run needs the
// body visible without a human pressing V, so the review is reproducible.
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log,
TEXT("GigLine: BeginPlay thirdperson=%d body=%s idle=%s walk=%s"),
FParse::Param(FCommandLine::Get(), TEXT("thirdperson")) ? 1 : 0,
GetMesh()->GetSkeletalMeshAsset() ? TEXT("ok") : TEXT("nil"),
IdleAnimation ? TEXT("ok") : TEXT("nil"),
WalkAnimation ? TEXT("ok") : TEXT("nil"));
}
if (FParse::Param(FCommandLine::Get(), TEXT("thirdperson")))
{
ToggleCamera();
}
// The PlayerStart sits above grade so the capsule cannot spawn embedded, so // The PlayerStart sits above grade so the capsule cannot spawn embedded, so
// the BeginPlay position is mid-air. Re-capture once the pawn has landed, // the BeginPlay position is mid-air. Re-capture once the pawn has landed,
// otherwise Reset drops the player 1.4 m back into the air every press. // otherwise Reset drops the player 1.4 m back into the air every press.
@@ -557,6 +630,10 @@ void AGigLineCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputCo
{ {
Input->BindAction(PauseAction, ETriggerEvent::Started, this, &AGigLineCharacter::TogglePause); Input->BindAction(PauseAction, ETriggerEvent::Started, this, &AGigLineCharacter::TogglePause);
} }
if (ToggleCameraAction)
{
Input->BindAction(ToggleCameraAction, ETriggerEvent::Started, this, &AGigLineCharacter::ToggleCamera);
}
if (JumpAction) if (JumpAction)
{ {
Input->BindAction(JumpAction, ETriggerEvent::Started, this, &AGigLineCharacter::StartJump); Input->BindAction(JumpAction, ETriggerEvent::Started, this, &AGigLineCharacter::StartJump);
@@ -644,6 +721,87 @@ void AGigLineCharacter::Look(const FInputActionValue& Value)
AddControllerPitchInput(Axis.Y * LookSensitivity * (bInvertLookY ? -1.0f : 1.0f)); AddControllerPitchInput(Axis.Y * LookSensitivity * (bInvertLookY ? -1.0f : 1.0f));
} }
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.
UpdateBodyAnimation();
}
void AGigLineCharacter::ToggleCamera()
{
// Both rigs exist from construction; the toggle only swaps which one is active
// and whether the body is drawn. Deactivating the other camera matters: with
// two active cameras the view is whichever was registered last.
bThirdPerson = !bThirdPerson;
FirstPersonCamera->SetActive(!bThirdPerson);
ThirdPersonCamera->SetActive(bThirdPerson);
GetMesh()->SetHiddenInGame(!bThirdPerson);
if (bThirdPerson)
{
UpdateBodyAnimation();
}
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine: camera %s"), bThirdPerson ? TEXT("third person") : TEXT("first person"));
}
}
void AGigLineCharacter::UpdateBodyAnimation()
{
if (!bThirdPerson || !GetMesh())
{
return;
}
const bool bMoving = GetVelocity().Size2D() > WalkAnimationThreshold;
UAnimSequence* Wanted = bMoving ? WalkAnimation.Get() : IdleAnimation.Get();
if (!Wanted)
{
// Logged once: a missing clip presents as an A-posed body, which looks like
// an animation problem rather than a missing asset reference.
static bool bWarnedMissingClip = false;
if (!bWarnedMissingClip)
{
bWarnedMissingClip = true;
UE_LOG(LogTemp, Warning,
TEXT("GigLine: body clip missing (idle=%s walk=%s) - body stays in bind pose"),
IdleAnimation ? TEXT("ok") : TEXT("nil"),
WalkAnimation ? TEXT("ok") : TEXT("nil"));
}
return;
}
// PlayAnimation is only an intent: it needs the component in single-node mode and
// a live single-node instance. The body is hidden at construction and only
// unhidden by the camera toggle, so the first call could land on a component that
// was not ready and never retried - the clip name logged, the pose left at bind.
// Driving the mode and the instance explicitly, and re-asserting every frame while
// the instance is missing, makes that failure self-correcting.
if (GetMesh()->GetAnimationMode() != EAnimationMode::AnimationSingleNode)
{
GetMesh()->SetAnimationMode(EAnimationMode::AnimationSingleNode);
}
if (Wanted != PlayingAnimation || GetMesh()->GetSingleNodeInstance() == nullptr)
{
PlayingAnimation = Wanted;
GetMesh()->SetAnimation(Wanted);
GetMesh()->Play(true);
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
static bool bLoggedAnimState = false;
if (!bLoggedAnimState)
{
bLoggedAnimState = true;
UE_LOG(LogTemp, Log, TEXT("GigLine: body clip %s, mode=%d, instance=%s"),
*Wanted->GetName(), static_cast<int32>(GetMesh()->GetAnimationMode()),
GetMesh()->GetSingleNodeInstance() ? TEXT("ok") : TEXT("nil"));
}
}
}
}
void AGigLineCharacter::ResetToStart() void AGigLineCharacter::ResetToStart()
{ {
// Reset is a level-restart in miniature: put the body back where it spawned, // Reset is a level-restart in miniature: put the body back where it spawned,
@@ -7,9 +7,11 @@
#include "InputActionValue.h" #include "InputActionValue.h"
#include "GigLineCharacter.generated.h" #include "GigLineCharacter.generated.h"
class UAnimSequence;
class UCameraComponent; class UCameraComponent;
class UInputAction; class UInputAction;
class UInputMappingContext; class UInputMappingContext;
class USpringArmComponent;
/** /**
@@ -29,6 +31,7 @@ public:
virtual void BeginPlay() override; virtual void BeginPlay() override;
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override; virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
virtual void Tick(float DeltaSeconds) override;
protected: protected:
/** Loads the Enhanced Input assets from /Game/Input; called from BeginPlay. */ /** Loads the Enhanced Input assets from /Game/Input; called from BeginPlay. */
@@ -38,6 +41,23 @@ protected:
void ResetToStart(); void ResetToStart();
void TogglePause(); void TogglePause();
/** Swaps between the first-person and third-person rigs (V). */
void ToggleCamera();
/**
* Plays the idle or walk clip on the visible body.
*
* Called from the movement tick rather than from input: an animation state
* driven by speed is correct whether the player walked, was pushed, or fell.
* Only switches when the clip changes, because PlayAnimation restarts a clip
* and calling it every frame would freeze the cycle at its first key.
*/
void UpdateBodyAnimation();
/** Velocity above which the walk clip plays, in cm/s (0.1 m/s tolerance). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Animation")
float WalkAnimationThreshold = 10.0f;
/** /**
* TEMP QA: scripted walk / reset / pause verification. * TEMP QA: scripted walk / reset / pause verification.
* *
@@ -109,6 +129,39 @@ protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera") UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UCameraComponent> FirstPersonCamera; TObjectPtr<UCameraComponent> FirstPersonCamera;
/**
* Third-person boom. Collision testing is on, which is what stops the camera
* from ending up inside a wall: the arm shortens until the camera is clear
* instead of clipping through geometry.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<USpringArmComponent> ThirdPersonArm;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UCameraComponent> ThirdPersonCamera;
/** Boom length in cm: far enough to see the body, close enough to read the street. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
float ThirdPersonBoomLength = 340.0f;
UPROPERTY()
TObjectPtr<UAnimSequence> IdleAnimation;
UPROPERTY()
TObjectPtr<UAnimSequence> WalkAnimation;
/** True while the third-person rig is the active view. */
UPROPERTY(BlueprintReadOnly, Category = "Camera")
bool bThirdPerson = false;
/** Clip currently playing on the body, so UpdateBodyAnimation can diff it. */
UPROPERTY()
TObjectPtr<UAnimSequence> PlayingAnimation;
/** Toggle-camera action (V). */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> ToggleCameraAction;
/** Degrees per second per unit of mouse input; defaults to Unreal's usual 45. */ /** Degrees per second per unit of mouse input; defaults to Unreal's usual 45. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
float LookSensitivity = 45.0f; float LookSensitivity = 45.0f;
+33 -86
View File
@@ -423,111 +423,58 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — quality priority flipped: realism first, performance last ## 2026-11-09 — quality priority flipped: realism first, performance last
- Owner decision: realism leads; performance closes the quality list. Applied in - Owner decision: realism leads; performance closes the quality list. Applied in `Docs/Architecture/01_introduction_and_goals.md`, `10_quality_requirements.md`, `04_solution_strategy.md`, the combined `architecture.md`, `Docs/release-checks.md` (performance section now last, with the reason), `ROADMAP.md` (a "Quality priority" section) and `AGENTS.md` (the tie-break rule next to the scope-cutting rule).
`Docs/Architecture/01_introduction_and_goals.md`, `10_quality_requirements.md`, - Ordering used: 1 visual realism, 2 geographic fidelity, 3 walkability/usability, 4 build reproducibility/portability, 5 provenance, 6 performance.
`04_solution_strategy.md`, the combined `architecture.md`, `Docs/release-checks.md` - **Both revisions carry the same guard so the order cannot be used as an excuse:** performance is a _floor_, not a preference — 60 FPS at 1440p without frame generation must be met at every release, and if realism cannot fit, the realism scope is cut deliberately and recorded in `CHANGELOG.md`, never the frame rate. Walkability outranks realism in one direction: a block the player falls through is not a demo.
(performance section now last, with the reason), `ROADMAP.md` (a "Quality priority"
section) and `AGENTS.md` (the tie-break rule next to the scope-cutting rule).
- Ordering used: 1 visual realism, 2 geographic fidelity, 3 walkability/usability,
4 build reproducibility/portability, 5 provenance, 6 performance.
- **Both revisions carry the same guard so the order cannot be used as an excuse:**
performance is a *floor*, not a preference — 60 FPS at 1440p without frame
generation must be met at every release, and if realism cannot fit, the realism
scope is cut deliberately and recorded in `CHANGELOG.md`, never the frame rate.
Walkability outranks realism in one direction: a block the player falls through is
not a demo.
## 2026-11-09 — 0.1.5 start: retail reference + kit layout maths ## 2026-11-09 — 0.1.5 start: retail reference + kit layout maths
- Commons holds exactly **one** usable reference for the retail run: - Commons holds exactly **one** usable reference for the retail run: `Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg` (Gb321, CC BY-SA 4.0, 1920×953) — 6800 Lee Drug **and** its neighbour at street level, shopfronts and parapets visible (vision pre-check: yes). Nothing for 6822 or 6806–6820, so those two façades still have no reference and stay blocked.
`Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg` (Gb321, CC BY-SA - **Kit layout was wrong for tall/wide buildings**, not just ugly: bays were `round(width / 496)`, so 6922 (65.8 m wide, 37.1 m tall) got **13 bays** — a window every 4.96 m across the whole frontage — and the storey count came out at 11 × 310 cm = 34.1 m against a 35.7 m window zone, leaving bare wall under the cornice. Fixed: `MAX_BAYS = 9` (module widens instead of subdividing), and the storey spread is derived from the available height (`usable / storeys`) so the top band always lands inside the building. Result: 6922 9 × 11, theatre 8 × 6, 6806 7 × 3, 6800 4 × 3, TCL 9 × 5.
4.0, 1920×953) — 6800 Lee Drug **and** its neighbour at street level, shopfronts
and parapets visible (vision pre-check: yes). Nothing for 6822 or 6806–6820, so
those two façades still have no reference and stay blocked.
- **Kit layout was wrong for tall/wide buildings**, not just ugly: bays were
`round(width / 496)`, so 6922 (65.8 m wide, 37.1 m tall) got **13 bays** — a
window every 4.96 m across the whole frontage — and the storey count came out at
11 × 310 cm = 34.1 m against a 35.7 m window zone, leaving bare wall under the
cornice. Fixed: `MAX_BAYS = 9` (module widens instead of subdividing), and the
storey spread is derived from the available height (`usable / storeys`) so the top
band always lands inside the building. Result: 6922 9 × 11, theatre 8 × 6,
6806 7 × 3, 6800 4 × 3, TCL 9 × 5.
## 2026-11-09 — hero fluting + motto band; and a silent placement divergence ## 2026-11-09 — hero fluting + motto band; and a silent placement divergence
- Fluting: 20 grooves per column as thin shadow-tone cylinders set into the shaft - Fluting: 20 grooves per column as thin shadow-tone cylinders set into the shaft (120 pieces) rather than boolean cuts — the eye reads them in silhouette and grazing light, booleans would cost far more for that. Motto band is now a real recess: surround 6 cm proud, panel 12 cm behind (was an 8 cm dark stripe). Hero 148 parts/3 044 tris -> 271/5 492, still 14.50 m.
(120 pieces) rather than boolean cuts — the eye reads them in silhouette and - **The real find:** `ue_hero_facade_import.py` had its own hero position `(-12024.5, -1450, 0)` — the pre-`0.1.0` value, before the frontage placement fix moved the hero to `(-13336, +1307)`. Running the importer on its own therefore teleported the hero **13 m east and onto the north side of the boulevard**, and nothing complained: a displaced building still renders. The only symptom was the reference score dropping 0.69 -> 0.56 with the 3x3 composition correlation flipping +0.54 -> -0.08.
grazing light, booleans would cost far more for that. Motto band is now a real - Fix: `Tools/ue_placement.py` is now the single source of truth (hero position, verified frontage spans, `span_error`/`describe_span_failure`). Both `ue_hero_facade_import.py` and `ue_build_demo_map.py` import it, the import asserts the staged span and logs `PLACEMENT ERROR` when it does not match, and tests assert the stale literal cannot come back. Spawn-view score back to **0.72** (best yet).
recess: surround 6 cm proud, panel 12 cm behind (was an 8 cm dark stripe). - Lesson to keep: **duplicated placement constants drift silently.** The metric that caught it was the composition correlation, not a visual review.
Hero 148 parts/3 044 tris -> 271/5 492, still 14.50 m.
- **The real find:** `ue_hero_facade_import.py` had its own hero position
`(-12024.5, -1450, 0)` — the pre-`0.1.0` value, before the frontage placement fix
moved the hero to `(-13336, +1307)`. Running the importer on its own therefore
teleported the hero **13 m east and onto the north side of the boulevard**, and
nothing complained: a displaced building still renders. The only symptom was the
reference score dropping 0.69 -> 0.56 with the 3x3 composition correlation
flipping +0.54 -> -0.08.
- Fix: `Tools/ue_placement.py` is now the single source of truth (hero position,
verified frontage spans, `span_error`/`describe_span_failure`). Both
`ue_hero_facade_import.py` and `ue_build_demo_map.py` import it, the import asserts
the staged span and logs `PLACEMENT ERROR` when it does not match, and tests assert
the stale literal cannot come back. Spawn-view score back to **0.72** (best yet).
- Lesson to keep: **duplicated placement constants drift silently.** The metric that
caught it was the composition correlation, not a visual review.
## 2026-11-09 — 1440p re-measured with the kit: 363 fps, ~12% cost ## 2026-11-09 — 1440p re-measured with the kit: 363 fps, ~12% cost
- 2560×1440, Epic preset, TSR, VSync off, no frame generation: **2.76 ms average - 2560×1440, Epic preset, TSR, VSync off, no frame generation: **2.76 ms average (363 fps)**, p50 2.73, p95 3.17, p99 3.64, max 4.62 — against `0.1.0`'s 2.42 ms (414 fps). The 625 kit actors + 8 generated textures cost about **12 %** and leave ~6× the 60 fps floor.
(363 fps)**, p50 2.73, p95 3.17, p99 3.64, max 4.62 — against `0.1.0`'s 2.42 ms - **Variance trap confirmed a third time:** runs reporting ~105 fps (9.5 ms) were two overlapping game instances — an earlier launch had not exited. `pgrep -af Binaries/Linux/GigLine` should show one wrapper + one binary; 363 fps reproduced twice on a clean run. Always count instances before believing a perf number.
(414 fps). The 625 kit actors + 8 generated textures cost about **12 %** and leave - Series committed at `Docs/perf/0.1.5-kit-walk-frametimes.csv`; comparison table in `Docs/release-checks.md`.
~6× the 60 fps floor.
- **Variance trap confirmed a third time:** runs reporting ~105 fps (9.5 ms) were
two overlapping game instances — an earlier launch had not exited. `pgrep -af
Binaries/Linux/GigLine` should show one wrapper + one binary; 363 fps reproduced
twice on a clean run. Always count instances before believing a perf number.
- Series committed at `Docs/perf/0.1.5-kit-walk-frametimes.csv`; comparison table in
`Docs/release-checks.md`.
## 2026-11-09 — 604 unmaterialed actors: material rebuild was delete+recreate ## 2026-11-09 — 604 unmaterialed actors: material rebuild was delete+recreate
- The "blockout cleaning pass" turned out to need **material and placement hygiene, - The "blockout cleaning pass" turned out to need **material and placement hygiene, not more geometry**. `Tools/ue_audit_scene.py` (new) found **604 of 620 mesh actors with no material**: kit pieces and theatre trim all untextured.
not more geometry**. `Tools/ue_audit_scene.py` (new) found **604 of 620 mesh actors - **Cause:** `ensure_material` in `ue_build_demo_map.py` deleted and recreated each `/Game/Hollywood/Site/M_*` asset every run ("so a look change cannot be masked by a stale asset"). That orphane every reference: actors staged by other tools pointed at deleted assets and fell back to the default material. The map looked only _slightly_ flat — no error, no warning.
with no material**: kit pieces and theatre trim all untextured. - **Fix:** update the material **in place** — load it, `MaterialEditingLibrary. delete_all_material_expressions(mat)`, rebuild the graph, recompile, save. Note `Material.expressions` is protected from Python; use the library's deleter.
- **Cause:** `ensure_material` in `ue_build_demo_map.py` deleted and recreated each - **Ordering rule now documented and enforced:** the demo builder rebuilds materials, so kit and theatre staging must run _after_ it. `render_screenshots.sh` now runs demo map → kit assembly → theatre trim → scene audit → shot cameras → capture, and **fails on a non-zero audit count**.
`/Game/Hollywood/Site/M_*` asset every run ("so a look change cannot be masked by a
stale asset"). That orphane every reference: actors staged by other tools pointed at
deleted assets and fell back to the default material. The map looked only *slightly*
flat — no error, no warning.
- **Fix:** update the material **in place** — load it, `MaterialEditingLibrary.
delete_all_material_expressions(mat)`, rebuild the graph, recompile, save. Note
`Material.expressions` is protected from Python; use the library's deleter.
- **Ordering rule now documented and enforced:** the demo builder rebuilds materials,
so kit and theatre staging must run *after* it. `render_screenshots.sh` now runs
demo map → kit assembly → theatre trim → scene audit → shot cameras → capture, and
**fails on a non-zero audit count**.
- Second find: 53 duplicated kit labels (both shop piers of a bay shared one name). - Second find: 53 duplicated kit labels (both shop piers of a bay shared one name).
- Scene audit after the fixes: **0 failures** (all materialed, all spans verified, no - Scene audit after the fixes: **0 failures** (all materialed, all spans verified, no duplicate labels). Packaged QA still PASS; spawn-view reference score 0.72.
duplicate labels). Packaged QA still PASS; spawn-view reference score 0.72.
## 2026-11-09 — v0.1.5 released ## 2026-11-09 — v0.1.5 released
- All actionable `0.1.5` work is done: hero fluting + recessed motto band, kit layout - All actionable `0.1.5` work is done: hero fluting + recessed motto band, kit layout for wide/tall buildings, placement single-source-of-truth, 1440p re-measure (363 fps), and the material/placement hygiene that the "blockout cleaning" task actually needed (604 unmaterialed actors, 53 duplicate labels) with a new scene audit gate.
for wide/tall buildings, placement single-source-of-truth, 1440p re-measure - Only item carried forward is the **retail frontage (6822 / 6806–6820 / 6800)**, blocked because Commons holds no usable reference for two of the three buildings and substituting the theatre's photographs would misattribute the façades. It now lives in a `0.1.6` Retail frontage patch.
(363 fps), and the material/placement hygiene that the "blockout cleaning" task - Precedent applied again: tag when the stated scope is done, carry blocked scope into the next patch rather than letting a release wait indefinitely on missing reference photography.
actually needed (604 unmaterialed actors, 53 duplicate labels) with a new scene
audit gate.
- Only item carried forward is the **retail frontage (6822 / 6806–6820 / 6800)**,
blocked because Commons holds no usable reference for two of the three buildings
and substituting the theatre's photographs would misattribute the façades. It now
lives in a `0.1.6` Retail frontage patch.
- Precedent applied again: tag when the stated scope is done, carry blocked scope
into the next patch rather than letting a release wait indefinitely on missing
reference photography.
## 2026-11-09 — Retail frontage and two pipeline traps ## 2026-11-09 — Retail frontage and two pipeline traps
- **`Tools/render_screenshots.sh` called `./Engine/...` for the capture step** while every commandlet above went through `run_cmdlet`'s `cd "$ENGINE"`. With `set -e` the script died at exit 127 *after* rebuilding the maps, so the PNGs silently stayed a day old and `--verify` never ran. Fixed to `"$ENGINE/Engine/Binaries/..."`. Symptom to remember: maps rebuild, screenshots do not move. - **`Tools/render_screenshots.sh` called `./Engine/...` for the capture step** while every commandlet above went through `run_cmdlet`'s `cd "$ENGINE"`. With `set -e` the script died at exit 127 _after_ rebuilding the maps, so the PNGs silently stayed a day old and `--verify` never ran. Fixed to `"$ENGINE/Engine/Binaries/..."`. Symptom to remember: maps rebuild, screenshots do not move.
- **The generic façade kit is applied to every actor labelled `Frontage_*` or `North_*`**, so staging a hand-modelled façade under those labels gets the kit pasted over it — two contradicting façade languages on one wall. `ue_apply_facade_kit.py` now skips `MODELLED` labels, and retail trim stages as `RetailTrim_*`. - **The generic façade kit is applied to every actor labelled `Frontage_*` or `North_*`**, so staging a hand-modelled façade under those labels gets the kit pasted over it — two contradicting façade languages on one wall. `ue_apply_facade_kit.py` now skips `MODELLED` labels, and retail trim stages as `RetailTrim_*`.
- **A Blender builder cannot be imported by a unit test** (`import bpy` at module scope). Pure layout arithmetic lives in `Tools/_common.py`; `Tools/tests/test_retail_frontage.py` tests it there, and the builder delegates. Same trick `fbx_export_preset.py` uses for its preset dicts. - **A Blender builder cannot be imported by a unit test** (`import bpy` at module scope). Pure layout arithmetic lives in `Tools/_common.py`; `Tools/tests/test_retail_frontage.py` tests it there, and the builder delegates. Same trick `fbx_export_preset.py` uses for its preset dicts.
- **Joining 85 parts into one mesh per building** before export cut the Unreal import from 85 assets to 2, and the staging step to two spawns. Record per-part dimensions *before* the join: after it only the joined bounds exist. - **Joining 85 parts into one mesh per building** before export cut the Unreal import from 85 assets to 2, and the staging step to two spawns. Record per-part dimensions _before_ the join: after it only the joined bounds exist.
## 2026-11-09 — Third-person body renders in bind pose (open defect)
- **Symptom**: the pawn's body is visible and skinned, the run logs `GigLine: playing body clip Character_AnimHuman_rig_GigLine_Idle`, and the figure still stands in the A-pose bind. Two captures were taken before the call was proven to succeed — the camera, then the material, were blamed first and neither was the cause.
- **The FBX is not the problem.** Re-importing `/tmp/gigline/character-anim.fbx` into Blender gives two actions, `GigLine_Idle` (frames 1–60) and `GigLine_Walk` (1–30), each with **1379 f-curves over 137 bones**. Animation is baked and exported correctly, so the defect is on the Unreal side.
- **The material was a real but separate defect**: `M_Character_Export` had no `SkeletalMesh` usage flag, so Unreal substituted the default grey with only a `LogMaterial` warning. Fixed in place (`used_with_skeletal_mesh = True`).
- **`PlayAnimation` alone is not enough.** `USkeletalMeshComponent::PlayAnimation` needs the component already in `AnimationSingleNode` mode; the body is hidden at construction (`SetHiddenInGame(true)`) and only unhidden by the camera toggle, so the first call can land on a component that is not yet in the right mode and never retries — the guard `Wanted == PlayingAnimation` then returns forever and the pose stays at bind.
- **What to log next time**: animation mode and `GetSingleNodeInstance()` together with the clip name. A clip name alone shows the intent, not the effect.
- **Fixed 2026-11-09, two independent causes.** (1) `PlayAnimation` needs `AnimationSingleNode` plus a live single-node instance; the body is hidden at construction and only unhidden by the camera toggle, so the first call could land before the component was ready and the `Wanted == PlayingAnimation` guard then returned forever. `UpdateBodyAnimation` now sets the mode, calls `SetAnimation` + `Play`, and re-asserts while `GetSingleNodeInstance()` is null. (2) The arms-down offsets lived only in the clip keys — the exporter writes clip data **relative to the rest pose**, so they arrived in Unreal as a ±20° swing around an A-posed bind. `apply_base_pose` now bakes them into the rest pose before any clip is authored, which is what `mpfb_character_export.py --pose idle` already did.
- **The base pose needs inverted signs when baked to rest.** Applied as written, the FBX axis mapping produced arms raised in a V; negating the table gives arms down. Both states were verified on the imported asset, not assumed.
- **Unreal bone names use underscores**: `upperarm01_L`, not Blender's `upperarm01.L`. `GetBoneIndex(TEXT("upperarm01.L"))` returns INDEX_NONE, `GetBoneTransform` then returns identity, and a probe reading it reports a constant 0.0000 — which looks like "animation not applied" and is actually "bone not found". Check `AnimPose.get_bone_names()` before trusting any bone probe.
- **`Log` verbosity does not reach the capture log, `Warning` does.** Two diagnostic runs produced zero lines because the messages were `UE_LOG(LogTemp, Log, ...)`; the same lines at `Warning` appeared. Instrument with Warning when the evidence has to survive into `/tmp/gigline/render-*.log`.
+42 -18
View File
@@ -16,13 +16,14 @@ Stages the demo shot cameras, runs the game on `/Game/Hollywood/DemoMap`, copies
Every shot is embedded below, in order, so the whole review happens in this file. Every shot is embedded below, in order, so the whole review happens in this file.
| # | Shot | Verified | Approved | | # | Shot | Verified | Approved |
| --- | ------------------------------------- | ------------ | ------------ | | --- | --- | --- | --- |
| 1 | [Spawn view](#1-spawn-view) | ☑ 2026-11-09 | ☑ 2026-11-09 | | 1 | [Spawn view](#1-spawn-view) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 2 | [Street level](#2-street-level) | ☑ 2026-11-09 | ☑ 2026-11-09 | | 2 | [Street level](#2-street-level) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 3 | [Crossing](#3-crossing) | ☑ 2026-11-09 | ☑ 2026-11-09 | | 3 | [Crossing](#3-crossing) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 4 | [North buildings](#4-north-buildings) | ☑ 2026-11-09 | ☑ 2026-11-09 | | 4 | [North buildings](#4-north-buildings) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 5 | [Retail frontage](#5-retail-frontage) | ☑ 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 | ☐ pending |
**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. **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.
@@ -78,8 +79,6 @@ Every shot is embedded below, in order, so the whole review happens in this file
- **File:** `demo/review-demo-retail-frontage.png` - **File:** `demo/review-demo-retail-frontage.png`
- **View:** north sidewalk, level horizon, 68° lens - **View:** north sidewalk, level horizon, 68° lens
- **Expected content:** 6800 Lee Drug and 6806–6820 façades, full width in frame, showing the parapet, mural row, and lettering band
- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09
- **Expected content:** the retail run beside the hero — 6800 Lee Drug's bronze cornice, mural row and parapet next to 6806–6820's burgundy fascia, pilasters and window storey - **Expected content:** the retail run beside the hero — 6800 Lee Drug's bronze cornice, mural row and parapet next to 6806–6820's burgundy fascia, pilasters and window storey
- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09 - **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09
@@ -95,6 +94,28 @@ Known gaps in this first pass, recorded rather than hidden:
--- ---
## 6. Third-person body
![Third-person body: the player pawn standing on the north sidewalk, seen from behind](demo/review-demo-third-person.png)
- **File:** `demo/review-demo-third-person.png`
- **View:** third-person boom, ~6 m behind the pawn, captured with `-thirdperson` so the pawn starts in the third-person rig
- **Expected content:** the player's own visible body on the north sidewalk, seen from behind, with the hero façade across the street
- **Verified:** ☑ 2026-11-09 · **Approved:** ☐ pending
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 now drives the body.** The arms hang at the sides rather than standing out in a bind A-pose, which is what the first three captures of this shot showed.
What is still not right, and is tracked:
- **The arms hang too far forward and the hands splay outward**, so the pose reads as a person standing awkwardly rather than at rest. The base pose is applied to the rest pose with inverted signs (the FBX axis mapping flips it); the elbow and hand orientation still need work, and that belongs to the `0.2.2` skin/animation tuning pass rather than to this rig.
- **The walk clip has no shot of its own.** It imports and its curves carry a ±18° arm swing and a 0.45 rad hip swing, but nothing has captured it in motion; a walking shot needs a camera that follows a moving pawn.
- **Camera collision is implemented but unproven:** the spawn point has open space behind it, so no shot exercises the arm shortening.
---
## The iteration loop (uncommitted) ## The iteration loop (uncommitted)
`Tools/visual_loop.py` measures each capture against its reference photograph and turns the deltas into modelling actions for the Blender skill packs: `Tools/visual_loop.py` measures each capture against its reference photograph and turns the deltas into modelling actions for the Blender skill packs:
@@ -115,16 +136,19 @@ Note the comparison is only meaningful when the shot and the reference frame the
## Known cosmetic issues ## Known cosmetic issues
Each issue is tracked in `TODO.md` under the version that will fix it; an issue with no ticket is a bug in the plan, not a note. Every issue is tracked in `TODO.md` under the version that will fix it; an issue with no ticket is a bug in the plan, not a note.
| Issue | Where it is tracked | State | | Issue | Where it is tracked | State |
| --- | --- | --- | | --- | --- | --- |
| Hero stone renders pale — the material is a procedural graph and a generated texture set, not a measured surface | `0.9.0` → Materials, UVs and performance: **measured material library** | Open | | Hero stone renders pale — the material is a procedural graph and a generated texture set, not a measured surface | `0.9.0` → measured material library | Open |
| Hero façade has no doors at street level left and right of the central bay, and the central bay elements (columns, stairs, set-back entrance, door frames, signage) are not positioned as the photographs show | `0.9.0` → Per-building models: **hero façade (6840)** | Open | | Hero façade has no doors at street level left and right of the central bay, and the central-bay elements (columns, stairs, set-back entrance, door frames, signage) are not positioned as the photographs show | `0.9.0` → per-building models, hero façade | Open |
| Nothing should 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 (6840)**, same item | Open | | 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 |
| Between kerb and building line the walkable surface is the terrain mesh, not a modelled pavement | `0.6.0` → Look and sound: **model the pavement between kerb and building line** | 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`) |
| Retail parapet has no moulded profile, and wall lettering built as a plate does not read at 40 m | `0.9.0` → Per-building models: **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`) |
| 6822 Budget is a bare blockout volume — no photograph exists to model it from | `0.9.0` → Per-building models: **photography gap** | Deferred (moved out of `0.1.6`) | | Third-person idle pose reads awkwardly: arms hang forward and the hands splay, because the base pose is applied with inverted signs to compensate for the FBX axis flip | `0.2.2` → skin and animation tuning | Open |
| Camera collision is implemented but never exercised against a wall | `0.2.0` → camera collision | 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 |
Accepted by the owner, not a defect: Accepted by the owner, not a defect:
@@ -132,5 +156,5 @@ Accepted by the owner, not a defect:
Resolved and removed from this list on 2026-11-09: Resolved and removed from this list on 2026-11-09:
- "The El Capitan Theatre has no marquee or blade sign" — the trim has been staged since `Tools/ue_stage_theatre_trim.py` landed; the demo map carries all six trim actors (`/tmp/gigline/theatre-import.txt`). - "The El Capitan Theatre has no marquee or blade sign" — `Tools/ue_stage_theatre_trim.py` stages all six trim actors; verified in `/tmp/gigline/theatre-import.txt`.
- "Materials are not yet created in Blender or fully integrated" — contradicted by the Scene conventions above: every surface carries a look-dev material built in `Tools/ue_build_demo_map.py`. - "Materials are not yet created in Blender or fully integrated" — contradicted by the scene conventions above: every surface carries a look-dev material built in `Tools/ue_build_demo_map.py`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -11,19 +11,19 @@ Actionable task list for this project. This file is the single, central place fo
## 0.2.0 — MPFB player body, third-person camera, basic animation (weeks 7–9) ## 0.2.0 — MPFB player body, third-person camera, basic animation (weeks 7–9)
### Character setup ### Character setup - active - session-5598ce7e-27e3-4c64-bb49-c04a4a31ee58 (branch `anim-body-0.2.0`)
- [x] Generate MPFB human base mesh; export baked materials and deform skeleton. Done in bootstrap: idle-pose 19 158-vert human with a 137-bone standard rig, `Tools/mpfb_character_export.py` (now `--pose idle` + warm `make_diffuse`). - [x] Generate MPFB human base mesh; export baked materials and deform skeleton. Done in bootstrap: idle-pose 19 158-vert human with a 137-bone standard rig, `Tools/mpfb_character_export.py` (now `--pose idle` + warm `make_diffuse`).
- [x] Import character into Unreal; validate skeleton, scale, and materials. Done: `/Game/Test/Character` — `SkeletalMesh` (138 bones), `Skeleton`, warm `M_Character_Export` material with baked `T_Character_Diffuse`. - [x] Import character into Unreal; validate skeleton, scale, and materials. Done: `/Game/Test/Character` — `SkeletalMesh` (138 bones), `Skeleton`, warm `M_Character_Export` material with baked `T_Character_Diffuse`.
- [x] Promote the character asset into a reusable NPC prefab (visible body, collision capsule, idle pose) ready for pedestrians (`0.5.0`) once the third-person body lands. Done 2026-11-07: `AGigLineNPCPerson` (see the 0.1.0 Character-development item); `0.5.0` reuses it as the pedestrian base. - [x] Promote the character asset into a reusable NPC prefab (visible body, collision capsule, idle pose) ready for pedestrians (`0.5.0`) once the third-person body lands. Done 2026-11-07: `AGigLineNPCPerson` (see the 0.1.0 Character-development item); `0.5.0` reuses it as the pedestrian base.
- [ ] Create basic idle/walk animation set. - [x] Create basic idle/walk animation set. Done 2026-11-09: `Tools/character_anim_build.py` authors `GigLine_Idle` (60 f) and `GigLine_Walk` (30 f) at 30 fps from parametric cycles, and `Tools/ue_character_anim_import.py` imports them. Both AnimSequences verified in `/Game/Test/Character`: Idle 1.967 s, Walk 0.967 s. Limb axes were **measured** on the rig, not assumed — the probe rotated `upperleg01.L` 0.5 rad per axis and read `foot.L`, which showed X is the fore/aft swing (Y twists, Z abducts).
- [ ] Verify animation plays correctly on the imported rig. - [~] Verify animation plays correctly on the imported rig. **Fixed 2026-11-09**: the clip now drives the body — shot 6 shows the arms hanging at the sides instead of an A-pose. Two causes, both proven by measurement rather than guessed: (1) `PlayAnimation` needs the component in `AnimationSingleNode` with a live single-node instance, and the body is hidden at construction, so the first call could land before the component was ready and never retry; the code now sets the mode, calls `SetAnimation` + `Play`, and re-asserts while the instance is missing. (2) the arms-down offsets were carried only in the clip keys, which the exporter writes relative to the rest pose — so they arrived as a ±20° swing around an A-posed bind; they are now baked into the rest pose before the clips are authored (`apply_base_pose`). **Still open:** the idle pose reads awkwardly (arms hang forward, hands splay) because the base is applied with inverted signs to compensate for the FBX axis flip, and the walk clip has never been captured in motion. Earlier evidence: the pawn's mesh is bound, both clips resolve, and the run logs `GigLine: playing body clip Character_AnimHuman_rig_GigLine_Idle`, but shot 5 still renders the body in bind pose (arms out). So `PlayAnimation` is called and the pose is not applied — next step is to check whether the imported clip carries bone curves at all (the FBX bake may be sampling only keyed bones) or whether the component needs `SetAnimationMode(AnimationSingleNode)` before `SetAnimation`/`Play`.
### Camera and controls ### Camera and controls
- [ ] Add third-person camera following the player body. - [x] Add third-person camera following the player body. Done 2026-11-09: `USpringArmComponent` + camera on the capsule, boom 340 cm, lag 8, and the body is the animated skeletal mesh with the skin material. Review shot 5 (`demo-third-person`, `Screenshots/README.md`) shows the rig; the shipped default view stays first person.
- [ ] Add first/third-person toggle. - [x] Add first/third-person toggle. **V**, via `IA_ToggleCamera`; the toggle swaps the active camera and the body's visibility. `-thirdperson` on the command line starts in third person so the review is reproducible.
- [ ] Add camera collision to prevent clipping through geometry. - [x] Add camera collision to prevent clipping through geometry. The spring arm's collision test (`bDoCollisionTest`, probe 12 cm on `ECC_Camera`) shortens the boom instead of letting the camera pass through a wall. **Not yet exercised against a wall in a shot** — the spawn point has open space behind it, so this is implemented but unproven.
- [ ] Document any input changes. - [ ] Document any input changes.
### Release checks ### Release checks
+2
View File
@@ -45,6 +45,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| Tool | Purpose | | Tool | Purpose |
| --- | --- | | --- | --- |
| `hero_facade_build.py` | Parametric hero façade; emits a JSON report naming the evidence behind every part | | `hero_facade_build.py` | Parametric hero façade; emits a JSON report naming the evidence behind every part |
| `character_anim_build.py` | Authors the basic animation set on the MPFB rig — `GigLine_Idle` (60 f) and `GigLine_Walk` (30 f) sampled from parametric cycles at 30 fps — and exports one animated FBX with the `ue_character_v1` preset. Rotation axes are measured on the rig, not assumed (probe in the docstring). `--out`, `--report`. |
| `mpfb_character_export.py` | MPFB human, standard rig, baked skin, FBX export | | `mpfb_character_export.py` | MPFB human, standard rig, baked skin, FBX export |
## Unreal import and level building ## Unreal import and level building
@@ -56,6 +57,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| `ue_build_demo_map.py` | Builds `/Game/Hollywood/DemoMap`: lighting, façade, site meshes, player start | | `ue_build_demo_map.py` | Builds `/Game/Hollywood/DemoMap`: lighting, façade, site meshes, player start |
| `ue_build_review_map.py` | Rebuilds `Test/ReviewMap`, stages the shot camera list, and stages the demo-map views | | `ue_build_review_map.py` | Rebuilds `Test/ReviewMap`, stages the shot camera list, and stages the demo-map views |
| `ue_create_input_assets.py` | Creates `/Game/Input` actions and the mapping context headlessly | | `ue_create_input_assets.py` | Creates `/Game/Input` actions and the mapping context headlessly |
| `ue_character_anim_import.py` | Imports the animated FBX into `/Game/Test/Character` and **verifies an AnimSequence exists** — an FBX exported without `bake_anim` imports a mesh and no clips, with no error. Writes `VERDICT: PASS/FAIL` to `/tmp/gigline/character-anim-import.txt`. |
| `ue_character_import.py` | Skeletal import plus the material/texture chain Interchange skips | | `ue_character_import.py` | Skeletal import plus the material/texture chain Interchange skips |
## Visual review ## Visual review
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Author the basic character animation set (0.2.0): idle and walk.
Three clips on the MPFB standard rig, sampled from parametric cycles rather than
hand-keyed poses, so the loop is closed by construction and every key can be
re-derived from the numbers below:
GigLine_Idle 60 frames @ 30 fps — breathing sway, feet planted
GigLine_Walk 30 frames @ 30 fps — one full stride, left and right contact
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/character_anim_build.py -- \
--out /tmp/gigline/character-anim.fbx \
--report /tmp/gigline/character-anim-report.json
Rotation axes are **measured, not assumed**. A probe on the actual rig rotated
`upperleg01.L` by 0.5 rad on each axis and read the resulting `foot.L` head:
X -> foot Y -0.022 -> +0.362 (fore/aft swing: the axis used here)
Y -> twist along the leg, foot barely moves
Z -> foot swings sideways into the other leg
The arm probe gives the same answer for `upperarm01`, so limbs swing on local X
and positive X sends the limb backwards. Everything else (spine lean, head nod)
uses small X deltas for the same reason.
The rig is `pelvis.L → upperleg01 → upperleg02 → lowerleg01 → lowerleg02 → foot`,
all deform bones, no constraints, so a plain pose-bone rotation deforms the mesh.
"""
from __future__ import annotations
import argparse
import json
import math
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
FPS = 30
CLIPS = {"Idle": 60, "Walk": 30}
# Stride amplitude in radians at the hip. A human walk swings the thigh about
# 25-30 deg forward and 15-20 back; 0.45 rad (~26 deg) is a normal-pace stride.
HIP = 0.45
# Knee flexion during swing, measured from the photographs of a normal gait.
KNEE = 0.55
# Arm swing counter-phases the leg; a relaxed walk swings about 20 deg.
ARM = 0.32
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.
BASE = {
"shoulder01.L": (0.0, -0.15, -0.03),
"shoulder01.R": (0.0, 0.15, 0.03),
"upperarm01.L": (0.0, 0.0, -1.55),
"upperarm01.R": (0.0, 0.0, 1.55),
"lowerarm01.L": (0.0, 0.0, -0.10),
"lowerarm01.R": (0.0, 0.0, 0.10),
}
def log(msg):
PARTS.append(msg)
print(f"[anim] {msg}")
def clear_animation(arm):
"""Drop any pre-existing actions so a rerun does not stack clips."""
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
if arm.animation_data:
arm.animation_data_clear()
def pose_bones(arm):
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
for pb in arm.pose.bones:
pb.rotation_mode = "XYZ"
return arm.pose.bones
def apply_base_pose(arm):
"""Bake the arms-down pose into the REST pose before authoring any clip.
The first two attempts added the arms-down as an offset inside every key. That
is not what the export sees: the exporter writes clip data relative to the rest
pose, so an offset that lives only in the keys arrives in Unreal as a small
delta around an A-posed bind - the body stood in an A with a ±20 degree arm
swing, which is exactly what the Unreal pose probe measured. Baking the pose
into the rest pose first is the same trick `mpfb_character_export.py --pose
idle` uses, and it makes bind and clips agree.
"""
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
# The sign is inverted relative to the original `--pose idle` table: the first
# attempt applied BASE as written and the body came out with its arms raised in
# a V, i.e. the FBX axis mapping flips this rotation. Verified on the imported
# asset rather than guessed - see MEMORY.md, 2026-11-09.
for name, eul in BASE.items():
bone = arm.pose.bones.get(name)
if bone is None:
continue
bone.rotation_mode = "XYZ"
bone.rotation_euler = (-eul[0], -eul[1], -eul[2])
bpy.ops.pose.armature_apply()
bpy.ops.object.mode_set(mode="OBJECT")
log("base pose applied to rest: arms down before any clip is authored")
def key_euler(pb, frame, x=0.0, y=0.0, z=0.0):
"""Key a pose-bone rotation; the arms-down base is already in the rest pose."""
pb.rotation_euler = (x, y, z)
pb.keyframe_insert(data_path="rotation_euler", frame=frame)
def clip_idle(arm, frames):
"""Breathing sway: spine and head counter-rotate, arms drift, feet planted."""
bones = pose_bones(arm)
action = bpy.data.actions.new("GigLine_Idle")
arm.animation_data_create()
arm.animation_data.action = action
for frame in range(1, frames + 1):
t = 2.0 * math.pi * (frame - 1) / frames
breathe = math.sin(t)
# Chest lifts and settles; the head counter-rotates so the gaze stays level.
key_euler(bones["spine03"], frame, x=-0.020 * breathe)
key_euler(bones["spine05"], frame, x=-0.012 * breathe)
key_euler(bones["neck01"], frame, x=0.010 * breathe)
key_euler(bones["head"], frame, x=0.008 * breathe)
# Arms hang and drift a little; nothing swings.
key_euler(bones["upperarm01.L"], frame, x=0.020 * breathe)
key_euler(bones["upperarm01.R"], frame, x=-0.020 * breathe)
action.use_fake_user = True
log(f"Idle: {frames} frames, spine/neck/head/arms, feet planted")
return action
def clip_walk(arm, frames):
"""One full stride: left contact at frame 1, right contact at the half-cycle."""
bones = pose_bones(arm)
action = bpy.data.actions.new("GigLine_Walk")
arm.animation_data.action = action
for frame in range(1, frames + 1):
t = 2.0 * math.pi * (frame - 1) / frames
swing = math.sin(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)
# The knee only flexes while the leg is swinging forward, so it is driven
# by the half-rectified swing rather than the raw sine.
knee_l = KNEE * max(0.0, math.sin(t + math.pi))
knee_r = KNEE * max(0.0, math.sin(t))
key_euler(bones["lowerleg01.L"], frame, x=knee_l)
key_euler(bones["lowerleg01.R"], frame, x=knee_r)
# Ankles keep the foot roughly level: counter the hip plus half the knee.
key_euler(bones["foot.L"], frame, x=0.25 * HIP * swing - 0.35 * knee_l)
key_euler(bones["foot.R"], frame, x=-0.25 * HIP * swing - 0.35 * knee_r)
# Arms counter-phase the legs, elbows carry a constant relaxed bend.
key_euler(bones["upperarm01.L"], frame, x=ARM * swing)
key_euler(bones["upperarm01.R"], frame, x=-ARM * swing)
key_euler(bones["lowerarm01.L"], frame, x=ELBOW + 0.10 * swing)
key_euler(bones["lowerarm01.R"], frame, x=ELBOW - 0.10 * swing)
# Torso counter-rotation: the shoulders turn against the hips.
key_euler(bones["spine04"], frame, z=0.08 * swing)
key_euler(bones["neck01"], frame, z=-0.04 * swing)
action.use_fake_user = True
log(f"Walk: {frames} frames, stride {HIP:.2f} rad hip / {KNEE:.2f} knee / {ARM:.2f} arm")
return action
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-anim.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")
mesh = next((o for o in bpy.data.objects if o.type == "MESH"), None)
log(f"rig: {arm.name}, {len(arm.pose.bones)} bones; mesh {mesh.name if mesh else 'none'} "
f"{len(mesh.data.vertices) if mesh else 0} verts")
bpy.context.scene.render.fps = FPS
clear_animation(arm)
apply_base_pose(arm)
idle = clip_idle(arm, CLIPS["Idle"])
walk = clip_walk(arm, CLIPS["Walk"])
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.
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
if mesh:
mesh.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", encoding="utf-8") as handle:
json.dump({
"fps": FPS,
"rig": arm.name,
"bones": len(arm.pose.bones),
"clips": [
{"action": idle.name, "frames": CLIPS["Idle"]},
{"action": walk.name, "frames": CLIPS["Walk"]},
],
"axis_evidence": "probe: upperleg01.L X 0.5 rad moves foot.L "
"Y -0.022 -> +0.362; Y twists, Z abducts",
"amplitudes_rad": {"hip": HIP, "knee": KNEE, "arm": ARM, "elbow": ELBOW},
"export": os.path.basename(args.out),
"log": PARTS,
}, handle, indent=2)
print(f"[anim] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+13
View File
@@ -49,12 +49,25 @@ _METRIC = {
} }
# Character exports differ only in the object set and the armature/path flags. # Character exports differ only in the object set and the armature/path flags.
#
# Animation export (added for the 0.2.0 animation set): without `bake_anim` the
# exporter silently writes a static bind pose even when the armature carries
# actions, and Unreal then imports a character with no AnimSequence and no error.
# `bake_anim_use_all_actions` exports every action as its own clip; NLA strips are
# off because the project does not author NLA. `bake_anim_simplify_factor=0.0`
# keeps the sampled keys exactly as authored - the clips are short, so the size
# cost is irrelevant and a simplified curve would drift from the source poses.
_CHARACTER = dict( _CHARACTER = dict(
_METRIC, _METRIC,
object_types={"ARMATURE", "MESH"}, object_types={"ARMATURE", "MESH"},
add_leaf_bones=False, add_leaf_bones=False,
path_mode="COPY", path_mode="COPY",
embed_textures=False, embed_textures=False,
bake_anim=True,
bake_anim_use_all_actions=True,
bake_anim_use_nla_strips=False,
bake_anim_force_startend_keying=True,
bake_anim_simplify_factor=0.0,
) )
PRESETS = {"ue_metric_v1": _METRIC, "ue_character_v1": _CHARACTER} PRESETS = {"ue_metric_v1": _METRIC, "ue_character_v1": _CHARACTER}
+1 -1
View File
@@ -43,7 +43,7 @@ echo "== 6/6 capture the demo map =="
# bare ./Engine/... failed with "No such file or directory" while every commandlet # bare ./Engine/... failed with "No such file or directory" while every commandlet
# above succeeded through run_cmdlet's cd. # above succeeded through run_cmdlet's cd.
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$PROJ" /Game/Hollywood/DemoMap \ "$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$PROJ" /Game/Hollywood/DemoMap \
-game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout -game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -thirdperson -stdout
echo "== collect the demo screenshots ==" echo "== collect the demo screenshots =="
mkdir -p "$REPO/Screenshots/demo" mkdir -p "$REPO/Screenshots/demo"
+18
View File
@@ -52,6 +52,24 @@ class CharacterPresetTest(unittest.TestCase):
self.assertEqual(kwargs["mesh_smooth_type"], "FACE") self.assertEqual(kwargs["mesh_smooth_type"], "FACE")
class CharacterAnimationTest(unittest.TestCase):
"""The animation flags are the difference between a clip and a silent bind pose."""
def test_bakes_animation(self):
kwargs = preset.preset_kwargs("ue_character_v1")
self.assertTrue(kwargs["bake_anim"], "without bake_anim Unreal imports no AnimSequence")
self.assertTrue(kwargs["bake_anim_use_all_actions"])
self.assertFalse(kwargs["bake_anim_use_nla_strips"])
def test_does_not_simplify_authored_keys(self):
kwargs = preset.preset_kwargs("ue_character_v1")
self.assertEqual(kwargs["bake_anim_simplify_factor"], 0.0)
def test_static_preset_stays_animation_free(self):
kwargs = preset.preset_kwargs("ue_metric_v1")
self.assertNotIn("bake_anim", kwargs)
class OverrideTest(unittest.TestCase): class OverrideTest(unittest.TestCase):
def test_overrides_win(self): def test_overrides_win(self):
kwargs = preset.preset_kwargs(mesh_smooth_type="OFF") kwargs = preset.preset_kwargs(mesh_smooth_type="OFF")
+5
View File
@@ -314,6 +314,11 @@ try:
# (X -22750) and look NORTH toward the mass. The old camera was at # (X -22750) and look NORTH toward the mass. The old camera was at
# X -22800 Y +2600 aiming at its own X, giving an 85%-sky frame. # X -22800 Y +2600 aiming at its own X, giving an 85%-sky frame.
("demo-north-buildings", unreal.Vector(-22750, 1600, 500), unreal.Vector(-22750, -5200, 350)), ("demo-north-buildings", unreal.Vector(-22750, 1600, 500), unreal.Vector(-22750, -5200, 350)),
# 0.2.0 third-person body. Stands where the pawn's own boom camera sits —
# 3.4 m behind the spawn point, 1 m above it — and looks at the pawn, so
# the visible body and its idle clip are what the shot is for.
# Demo_PlayerStart is at (-13336, -1150, 600), yaw 90 (facing +Y).
("demo-third-person", unreal.Vector(-13336, -1750, 780), unreal.Vector(-13336, -1150, 560), 58.0),
] ]
seq = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer") seq = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
if seq: if seq:
+96
View File
@@ -0,0 +1,96 @@
# Import the character animation clips into Unreal (0.2.0).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/character_anim_build.py -- --out /tmp/gigline/character-anim.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_anim_import.py -nullrhi -unattended -nopause -stdout
#
# Imports the animated FBX next to the existing character assets and verifies the
# result rather than assuming it: an FBX whose export omitted `bake_anim` imports
# without error and produces a skeletal mesh but no AnimSequence, which is exactly
# the failure this tool must catch.
#
# Destination: /Game/Test/Character. Result file: /tmp/gigline/character-anim-import.txt
import glob
import os
import unreal
SRC = "/tmp/gigline/character-anim.fbx"
DEST = "/Game/Test/Character"
PREFIX = "Character_Anim"
RESULT = "/tmp/gigline/character-anim-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
if not os.path.exists(SRC):
log(f"missing {SRC}; run Tools/character_anim_build.py first")
return
task = unreal.AssetImportTask()
task.filename = SRC
task.destination_path = DEST
task.destination_name = PREFIX
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = True
ui.import_materials = True
ui.import_textures = False
# UE 5.8 has no SKELETON_ONLY content type (FBXICT_ALL/GEOMETRY/SKINNING_WEIGHTS
# only), so the clips come in with the mesh and the verification below decides
# whether that worked. The mesh is replaced at the same path by design.
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
# Verify: the clips are the deliverable, not the mesh.
registry = unreal.AssetRegistryHelpers.get_asset_registry()
found = []
for asset in registry.get_assets_by_path(DEST, recursive=False):
name = str(asset.asset_name)
if not name.startswith(PREFIX):
continue
obj = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if isinstance(obj, unreal.AnimSequence):
length = obj.get_play_length()
# UE 5.8 exposes only play length and sequence length here; the sampled
# frame count is derived so the report still states the clip duration.
frames = round(length * 30.0)
found.append(name)
log(f"AnimSequence {name:34} {length:.3f} s (~{frames} frames at 30 fps)")
elif isinstance(obj, unreal.SkeletalMesh):
skeleton = obj.get_editor_property("skeleton")
log(f"SkeletalMesh {name:34} skeleton={'ok' if skeleton else 'MISSING'}")
if not found:
log("FAILED: no AnimSequence was created — the FBX carried no baked animation")
lines.append("VERDICT: FAIL")
return
log(f"{len(found)} AnimSequence(s) imported: {', '.join(sorted(found))}")
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
+5 -1
View File
@@ -127,6 +127,10 @@ def build():
ia_reset.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN) ia_reset.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_reset) save(ia_reset)
ia_cam = fresh("IA_ToggleCamera", unreal.InputAction, unreal.InputAction_Factory)
ia_cam.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_cam)
ia_pause = fresh("IA_Pause", unreal.InputAction, unreal.InputAction_Factory) ia_pause = fresh("IA_Pause", unreal.InputAction, unreal.InputAction_Factory)
ia_pause.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN) ia_pause.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_pause) save(ia_pause)
@@ -136,7 +140,7 @@ def build():
(ia_move, "Gamepad_LeftY"), (ia_strafe, "Gamepad_LeftX"), (ia_move, "Gamepad_LeftY"), (ia_strafe, "Gamepad_LeftX"),
(ia_look, "MouseX"), (ia_look, "MouseY"), (ia_look, "Gamepad_Right2D"), (ia_look, "MouseX"), (ia_look, "MouseY"), (ia_look, "Gamepad_Right2D"),
(ia_jump, "SpaceBar"), (ia_jump, "Gamepad_FaceButton_Bottom"), (ia_jump, "SpaceBar"), (ia_jump, "Gamepad_FaceButton_Bottom"),
(ia_reset, "R"), (ia_pause, "Escape"), (ia_reset, "R"), (ia_pause, "Escape"), (ia_cam, "V"),
] ]
mappings = [] mappings = []
+12
View File
@@ -60,6 +60,18 @@
"Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg" "Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg"
], ],
"expect": "The retail run beside the hero from across the street: 6800 Lee Drug's bronze cornice, mural row and stepped parapet next to 6806-6820's pilasters and window storey, daylight" "expect": "The retail run beside the hero from across the street: 6800 Lee Drug's bronze cornice, mural row and stepped parapet next to 6806-6820's pilasters and window storey, daylight"
},
{
"name": "demo-third-person",
"shot": "Screenshots/demo/review-demo-third-person.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
],
"expect": "Third-person view of the player body standing on the north sidewalk, full figure visible from behind, the hero facade across the street, daylight"
} }
] ]
} }