ifcviewer-web: never hand setBindGroup the whole wasm heap as dynamic offsets

Emscripten's generated WebGPU shim implements the dynamic-offset path of
wgpuRenderPassEncoderSetBindGroup as

    pass.setBindGroup(index, group, HEAPU32, ptr >>> 2, count);

where HEAPU32 is the view over the entire wasm linear memory. Browsers
validate the byte length of that whole backing buffer, not the slice
actually read, and refuse anything over 2 GB. This build lets the heap
grow to 4 GB because large federations need it, so on a big enough
session (66 models) every dynamic-offset draw — the axis gizmo, section
gizmo and overlay lines, all drawn every frame — throws

    TypeError: GPURenderPassEncoder.setBindGroup: Argument 3 can't be an
    ArrayBuffer or an ArrayBufferView larger than 2 GB

on every frame for the life of the page.

ifcviewer::setBindGroupDynamic copies the handful of offsets into a
small Uint32Array on web (HEAPU32.slice, not subarray, which would alias
the heap again) and forwards straight through natively. The five
dynamic-offset call sites route through it. A Playwright spec spies on
setBindGroup and asserts the largest buffer it is ever handed is the
offsets themselves (4 bytes), where the shim previously passed the full
268 MB heap 35 times in three seconds of idle rendering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-23 22:20:14 +10:00
parent b7f1f1728f
commit 06d87e21a7
7 changed files with 226 additions and 5 deletions
@@ -0,0 +1,91 @@
// Regression guard for "GPURenderPassEncoder.setBindGroup: Argument 3 can't be
// an ArrayBuffer or an ArrayBufferView larger than 2 GB".
//
// Emscripten's generated WebGPU shim implements the dynamic-offset path of
// wgpuRenderPassEncoderSetBindGroup as
//
// pass.setBindGroup(index, group, HEAPU32, ptr >>> 2, count);
//
// handing WebGPU the persistent view over the *entire* wasm linear memory.
// Browsers validate the byte length of that whole backing buffer rather than
// the (start, length) slice actually read, and reject anything past 2 GB. This
// build allows the heap to grow to 4 GB (ALLOW_MEMORY_GROWTH +
// MAXIMUM_MEMORY=4294967296, because large federations need the room), so on a
// big enough session every dynamic-offset draw throws on every frame for the
// life of the page. The axis gizmo, section gizmo and overlay lines all draw
// with dynamic offsets every frame, so the viewport dies as soon as the heap
// crosses the line. ifcviewer::setBindGroupDynamic (WgpuDynamicOffsets.h)
// copies the handful of offsets into a small Uint32Array instead.
//
// Rather than allocate 2 GB to reproduce, this asserts the invariant that
// actually matters and holds at any heap size: nothing we hand to
// setBindGroup may alias the wasm heap. Run against a build without the fix
// and it fails on the first frame — the observed buffer is the whole heap.
import { test, expect } from '@playwright/test';
// Comfortably above the 4 bytes a single dynamic offset needs, and ~5 orders
// of magnitude below INITIAL_MEMORY (256 MB), so this cannot pass by accident.
const SANE_MAX_BYTES = 4096;
test('setBindGroup is never handed the wasm heap as dynamic offsets', async ({ page }) => {
// Must be installed before the module boots so no frame is missed.
await page.addInitScript(() => {
const probe = { dynamicCalls: 0, maxBufferBytes: 0, samples: [] };
window.__bindGroupProbe = probe;
const proto = GPURenderPassEncoder.prototype;
const original = proto.setBindGroup;
proto.setBindGroup = function (index, group, data, ...rest) {
if (ArrayBuffer.isView(data)) {
probe.dynamicCalls++;
const bytes = data.buffer.byteLength;
if (bytes > probe.maxBufferBytes) probe.maxBufferBytes = bytes;
if (probe.samples.length < 5) {
probe.samples.push({ bytes, elements: data.length, ctor: data.constructor.name });
}
}
return original.call(this, index, group, data, ...rest);
};
});
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/IfcViewerWeb.html');
await page.waitForFunction(
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
await page.waitForTimeout(1200);
// The corner gizmo draws every frame on its own; an orbit drag additionally
// brings up the pivot triad, which is the other pair of axis call sites.
const box = await page.locator('#viewer-canvas').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 90, box.y + box.height / 2 + 30, { steps: 8 });
await page.waitForTimeout(300);
await page.mouse.up();
await page.waitForTimeout(300);
const result = await page.evaluate(() => ({
...window.__bindGroupProbe,
heapBytes: window.Module.HEAPU32.buffer.byteLength,
}));
console.log('BINDGROUP ' + JSON.stringify(result));
// Without this the assertion below would pass vacuously on a build where
// nothing draws with dynamic offsets at all.
expect(
result.dynamicCalls,
'no dynamic-offset setBindGroup calls were observed — the gizmos did not draw, ' +
'so this test proved nothing',
).toBeGreaterThan(0);
expect(
result.maxBufferBytes,
`setBindGroup received a ${result.maxBufferBytes}-byte backing buffer; the wasm heap ` +
`is ${result.heapBytes} bytes. A match means the whole-heap HEAPU32 view is being ` +
`passed straight through, which throws once the heap passes 2 GB. ` +
`Samples: ${JSON.stringify(result.samples)}`,
).toBeLessThanOrEqual(SANE_MAX_BYTES);
expect(errors, `page errors during the run: ${errors.join(' | ')}`).toHaveLength(0);
});
+4 -3
View File
@@ -18,6 +18,7 @@
********************************************************************************/
#include "AxisIndicatorRenderer.h"
#include "WgpuDynamicOffsets.h"
#include <algorithm>
#include <cmath>
@@ -341,10 +342,10 @@ void AxisIndicatorRenderer::encodePivot(WGPURenderPassEncoder pass,
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetPipeline(pass, pivot_xray_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &xray_off);
ifcviewer::setBindGroupDynamic(pass, 0, bind_group_, 1, &xray_off);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
wgpuRenderPassEncoderSetPipeline(pass, pivot_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &visible_off);
ifcviewer::setBindGroupDynamic(pass, 0, bind_group_, 1, &visible_off);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
}
@@ -404,7 +405,7 @@ void AxisIndicatorRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
0.0f, 1.0f);
wgpuRenderPassEncoderSetPipeline(pass, corner_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &slot_offset);
ifcviewer::setBindGroupDynamic(pass, 0, bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
+1
View File
@@ -154,6 +154,7 @@ set(IFCVIEWER_CORE_SOURCES
StreamingThread.cpp
SectionGizmoRenderer.cpp
ViewportCore.cpp
WgpuDynamicOffsets.cpp
)
# Web needs a zstd DECODER (Emscripten has no zstd port; the desktop links the
# full libzstd below). Rather than vendor a generated blob, fetch the pinned
+2 -1
View File
@@ -18,6 +18,7 @@
********************************************************************************/
#include "OverlayRenderer.h"
#include "WgpuDynamicOffsets.h"
#include <QFont>
#include <QFontMetrics>
@@ -886,7 +887,7 @@ void OverlayRenderer::encodeOverlayLines(WGPURenderPassEncoder pass,
wgpuQueueWriteBuffer(queue_, overlay_line_uniform_buffer_,
slot_off + 96, viewport, sizeof(viewport));
const uint32_t dynamic_offsets[1] = { uint32_t(slot_off) };
wgpuRenderPassEncoderSetBindGroup(pass, 0, overlay_line_bind_group_,
ifcviewer::setBindGroupDynamic(pass, 0, overlay_line_bind_group_,
1, dynamic_offsets);
wgpuRenderPassEncoderDraw(pass, d.vertex_count, 1, d.first_vertex, 0);
}
+2 -1
View File
@@ -18,6 +18,7 @@
********************************************************************************/
#include "SectionGizmoRenderer.h"
#include "WgpuDynamicOffsets.h"
#include <algorithm>
#include <array>
@@ -335,7 +336,7 @@ void SectionGizmoRenderer::encode(WGPURenderPassEncoder pass, const Eigen::Matri
bitangent, nn, tr, tg, tb, 1.0f, vw, vh);
const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot));
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &slot_offset);
ifcviewer::setBindGroupDynamic(pass, 0, bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, uint32_t(vertex_count_), 1, 0, 0);
}
}
+65
View File
@@ -0,0 +1,65 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "WgpuDynamicOffsets.h"
#ifdef __EMSCRIPTEN__
#include <emscripten/em_js.h>
#endif
namespace ifcviewer {
#ifdef __EMSCRIPTEN__
namespace {
EM_JS(void, ifcv_set_bind_group_dynamic_js,
(WGPURenderPassEncoder pass, uint32_t group_index, WGPUBindGroup group,
uint32_t offsets_ptr, uint32_t count), {
// HEAPU32.slice() copies into a freshly allocated buffer of exactly
// `count` elements; .subarray() would alias the whole heap again and
// reintroduce the bug this function exists to avoid.
var start = offsets_ptr >>> 2;
var small = HEAPU32.slice(start, start + count);
WebGPU.getJsObject(pass).setBindGroup(
group_index, WebGPU.getJsObject(group), small, 0, count);
});
} // namespace
void setBindGroupDynamic(WGPURenderPassEncoder pass, uint32_t group_index,
WGPUBindGroup group, uint32_t count,
const uint32_t* offsets) {
if (count == 0) {
wgpuRenderPassEncoderSetBindGroup(pass, group_index, group, 0, nullptr);
return;
}
ifcv_set_bind_group_dynamic_js(pass, group_index, group,
uint32_t(reinterpret_cast<uintptr_t>(offsets)), count);
}
#else
void setBindGroupDynamic(WGPURenderPassEncoder pass, uint32_t group_index,
WGPUBindGroup group, uint32_t count,
const uint32_t* offsets) {
wgpuRenderPassEncoderSetBindGroup(pass, group_index, group, count, offsets);
}
#endif
} // namespace ifcviewer
+61
View File
@@ -0,0 +1,61 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUDYNAMICOFFSETS_H
#define WGPUDYNAMICOFFSETS_H
#include <cstdint>
#include <webgpu/webgpu.h>
namespace ifcviewer {
// setBindGroup with dynamic offsets. Always use this instead of calling
// wgpuRenderPassEncoderSetBindGroup with a nonzero offset count.
//
// Emscripten's generated WebGPU shim implements the dynamic-offset path as
//
// pass.setBindGroup(index, group, HEAPU32, ptr >>> 2, count);
//
// where HEAPU32 is the persistent view over the *entire* wasm linear memory.
// Browsers validate the byte length of the whole backing buffer handed to
// setBindGroup, not the (start, length) slice actually read, and reject
// anything over 2 GB:
//
// TypeError: GPURenderPassEncoder.setBindGroup: Argument 3 can't be an
// ArrayBuffer or an ArrayBufferView larger than 2 GB
//
// So once the heap grows past 2^31 bytes every dynamic-offset draw throws, on
// every frame, for the life of the page -- and this build deliberately allows
// that (ALLOW_MEMORY_GROWTH with MAXIMUM_MEMORY=4 GB, because large
// federations need the headroom). The offsets are only a handful of uint32_t,
// so the web implementation copies them into a small short-lived Uint32Array.
// Native builds forward straight through; wgpu-native reads the pointer
// directly and has no such limit.
//
// Defined out-of-line in WgpuDynamicOffsets.cpp: the web path is an EM_JS
// function, and EM_JS emits real per-translation-unit symbols that collide at
// link time if instantiated in more than one TU.
void setBindGroupDynamic(WGPURenderPassEncoder pass, uint32_t group_index,
WGPUBindGroup group, uint32_t count,
const uint32_t* offsets);
} // namespace ifcviewer
#endif