FERRETGame engine over Babylon.js
Ferret

Parity with the original

A port is checked against the original, not against memory: the same views taken in Unreal and in Ferret, side by side. The shots answer what a description cannot ("are the far pillars supposed to be red brick?": yes, they are), and they keep a by-eye tuning honest.

The camera set#

A JSON file the game keeps (one per level): a list of named views, each with its place in both engines.

{
  "size": [1280, 720],
  "cameras": [
    { "name": "hall_corridor",
      "ferret": { "x": 31.524, "z": 2.0, "yaw": 4.7124, "pitch": 0 },
      "ue":     { "x": -3152.4, "y": 200.0, "z": 82.0, "yawDeg": 0, "pitch": 0 } }
  ]
}

The mapping between the two, as the export writes the level:

FerretUnreal
x metresx = -x * 100 centimetres (the scene mirrors x)
z metresy = z * 100
heightz = the pawn's capsule centre: floor + half height (80 for a 0.4 x 0.8 m capsule); 196 suits a yard whose ground is at 115 cm
yaw radiansyawDeg = degrees(yaw + pi / 2)
pitch degrees (the camera set keeps degrees on both sides)pitch degrees, positive up; the game converts to its camera rig's units and sign

Start from a spawn (its Unreal location is in the census: PlayerStart_* with loc): four turns there, then a few steps along the main path. Steps go along the axis the view looks down; check one view in both engines before taking the rest.

The Unreal shots#

tools/ue-export/ue/capture_parity.py runs inside the game (-game), so exposure, post process and fog are what a player sees, not the editor's:

FERRET_CAMS=cameras.hall.json FERRET_SHOTS=shots/unreal-hall FERRET_LOG=shots/unreal-hall/capture.log \
FERRET_MAP_OBJECT=/Game/Maps/Hall.Hall \
UnrealEditor.exe Game.uproject /Game/Maps/Hall -game -windowed -ResX=1280 -ResY=720 -ForceRes -nosound -nosplash -unattended \
  -ExecCmds="py <ferret>/tools/ue-export/ue/capture_parity.py"
  • It waits for the game world and the pawn, places the pawn at each camera, holds the view a moment (the character lands, streaming and shadows settle), writes HighResShot and quits. A few minutes with a cold shader cache.
  • FERRET_MAP_OBJECT must name the level being shot; with a wrong one it waits for a world that never comes.
  • One folder per level. A launcher that clears its shots folder before a run wipes another level's references if they share it.
  • The run is the real game: its network and its timers run too (see Running the Unreal side). Stop the process when capture.log says done.
  • A launcher that builds the -ExecCmds path from a variable: check the editor log's command line shows the full py <path>. A lost backslash left py alone, the level loaded and nothing was shot.

The Ferret shots#

The game puts its player at each camera's ferret position and yaw (through its own handles), hides the HUD, waits for the view to settle (2-3 s; textures stream in) and screenshots at the same size. Use the GPU (Chromium with ANGLE on D3D11 or the machine's GL) and the tier you are judging. Shoot every view after each change; stitch Unreal left, Ferret right, one image per view, and look at all of them, not the one you were fixing.

Reading the differences#

Take them in this order; each depends on the one before:

  1. Layout: the same things in the same places. A missing or moved thing is an export or mapping problem.
  2. Materials: colour, pattern and scale of each surface under the same light. Compare a flat, evenly lit patch. See Making a ported level look right for the usual causes.
  3. Light: overall level, the colour of shadowed areas (the floor), where it is bright. Tune per level in an override, a few numbers at a time, and reshoot.
  4. Details: decals, emissive lamps, screens, posters (per-instance textures), particles.

Write what matches and what still differs into the level's port plan after each round; an accepted difference is recorded as accepted, with the reason.