Babylon Viewer HTML element broken in Safari (Intel)

Hello!

I am using the Babylon Viewer HTML element to show a model (glb). I am running macOS 26.5.2 (25F84) on an Intel Mac. Everything works well on Apple Silicon. On Intel it is a different story: It works well in Chrome, Firefox and Opera, but is completely broken in Safari (26.5). Here is a test project which only contains the viewer element: Babylon Test

This is how it looks like correctly:

This is how it looks like in Safari macOS:

There are no errors in the console. The same happens in the sandbox.

Three months ago there wasn’t any problem with Safari – so there is either a regression in Babylon or in Safari I guess? Are there any features I should enable/disable for Safari? Or are Intel Macs simply not supported anymore? ATM I am at a loss.

Thanks!

cc @ryantrem

This one may take some time as on one on the team has an Intel based Mac! :eyes:

Thanks for the clear report and for putting up a test page — that made this much easier to dig into.

I pulled your model.glb. It has 574,189 vertices and uses 32-bit (UNSIGNED_INT) indices. Babylon needs the uintIndices capability to render that — it comes from WebGL2, or from WebGL1 via the OES_element_index_uint extension. If neither is available, we silently truncate the index buffer to 16-bit, and every index above 65,535 wraps around and points at an unrelated vertex.

I decoded your index buffer to see how much of the model that would affect: 978,533 of 1,103,457 triangles (88.7%) reference at least one vertex above 65,535. So if this is what’s happening, ~89% of the mesh gets rewired into garbage triangles stretching between unrelated parts of the model, leaving a small fraction recognizable — which is what your screenshot looks like to me.

That would also fit the rest of the symptoms: no console errors (the truncation is silent), fine in Chrome/Firefox/Opera on the same machine, and fine on Apple Silicon.

So my working theory is that Safari 26.5 on Intel isn’t giving us a usable WebGL2 context, and we’re falling back to WebGL1.

Two things would confirm it:

1. Does a smaller model (under ~65k vertices) render correctly on the same page?

2. Paste this into the Safari console on your test page, once the model has finished loading:

(async () => {
  const out = {}, mk = t => { try { return document.createElement('canvas').getContext(t); } catch { return null; } };
  const gl2 = mk('webgl2'), gl1 = mk('webgl');
  out.webgl2Available = !!gl2;
  out.webgl2IsReal = !!(gl2 && gl2.deleteQuery);
  out.webgl1Available = !!gl1;
  out.OES_element_index_uint = !!(gl1 && gl1.getExtension('OES_element_index_uint'));

  const gl = gl2 || gl1;
  if (gl) {
    const dbg = gl.getExtension('WEBGL_debug_renderer_info');
    out.vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
    out.renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
    out.glVersion = gl.getParameter(gl.VERSION);
  }

  const d = document.querySelector('babylon-viewer')?.viewerDetails;
  if (!d) {
    out.viewer = 'not ready — rerun once the model has loaded';
  } else {
    const e = d.scene.getEngine();
    out.babylonEngine = e.constructor.name;
    out.babylonGLVersion = e.webGLVersion;
    out.uintIndicesCap = e.getCaps().uintIndices;
    out.meshes = d.scene.meshes.filter(m => m.getTotalVertices() > 0).map(m => ({
      name: m.name,
      verts: m.getTotalVertices(),
      indices: m.getTotalIndices(),
      gpuIndexType: (() => { try { return m.geometry?.getIndexBuffer()?.is32Bits ? 'Uint32' : 'Uint16'; } catch { return '?'; } })()
    }));
  }
  console.log(JSON.stringify(out, null, 2));
})();

The value I care about most is uintIndicesCap — if that comes back false, this is confirmed.

A couple of other things worth checking while you’re in there:

  • Safari → Settings → Advanced → Feature Flags — is WebGL 2.0 enabled?
  • Which GPU does the machine have (Intel Iris/UHD, or a discrete AMD)? And which Mac model?

One note on “it worked three months ago”: your page loads @babylonjs/viewer from jsDelivr unpinned, so it auto-updates. If you pin an older version and it still breaks, that points at Safari rather than at Babylon.

Separately, and regardless of what turns out to be happening on your machine — Babylon dropping index data silently here isn’t great. I’ll look into getting a warning logged for that case.

