Web (WebGL) Workarounds

The WebGL build runs the same Unity project as the native Android and iOS builds, but the browser environment forces a number of platform-specific hacks that don’t exist on native. They mostly work around two hard limitations:

  • Unity’s WebGL keyboard/text-input path is unreliable, especially on desktop browsers.

  • Mobile browsers (particularly iOS Safari) fight fixed layouts when the software keyboard opens — resizing the viewport, scrolling the page, and mispositioning position: fixed elements.

  • Browsers refuse to play audio until the user has interacted with the page, and iOS Safari keeps interrupting playback afterwards whenever the audio session changes.

Because these behaviours are browser- and OS-version sensitive, each workaround is documented with why it exists and why it’s done the specific way it is, so the reasoning survives even if a future browser change breaks it.

Topics

  • Text Input on Web — Web-specific parts of the DOM <input> behind BetterTextField: the keyboard capture scope, the focus guard, and the mobile-keyboard bridge for other text fields. The cross-platform design is Native Text Input.

  • iOS Focus-Scroll Prevention — stopping iOS from scrolling the whole page when an input is focused.

  • Viewport & Insets — page/canvas locking, keyboard & chrome inset reporting, DPI scaling, and fullscreen-on-touch.

  • Copying to the clipboard — why writing GUIUtility.systemCopyBuffer reaches nothing on Web, and why a copy is written from the press rather than from the click.

  • SSO popup and user activation — why the login popup is blocked when nothing was clicked, and why the provider picker is shown even for a single provider.

  • WebGL fallback error dialog — the plain-DOM error modal for when Unity can’t draw one, what makes it appear, the promise-rejection rule that keeps it from appearing spuriously, and the No WebGPU adapter pre-flight that gives a load failure a name.

  • Rendering in the background — why a hidden tab keeps drawing at all, and what stops it.

  • UI Toolkit staging-buffer overrun — the engine defect behind GfxDevice::CopyBufferRanges: range reads out of bounds and the blank or doubled-up cards it leaves, and the usage hints that keep the renderer away from it.

File map

File Responsibility

Assets/WebGLTemplates/Client/index.html

Page/canvas CSS lock, iOS focus-scroll animation, iOS detection, script load order, pre-Unity loading splash, WebGPU adapter pre-flight.

Assets/WebGLTemplates/Client/arc-viewport.js

Viewport meta, keyboard/chrome inset computation (window.__arcViewport), canvas height lock.

Assets/WebGLTemplates/Client/arc-fullscreen.js

Optional hide-browser-chrome-on-first-touch (disabled by default).

Assets/Plugins/WebGL/NativeTextInput.jslib

DOM input registry (the Web backend of Native Text Input), keyboard focus guard, iOS animation warm-up.

Assets/Plugins/WebGL/WebKeyboard.jslib

Override of Unity’s built-in mobile keyboard (JS_MobileKeyboard_*) with a DOM input.

Assets/Plugins/WebGL/ScreenBridge.jslib

Exposes __arcViewport insets to C# in device pixels.

Assets/Plugins/WebGL/DeviceInfo.jslib

Maps the browser’s user agent onto a device platform, so a phone browser is known as a phone.

Assets/Plugins/WebGL/Clipboard.jslib

Writes the real clipboard through navigator.clipboard, and holds the text a press announced until the release that is allowed to write it.

Assets/Plugins/WebGL/WebAudio.jslib

Speech playback: AudioContext lifecycle, autoplay unlock, iOS interruption recovery.

Assets/StreamingAssets/webaudio/worklet-processor.js

The pcm-player AudioWorklet that drains pushed PCM chunks and reports played samples.

Assets/Scripts/UI/Elements/BetterTextField.cs

Cross-platform text field; drives the DOM input on WebGL. See Native Text Input.

Assets/Scripts/UI/Elements/SafeAreaContainer.cs

Reads insets (via ScreenBridge on WebGL) and insets its content by the safe area.

