viewer: two-pass alpha transparency + Alt+X global x-ray cap

## The bug

FZK-Haus windows rendered fully opaque despite every piece of the
data path carrying alpha correctly: vertex format is RGBA u8x4,
InstanceCpu/InstanceGpu carry color_override_rgba8 with its alpha
byte, fs_main returns vec4(rgb, in.color.a). Cause: the main render
pipeline's color target had `blend = nullptr`, which in wgpu disables
the blend stage entirely — fragment RGBA overwrites the back buffer
unmodified, alpha discarded.

## Why "just enable blend" isn't enough

Two failure modes that don't go away with a one-liner:

1. `depthWriteEnabled = True` on the main pipeline would make a
   transparent window-frame pane occlude geometry behind it in
   depth, so the wall behind the window then fails the depth test
   and never draws — you'd see the silhouette of the window with
   whatever colour was in the back buffer before, not the wall.
2. Order-dependent blending across transparent surfaces in arbitrary
   cull order — overlapping transparent surfaces would shift colours
   as the camera moves.

Standard fix for a BIM viewer is two-pass opaque-then-transparent.

## What this commit adds

### Per-mesh "has any alpha < 255" classifier
* `ModelGpuData::mesh_has_alpha` (uint8_t vector, parallel to meshes).
* Sized in `applyCachedModel`.
* Populated in `applyStreamedChunk` by scanning each in-chunk mesh's
  vertex bytes for a vertex's alpha byte < 255 (offset 11 within
  the 12-byte vertex record — the 4th byte of the third u32, which
  the shader reads as `w2 >> 24`). Single chunk-arrival site covers
  both sidecar streaming and the worker-result drain. First-load
  IFC-without-sidecar geometry still routes opaque until the sidecar
  bake completes; A-path scan is deferred.

### Per-chunk opaque/transparent partition during cull
* `Chunk::opaque_visible_vertices` / `opaque_visible_draws`
  (per-frame counts).
* Transient `visible_draws_scratch_transparent` +
  `transparent_per_draw_vertex_counts` filled alongside the existing
  opaque half during the cull walk. Post-walk concat appends
  transparent entries onto the opaque half and continues the
  cumulative prefix-sum sequence — single buffer, single bind
  group, no doubling.
* Classifier inside the cull lambda:
    `xray_active ? always_transparent
     : override_active ? (override.alpha < 255)
     : mesh_has_alpha[mesh_id]`

### Per-chunk uniform layout extension
From `[total_draws, total_verts, 0, 0]` to
`[total_draws, total_verts, opaque_verts, opaque_draws]`. The third
slot is what `render()` passes as `firstVertex` to the transparent-
pass draw call so the shader's vid lands in the transparent range of
the same visible_draws_scratch buffer.

### `main_pipeline_transparent_`
Copy of `main_pipeline_` with `color_target.blend = SrcAlpha /
OneMinusSrcAlpha`. depthWriteEnabled stays True (see below).

### Two-pass `render()`
Opaque pass (`main_pipeline_`, firstVertex=0,
vertexCount=opaque_visible_vertices) then transparent pass
(`main_pipeline_transparent_`, firstVertex=opaque_visible_vertices,
vertexCount=total - opaque). Each loop skips empty halves so an
opaque-only chunk costs one draw call, transparent-only one draw,
mixed chunks two.

### depth_transparent.depthWriteEnabled = True (NOT off)