Quick update — I simulated this locally against your model, and the theory only half holds up.

Confirmed: the parts that still look right in your render are exactly the triangles whose indices are all under 65,536. I isolated just those and got the hood top, the knees, and the shoes with the speckled ground patch — precisely the pieces that survive in your screenshot. So the 65,536 boundary is definitely involved.

But the broken part doesn’t match. If Babylon truncates the indices, every triangle still points at one of your model’s own vertices, so the mess stays inside the model’s silhouette — I get a dense, narrow spike-cone. Your spikes spread about twice as wide as the model itself. I projected all 574,189 vertices through the same camera from 24 different angles: the widest they can ever appear is about 0.32 of the frame height, and your artifact spans about 0.63. Index wrapping can’t produce that.

So this looks less like Babylon truncating indices, and more like the GPU handing back bad vertex data above index 65,535 — the kind of thing you see when a driver can’t cope with meshes over 65,536 vertices. That fits the rest of the picture too: Chrome and Firefox go through ANGLE, which works around this, and it’s Intel-specific.

Practically, that means uintIndicesCap will probably come back true. That’s still a useful answer rather than a dead end — it’s exactly what separates the two cases — so the snippet is still worth running, along with the small-model test (something under 65k vertices).

Hi, thank you for investigating!

Here are my answers:

  • A smaller model renders fine.
  • My GPU: AMD Radeon Pro 5500M 8 GB
  • My Mac: Macbook Pro, 2,4 GHz 8-Core Intel Core i9, 32 GB 2667 MHz DDR4

The result from your code:

{
"webgl2Available": true,
"webgl2IsReal": true,
"webgl1Available": true,
"OES_element_index_uint": true,
"vendor": "Apple Inc.",
"renderer": "Apple GPU",
"glVersion": "WebGL 2.0",
"babylonEngine": "cl",
"babylonGLVersion": 2,
"uintIndicesCap": true,
"meshes": [
{
"name": "hdrSkyBox",
"verts": 24,
"indices": 36,
"gpuIndexType": "Uint16"
},
{
"name": "3DModel",
"verts": 574189,
"indices": 3310371,
"gpuIndexType": "Uint32"
}
]
}

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 &mdash; 32-bit (UNSIGNED_INT) indices, N = 600,000</h3>
<canvas id="cA" width="1024" height="120"></canvas>

<h3>Test B &mdash; control: 16-bit (UNSIGNED_SHORT) indices, N = 65,536</h3>
<canvas id="cB" width="1024" height="120"></canvas>

<h3>Test C &mdash; 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.

Hi, here is the result from the test page:

{
  "vendor": "Apple Inc.",
  "renderer": "Apple GPU",
  "version": "WebGL 2.0",
  "maxTextureSize": 16384,
  "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5.2 Safari/605.1.15",
  "testA_uint32_600k_points": {
    "firstBadIndexApprox": 86803,
    "badColumns": 300,
    "litColumns": 448,
    "glError": 0
  },
  "testB_uint16_65536_points": {
    "firstBadIndexApprox": null,
    "badColumns": 0,
    "litColumns": 1024,
    "glError": 0
  },
  "testC_uint32_600k_triangles": {
    "firstBadIndexApprox": 0,
    "badColumns": 449,
    "litColumns": 449,
    "glError": 0
  }
}

That’s the answer, and it isn’t us. There’s no Babylon anywhere in that test page — it’s ~120 lines of raw WebGL2 — so what it shows is Safari’s behaviour on its own. You can skip the version-pin test.

It’s not 65,536, it’s 1 MiB

Your Test B is solid green, and both of its buffers are under 1 MB. Test A has two separate failure points, and they’re the interesting part:

  • Vertex data goes wrong at vertex 87,381. Each vertex is 12 bytes → 1,048,576 bytes.
  • Nothing is drawn past vertex 262,144, meaning the index buffer stopped being honoured there. Each index is 4 bytes → 1,048,576 bytes.

Two different buffers, two different element sizes, same byte offset: exactly 1 MiB. Nothing else fits — a 1,000,000-byte boundary would have put those transitions 6 and 21 columns to the left of where your red and black regions actually start.

