
Unity URP Flickering on iPhone: Three Weeks of Wrong Fixes, Then the Lights Inside Every Flame
A green frame in a tunnel, five plausible causes that were not it, a measurement that named the one that was, and the rules that came out of it. Written for Unity builders, with the numbers.
This is the full account of one bug in The Magus, my stealth game for iPhone, built in Unity 6 with URP and Metal. Whole frames in the tunnels turned green, sometimes pink, for a frame or two, then went back to normal. Never in the dungeon next door. Never on the Mac. It took the better part of three weeks, and in the end it was not a shader, not a probe, not the batcher, not precision. It was the Lights module on the torch flames, spawning a real-time point light on every particle, a hundred and fifty torches deep. If you have Unity URP flickering on a phone and the usual fixes do nothing, this is the method that found ours, and the mistakes that cost us the three weeks.
- The symptom: whole frames tinted green or pink in URP Forward+ on iPhone, below ground only, never in the Editor.
- Five fixes that made sense and changed nothing: a skin shader with too many samplers for Metal, stripped shader variants, half precision, the SRP batcher, reflection probes. One of them was a real error and still not the cause.
- The method that worked: measure the frame instead of looking at it, then bisect renderer by renderer inside a session that is already green.
- The cause: the ParticleSystem Lights module on 150 torch flames, a real-time light per particle, born and dying every frame under a per-camera light list.
- The fix: the module off. Fifty flashes to zero in five sessions, and a clean phone. Build time 117 seconds, 0 errors.
- What went into the rules file: the Editor lies in four known ways, the test runs before every build, the known-good state is frozen, and no particle renderer uses a package-default material again.
Table of Contents
- The Symptom
- The Wrong Turns, In Order
- What the Editor Hides From You
- The Measurement
- The Bisect
- The Cause: Lights in the Flames
- The Fix
- What Went Into the Rules File
- Unity URP Flickering: The Checklist
- Summary
- Frequently Asked Questions
- Related Reading
The Symptom
Four tunnels run under the island. They are lit by wall torches, each a small particle flame with a smoke plume, and by point lights along the walls. On the phone, walking the first tunnel, the whole frame would flash green. A frame, sometimes two. Then pink. Then nothing for a while. The pirate's own coat would flash green at the hem. The Mursi guards picked up a colour when you walked toward them. Above ground, in daylight, nothing. In the dungeon, which uses the same kit and the same pipeline, nothing.
The setup, for anyone matching symptoms: Unity 6 with URP, Forward+ rendering, SRP batcher on, GPU Resident Drawer on, additional lights per pixel, per-object light limit 8, depth texture off, Metal, iPhone 15 Pro. The tunnels are lit real-time, no lightmaps. Cameras below ground clear to a solid black.
The work runs through two AIs. One designs and writes the briefs, one executes inside the Unity project headless, with the Editor closed, and I play every build on the phone. The builder cannot see the phone. That one fact shaped the whole hunt.

The Wrong Turns, In Order
1. The skin shader. The first measurement, six hundred frames walked with the mean colour of each printed, flagged twenty-six frames and pointed at the pirate's skin material. It sat on a third-party skin shader that uses more than sixteen texture samplers, and Metal allows sixteen per stage. The build log had reported that compile error for weeks as "the one known error". We converted every material on it to URP/Lit with the same maps, and the error count went to zero for the first time in a month. A real bug, fixed. The tunnels kept flashing.
2. Stripped shader variants. Magenta on a phone usually means a stripped variant, and green felt close enough. A variant census tool counted the compiled variants in the build: 6,144 of the lighting variants present, the ones we thought missing stripped by design because Forward+ uses its cluster variants instead. Nothing missing. Reverted.
3. Half precision. On a Mac, Metal promotes half to float, so a shader that overflows a sixteen-bit value on the phone looks fine on the desktop. A good theory for a phone-only bug. The Shader Graph materials in question were already set to single precision. Reverted.
4. The SRP batcher. Turned off for one build to rule out a batching artefact. The build hit a cold shader cache, compiled 665 of 12,288 variants in twenty-five minutes, and was killed by the watchdog. Batcher back on, warm cache, builds at 93 to 117 seconds again. Nothing learned about the flash, something learned about the build.
5. Reflection probes. This one was half right, which is the dangerous kind. The tunnels used a custom reflection with no cubemap assigned. A null cubemap samples black on the Mac and garbage on the device. We reproduced a green tint on the Mac by forcing the case, bound a real sixteen-pixel black cubemap, turned every character renderer's probe usage off, and removed all per-frame writes to the render settings. Good hygiene, kept. The phone still flashed.
Five fixes, each one reasonable, each one a build and a phone session. That is three weeks. The lesson is not that the theories were stupid. The lesson is that none of them was a measurement.
What the Editor Hides From You
Before the measurement could be trusted, the test environment had to be understood, because it had already lied to us twice that week. These four are now written into the brief every block, under rule 11:
Async shader compilation draws a placeholder. In the Editor, a shader variant that is not compiled yet is drawn with a stand-in tint for a few frames. Any colour measurement run with it on will count flashes that do not exist in a build. Every measure runs with EditorSettings.asyncShaderCompilation set to false.
Batch mode never ends a frame. WaitForEndOfFrame does not fire in a headless run. A shop close that waited for it looked broken in the harness for two days and had worked on the phone all along.
The Mac promotes half to float. Precision bugs are invisible on the desktop and real on the A-series GPU.
A null cubemap is black on the Mac and garbage on the phone. The reverse holds too: a failure only the phone can produce is fixed on the phone's evidence, not argued away because the harness is clean.
Write these down for your own project. The specific list will differ. The habit is the same: before a failure is called a bug, rule out the environment, and a failure only the environment can produce is written down as such and never fixed in the game.
The Measurement
The brief stopped asking for fixes and asked for a count. The harness walks the pirate six hundred frames through the first tunnel, down the hatch the way a player goes, with synthetic touch on the real controls, and reads back every frame. The first version printed the mean colour per frame and called a flash any frame whose saturation jumped past a line. That found the skin shader and nothing else, because a whole-frame mean hides a flash that only covers part of the screen.
The second version went per pixel. A pixel counts as green when its green channel is above 0.2 and more than one and a half times the larger of red and blue. The score of a frame is the share of pixels that pass. The third version took the maximum over ten renders a frame apart, because the flash lasts one or two frames and a single sample misses it. With that, a clean baseline read fifty flashes in a session, and every change could be scored against fifty.
One more tool mattered: the "green renderers" test. Every renderer in the scene is drawn alone over a magenta clear colour. A renderer that paints green shows itself, and a renderer that is innocent cannot hide behind the others. That is the test that made the bisect possible.

