wgpu: shared thick-line shader, Bonsai decorator palette, fatter section gizmo

Consolidation

THICK_LINE_HELPERS_WGSL — a macro that both AXIS_WGSL and SECTION_WGSL
prefix via adjacent string-literal pasting — holds:

  - VsOut: clip_pos + rgba colour + side_t for AA
  - thick_line_clip(p_start, p_end, t, side, viewport, line_width):
    the screen-space quad expansion with consistent perpendicular so
    the quad never collapses into a bowtie
  - fs_main: |side_t| + fwidth() coverage smoothstep — analytical
    1-pixel AA regardless of MSAA

Each gizmo shader now only declares its uniform struct + a 10-line
vertex shader. C++ side gains thickLineVertexLayout(attribs[5]) so
both call sites set up the 5-attribute layout in one call instead of
20+ lines each. Net diff is -28 lines on this commit and roughly -60
relative to the unconsolidated section commit; the next thick-line
gizmo (measure tool, selection outline, …) starts from ~30 lines of
WGSL + a vertex buffer.

Bonsai decorator palette

All overlay colours now come from src/bonsai/bonsai/bim/ui.py's
decorator_color_* defaults so they match Bonsai's Blender add-on:

  decorator_color_error    = (1.000, 0.200, 0.322)  red   → +X axis, section gizmo
  decorator_color_selected = (0.545, 0.863, 0.000)  green → +Y axis
  decorator_color_special  = (0.157, 0.565, 1.000)  blue  → +Z axis

Section gizmo polish

  - Entire gizmo (quad outline + arrow shaft + arrow head) goes red.
    GL's white quad + yellow arrow disappeared against light surfaces;
    one saturated red reads against any background and identifies the
    geometry as a tool overlay.
  - Line width bumped to 5 logical px and the per-vertex tint dropped
    to (1, 1, 1, 1) so the tint multiplier stays available for a future
    "selected" state without changing the base colour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-30 16:00:09 +10:00
