diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 4e81d2ac8b..2805cfccae 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -74,7 +74,11 @@ add_subdirectory(${IFCVIEWER_DIR} ifcviewer EXCLUDE_FROM_ALL) # --- The web executable ------------------------------------------------------ -add_executable(IfcViewerWeb main_web.cpp) +add_executable(IfcViewerWeb + main_web.cpp + WebViewportHost.cpp + WebViewportHost.h +) target_link_libraries(IfcViewerWeb PRIVATE IfcViewerCore) target_link_options(IfcViewerWeb PRIVATE # Asyncify lets us await wgpu's RequestAdapter/RequestDevice/MapAsync diff --git a/src/ifcviewer-web/WebViewportHost.cpp b/src/ifcviewer-web/WebViewportHost.cpp new file mode 100644 index 0000000000..65d52e7fd5 --- /dev/null +++ b/src/ifcviewer-web/WebViewportHost.cpp @@ -0,0 +1,75 @@ +/******************************************************************************** + * * + * 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 "WebViewportHost.h" + +#include +#include + +#include +#include + +WebViewportHost::WebViewportHost(std::string canvas_selector) + : canvas_selector_(std::move(canvas_selector)) {} + +WGPUSurface WebViewportHost::createSurface(WGPUInstance instance) { + // Emdawnwebgpu canvas-selector surface source. Same shape as + // WGPUSurfaceSourceCanvasHTMLSelector_Emscripten in Dawn's headers. + WGPUEmscriptenSurfaceSourceCanvasHTMLSelector canvas_desc = {}; + canvas_desc.chain.sType = + WGPUSType_EmscriptenSurfaceSourceCanvasHTMLSelector; + canvas_desc.selector.data = canvas_selector_.c_str(); + canvas_desc.selector.length = canvas_selector_.size(); + + WGPUSurfaceDescriptor surface_desc = {}; + surface_desc.nextInChain = &canvas_desc.chain; + return wgpuInstanceCreateSurface(instance, &surface_desc); +} + +void WebViewportHost::framebufferSize(int& width_px, int& height_px) const { + double w_css = 0.0, h_css = 0.0; + // Pull the element's logical (CSS-pixel) size, then multiply by DPR + // — matches the QWindow desktop host's framebufferSize semantics. + emscripten_get_element_css_size(canvas_selector_.c_str(), &w_css, &h_css); + const float ratio = dpr(); + width_px = int(w_css * double(ratio)); + height_px = int(h_css * double(ratio)); + if (width_px < 1) width_px = 1; + if (height_px < 1) height_px = 1; +} + +float WebViewportHost::dpr() const { + return float(emscripten_get_device_pixel_ratio()); +} + +void WebViewportHost::requestFrame() { + request_frame_pending_ = true; +} + +void WebViewportHost::quit() { + // emscripten_force_exit honours -sEXIT_RUNTIME=1; without that flag + // the runtime swallows the call and keeps the page interactive. + emscripten_force_exit(0); +} + +bool WebViewportHost::consumeFrameRequest() { + const bool pending = request_frame_pending_; + request_frame_pending_ = false; + return pending; +} diff --git a/src/ifcviewer-web/WebViewportHost.h b/src/ifcviewer-web/WebViewportHost.h new file mode 100644 index 0000000000..44661d9bef --- /dev/null +++ b/src/ifcviewer-web/WebViewportHost.h @@ -0,0 +1,62 @@ +/******************************************************************************** + * * + * 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 WEBVIEWPORTHOST_H +#define WEBVIEWPORTHOST_H + +// Web implementation of ViewportHost. ViewportCore owns the wgpu state +// + render path; this class adapts the embedder hooks to a browser +// canvas (surface creation via emdawnwebgpu's canvas-selector extension, +// framebuffer size from the canvas element, requestFrame via +// requestAnimationFrame, quit via emscripten_force_exit). All +// notifications fall through to the base class no-ops for now — +// future iterations route them to DOM events / a status bar. + +#include "ViewportHost.h" + +#include + +class WebViewportHost final : public ViewportHost { +public: + // `canvas_selector` is the CSS selector for the host (e.g. + // "#viewer-canvas" — matches shell.html). The string is stored; + // it must outlive the host. + explicit WebViewportHost(std::string canvas_selector); + + WGPUSurface createSurface(WGPUInstance instance) override; + void framebufferSize(int& width_px, int& height_px) const override; + float dpr() const override; + // Sets request_frame_pending_ so the RAF callback knows to render + // on the next browser tick. The actual frame scheduling is done by + // the main_web.cpp loop; this avoids spamming RAF callbacks when + // multiple sources request a frame in the same tick. + void requestFrame() override; + void quit() override; + + // True when ViewportCore has asked for a frame since the last one + // was rendered. The main loop clears this before invoking + // core.render(). + bool consumeFrameRequest(); + +private: + std::string canvas_selector_; + bool request_frame_pending_ = true; // arm an initial frame +}; + +#endif // WEBVIEWPORTHOST_H diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 66a7bdc715..1e992eedee 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -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 #include -#include - #include -#include -#include 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(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; }