webGPU Model Error (works on webGL2)

We recently upgraded to defaulting to webGPU on clients if its available and noticed one of our legacy models (one we use for baseline testing) was showing vertex distortion on a part of its model. So I loaded it up into the sandbox to see what was going on.

It works perfect in webGL2

but when you toggle over to webGPU mode we get the exact same results as we get in Frame.

Not sure if anyone else has been seeing similar things or not!

Any insight would be welcome.

We are assuming some vertex buffer remapping/naming is going wrong.

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

Is the file^

I did a bit of spelunking and came up with a fix. The discussion and conclusions are linked in the following url: Claude Artifact

Here is the patch:

import type { Effect } from "@babylonjs/core/Materials/effect";



const MERGE_CONTINUATION_BIT = 1 << 26;

const VERTEX_STATE_POSITION = 13;



interface PatchableVertexBuffer {

\_validOffsetRange?: boolean;

effectiveBuffer?: { underlyingResource: unknown } | null;

effectiveByteOffset: number;

effectiveByteStride: number;

getSize: (sizeInBytes?: boolean) => number;

hashCode: number;

}



interface PatchableCache {

\_emptyVertexBuffer: PatchableVertexBuffer;

\_isDirty: boolean;

\_kMaxVertexBufferStride: number;

\_overrideVertexBuffers?: Record<string, PatchableVertexBuffer> | null;

\_stateDirtyLowestIndex: number;

\_states: number\[\];

\_statesLength: number;

\_vertexBuffers: Record<string, PatchableVertexBuffer>;

vertexBuffers: PatchableVertexBuffer\[\];

}



let installation: Promise<void> | null = null;



const patchVertexStateCacheKey = async (): Promise<void> => {

const { WebGPUCacheRenderPipeline } =

await import("@babylonjs/core/Engines/WebGPU/webgpuCacheRenderPipeline");

const prototype = WebGPUCacheRenderPipeline.prototype as unknown as Record<

string,

unknown

  >;



if (typeof prototype.\_setVertexState !== "function") {

    console.error(

"\[webgpuVertexBufferCacheFix\] WebGPUCacheRenderPipeline.prototype.\_setVertexState is absent β€” this Babylon version needs the patch re-derived, and WebGPU pipeline-cache collisions are live (KD-c315990e).",

    );

return;

  }



  prototype.\_setVertexState = function (

this: PatchableCache,

effect: Effect,

  ): void {

const currStateLen = this.\_statesLength;

let newNumStates = VERTEX_STATE_POSITION;

const shaderProcessingContext = (

      effect as unknown as {

\_pipelineContext: {

shaderProcessingContext: {

attributeLocationsFromEffect: number\[\];

attributeNamesFromEffect: string\[\];

          };

        };

      }

    ).\_pipelineContext.shaderProcessingContext;

const attributes = shaderProcessingContext.attributeNamesFromEffect;

const locations = shaderProcessingContext.attributeLocationsFromEffect;

let currentGPUBuffer: unknown;

let numVertexBuffers = 0;



for (let index = 0; index < attributes.length; index++) {

const location = locations\[index\];

let vertexBuffer =

this.\_overrideVertexBuffers?.\[attributes\[index\]\] ??

this.\_vertexBuffers\[attributes\[index\]\];

if (!vertexBuffer) {

        vertexBuffer = this.\_emptyVertexBuffer;

      }

const buffer = vertexBuffer.effectiveBuffer?.underlyingResource;

if (vertexBuffer.\_validOffsetRange === undefined) {

const byteStride = vertexBuffer.effectiveByteStride;

const formatSize = vertexBuffer.getSize(true);

const offset = vertexBuffer.effectiveByteOffset;

        vertexBuffer.\_validOffsetRange =

          (offset + formatSize <= this.\_kMaxVertexBufferStride &&

            byteStride === 0) ||

          (byteStride !== 0 && offset + formatSize <= byteStride);

      }

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);

this.\_isDirty = this.\_isDirty || this.\_states\[newNumStates\] !== vid;

this.\_states\[newNumStates++\] = vid;

    }



this.vertexBuffers.length = numVertexBuffers;

this.\_statesLength = newNumStates;

this.\_isDirty = this.\_isDirty || newNumStates !== currStateLen;

if (this.\_isDirty) {

this.\_stateDirtyLowestIndex = Math.min(

this.\_stateDirtyLowestIndex,

VERTEX_STATE_POSITION,

      );

    }

  };

};



/\*\* Adds vertex-buffer merge structure to Babylon's WebGPU render-pipeline cache key (KD-c315990e). \*/

export const installWebGPUVertexBufferCacheFix = (): Promise<void> => {

  installation ??= patchVertexStateCacheKey().catch((error: unknown) => {

    installation = null;

throw error;

  });

return installation;

};

I think you gotta make the claude link public.

Also we tested this in Frame and it worked so the BJS team can probably use this info for sure.

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.

Let s add @Evgeni_Popov to see if he can have a quick look

Thanks for the detailed investigation and suggested solution! I created a fix:

The pipeline cache key now includes both vertex-buffer merge boundaries and effective attribute offsets, covering the reported case and interleaved layouts. I tested the provided model locally with WebGPU, and it now renders correctly.