RealityKit Was Lighting My Dungeon Behind My Back

I have been rebuilding the lighting in my fixed-camera RealityKit dungeon crawler.

The game uses scrolling corridor rows. Same seed. Same corridor. Same camera. The left half of the comparison screenshot is the old lighting, and the right half is the new version.

Ignore the green hexes. They are a debug overlay.

RealityKit does not give you fog. It does not give you a hemisphere light either. I grepped the frameworks to make sure I was not missing something obvious.

Both had to be built from scratch.

Along the way, I also discovered that my scene was being lit by something I did not know existed.

The sanity check that broke it open

I set every light in the scene to zero intensity.

I expected black.

The corridor still rendered at an sRGB luma of roughly 131. That was brighter than the final look I was trying to achieve—with every light supposedly switched off.

RealityKit applies a bright default image-based environment to every RealityView. That environment was doing almost all the work. The explicit lighting rig I had carefully built was contributing very little on top of it.

That explained some of the increasingly stupid constants that had accumulated in my scene setup:

  • A 50-lux key light.
  • An albedo tint crushing textures to 8%.
  • Repeated attempts to darken materials that should not have needed darkening.

They were all compensating for a light source nobody realised was on.

The fix was to replace RealityKit’s default environment with my own EnvironmentResource, generated at runtime from a tiny equirectangular CGImage.

Once I controlled the environment, the rest of the lighting finally started behaving predictably.

Building fog without a fog API

My original fog was made from eight camera-facing transparent quads placed at fixed depths.

It looked acceptable until it did not.

Each quad creates a binary in-front-or-behind depth relationship with the floor. Where a plane crosses the corridor, it creates a visible seam. Instead of a tunnel gradually disappearing into fog, the corridor looked like it had been divided into bands.

I measured the steps in the rendered image at:

444 / 544 / 656 / 783 / 927

Then I calculated where the fog quads should project on screen:

443.0 / 543.1 / 655.4 / 782.1 / 926.3

They matched within a pixel.

That was a deeply satisfying way to prove the diagnosis before deleting the whole thing.

I replaced all eight quads with one camera-parented gradient quad.

Because the camera is fixed, screen height maps monotonically to corridor depth. For every row in the texture, I cast the corresponding view ray, intersect it with the floor and evaluate the fog curve at the point where it lands.

The result is a smooth fog gradient using one draw call, with considerably less transparent overdraw.

There is a trade-off. The fog is now based on screen height rather than the true depth of every fragment. Tall geometry therefore enters the fog marginally earlier than it should.

At this camera angle, the error is less than one corridor row.

I can live with that.

Faking a hemisphere light—badly, then properly

RealityKit does not provide a hemisphere light.

My first attempt at recreating one used a downward-facing DirectionalLight as the sky contribution.

It lit the scene, but it also placed a sheet of gloss across the entire floor.

Punctual and directional lights produce a concentrated GGX specular lobe. At the floor material’s roughness of 0.45, that lobe made the stone look polished.

That was not the dungeon I was going for.

An ambient or image-based contribution behaves differently. Its specular component appears as a broad, even wash rather than a concentrated hotspot.

The correct primitive was another image-based light.

I generated a sky-to-ground gradient using:

(cos θ + 1) / 2

That gives the upper hemisphere one colour, the lower hemisphere another and smoothly blends between them.

The fake directional sky light disappeared. So did the unwanted sheen.

It also ended up being a much closer approximation of an actual hemisphere light.

RealityKit’s light units are only part of the story

RealityKit’s directional lights use lux. Its own default directional-light intensity is 1000.

Point lights use lumens and theoretically fall off according to:

lm / 4πd²

I converted my old candela-style torch intensity through , expecting a physically equivalent result.

It overshot the desired brightness by roughly 1.9 times.

RealityKit does not document the details of its attenuation windowing, and its behaviour is clearly more complicated than a simple inverse-square falloff followed by a hard range cutoff.

In the end, I used an empirical calibration factor.

Not glamorous, but it brought the torch within a few percent of the hand-tuned value that was already working before I tried to “correct” it.

The dungeon had no shadows

There were no shadows in the scene at all.

I added DirectionalLightComponent.Shadow to the key light, but the placement mattered more than expected.

The light had to sit on the corridor centreline. Any lateral offset pushed its rays out of the YZ plane and threw wall shadows sideways across the playable lane.

When the light remains centred, a wall can only cast its shadow along its own line. The corridor gains depth without filling the path with strange diagonal shapes.

The shadows did not alter the scene’s overall brightness.

Median floor luma was identical before and after enabling them. The difference appeared almost entirely in the darkest ten percent of the image.

They improved grounding and depth—not exposure.

The torch pattern was sabotaging one wall

At the beginning of every run, the detail on one corridor wall was almost invisible.

Torch placement alternated by block. Block zero always started on the same side, so one wall received light while the other remained flat.

I wanted three torches visible at all times.

The visible window contains twelve rows. Guaranteeing exactly three torches therefore requires a strict period of four rows.

That means a fixed on-screen count and randomised spacing are mathematically incompatible.

The old placement jitter had to go.

The new pattern is less random, but both walls consistently receive nearby light and the stonework remains readable on either side.

That is the better trade.

The floor tint was darker than it looked

baseColor.tint accepts a UIColor.

That colour is specified in sRGB, but RealityKit converts it to linear space before multiplying it with the texture.

A tint value of 0.55 is therefore not a 55% multiplier. In linear space, it is roughly 0.21.

The old tint value was 0.32, which becomes approximately 0.084.

Only about 8% of the floor texture’s albedo was making it through.

Once the default environment was under control, I could stop abusing the material tint and let the lighting create the darkness instead.

The revised setup took the lit floor from an sRGB luma of 88 down to 39. That change accounts for most of the mood difference visible in the comparison.

One other detail worth knowing: tint scales the diffuse response, but it does not reduce the specular F0 of a dielectric material. That remains around 0.04.

In theory, crushing the diffuse colour can therefore make a surface appear relatively shinier, even though its material settings have not changed.

Measure it

Very little of this work was eyeballed.

I read pixels from captured frames and compared the actual values.

Pinning the run seed through a launch environment variable was probably the most useful thing I did. Every A/B screenshot could show the exact same corridor, camera and geometry, with only the variable under test changed.

I measured the specular contribution by creating a build with floor roughness forced to 1.0, then diffing it against the shipping material.

I measured fog seams.

I measured median brightness.

I measured the darkest decile before and after enabling shadows.

Without repeatable scenes and actual numbers, I would still be adjusting random material values and blaming the wrong parts of the renderer.

Things I got wrong

Probably the most useful part.

  • I assumed the glossy floor was a material problem. It was not. The material parameters remained untouched. The light type was wrong.
  • I predicted that specular would account for 20–25% of the floor response at the darker tint and bring the sheen back. It measured at 3.5%. Tone mapping also compresses the lower range, so the ratio barely moved. The warning was unfounded.
  • I assumed re-enabling a disabled scene graph was causing a hitch during a screen transition. I tested it directly: 372ms versus 383ms. No meaningful difference. Theory binned.

The final result is darker, smoother and much closer to the dungeon I thought I had built the first time.

More importantly, I now know which parts of the image are actually producing it.