Thanks — that data settles part of it.
Babylon isn’t truncating. uintIndicesCap: true + gpuIndexType: "Uint32" means we detected 32-bit support and handed the GPU a correct Uint32Array of 3,310,371 indices, so my first reply’s theory is dead. (And renderer: "Apple GPU" is just Safari masking the real GPU string for fingerprinting — it reports that on every Mac and doesn’t mean your Radeon is being ignored.)
The 65,536 boundary is real, though. I rendered your model with only the triangles whose three indices are all below 65,536 (~11% of them) and got exactly the coherent fragments in your screenshot — hood top, the band across the knees, the shoes, the speckled patch on the ground. Everything above 65,535 is the spiky noise. So vertex fetch looks correct below 65,536 and wrong at or above it, which points at the WebGL/Metal path rather than anything we control.
But it worked three months ago
I glossed over that, and it’s the important part — driver bugs don’t usually appear on their own. Two things changed in that window and I can’t yet tell them apart:
- Babylon — your page loads the viewer unpinned, so it auto-updates on every reload. Early May ≈ 9.6, today ≈ 9.21, with a major bump (9.0) in between.
- Safari/macOS — now 26.5.
I’ve been saying “Safari regression” without ruling out the first, which isn’t fair. Pinning separates them.
Three things that would help, cheapest first
1. Pin an old version. In your test page, change @babylonjs/viewer to @babylonjs/viewer@9.6.0:
<script type="module" src="https://cdn.jsdelivr.net/npm/@babylonjs/viewer@9.6.0/dist/babylon-viewer.esm.min.js"></script>
9.6.0 shipped 7 May, roughly when it last worked for you. Hard-refresh afterwards. Still broken → it’s Safari/macOS, and that’s the answer. Renders fine → we regressed and it’s on us — in which case @8.56.2, the last pre-9.0 release, is a useful second data point. This is the test that decides it, so it’s worth doing before the other two.
2. Try WebGPU. Safari 26 has it on by default and it skips the WebGL/Metal path entirely:
<babylon-viewer engine="WebGPU" source="model.glb" environment="auto" tone-mapping="neutral" camera-auto-orbit></babylon-viewer>
If that renders correctly it’s a usable workaround today, wherever the bug turns out to live.
3. Run this test page. ~120 lines of plain WebGL2 with no Babylon involved, so the result is purely a Safari one. Save it as test.html, open it in Safari, and paste back the JSON at the bottom.
It draws 600,000 vertices that each carry their own index as an attribute, and the shader checks that value against gl_VertexID. Green = correct, red = wrong, horizontal axis = index 0 to 599,999. Healthy hardware is solid green. If I’m right, yours turns red ~11% across with firstBadIndexApprox near 65,536 — but all-green is just as useful, since it would mean the problem is in the buffer upload rather than the fetch.
test.html — click to expand
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGL2 large-index vertex fetch test</title>
<style>
body { font: 13px/1.5 -apple-system, system-ui, sans-serif; margin: 24px; max-width: 1080px; }
canvas { display: block; width: 1024px; height: 120px; border: 1px solid #999; image-rendering: pixelated; margin: 8px 0 16px; }
pre { background: #f4f4f4; padding: 12px; white-space: pre-wrap; }
h3 { margin: 20px 0 4px; font-size: 14px; }
</style>
</head>
<body>
<h2>WebGL2 vertex fetch test for indices above 65,535</h2>
<p>Green = the GPU returned the correct vertex attribute for that index. Red = it returned something else.
The horizontal axis is the vertex index, from 0 on the left to N-1 on the right.</p>
<h3>Test A — 32-bit (UNSIGNED_INT) indices, N = 600,000</h3>
<canvas id="cA" width="1024" height="120"></canvas>
<h3>Test B — control: 16-bit (UNSIGNED_SHORT) indices, N = 65,536</h3>
<canvas id="cB" width="1024" height="120"></canvas>
<h3>Test C — 32-bit indices drawn as TRIANGLES, N = 600,000</h3>
<canvas id="cC" width="1024" height="120"></canvas>
<pre id="out">running...</pre>
<script>
const VS = `#version 300 es
precision highp float;
in vec3 a_id;
uniform float u_count;
uniform float u_tri;
out float v_bad;
void main() {
// a_id was written as vec3(i, i, i). If vertex fetch is correct it must equal gl_VertexID.
int id = gl_VertexID;
bool ok = (int(a_id.x + 0.5) == id) && (a_id.y == a_id.x) && (a_id.z == a_id.x);
v_bad = ok ? 0.0 : 1.0;
float t = float(id) / max(u_count - 1.0, 1.0);
// Scatter vertically so each column gets many samples; horizontal position encodes the index.
float h = fract(float(id) * 0.6180339887);
// In triangle mode, fan the 3 corners apart horizontally so the triangle has real area.
float sub = float(id - (id / 3) * 3) - 1.0;
float x = t * 2.0 - 1.0 + u_tri * sub * (2.0 / 1024.0) * 2.0;
gl_Position = vec4(x, h * 2.0 - 1.0, 0.0, 1.0);
gl_PointSize = 2.0;
}`;
const FS = `#version 300 es
precision highp float;
in float v_bad;
out vec4 o;
void main() { o = (v_bad > 0.001) ? vec4(1.0, 0.0, 0.0, 1.0) : vec4(0.0, 1.0, 0.0, 1.0); }`;
function compile(gl, type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(s)); }
return s;
}
function runTest(canvas, count, use32, triangles) {
const gl = canvas.getContext("webgl2", { antialias: false, preserveDrawingBuffer: true });
if (!gl) { throw new Error("WebGL2 unavailable"); }
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, VS));
gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { throw new Error(gl.getProgramInfoLog(prog)); }
gl.useProgram(prog);
// Vertex attribute: vec3(i,i,i) -> 12 bytes per vertex, same shape as a POSITION stream.
const data = new Float32Array(count * 3);
for (let i = 0; i < count; i++) { data[i * 3] = i; data[i * 3 + 1] = i; data[i * 3 + 2] = i; }
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "a_id");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 3, gl.FLOAT, false, 0, 0);
const drawCount = triangles ? count - (count % 3) : count;
const indices = use32 ? new Uint32Array(drawCount) : new Uint16Array(drawCount);
for (let i = 0; i < drawCount; i++) { indices[i] = i; }
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
gl.uniform1f(gl.getUniformLocation(prog, "u_count"), count);
// Triangles from 3 consecutive indices are slivers; fan them apart so they rasterize.
gl.uniform1f(gl.getUniformLocation(prog, "u_tri"), triangles ? 1.0 : 0.0);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// MAX blending so a single bad vertex in a column shows up as red.
gl.enable(gl.BLEND);
gl.blendEquation(gl.MAX);
gl.drawElements(triangles ? gl.TRIANGLES : gl.POINTS, drawCount, use32 ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT, 0);
const px = new Uint8Array(canvas.width * canvas.height * 4);
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, px);
let firstBadCol = -1, badCols = 0, litCols = 0;
for (let x = 0; x < canvas.width; x++) {
let red = false, lit = false;
for (let y = 0; y < canvas.height; y++) {
const o = (y * canvas.width + x) * 4;
if (px[o] > 128) { red = true; }
if (px[o] > 128 || px[o + 1] > 128) { lit = true; }
}
if (lit) { litCols++; }
if (red) { badCols++; if (firstBadCol < 0) { firstBadCol = x; } }
}
return {
firstBadIndexApprox: firstBadCol < 0 ? null : Math.round((firstBadCol / (canvas.width - 1)) * (count - 1)),
badColumns: badCols,
litColumns: litCols,
glError: gl.getError(),
};
}
(function main() {
const out = document.getElementById("out");
const report = {};
try {
const gl = document.createElement("canvas").getContext("webgl2");
const dbg = gl && gl.getExtension("WEBGL_debug_renderer_info");
report.vendor = gl ? (dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR)) : "n/a";
report.renderer = gl ? (dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : "n/a";
report.version = gl ? gl.getParameter(gl.VERSION) : "n/a";
report.maxTextureSize = gl ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : "n/a";
report.userAgent = navigator.userAgent;
report.testA_uint32_600k_points = runTest(document.getElementById("cA"), 600000, true, false);
report.testB_uint16_65536_points = runTest(document.getElementById("cB"), 65536, false, false);
report.testC_uint32_600k_triangles = runTest(document.getElementById("cC"), 600000, true, true);
} catch (e) {
report.error = String(e);
}
out.textContent = JSON.stringify(report, null, 2);
console.log(JSON.stringify(report, null, 2));
})();
</script>
</body>
</html>
Once I know which side of the line this falls on, I’ll either open an issue on our end or file it at bugs.webkit.org with a minimal repro.