Initially set False (standard "let further-back geometry paint
through transparent front faces" trick) but that broke the edge-
detect pass: edge detection reads the depth buffer to find
silhouette discontinuities, and windows-without-depth meant the
glass had no silhouette at all (panes looked like framed holes) and
the edges of opaque geometry behind the glass painted through at
full intensity. Keeping the write avoids that — trade-off is depth-
test occlusion between transparent surfaces (closer occludes
farther), which for BIM panes that don't overlap in screen space
is invisible. Real fix for the overlap case is OIT or sort-back-
to-front, not depth-write toggling.

## Alt+X global X-ray (drops in basically free)

* `xray_alpha_cap` field on FrameUniforms + WGSL counterpart, default
  1.0 (no effect). fs_main clamps `out.a = min(in.color.a, cap)`.
* `ViewportWindow::xray_alpha_cap_` member, default 1.0. Alt+X
  toggles between 1.0 and 0.3.
* Cull classifier sees `xray_alpha_cap_ < 1.0` and forces every
  instance into the transparent pass so the blend stage actually
  fires (an opaque-pass fragment with capped alpha would still
  overwrite the back buffer).
* No per-instance state mutation needed — toggle is a single float
  in a uniform plus a re-cull. Excluding objects from x-ray later
  would mean tagging them so the classifier skips the force-
  transparent branch for them, also small.

Stress-tested on FZK-Haus: window glass visibly translucent with
correct silhouette edges; Alt+X turns the whole scene to a tinted
ghost of itself and back without artefact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-04 12:58:50 +10:00
parent 748b4e72a9
commit b123ee69d6
3 changed files with 291 additions and 18 deletions
+39
View File
@@ -120,9 +120,33 @@ struct ModelGpuData {
uint32_t total_visible_draws = 0;
uint32_t frustum_visible_count = 0;
// Opaque-first partition counts. The cull loop fills
// visible_draws_scratch with all opaque visible instances first,
// then all transparent ones; cumulative prefix_sums_scratch spans
// both. The opaque-pass draw call uses firstVertex=0 and
// vertexCount=opaque_visible_vertices; the transparent-pass draw
// call uses firstVertex=opaque_visible_vertices and
// vertexCount=(total_visible_vertices - opaque_visible_vertices).
// 0 means no opaque (transparent-only chunk) or no transparent
// (opaque-only chunk) — the render loop skips empty halves.
uint32_t opaque_visible_vertices = 0;
uint32_t opaque_visible_draws = 0;
std::vector<VisibleDrawGpu> visible_draws_scratch;
std::vector<uint32_t> prefix_sums_scratch;
// Transient transparent-half scratch. Populated alongside
// visible_draws_scratch during cull (the cull loop routes each
// visible instance to opaque or transparent based on the
// mesh_has_alpha + color_override_rgba8 classification). After
// the chunk's instances are walked, the post-process step appends
// these entries onto visible_draws_scratch and continues the
// prefix-sum sequence, yielding a single buffer/upload with
// [opaque-draws][transparent-draws] partitioning. Cleared at the
// start of each cull alongside visible_draws_scratch.
std::vector<VisibleDrawGpu> visible_draws_scratch_transparent;
std::vector<uint32_t> transparent_per_draw_vertex_counts;
// Residency. Streaming sets is_resident=false at applyCachedModel
// and flips true once the chunk's vertex bytes are uploaded.
// Render and pick skip chunks where !is_resident.
@@ -306,6 +330,21 @@ struct ModelGpuData {
std::vector<MeshInfo> meshes;
std::vector<InstanceCpu> instances;
// Per-mesh "any vertex has alpha < 255?" flag, indexed by mesh_id.
// Populated at uploadMeshChunk / applyStreamedChunk as vertex bytes
// become CPU-resident. Used at cull time to classify each instance
// into the opaque or transparent draw partition: an instance with
// color_override_rgba8==0 (the "use baked vertex color" sentinel)
// routes to the transparent pass iff its mesh has alpha; an instance
// with a non-zero override uses the override's alpha byte instead.
// 0 means false (opaque mesh), non-zero means true (any-vertex-alpha
// < 255). Initial size matches meshes.size(); entries default to 0
// until a vertex chunk arrives for that mesh, so a transparent mesh
// is briefly mis-classified as opaque between instance compose and
// chunk arrival — corrected on the next cull tick once the chunk
// lands.
std::vector<uint8_t> mesh_has_alpha;
// Local-frame volume (m³) of every mesh, indexed by mesh_id. Computed
// once at applyCachedModel via signed-tetrahedra-from-origin on the
// raw vertex+index data; reused by the Volume measurement tool to