Assets/Scripts/UI/ScreenHandler.cs

Applies the Web DPI scale.

Assets/Scripts/SystemNative/Clipboard.cs

Clipboard.SetText / Clipboard.PrepareCopy — the clipboard for the whole app, and the only place the Web gesture rule is dealt with (Copying to the clipboard).

Assets/Scripts/SystemNative/DeviceInfo.cs

DeviceInfo.Kind, read from DeviceInfo.jslib on Web — the device the app runs on rather than the target it was built for. One user of it is the desktop having no overscroll stretch (Desktop has no overscroll).

Assets/Scripts/AudioStreaming/Runtime/WebAudioStreamer.cs

C# side of the bridge; the WebGL implementation of AudioStreamerBase.

Assets/Plugins/WebGL/Microphone.jslib

getUserMedia capture feeding SystemNative.Microphone; muted while backgrounded.

Assets/Scripts/Rendering/Runtime/Quality/QualityManager.cs

Stops rendering while the page is hidden (Rendering in the background).

Assets/Plugins/WebGL/WebView.jslib

window.open for the SSO popup and the hidden iframe the silent login rides on.

Assets/Scripts/Authentication/AuthenticationManager.cs

The login flow, and the provider picker that affords the popup (SSO popup and user activation).

Text Input on Web

Unity’s built-in WebGL text entry is unreliable — on desktop browsers it routes keystrokes through Unity’s own capture listeners (dropped first keystrokes, broken IME/dead keys, focus stealing), and there is no consistent caret/selection behaviour. A software keyboard also only opens for a focused DOM input. So instead of typing into a UITK TextField, a real browser <input>/<textarea> floats over the field and the browser handles typing natively.

That is no longer a Web-only arrangement: the same design backs BetterTextField on iOS and Android too, and the protocol, the frame sync, the text mirror and the growth contract are all described once in Native Text Input. NativeTextInput.jslib (once WebOverlay.jslib) is the Web backend of it. What follows is only what is specific to the browser.

Keyboard capture

Unity registers its keyboard listeners with useCapture: true, so stopPropagation from the DOM input is too late — Unity would still swallow the keystrokes. While an input is focused we therefore disable Unity’s capture with WebGLInput.captureAllKeyboardInput = false so characters reach the DOM element (SystemNative.NativeTextInput.SetKeyboardCaptureSuppressed, driven by the single focused field the registry tracks).

Focus is the right scope, and visibility — which an earlier version used — was wrong twice over: DOM focus always precedes the first keydown, so focus is early enough; and keying it to visibility disabled every Unity keyboard shortcut on desktop for as long as a chat input was merely on screen.

Keyboard focus guard

Tapping a UITK button (e.g. the chat send button) moves browser focus to the Unity canvas, which blurs the DOM input and dismisses the software keyboard. We keep the keyboard open without a DOM button:

While an input is focused, a capture-phase mousedown/touchstart listener on document calls preventDefault() for taps at or below the focused input’s top edge (the row where send/submit buttons sit). preventDefault() on mousedown/touchstart cancels the browser’s focus-steal, so the input keeps focus and the keyboard stays open — while Unity still receives the event (preventDefault does not stop propagation) and fires the button click.

  • The guard band’s top edge is derived from the input’s own rect, which the overlay already syncs every frame (reg.guardTopCss in NativeTextInput_SetFrame) — no per-button machinery.

  • Taps above the input (e.g. a message list) are left alone, so tapping there still dismisses the keyboard.

  • Taps on the focused input itself are skipped so caret placement/selection keep working.

An earlier design used a transparent DOM "click-catcher" button stacked over the UITK button. It was removed: a <button> still steals focus on click unless it also preventDefault`s `mousedown, so it never actually solved the keyboard dismissal — the focus guard does, with no extra element.

Mobile keyboard bridge

