WebGL 6000.5.2f1: UI Toolkit staged updater under-sizes its staging buffer, CopyBufferRanges reads out of bounds on large single-frame restyles

SUMMARY

On the WebGL player in Unity 6000.5, UI Toolkit’s staged geometry updater allocates its per-frame staging buffer from a dirty count that is computed before the updater grows the ranges it is about to copy. When a single frame’s geometry update lands in one of several predictable size windows, the copy runs past the end of the staging buffer. You get this in the browser console:

GfxDevice::CopyBufferRanges: range reads out of bounds (srcEnd=590904 srcSize=589824)

The out-of-range copy is skipped, so the affected panel keeps rendering stale geometry (a restyled UI that visually did not change), and soon after the player dies with a fatal wasm “bounds” RuntimeError. This is the WebGL/WebGPU geometry path only. Platforms with mapped GPU buffers use a different updater (GpuUpdaterMapped) and are not affected.

We read the actual IL of the shipped module to pin this down (the WebGL playback engine’s UnityEngine.UIElementsModule.dll ships with its PDB, so the names below are the real ones, not guesses), and we have a one-instruction workaround that has held up. Full details below so someone on the UI Toolkit team can check it against source.

ENVIRONMENT

  • Unity 6000.5.2f1
  • WebGL player. Both our release configuration (Brotli, ExplicitlyThrownExceptionsOnly) and a development diagnostic build (uncompressed, FullWithStacktrace) hit it.
  • UI Toolkit runtime panels. We hit it hardest with world-space panels (PanelRenderMode.WorldSpace) carrying per-element custom materials (style.unityMaterial), because those produce large single-frame geometry updates. Screen-space panels run the same updater and are exposed to the same math, they just need a big enough burst.
  • Observed in desktop Chrome. Nothing about it is browser specific; the sizing happens in Unity’s managed renderer.

One diagnostic note that may save someone a day: with default exception support the eventual crash is a bare RuntimeError with a native-only stack that names nothing. Building with WebGLExceptionSupport.FullWithStacktrace turns it into a named managed exception, which is how we connected the crash to the staging copy at all.

THE CONSOLE OUTPUT

Two representative lines, one from the vertex staging buffer and one from the index staging buffer:

GfxDevice::CopyBufferRanges: range reads out of bounds (srcEnd=590904 srcSize=589824)
GfxDevice::CopyBufferRanges: range reads out of bounds (srcEnd=17352  srcSize=16384)

The numbers are worth a second look, because they identify the bug on their own:

  • srcSize=589824 is exactly 8192 * 72: the 8192-element staging tier at UI Toolkit’s Vertex stride (72 bytes: position, tint, uv, layoutUV, xformClipPages, ids, flags, opacityColorPages, settingIndex, circle, textureId). And srcEnd=590904 is exactly 8207 * 72, so the copy wanted 15 vertices more than the tier holds.
  • srcSize=16384 is exactly 8192 * 2: the 8192-element index tier at 2 bytes per UInt16 index. srcEnd=17352 is 8676 * 2, 484 indices past the tier.

Both overruns are small and both start at exactly a tier boundary. That is the signature of a buffer sized to a tier and then grown past it after sizing.

ROOT CAUSE

This is in UnityEngine.UIElements.UIR.GpuUpdaterStaged.CompleteUpdate. Per dirty data set, it does this (reconstructed from the IL of the shipped module; call order verified):

// sized from the dirty count as it is right now...
StagingBufferInfo<T> staging = FindOrAllocateBuffer(m_AvailableStagingBuffers,(int)dataSet.totalDirtyCount);   

// ...then the ranges grow:
PrepareCopyRanges(dataSet, staging);

PrepareCopyRanges opens with dataSet.ConsolidateRanges(0.9f). If the dirty ranges cover at least 90% of their [min, max) span, consolidation throws the range list away, replaces it with the whole span including the gaps, and writes the span back as the new totalDirtyCount. That is up to roughly 11% growth (1 / 0.9), applied after the buffer was sized from the old count. For index data sets, AlignIndexRange then rounds every remaining range outward to even boundaries (start down, end up), up to 2 more indices per range.

The tiering turns that growth into overruns. Staging buffers come from FindSuitableBufferLength, which walks m_SupportedBufferLengths and returns the first supported length that fits: {8192, 65536} for the vertex updater, {8192, 262144} for the index updater. If the request is larger than the last tier, it returns the request exactly, no padding. So:

  • A request within about 11% below a tier boundary rounds up to the tier, and consolidation can grow the copy past it. The first vertex window is roughly 7,400 to 8,200 dirty vertices in one data set, which is about the size of one large component’s restyle.
  • A request above the top tier has zero headroom, so any growth at all overruns.

Because a window exists at every tier boundary, spreading work across frames on the host side reduces the frequency but cannot eliminate it; we paced our restyles one element per frame and still hit it. Buffers are also shared across data sets within a frame (FindOrAllocateBuffer reuses any buffer where capacity - usedCount fits the request), so an under-sized reservation can also trample the neighbouring data set’s staging region rather than the end of the buffer.

The failure then shows up in two stages, matching what we see: the native side detects the bad range, logs “CopyBufferRanges: range reads out of bounds”, and skips the copy, which leaves the panel on stale geometry. The fatal wasm “bounds” exception follows shortly after.

SUGGESTED FIX

ConsolidateRanges takes only the data set and the density threshold. It does not need the staging buffer, so it can run before the buffer is sized. Moving the consolidation call above FindOrAllocateBuffer and sizing from the post-consolidation count, plus two indices of alignment allowance per remaining range for the index updater, closes the hole exactly. From the outside it looks like a very small reorder.

OUR WORKAROUND, FOR REFERENCE ONLY

We needed shipping builds before an engine fix, so we patch the request at the single call site from r to 2r + 64 with a one-instruction IL edit (Cecil) against the WebGL playback module on the build machine:

FindOrAllocateBuffer(m_AvailableStagingBuffers, (int)(totalDirtyCount + totalDirtyCount + 64));

Doubling covers the worst realistic growth (consolidation tops out near 1.12x, index alignment near 1.34x for real 6-index quad ranges) and the +64 covers the tiny-count edge. The cost is transient staging buffers up to twice as large, which in practice top out around a megabyte. The patcher asserts there is exactly one FindOrAllocateBuffer call in CompleteUpdate and refuses to run otherwise. We mention it only so the call site and the growth bounds are concrete for whoever picks this up; binary-patching the editor is not something we would suggest as a general fix.

REPRODUCING IT

We applied a small patch with the tool here, and you can reproduce by following the steps below or building design system showcase > world space gallery without the patch:

  1. WebGL build, Unity 6000.5.2f1.
  2. A UI Toolkit hierarchy with a few thousand elements.
  3. In a single frame, dirty enough geometry that one data set lands in a window: within about 11% below 8192 or 65536 vertices (or 8192 / 262144 indices), or anywhere above the top tier. A full-subtree stylesheet swap or per-element material assignment is the easiest driver.
  4. Watch the console for “CopyBufferRanges: range reads out of bounds”, stale visuals on the affected panel, then a wasm “bounds” RuntimeError. Build with FullWithStacktrace if you want the crash named.

Happy to share the decompiled CompleteUpdate body, exact build settings, or put together a stripped repro project if that helps. If someone from the UI Toolkit team can confirm the allocate-before-consolidate ordering in current source, we would also love to know whether 6000.6+ already changed it.