DEV Community

Richard Fu
Richard Fu

Posted on • Originally published at richardfu.net on

Unity WebGL Safari Hang: The First-Draw Shader Stall

TL;DR — This is a war story about a Unity WebGL Safari hang : a game that ran beautifully on Chrome but froze for ~3 seconds on the first major animation event in iOS Safari. After a long detour through material instancing, shader keywords, animator transitions, and post-FX, the culprit was a single prefab nobody thought to warm up: an overlay spawned by an obscure event handler one frame before the animation started. Mid-game Instantiate of a prefab with a custom shader triggers Safari’s webglPrepareUniformLocationsBeforeFirstDraw on first render — synchronous, main-thread, ~3ms per uniform lookup, ~200 uniforms across a complex prefab = 2.8s stall. The fix is to render the prefab once during the splash so the cache is warm before gameplay needs it.

Reproducing the Unity WebGL Safari Hang

I’m creating a Unity WebGL game with the Universal Render Pipeline (URP) and a lot of custom shaders — particle bursts, dissolve effects, glow overlays, 3D character animations, the works. The target audience is mobile; the majority of players are on iOS Safari, which is exactly where the Unity WebGL Safari hang showed up.

Everything tested fine on desktop Chrome — silky 60fps, fast first interaction, no hitches.

Then we tried Safari on a Mac (and later an iPhone 12). The first major animation event of every session froze the entire game for ~3 seconds. The freeze happened after the build-up animation completed but before the payoff sequence visually started. Every subsequent occurrence of the same event was perfectly smooth.

The pattern was textbook “first-use compile stall”:

Browser Hang on first event
Chrome (normal) ~0.1s (barely perceptible)
Chrome (incognito, no GPU cache) ~0.2s
Firefox ~2s
Safari (macOS) ~3s
iPhone 12 Safari ~3s

The 25× delta between Chrome and Safari is the smoking gun. Both browsers run Metal under the hood on macOS/iOS — Chrome via ANGLE, Safari directly — but ANGLE aggressively caches shader compilation work that Safari’s WebGL→Metal translator does over and over.

The wrong rabbit holes

Before finding the real cause, we eliminated a long list of suspects through targeted A/B tests. Each took a rebuild + Safari test cycle. Documenting them here in case anyone hits a similar pattern:

  1. A shader keyword toggle. Our main mesh shader uses a [Toggle(_USE_DISSOLVE)] property that activates a fragment-shader branch. Disabling the keyword → hang persisted.
  2. The renderer.material = newInstance assignment. Skipped it on 13 simultaneous objects → hang persisted.
  3. An animator state transition. Commented out the state change that occurred on the same frame → hang persisted.
  4. The entire state handler for the suspect state. Stubbed out the whole switch case → hang persisted.
  5. MMFeedbacks / Feel post-FX (camera shake, vignette). Unsubscribed the handler → hang persisted.
  6. A separate fade-out coroutine on UI overlays. Unsubscribed it → hang persisted.

After all six A/Bs, we’d conclusively ruled out everything on the visible event path. The hang persisted even when the event handler did effectively nothing.

The diagnostic that cracked it

Stopping the guess-and-check loop and capturing a Safari Web Inspector → Timelines profile of the hang was the turning point.

Inside the 3.1-second Animation Frame Fired event, the breakdown was:


wasm-stub 1.94s
  _glGetUniformLocation 906ms (261 calls)
    webglPrepareUniformLocationsBeforeFi… 671ms (194 calls)
      getActiveUniform 444ms (128 calls)
      getProgramParameter 211ms (61 calls)
      getUniformLocation 201ms (58 calls)
  _glGetActiveUniform 423ms (122 calls)
  _glGetActiveUniformsiv 409ms (118 calls)
  _glGetActiveUniformBlockiv 191ms (55 calls)

Enter fullscreen mode Exit fullscreen mode

The 1.94s is Unity’s WebGL runtime asking the browser to look up uniform locations for shader programs it had never drawn before. The remaining ~1.2s of the frame is the actual Metal pipeline-state-object (PSO) compile triggered by the first draw call.

Most importantly: 194 distinct shader programs went through webglPrepareUniformLocationsBeforeFirstDraw in a single frame. That’s not “one or two new variants” — that’s almost the entire scene’s shader inventory being prepared at once.

webglPrepareUniformLocationsBeforeFirstDraw is WebKit-specific. On first draw of a program, it iterates every active uniform, queries the location for each, and caches the mapping. Each call into the WebGL JavaScript API costs ~3ms on Safari (vs ~0.1ms on Chrome’s ANGLE). 200 programs × ~10 calls each at 3ms ≈ 6 seconds of synchronous main-thread work. Spread that across however many programs need preparing on a given frame.

The actual root cause

Once we knew it was first-draw uniform reflection, the question became which prefab is first-drawn on that specific frame. The visible event handler had been stubbed out. Material instancing had been skipped. So what spawned at exactly the right moment?

The game has a server-driven event sequence with several intermediate steps between the trigger and the visible animation. One of those intermediate steps was an “overlay reveal” — a manager that handles a separate server event:


GameObject instance = Instantiate(overlayPrefab, transform);
instance.transform.localPosition = pos;
var overlay = instance.GetComponent<OverlayComponent>();
overlay.SetLevel(level);

Enter fullscreen mode Exit fullscreen mode

