ifcviewer-web: wire WebViewportHost + ViewportCore (#87)

Replaces the standalone wgpu-only clear-color spike in main_web.cpp
with a real WebViewportHost implementation: surface creation via the
emdawnwebgpu canvas-selector source, framebufferSize through
emscripten_get_element_css_size + dpr, requestFrame as a deferred
flag the RAF main_loop consumes, quit through emscripten_force_exit.

main_web.cpp now does the same lifecycle the desktop initWgpu shell
does: core_.initWgpu(web_limits=true) → buildPipelines → buildHiz/
Edge/Pick. The render loop runs core_.render() once per RAF tick when
the host has flagged a frame pending, with a surface reconfigure on
size changes.

Builds clean under emcc 6.0 + emdawnwebgpu (1.4 MB wasm, 278 KB JS
glue). Renders an empty scene with the configured background — the
plumbing is end-to-end through the same ViewportCore code path the
desktop build uses. No sidecar load yet: that lands with the
emscripten_fetch streaming backend (#88).
This commit is contained in:
Dion Moult
2026-06-06 21:09:35 +10:00
parent 50014f4842
commit 1fc15e78f2
4 changed files with 213 additions and 157 deletions
+71 -156
View File
@@ -17,181 +17,96 @@
* *
********************************************************************************/
// Phase-B-step-3 scaffold for the web target. Brings up a wgpu instance
// against a #canvas via the emdawnwebgpu port, requests adapter+device
// asynchronously, configures the surface, and renders a clear color on
// each requestAnimationFrame tick. No sidecar load, no pipelines, no
// scene state yet — the goal of this commit is "something renders in a
// browser tab" so the build + canvas + wgpu plumbing is end-to-end
// verified before we wire in IfcViewerCore.
// Web entry point. Wires a WebViewportHost to a ViewportCore, brings up
// wgpu through emdawnwebgpu (the spec-compatible WebGPU header set that
// shipped with Dawn), then drives a render() per requestAnimationFrame
// tick. No sidecar load yet — that lands with the emscripten_fetch
// streaming backend (#88). For this scaffold we render an empty scene
// with the configured background so init + present is verified
// end-to-end through the same ViewportCore code path the desktop build
// uses.
#include "ViewportCore.h"
#include "WebViewportHost.h"
#include "Log.h"
#include <emscripten/emscripten.h>
#include <emscripten/html5.h>
#include <webgpu/webgpu.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace {
// Async-arrived handles. Populated by the requestAdapter/requestDevice
// callback chain in startup(); render() short-circuits until they're
// all set. Keeps the path single-threaded — the JS event loop drives
// progress between callbacks.
WGPUInstance g_instance = nullptr;
WGPUAdapter g_adapter = nullptr;
WGPUDevice g_device = nullptr;
WGPUQueue g_queue = nullptr;
WGPUSurface g_surface = nullptr;
WGPUTextureFormat g_surface_format = WGPUTextureFormat_Undefined;
bool g_main_loop_started = false;
// Shared by the main_loop trampoline + the cleanup path. Allocated on
// the heap so Emscripten's set_main_loop callback (which is C-style)
// can recover state through a void*.
struct AppState {
WebViewportHost host{ "#viewer-canvas" };
ViewportCore core{ &host };
int last_w = 0;
int last_h = 0;
};
// Canvas dimensions in CSS pixels. emscripten reports the canvas size in
// CSS pixels but the GPU surface wants device pixels; we keep things at
// 1× DPR for the scaffold and re-derive on resize once we wire input.
int g_width = 1280;
int g_height = 800;
// The main_loop is a free function (Emscripten signature em_callback_func)
// so we can hand it directly to emscripten_set_main_loop_arg.
void main_loop(void* user) {
auto* app = static_cast<AppState*>(user);
void frame() {
if (!g_surface || !g_device) return;
WGPUSurfaceTexture st = {};
wgpuSurfaceGetCurrentTexture(g_surface, &st);
if (st.status != WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal &&
st.status != WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal) {
// Lost / outdated / OOM / device-lost — log once and bail out
// of this frame so we don't queue work against a torn surface.
static int s_complained = 0;
if (s_complained++ < 4) {
std::fprintf(stderr,
"[viewer-web] surface texture status %d; skipping frame\n",
int(st.status));
}
return;
// Reconfigure when the canvas resizes. The first tick also lands
// here because last_w / last_h start at 0.
int w = 0, h = 0;
app->host.framebufferSize(w, h);
if (w != app->last_w || h != app->last_h) {
app->core.configureSurface(w, h);
app->last_w = w;
app->last_h = h;
}
WGPUTextureView view = wgpuTextureCreateView(st.texture, nullptr);
WGPURenderPassColorAttachment ca = {};
ca.view = view;
ca.loadOp = WGPULoadOp_Clear;
ca.storeOp = WGPUStoreOp_Store;
ca.clearValue = {0.18, 0.21, 0.28, 1.0}; // BonsaiViewer slate background
ca.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDescriptor rp_desc = {};
rp_desc.colorAttachmentCount = 1;
rp_desc.colorAttachments = &ca;
WGPUCommandEncoderDescriptor enc_desc = {};
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(g_device, &enc_desc);
WGPURenderPassEncoder rp = wgpuCommandEncoderBeginRenderPass(enc, &rp_desc);
wgpuRenderPassEncoderEnd(rp);
wgpuRenderPassEncoderRelease(rp);
WGPUCommandBufferDescriptor cb_desc = {};
WGPUCommandBuffer cb = wgpuCommandEncoderFinish(enc, &cb_desc);
wgpuQueueSubmit(g_queue, 1, &cb);
wgpuCommandBufferRelease(cb);
wgpuCommandEncoderRelease(enc);
wgpuTextureViewRelease(view);
wgpuTextureRelease(st.texture);
}
void configure_surface() {
WGPUSurfaceCapabilities caps = {};
if (wgpuSurfaceGetCapabilities(g_surface, g_adapter, &caps) != WGPUStatus_Success
|| caps.formatCount == 0) {
std::fprintf(stderr, "[viewer-web] surface has no formats\n");
return;
// Only render when something has requested a frame — saves battery
// on the still-camera case. The initial frame request is armed by
// WebViewportHost's ctor so the canvas always paints once at startup.
if (app->host.consumeFrameRequest()) {
app->core.render();
}
g_surface_format = caps.formats[0];
wgpuSurfaceCapabilitiesFreeMembers(caps);
WGPUSurfaceConfiguration cfg = {};
cfg.device = g_device;
cfg.format = g_surface_format;
cfg.usage = WGPUTextureUsage_RenderAttachment;
cfg.width = uint32_t(g_width);
cfg.height = uint32_t(g_height);
cfg.alphaMode = WGPUCompositeAlphaMode_Auto;
cfg.presentMode = WGPUPresentMode_Fifo;
wgpuSurfaceConfigure(g_surface, &cfg);
if (!g_main_loop_started) {
emscripten_set_main_loop(frame, 0, /*simulate_infinite=*/0);
g_main_loop_started = true;
std::fprintf(stderr,
"[viewer-web] surface configured (%dx%d format=%d); RAF loop started\n",
g_width, g_height, int(g_surface_format));
}
}
void on_device_ready(WGPURequestDeviceStatus status, WGPUDevice device,
WGPUStringView message, void* /*ud1*/, void* /*ud2*/) {
if (status != WGPURequestDeviceStatus_Success || !device) {
std::fprintf(stderr, "[viewer-web] requestDevice failed: %.*s\n",
int(message.length), message.data ? message.data : "");
return;
}
g_device = device;
g_queue = wgpuDeviceGetQueue(device);
WGPUEmscriptenSurfaceSourceCanvasHTMLSelector canvas = {};
canvas.chain.sType = WGPUSType_EmscriptenSurfaceSourceCanvasHTMLSelector;
static const char* kSelector = "#viewer-canvas";
canvas.selector.data = kSelector;
canvas.selector.length = std::strlen(kSelector);
WGPUSurfaceDescriptor sd = {};
sd.nextInChain = &canvas.chain;
g_surface = wgpuInstanceCreateSurface(g_instance, &sd);
if (!g_surface) {
std::fprintf(stderr,
"[viewer-web] wgpuInstanceCreateSurface returned null "
"(canvas '#viewer-canvas' missing?)\n");
return;
}
configure_surface();
}
void on_adapter_ready(WGPURequestAdapterStatus status, WGPUAdapter adapter,
WGPUStringView message, void* /*ud1*/, void* /*ud2*/) {
if (status != WGPURequestAdapterStatus_Success || !adapter) {
std::fprintf(stderr, "[viewer-web] requestAdapter failed: %.*s\n",
int(message.length), message.data ? message.data : "");
return;
}
g_adapter = adapter;
WGPUDeviceDescriptor dd = {};
WGPURequestDeviceCallbackInfo cb = {};
cb.mode = WGPUCallbackMode_AllowSpontaneous;
cb.callback = on_device_ready;
wgpuAdapterRequestDevice(adapter, &dd, cb);
}
} // namespace
int main() {
WGPUInstanceDescriptor desc = {};
g_instance = wgpuCreateInstance(&desc);
if (!g_instance) {
std::fprintf(stderr, "[viewer-web] wgpuCreateInstance returned null\n");
int main(int /*argc*/, char** /*argv*/) {
Log::info() << "ifcviewer-web: starting";
auto* app = new AppState();
// Web limits floor: requestDevice the WebGPU spec's mandatory floor
// (maxStorageBufferBindingSize=128MB, maxBufferSize=256MB) so the
// chunking + pool probe see the same constraints they would hit in
// any browser. Desktop --web-limits did this opt-in; on web it's
// the only sensible default.
if (!app->core.initWgpu(/*web_limits=*/true)) {
std::fprintf(stderr, "[viewer-web] initWgpu failed\n");
delete app;
return 1;
}
if (!app->core.buildPipelines()) {
std::fprintf(stderr, "[viewer-web] buildPipelines failed\n");
delete app;
return 1;
}
// HiZ + edge + pick pipelines: built up-front to match the desktop
// path's lifetime. shutdown() in ViewportCore expects each
// resource to be either constructed or null, so building them all
// here keeps teardown symmetric.
app->core.buildHizPipeline();
app->core.buildEdgePipeline();
app->core.buildPickPipeline();
WGPURequestAdapterOptions opts = {};
WGPURequestAdapterCallbackInfo cb = {};
cb.mode = WGPUCallbackMode_AllowSpontaneous;
cb.callback = on_adapter_ready;
wgpuInstanceRequestAdapter(g_instance, &opts, cb);
// main() returns; the browser keeps the JS event loop running so
// the async adapter/device callbacks above land naturally and the
// main loop kicks off from configure_surface().
// fps = 0 means "use the browser's natural rate (RAF)" — Emscripten
// schedules the callback once per requestAnimationFrame tick.
// simulate_infinite_loop = false because we want main() to return
// so the runtime + JS event loop keep ticking. The AppState leaks
// on tab close, which is fine — emscripten_force_exit (called
// from host_->quit()) is the only formal shutdown path on web.
emscripten_set_main_loop_arg(main_loop, app, /*fps=*/0,
/*simulate_infinite_loop=*/0);
return 0;
}