So bufferData uploads larger than 1 MiB only transfer the first 1 MiB; the rest never reaches the GPU. The 65,536 thing was a red herring on my part — the real threshold is buffer size, and ~65k vertices is simply where a typical mesh crosses 1 MB.

Why your Mac, and why now

On Apple Silicon, Metal buffers sit in memory the CPU and GPU both read, so an upload is essentially a memcpy into memory the GPU already sees. On an Intel Mac with a discrete Radeon they’re managed, and WebKit has to explicitly copy each modified range across to VRAM. That copy path is the one that’s capped. It fits everything: Intel only, large buffers only, your smaller model fine, and it appearing after a Safari update rather than after anything you changed.

Next

Filed as WebKit bug 322040, with a reduced test case attached that reports the exact byte offset where each upload stops surviving.

One last thing, if you don’t mind — does engine="WebGPU" render correctly?

<babylon-viewer engine="WebGPU" source="model.glb" environment="auto" tone-mapping="neutral" camera-auto-orbit></babylon-viewer>

It’s an entirely different backend, so it tells us whether the bug is confined to the WebGL path — and if it renders, it’s something you can ship today.

Yes, WebGPU does render correctly, BUT only when not animating. I have set camera-auto-orbit to true, and the model is not displayed when rotating, only when I click the viewer. Also when I rotate the model manually, it is not rendered. But I guess that is a different issue. When the model is still, it is rendered perfectly

On the WebGPU problem (model renders when still, disappears while the camera moves): I haven’t been able to reproduce it anywhere I have access to. It’s fine on Windows/Chrome and on an Apple Silicon Mac, and cloud Mac services can’t fill the gap — Safari only exposes WebGPU on macOS Tahoe, every Tahoe machine I could get was Apple Silicon, and their Intel Macs are old enough that WebGPU doesn’t exist there at all.

So your machine is realistically the only way to diagnose this. Three things, in priority order.

1. Which GPU is Safari actually using?

Your MacBook Pro 16" has both integrated (UHD 630) and discrete (Radeon Pro 5500M) graphics, and which one WebGPU picks matters a lot here. Load your test page, then paste this into the Console and share the output:

(async () => {
  const d = document.querySelector('babylon-viewer').viewerDetails;
  const eng = d.scene.getEngine();
  const ai = async p => {
    if (!navigator.gpu) return 'WebGPU unavailable';
    const a = await navigator.gpu.requestAdapter(p ? { powerPreference: p } : undefined);
    if (!a) return 'no adapter';
    const i = a.info || (a.requestAdapterInfo ? await a.requestAdapterInfo() : null);
    return i ? [i.vendor, i.architecture, i.device, i.description].join(' | ') : 'no info';
  };
  console.log(JSON.stringify({
    engine: eng.isWebGPU ? 'WebGPU' : 'WebGL',
    description: eng.description,
    snapshotRendering: eng.snapshotRendering,
    snapshotRenderingMode: eng.snapshotRenderingMode,
    adapterDefault: await ai(null),
    adapterLowPower: await ai('low-power'),
    adapterHighPerf: await ai('high-performance')
  }, null, 2));
})();

2. Does turning off snapshot rendering fix it? (most useful)

On WebGPU the Viewer enables “snapshot rendering”, which records GPU render bundles and replays them each frame. That’s my main suspect, because the Viewer turns it on while the camera is moving and off when things go idle — which would explain why the model reappears the moment you click or stop moving.

With the model loaded and orbiting, paste this:

const v = document.querySelector('babylon-viewer').viewerDetails.viewer;
v._snapshotHelper.enableSnapshotRendering = function () {};
v._snapshotHelper.disableSnapshotRendering();

Then let it orbit for ~10 seconds.

  • Model stays visible → it’s snapshot rendering / render bundles, and I can fix that on the Babylon side
  • Model still disappears → it’s something lower-level in Safari’s WebGPU, and this becomes a second WebKit bug

3. Is it size-dependent?

If you have a smaller model handy (a few MB), does that one also vanish while orbiting? If small models are fine and only the large one breaks, that points at a big-upload bug — which would suggest the WebGL and WebGPU issues share a root cause, and that’s worth knowing.

No rush on any of it. #2 is the one that would help most.