Regular (non-BetterTextField) UITK text fields still exist elsewhere in the app. On mobile web, focusing one triggers Unity’s TouchScreenKeyboard, which calls the built-in JS_MobileKeyboard_* functions. WebKeyboard.jslib overrides those to spawn our own DOM <input>/<textarea> (positioned via WebKeyboard_SetTargetRect, with blur/hide handling and Enter-to-close), giving those fields the same native typing behaviour. WebKeyboard_IsDesktop lets C# skip this path on desktop, where UITK direct input is acceptable.

iOS Focus-Scroll Prevention

When a text input is focused on iOS Safari, WebKit scrolls the whole page to reveal the input above the keyboard. With our fixed, canvas-filling layout there is nothing to legitimately scroll, so this just shifts the entire canvas up and leaves it misplaced (position: fixed elements also stop behaving as fixed while the keyboard is open). We suppress that scroll.

The mechanism

WebKit skips its scroll-to-focus when the focused input has opacity: 0 at focus time. So we blank the input for a brief window as it’s focused, then restore it. This lives in Assets/WebGLTemplates/Client/index.html:

@keyframes ios-prevent-focus-scroll {
    from { opacity: 0; }
    to   { opacity: 0; }
}
html.ios input:focus,
html.ios textarea:focus {
    animation: ios-prevent-focus-scroll 0.1s;
}

The html.ios class is added by a small UA check in index.html (covering iPadOS 13+, which reports a desktop UA). The rule targets any focused input, so it covers both BetterTextField overlays and the mobile-keyboard bridge inputs.

Why it’s done exactly this way

Three details each fix a specific failure mode that earlier attempts hit:

Driven by :focus, not a JS listener

Pseudo-class styles are applied synchronously as the element gains focus — before WebKit evaluates the scroll. A JS focus/focusin listener fires too late on iOS: the scroll decision is already made, so a JS approach never prevents the scroll (it made things strictly worse when tried).

Hold opacity at 0, don’t ramp it

The original rule animated opacity 0 → 1. WebKit only skips the scroll while opacity is exactly 0, so only the first instant qualified; if WebKit evaluated the scroll a few frames later (opacity already 0.2…) it scrolled anyway. That race is why it worked only intermittently. Both keyframes are now 0, holding the input invisible for the whole short window, then it snaps back to visible.

Warm-up for the first focus

A freshly-created input’s animation/compositor path is cold: the very first time the animation runs, its opacity-0 frame commits a beat late and the first focus still scrolls (every later focus is fine). NativeTextInput_Create (NativeTextInput.jslib) therefore warms the animation once at creation — running it on the element while rendered but invisible (opacity: 0), with no focus and no keyboard — so the user’s first real focus hits an already-warm path:

el.style.opacity = '0';
el.style.display = 'block';
el.style.animation = 'ios-prevent-focus-scroll 0.01s';
// on animationend (or a 200ms fallback): clear animation, restore opacity, hide again

NativeTextInput_SetVisible cancels a pending warm-up if the element is shown before it finishes (e.g. a multimodal input shown immediately after creation), so the cleanup can’t hide a now-visible field.

Tuning

The input is invisible for ~100ms after focus. If that blink is noticeable, shorten the duration (e.g. 0.05s) or soften the tail (0%, 70% { opacity: 0; } 100% { opacity: 1; }) — at the risk of re-introducing the scroll if the window becomes too short on some device. Test on real hardware after any change here.

Viewport & Insets

On mobile web the canvas must fill the screen and stay put while browser chrome and the software keyboard come and go. Unity has no notion of these browser insets, so the template computes them in JS and feeds them to C#.

Page & canvas lock

index.html pins the page so the browser can never scroll or rubber-band it:

html, body {
    position: fixed; inset: 0; margin: 0; padding: 0;
    overflow: hidden; touch-action: none; overscroll-behavior: none;
}
#unity-canvas { position: fixed; inset: 0; width: 100vw; height: 100vh; }