parent 8173074050
commit 0ed355154a
+128 -156
View File
@@ -2182,7 +2182,50 @@ void WgpuViewportWindow::encodeEdgePass(WGPUCommandEncoder enc,
// so depth interaction is correct; corner draws on the resolved surface after
// the edge silhouette pass so the laplacian can't darken its lines.
static const char* AXIS_WGSL = R"(
// Shared by every thick-line gizmo (axis indicator, section plane
// gizmo, future overlays). Provides:
// - VsOut: rasterizer carry — clip_pos, rgba colour, side_t for AA
// - thick_line_clip(p_start, p_end, t, side, viewport_size,
// line_width_px): the actual screen-space expansion.
// Every vertex carries BOTH endpoints; expanding from
// `s_end - s_start` (NOT this vertex vs the other) keeps the
// perpendicular consistent so the quad stays a rectangle and not
// a bowtie. clip.w is preserved so depth interpolation stays correct.
// - fs_main: analytical 1-pixel AA via |side_t| + fwidth() —
// interpolated across the perpendicular, smoothstep over its
// per-fragment derivative gives a smooth edge regardless of MSAA.
// Pasted into each shader as a prefix via adjacent string literals.
#define THICK_LINE_HELPERS_WGSL R"WGSL(
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) side_t: f32,
};
fn thick_line_clip(p_start: vec4<f32>, p_end: vec4<f32>,
t: f32, side: f32,
viewport_size: vec2<f32>,
line_width_px: f32) -> vec4<f32> {
let p_here = mix(p_start, p_end, t);
let s_start = (p_start.xy / p_start.w) * viewport_size * 0.5;
let s_end = (p_end.xy / p_end.w ) * viewport_size * 0.5;
let dir = normalize(s_end - s_start);
let perp = vec2<f32>(-dir.y, dir.x);
let off_pixels = perp * (line_width_px * 0.5) * side;
let off_ndc = off_pixels * 2.0 / viewport_size;
return vec4<f32>(p_here.xy + off_ndc * p_here.w, p_here.zw);
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let d = abs(in.side_t);
let aa = fwidth(in.side_t);
let coverage = 1.0 - smoothstep(1.0 - aa, 1.0, d);
return vec4<f32>(in.color.xyz, in.color.w * coverage);
}
)WGSL"
static const char* AXIS_WGSL = THICK_LINE_HELPERS_WGSL R"WGSL(
struct AxisUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
@@ -2194,27 +2237,6 @@ struct AxisUniforms {
@group(0) @binding(0) var<uniform> u: AxisUniforms;
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec3<f32>,
@location(1) alpha: f32,
// Signed perpendicular position across the quad (±1 at the long
// edges, 0 down the line's centre). The rasterizer interpolates it,
// and the fragment shader uses |side_t| + fwidth() as a 1-pixel
// smoothstep so the line has analytically anti-aliased edges
// without needing MSAA on the corner gizmo's resolved-surface pass.
@location(2) side_t: f32,
};
// Each axis is a 4-vertex quad expanded in SCREEN space from a notional
// line segment. Every vertex carries BOTH endpoints (start, end) so the
// screen-space direction is computed consistently as `s_end - s_start`
// regardless of which end this vertex sits at. `t` selects which end
// (0 = start, 1 = end) for the base projected point; `side` is +1/-1
// for the two sides of the perpendicular offset. Computing direction
// from this vertex to the other (a previous design) flipped sign at
// the end → the quad became a bowtie. `clip.w` is preserved so depth
// interpolation stays correct.
@vertex
fn vs_main(@location(0) start: vec3<f32>,
@location(1) end: vec3<f32>,
@@ -2223,37 +2245,44 @@ fn vs_main(@location(0) start: vec3<f32>,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(u.origin + start * u.arm, 1.0);
let p_end = u.mvp * vec4<f32>(u.origin + end * u.arm, 1.0);
let p_here = mix(p_start, p_end, t);
let s_start = (p_start.xy / p_start.w) * u.viewport_size * 0.5;
let s_end = (p_end.xy / p_end.w ) * u.viewport_size * 0.5;
let dir = normalize(s_end - s_start);
let perp = vec2<f32>(-dir.y, dir.x);
let off_pixels = perp * (u.line_width_px * 0.5) * side;
let off_ndc = off_pixels * 2.0 / u.viewport_size;
var out: VsOut;
out.clip_pos = vec4<f32>(p_here.xy + off_ndc * p_here.w,
p_here.zw);
out.color = col;
out.alpha = u.alpha;
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = vec4<f32>(col, u.alpha);
out.side_t = side;
return out;
}
)WGSL";
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
// |side_t| is 0 at line centre, 1 at the long edges. fwidth gives
// the per-pixel change — smoothing across that range gives a single
// pixel of analytical AA along the perpendicular.
let d = abs(in.side_t);
let aa = fwidth(in.side_t);
let coverage = 1.0 - smoothstep(1.0 - aa, 1.0, d);
return vec4<f32>(in.color, in.alpha * coverage);
// Populate `attribs[5]` with the standard thick-line layout:
// loc 0: start (vec3 @ 0)
// loc 1: end (vec3 @ 12)
// loc 2: col (vec3 @ 24)
// loc 3: t (f32 @ 36)
// loc 4: side (f32 @ 40)
// Returns a WGPUVertexBufferLayout aliasing the caller-owned `attribs`.
static WGPUVertexBufferLayout thickLineVertexLayout(WGPUVertexAttribute attribs[5]) {
attribs[0].format = WGPUVertexFormat_Float32x3; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
attribs[1].format = WGPUVertexFormat_Float32x3; attribs[1].offset = 12; attribs[1].shaderLocation = 1;
attribs[2].format = WGPUVertexFormat_Float32x3; attribs[2].offset = 24; attribs[2].shaderLocation = 2;
attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 36; attribs[3].shaderLocation = 3;
attribs[4].format = WGPUVertexFormat_Float32; attribs[4].offset = 40; attribs[4].shaderLocation = 4;
WGPUVertexBufferLayout vbl = {};
vbl.arrayStride = 44;
vbl.stepMode = WGPUVertexStepMode_Vertex;
vbl.attributeCount = 5;
vbl.attributes = attribs;
return vbl;
}
)";
bool WgpuViewportWindow::buildAxisIndicator() {
// Bonsai decorator palette (src/bonsai/bonsai/bim/ui.py:593+):
// decorator_color_error = (1.000, 0.200, 0.322) — red → +X
// decorator_color_selected = (0.545, 0.863, 0.000) — green → +Y
// decorator_color_special = (0.157, 0.565, 1.000) — blue → +Z
// Same palette is reused for the section gizmo so all overlay colours
// come from one canonical source.
//
// Vertex buffer: three positive-axis rays, each expanded into a
// 4-corner quad (6 vertices in triangle-list order) so the vertex
// shader can offset by `line_width / 2` pixels in screen space.
@@ -2268,30 +2297,30 @@ bool WgpuViewportWindow::buildAxisIndicator() {
// (start,-1) (start,+1) (end,-1) (end,-1) (start,+1) (end,+1)
// — a standard triangle-list two-tri quad split.
static const float axis_verts[] = {
// start end color (RGB) t side
// ---- +X (red) — start=(0,0,0), end=(1,0,0) ----
0,0,0, 1,0,0, 1.00f, 0.15f, 0.15f, 0.f, -1.f,
0,0,0, 1,0,0, 1.00f, 0.15f, 0.15f, 0.f, +1.f,
0,0,0, 1,0,0, 1.00f, 0.15f, 0.15f, 1.f, -1.f,
0,0,0, 1,0,0, 1.00f, 0.15f, 0.15f, 1.f, -1.f,
0,0,0, 1,0,0, 1.00f, 0.15f, 0.15f, 0.f, +1.f,
0,0,0, 1,0,0, 1.00f, 0.15f, 0.15f, 1.f, +1.f,
// start end color (RGB — Bonsai decorators) t side
// ---- +X (decorator red) — start=(0,0,0), end=(1,0,0) ----
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, +1.f,
// ---- +Y (green) — start=(0,0,0), end=(0,1,0) ----
0,0,0, 0,1,0, 0.15f, 0.85f, 0.20f, 0.f, -1.f,
0,0,0, 0,1,0, 0.15f, 0.85f, 0.20f, 0.f, +1.f,
0,0,0, 0,1,0, 0.15f, 0.85f, 0.20f, 1.f, -1.f,
0,0,0, 0,1,0, 0.15f, 0.85f, 0.20f, 1.f, -1.f,
0,0,0, 0,1,0, 0.15f, 0.85f, 0.20f, 0.f, +1.f,
0,0,0, 0,1,0, 0.15f, 0.85f, 0.20f, 1.f, +1.f,
// ---- +Y (decorator green) — start=(0,0,0), end=(0,1,0) ----
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, +1.f,
// ---- +Z (blue) — start=(0,0,0), end=(0,0,1) ----
0,0,0, 0,0,1, 0.20f, 0.40f, 1.00f, 0.f, -1.f,
0,0,0, 0,0,1, 0.20f, 0.40f, 1.00f, 0.f, +1.f,
0,0,0, 0,0,1, 0.20f, 0.40f, 1.00f, 1.f, -1.f,
0,0,0, 0,0,1, 0.20f, 0.40f, 1.00f, 1.f, -1.f,
0,0,0, 0,0,1, 0.20f, 0.40f, 1.00f, 0.f, +1.f,
0,0,0, 0,0,1, 0.20f, 0.40f, 1.00f, 1.f, +1.f,
// ---- +Z (decorator blue) — start=(0,0,0), end=(0,0,1) ----
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, +1.f,
};
{
WGPUBufferDescriptor bdesc = {};
@@ -2364,29 +2393,8 @@ bool WgpuViewportWindow::buildAxisIndicator() {
axis_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
}
// Vertex layout: start(vec3) + end(vec3) + col(vec3) + t(f32) + side(f32),
// interleaved, stride 44.
WGPUVertexAttribute attribs[5] = {};
attribs[0].format = WGPUVertexFormat_Float32x3; // start
attribs[0].offset = 0;
attribs[0].shaderLocation = 0;
attribs[1].format = WGPUVertexFormat_Float32x3; // end
attribs[1].offset = 12;
attribs[1].shaderLocation = 1;
attribs[2].format = WGPUVertexFormat_Float32x3; // color
attribs[2].offset = 24;
attribs[2].shaderLocation = 2;
attribs[3].format = WGPUVertexFormat_Float32; // t
attribs[3].offset = 36;
attribs[3].shaderLocation = 3;
attribs[4].format = WGPUVertexFormat_Float32; // side
attribs[4].offset = 40;
attribs[4].shaderLocation = 4;
WGPUVertexBufferLayout vbl = {};
vbl.arrayStride = 44;
vbl.stepMode = WGPUVertexStepMode_Vertex;
vbl.attributeCount = 5;
vbl.attributes = attribs;
WGPUVertexBufferLayout vbl = thickLineVertexLayout(attribs);
// Standard alpha blend so the corner gizmo can soften over the resolved
// background and the pivot can fade against scene colour.
@@ -2651,12 +2659,11 @@ void WgpuViewportWindow::setPivotIndicatorVisible(bool visible, int hide_after_m
// sees the back of the plane (matches what GL's plane gizmo does visually).
// 6 active-plane × 1 quad × 6 verts = 36 verts max per frame, trivial.
// Matches GL's section gizmo (2 × 2 m wireframe quad outline + arrow shaft
// + 4-line arrow head) but with screen-space thick-line expansion so the
// gizmo reads cleanly against busy BIM geometry. Same technique the axis
// indicator uses: per-vertex (start, end, t, side, colour) and the vertex
// shader offsets by ±line_width/2 along the screen-space perpendicular.
static const char* SECTION_WGSL = R"(
// GL's section gizmo (2 × 2 m wireframe quad outline + arrow shaft + 4-line
// arrow head) rendered as thick lines so it stays visible against busy
// BIM geometry. Shares VsOut, thick_line_clip and fs_main with the axis
// indicator via THICK_LINE_HELPERS_WGSL.
static const char* SECTION_WGSL = THICK_LINE_HELPERS_WGSL R"WGSL(
struct SectionUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
@@ -2674,12 +2681,6 @@ struct SectionUniforms {
@group(0) @binding(0) var<uniform> u: SectionUniforms;
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) side_t: f32,
};
fn plane_to_world(p: vec3<f32>) -> vec3<f32> {
return u.origin + (u.tangent * p.x + u.bitangent * p.y + u.normal * p.z)
* u.half_size;
@@ -2693,30 +2694,14 @@ fn vs_main(@location(0) start_local: vec3<f32>,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(plane_to_world(start_local), 1.0);
let p_end = u.mvp * vec4<f32>(plane_to_world(end_local), 1.0);
let p_here = mix(p_start, p_end, t);
let s_start = (p_start.xy / p_start.w) * u.viewport_size * 0.5;
let s_end = (p_end.xy / p_end.w ) * u.viewport_size * 0.5;
let dir = normalize(s_end - s_start);
let perp = vec2<f32>(-dir.y, dir.x);
let off_pixels = perp * (u.line_width_px * 0.5) * side;
let off_ndc = off_pixels * 2.0 / u.viewport_size;
var out: VsOut;
out.clip_pos = vec4<f32>(p_here.xy + off_ndc * p_here.w, p_here.zw);
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = vec4<f32>(col * u.tint.xyz, u.tint.w);
out.side_t = side;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let d = abs(in.side_t);
let aa = fwidth(in.side_t);
let coverage = 1.0 - smoothstep(1.0 - aa, 1.0, d);
return vec4<f32>(in.color.xyz, in.color.w * coverage);
}
)";
)WGSL";
bool WgpuViewportWindow::buildSectionVisualizer() {
// 9 line segments (4 quad-outline + 1 arrow shaft + 4 arrow head),
@@ -2731,19 +2716,24 @@ bool WgpuViewportWindow::buildSectionVisualizer() {
std::array<float, 3> s, e;
std::array<float, 3> c;
};
// Entire gizmo uses Bonsai's decorator_color_error red so it reads
// against any geometry. White quad + yellow arrow (GL's defaults)
// disappeared into light surfaces; one consistent saturated red is
// both more visible and visually identifies "this is a tool overlay".
static constexpr std::array<float, 3> kSectionRed = {1.000f, 0.200f, 0.322f};
static const Seg segs[] = {
// ---- quad outline (white) ----
{ {-1, -1, 0}, { 1, -1, 0}, {1, 1, 1} },
{ { 1, -1, 0}, { 1, 1, 0}, {1, 1, 1} },
{ { 1, 1, 0}, {-1, 1, 0}, {1, 1, 1} },
{ {-1, 1, 0}, {-1, -1, 0}, {1, 1, 1} },
// ---- arrow shaft along +n (yellow) ----
{ { 0, 0, 0}, { 0, 0, 1}, {1.0f, 0.85f, 0.20f} },
// ---- quad outline ----
{ {-1, -1, 0}, { 1, -1, 0}, kSectionRed },
{ { 1, -1, 0}, { 1, 1, 0}, kSectionRed },
{ { 1, 1, 0}, {-1, 1, 0}, kSectionRed },
{ {-1, 1, 0}, {-1, -1, 0}, kSectionRed },
// ---- arrow shaft along +n ----
{ { 0, 0, 0}, { 0, 0, 1}, kSectionRed },
// ---- arrow head: 4 diagonals from tip to ring at z = 0.78 ----
{ { 0, 0, 1}, {-0.18f, 0, 0.78f}, {1.0f, 0.85f, 0.20f} },
{ { 0, 0, 1}, { 0.18f, 0, 0.78f}, {1.0f, 0.85f, 0.20f} },
{ { 0, 0, 1}, { 0, -0.18f, 0.78f}, {1.0f, 0.85f, 0.20f} },
{ { 0, 0, 1}, { 0, 0.18f, 0.78f}, {1.0f, 0.85f, 0.20f} },
{ { 0, 0, 1}, {-0.18f, 0, 0.78f}, kSectionRed },
{ { 0, 0, 1}, { 0.18f, 0, 0.78f}, kSectionRed },
{ { 0, 0, 1}, { 0, -0.18f, 0.78f}, kSectionRed },
{ { 0, 0, 1}, { 0, 0.18f, 0.78f}, kSectionRed },
};
constexpr size_t kVertsPerSegment = 6; // (start,-1) (start,+1) (end,-1) (end,-1) (start,+1) (end,+1)
constexpr size_t kFloatsPerVertex = 11; // start.xyz, end.xyz, col.xyz, t, side
@@ -2823,28 +2813,8 @@ bool WgpuViewportWindow::buildSectionVisualizer() {
section_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
}
// start_local + end_local + col + t + side, interleaved, stride 44.
WGPUVertexAttribute attribs[5] = {};
attribs[0].format = WGPUVertexFormat_Float32x3; // start_local
attribs[0].offset = 0;
attribs[0].shaderLocation = 0;
attribs[1].format = WGPUVertexFormat_Float32x3; // end_local
attribs[1].offset = 12;
attribs[1].shaderLocation = 1;
attribs[2].format = WGPUVertexFormat_Float32x3; // color
attribs[2].offset = 24;
attribs[2].shaderLocation = 2;
attribs[3].format = WGPUVertexFormat_Float32; // t
attribs[3].offset = 36;
attribs[3].shaderLocation = 3;
attribs[4].format = WGPUVertexFormat_Float32; // side
attribs[4].offset = 40;
attribs[4].shaderLocation = 4;
WGPUVertexBufferLayout vbl = {};
vbl.arrayStride = 44;
vbl.stepMode = WGPUVertexStepMode_Vertex;
vbl.attributeCount = 5;
vbl.attributes = attribs;
WGPUVertexBufferLayout vbl = thickLineVertexLayout(attribs);
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
@@ -2954,16 +2924,18 @@ void WgpuViewportWindow::encodeSectionPlanes(WGPURenderPassEncoder pass,
// sheet sized to the cut subject.
const float half_size = 1.0f;
const float dpr = float(std::max(1, int(devicePixelRatio())));
const float line_w = 3.0f * dpr;
const float line_w = 5.0f * dpr;
const float vw = float(configured_w_);
const float vh = float(configured_h_);
uint8_t slot[256];
// GL palette: warm yellow-orange tint, alpha 0.85 — slightly
// bolder than the GL default so the thick-line + AA reads cleanly.
// Neutral tint — actual colours come from the per-vertex VBO
// (white quad outline + red arrow). Tint stays available for a
// future "selected" multiplier; alpha controls the whole gizmo's
// opacity.
packSectionUniform(slot, view_proj, p.origin, half_size,
tangent, line_w, bitangent, nn,
1.0f, 0.85f, 0.40f, 0.85f,
1.0f, 1.0f, 1.0f, 1.0f,
vw, vh);
const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlotSize;
wgpuQueueWriteBuffer(queue_, section_uniform_buffer_,