I spent a weekend trying to make a 3D scene look less flat and ended up making it faster instead. The flatness and the frame drops turned out to be the same bug, viewed from opposite sides.
I was helping out a friend on their app. The scene is a few hundred small buildings on a flat ground plane, rendered with Filament through react-native-filament, running on whatever phone the user happens to own. It has a performance budget system with tiers, and the lower tiers get simplified meshes, fewer decorations and smaller render arenas. It is the kind of app where you count draw calls.
What I had not counted was the sun.
An all-emissive pipeline
The models were authored the classic mobile way. Ambient occlusion and colour are baked into one texture per material, and that texture goes in the glTF emissive slot with the base colour set to black. The result is a surface that looks the same from every angle, needs no lights, and is cheap. Artists have done this for years and for good reason. Per-instance tinting works too: you multiply the emissive factor on a material instance and the shared texture picks up the colour.
I checked the material setup across every GLB the app loads, with a short script that reads the JSON chunk out of each binary:
import struct, json, sys
d = open(sys.argv[1], 'rb').read()
length = struct.unpack('<I', d[12:16])[0]
gltf = json.loads(d[20:20 + length])
for m in gltf['materials']:
pbr = m.get('pbrMetallicRoughness', {})
print(m['name'],
'base', pbr.get('baseColorFactor'),
'emissiveTex' if 'emissiveTexture' in m else '-',
'unlit' if 'KHR_materials_unlit' in m.get('extensions', {}) else 'lit')
Out of 480 materials, 477 were black base colour plus emissive texture, which is fine. That is the pipeline working as designed.
The last column is the problem. Every one of them said lit.
What a lit material costs when it has nothing to light
Filament’s glTF loader picks a shader from the material’s declared shading model. A material without the KHR_materials_unlit extension is lit, and the lit “ubershader” does the full job on every fragment: evaluate the directional light, sample the shadow map, look up the indirect light from the environment, run the BRDF. Then it multiplies the diffuse term by the base colour.
The base colour is black. So the fragment shader does all of that work, multiplies it by zero, adds the emissive texture on top, and writes the pixel. It does that every frame, for every covered pixel, on a phone.
And there was a sun. The scene had a directional light at 80,000 lux with shadow casting enabled, because that is what you put in a scene when you set it up, and nobody takes it out. A light that casts shadows makes Filament render a shadow map before the main pass: every caster drawn again from the light’s point of view into a depth texture, each time the camera or anything in the scene moves. On a map that scrolls, that is most frames.
Not one pixel showed any of this. Shadows darken the lit diffuse term, which was zero. The indirect light brightens the lit diffuse term, which was zero. The sun was a hidden workload with no output, sitting in the scene like a process you forgot was running.
I had been treating lighting as something I could add later, when the tier budget allowed. The budget was already being spent. The sun was my single biggest “SUPER tier” feature, enabled on the cheapest phones the app supports, and nobody knew it was on.
Finding it from the other direction
I only noticed because I was trying to make things look better. I had seen a lovely three.js scene with shaded, matcap-style buildings and wondered why mine looked like paper cutouts. The honest answer was that nothing in my scene responded to light, so of course it looked flat. Chasing that down meant reading the material JSON above and then rendering the models headlessly with Filament’s own gltf_viewer, which ships in the Filament release archive alongside matc and cmgen.
The headless run confirmed it. With the stock materials, shadows on, SSAO on, an environment map loaded, the render was identical to shadows off, SSAO off, no environment. The pipeline had quietly made every lighting feature a no-op, and the lit shader was still running underneath.
It is worth getting into the habit of rendering your assets outside your app with the engine’s reference viewer. The viewer has a batch mode that takes a JSON spec and writes screenshots without a window:
gltf_viewer --headless --batch=spec.json --ibl=./sky model.glb
If toggling a feature in that spec changes nothing in the output, you are paying for that feature and getting nothing back.
The one-line fix
// before
<Light type="sun" intensity={80000} castShadows />
// after
<Light type="sun" intensity={80000} />
That removes the shadow map pass. The lit shader still runs, but the second geometry pass is gone. I was sceptical it would be noticeable. It was. On a mid-range Android device the camera flyover went from slightly jerky to smooth, enough that I shipped the change on its own the same day. There was no visual difference, because there was no visual to lose.
If you have a Filament scene with baked, emissive-only materials and a shadow-casting light, go and check. It is a thirty-second fix.
Actually turning the sun off
Removing shadows is the cheap half. The per-fragment lighting maths still runs on the black base colour. To stop that you need the materials declared unlit, so the loader picks the unlit shader.
The obvious move is to add KHR_materials_unlit to the materials in the pipeline. I tried that on one model a while ago and it rendered black, and at the time I shrugged and moved on. Now I know why. In Filament’s glTF ubershader source (libs/gltfio/materials/base.mat.in), the entire emissive block sits inside #if !defined(SHADING_MODEL_UNLIT). An unlit glTF material in Filament outputs base colour and nothing else. The emissive slot that the whole pipeline depends on is ignored.
So the transform has to be two steps, not one. Move the texture from the emissive slot to the base colour slot, then flag the material unlit. With gltf-transform it is small:
for (const m of doc.getRoot().listMaterials()) {
const tex = m.getEmissiveTexture();
if (!tex) continue;
m.setBaseColorTexture(tex);
m.setBaseColorFactor([1, 1, 1, 1]);
m.setEmissiveTexture(null);
m.setEmissiveFactor([0, 0, 0]);
m.setExtension('KHR_materials_unlit', unlit.createUnlit());
}
Per-instance tinting survives this, because the unlit ubershader still has the baseColorFactor parameter and multiplies it in. The runtime code changes from setting a float3 on emissiveFactor to setting a float4 on baseColorFactor. The textures, the instancing and the draw calls are all unchanged.
The part that got me interested
Once the texture is in the base colour slot, the same asset works as a lit material too. Leave the unlit flag off, add a small environment map, and the sun that was doing nothing starts doing something. The buildings cast shadows on each other, the walls read darker than the roofs, and the flatness I set out to fix goes away.
Which means the lighting decision becomes a per-tier flag in the asset build rather than a rewrite. Low tiers get the unlit variant and stop paying for the sun at all. The top tier gets the lit variant and, for the first time, actually gets what it was paying for. The transform is the same function with one boolean.
I have not shipped that half yet. It needs on-device measurements, an exposure pass, and a think about dark mode. But it reframed the whole thing for me. I had assumed lighting was a luxury I could not afford on mobile. It turned out I had been buying it all along and leaving it in the box.
What to check in your own scene
- Dump the materials from your GLBs and look for the combination of black base colour, emissive texture and no unlit extension. That is the hidden-sun signature.
- If you have a shadow-casting light and no material has a non-black base colour, the shadow map is pure cost. Turn it off today.
- If you want to stop the lighting maths entirely, remember that Filament’s unlit path drops emissive. Move the texture first, then flag the material.
- Render your assets with the engine’s reference viewer in batch mode and toggle features. Anything that does not change the output is a candidate for removal, or a sign that you have a feature you are not actually using.
The sun is a budget user like any other. It just does not show up on the invoice unless you go looking.