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