WebGPU: render-pipeline cache collision corrupts vertex attributes on shared-bufferView glTFs
Babylon.js 9.11.0 Β· WebGPU only, WebGL2 unaffected Β· reproduces on the Playground
A skinned glTF renders correctly on WebGL2 and explodes into long thin triangles on WebGPU. The cause is that the WebGPU render-pipeline cache key omits vertex-buffer `byteOffset` and buffer identity, while the `GPUVertexBufferLayout` built for that pipeline depends on both. Two draws that need different layouts therefore share one pipeline, and every draw after the first binds its buffers into slots the pipeline maps to different attributes.
On a skinned mesh the visible result is that `matricesIndices` / `matricesWeights` get sampled from a UV buffer, so vertices are transformed by arbitrary bone matrices. No `GPUValidationError` is raised, so it fails silently.
> Attach screenshots here: WebGL2 reference, WebGPU before, WebGPU after.
Reproduction
Set the Playground engine to WebGPU and run:
var createScene = function () {
const scene = new BABYLON.Scene(engine);
const camera = new BABYLON.ArcRotateCamera("cam", -Math.PI / 2, Math.PI / 2.6, 12, new BABYLON.Vector3(0, 0.5, 1.4), scene);
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight("h", new BABYLON.Vector3(0, 1, 0), scene);
const url = "https://storage.googleapis.com/download/storage/v1/b/frame-vr-staging.appspot.com/o/skfb-5636f6a6-3b45-4ec7-9d13-b85d0c9abfaf%2Fmodel-skfb-5636f6a6-3b45-4ec7-9d13-b85d0c9abfaf.glb?generation=1759347529200529&alt=media";
BABYLON.LoadAssetContainerAsync(url, scene).then((container) => {
container.addAllToScene();
container.animationGroups.forEach((g) => g.stop());
// Uncomment to work around it: splitting the deduplicated UV sets onto their
// own buffers removes the merge divergence and the model renders correctly.
// for (const mesh of scene.meshes) {
// for (const kind of \["uv2", "uv3", "uv4"\]) {
// if (mesh.isVerticesDataPresent(kind)) {
// mesh.setVerticesData(kind, Array.from(mesh.getVerticesData(kind)), false, 2);
// }
// }
// }
});
return scene;
};
The asset is an ordinary Sketchfab export: one skin with 101 joints, ten skinned meshes, four bone influencers, `KHR_materials_unlit`, and `TEXCOORD_0` through `TEXCOORD_3`. Loaded vertex buffers per mesh:
position:FLOATx3@stride12 normal:FLOATx3@stride12
uv:FLOATx2@stride8 uv2:FLOATx2@stride8 uv3:FLOATx2@stride8 uv4:FLOATx2@stride8
matricesIndices:USHORTx4@stride8 matricesWeights:FLOATx4@stride16
The four TEXCOORD sets are duplicates, so they resolve to **the same underlying GPU buffer at the same `byteOffset`**, and every meshβs vertex data sits in shared bufferViews at differing offsets.
Cause
`WebGPUCacheRenderPipeline._setVertexState` merges consecutive attributes that share one underlying GPU buffer into a single `GPUVertexBufferLayout`, gated on `_validOffsetRange`:
vertexBuffer.\_validOffsetRange =
(offset + formatSize <= this.\_kMaxVertexBufferStride && byteStride === 0) ||
(byteStride !== 0 && offset + formatSize <= byteStride);
The mesh whose attributes sit at offset 0 merges; meshes at later offsets do not.
The pipeline cache is a trie over `_states`, and each vertex entry is:
const vid = vertexBuffer.hashCode + (location << 7);
`VertexBuffer.hashCode` is derived from `type`, `normalized`, `size`, `instanced`, and `byteStride`:
this.hashCode =
((this.type - 5120) << 0) +
((this.normalized ? 1 : 0) << 3) +
(this.\_size << 4) +
((this.\_instanced ? 1 : 0) << 6) +
/\* keep 5 bits free \*/
(this.byteStride << 12);
It contains neither `byteOffset` nor any notion of *which* buffer the attribute lives in β yet both determine the merge structure, and therefore the layout. Two meshes differing only in offset take the same trie path and receive the same `GPURenderPipeline`.
Observed
Trace captured by wrapping `GPURenderPassEncoder`. `b2` is the UV buffer, `b3` bone indices, `b4` bone weights. Same pipeline object, two incompatible bind layouts:
\--- setPipeline pipe0 (merged: 5 slots)
slot=0 b1 offset=0 position
slot=1 b1 offset=40740 normal
slot=2 b2 offset=0 uv + uv2 + uv3 + uv4 merged into one layout
slot=3 b3 offset=0 matricesIndices
slot=4 b4 offset=0 matricesWeights
drawIndexed count=14910
\--- setPipeline pipe0 (unmerged: 8 slots)
slot=0 b1 offset=96936 position
slot=1 b1 offset=98016 normal
slot=2..5 b2 offset=32312 uv, uv2, uv3, uv4 β one slot each
slot=6 b3 offset=32312 matricesIndices
slot=7 b4 offset=64624 matricesWeights
drawIndexed count=444
The pipeline was compiled for the first draw, so it maps slot 3 to `matricesIndices`. On the second draw slot 3 holds the UV buffer.
What is and isnβt involved
Each row is the same scene under WebGPU with one variable changed.
| Change | Result |
| β | β |
| Baseline, four UV sets | broken |
| Add merge structure to the pipeline cache key | correct |
| Give each UV set its own buffer | correct |
| Drop `uv3` and `uv4` (rebuilds the buffers) | correct |
| Rewrite `matricesIndices` as `FLOAT` | still broken |
| `skeleton.useTextureToStoreBoneMatrices = false` | still broken |
The two intuitive suspects are both disproven: the `USHORT` β `uint16x4` bone-index format is handled correctly by the non-float attribute path (the generated WGSL declares `_int_matricesIndices_ : vec4` and casts, which is right), and the 101-bone matrix texture is not implicated.
Nothing here is specific to skinning β any attribute can land on the wrong slot. Skinning is just where the corruption becomes unmistakable.
Suggested fix
Include the merge structure in the cache key. Keying the *merge boundary* β whether each attribute started a new vertex-buffer slot or continued the previous one β covers both missing inputs (offset and buffer identity) at no extra cost, since `_setVertexState` already computes that decision:
const startsNewSlot = !(
currentGPUBuffer &&
currentGPUBuffer === buffer &&
vertexBuffer.\_validOffsetRange
);
if (startsNewSlot) {
this.vertexBuffers\[numVertexBuffers++\] = vertexBuffer;
currentGPUBuffer = vertexBuffer.\_validOffsetRange ? buffer : null;
}
const vid =
vertexBuffer.hashCode +
(location << 7) +
(startsNewSlot ? 0 : MERGE_CONTINUATION_BIT); // 1 << 26
Bit 26 is free in practice: `byteStride << 12` reaches bit ~23 at the maximum `maxVertexBufferArrayStride` of 2048.
One narrower case remains unkeyed by this: attributes merged into a single layout keep their individual `effectiveByteOffset` in the `GPUVertexBufferLayout`, so two draws with identical merge boundaries but different within-group offsets (an interleaved layout) can still collide. Keying that needs the offsets themselves, which no longer fits the bit-packed state value.
An alternative β forcing `_validOffsetRange` false so merging never happens β also makes layouts self-consistent, but it costs a vertex-buffer slot per attribute, and meshes with many attributes plus instancing can then exceed `maxVertexBuffers` (8).
Worth noting for anyone patching locally: `_setVertexState`'s slot accounting and `_getVertexInputDescriptor`'s layout construction must stay in lockstep, including the `currentGPUBuffer && currentGPUBuffer === buffer` truthiness guard. Any divergence between the two reintroduces the same class of mismatch.
Verification
Per-pixel comparison of each WebGPU render against the WebGL2 frame of the same scene, real Metal adapter. Deltas are per-channel out of 255; βdifferingβ counts pixels off by more than 8.
| Comparison | Mean Ξ | Pixels differing | Max Ξ |
| β | β | β | β |
| Bind pose β with the fix | 0.052 | 0.19% | 51 |
| Bind pose β without the fix | 155.228 | 99.62% | 216 |
| Animating β with the fix | 0.079 | 0.24% | 67 |
| Animating β without the fix | 172.933 | 98.97% | 217 |
With the fix, WebGPU lands within antialiasing noise of WebGL2 β the residual fraction of a percent is edge pixels where the two rasterizers legitimately disagree. Without it, virtually every pixel is wrong. Verified in bind pose and through animation playback.