The viewport meta uses viewport-fit=cover (render behind the notch/chrome) and interactive-widget=overlays-content (let the keyboard overlay the layout viewport instead of resizing it), set from arc-viewport.js.

arc-viewport.js also locks the canvas CSS height to the large-viewport height at load. Chrome shrinks the large viewport by the mini URL-bar height (~24px) when the keyboard opens; without the lock that change reaches Unity’s ResizeObserver and triggers an expensive buffer resize / layout shift. The lock is refreshed on orientationchange (after a short settle delay).

Inset computation

arc-viewport.js publishes insets on window.__arcViewport (top, bottom, width, height, in CSS px). The bottom obstruction has two disjoint zones inside the 100vh canvas:

  • Zone A — the software keyboard, inside the layout viewport. Read from the VirtualKeyboard API (navigator.virtualKeyboard.boundingRect.height) or the env(keyboard-inset-height) CSS variable (probed via a hidden element + ResizeObserver, since env() transitions fire no JS event).

  • Zone B — persistent bottom chrome (home indicator, nav strip, retracted URL-bar residue), below the layout viewport but inside 100vh. Computed as largeH - visualViewport.height - visualViewport.offsetTop.

The total is their sum. On browsers that honour overlaysContent the zones are disjoint; on Safari (no VK API, shrinks the layout viewport) Zone A collapses to 0 and the shrink term absorbs everything, so the same formula degenerates correctly.

Bridge to C#

ScreenBridge.jslib exposes __arcViewport to C# in device pixels (multiplying by devicePixelRatio) so the values line up with Screen.width/Screen.height. SafeAreaContainer.cs polls ScreenBridge_GetInsetTop/Bottom each frame and eases its content margins to sit inside the safe area (e.g. lifting above the keyboard). UI screens just drop their content into a <pui:SafeAreaContainer> instead of handling insets themselves.

ScreenHandler.cs handles one Web quirk:

  • DPI scale — Web DPI is a constant multiple of native DPI; panelSettings.referenceDpi is multiplied by WebDpiScale (3/5, empirical) so UITK scaling matches native.

The bottom inset can sample too large on phones at startup, but SafeAreaContainer re-polls every frame, so the value self-corrects within a frame or two.

Fullscreen on touch (disabled)

arc-fullscreen.js can hide the browser navigation bar by entering fullscreen on the first touchend (a spec "activation-triggering" event; captured in the capture phase so Unity’s canvas handlers can’t eat the bubble first). It is gated behind ENABLE_FULLSCREEN_ON_TOUCH, which is currently false.

SSO popup and user activation

Login opens Keycloak in a popup (_WebViewOpenAuth), and window.open only returns a window while the page holds transient user activation — a real gesture, still valid for about five seconds, and consumed by the call. Without it the browser hands back null and the flow ends in Failed to get authentication code, popup_blocked.

A Unity click clears that bar: the canvas takes a genuine DOM pointer event, and the UI Toolkit callback runs a frame or so later, well inside the activation window. What does not clear it is the distance between the click and the popup. LogInToProjectAsync can await a project fetch and a terms prompt before it reaches WebView.OpenAuth, and a deep link to a project that requires an identity (/p/<key>) reaches the login on page load with no gesture behind it at all.

The provider picker is the gesture

So the picker is shown even when the project offers a single provider, rather than taking the lone entry and going straight on. Its click is the last thing before the popup, which is what makes the popup open — the multi-provider path already worked for exactly this reason, while the single-provider one was a race against the five seconds and the deep-link start lost it outright.

Anything that reintroduces a shortcut past the picker — an auto-pick, a remembered choice, a retry that skips it — has to open the popup from its own gesture instead, or it brings the block back. The one exception already in place is the retry on the login-failed prompt: that button is itself the gesture, and it re-enters the loop directly at WebView.OpenAuth.

