A flat-looking 3D scene almost always has one of three causes: unlit materials, no environment light, or a camera placed too high and far. Use physically based materials, add a sky for ambient light plus one shadow-casting sun, and bring the camera close and low.

This is for hobby game makers and app builders working in a 3D engine for the first time. The values below are the ones we tuned by eye for a runner game in flutter_scene 0.20.0, in September 2026. They transfer to any engine that uses glTF-style materials.

In short:

  • Roughness and metallic make a surface read as a real thing.
  • Ambient light from a sky stops shadows from turning pure black.
  • A key light that follows the character stops it becoming a silhouette.
  • Camera height and field of view decide whether motion reads at all.

Unlit materials ignore every light you add

A material decides how a surface reacts to light. An unlit material shows its colour no matter what, so a scene built from it stays flat even with a sun. Use unlit only for things that are light: a lamp tube, a headlight, a glow strip.

Everything else should be physically based (PBR), meaning it follows real light behaviour. A PBR material has three main values:

Value What it controls Example in our scene
Base colour The surface colour Dark concrete, 0.16 grey
Roughness, 0 to 1 Sharp or blurry reflections Rails 0.22, concrete 0.94
Metallic, 0 or 1 Metal or not Rails 1.0, concrete 0.0

Shiny metal rails were the strongest depth cue in our tunnel, because they caught every light. The LearnOpenGL PBR theory chapter explains the physics if you want it.

In flutter_scene, a material is a few lines:

final rails = PhysicallyBasedMaterial()
                ..baseColorFactor = vm.Vector4(0.68, 0.71, 0.76, 1.0)
                ..roughnessFactor = 0.22
                ..metallicFactor = 1.0;
              

For glowing coins, we also set emissiveFactor, which makes a surface give off its own light.

A sky provides the light the sun misses

Without ambient light, every surface the sun does not hit renders black. PBR engines get ambient light from an environment: a sky or a photo of a real place, wrapped around the scene. This is called image-based lighting.

In flutter_scene 0.20.0 there is no ambient light object, so we baked a simple gradient sky once into the scene's environment. It is never visible through the tunnel walls. It only lights them. For a product viewer, a free studio HDRI (a 360 degree light photo) from Poly Haven's HDRI library gives realistic reflections.

One sun casts the shadows, small lights add mood

A directional light acts like the sun: parallel rays from one direction. Give it shadows, and objects finally sit on the ground instead of floating. Our sun came from above and slightly to the side, so shadows fell at an angle the camera could see.

Point lights shine in all directions from one spot, like a bulb. We added them to the four lamps nearest the player. They create pools of warm light that make the space feel deep.

The light that mattered most rides with the character. Seen from behind, our runner read as a flat orange shape against the sun. A warm point light above it on the camera side, moving with it, fixed that at once.

Tone mapping and exposure replace colour grading

Real light ranges far beyond what a screen can show. Tone mapping squeezes that range into screen colours without blowing out highlights. We used ACES tone mapping, a film industry curve, with exposure at 1.34.

That combination let us keep base colours dark (0.06 to 0.20) in a night scene and still see them. It also replaced a colour grading pass that cost us about 360 ms per frame on the web backend.

Camera height and field of view decide what reads

A camera has a position, a target to look at, and a field of view (FOV), the angle it sees. For our chase camera:

PerspectiveCamera(
                position: vm.Vector3(0.0, 2.32, -4.05),
                target: vm.Vector3(0.0, 0.95, 8.5),
                fovRadiansY: 61.0 * vm.degrees2Radians,
              );
              

That sits close behind and just above the runner. A camera placed high and far back foreshortens every arm and leg swing, so a working run cycle looks like a stiff doll.

Two tricks add speed without moving the camera further away:

  • Widen the field of view as the game speeds up, by up to 8 degrees.
  • Smooth the camera's sideways movement, so lane changes glide.

The Flutter Scene cameras guide covers orbit and follow cameras for other setups.

Fog makes a corridor feel long

Fog blends distant objects into a colour. It gives a strong sense of depth and hides the far end, where new props appear. We used exponential fog in a dark blue, so distant walls faded rather than ending.

How GrowApps sets this up

GrowApps builds 3D games and viewers on flutter_scene, starting from a 3D starter scene. That starter uses exactly the setup above: PBR materials everywhere, a baked sky for ambient light, one shadow-casting sun, point lights, a key light on the character, ACES tone mapping, fog and the close chase camera.

The builder agent changes colours, props and gameplay, and keeps that structure. You can ask GrowApps for a 3D scene and adjust it by describing what you see.

Fix a flat scene in ten minutes

Take your flattest scene and change one thing at a time. Swap any unlit material for a PBR one with roughness 0.8. Add an environment, then a key light near your main object. Lower the camera until motion reads. For the cost side of these choices, read the 3D performance budget for phones.

Frequently asked questions

Why are my shadows pure black?

There is no ambient or environment light. Shadows are areas the sun cannot reach, and without a sky or ambient term nothing else lights them.

What field of view should a mobile game use?

There is no single right value. We used 61 degrees vertical for a third-person runner. Narrower angles flatten depth, wider ones stretch the screen edges.

Sources and further reading