The Bisect
With a number to score against, the hunt became arithmetic. Inside one session that was already green, the builder turned groups of renderers off and measured again. The characters off: still green. The wall lights off: still green. The torches off: zero. Within the torches: the flame and smoke particle renderers, two per torch, three hundred objects across the four tunnels, all on URP's package-default particle material. Off: zero in five sessions. On: fifty.
So the brief gave the torches their own materials, a flame and a smoke with Soft Particles, Camera Fading, Distortion and Flipbook all off, and measured again. Still green, 3.7 on the score where zero was the target. The material was not it. The particle system still carried something that the renderer test had switched off together with the renderer.
The Cause: Lights in the Flames
The torch prefab's flame had the Lights module enabled, pointing at a small point light prefab. The Lights module does exactly what it says: it attaches a real-time light to a ratio of the particles, up to a maximum per system, and those lights are created and destroyed as the particles are born and die. They inherit the particle's colour and size. They are real lights to the renderer.
One hundred and fifty flames in the tunnels. Each one spawning and killing point lights every frame. Forward+ builds a per-camera light list and sorts lights into clusters each frame, with a hard cap per camera; this scene was feeding it a list that changed every frame and could run past the cap when enough flames were in view. The wall lights were twenty-five to a tunnel, counted and tuned. The flame lights were never counted because nobody had placed them. That is why daylight never showed it, the sun washes a small point light out, and why the dungeon never showed it, the dungeon's torches sat farther apart.
I will be honest about the limit of what we know. We proved the cause by removal, not the mechanism by inspection: why Metal rendered the overflow as a green or pink frame rather than a dropped light we did not trace, and I am not going to pretend we did. What we have is this: with the module on, fifty flashes a session; with it off, zero across five sessions of ten renders, and a phone that has stayed clean through every build since.
The Fix
The fix is four lines in the tunnel tool, run once over the existing torches and applied to every torch it builds from now on. For every particle renderer under a torch: the smoke gets the smoke material and is set inactive, the flame gets the flame material, and the flame's Lights module is disabled.
The first build with it: flames visible, luminance where it was, zero green in five sessions, build 117 seconds, 2,777 MB, 0 errors, 166 warnings. The phone that night: "the tunnel is clean. No flashing." The next block swept every scene in the build and found fifty-four more flames carrying the module, in the dungeon and the forge, and turned them off before they ever showed.
What stayed from the wrong turns, because it was right on its own terms: zero shader errors, a real black cubemap instead of a null one, probe usage off on every character, no per-frame writes to render settings. The tunnels also got lit real-time instead of baked along the way, and the lights that remain are the ones somebody placed: twenty-five, thirteen, eleven and twenty-six per tunnel, intensity 5, range 11.5, no shadows.
What Went Into the Rules File
Every block the builder runs is governed by a rules file in the repo root, and every hard week adds to it. This bug added four things.
The freeze. The tunnel state that passed on the phone is frozen by name: the lights and their numbers, the fog, the ambient, the clear colour, the reflection settings, the flame materials with the Lights module off, the render pipeline asset. A brief lifts the freeze only by naming the law and the thing. The controls are frozen for good.
The particle law. No particle renderer in the project uses a package-default material. Soft Particles, Camera Fading and Distortion are off on every particle material. The Lights module is off on every particle system in every scene and prefab. The law states the evidence: the flash was the flames' Lights module, not the shaders, not the probes, not the batcher.
The scenario before every build. A buttons scenario presses all nine controls at the fire, with the stick held, in the tunnel, at the cage, at the ladder, on the surface, in the dungeon and in the forge, and the count of dead presses must read zero. It now runs after every item of a block, and an item that turns a press dead is reverted on the spot. A freeze stops edits. It cannot stop side effects. Only the scenario catches those.
Change nothing not named. The brief lists the files it may touch, and the diff may contain nothing else. One change, one measure, one commit. The reflection probe week is what happens without that: three things changed at once, one of them half right, and no way to know which.
If you want this shape of work on your own problem, the hour exists for it: a one hour call, $100. A full Nexus build is $4,999 plus $299 a month, enterprise from $9,999, scoped first. And if the honest answer after the hour is that you do not need me yet, you will hear that too.