The silent login (prompt=none) is unaffected. It runs in a hidden iframe (_WebViewFrameNavigate), not a popup, so it needs no activation — which is why a returning user with a live Keycloak session never sees any of this.

WebGL fallback error dialog

The in-app error dialog is DialogController (DialogController.Show("…", DialogType.Error)), rendered with UI Toolkit like everything else. That path only works while Unity is running. On the Web build there is a class of failure where it isn’t — the runtime fails to load, or a fatal window.onerror-level exception leaves it in an unknown state — and in those cases a UITK dialog cannot be drawn.

For that gap the WebGL template ships a duplicate of the error dialog written in plain HTML/CSS/JS, Assets/WebGLTemplates/Client/arc-error-dialog.js. It is deliberately a near-copy of the native dialog’s look (dark translucent backdrop, light rounded card, Figtree, error-red title, iOS-blue button — the tokens from Assets/UI/Dialog/DialogStyles.uss) so that when it does appear the user sees the same "We’re sorry, there was a problem" treatment they’d get in-app. Because it is a plain DOM overlay it has zero dependency on the Unity runtime, which is the whole point: it can render precisely when Unity can’t.

It follows the template’s existing arc-.js convention (like arc-viewport.js / arc-fullscreen.js) and hangs its API off the window.__arc namespace:

window.__arcError.show({ title, body, button, onButton }); // button defaults to a page reload
window.__arcError.hide();

It is wired in Assets/WebGLTemplates/Client/index.html from three places, all showing the modal with a Reload button (the only sensible recovery when Unity is dead):

  • the createUnityInstance(…​).catch handler — the fatal "runtime never started" path;

  • the errorHandler config hook, which returns true to suppress Unity’s default browser alert;

  • the No WebGPU adapter pre-flight, which runs before the loader is ever called.

Which of the first two fires is decided inside UnityLoader.js, not by us. It puts a listener on window for both error and unhandledrejection, and routes them through its own errorHandler: while the instance is still starting, Module.startupErrorHandler is the promise’s reject, so everything goes down the first path and the modal appears at most once; from the frame WaitForInitialization() resolves, that hook is deleted and the config errorHandler gets them instead. Module.abortHandler — native aborts, out-of-memory, engine assertions — takes the same route.

No WebGPU adapter

The Web player is built for WebGPU alone — Player Settings ▸ Web ▸ Graphics APIs holds a single entry, WebGPU — so there is no WebGL2 to fall back to when the browser cannot hand out an adapter. Unity’s loader does not report that case: UnityLoader.js checkForWebGPU() records navigator.gpu.requestAdapter() returning null as SystemInfo.hasWebGPU = false and loads the build anyway, and its only refusal messages are for WebGL, WebGL 2 and WebAssembly. The engine then fails to create GfxDeviceWebGPU, which surfaces as the generic "The experience couldn’t be loaded", and the only mention of an adapter is the browser’s own console line. In telemetry that failure is indistinguishable from a bad download.

So index.html asks for an adapter itself, before createUnityInstance, and names the cause:

navigator.gpu.requestAdapter({ powerPreference: "high-performance" })

high-performance is what the build itself asks for (Player Settings ▸ Web ▸ Power Preference), so the pre-flight fails exactly where the engine would. A refusal is retried once without a preference, because a machine with switchable graphics can decline the discrete GPU while still having an integrated adapter to give; that retry logs a distinct warning, which is what separates a hybrid-GPU machine from one where WebGPU is gone altogether. Only a null from both stops the load.

The two failures read differently to the user, so they carry different copy: a missing navigator.gpu is a browser too old for WebGPU, while a present navigator.gpu that yields no adapter is almost always Chrome having turned hardware acceleration off for the profile — after repeated GPU-process crashes, a blocklisted driver, or a session already running on software. That state does not survive a browser restart, which is why the copy asks for one; a page reload alone will not clear it.

An adapter granted here is not a promise that the engine gets one. Nothing is held onto — the adapter is dropped for the engine to request its own — and the GPU process can still fall over between the two calls. The pre-flight narrows the common case; it does not remove the generic path.

