FERRETGame engine over Babylon.js
Ferret

Making a ported level look right

A ported level that loads and walks can still look wrong: black walls, a floor half dark, red smears on pillars, metal like coal, everything too bright. Each of these had one concrete cause in a real port, and a fix in the engine or in post-processing. Find the cause before changing anything: a guessed fix on top of an unknown cause makes the picture worse ("more and more black"), and the next guess cannot tell which change did what.

Find the cause, do not guess#

  1. Compare with the original first (Parity with the original): a shot of the same view in Unreal says what is right. Without it a red brick pillar looks like a bug and a wrong cream wall looks right.
  2. Name the thing. Pick the pixel: scene.pick(x, y) gives the mesh and material. pick ignores thin instances (EXT_mesh_gpu_instancing) unless picking is enabled on them, so a dark patch may belong to a thin-instanced mesh the pick passes through; list visible meshes whose bounds cover the point as well.
  3. Switch one thing at a time and read the pixel: sun off, ambient off, environment intensity 0, the material's normal map off, the mesh disabled (what is behind it?). The source that changes the pixel is the one to look at.
  4. Read the compiled shader of the mesh that is wrong and of a neighbour that is right: sourceMesh.subMeshes[0].effect.defines. A missing LIGHT0/HEMILIGHT1/LIGHTCOUNT means no lights; a missing REFLECTION means no environment; a difference between two meshes of one material means stale per-mesh state, not a material problem.
  5. Compare the data of a wrong and a right mesh: normals of the visible face against its winding, the world matrix determinant, UV ranges, vertex colours, which buffers exist.
  6. Check other browsers before blaming one: in the ports so far every "only in this browser" turned out to be timing (load order), not the browser.

Symptoms, causes, fixes#

