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#
- 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.
- Name the thing. Pick the pixel:
scene.pick(x, y)gives the mesh and material.pickignores 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. - 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.
- Read the compiled shader of the mesh that is wrong and of a neighbour that is right:
sourceMesh.subMeshes[0].effect.defines. A missingLIGHT0/HEMILIGHT1/LIGHTCOUNTmeans no lights; a missingREFLECTIONmeans no environment; a difference between two meshes of one material means stale per-mesh state, not a material problem. - 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.
- 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#
| Symptom | Cause | Fix |
|---|---|---|
| Some meshes black, others fine; which ones changes between runs or browsers | instanced 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 loaded | fixed 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 material | the same stale shader: compiled before the level's environment texture was set | the 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 all | the 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 end | the 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 edge | bake-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 in | dozens 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 side | nodes placed mirrored (negative scale) share a GPU-instanced mesh with unmirrored ones; instancing culls one side for all | postprocess.mjs gives mirrored nodes their own mesh, wound the other way when instanced |
| A mirrored floor lit as if from below | a mirrored placement turns the faces' side but not the normals | postprocess.mjs reverses the normals (and tangent handedness) of mirrored copies |
| Metal (lockers, pipes, supports) near black | PBR metal shows only what it reflects; an interior has no sky and the scene no environment texture | environment.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 floor | a 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 patch | export 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 base | if 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 none | dump 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 textures | the source texture is 16-bit, which the export writes linear | encode 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 nothing | helper 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 inside | the 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 lightmaps | until 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 is | the 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 horizon | fixed 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 glow | Unreal's post process volume blooms (bloom 3-4 in a lit interior); Ferret drew none | environment.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 fix | the 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 strength | the 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 misbehaves | zero-area triangles and duplicated hull faces in the source | collision.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.