Unity URP Flickering: The Checklist
If your Unity URP build flickers on a phone and not in the Editor, in this order:
1. Measure before you touch anything. Read the frame back. Score pixels, not your eyes. Take the maximum over several renders a frame apart. Get a baseline number you can beat.
2. Kill the Editor's lies first. Async shader compilation off during any colour measure. Never trust a frame-end wait in batch mode. Remember the Mac runs half as float. Never leave a cubemap or a texture slot null in a material that samples it.
3. Bisect by renderer inside a failing session. Groups off, measure, halve, measure. Do not bisect by theory. A theory is a build and a phone session; a renderer group is thirty seconds.
4. Count your real-time lights, including the ones you did not place. Every ParticleSystem with the Lights module on is a light factory. Multiply by the number of systems in view. Forward+ has a per-camera cap and rebuilds its list every frame.
5. Own your particle materials. Package defaults change under you with every package update and carry features you did not choose. Make yours, name them, freeze them.
6. Fix real errors even when they are not the cause. The sixteen-sampler shader was going to bite later. Zero errors in the build log is a condition, not a nicety.
7. Freeze what passes, change one named thing, run the regression scenario after each change, and let the phone decide.
Summary
- Unity URP flickering on iPhone, below ground only, never in the Editor, for three weeks.
- Five plausible fixes changed nothing: skin shader samplers (a real error, fixed), variant stripping, half precision, the SRP batcher, reflection probes.
- A per-pixel green score over ten renders gave a baseline of fifty flashes; the "green renderers" test made every renderer answer for itself.
- The bisect went characters, wall lights, torches, then flame and smoke renderers, then the material, then the one thing left: the Lights module.
- One hundred and fifty flames each spawning real-time point lights per particle, under a per-camera light list rebuilt every frame. Module off: zero in five sessions, phone clean.
- Four rules came out of it: the freeze, the particle law, the scenario after every item with revert on regression, and change nothing not named.
Frequently Asked Questions
What causes Unity URP flickering on mobile but not in the Editor?
Usually something the Editor hides: a placeholder tint from async shader compilation, a precision difference between the desktop GPU and the phone, a null texture or cubemap that reads black on one and garbage on the other, or a light count the Editor's bigger limits absorb. In our case it was the last: real-time lights spawned by the particle Lights module, far more of them than anyone had placed.
Does the ParticleSystem Lights module really create real lights?
Yes. It instantiates the light you assign on a ratio of particles up to the maximum you set, and the renderer treats them as ordinary real-time lights. On a desktop test scene with one emitter it is harmless. On a phone with a hundred and fifty emitters in view it is a light list that changes every frame.
Why did the whole-frame colour measurement miss it the first time?
Because it averaged the frame. A flash that tints part of the screen pulls the mean a little; a per-pixel rule counts every green pixel on its own. The first measure found a different, real bug and declared victory too early. Measurements have to be designed for the symptom, not just taken.
Could this have been found by reading the code?
Not easily. Nothing in the code spawned those lights; a checkbox on a prefab did, set by whoever made the torch. Removal found it in one session once the renderers could be switched off one group at a time. Reading would have had to know where to look.
Should I turn the SRP batcher off to test rendering bugs?
Only if you can afford the build. Ours hit a cold shader cache and died at the watchdog's twenty-five minutes with 665 of 12,288 variants compiled. Turn it off in the Editor's frame debugger for a look if you must, but do not make it a build.
What is the one habit from this that transfers outside games?
Freeze what works by name and run the regression test after every change, not at the end. Two blocks after the fix, a change to when a scene loads opened a menu that swallowed seven button presses, and no file of the menu's had been touched. A freeze stops edits. The test is the only thing that catches a side effect.


