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: fixedelements. -
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>behindBetterTextField: 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.systemCopyBufferreaches 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 boundsand the blank or doubled-up cards it leaves, and the usage hints that keep the renderer away from it.
File map
| File | Responsibility |
|---|---|
|
Page/canvas CSS lock, iOS focus-scroll animation, iOS detection, script load order, pre-Unity loading splash, WebGPU adapter pre-flight. |
|
Viewport meta, keyboard/chrome inset computation ( |
|
Optional hide-browser-chrome-on-first-touch (disabled by default). |
|
DOM input registry (the Web backend of Native Text Input), keyboard focus guard, iOS animation warm-up. |
|
Override of Unity’s built-in mobile keyboard ( |
|
Exposes |
|
Maps the browser’s user agent onto a device platform, so a phone browser is known as a phone. |
|
Writes the real clipboard through |
|
Speech playback: |
|
The |
|
Cross-platform text field; drives the DOM input on WebGL. See Native Text Input. |
|
Reads insets (via |
|
Applies the Web DPI scale. |
|
|
|
|
|
C# side of the bridge; the WebGL implementation of |
|
|
|
Stops rendering while the page is hidden (Rendering in the background). |
|
|
|
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.guardTopCssinNativeTextInput_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/focusinlistener 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 exactly0, so only the first instant qualified; if WebKit evaluated the scroll a few frames later (opacity already0.2…) it scrolled anyway. That race is why it worked only intermittently. Both keyframes are now0, 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-
0frame 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 againNativeTextInput_SetVisiblecancels 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 theenv(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.referenceDpiis multiplied byWebDpiScale(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(…).catchhandler — the fatal "runtime never started" path; -
the
errorHandlerconfig hook, which returnstrueto suppress Unity’s default browseralert; -
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:
-
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. -
The unlock listeners are never removed. They are bound once per page and read
Module.WA_STATE.ctxat 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. -
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
onaudioprocesshandler 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
AudioContextis 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.