Unhandled promise rejections show the dialog

The consequence worth internalising: the listener is on window, so any uncaught error or unhandled promise rejection anywhere on the page puts up a full-screen "The experience couldn’t be loaded" — including one thrown by our own .jslib code long after the experience loaded fine.

The Web Audio API is where this bites. AudioContext’s `resume, suspend and close report a context that cannot honour them by rejecting their promise, not by throwing, so wrapping the call in try/catch looks careful and catches nothing. Every such call in a .jslib must settle its promise:

try {
    var p = ctx.resume();
    if (p && p.then)
        p.then(ignore, ignore);   // two handlers: a lone .catch also swallows the success path's throws
} catch (e) {
}

The p && p.then guard is not decoration — older Safari returns undefined from these methods rather than a promise. WebAudio.jslib hangs this as st.settle on its state object so the entry points share one copy.

The same applies to anything else promise-shaped a plugin touches — getUserMedia, navigator.share, clipboard.writeText, requestFullscreen, audioWorklet.addModule — and to a .then() whose callback can throw, since that throw becomes a rejection of the promise .then() returned.

This is a hand-maintained duplicate, not a shared component — the HTML dialog and DialogController do not share code or styling. If the native error dialog’s look or copy changes and you want the fallback to match, update arc-error-dialog.js by hand. Its text is also hardcoded English, since the template has no access to the app’s localization.

Audio Playback on Web

Agent speech is streamed as raw PCM and played through a Web Audio AudioWorklet (pcm-player), not through Unity’s audio engine. WebAudioStreamer pushes float chunks over WebAudio.jslib, and FAnimator drives lipsync from WA_GetPlayedSamples() — so a stalled or suspended AudioContext costs you the lipsync as well as the sound, which is the signature of every bug in this area.

The autoplay lock

Browsers will not start an AudioContext that was created outside a user gesture; iOS Safari is the strictest. Three rules follow, and all three are load-bearing:

  1. The context is created once, early (WebAudioStreamer.Start()WA_Init), so it exists before the first speech arrives and can be unlocked by the user’s first tap rather than racing it.

  2. The unlock listeners are never removed. They are bound once per page and read Module.WA_STATE.ctx at call time, so a context recreated later is still covered. An earlier version unbound them after the first gesture and guarded rebinding with a sticky flag — a context created afterwards could then never be unlocked.

  3. The unlock plays a one-sample silent buffer, not just resume(). iOS only leaves its locked state once something has actually been rendered through the context.

Never destroy a live context

WA_Init is idempotent and owns the whole context lifecycle. It rebuilds only when the requested sample rate differs from the current context’s (an AudioContext sample rate is fixed at construction), and a rebuild re-runs the unlock path.

Do not tear the context down from C#. A previous FillBuffer compared the incoming sample rate against a -1 sentinel, so the first chunk after every page load always took the "rate changed" branch and called WA_Stop + WA_Dispose. That destroyed the context the user had just unlocked and rebuilt it mid-frame, outside any gesture — the first agent speech of the first conversation was silent and lipsync-less, everything afterwards worked. If you need a rebuild, change the rate passed to WA_Init and let the JS side decide.

A rebuild also invalidates any audioWorklet.addModule() still in flight, so the resolve handler checks a generation counter before building its node. Without that check the stale promise builds a second AudioWorkletNode on the current context, doubling both playback and the played-sample counter that lipsync reads.

iOS interrupts running contexts

Starting a capture stream flips the iOS audio session to play-and-record and interrupts existing contexts. In practice that means granting the microphone permission silences playback, and the permission prompt appears at almost exactly the same moment as the first speech (ConversationManager.BeginConversation sends the intro request and starts the mic together).

Recovery is centralised in st.ensureRunning(), called from the gesture listeners, the context’s own onstatechange (iOS also has an interrupted state), on returning to the foreground, and from Microphone.jslib once getUserMedia resolves. It refuses to resume while an explicit WA_Pause or a backgrounded page is in effect, so the recovery paths never fight a deliberate pause.

Pausing in the background

Native builds pause on background for free: the engine suspends the whole player, which stops both the audio and the frame loop. WebGL gets neither half of that for free. Unity’s loop all but stops when the page is hidden (Rendering in the background has the caveat), but the worklet renders on the browser’s audio thread and carries on — so the agent keeps talking over a frozen app, and comes back with playback and lipsync desynchronised.

st.setHidden() suspends the context on visibilitychange/pagehide and resumes on return.

It has to live in JS. Unity is frozen at exactly the moment the decision has to be made, so C# cannot drive it — WA_Pause would never be called. Nothing else needs to be notified: everything downstream (WatchProgress, FAnimator lipsync) is derived from WA_GetPlayedSamples(), which freezes with the context, so the whole app pauses and resumes coherently on its own.

ensureRunning() deliberately bails while st.hidden. Without that check the recovery paths undo the pause: on iOS the OS suspends the context itself on screen lock, onstatechange fires, and an unguarded resume puts the speech straight back on the lock screen.

A page opened in a background tab stays silent until its first user interaction even after being revealed. That is ordinary autoplay policy, not a bug — the user has to tap to start a conversation anyway.

Rendering in the background

The claim above that the frame loop stops on its own holds only while Unity is driven by requestAnimationFrame. It is not: Application.targetFrameRate is set for every interaction, and a frame rate limit makes Emscripten schedule the loop with setTimeout instead. Browsers throttle that to about a tick a second in a hidden tab rather than stopping it, so the app goes on rendering full frames — GPU work, and on a laptop a fan — for a tab nobody is looking at.

QualityManager polls document.hidden through ScreenBridge_IsPageHidden and parks OnDemandRendering.renderFrameInterval at int.MaxValue while the page is away, putting it back at 1 on return. The poll rides the same throttled ticks, which is exactly enough: the decision only has to land once per visibility change, and the first tick after the tab is revealed restores rendering before a frame would be seen. Update keeps running, so nothing else in the app is paused by this — audio and the microphone are silenced separately, above.

This is deliberately not driven off Application.focusChanged. The canvas also loses focus to the URL bar, to devtools and to another window that leaves ours plainly visible, and freezing the picture there would be a bug rather than a saving.

The microphone stops too

Capture has the same problem in reverse, and it is worse than a cosmetic one: ScriptProcessorNode delivers its buffers on the main thread, which keeps running while the page is hidden. Android Chrome will happily go on streaming the user’s microphone to the engine with the phone in their pocket. (iOS usually revokes capture in the background on its own, but not dependably across versions.)

Module.MIC_SETHIDDEN in Microphone.jslib mirrors the playback side on the same events. It gates capture three ways, because each covers a different failure:

  • the onaudioprocess handler returns early — nothing reaches Unity even if the browser keeps delivering buffers;

  • track.enabled = false — the browser itself stops producing audio from the device;

  • the capture AudioContext is suspended — the callback stops firing at all.

It is re-applied once getUserMedia resolves, since the page can be backgrounded while the permission prompt is still up.

The MediaStream is deliberately not stopped. Releasing it would force a fresh getUserMedia on return, and Safari does not reliably remember the grant across calls — a permission prompt every time the user unlocks their phone would be far worse than the tracks staying open but silent. The trade-off is that the browser’s recording indicator may remain visible while backgrounded even though no audio is being captured.

Stop does not suspend

WA_Stop drops the buffered audio but deliberately leaves the context running. Suspending between tracks would mean asking iOS to resume again for every single reply, outside any user gesture — a permission gamble with nothing to gain. An idle worklet renders silence, and it only posts a progress message when the played count actually moves, so an idle context is close to free.

UI Toolkit staging-buffer overrun

On Web the browser console reports GfxDevice::CopyBufferRanges: range reads out of bounds, and a card or a chat bubble draws empty or with two sets of text on top of each other. Left alone it escalates to a WebAssembly crash. It is an engine defect, not ours; what follows is why it fires here and what the code does to stay away from it.

The defect

GpuUpdaterStaged<T>.CompleteUpdate reserves staging space from a dirty count taken before the ranges are consolidated:

var staging = FindOrAllocateBuffer(m_AvailableStagingBuffers, (int)dataSet.totalDirtyCount);
PrepareCopyRanges(dataSet, staging);   // calls dataSet.ConsolidateRanges() first thing

DataSet<T>.ConsolidateRanges(threshold = 0.9f) collapses several dirty ranges into one spanning range whenever the dirty total covers at least 90% of min..max, and then reports the span as the new total. The span can therefore be up to 1 / 0.9 = 1.111x the number the buffer was just sized against, usedCount runs past capacity, and the emitted copy reads off the end.

The arithmetic is exact. A Vertex is 72 bytes and the vertex staging tiers are 8192 and 65536 elements, so 8192 * 72 = 589824 is the reported srcSize, and a logged srcEnd of 599328 is 8324 * 72 — a reservation of at most 8192 vertices grown into an 8324-vertex span. The index updater has the same flaw with a second growth source, AlignIndexRange, which widens every range outward to an even boundary.

Why only Web

UIRenderDevice’s constructor is the only place the updater is chosen. WebGPU is the sole backend routed to `GpuUpdaterType.StagedGpuOnly; every platform with mapped buffer ranges gets MeshManagerTracked and GpuUpdaterMapped, which has no staging buffer and so no mismatch. WebGL2 falls to StagedCpuGpu, which carries the same defect but as an unchecked MemCpy past the end of a NativeArray — it corrupts silently instead of logging.