SymptomCauseFix
Some meshes black, others fine; which ones changes between runs or browsersinstanced meshes draw with their source mesh, which AssetCache keeps in the container, out of the scene; Babylon updates lights and material state only for the scene's meshes, so a source keeps the lights and shader of the moment it loadedfixed in @ferret/assets (AssetCache resyncs the sources' lights and marks their materials dirty with the scene's). If it returns: compare the shader defines of two meshes of one material
One floor piece darker than the next, same materialthe same stale shader: compiled before the level's environment texture was setthe same fix; check REFLECTION in the defines
One floor tile darker than the next with a razor-sharp edge, only once the level has baked light; the tile lights up when the probes are switched off for allthe source was drawn before the light grid arrived; adding the probe plugin to the material calls resetDrawCache, which Babylon runs on the scene's meshes only, so the source kept a defines object without the plugin's keys (MATERIALPLUGIN_n of an older plugin, no FERRET_GI)fixed in @ferret/assets (the container's materials also reset the container's meshes). Check: the define strings of a dark and a light tile (String(sm._drawWrappers[i].defines)), not the defines object's fields, which can say FERRET_GI: true while the shader lacks it
Baked light in the wrong places: light pools where the original is dark, a corridor lit as if from its far endthe probe grid was baked in the export's glTF frame (x = ux/100) while the level package is in the loader's frame (x = -ux/100): the grid covers the mirror image of the level and the shader clamps to its edgebake-gi.mjs --mirror-x; check that environment.probes origin and dims cover the bounds of the level's floors and walls
The whole interior black once the level's lamps are indozens of point and spot lights take every light slot of a material ahead of the sun and ambient, and that many light blocks do not compile (GL_MAX_VERTEX_UNIFORM_BUFFERS)postprocess.mjs --no-lights for a level whose lamps were baked by Lightmass; light it by the environment (below) until baked light is ported
Walls or supports black from one sidenodes placed mirrored (negative scale) share a GPU-instanced mesh with unmirrored ones; instancing culls one side for allpostprocess.mjs gives mirrored nodes their own mesh, wound the other way when instanced
A mirrored floor lit as if from belowa mirrored placement turns the faces' side but not the normalspostprocess.mjs reverses the normals (and tangent handedness) of mirrored copies
Metal (lockers, pipes, supports) near blackPBR metal shows only what it reflects; an interior has no sky and the scene no environment textureenvironment.reflection in the manifest (a gradient cube; colours and intensity of its own; 0.3 for a dim hall)
Pillars and walls with red-pink smears, a black line across a wall, a flat grey floora world-aligned material (texture placed by world position, Unreal's WorldAlignedTexture): the exporter bakes it over one mesh's UVs, every other mesh samples a wrong patchexport the material parameters (ue/materials.py), write a spec of the source tiles and tile sizes, and run postprocess.mjs --world-aligned <spec>: source textures back, UVs from the world position per node
A band missing along the foot of walls (a skirting row of other tiles) on a material that is a blend of two layers (Unreal: base x (1 - mask) + layer x mask, both on the mesh's own UVs)the world-aligned replacement carries only the baseif the mask depends on V alone, give the spec overlay: { baseColor, vBands }: the mesh is cut at the band edges and the band gets the layer on the mesh's UVs (worldaligned.mjs); a mask that varies along U is not carried
Pictures on boards and walls missing (posters, stains)they are decals; the exporter writes nonedump each DecalComponent (world transform, DecalSize, the material's textures), write a spec and run postprocess.mjs --decals <spec>: a quad on the surface for each, the picture's alpha as opacity (decals.mjs)
Colours too dark and saturated (tan instead of cream) after replacing a bake by source texturesthe source texture is 16-bit, which the export writes linearencode 16-bit colour maps to sRGB before use (see Running the Unreal side); compare the mean colour with the bake
Black boxes or planes where the original shows nothinghelper geometry hidden in game in the original (black hollow boxes behind fake doors, blocking volumes) or a screen material that is black until a video plays (/Movies/Black)check the actor's hidden-in-game flag and the material's folder; drop helpers in the mapping, wire screens to a VideoScreen or HtmlScreen
Everything flat and too bright insidethe original is lit by baked light (Lightmass: the lamps light walls and ceiling, the floor stays in the sky colour, the roof shuts the sun out); the export has no lightmapsuntil baked light is ported: sun down, environment.ambient.direction facing down with ambient.ground the floor's colour, tuned by eye against the Unreal shots in the level's own override, not in the engine
With baked light, floors and ceilings brighter than the original however low the probe gain isthe reflection cube (environment.reflection) also lights every surface diffusely (its spherical harmonics), counting the sky a second time beside the probes; a floor seen at a grazing angle also reflects its bright horizonfixed in @ferret/levels: with environment.probes the cube only reflects. Check by scene.environmentIntensity = 0: what is left is the probes' share. For grazing floors darken the cube's horizon in the override
Lamps and windows without their glowUnreal's post process volume blooms (bloom 3-4 in a lit interior); Ferret drew noneenvironment.post.bloom (written by postprocess.mjs from the export's exposure.bloom); a game drops it on weak tiers
Tuning that stops matching after a fixthe earlier numbers were tuned against a picture with a fault still in it (stale shaders, a misplaced grid)after every fix of a cause, reshoot and retune; count the meshes drawn without the baked light first (no FERRET_GI in the define string of any drawn submesh)
Hazy distance, light of the wrong strengththe environment is converted by simple factors (fog x0.1 per metre, sun /4, ambient x0.5)tune per level in an override file merged over the exported environment.json; keep the export as Unreal wrote it
NaN normals in Babylon, a collision shape that misbehaveszero-area triangles and duplicated hull faces in the sourcecollision.mjs drops them for the collision mesh; drop them in a render mesh the same way

What is not a look problem#

  • A material without a base colour texture is often right: a tiled dirt landscape, a tinted plastic. Check the original before adding a texture.
  • Posters and pictures that differ come from per-instance material or texture overrides on the actor, not from the mesh's material; they are read from the actor, like any other instance property.
  • Draw calls rising after the world-aligned step: every such node gets its own mesh. Count it against the tier budget; merge per cell later if needed.