From 06d87e21a75aa0ffcf0ac3174ee81452aaf65ac0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 23 Aug 2026 22:20:14 +1000 Subject: [PATCH] ifcviewer-web: never hand setBindGroup the whole wasm heap as dynamic offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/bindgroup-heap.spec.mjs | 91 +++++++++++++++++++ src/ifcviewer/AxisIndicatorRenderer.cpp | 7 +- src/ifcviewer/CMakeLists.txt | 1 + src/ifcviewer/OverlayRenderer.cpp | 3 +- src/ifcviewer/SectionGizmoRenderer.cpp | 3 +- src/ifcviewer/WgpuDynamicOffsets.cpp | 65 +++++++++++++ src/ifcviewer/WgpuDynamicOffsets.h | 61 +++++++++++++ 7 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/ifcviewer-web/tests/bindgroup-heap.spec.mjs create mode 100644 src/ifcviewer/WgpuDynamicOffsets.cpp create mode 100644 src/ifcviewer/WgpuDynamicOffsets.h diff --git a/src/ifcviewer-web/tests/bindgroup-heap.spec.mjs b/src/ifcviewer-web/tests/bindgroup-heap.spec.mjs new file mode 100644 index 0000000000..b5cacebe3f --- /dev/null +++ b/src/ifcviewer-web/tests/bindgroup-heap.spec.mjs @@ -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); +}); diff --git a/src/ifcviewer/AxisIndicatorRenderer.cpp b/src/ifcviewer/AxisIndicatorRenderer.cpp index c56eb068e2..69238a5692 100644 --- a/src/ifcviewer/AxisIndicatorRenderer.cpp +++ b/src/ifcviewer/AxisIndicatorRenderer.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "AxisIndicatorRenderer.h" +#include "WgpuDynamicOffsets.h" #include #include @@ -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); diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt index 9c7dbf1684..820af7ed30 100644 --- a/src/ifcviewer/CMakeLists.txt +++ b/src/ifcviewer/CMakeLists.txt @@ -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 diff --git a/src/ifcviewer/OverlayRenderer.cpp b/src/ifcviewer/OverlayRenderer.cpp index 674eb51cba..9e201f3048 100644 --- a/src/ifcviewer/OverlayRenderer.cpp +++ b/src/ifcviewer/OverlayRenderer.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "OverlayRenderer.h" +#include "WgpuDynamicOffsets.h" #include #include @@ -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); } diff --git a/src/ifcviewer/SectionGizmoRenderer.cpp b/src/ifcviewer/SectionGizmoRenderer.cpp index c0ef92ab6f..ec85b4b652 100644 --- a/src/ifcviewer/SectionGizmoRenderer.cpp +++ b/src/ifcviewer/SectionGizmoRenderer.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "SectionGizmoRenderer.h" +#include "WgpuDynamicOffsets.h" #include #include @@ -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); } } diff --git a/src/ifcviewer/WgpuDynamicOffsets.cpp b/src/ifcviewer/WgpuDynamicOffsets.cpp new file mode 100644 index 0000000000..05920fc7b6 --- /dev/null +++ b/src/ifcviewer/WgpuDynamicOffsets.cpp @@ -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 . * + * * + ********************************************************************************/ + +#include "WgpuDynamicOffsets.h" + +#ifdef __EMSCRIPTEN__ +#include +#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(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 diff --git a/src/ifcviewer/WgpuDynamicOffsets.h b/src/ifcviewer/WgpuDynamicOffsets.h new file mode 100644 index 0000000000..a338a62331 --- /dev/null +++ b/src/ifcviewer/WgpuDynamicOffsets.h @@ -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 . * + * * + ********************************************************************************/ + +#ifndef WGPUDYNAMICOFFSETS_H +#define WGPUDYNAMICOFFSETS_H + +#include + +#include + +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