← All posts

Six reasons an imported scene looked wrong, and none of them were the importer

By August the importers were good. Geometry, materials, prefabs, hierarchies, colliders, terrain: a Synty pack came across, and it came across accurately.

It also looked wrong. Not broken, wrong. Metal read as dead grey plastic. Whole surfaces rendered black at any shadow setting. A lamp-lit town lit four of its lamps. Every instinct said importer bug, and every one of these turned out to be the renderer making an assumption the source engine did not share.

This post is six of them, because the pattern matters more than any single fix: content authored in another engine is a specification of what your renderer is supposed to do. When it looks wrong, the content is the test, and the renderer is the thing under test.

1. Every shadowed surface was 3.14 times too dark

The symptom was that shadowed and indirect areas collapsed toward black, and nothing in the render settings could rescue them. Raising ambient blew out the lit side before the shadowed side became visible.

The cause was one divide. The shader divided the ambient term by pi, as a diffuse BRDF properly does, and the direct term did not.

That is inconsistent, and it is inconsistent in the direction that matters, because the ambient colour an engine hands you is irradiance already convolved with the cosine lobe. Unity evaluates its ambient probe as albedo times SH. Godot as albedo times ambient_light times energy. Both are authored as surface brightness, exactly like the direct term. Dividing only ambient by pi therefore made indirect light 3.14 times darker than the sun relative to the source engine: every sunlit surface matched Unity, and everything the sun missed fell away.

Measured on a real imported island scene, the ground ambient band is 0.017 linear. Over pi, it is 0.005. That is black on any albedo, and no shadow setting can reach it, which is exactly why changing shadow distance and strength did nothing.

The fix is a deleted divide. Finding it took measuring one band's value in two engines.

2. Surfaces that were black at every setting

Some objects rendered pure black. Not dark: black, immune to every light, every shadow setting, and every render effect removed.

Normal maps compress to bc5-rg-unorm, which is a two-channel format. The sampler returns (x, y, 0, 1). The shader read .xyz and mapped it to -1..1, so z became -1, and every normal pointed straight into its own surface. N·L then went below zero for every light, and the surface had no lit side at all.

Two details that made this hard to see:

  • 96% of an audited pack's surfaces carry a normal map, so it hit almost everything, and "almost everything is black" reads as a broken renderer rather than a specific bug.
  • It only reproduced on devices where BC compression is actually available. The RGBA8 fallback path has a real blue channel, so on one machine it was fine and on another it was catastrophic.

The correct read for a tangent-space normal map is to reconstruct z from x and y regardless of format, because z is redundant there. Reading the blue channel was never right; the two-channel format is just what made it fatal.

3. Metal was dead grey

Every metallic, glass or polished-stone surface rendered near-black however bright the scene was. The engine's own help text warned users to keep metallic under 0.9.

That warning was describing a missing feature and calling it a property of metal.

Metallic switches off the diffuse term. That is correct: metal has no diffuse. But it means a metal's entire appearance is its environment reflection, and there was no ambient specular term at all. So metal had no diffuse (by design) and no reflection (by omission), which sums to nothing.

The fix uses the scene's own ambient sky, equator and ground bands, sampled along the reflection vector rather than the normal, so a floor picks up ground colour and a wall facing the sky picks up sky, blurred toward the flat mid band by roughness to stand in for a prefiltered cubemap's mip chain. It is not a real IBL probe. It is enough that metal reads as metal.

I keep this one as a reminder to distrust guidance in my own tooltips. "Stay under 0.9 metallic" was a workaround that had been sitting in the UI long enough to look like advice.

4. A town lit four of its seventy lamps

An imported town ships 70-plus lamps. The forward shader had a fixed per-fragment light budget, so it shaded the most important 16 and ignored the rest. Worse than the count: which lamps changed as the camera moved, so the lighting rippled as you walked.

The fix is clustered forward shading (Forward+): a compute pass bins every light into a grid of screen tiles by depth slice each frame, and a fragment shades only the lights whose range actually reaches it, up to 32 per cluster. Cost tracks how many lights are near a pixel rather than how many the scene contains.

It is opt-in, and I think that is right. A scene with one sun and three lamps pays a compute pass for nothing.

5. The lamps that did light up were floodlights

URP stores light intensities meant for inverse-square falloff. Awaken used a linear falloff to zero at range, which is the cheap, controllable model most stylised engines start with.

Those intensities under that curve are not slightly wrong. A pack whose lamps are authored between 40 and 5000 against a sun of 2 blows out to white near every lamp, and everything away from a lamp reads as black.

So physical (windowed inverse-square) falloff is now a setting, and turning it on also undoes the extra brightness the importer had been adding to point lights to compensate for the linear curve. Two corrections that were fighting each other, now switched together.

6. Spot lights were not spots

Unity light type 0 is a spot. It was being mapped to a point light: 112 lights across 8 packs.

A lantern, a stage lamp or a torch beam then lit everything around it evenly instead of throwing light where its author aimed it, which is the entire reason the author reached for a spot. The per-light GPU record grew from two vec4s to three to carry a cone axis and cos(half-angle), and a non-spot writes an impossible cosine there so the shader's cone branch is a single compare a directional or point light can never satisfy.

The pattern

Not one of these was found by looking at the renderer and thinking hard. Each was found by putting the same scene in both engines and asking a narrow, numeric question: what is this band's linear value here versus there; how many lights does this fragment actually shade; what does the sampler return for this texture format.

The reason that works is that the content is a test with an expected output, and the expected output is sitting in the other engine's viewport. That is a luxury a from-scratch renderer never has, and importing content is what buys it.

The corollary, which cost me the most time: when imported content looks wrong, the importer is the least likely culprit, because an importer failure is usually loud. Geometry in the wrong place, a missing texture, a crash. Quiet, uniform wrongness across a whole scene is almost always one shared assumption in the renderer, and one shared assumption is a single line.

← All posts