A fresh Instantiate, no pool. The overlay’s first draw call happens on the very next render frame after spawn — which on this code path is frame #2 of the next animation’s wait window. The hang at frame #2 matched perfectly. The overlay prefab uses a custom TMP text shader plus a particle/glow combo, with about 200 uniforms across its passes.

Our shader warmup at boot covered the major VFX prefabs we knew about — the burst particles, the highlight glow, the popup labels, the dissolve variant of the main mesh shader. It did not cover this overlay because the manager was an “old” engine system that pre-dated the warmup pattern. Nobody connected the dots: the overlay is spawned by a different event than the animation that hangs, so its first draw appears to be unrelated to the hang location.

Fixing the Unity WebGL Safari Hang

A single line in the overlay manager’s Awake resolved the Unity WebGL Safari hang completely:


using MyGame.Rendering;

protected virtual void Awake()
{
    // ...existing setup...

    if (overlayPrefab != null)
        ShaderWarmup.WarmupPrefab(overlayPrefab);
}

Enter fullscreen mode Exit fullscreen mode

ShaderWarmup.WarmupPrefab does the bare minimum needed to make WebKit compile the program and cache its uniform locations:

  1. Instantiates one copy of the prefab as a child of a WarmupParent transform (parked at the gameplay anchor — wherever the real prefab will spawn in-game, so lighting matches)
  2. Plays any ParticleSystem components so their geometry actually emits this frame
  3. Lets the main camera render the warmup parent for 3 frames during the splash screen — same camera, same lighting state, same render-pass configuration as gameplay, so the PSO key matches
  4. Destroys the throwaway instance

The web front-end’s splash screen waits for both loadProgress >= 1 AND a Unity-side gameReady signal that only fires after every warmup pass has flushed. So the 1-3 seconds of pre-compile work happens behind the loading bar where players already expect to wait, instead of during a critical animation later.

Result on Safari: first event dropped from 3.1s to ~500ms — identical to every subsequent occurrence.

When you need this — and when you don’t

The combination of all three must be true:

Criterion Need warmup if…
Lifecycle Prefab is Instantiated at runtime (not present from scene load)
Shader complexity Uses non-trivial shader: custom *.shader, [Toggle(_KEYWORD)] properties, multi_compile keywords, multi-pass shaders, particle shaders, or TMP shaders with OUTLINE_ON / UNDERLAY_ON / BEVEL_ON variants
Timing First draw happens during gameplay, not during the loading splash

You don’t need warmup for:

  • Objects visible from scene load (Unity compiles their shaders during the splash render)
  • Stock URP Lit/Unlit shaders with no keyword toggles
  • UI Image components with the default UI shader
  • Anything that doesn’t render (audio sources, logic GameObjects)

The diagnostic loop

If you suspect this is happening in your game:

  1. Reproduce on Safari , ideally on real hardware. macOS Safari is usually enough — the WebGL-via-Metal path is the same.
  2. Open Web Inspector → Timelines tab. Hit record, trigger the event that hangs, stop recording.
  3. Find the long frame in the JavaScript & Events row.
  4. Look under wasm-stub for high counts of _glGetUniformLocation, _glGetActiveUniform, webglPrepareUniformLocationsBeforeFirstDraw. If you see dozens or hundreds of these on one frame, you’ve found a first-use shader event.
  5. Identify what’s first-rendered. What prefab spawned recently? What keyword was toggled? What material was instanced?
  6. Add a ShaderWarmup.WarmupPrefab call in that subsystem’s Awake.

A subtle gotcha: the prefab that causes the hang may not be the one whose animation is running when the hang happens. In our case the overlay was spawned by a completely separate event handler one frame before the visible animation started, so the hang appeared to be inside the animation. Trace recent Instantiate calls backward from the slow frame, not just what’s animating during it.

Related prior art

The general “WebGL on Safari is slow at things ANGLE handles fast” story is well-documented:

The specific case that bit us — a prefab Instantiated mid-game by an obscure event handler, whose first draw lands inside an unrelated critical animation one frame later — isn’t covered in the existing material. The Safari Web Inspector webglPrepareUniformLocations* markers as a diagnostic tool also seem underused; I didn’t find any reference to using them for Unity WebGL debugging.

Takeaways

  1. Shader.WarmupAllShaders is not enough on Metal/Vulkan. You have to actually render the geometry through the same camera pipeline that gameplay will use.
  2. The first draw call of any custom-shader prefab is expensive on Safari. Plan for it. Render once during the splash. Engineering effort: one line per missed prefab.
  3. Self-registering warmup is the right pattern. Each subsystem that spawns prefabs at runtime should warm its own prefab in Awake instead of relying on a central registry. New systems get coverage automatically without anyone remembering to wire them up.
  4. Diagnose with the Safari profiler, not by guessing. We burned a full day on A/B isolation before realizing the answer was sitting in the Web Inspector timeline the whole time. The webglPrepareUniformLocations* markers tell you exactly when first-use uniform discovery is happening — search for them.
  5. Pool spawned prefabs aren’t a substitute for warmup. Pooling avoids Instantiate allocation cost (microseconds). Warmup avoids first-draw shader compile cost (seconds). They solve different problems and you typically want both.

If your Unity WebGL game has a “first time something happens” hitch on Safari that doesn’t repeat, you’re almost certainly looking at a Unity WebGL Safari hang of the same family. The Safari profile + webglPrepareUniformLocationsBeforeFirstDraw markers are the fastest path to the answer. Hope this saves someone a long debugging session.

The post Unity WebGL Safari Hang: The First-Draw Shader Stall appeared first on Richard Fu.

Top comments (0)