Let the viewport composite over what is behind it

Background colour gains a meaningful alpha. Below 1 the viewport composites
over whatever the host has stacked behind it rather than painting a colour
of its own; at 0 it clears to nothing, so the model draws over another 3D
view, a map, or ordinary page content. There is no depth interaction -- the
layer behind is strictly behind and cannot occlude the model.

Honouring the alpha needs a premultiplied surface. configureSurface asks for
one only when the platform advertises it, the same way it picks a present
mode, and falls back to Auto otherwise -- this machine's Vulkan surface
offers opaque compositing only, so hardcoding premultiplied would have
requested a mode it does not support. The clear follows that choice: scaled
by alpha when premultiplied, held at 1 when not, since scaling would
otherwise just darken the colour for an alpha nobody reads.

ViewportWindow::setBackgroundColor now forwards to core rather than writing
through a reference proxy, dropping one more member from the friend
declaration it is meant to shrink. The screenshot QImage is tagged
premultiplied to match what the main pass leaves in the buffer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-04 10:46:02 +10:00
parent c9f269a0e5
commit 0b8875669e
8 changed files with 227 additions and 12 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ target_link_options(IfcViewerWeb PRIVATE
# EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't
# add them to Module. ccall lets the host page (web/ifcviewer.js) pass a JS string (the ?model
# URL) to load_sidecar_from_url_c without manual heap marshalling.
"-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c']"
"-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_set_background_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c']"
# ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c,
# and the nav-preset name to ifcv_set_nav_preset_c.
# HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large
+9
View File
@@ -635,6 +635,15 @@ extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_ortho_c(int on) {
if (bool(on) != g_app->core.projectionOrtho()) g_app->core.toggleProjection();
}
// Background colour, RGBA in [0..1]. Alpha 0 clears the canvas to nothing, so
// whatever the host page has stacked behind it shows through. See
// ViewportCore::setBackgroundColor.
extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_background_c(float r, float g,
float b, float a) {
if (!g_app || !g_app->ready) return;
g_app->core.setBackgroundColor(r, g, b, a);
}
// Mouse navigation scheme: "blender" | "rhino" | "revit" | "web". The preset
// only rewrites the button/modifier table classifyPress reads, so unlike the
// rest of the scripting API it does not need the GPU app to be live — a host
+114
View File
@@ -0,0 +1,114 @@
// This file was generated with the assistance of an AI coding tool.
//
// Background colour, and specifically its alpha. The alpha only means
// anything if two separate things are right: the surface has to be
// configured with a premultiplied composite mode, or the channel is
// discarded before it reaches the compositor; and the clear value has to be
// scaled by it, or the viewport adds its background colour on top of the
// layer behind instead of vanishing. Both failures are invisible at the
// default opaque alpha, so nothing else in the suite would catch either.
import { test, expect } from '@playwright/test';
import zlib from 'node:zlib';
// Decode the top-left pixel (RGB) of a PNG buffer. Row 0 pixel 0 is
// filter-agnostic — every PNG predictor references zero neighbours there —
// so this can skip filter handling entirely.
function firstPixelRGB(png) {
let off = 8, colorType = 6;
const idat = [];
while (off + 8 <= png.length) {
const len = png.readUInt32BE(off);
const type = png.toString('ascii', off + 4, off + 8);
const data = png.subarray(off + 8, off + 8 + len);
if (type === 'IHDR') colorType = data[9];
else if (type === 'IDAT') idat.push(data);
else if (type === 'IEND') break;
off += 12 + len;
}
const raw = zlib.inflateSync(Buffer.concat(idat));
return [raw[1], raw[2], raw[3]]; // skip the row filter byte
}
// A 1x1 sample just inside the canvas corner — background, clear of the
// framed model. Screenshotting through the browser rather than reading the
// canvas back in-page is deliberate: a WebGPU swap-chain texture isn't
// persisted for 2D copy, but the browser's own capture path composites the
// GPU layer against what is behind it, which is the whole point here.
async function cornerPixel(page) {
const box = await page.locator('#viewer-canvas').boundingBox();
const png = await page.screenshot({
clip: { x: Math.round(box.x + 6), y: Math.round(box.y + 6), width: 1, height: 1 },
});
return firstPixelRGB(png);
}
function watchGpuErrors(page) {
const errors = [];
page.on('console', (msg) => {
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(msg.text()))
errors.push(msg.text());
});
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
return errors;
}
async function bootViewer(page) {
await page.goto('/scripting.html');
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()),
null, { timeout: 30_000 });
await page.waitForTimeout(1200);
}
test('an opaque background colour reaches the clear', async ({ page }) => {
const gpuErrors = watchGpuErrors(page);
await bootViewer(page);
await page.evaluate(() => window.viewer.setBackground('#c81e1e'));
await page.waitForTimeout(600);
const [r, g, b] = await cornerPixel(page);
expect(r, `expected the red clear, got ${r},${g},${b}`).toBeGreaterThan(150);
expect(g, `expected the red clear, got ${r},${g},${b}`).toBeLessThan(80);
expect(b, `expected the red clear, got ${r},${g},${b}`).toBeLessThan(80);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
test('alpha 0 clears to nothing and the layer behind shows through', async ({ page }) => {
const gpuErrors = watchGpuErrors(page);
await bootViewer(page);
// #viewer-box is the element the canvas sits in front of — where a host
// page would stack a second view. Paint it a colour neither the scene nor
// the palette uses, so a match can only mean the canvas showed through.
await page.evaluate(() => {
document.getElementById('viewer-box').style.background = '#ff00ff';
});
await page.waitForTimeout(300);
const before = await cornerPixel(page);
expect(
Math.max(...before),
`the corner is already bright before the alpha changed (${before}) — it is not `
+ `sampling background, so the assertion below would prove nothing`,
).toBeLessThan(90);
// White at zero alpha, not transparent black: black would pass even if the
// clear forgot to scale by alpha, since scaling black changes nothing.
// White separates the two — premultiplied it becomes (0,0,0,0) and the
// magenta behind survives intact, unscaled it is added on top and washes
// the corner out to white.
await page.evaluate(() => window.viewer.setBackground('#ffffff00'));
await page.waitForTimeout(600);
const [r, g, b] = await cornerPixel(page);
const got = `got ${r},${g},${b}`;
const why = `expected the magenta behind the canvas. Washed-out/white means the `
+ `clear was not premultiplied, or the surface fell back to an opaque `
+ `composite mode and held alpha at 1. ${got}`;
expect(r, why).toBeGreaterThan(200);
expect(g, why).toBeLessThan(60);
expect(b, why).toBeGreaterThan(200);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
+34
View File
@@ -320,6 +320,40 @@
if (c.ortho !== undefined) Module._ifcv_set_ortho_c(c.ortho ? 1 : 0);
},
// ---- Background ------------------------------------------------------
// Clear colour, as '#rgb' / '#rrggbb' / '#rrggbbaa' or {r, g, b, a} with
// 0-255 channels. Alpha defaults to opaque.
//
// Unlike setColor, an alpha of 0 here is meaningful rather than the "no
// override" sentinel: it clears the canvas to nothing, letting whatever
// is behind it in the DOM show through. Stack anything under the canvas
// — another 3D view, a map, ordinary page content — and the model draws
// on top of it. There is no depth interaction: the layer behind is
// strictly behind, so it reads as a backdrop and cannot occlude the
// model. Requires a platform that composites the canvas with alpha;
// where it does not, the colour still applies and the alpha is ignored.
setBackground: function (color) {
let r, g, b, a = 255;
if (typeof color === 'string') {
let hex = color.replace(/^#/, '');
if (hex.length === 3) hex = hex.split('').map(function (c) { return c + c; }).join('');
if (hex.length !== 6 && hex.length !== 8) {
throw new Error('setBackground: expected #rgb, #rrggbb or #rrggbbaa, got ' + color);
}
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16);
} else if (color && typeof color === 'object') {
r = color.r | 0; g = color.g | 0; b = color.b | 0;
if (color.a !== undefined) a = color.a | 0;
} else {
throw new Error('setBackground: expected a colour, got ' + color);
}
Module._ifcv_set_background_c(r / 255, g / 255, b / 255, a / 255);
},
// ---- Navigation ------------------------------------------------------
// Which mouse buttons orbit / pan / select, as one of NAV_PRESETS:
+42 -5
View File
@@ -489,6 +489,13 @@ void ViewportCore::setBackfaceCulling(bool enabled) {
host_->requestFrame();
}
void ViewportCore::setBackgroundColor(float r, float g, float b, float a) {
Eigen::Vector4f next{r, g, b, a};
if (background_color_ == next) return;
background_color_ = next;
host_->requestFrame();
}
bool ViewportCore::frameSelection() {
if (selection_.count() == 0) return false;
float lo[3] = { std::numeric_limits<float>::infinity(),
@@ -6869,6 +6876,22 @@ void ViewportCore::configureSurface(int width_px, int height_px) {
case WGPUPresentMode_Fifo: pm_name = "fifo"; break;
default: break;
}
// Premultiplied is what lets setBackgroundColor's alpha reach the
// compositor, so a clear below alpha 1 shows through to whatever the
// viewport is stacked over. Not every surface advertises it, so pick it
// only when offered and fall back to Auto — which composites opaquely and
// discards the alpha channel — otherwise. The two are indistinguishable
// at alpha 1, the default and the only value a caller that never touches
// the background will see, so the fallback costs nothing there.
auto supportsAlphaMode = [&](WGPUCompositeAlphaMode mode) {
for (std::size_t i = 0; i < caps.alphaModeCount; ++i) {
if (caps.alphaModes[i] == mode) return true;
}
return false;
};
const bool premultiplied =
supportsAlphaMode(WGPUCompositeAlphaMode_Premultiplied);
wgpuSurfaceCapabilitiesFreeMembers(caps);
cfg.presentMode = pm;
if (!surface_configured_) {
@@ -6886,7 +6909,14 @@ void ViewportCore::configureSurface(int width_px, int height_px) {
}
Log::info() << "[wgpu] present mode = " << pm_name << note;
}
cfg.alphaMode = WGPUCompositeAlphaMode_Auto;
cfg.alphaMode = premultiplied ? WGPUCompositeAlphaMode_Premultiplied
: WGPUCompositeAlphaMode_Auto;
surface_premultiplied_ = premultiplied;
if (!surface_configured_) {
Log::info() << "[wgpu] composite alpha = "
<< (premultiplied ? "premultiplied (background alpha honoured)"
: "auto (opaque -- background alpha ignored)");
}
wgpuSurfaceConfigure(surface_, &cfg);
configured_w_ = width_px;
@@ -7292,11 +7322,18 @@ void ViewportCore::render() {
color.resolveTarget = view;
color.loadOp = WGPULoadOp_Clear;
color.storeOp = WGPUStoreOp_Store;
// A premultiplied surface expects colour already scaled by alpha, so the
// clear fades toward transparent instead of tinting what shows through:
// leaving it unscaled would have the compositor add the background colour
// on top of the layer behind. When the surface could only be configured
// opaque the alpha is discarded anyway, and scaling would darken the
// colour for nothing — so hold alpha at 1 and write it straight.
const float bg_a = surface_premultiplied_ ? background_color_[3] : 1.0f;
color.clearValue = {
srgbToLinear(background_color_[0]),
srgbToLinear(background_color_[1]),
srgbToLinear(background_color_[2]),
1.0,
srgbToLinear(background_color_[0]) * bg_a,
srgbToLinear(background_color_[1]) * bg_a,
srgbToLinear(background_color_[2]) * bg_a,
bg_a,
};
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
+19
View File
@@ -248,6 +248,20 @@ public:
void setBackfaceCulling(bool enabled);
bool backfaceCulling() const { return backface_culling_; }
// Background colour, RGBA in [0..1]. An alpha below 1 makes the viewport
// composite over whatever is behind it rather than painting a colour of
// its own: at alpha 0 it clears to nothing, so the model appears to sit
// on top of the layer behind — another canvas, a second 3D view, plain
// page content, anything the host stacks there. There is no depth
// interaction; the layer behind is strictly behind.
//
// Honouring the alpha needs a premultiplied surface, which configureSurface
// requests when the platform advertises it. Where it does not, the alpha is
// ignored and the clear stays opaque. At alpha 1 — the default, and every
// caller that leaves the background alone — the two are identical.
void setBackgroundColor(float r, float g, float b, float a);
const Eigen::Vector4f& backgroundColor() const { return background_color_; }
// Frame the current selection: union the selected objects' world AABBs and
// fit the camera to them (same 1.30 padding as the desktop "F" hotkey).
// No-op with an empty selection or no resolvable AABBs; returns whether it
@@ -1504,6 +1518,11 @@ private:
// an sRGB-to-linear conversion on top so the on-screen colour
// matches the hex value passed via setBackgroundColor.
Eigen::Vector4f background_color_ = {0.125f, 0.137f, 0.161f, 1.0f};
// Whether the surface was configured with a premultiplied alpha mode, and
// so whether background_color_'s alpha reaches the compositor at all.
// Decided per configureSurface against the surface's advertised modes.
bool surface_premultiplied_ = false;
};
#endif // VIEWPORTCORE_H
+8 -4
View File
@@ -233,7 +233,6 @@ ViewportWindow::ViewportWindow(QWindow* parent)
camera_fov_y_deg_(core_.camera_fov_y_deg_),
camera_near_ (core_.camera_near_),
camera_far_ (core_.camera_far_),
background_color_(core_.background_color_),
frame_uniform_buffer_(core_.frame_uniform_buffer_),
frame_bind_group_ (core_.frame_bind_group_),
selection_flags_buffer_ (core_.selection_flags_buffer_),
@@ -465,7 +464,13 @@ void ViewportWindow::saveScreenshotRgba8(const std::string& path,
// QImage takes a stride argument so it doesn't try to read past the
// last row — wgpu's staging buffer was BGRA8 padded; core already
// packed the rows tightly into rgba.
QImage img(rgba, w, h, w * 4, QImage::Format_RGBA8888);
//
// Premultiplied, matching what the render pass leaves in the buffer: the
// main blend accumulates alpha as One/OneMinusSrcAlpha, so colour there is
// already scaled by coverage. Tagging it straight would wash out a
// screenshot taken over a translucent background; at the default opaque
// alpha the two formats describe the same bytes.
QImage img(rgba, w, h, w * 4, QImage::Format_RGBA8888_Premultiplied);
const QString qpath = QString::fromStdString(path);
if (img.save(qpath, "PNG")) {
Log::info() << "[wgpu] saved screenshot: "
@@ -476,8 +481,7 @@ void ViewportWindow::saveScreenshotRgba8(const std::string& path,
}
void ViewportWindow::setBackgroundColor(float r, float g, float b, float a) {
background_color_ = {r, g, b, a};
if (isExposed()) requestUpdate();
core_.setBackgroundColor(r, g, b, a);
}
// -----------------------------------------------------------------------------
-2
View File
@@ -760,8 +760,6 @@ private:
uint32_t& hiz_reject_count_;
std::atomic<int>& hiz_trace_budget_;
Eigen::Vector4f& background_color_;
// Camera state aliases (storage in core_).
float (&camera_target_)[3];
float& camera_distance_;