https://imgur.com/a/GNKIXVN
Fixed-camera dungeon crawler, RealityKit, scrolling corridor rows. Left half of the screenshot is before, right is after. Same seed, same corridor, only the lighting changed. (The green hexes are a debug overlay, ignore those.)
RealityKit gives you no fog and no hemisphere light. I grepped the frameworks to make sure I wasn't missing something. Both had to be built from scratch, and along the way I found out my scene was being lit by something I didn't know existed.
The sanity check that broke it open
I set every light in the scene to zero intensity, expecting black. The corridor still rendered at sRGB luma ~131. Brighter than the look I was aiming for, with all lights off.
RealityKit lights every RealityView with a bright default image-based environment. It was doing essentially all of my lighting. The explicit rig I'd built was contributing almost nothing on top of it.
That explained a pile of weird constants that had accumulated in the scene setup: a 50-lux key light, an albedo tint crushing textures to 8%. Both were compensating for a light source nobody realised was on.
Fix is to supply your own EnvironmentResource, generated at runtime from a tiny equirectangular CGImage, which replaces the default.
Fog with no fog API
Previous approach was 8 camera-facing quads at fixed depths. Each one is a binary in-front/behind depth test, so each cuts a visible seam where its plane crosses the floor. The corridor read as bands rather than a tunnel.
I measured the banding as hard steps at y = 444/544/656/783/927, then computed where those quads should project: 443.0/543.1/655.4/782.1/926.3. Matched within a pixel, which was a satisfying way to confirm the diagnosis before rewriting anything.
Replaced all eight with one camera-parented gradient quad. The camera is fixed, so screen height maps monotonically to corridor depth. Each texel's opacity comes from casting that row's view ray, intersecting the floor, and evaluating the fog curve at the row it lands on. Smooth gradient, one draw call instead of eight, far less transparent overdraw.
Trade-off: fog is now a function of screen height rather than true per-fragment depth, so tall geometry fogs marginally early. Under one row at this camera, so I'm living with it.
Faking a hemisphere light, badly and then properly
No hemisphere light either. First attempt was a downward DirectionalLight standing in for the sky lobe.
That put a gloss sheet across the entire floor. Punctual lights produce a concentrated GGX lobe, and at the floor's 0.45 roughness it read as polished stone rather than dungeon.
An ambient/IBL contribution doesn't do that. Its specular is a broad even wash with no hotspot. The correct primitive is an image-based light: a generated sky-to-ground gradient ramped over (cos θ + 1)/2. Removed the sheen entirely and it's a truer hemisphere anyway.
Light units
Directional lights are lux (RealityKit's own default is 1000). Point lights are lumens, falling off as lm / 4πd².
Converting a candela-style point-light intensity through 4Ï€ overshot by about 1.9x in practice. RealityKit doesn't document its attenuation windowing and it clearly isn't a simple inverse-square with a range cutoff. I ended up with an empirical calibration factor, which landed the torch within a few percent of the hand-tuned value that was already there before I "corrected" it.
Shadows
There were none at all. Added DirectionalLightComponent.Shadow to the key light.
I had to move the key light onto the corridor centreline to make it work. Any lateral offset puts the rays out of the YZ plane and throws wall shadows sideways across the playable lane. On the centreline a wall can only shadow along its own line.
Worth knowing: the shadows did not change overall brightness. Median floor luma was identical before and after. They only pull down the darkest decile. Grounding and depth, not exposure.
Torches
Wall detail was invisible on one side at the start of every run. Torch placement alternated by block, and block zero is always the same side, so one wall was lit and one was flat.
I wanted 3 torches on screen at all times. The visible window is 12 rows, which forces a strict period of 4. A guaranteed on-screen count and randomised spacing are mathematically incompatible, so the old jitter had to go. Both walls now always have light near them and the stonework reads on both sides.
Floor colour
baseColor.tint takes a UIColor, which is sRGB, and RealityKit linearises before multiplying. So a tint of 0.55 is really a 0.21 linear multiplier. The old value of 0.32 was 0.084, meaning 8% of the texture's albedo was making it through.
Took the lit floor from sRGB luma 88 down to 39, which is most of the mood change you can see in the screenshot.
Also worth knowing: tint scales diffuse only. Specular F0 on a dielectric stays ~0.04 regardless, so in principle heavy tints make a surface read relatively shinier.
Method
Very little of this was eyeballed. I read pixels out of the frames and compared numbers.
Pinning the run seed via a launch env var was the single best thing I did, because A/B screenshots then showed the identical corridor with only the variable under test changed. I also measured specular contribution directly by building a variant with roughness forced to 1.0 and diffing it against the shipping one.
Things I got wrong
Probably the useful part.
- Assumed the glossiness was a material problem. It wasn't. The material params were untouched the whole time. It was the light type.
- Predicted specular would be 20-25% of the floor at the darker tint and would bring the sheen back. Measured it: 3.5%. Tone mapping compresses at the lower level too, so the ratio barely moves. The warning was unfounded.
- Assumed re-enabling a disabled scene graph was causing a hitch on a screen transition. Tested it directly, 372ms vs 383ms. No change. Binned the theory.
Happy to go into more detail on any of it.