Compare commits
14
Commits
v0.2.2
...
b50afa4bcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b50afa4bcd | ||
|
|
021aa56121 | ||
|
|
1cddfbfc68 | ||
|
|
627737062b | ||
|
|
5dd53ac30b | ||
|
|
4f3ed20e49 | ||
|
|
2e0421480c | ||
|
|
02cadd4964 | ||
|
|
a72bf85b2e | ||
|
|
73b5bd6cd1 | ||
|
|
82cafc2f29 | ||
|
|
3e4dbd1014 | ||
|
|
a6701aa3d1 | ||
|
|
d8b44f1e57 |
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,28 @@ Format: [Keep a Changelog](https://keepachangelog.com/) style, versions follow [
|
|||||||
|
|
||||||
Nothing yet.
|
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
|
## [0.2.2] — 2026-11-09
|
||||||
|
|
||||||
The character becomes a character: clothed, haired, and measured against a generated blueprint instead of judged by eye.
|
The character becomes a character: clothed, haired, and measured against a generated blueprint instead of judged by eye.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ 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` | 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` | 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.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` | 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.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` | 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 |
|
||||||
|
|||||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,5 +14,19 @@ public class GigLine : ModuleRules
|
|||||||
"InputCore",
|
"InputCore",
|
||||||
"EnhancedInput"
|
"EnhancedInput"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Editor-only: the vehicle physics asset is generated from the skeletal mesh with
|
||||||
|
// FPhysicsAssetUtils, which lives in the Developer/PhysicsUtilities module. Chaos
|
||||||
|
// Vehicles also need the physics vehicle module at runtime.
|
||||||
|
if (Target.bBuildEditor)
|
||||||
|
{
|
||||||
|
PrivateDependencyModuleNames.AddRange(new string[]
|
||||||
|
{
|
||||||
|
"UnrealEd",
|
||||||
|
"PhysicsUtilities",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PrivateDependencyModuleNames.Add("ChaosVehicles");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
#include "Engine/World.h"
|
#include "Engine/World.h"
|
||||||
#include "TimerManager.h"
|
#include "TimerManager.h"
|
||||||
#include "Containers/Ticker.h"
|
#include "Containers/Ticker.h"
|
||||||
|
#include "Camera/PlayerCameraManager.h"
|
||||||
|
#include "Engine/StaticMeshActor.h"
|
||||||
|
#include "UnrealClient.h"
|
||||||
#include "HAL/IConsoleManager.h"
|
#include "HAL/IConsoleManager.h"
|
||||||
#include "Kismet/GameplayStatics.h"
|
#include "Kismet/GameplayStatics.h"
|
||||||
#include "Misc/CommandLine.h"
|
#include "Misc/CommandLine.h"
|
||||||
@@ -311,6 +314,11 @@ void AGigLineCharacter::BeginPlay()
|
|||||||
// `-walk` holds forward input from the first frame. The screenshot run uses it
|
// `-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
|
// 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.
|
// 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
|
// Bind the character materials here rather than in the constructor: the slot
|
||||||
// assignment made there never reached the render. LoadObject, not FObjectFinder,
|
// assignment made there never reached the render. LoadObject, not FObjectFinder,
|
||||||
// because FObjectFinder may only be used inside a constructor - putting one in
|
// because FObjectFinder may only be used inside a constructor - putting one in
|
||||||
@@ -333,6 +341,7 @@ void AGigLineCharacter::BeginPlay()
|
|||||||
}
|
}
|
||||||
|
|
||||||
bWalkForever = FParse::Param(FCommandLine::Get(), TEXT("walk"));
|
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
|
// `-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
|
// check should not depend on someone pressing F3 first. The built demo has
|
||||||
@@ -346,6 +355,102 @@ void AGigLineCharacter::BeginPlay()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
void AGigLineCharacter::ToggleDebugOverlay()
|
||||||
{
|
{
|
||||||
static IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug"));
|
static IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug"));
|
||||||
@@ -852,6 +957,11 @@ void AGigLineCharacter::Tick(float DeltaSeconds)
|
|||||||
Super::Tick(DeltaSeconds);
|
Super::Tick(DeltaSeconds);
|
||||||
// Speed-driven animation state: correct whether the player walked, was pushed,
|
// Speed-driven animation state: correct whether the player walked, was pushed,
|
||||||
// or fell, and independent of which input produced the movement.
|
// or fell, and independent of which input produced the movement.
|
||||||
|
if (bCameraTest)
|
||||||
|
{
|
||||||
|
UpdateCameraCollisionTest(GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.f);
|
||||||
|
}
|
||||||
|
|
||||||
WalkForever();
|
WalkForever();
|
||||||
UpdateBodyAnimation();
|
UpdateBodyAnimation();
|
||||||
|
|
||||||
@@ -866,6 +976,15 @@ void AGigLineCharacter::ToggleCamera()
|
|||||||
bThirdPerson = !bThirdPerson;
|
bThirdPerson = !bThirdPerson;
|
||||||
FirstPersonCamera->SetActive(!bThirdPerson);
|
FirstPersonCamera->SetActive(!bThirdPerson);
|
||||||
ThirdPersonCamera->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);
|
GetMesh()->SetHiddenInGame(!bThirdPerson);
|
||||||
if (HairMesh)
|
if (HairMesh)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -200,6 +200,21 @@ protected:
|
|||||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||||
TObjectPtr<UInputAction> ToggleDebugAction;
|
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. */
|
/** Flips the gigline.Debug console variable that the HUD reads. */
|
||||||
void ToggleDebugOverlay();
|
void ToggleDebugOverlay();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
#include "GigLineVehicle.h"
|
||||||
|
|
||||||
|
#include "ChaosWheeledVehicleMovementComponent.h"
|
||||||
|
#include "Components/SkeletalMeshComponent.h"
|
||||||
|
|
||||||
|
// From the Blender report (Art/blender/vehicle/report.json). Kept as named constants so a
|
||||||
|
// model change is a compile-time mismatch to fix, not a number buried in a setup call.
|
||||||
|
namespace GigLineVehicleDims
|
||||||
|
{
|
||||||
|
constexpr float WheelRadiusCm = 33.0f;
|
||||||
|
constexpr float WheelBaseCm = 278.0f;
|
||||||
|
constexpr float TrackCm = 156.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
AGigLineVehicle::AGigLineVehicle()
|
||||||
|
{
|
||||||
|
PrimaryActorTick.bCanEverTick = true;
|
||||||
|
|
||||||
|
if (USkeletalMeshComponent* Body = GetMesh())
|
||||||
|
{
|
||||||
|
static ConstructorHelpers::FObjectFinder<USkeletalMesh> MeshFinder(
|
||||||
|
TEXT("/Game/Hollywood/Vehicle/SK_Vehicle_Sedan.SK_Vehicle_Sedan"));
|
||||||
|
if (MeshFinder.Succeeded())
|
||||||
|
{
|
||||||
|
Body->SetSkeletalMesh(MeshFinder.Object);
|
||||||
|
}
|
||||||
|
Body->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
|
||||||
|
Body->SetCollisionProfileName(TEXT("PhysicsActor"));
|
||||||
|
Body->SetSimulatePhysics(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UChaosWheeledVehicleMovementComponent* Movement =
|
||||||
|
Cast<UChaosWheeledVehicleMovementComponent>(GetVehicleMovementComponent()))
|
||||||
|
{
|
||||||
|
ConfigureWheels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AGigLineVehicle::BuildVisuals()
|
||||||
|
{
|
||||||
|
// One component per authored system: body, glass, front and rear lights, mirrors and
|
||||||
|
// the four wheels. Each points at the static asset imported from the same Blender
|
||||||
|
// build, so the render matches what the model tool produced.
|
||||||
|
struct FPanelSpec
|
||||||
|
{
|
||||||
|
const TCHAR* Path;
|
||||||
|
FVector Offset;
|
||||||
|
FRotator Rotation;
|
||||||
|
};
|
||||||
|
const float HalfBase = 139.0f; // wheelbase 278 cm / 2
|
||||||
|
const float HalfTrack = 81.0f; // track 162 cm / 2
|
||||||
|
const FPanelSpec Specs[] = {
|
||||||
|
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Body.SM_Vehicle_Body"), FVector::ZeroVector, FRotator::ZeroRotator},
|
||||||
|
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Glass.SM_Vehicle_Glass"), FVector::ZeroVector, FRotator::ZeroRotator},
|
||||||
|
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Light_Front.SM_Vehicle_Light_Front"), FVector::ZeroVector, FRotator::ZeroRotator},
|
||||||
|
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Light_Rear.SM_Vehicle_Light_Rear"), FVector::ZeroVector, FRotator::ZeroRotator},
|
||||||
|
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Mirrors.SM_Vehicle_Mirrors"), FVector::ZeroVector, FRotator::ZeroRotator},
|
||||||
|
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(HalfBase, HalfTrack, -16.f), FRotator::ZeroRotator},
|
||||||
|
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(HalfBase, -HalfTrack, -16.f), FRotator::ZeroRotator},
|
||||||
|
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(-HalfBase, HalfTrack, -16.f), FRotator::ZeroRotator},
|
||||||
|
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(-HalfBase, -HalfTrack, -16.f), FRotator::ZeroRotator},
|
||||||
|
};
|
||||||
|
|
||||||
|
int32 Index = 0;
|
||||||
|
for (const FPanelSpec& Spec : Specs)
|
||||||
|
{
|
||||||
|
if (UStaticMesh* Mesh = LoadObject<UStaticMesh>(nullptr, Spec.Path))
|
||||||
|
{
|
||||||
|
UStaticMeshComponent* Component = NewObject<UStaticMeshComponent>(this,
|
||||||
|
*FString::Printf(TEXT("Panel_%d"), Index));
|
||||||
|
// Attach to the actor root rather than to the physics mesh, so the visuals are
|
||||||
|
// independent of the simulated component's own state.
|
||||||
|
Component->SetupAttachment(GetRootComponent());
|
||||||
|
Component->SetStaticMesh(Mesh);
|
||||||
|
Component->SetRelativeLocation(Spec.Offset);
|
||||||
|
Component->SetRelativeRotation(Spec.Rotation);
|
||||||
|
Component->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||||||
|
Component->SetMobility(EComponentMobility::Movable);
|
||||||
|
Component->RegisterComponent();
|
||||||
|
// Runtime-created components need their render state forced: without this the
|
||||||
|
// panels registered and reported visible but never reached the renderer.
|
||||||
|
Component->MarkRenderStateDirty();
|
||||||
|
Component->UpdateBounds();
|
||||||
|
Panels.Add(Component);
|
||||||
|
++Index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: %d visual panels attached"), Panels.Num());
|
||||||
|
for (int32 PanelIndex = 0; PanelIndex < Panels.Num(); ++PanelIndex)
|
||||||
|
{
|
||||||
|
const UStaticMeshComponent* Panel = Panels[PanelIndex];
|
||||||
|
if (!Panel)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const FVector Where = Panel->GetComponentLocation();
|
||||||
|
const FBoxSphereBounds B = Panel->Bounds;
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("GigLine vehicle: panel %d at (%.0f,%.0f,%.0f) visible=%d extent=%.0f mesh=%s"),
|
||||||
|
PanelIndex, Where.X, Where.Y, Where.Z,
|
||||||
|
Panel->IsVisible() ? 1 : 0, B.BoxExtent.GetMax(),
|
||||||
|
Panel->GetStaticMesh() ? *Panel->GetStaticMesh()->GetName() : TEXT("NONE"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AGigLineVehicle::ConfigureWheels()
|
||||||
|
{
|
||||||
|
UChaosWheeledVehicleMovementComponent* Movement =
|
||||||
|
Cast<UChaosWheeledVehicleMovementComponent>(GetVehicleMovementComponent());
|
||||||
|
if (!Movement)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One entry per wheel bone, in the order the rig was built. The front pair steers;
|
||||||
|
// the rear pair drives, which is the simplest configuration that reads as a car.
|
||||||
|
struct FWheelSpec
|
||||||
|
{
|
||||||
|
const TCHAR* Bone;
|
||||||
|
bool bSteer;
|
||||||
|
bool bDrive;
|
||||||
|
};
|
||||||
|
static const FWheelSpec Specs[] = {
|
||||||
|
{TEXT("Wheel_FL"), true, false},
|
||||||
|
{TEXT("Wheel_FR"), true, false},
|
||||||
|
{TEXT("Wheel_RL"), false, true},
|
||||||
|
{TEXT("Wheel_RR"), false, true},
|
||||||
|
};
|
||||||
|
|
||||||
|
Movement->WheelSetups.Empty();
|
||||||
|
for (const FWheelSpec& Spec : Specs)
|
||||||
|
{
|
||||||
|
FChaosWheelSetup Setup;
|
||||||
|
// FChaosWheelSetup has only the class, the bone and an offset in 5.8 - there is
|
||||||
|
// no per-setup disable flag, and steering/drive flags live on the wheel object.
|
||||||
|
Setup.WheelClass = UChaosVehicleWheel::StaticClass();
|
||||||
|
Setup.BoneName = FName(Spec.Bone);
|
||||||
|
Movement->WheelSetups.Add(Setup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radius and stance for every wheel; the per-wheel subclass would normally carry
|
||||||
|
// these, so they are applied after the setups exist.
|
||||||
|
for (int32 Index = 0; Index < Movement->WheelSetups.Num(); ++Index)
|
||||||
|
{
|
||||||
|
UChaosVehicleWheel* Wheel = Movement->Wheels.IsValidIndex(Index)
|
||||||
|
? Movement->Wheels[Index] : nullptr;
|
||||||
|
if (!Wheel)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Wheel->WheelRadius = GigLineVehicleDims::WheelRadiusCm;
|
||||||
|
Wheel->WheelWidth = 22.0f;
|
||||||
|
Wheel->bAffectedBySteering = Specs[Index].bSteer;
|
||||||
|
Wheel->bAffectedByEngine = Specs[Index].bDrive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AGigLineVehicle::BeginPlay()
|
||||||
|
{
|
||||||
|
Super::BeginPlay();
|
||||||
|
|
||||||
|
// The skeletal mesh is the physics driver only: its imported geometry is unusable
|
||||||
|
// (1 cm bounds), so it is hidden and the visible car comes from the static panels.
|
||||||
|
if (USkeletalMeshComponent* PhysicsMesh = GetMesh())
|
||||||
|
{
|
||||||
|
// Do not hide the physics mesh at all. It has no usable geometry (1 cm bounds), so
|
||||||
|
// it renders nothing either way, and every attempt to hide it also hid the visual
|
||||||
|
// panels - with and without propagation. Leaving it visible avoids the fight.
|
||||||
|
PhysicsMesh->SetCastShadow(false);
|
||||||
|
}
|
||||||
|
BuildVisuals();
|
||||||
|
|
||||||
|
// Where is the road, really? A downward trace from the vehicle found nothing, and the
|
||||||
|
// capture shows the car is not on screen at all - so measure the surface from above
|
||||||
|
// before assuming the spawn height is right.
|
||||||
|
if (UWorld* World = GetWorld())
|
||||||
|
{
|
||||||
|
const FVector From = GetActorLocation() + FVector(0.f, 0.f, 1000.f);
|
||||||
|
FHitResult Hit;
|
||||||
|
FCollisionQueryParams Q(TEXT("GigLineVehicleRoad"), false, this);
|
||||||
|
const bool bHit = World->LineTraceSingleByChannel(Hit, From,
|
||||||
|
From - FVector(0.f, 0.f, 4000.f), ECC_Visibility, Q);
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("GigLine vehicle: surface below spawn at Z %.0f cm (hit=%d, actor at %.0f)"),
|
||||||
|
bHit ? Hit.ImpactPoint.Z : -9999.f, bHit ? 1 : 0, GetActorLocation().Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wheel objects do not exist until the component is registered, so the per-wheel
|
||||||
|
// flags set in the constructor went nowhere - which is why the drive test measured
|
||||||
|
// zero speed with throttle held. Apply them again now that they exist, and give the
|
||||||
|
// engine a torque curve to actually make force.
|
||||||
|
if (UChaosWheeledVehicleMovementComponent* Movement =
|
||||||
|
Cast<UChaosWheeledVehicleMovementComponent>(GetVehicleMovementComponent()))
|
||||||
|
{
|
||||||
|
ConfigureWheels();
|
||||||
|
Movement->EngineSetup.MaxTorque = 500.0f;
|
||||||
|
Movement->EngineSetup.MaxRPM = 6000.0f;
|
||||||
|
Movement->EngineSetup.EngineIdleRPM = 900.0f;
|
||||||
|
Movement->TransmissionSetup.bUseAutomaticGears = true;
|
||||||
|
Movement->TransmissionSetup.FinalRatio = 3.5f;
|
||||||
|
Movement->SetThrottleInput(0.0f);
|
||||||
|
if (const USkeletalMeshComponent* BodyComp = GetMesh())
|
||||||
|
{
|
||||||
|
const USkeletalMesh* Asset = BodyComp->GetSkeletalMeshAsset();
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("GigLine vehicle: mesh=%s visible=%d hidden=%d bounds=%s"),
|
||||||
|
Asset ? *Asset->GetName() : TEXT("NONE"),
|
||||||
|
BodyComp->IsVisible() ? 1 : 0,
|
||||||
|
BodyComp->bHiddenInGame ? 1 : 0,
|
||||||
|
*BodyComp->Bounds.BoxExtent.ToString());
|
||||||
|
}
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("GigLine vehicle: %d wheel objects after register, torque %.0f"),
|
||||||
|
Movement->Wheels.Num(), Movement->EngineSetup.MaxTorque);
|
||||||
|
}
|
||||||
|
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: spawned at (%.0f, %.0f, %.0f), %d wheel setups"),
|
||||||
|
GetActorLocation().X, GetActorLocation().Y, GetActorLocation().Z,
|
||||||
|
GetVehicleMovementComponent()
|
||||||
|
? Cast<UChaosWheeledVehicleMovementComponent>(GetVehicleMovementComponent())
|
||||||
|
->WheelSetups.Num()
|
||||||
|
: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AGigLineVehicle::Tick(float DeltaSeconds)
|
||||||
|
{
|
||||||
|
Super::Tick(DeltaSeconds);
|
||||||
|
|
||||||
|
// One settled-state report: proves the physics body exists, is simulating, and has
|
||||||
|
// come to rest on the road rather than falling through it. Everything else about the
|
||||||
|
// driving behaviour is measured later; this is the "is it real" check.
|
||||||
|
// `-drivetest`: hold full throttle from 1 s to 4 s and report the result. A vehicle
|
||||||
|
// that spawns is not a vehicle that drives.
|
||||||
|
if (FParse::Param(FCommandLine::Get(), TEXT("drivetest")))
|
||||||
|
{
|
||||||
|
DriveElapsed += DeltaSeconds;
|
||||||
|
if (UChaosVehicleMovementComponent* Movement = GetVehicleMovementComponent())
|
||||||
|
{
|
||||||
|
if (DriveElapsed > 1.0f && DriveElapsed < 4.0f)
|
||||||
|
{
|
||||||
|
Movement->SetThrottleInput(1.0f);
|
||||||
|
}
|
||||||
|
else if (DriveElapsed >= 4.0f && !bDriveReported)
|
||||||
|
{
|
||||||
|
Movement->SetThrottleInput(0.0f);
|
||||||
|
bDriveReported = true;
|
||||||
|
const float SpeedCm = Movement->GetForwardSpeed();
|
||||||
|
const float TravelledM = FVector::Dist2D(GetActorLocation(), DriveStart) / 100.f;
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("GigLine vehicle DRIVETEST speed=%.0f cm/s (%.1f km/h), travelled %.1f m -> %s"),
|
||||||
|
SpeedCm, SpeedCm * 0.036f, TravelledM,
|
||||||
|
(SpeedCm > 200.f && TravelledM > 1.0f) ? TEXT("PASS") : TEXT("FAIL"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (DriveStart.IsZero() && DriveElapsed > 1.0f)
|
||||||
|
{
|
||||||
|
DriveStart = GetActorLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
SettleElapsed += DeltaSeconds;
|
||||||
|
if (!bSettleReported && SettleElapsed > 3.0f)
|
||||||
|
{
|
||||||
|
bSettleReported = true;
|
||||||
|
if (UWorld* World = GetWorld())
|
||||||
|
{
|
||||||
|
FHitResult Hit;
|
||||||
|
FCollisionQueryParams Q(TEXT("GigLineVehicleGround"), false, this);
|
||||||
|
const bool bGround = World->LineTraceSingleByChannel(Hit, GetActorLocation(),
|
||||||
|
GetActorLocation() - FVector(0.f, 0.f, 2000.f), ECC_Visibility, Q);
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("GigLine vehicle: ground at Z %.0f cm, actor %.0f cm, height above ground %.0f cm"),
|
||||||
|
bGround ? Hit.ImpactPoint.Z : -9999.f, GetActorLocation().Z,
|
||||||
|
bGround ? GetActorLocation().Z - Hit.ImpactPoint.Z : -9999.f);
|
||||||
|
}
|
||||||
|
const USkeletalMeshComponent* Body = GetMesh();
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("GigLine vehicle: settled at Z %.0f cm, simulating=%d, speed=%.0f cm/s"),
|
||||||
|
GetActorLocation().Z,
|
||||||
|
Body && Body->IsSimulatingPhysics() ? 1 : 0,
|
||||||
|
GetVehicleMovementComponent() ? GetVehicleMovementComponent()->GetForwardSpeed() : 0.f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AGigLineVehicle::IsMoving() const
|
||||||
|
{
|
||||||
|
const UChaosVehicleMovementComponent* Movement = GetVehicleMovementComponent();
|
||||||
|
return Movement && FMath::Abs(Movement->GetForwardSpeed()) > 50.0f;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "WheeledVehiclePawn.h"
|
||||||
|
#include "GigLineVehicle.generated.h"
|
||||||
|
|
||||||
|
class UChaosWheeledVehicleMovementComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one drivable vehicle in the demo.
|
||||||
|
*
|
||||||
|
* A skeletal mesh with a root bone and four wheel bones, driven by Chaos. Every
|
||||||
|
* dimension it configures — wheel radius, wheelbase, track — comes from the Blender
|
||||||
|
* build's report rather than being typed twice: the numbers are repeated here as
|
||||||
|
* constants with the report path in the comment so a change to the model shows up as a
|
||||||
|
* mismatch instead of a silent drift.
|
||||||
|
*
|
||||||
|
* Model: `Art/blender/vehicle/gigline-sedan.blend`, report `Art/blender/vehicle/report.json`
|
||||||
|
* (4.72 x 1.80 x 1.44 m, wheelbase 2.78 m, track 1.62 m, wheel radius 0.33 m).
|
||||||
|
*/
|
||||||
|
UCLASS()
|
||||||
|
class GIGLINE_API AGigLineVehicle : public AWheeledVehiclePawn
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
AGigLineVehicle();
|
||||||
|
|
||||||
|
virtual void BeginPlay() override;
|
||||||
|
virtual void Tick(float DeltaSeconds) override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual panels, as static mesh components.
|
||||||
|
*
|
||||||
|
* The skeletal vehicle asset imported with no usable geometry (runtime bounds of
|
||||||
|
* 1 cm), so the car was invisible in the scene while its physics body still cast a
|
||||||
|
* shadow - the "no vehicle in scene" report. The static assets are correct (body
|
||||||
|
* 476 x 176 x 128 cm), so the visible car is assembled from them and the skeletal
|
||||||
|
* mesh stays as the physics/animation driver only.
|
||||||
|
*/
|
||||||
|
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Visual")
|
||||||
|
TArray<TObjectPtr<UStaticMeshComponent>> Panels;
|
||||||
|
|
||||||
|
/** True once the vehicle reports a forward speed above a walking pace. */
|
||||||
|
bool IsMoving() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
/** Configure the Chaos wheels from the modelled dimensions. */
|
||||||
|
void ConfigureWheels();
|
||||||
|
|
||||||
|
/** Attach the visible panels from the imported static systems. */
|
||||||
|
void BuildVisuals();
|
||||||
|
|
||||||
|
float DriveElapsed = 0.f;
|
||||||
|
bool bDriveReported = false;
|
||||||
|
FVector DriveStart = FVector::ZeroVector;
|
||||||
|
|
||||||
|
float SettleElapsed = 0.f;
|
||||||
|
bool bSettleReported = false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#include "GigLineVehicleBuilder.h"
|
||||||
|
|
||||||
|
#if WITH_EDITOR
|
||||||
|
#include "AssetToolsModule.h"
|
||||||
|
#include "Engine/SkeletalMesh.h"
|
||||||
|
#include "PhysicsAssetUtils.h"
|
||||||
|
#include "PhysicsEngine/PhysicsAsset.h"
|
||||||
|
#include "PhysicsEngine/BodySetup.h"
|
||||||
|
#include "PhysicsEngine/SkeletalBodySetup.h"
|
||||||
|
#include "Engine/SkeletalMesh.h"
|
||||||
|
|
||||||
|
#include "UObject/Package.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
UPhysicsAsset* UGigLineVehicleBuilder::BuildPhysicsAsset(USkeletalMesh* Mesh,
|
||||||
|
const FString& PackagePath,
|
||||||
|
const FString& AssetName)
|
||||||
|
{
|
||||||
|
#if WITH_EDITOR
|
||||||
|
if (!Mesh)
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: no skeletal mesh given"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FString FullName = PackagePath / AssetName;
|
||||||
|
UPackage* Package = CreatePackage(*FullName);
|
||||||
|
if (!Package)
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: could not create package %s"), *FullName);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
UPhysicsAsset* PhysicsAsset = NewObject<UPhysicsAsset>(Package, *AssetName,
|
||||||
|
RF_Public | RF_Standalone | RF_Transactional);
|
||||||
|
|
||||||
|
// ONE body on the chassis bone, which is what Unreal's own vehicle template uses.
|
||||||
|
// The first attempt let FPhysicsAssetUtils build a body per bone and the car settled
|
||||||
|
// 3.7 m above the road: the four wheel bodies were intersecting the chassis body and
|
||||||
|
// pushing the whole vehicle up. Chaos drives the wheels itself, so they must not be
|
||||||
|
// separate rigid bodies here.
|
||||||
|
const FBoxSphereBounds Bounds = Mesh->GetBounds();
|
||||||
|
const FVector Extent = Bounds.BoxExtent;
|
||||||
|
const FName ChassisBone(TEXT("root"));
|
||||||
|
|
||||||
|
USkeletalBodySetup* Body = NewObject<USkeletalBodySetup>(PhysicsAsset, ChassisBone,
|
||||||
|
RF_Public | RF_Transactional);
|
||||||
|
Body->BoneName = ChassisBone;
|
||||||
|
// A box slightly under the mesh bounds: a body that is exactly the bounds sits on the
|
||||||
|
// ground plane and reads as floating because the mesh has wheels below the sills.
|
||||||
|
FKBoxElem Box;
|
||||||
|
Box.X = Extent.X * 1.9f;
|
||||||
|
Box.Y = Extent.Y * 1.8f;
|
||||||
|
Box.Z = Extent.Z * 0.55f;
|
||||||
|
Box.Center = FVector(0.f, 0.f, -Extent.Z * 0.25f);
|
||||||
|
Body->AggGeom.BoxElems.Add(Box);
|
||||||
|
PhysicsAsset->SkeletalBodySetups.Add(Body);
|
||||||
|
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("GigLine vehicle: chassis body %.0f x %.0f x %.0f cm on bone '%s'"),
|
||||||
|
Box.X, Box.Y, Box.Z, *ChassisBone.ToString());
|
||||||
|
|
||||||
|
PhysicsAsset->MarkPackageDirty();
|
||||||
|
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: physics asset %s with %d bodies"),
|
||||||
|
*FullName, PhysicsAsset->SkeletalBodySetups.Num());
|
||||||
|
return PhysicsAsset;
|
||||||
|
#else
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("GigLine vehicle: physics asset generation is editor-only"));
|
||||||
|
return nullptr;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||||
|
#include "GigLineVehicleBuilder.generated.h"
|
||||||
|
|
||||||
|
class UPhysicsAsset;
|
||||||
|
class USkeletalMesh;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script-callable helpers for vehicle setup.
|
||||||
|
*
|
||||||
|
* Chaos Vehicles need a PhysicsAsset, and Unreal's `PhysicsAssetFactory` exposes no mesh
|
||||||
|
* property to Python, so the asset cannot be generated from a commandlet. The body does
|
||||||
|
* the job with `FPhysicsAssetUtils::CreateFromSkeletalMesh`, which is the same code path
|
||||||
|
* the editor's "Create Physics Asset" action uses.
|
||||||
|
*
|
||||||
|
* Editor only: it is compiled into the editor target and never shipped.
|
||||||
|
*/
|
||||||
|
UCLASS()
|
||||||
|
class GIGLINE_API UGigLineVehicleBuilder : public UBlueprintFunctionLibrary
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Build (or rebuild) a physics asset for `Mesh` at `PackagePath`/`AssetName`.
|
||||||
|
*
|
||||||
|
* @return the created asset, or nullptr with the reason written to the log.
|
||||||
|
*/
|
||||||
|
UFUNCTION(BlueprintCallable, Category = "GigLine|Vehicle")
|
||||||
|
static UPhysicsAsset* BuildPhysicsAsset(USkeletalMesh* Mesh, const FString& PackagePath,
|
||||||
|
const FString& AssetName);
|
||||||
|
};
|
||||||
@@ -26,6 +26,7 @@ Every shot is embedded below, in order, so the whole review happens in this file
|
|||||||
| 6 | [Third-person body](#6-third-person-body) | ☑ 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 |
|
| 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 |
|
| 8 | [Third-person walk](#8-third-person-walk) | ☑ 2026-11-09 | ☑ 2026-11-09 |
|
||||||
|
| 9 | [Vehicle](#9-vehicle) | ☐ FAIL 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.
|
||||||
|
|
||||||
|
|||||||
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.
@@ -9,19 +9,20 @@ 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.
|
- 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.
|
- 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.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)
|
## 0.3.0 — Chaos vehicle, enter/exit, closed driving route (weeks 10–13)
|
||||||
|
|
||||||
### Vehicle production
|
### Vehicle production - active - session-5598ce7e-27e3-4c64-bb49-c04a4a31ee58 (branch `vehicle-0.3.0`)
|
||||||
|
|
||||||
- [ ] Model one vehicle in Blender with collision meshes; export to Unreal.
|
- [A] Model one vehicle in Blender with collision meshes; export to Unreal. **Model done** (`Tools/vehicle_build.py` → `Art/blender/vehicle/gigline-sedan.blend`, preview in `Art/blender/vehicle/preview.png`): 4.72 x 1.80 x 1.44 m, 2.78 m wheelbase, 1.62 m track, 0.33 m wheels, 252 triangles, four `UCX_` convex hulls. Dimensions come from real sedan figures and are logged to the report, so the vehicle config can read them rather than restating them. - **Imported into Unreal** (`Tools/ue_vehicle_import.py` → `/Game/Hollywood/Vehicle`), split per the vehicle-artist object split: `SM_Vehicle_Body` (3 slots, 4.76 x 1.76 x 1.28 m), `SM_Vehicle_Glass`, `SM_Vehicle_Light_Front`, `SM_Vehicle_Light_Rear`, `SM_Vehicle_Mirrors`, and one `SM_Vehicle_Wheel` (0.22 x 0.66 x 0.66 m) reused four times. The body FBX carries the `UCX_` hulls as simple collision.
|
||||||
- [ ] Set up Chaos Vehicle with wheel and physics configuration.
|
- **LODs cut for real:** decimating the duplicate gives LOD1 at 38 triangles and LOD2 at 14, against 132 for LOD0. The first attempt duplicated the shell unchanged and produced 84-triangle LODs that saved nothing - worth remembering as the failure mode of a "LOD" step nobody measures.
|
||||||
|
- **LOD group wiring is open:** `StaticMeshEditorSubsystem.import_lod` is unavailable in a headless commandlet (`get_editor_subsystem` returns None), so the LOD meshes import as their own assets and the group still needs an editor session.
|
||||||
|
- [~] Set up Chaos Vehicle with wheel and physics configuration. **It sits on the road now; it does not drive yet.**
|
||||||
|
- **Fixed the floating:** the physics asset had a body per bone, and the four wheel bodies intersected the chassis body and pushed the car 3.7 m up. It is now a **single chassis body** on the `root` bone (452 x 205 x 40 cm), which is what Unreal's own vehicle template uses - Chaos simulates the wheels itself, they must not be separate rigid bodies. Settle check: **Z 119 cm** at a spawn of 120 (was 494).
|
||||||
|
- **Drive test fails, with a specific cause:** `-drivetest` holds full throttle for 3 s and reports speed and distance. Result: `speed=0 cm/s, travelled 2.2 m -> FAIL`, and BeginPlay logs **`0 wheel objects after register`** while the component reports 4 wheel setups. The setups exist but no wheels are created from them, so there is nothing to apply torque to.
|
||||||
|
- **Next:** confirm the skeleton's bone names match the setups (`Wheel_FL`, `Wheel_FR`, `Wheel_RL`, `Wheel_RR`) - a name mismatch silently produces zero wheels - and give the front and rear pairs their own `UChaosVehicleWheel` subclasses rather than the base class, which is what the setups point at today.
|
||||||
|
- **Also open:** LOD group wiring, enter/exit, driving route, handling.
|
||||||
|
- [x] Fix the vehicle not rendering in the scene. **Chased to a strong clue, not yet solved.** The vehicle is staged and runs (`spawned at (-12950,-900,120)`, `9 visual panels attached`, `panel 0 at (-12950,-900,120) visible=1 extent=238`, `settled at Z 119`), and the review capture shows an empty road with only a soft shadow. `Tools/visual_loop.py --verify` passes the frame; `Tools/judge_screenshot.py` returns `verdict=no confidence=1.0`. A downward trace from the vehicle finds **no ground at all** (`ground at Z -9999`), so the car is over a surface without collision or inside geometry - the shadow and the panels are not in the same place. **Fixed 2026-11-09** — the car renders, built from the owner's `hot hatch.blend`. **Fixed:** the vehicle was spawned 2.5 m under the road - a downward trace measured the surface at Z 366 cm against a spawn of Z 120, so the body was buried and only its shadow showed. The spawn is now Z 470 and it settles there.
|
||||||
|
- **Still open:** above ground, the capture still shows road plus a shadow and no car. Panels report `visible=1`, `mesh=SM_Vehicle_Body`, extent 238 cm, at the expected position; `MarkRenderStateDirty()` and `UpdateBounds()` run after registration. Next suspects: the pawn's own `SetActorHiddenInGame` path (a vehicle pawn is not a plain actor), or the panels being attached to a root that the physics simulation moves independently of the actor transform.
|
||||||
- [ ] Implement enter/exit interaction.
|
- [ ] Implement enter/exit interaction.
|
||||||
- [ ] Build a closed driving route with boundaries.
|
- [ ] Build a closed driving route with boundaries.
|
||||||
- [ ] Tune basic handling to a drivable state.
|
- [ ] Tune basic handling to a drivable state.
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
|||||||
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
||||||
|
| `vehicle_build.py` | Models the drivable sedan: three-tier body, four wheels, lamps and bumpers, plus four `UCX_` convex collision hulls; saves the .blend, exports an FBX with the `ue_metric_v1` preset, and writes a report with every dimension the vehicle config needs. | `--out`, `--fbx`, `--report` | `blender -b --factory-startup --python Tools/vehicle_build.py -- --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx` |
|
||||||
|
| `ue_vehicle_import.py` | Imports the sedan's six systems into `/Game/Hollywood/Vehicle` as separate static meshes, plus the two LOD meshes; the body carries the `UCX_` hulls as simple collision. | (no arguments; reads `/tmp/gigline/vehicle`) | `UnrealEditor-Cmd <uproject> -run=pythonscript -script=Tools/ue_vehicle_import.py -nullrhi -unattended -nopause` |
|
||||||
|
| `vehicle_from_template.py` | Converts the owner's `hot hatch.blend` into UE-ready assets: normalises the scale to a documented 4.05 m, splits systems (body/trim/glass/lights/wheels), adds a `UCX_` hull, decimates two LODs, exports FBX per system and writes a report. | `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/vehicle_from_template.py -- --out /tmp/gigline/hatch` |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -305,8 +305,16 @@ def build():
|
|||||||
log(f"could not create {MAP}")
|
log(f"could not create {MAP}")
|
||||||
return
|
return
|
||||||
|
|
||||||
removed = clear(("Demo_", "PlayerStart", "Hero_Facade", "Frontage_", "RetailTrim_",
|
# Repeat until nothing is left: destroying actors while iterating a snapshot can miss
|
||||||
"ShotSequencer"))
|
# some, which is how two Demo_Vehicle_Sedan actors survived into a saved map and the
|
||||||
|
# scene audit failed on a duplicate label.
|
||||||
|
removed = 0
|
||||||
|
for _ in range(4):
|
||||||
|
pass_removed = clear(("Demo_", "PlayerStart", "Hero_Facade", "Frontage_",
|
||||||
|
"RetailTrim_", "ShotSequencer"))
|
||||||
|
removed += pass_removed
|
||||||
|
if pass_removed == 0:
|
||||||
|
break
|
||||||
log(f"cleared {removed} previous demo actors")
|
log(f"cleared {removed} previous demo actors")
|
||||||
|
|
||||||
# --- lighting: fixed daytime ------------------------------------------
|
# --- lighting: fixed daytime ------------------------------------------
|
||||||
@@ -647,6 +655,42 @@ def build():
|
|||||||
unreal.Rotator(pitch=0.0, yaw=90.0, roll=0.0))
|
unreal.Rotator(pitch=0.0, yaw=90.0, roll=0.0))
|
||||||
log("staged: Demo_PlayerStart on the cut north sidewalk, facing south (+Y)")
|
log("staged: Demo_PlayerStart on the cut north sidewalk, facing south (+Y)")
|
||||||
|
|
||||||
|
# --- vehicle ---------------------------------------------------------
|
||||||
|
# The physics pawn and the visible panels are staged as two things on purpose: the
|
||||||
|
# probe showed the meshes render as plain StaticMeshActors while the same meshes
|
||||||
|
# attached to the vehicle pawn stay invisible, so the visuals do not depend on the
|
||||||
|
# pawn until that is solved. A visible car that does not drive yet beats an invisible
|
||||||
|
# one that does.
|
||||||
|
# The hot-hatch template, normalised to 4.05 m and split per the vehicle-artist
|
||||||
|
# object split. Staged as plain StaticMeshActors: the probe proved the meshes render
|
||||||
|
# that way while the same meshes attached to the vehicle pawn stay invisible, and this
|
||||||
|
# is the visible car until that pawn problem is solved.
|
||||||
|
vehicle_origin = unreal.Vector(-12950.0, -900.0, 372.0)
|
||||||
|
vehicle_rotation = unreal.Rotator(pitch=0.0, yaw=0.0, roll=0.0)
|
||||||
|
|
||||||
|
panel_systems = (
|
||||||
|
"SM_Vehicle_Body",
|
||||||
|
"SM_Vehicle_Trim",
|
||||||
|
"SM_Vehicle_Glass",
|
||||||
|
"SM_Vehicle_Lights",
|
||||||
|
"SM_Vehicle_Wheels",
|
||||||
|
)
|
||||||
|
staged_panels = 0
|
||||||
|
for index, mesh_name in enumerate(panel_systems):
|
||||||
|
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Vehicle/{mesh_name}")
|
||||||
|
if not mesh:
|
||||||
|
log(f"vehicle panel {mesh_name}: MISSING")
|
||||||
|
continue
|
||||||
|
actor = spawn(unreal.StaticMeshActor, vehicle_origin,
|
||||||
|
f"Demo_VehiclePanel_{index:02d}", vehicle_rotation)
|
||||||
|
if actor:
|
||||||
|
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
|
||||||
|
if comp:
|
||||||
|
comp.set_static_mesh(mesh)
|
||||||
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||||
|
staged_panels += 1
|
||||||
|
log(f"staged: {staged_panels} Demo_VehiclePanel actors (plain static meshes)")
|
||||||
|
|
||||||
# A lockable LFS .umap checks out read-only, which makes save_map silently
|
# A lockable LFS .umap checks out read-only, which makes save_map silently
|
||||||
# return False — chmod u+w first or the staged actors never persist.
|
# return False — chmod u+w first or the staged actors never persist.
|
||||||
umap = os.path.join(unreal.Paths.project_dir(), MAP.replace("/Game/", "Content/") + ".umap")
|
umap = os.path.join(unreal.Paths.project_dir(), MAP.replace("/Game/", "Content/") + ".umap")
|
||||||
|
|||||||
@@ -289,6 +289,10 @@ try:
|
|||||||
# Level horizon for the same reason: a block elevation is read by its
|
# Level horizon for the same reason: a block elevation is read by its
|
||||||
# verticals, and a pitched camera makes them lean.
|
# verticals, and a pitched camera makes them lean.
|
||||||
("demo-third-person-walk", unreal.Vector(-12940, -980, 560), unreal.Vector(-13336, -980, 500), 55.0),
|
("demo-third-person-walk", unreal.Vector(-12940, -980, 560), unreal.Vector(-13336, -980, 500), 55.0),
|
||||||
|
# The sedan, staged on the carriageway at (-12950, -900). Three-quarter front
|
||||||
|
# view from the road so both the body and the wheels are visible: a side-on
|
||||||
|
# or rear view hides the wheel stances that the build is judged on.
|
||||||
|
("demo-vehicle", unreal.Vector(-12560, -1560, 560), unreal.Vector(-12950, -900, 420), 45.0),
|
||||||
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
|
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
|
||||||
# Street level, from the north sidewalk: the old camera sat at Y -400,
|
# Street level, from the north sidewalk: the old camera sat at Y -400,
|
||||||
# inside the facade body (which spans Y -1457..-200), so it looked at
|
# inside the facade body (which spans Y -1457..-200), so it looked at
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Import the sedan systems as separate static meshes (0.3.0).
|
||||||
|
#
|
||||||
|
# blender -b --factory-startup --python Tools/vehicle_build.py -- \
|
||||||
|
# --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx
|
||||||
|
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
|
||||||
|
# -script=<repo>/Tools/ue_vehicle_import.py -nullrhi -unattended -nopause -stdout
|
||||||
|
#
|
||||||
|
# One asset per system, per the vehicle-artist skill's object split: body, glass, front
|
||||||
|
# and rear lights, mirrors, and one wheel reused four times. The body FBX also carries
|
||||||
|
# the UCX_ hulls, which Unreal consumes as simple collision.
|
||||||
|
#
|
||||||
|
# Result file: /tmp/gigline/vehicle-import.txt
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import unreal
|
||||||
|
|
||||||
|
DEST = "/Game/Hollywood/Vehicle"
|
||||||
|
SOURCE = "/tmp/gigline/hatch"
|
||||||
|
SYSTEMS = (
|
||||||
|
("body", "SM_Vehicle_Body"),
|
||||||
|
("trim", "SM_Vehicle_Trim"),
|
||||||
|
("glass", "SM_Vehicle_Glass"),
|
||||||
|
("lights", "SM_Vehicle_Lights"),
|
||||||
|
("wheels", "SM_Vehicle_Wheels"),
|
||||||
|
)
|
||||||
|
RESULT = "/tmp/gigline/vehicle-import.txt"
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
lines.append(str(msg))
|
||||||
|
unreal.log(str(msg))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
for source, name in SYSTEMS:
|
||||||
|
path = os.path.join(SOURCE, f"{source}.fbx")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
log(f"{name}: missing source {path}")
|
||||||
|
lines.append("VERDICT: FAIL")
|
||||||
|
return
|
||||||
|
task = unreal.AssetImportTask()
|
||||||
|
task.filename = path
|
||||||
|
task.destination_path = DEST
|
||||||
|
task.destination_name = name
|
||||||
|
task.automated = True
|
||||||
|
task.save = True
|
||||||
|
task.replace_existing = True
|
||||||
|
|
||||||
|
ui = unreal.FbxImportUI()
|
||||||
|
ui.import_mesh = True
|
||||||
|
ui.import_as_skeletal = False
|
||||||
|
ui.import_materials = True
|
||||||
|
ui.import_textures = False
|
||||||
|
ui.static_mesh_import_data.combine_meshes = True
|
||||||
|
ui.static_mesh_import_data.generate_lightmap_u_vs = True
|
||||||
|
ui.static_mesh_import_data.auto_generate_collision = True
|
||||||
|
ui.static_mesh_import_data.import_uniform_scale = 1.0
|
||||||
|
task.options = ui
|
||||||
|
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||||
|
|
||||||
|
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
|
||||||
|
if mesh is None:
|
||||||
|
log(f"{name}: MISSING after import")
|
||||||
|
lines.append("VERDICT: FAIL")
|
||||||
|
return
|
||||||
|
bounds = mesh.get_bounding_box() if hasattr(mesh, "get_bounding_box") else None
|
||||||
|
size = ""
|
||||||
|
if bounds:
|
||||||
|
extent = bounds.max - bounds.min
|
||||||
|
size = f"{extent.x/100:.2f} x {extent.y/100:.2f} x {extent.z/100:.2f} m"
|
||||||
|
mats = mesh.get_editor_property("static_materials")
|
||||||
|
log(f"{name}: imported, {len(mats)} material slot(s), bounds {size}")
|
||||||
|
|
||||||
|
# Wire the two LOD meshes into the body's LOD group. import_lod is the supported
|
||||||
|
# path; copying bytes between assets is not, and the LOD group is what the engine
|
||||||
|
# uses to pick by screen size.
|
||||||
|
# LOD meshes are imported as their own assets. Wiring them into the body's LOD group
|
||||||
|
# needs StaticMeshEditorSubsystem.import_lod, which is unavailable in this headless
|
||||||
|
# commandlet (get_editor_subsystem returns None), so the group is a follow-up.
|
||||||
|
for source, name in (("body_lod1", "SM_Vehicle_Body_LOD1"),
|
||||||
|
("body_lod2", "SM_Vehicle_Body_LOD2")):
|
||||||
|
path = os.path.join(SOURCE, f"{source}.fbx")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
log(f"{name}: missing {path}")
|
||||||
|
continue
|
||||||
|
task = unreal.AssetImportTask()
|
||||||
|
task.filename = path
|
||||||
|
task.destination_path = DEST
|
||||||
|
task.destination_name = name
|
||||||
|
task.automated = True
|
||||||
|
task.save = True
|
||||||
|
task.replace_existing = True
|
||||||
|
ui = unreal.FbxImportUI()
|
||||||
|
ui.import_mesh = True
|
||||||
|
ui.import_materials = True
|
||||||
|
ui.static_mesh_import_data.combine_meshes = True
|
||||||
|
task.options = ui
|
||||||
|
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||||
|
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
|
||||||
|
log(f"{name}: imported as its own asset ({'ok' if mesh else 'MISSING'}); "
|
||||||
|
"LOD group wiring needs an editor session")
|
||||||
|
|
||||||
|
# Skeletal vehicle for Chaos is not built from this template yet: the hatch is a
|
||||||
|
# static model, and the drivable rig has to be authored around it.
|
||||||
|
skel_path = os.path.join(SOURCE, "vehicle_skeletal.fbx")
|
||||||
|
if os.path.exists(skel_path):
|
||||||
|
task = unreal.AssetImportTask()
|
||||||
|
task.filename = skel_path
|
||||||
|
task.destination_path = DEST
|
||||||
|
task.destination_name = "SK_Vehicle_Sedan"
|
||||||
|
task.automated = True
|
||||||
|
task.save = True
|
||||||
|
task.replace_existing = True
|
||||||
|
ui = unreal.FbxImportUI()
|
||||||
|
ui.import_mesh = True
|
||||||
|
ui.import_as_skeletal = True
|
||||||
|
ui.import_animations = False
|
||||||
|
ui.import_materials = True
|
||||||
|
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
|
||||||
|
task.options = ui
|
||||||
|
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||||
|
skel = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SK_Vehicle_Sedan")
|
||||||
|
if skel:
|
||||||
|
skeleton = skel.get_editor_property("skeleton")
|
||||||
|
# Skeleton has no bone_names property in 5.8; read the reference skeleton
|
||||||
|
# through the asset registry instead of guessing a property name.
|
||||||
|
log(f"SK_Vehicle_Sedan: imported, skeleton={skeleton.get_name() if skeleton else 'NONE'}")
|
||||||
|
else:
|
||||||
|
log("SK_Vehicle_Sedan: MISSING after import")
|
||||||
|
lines.append("VERDICT: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
import traceback
|
||||||
|
log(f"FAILED: {type(exc).__name__}: {exc}")
|
||||||
|
log(traceback.format_exc())
|
||||||
|
lines.append("VERDICT: FAIL")
|
||||||
|
|
||||||
|
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
||||||
|
with open(RESULT, "w") as f:
|
||||||
|
f.write("\n".join(lines) + "\n")
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Model a drivable sedan in Blender: body, wheels, collision and LOD.
|
||||||
|
|
||||||
|
blender -b --factory-startup --python Tools/vehicle_build.py -- \
|
||||||
|
--out Art/blender/vehicle/gigline-sedan.blend \
|
||||||
|
--fbx /tmp/gigline/vehicle/sedan.fbx \
|
||||||
|
--report /tmp/gigline/vehicle/report.json
|
||||||
|
|
||||||
|
Scale is metres, sourced from real sedan dimensions: 4.72 m long, 1.83 m wide,
|
||||||
|
1.44 m tall, 2.78 m wheelbase. Wheels are separate objects so Chaos can attach
|
||||||
|
PhysicsAssets-lite wheel references and so the wheel radius the vehicle config needs is
|
||||||
|
read from the mesh rather than typed in twice.
|
||||||
|
|
||||||
|
Collision is `UCX_`-prefixed convex boxes per panel group, which is what the
|
||||||
|
`collision-proxy` skill asks for and what Unreal's `ue_metric_v1` preset expects: one
|
||||||
|
hull for the body, one per wheel-less corner trimmed, and a floor slab. Simplified
|
||||||
|
hulls are deliberate — a car does not need per-panel collision for driving.
|
||||||
|
|
||||||
|
Every measurement is logged and written to the report, so the vehicle config values
|
||||||
|
(wheelbase, track, radius) can be read from the build instead of guessed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 # noqa: E402
|
||||||
|
|
||||||
|
# Real sedan reference: length, width, height, wheelbase, track, wheel radius.
|
||||||
|
DIMS = {
|
||||||
|
"length": 4.72,
|
||||||
|
"width": 1.80,
|
||||||
|
"height": 1.44,
|
||||||
|
"wheelbase": 2.78,
|
||||||
|
"track": 1.62,
|
||||||
|
"wheel_radius": 0.33,
|
||||||
|
"wheel_width": 0.22,
|
||||||
|
"ride_height": 0.16,
|
||||||
|
}
|
||||||
|
|
||||||
|
PARTS: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
PARTS.append(message)
|
||||||
|
print(f"[vehicle] {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def material(name, colour, roughness=0.45, metallic=0.0):
|
||||||
|
mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
|
||||||
|
mat.use_nodes = True
|
||||||
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||||
|
if bsdf:
|
||||||
|
bsdf.inputs["Base Color"].default_value = (*colour, 1.0)
|
||||||
|
bsdf.inputs["Roughness"].default_value = roughness
|
||||||
|
bsdf.inputs["Metallic"].default_value = metallic
|
||||||
|
return mat
|
||||||
|
|
||||||
|
|
||||||
|
def box(name, centre, size, mat):
|
||||||
|
bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre)
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
obj.name = name
|
||||||
|
obj.scale = size
|
||||||
|
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||||
|
obj.data.materials.append(mat)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def build_body(mats):
|
||||||
|
"""Three-tier silhouette: sill slab, bonnet and boot shoulders, cabin greenhouse.
|
||||||
|
|
||||||
|
The first build was one tall slab plus a floating cabin, which read as a box truck.
|
||||||
|
A sedan reads from the step between the bonnet and the roof, so the volumes are
|
||||||
|
stacked instead: a slab to beltline height, a lower bonnet in front and boot behind,
|
||||||
|
and the cabin only over the middle.
|
||||||
|
"""
|
||||||
|
L, W, H = DIMS["length"], DIMS["width"], DIMS["height"]
|
||||||
|
ride = DIMS["ride_height"]
|
||||||
|
belt = 0.98 # beltline height above ground
|
||||||
|
roof = H # 1.44
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Sill slab: full length, narrow enough that the wheels stand proud of it.
|
||||||
|
parts.append(box("Body_Sill", (0.0, 0.0, ride + (belt - ride) * 0.5),
|
||||||
|
(L, 1.56, belt - ride), mats["paint"]))
|
||||||
|
# Bonnet: front 1.55 m, from beltline to a lower bonnet top.
|
||||||
|
parts.append(box("Body_Bonnet", (L * 0.5 - 0.78, 0.0, (belt + 1.06) * 0.5),
|
||||||
|
(1.56, W * 0.94, 1.06 - belt), mats["paint"]))
|
||||||
|
# Boot: rear 1.05 m, slightly lower than the bonnet.
|
||||||
|
parts.append(box("Body_Boot", (-L * 0.5 + 0.52, 0.0, (belt + 1.02) * 0.5),
|
||||||
|
(1.04, W * 0.94, 1.02 - belt), mats["paint"]))
|
||||||
|
# Cabin: the greenhouse, between bonnet and boot, narrower than the sills.
|
||||||
|
parts.append(box("Body_Cabin", (-0.02, 0.0, (1.06 + roof) * 0.5),
|
||||||
|
(2.02, W * 0.86, roof - 1.06), mats["paint"]))
|
||||||
|
|
||||||
|
# Glazing, inset a couple of centimetres so it reads as glass, not paint.
|
||||||
|
parts.append(box("Glass_Windscreen", (0.96, 0.0, 1.24), (0.05, W * 0.78, 0.30), mats["glass"]))
|
||||||
|
parts.append(box("Glass_Rear", (-1.00, 0.0, 1.24), (0.05, W * 0.76, 0.28), mats["glass"]))
|
||||||
|
for side in (-1.0, 1.0):
|
||||||
|
parts.append(box(f"Glass_Side_{'L' if side > 0 else 'R'}",
|
||||||
|
(-0.02, side * W * 0.43, 1.24), (1.76, 0.02, 0.26), mats["glass"]))
|
||||||
|
|
||||||
|
# Lamps, grille, bumpers, mirrors.
|
||||||
|
for side in (-1.0, 1.0):
|
||||||
|
parts.append(box(f"Lamp_Front_{'L' if side > 0 else 'R'}",
|
||||||
|
(L * 0.5 - 0.03, side * W * 0.32, 0.94), (0.05, 0.28, 0.12), mats["lamp"]))
|
||||||
|
parts.append(box(f"Lamp_Rear_{'L' if side > 0 else 'R'}",
|
||||||
|
(-L * 0.5 + 0.03, side * W * 0.32, 0.94), (0.04, 0.26, 0.10), mats["tail"]))
|
||||||
|
parts.append(box(f"Mirror_{'L' if side > 0 else 'R'}",
|
||||||
|
(0.80, side * (W * 0.5 + 0.05), 1.18), (0.08, 0.12, 0.07), mats["trim"]))
|
||||||
|
parts.append(box("Grille", (L * 0.5 - 0.02, 0.0, 0.80), (0.04, W * 0.48, 0.12), mats["trim"]))
|
||||||
|
for front in (1.0, -1.0):
|
||||||
|
parts.append(box(f"Bumper_{'F' if front > 0 else 'R'}",
|
||||||
|
(front * (L * 0.5 - 0.06), 0.0, ride + 0.14),
|
||||||
|
(0.16, W * 0.90, 0.20), mats["trim"]))
|
||||||
|
log(f"body: {len(parts)} parts, {L} x {W} x {H} m, beltline {belt} m")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def build_wheels(mats):
|
||||||
|
r, w = DIMS["wheel_radius"], DIMS["wheel_width"]
|
||||||
|
half_base = DIMS["wheelbase"] * 0.5
|
||||||
|
half_track = DIMS["track"] * 0.5
|
||||||
|
wheels = []
|
||||||
|
for name, x, y in (("Wheel_FL", half_base, half_track),
|
||||||
|
("Wheel_FR", half_base, -half_track),
|
||||||
|
("Wheel_RL", -half_base, half_track),
|
||||||
|
("Wheel_RR", -half_base, -half_track)):
|
||||||
|
bpy.ops.mesh.primitive_cylinder_add(vertices=20, radius=r, depth=w,
|
||||||
|
location=(x, y, r), rotation=(0.0, math.pi / 2, 0.0))
|
||||||
|
wheel = bpy.context.active_object
|
||||||
|
wheel.name = name
|
||||||
|
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
|
||||||
|
wheel.data.materials.append(mats["tyre"])
|
||||||
|
wheels.append(wheel)
|
||||||
|
log(f"wheels: 4 at r={r} m, width {w} m, wheelbase {DIMS['wheelbase']} m, track {DIMS['track']} m")
|
||||||
|
return wheels
|
||||||
|
|
||||||
|
|
||||||
|
def build_collision(mats):
|
||||||
|
"""One convex hull per major volume: body, cabin, and the four wheel arches.
|
||||||
|
|
||||||
|
Unreal reads `UCX_<mesh>_NN` names; the exporter ships them as separate meshes and
|
||||||
|
the import preset turns them into simple collision.
|
||||||
|
"""
|
||||||
|
L, W, H = DIMS["length"], DIMS["width"], DIMS["height"]
|
||||||
|
ride = DIMS["ride_height"]
|
||||||
|
hulls = [
|
||||||
|
box("UCX_Sedan_Body_01", (0.0, 0.0, ride + 0.40), (L * 0.98, W * 0.98, 0.70), mats["collision"]),
|
||||||
|
box("UCX_Sedan_Cabin_01", (-0.12, 0.0, ride + 0.94), (L * 0.44, W * 0.86, 0.42), mats["collision"]),
|
||||||
|
box("UCX_Sedan_BumperF_01", (L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]),
|
||||||
|
box("UCX_Sedan_BumperR_01", (-L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]),
|
||||||
|
]
|
||||||
|
for hull in hulls:
|
||||||
|
hull.hide_render = True
|
||||||
|
hull.display_type = "WIRE"
|
||||||
|
log(f"collision: {len(hulls)} convex hulls (body, cabin, two bumpers), hidden from render")
|
||||||
|
return hulls
|
||||||
|
|
||||||
|
|
||||||
|
def build_skeletal(joined, wheels, out_fbx, exp_dir):
|
||||||
|
"""Bind the whole vehicle to a five-bone rig for Chaos.
|
||||||
|
|
||||||
|
Chaos Vehicles drive a *skeletal* mesh: the movement component names wheel bones and
|
||||||
|
spins them, and the physics asset supplies the collision. The static split above
|
||||||
|
stays for parts that do not deform, but the drivable asset has to be skeletal, so
|
||||||
|
this builds a rig with a root plus one bone per wheel and skins every wheel to its
|
||||||
|
own bone at full weight.
|
||||||
|
"""
|
||||||
|
bpy.ops.object.armature_add(location=(0.0, 0.0, 0.0))
|
||||||
|
rig = bpy.context.active_object
|
||||||
|
rig.name = "VehicleRig"
|
||||||
|
bpy.ops.object.mode_set(mode="EDIT")
|
||||||
|
edit_bones = rig.data.edit_bones
|
||||||
|
root = edit_bones[0]
|
||||||
|
root.name = "root"
|
||||||
|
root.head = (0.0, 0.0, 0.0)
|
||||||
|
root.tail = (0.0, 0.0, 0.30)
|
||||||
|
half_base = DIMS["wheelbase"] * 0.5
|
||||||
|
half_track = DIMS["track"] * 0.5
|
||||||
|
r = DIMS["wheel_radius"]
|
||||||
|
wheel_positions = {
|
||||||
|
"Wheel_FL": (half_base, half_track),
|
||||||
|
"Wheel_FR": (half_base, -half_track),
|
||||||
|
"Wheel_RL": (-half_base, half_track),
|
||||||
|
"Wheel_RR": (-half_base, -half_track),
|
||||||
|
}
|
||||||
|
for name, (x, y) in wheel_positions.items():
|
||||||
|
bone = edit_bones.new(name)
|
||||||
|
bone.head = (x, y, r)
|
||||||
|
# Point the bone along the axle: the wheel spins about its own Y.
|
||||||
|
bone.tail = (x, y - math.copysign(0.25, y), r)
|
||||||
|
bone.parent = root
|
||||||
|
bpy.ops.object.mode_set(mode="OBJECT")
|
||||||
|
|
||||||
|
# Join every visible system plus the wheels into one mesh, then weight by part.
|
||||||
|
parts = [obj for obj in joined.values()] + list(wheels)
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
for part in parts:
|
||||||
|
part.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = joined["body"]
|
||||||
|
bpy.ops.object.join()
|
||||||
|
mesh = bpy.context.active_object
|
||||||
|
mesh.name = "SM_Vehicle_Skeletal"
|
||||||
|
|
||||||
|
groups = {name: mesh.vertex_groups.new(name=name) for name in ["root", *wheel_positions]}
|
||||||
|
# Join order is preserved per part: re-derive which vertices belong to which wheel by
|
||||||
|
# their world position, since the join loses the object boundaries.
|
||||||
|
half_base = DIMS["wheelbase"] * 0.5
|
||||||
|
half_track = DIMS["track"] * 0.5
|
||||||
|
for vertex in mesh.data.vertices:
|
||||||
|
co = vertex.co
|
||||||
|
nearest = None
|
||||||
|
for name, (x, y) in wheel_positions.items():
|
||||||
|
if abs(co.x - x) < 0.45 and abs(co.y - y) < 0.16 and co.z < DIMS["wheel_radius"] * 2.2:
|
||||||
|
nearest = name
|
||||||
|
break
|
||||||
|
groups[nearest or "root"].add([vertex.index], 1.0, "REPLACE")
|
||||||
|
|
||||||
|
armature = mesh.modifiers.new(name="Armature", type="ARMATURE")
|
||||||
|
armature.object = rig
|
||||||
|
mesh.parent = rig
|
||||||
|
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
rig.select_set(True)
|
||||||
|
mesh.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = rig
|
||||||
|
path = os.path.join(exp_dir, "vehicle_skeletal.fbx")
|
||||||
|
fbx_export_preset.export_character(path, selection=True)
|
||||||
|
log(f"skeletal vehicle exported: {path}, bones root + 4 wheels, "
|
||||||
|
f"{sum(len(p.vertices) - 2 for p in mesh.data.polygons)} triangles")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
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="Art/blender/vehicle/gigline-sedan.blend")
|
||||||
|
ap.add_argument("--fbx", default="/tmp/gigline/vehicle/sedan.fbx")
|
||||||
|
ap.add_argument("--report", default="/tmp/gigline/vehicle/report.json")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
mats = {
|
||||||
|
"paint": material("M_Vehicle_Paint", (0.42, 0.05, 0.05), 0.28, 0.35),
|
||||||
|
"glass": material("M_Vehicle_Glass", (0.06, 0.08, 0.09), 0.12),
|
||||||
|
"tyre": material("M_Vehicle_Tyre", (0.02, 0.02, 0.02), 0.85),
|
||||||
|
"trim": material("M_Vehicle_Trim", (0.04, 0.04, 0.04), 0.55),
|
||||||
|
"lamp": material("M_Vehicle_Lamp", (0.85, 0.82, 0.70), 0.18),
|
||||||
|
"tail": material("M_Vehicle_Tail", (0.45, 0.03, 0.03), 0.25),
|
||||||
|
"collision": material("M_Vehicle_Collision", (0.2, 0.2, 0.2), 0.9),
|
||||||
|
}
|
||||||
|
|
||||||
|
body = build_body(mats)
|
||||||
|
wheels = build_wheels(mats)
|
||||||
|
hulls = build_collision(mats)
|
||||||
|
|
||||||
|
# Object split per the vehicle-artist skill: body, glass, lights and wheels stay
|
||||||
|
# separate so each can be swapped, made transparent or made emissive on its own. A
|
||||||
|
# single merged mesh is exactly what the skill forbids for a game vehicle.
|
||||||
|
systems = {}
|
||||||
|
for part in body:
|
||||||
|
name = part.name
|
||||||
|
if name.startswith("Glass_"):
|
||||||
|
key = "glass"
|
||||||
|
elif name.startswith("Lamp_"):
|
||||||
|
key = "light_front"
|
||||||
|
elif name.startswith("Lamp_Rear") or name.startswith("Lamp_Rear_"):
|
||||||
|
key = "light_rear"
|
||||||
|
elif name.startswith("Mirror_"):
|
||||||
|
key = "mirrors"
|
||||||
|
else:
|
||||||
|
key = "body"
|
||||||
|
systems.setdefault(key, []).append(part)
|
||||||
|
# Lamps were named lamp_front/lamp_rear by prefix; separate them properly.
|
||||||
|
rear = [p for p in systems.pop("light_front", []) if "Rear" in p.name]
|
||||||
|
front = [p for p in systems.get("body", []) if False]
|
||||||
|
# Collect every lamp again from the body list by name.
|
||||||
|
lamps = [p for p in body if p.name.startswith("Lamp_")]
|
||||||
|
systems["light_front"] = [p for p in lamps if "Rear" not in p.name]
|
||||||
|
systems["light_rear"] = [p for p in lamps if "Rear" in p.name]
|
||||||
|
systems["body"] = [p for p in body if not p.name.startswith(
|
||||||
|
("Glass_", "Lamp_", "Mirror_"))]
|
||||||
|
|
||||||
|
joined = {}
|
||||||
|
for key, parts in systems.items():
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
for part in parts:
|
||||||
|
part.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = parts[0]
|
||||||
|
if len(parts) > 1:
|
||||||
|
bpy.ops.object.join()
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
obj.name = {
|
||||||
|
"body": "SM_Vehicle_Body",
|
||||||
|
"glass": "SM_Vehicle_Glass",
|
||||||
|
"light_front": "SM_Vehicle_Light_Front",
|
||||||
|
"light_rear": "SM_Vehicle_Light_Rear",
|
||||||
|
"mirrors": "SM_Vehicle_Mirrors",
|
||||||
|
}[key]
|
||||||
|
joined[key] = obj
|
||||||
|
shell = joined["body"]
|
||||||
|
hull_join = hulls
|
||||||
|
log("systems: " + ", ".join(f"{k}={len(v)} parts" for k, v in systems.items()))
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
|
||||||
|
bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(args.out))
|
||||||
|
log(f"saved {args.out}")
|
||||||
|
|
||||||
|
# Export: shell + collision hulls in one file, wheels in their own file so Chaos
|
||||||
|
# can bind them to wheel bones or use them as visual wheels.
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.fbx)), exist_ok=True)
|
||||||
|
exp_dir = os.path.dirname(os.path.abspath(args.fbx))
|
||||||
|
exported = {}
|
||||||
|
for key, obj in joined.items():
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
obj.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
path = os.path.join(exp_dir, f"{key}.fbx")
|
||||||
|
fbx_export_preset.export_static(path, selection=True)
|
||||||
|
exported[key] = path
|
||||||
|
# Body carries the collision hulls so the hulls and the shell land in one asset,
|
||||||
|
# which is how Unreal wires UCX_ meshes to their mesh.
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
shell.select_set(True)
|
||||||
|
for hull in hull_join:
|
||||||
|
hull.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = shell
|
||||||
|
fbx_export_preset.export_static(os.path.join(exp_dir, "body.fbx"), selection=True)
|
||||||
|
# One wheel asset, reused four times: Chaos binds the same wheel mesh per bone.
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
wheels[0].select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = wheels[0]
|
||||||
|
fbx_export_preset.export_static(os.path.join(exp_dir, "wheel.fbx"), selection=True)
|
||||||
|
exported["wheel"] = os.path.join(exp_dir, "wheel.fbx")
|
||||||
|
log(f"exported {len(exported)} system FBX files to {exp_dir}")
|
||||||
|
per_system = {key: sum(len(p.vertices) - 2 for p in obj.data.polygons)
|
||||||
|
for key, obj in joined.items()}
|
||||||
|
system_names = {key: obj.name for key, obj in joined.items()}
|
||||||
|
wheel_names = [w.name for w in wheels]
|
||||||
|
wheel_triangles = sum(len(p.vertices) - 2 for p in wheels[0].data.polygons)
|
||||||
|
|
||||||
|
triangles = sum(len(p.vertices) - 2 for p in shell.data.polygons)
|
||||||
|
shell_triangles = triangles
|
||||||
|
for hull in hull_join:
|
||||||
|
triangles += sum(len(p.vertices) - 2 for p in hull.data.polygons)
|
||||||
|
# LODs per the vehicle-artist targets: LOD1 drops mirrors and lamp detail, LOD2 is a
|
||||||
|
# body-plus-glass shell. They are separate objects here and get wired into the
|
||||||
|
# body's LOD group at import time.
|
||||||
|
lod_paths = {}
|
||||||
|
for index, keep_glass, ratio in ((1, True, 0.55), (2, False, 0.25)):
|
||||||
|
lod_key = f"body_lod{index}"
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
shell.select_set(True)
|
||||||
|
if keep_glass and "glass" in joined:
|
||||||
|
joined["glass"].select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = shell
|
||||||
|
bpy.ops.object.duplicate()
|
||||||
|
lod = bpy.context.active_object
|
||||||
|
lod.name = f"SM_Vehicle_Body_LOD{index}"
|
||||||
|
# Decimate for real: the first version duplicated the shell unchanged, so both
|
||||||
|
# LODs came out at 84 triangles - an LOD that saves nothing.
|
||||||
|
decimate = lod.modifiers.new(name="LOD", type="DECIMATE")
|
||||||
|
decimate.ratio = ratio
|
||||||
|
bpy.ops.object.modifier_apply(modifier=decimate.name)
|
||||||
|
lod_path = os.path.join(exp_dir, f"{lod_key}.fbx")
|
||||||
|
fbx_export_preset.export_static(lod_path, selection=True)
|
||||||
|
lod_paths[lod_key] = lod_path
|
||||||
|
lod.hide_set(False)
|
||||||
|
log(f"{lod_key}: exported, {sum(len(p.vertices) - 2 for p in lod.data.polygons)} triangles")
|
||||||
|
bpy.data.objects.remove(lod, do_unlink=True)
|
||||||
|
|
||||||
|
# Skeletal asset last: joining the parts for it destroys the objects the LOD and
|
||||||
|
# system steps need. The first attempt called it earlier and died on
|
||||||
|
# "StructRNA of type Object has been removed".
|
||||||
|
skeletal_path = build_skeletal(joined, wheels, args.fbx, exp_dir)
|
||||||
|
exported["skeletal"] = skeletal_path
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"asset": "SM_Vehicle_Sedan",
|
||||||
|
"dimensions_m": DIMS,
|
||||||
|
"body_parts": len(body),
|
||||||
|
"triangles": shell_triangles,
|
||||||
|
"collision_hulls": [h.name for h in hull_join],
|
||||||
|
"wheels": wheel_names,
|
||||||
|
"systems": {key: {"object": system_names.get(key), "triangles": per_system.get(key, 0),
|
||||||
|
"fbx": exported.get(key)}
|
||||||
|
for key in system_names},
|
||||||
|
"wheel_triangles": wheel_triangles,
|
||||||
|
"lods": lod_paths,
|
||||||
|
"log": PARTS,
|
||||||
|
}
|
||||||
|
if args.report:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
|
||||||
|
with open(args.report, "w") as handle:
|
||||||
|
json.dump(report, handle, indent=2)
|
||||||
|
print(f"[vehicle] report -> {args.report}")
|
||||||
|
print(f"[vehicle] {triangles} triangles, {len(hull_join)} hulls")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Turn the provided hot-hatch .blend into UE-ready assets.
|
||||||
|
|
||||||
|
blender -b "Art/blender/vehicle/hot hatch.blend" \
|
||||||
|
--python Tools/vehicle_from_template.py -- \
|
||||||
|
--out /tmp/gigline/hatch --report /tmp/gigline/hatch/report.json
|
||||||
|
|
||||||
|
Follows two skills:
|
||||||
|
|
||||||
|
* **vehicle-artist** — one object per system (body, wheels, glass, lights, trim,
|
||||||
|
mirrors), collision planned with the body, LODs planned before export.
|
||||||
|
* **unreal-export** — scale confirmed with a known dimension, `SM_`/`UCX_` naming,
|
||||||
|
LOD chain, and every asset measured after import.
|
||||||
|
|
||||||
|
The template is not to scale: its longest axis is 7.21 m, which no hot hatch is. The
|
||||||
|
tool therefore normalises to a documented real length (default 4.05 m) and records the
|
||||||
|
factor, rather than exporting whatever units the file happened to use. That is the
|
||||||
|
"confirm scale with known-dimension test" step, done before the export instead of after
|
||||||
|
the import.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 # noqa: E402
|
||||||
|
|
||||||
|
# A three-door hot hatch: 4.05 m long, 1.75 m wide, 1.42 m tall.
|
||||||
|
TARGET_LENGTH_M = 4.05
|
||||||
|
TARGET_WIDTH_M = 1.75
|
||||||
|
TARGET_HEIGHT_M = 1.42
|
||||||
|
|
||||||
|
PARTS: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
PARTS.append(message)
|
||||||
|
print(f"[hatch] {message}")
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_RULES = (
|
||||||
|
# (system, material substrings that identify it, object-name substrings)
|
||||||
|
("glass", ("windows",), ("Cube.003",)),
|
||||||
|
("lights", ("light", "reflector"), ("lights and grill", "Cylinder.003", "Cylinder.004")),
|
||||||
|
("mirrors", ("mirror",), ("Cube.005",)),
|
||||||
|
("wheels", ("rubber", "brake caliper", "black shiny metal"), ("Cylinder", "Cylinder.001")),
|
||||||
|
("trim", ("plastic black", "pure black", "chrome", "black metal", "rough chrome"),
|
||||||
|
("Cube.001", "Cube.002", "Cube.004", "Cube.006", "Cube.007", "Cylinder.002")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(obj):
|
||||||
|
"""Assign an object to a system by its materials first, then by its name."""
|
||||||
|
materials = [m.name.lower() for m in obj.data.materials if m]
|
||||||
|
name = obj.name.lower()
|
||||||
|
for system, materials_hint, names_hint in SYSTEM_RULES:
|
||||||
|
if any(hint in name for hint in (n.lower() for n in names_hint)):
|
||||||
|
return system
|
||||||
|
if any(any(h in material for h in materials_hint) for material in materials):
|
||||||
|
return system
|
||||||
|
return "body"
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_scale(objects):
|
||||||
|
"""Scale and centre the whole car so it matches the documented dimensions."""
|
||||||
|
xs, ys, zs = [], [], []
|
||||||
|
for obj in objects:
|
||||||
|
for corner in obj.bound_box:
|
||||||
|
world = obj.matrix_world @ __import__("mathutils").Vector(corner)
|
||||||
|
xs.append(world.x)
|
||||||
|
ys.append(world.y)
|
||||||
|
zs.append(world.z)
|
||||||
|
length = max(ys) - min(ys)
|
||||||
|
width = max(xs) - min(xs)
|
||||||
|
height = max(zs) - min(zs)
|
||||||
|
log(f"template bounds: {length:.2f} x {width:.2f} x {height:.2f} m")
|
||||||
|
factor = TARGET_LENGTH_M / length if length > 0.01 else 1.0
|
||||||
|
log(f"scale factor {factor:.4f} (target length {TARGET_LENGTH_M} m, "
|
||||||
|
f"was {length:.2f} m)")
|
||||||
|
|
||||||
|
for obj in objects:
|
||||||
|
obj.scale = tuple(s * factor for s in obj.scale)
|
||||||
|
obj.location = tuple(l * factor for l in obj.location)
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
|
||||||
|
# Drop the car onto Z 0 and centre it on the origin in X/Y.
|
||||||
|
xs, ys, zs = [], [], []
|
||||||
|
for obj in objects:
|
||||||
|
for corner in obj.bound_box:
|
||||||
|
world = obj.matrix_world @ __import__("mathutils").Vector(corner)
|
||||||
|
xs.append(world.x)
|
||||||
|
ys.append(world.y)
|
||||||
|
zs.append(world.z)
|
||||||
|
offset = __import__("mathutils").Vector(
|
||||||
|
(-(min(xs) + max(xs)) / 2.0, -(min(ys) + max(ys)) / 2.0, -min(zs)))
|
||||||
|
for obj in objects:
|
||||||
|
obj.location = tuple(l + o for l, o in zip(obj.location, offset))
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
log(f"centred and grounded (offset {offset.x:.2f}, {offset.y:.2f}, {offset.z:.2f} m)")
|
||||||
|
|
||||||
|
|
||||||
|
def join_system(objects, name):
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
for obj in objects:
|
||||||
|
obj.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = objects[0]
|
||||||
|
if len(objects) > 1:
|
||||||
|
bpy.ops.object.join()
|
||||||
|
joined = bpy.context.active_object
|
||||||
|
joined.name = name
|
||||||
|
return joined
|
||||||
|
|
||||||
|
|
||||||
|
def add_collision(body):
|
||||||
|
"""One convex box per major volume, `UCX_`-prefixed for Unreal."""
|
||||||
|
hulls = []
|
||||||
|
xs, ys, zs = [], [], []
|
||||||
|
for corner in body.bound_box:
|
||||||
|
world = body.matrix_world @ __import__("mathutils").Vector(corner)
|
||||||
|
xs.append(world.x)
|
||||||
|
ys.append(world.y)
|
||||||
|
zs.append(world.z)
|
||||||
|
length, width, height = max(ys) - min(ys), max(xs) - min(xs), max(zs) - min(zs)
|
||||||
|
centre = ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0,
|
||||||
|
min(zs) + height * 0.5)
|
||||||
|
bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre)
|
||||||
|
hull = bpy.context.active_object
|
||||||
|
hull.name = "UCX_SM_Vehicle_Body_01"
|
||||||
|
hull.scale = (width * 0.96, length * 0.98, height * 0.62)
|
||||||
|
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||||
|
hull.hide_render = True
|
||||||
|
hulls.append(hull)
|
||||||
|
log(f"collision: 1 hull {width*0.96:.2f} x {length*0.98:.2f} x {height*0.62:.2f} m")
|
||||||
|
return hulls
|
||||||
|
|
||||||
|
|
||||||
|
def decimate_copy(source, name, ratio):
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
source.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = source
|
||||||
|
bpy.ops.object.duplicate()
|
||||||
|
copy = bpy.context.active_object
|
||||||
|
copy.name = name
|
||||||
|
modifier = copy.modifiers.new(name="LOD", type="DECIMATE")
|
||||||
|
modifier.ratio = ratio
|
||||||
|
bpy.ops.object.modifier_apply(modifier=modifier.name)
|
||||||
|
return copy
|
||||||
|
|
||||||
|
|
||||||
|
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/hatch")
|
||||||
|
ap.add_argument("--report", default="/tmp/gigline/hatch/report.json")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||||
|
if not meshes:
|
||||||
|
raise SystemExit("no meshes in the template")
|
||||||
|
log(f"template: {len(meshes)} mesh objects, "
|
||||||
|
f"{sum(len(o.data.vertices) for o in meshes)} vertices")
|
||||||
|
|
||||||
|
normalise_scale(meshes)
|
||||||
|
|
||||||
|
systems = {}
|
||||||
|
for obj in meshes:
|
||||||
|
systems.setdefault(classify(obj), []).append(obj)
|
||||||
|
log("systems: " + ", ".join(f"{k}={len(v)} objects" for k, v in systems.items()))
|
||||||
|
|
||||||
|
os.makedirs(args.out, exist_ok=True)
|
||||||
|
exported = {}
|
||||||
|
joined = {}
|
||||||
|
for system, objects in systems.items():
|
||||||
|
name = {
|
||||||
|
"body": "SM_Vehicle_Body",
|
||||||
|
"glass": "SM_Vehicle_Glass",
|
||||||
|
"lights": "SM_Vehicle_Lights",
|
||||||
|
"mirrors": "SM_Vehicle_Mirrors",
|
||||||
|
"wheels": "SM_Vehicle_Wheels",
|
||||||
|
"trim": "SM_Vehicle_Trim",
|
||||||
|
}[system]
|
||||||
|
obj = join_system(objects, name)
|
||||||
|
joined[system] = obj
|
||||||
|
path = os.path.join(args.out, f"{system}.fbx")
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
obj.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
fbx_export_preset.export_static(path, selection=True)
|
||||||
|
exported[system] = path
|
||||||
|
tris = sum(len(p.vertices) - 2 for p in obj.data.polygons)
|
||||||
|
log(f"{name}: {tris} triangles -> {path}")
|
||||||
|
|
||||||
|
hulls = add_collision(joined["body"])
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
joined["body"].select_set(True)
|
||||||
|
for hull in hulls:
|
||||||
|
hull.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = joined["body"]
|
||||||
|
fbx_export_preset.export_static(os.path.join(args.out, "body.fbx"), selection=True)
|
||||||
|
log("body re-exported with its UCX hull")
|
||||||
|
|
||||||
|
lods = {}
|
||||||
|
for index, ratio in ((1, 0.35), (2, 0.12)):
|
||||||
|
lod = decimate_copy(joined["body"], f"SM_Vehicle_Body_LOD{index}", ratio)
|
||||||
|
path = os.path.join(args.out, f"body_lod{index}.fbx")
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
lod.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = lod
|
||||||
|
fbx_export_preset.export_static(path, selection=True)
|
||||||
|
tris = sum(len(p.vertices) - 2 for p in lod.data.polygons)
|
||||||
|
lods[f"body_lod{index}"] = {"path": path, "triangles": tris}
|
||||||
|
log(f"SM_Vehicle_Body_LOD{index}: {tris} triangles")
|
||||||
|
bpy.data.objects.remove(lod, do_unlink=True)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"template": bpy.data.filepath,
|
||||||
|
"target_dimensions_m": {"length": TARGET_LENGTH_M, "width": TARGET_WIDTH_M,
|
||||||
|
"height": TARGET_HEIGHT_M},
|
||||||
|
"systems": {system: {"object": joined[system].name,
|
||||||
|
"triangles": sum(len(p.vertices) - 2
|
||||||
|
for p in joined[system].data.polygons),
|
||||||
|
"fbx": exported.get(system)}
|
||||||
|
for system in joined},
|
||||||
|
"lods": lods,
|
||||||
|
"collision": [h.name for h in hulls],
|
||||||
|
"log": PARTS,
|
||||||
|
}
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
|
||||||
|
with open(args.report, "w") as handle:
|
||||||
|
json.dump(report, handle, indent=2)
|
||||||
|
print(f"[hatch] report -> {args.report}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -96,6 +96,16 @@
|
|||||||
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
|
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
|
||||||
],
|
],
|
||||||
"expect": "The player body mid-stride in profile: legs apart, arms counter-swinging, walking along the sidewalk"
|
"expect": "The player body mid-stride in profile: legs apart, arms counter-swinging, walking along the sidewalk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "demo-vehicle",
|
||||||
|
"shot": "Screenshots/demo/review-demo-vehicle.png",
|
||||||
|
"size": [
|
||||||
|
1920,
|
||||||
|
1080
|
||||||
|
],
|
||||||
|
"references": [],
|
||||||
|
"expect": "A red sedan parked on the road seen from the front three-quarter: body, cabin, four wheels visible, sitting on the surface rather than floating or sunk"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user