The failure needs a frame whose dirty vertex total sits just under a tier boundary with the ranges densely packed, so roughly 7,373–8,192 vertices. UI Toolkit text is four vertices per glyph, which puts the band at about 2,000 glyphs — a screenful, not an extreme.

What dirties vertices

Not only building. MeshManagerBasic.Update marks an element’s entire vertex allocation dirty whenever the element is re-tessellated or merely moved: a shifted element with unchanged geometry takes RenderEvents.NudgeVerticesToNewSpace, which rewrites every one of its vertices in place. Reflowing a list therefore dirties every item that moved, in the contiguous sequential ranges those items occupy — exactly the dense pattern consolidation collapses.

Assigning identical text is free, however: `TextElement’s setter early-outs on an unchanged string, so rebinding an unchanged item costs nothing.

The mitigations

RenderEvents tests the transform-ID branch before the nudge branch, so an element that owns a transform ID answers a move with a single shader-info write and leaves its vertices alone — and its descendants inherit the ID and are skipped with it. UsageHints.DynamicTransform is what allocates that ID (NeedsTransformID reads RenderHints.BoneTransform).

It is therefore set on every element that shifts as a group while its content stays put:

  • the chat item root in ChatMessage.uxml, which moves whenever the conversation reflows;

  • the insight cards and session rows built in InsightCardBuilder, which move whenever a fold above them animates open.

Nested node cards inside a session row are deliberately left without it. They move only when a fold within a row animates, which shifts far less geometry, and every transform ID costs a slot in the shader-info texture.

Separately, ConversationController rebinds one entry rather than the whole list when an event names its index, so a tool call completing no longer runs a full virtualization refresh over every visible message.

The ceiling

These keep the common frames well clear of the band; they cannot close it. Any frame that genuinely re-tessellates a bandful of geometry can still land on it. The deterministic options are an IL patch of the shipped UnityEngine.UIElementsModule.dll to over-reserve in FindOrAllocateBuffer, or an engine fix — still absent as of 6000.5.9f1, and still absent in 6000.6.0b8.