forked from 1b-agent/opencadstudio-upstream
Options > Selection gains "Selection highlight opacity" and "Selection highlight extra width". Below full opacity the object's own colour shows through the highlight. The selection overlay draws with its own copy of the frame uniforms: transparency on, and every line widened by the extra width. Drawings opened at startup now get the selection colour settings too; they used to keep the defaults until a setting changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
356 lines
14 KiB
WebGPU Shading Language
356 lines
14 KiB
WebGPU Shading Language
// Wire shader (native) — same as wire.wgsl, but the per-wire constants
|
|
// (color / half_width / dash pattern / draw_depth) live in a per-wire storage
|
|
// buffer indexed by `wire_id` instead of being replicated on every segment
|
|
// instance. Cuts the instance from 104 B to one 64-byte cache line and removes
|
|
// the redundant per-segment re-fetch of constants. WebGL2 has no vertex-stage
|
|
// storage buffers, so the wasm build uses wire.wgsl (fat instance) instead.
|
|
|
|
struct Uniforms {
|
|
viewport_size: vec2<f32>,
|
|
world_per_pixel: f32,
|
|
lwdisplay_enable: f32,
|
|
flat_shade: f32,
|
|
transparency_enable: f32,
|
|
linetype_scale: f32,
|
|
lineweight_scale: f32,
|
|
view_rot: mat4x4<f32>,
|
|
eye_high: vec3<f32>,
|
|
highlight_widen: f32,
|
|
eye_low: vec3<f32>,
|
|
_pad_el: f32,
|
|
}
|
|
@group(0) @binding(0) var<uniform> u: Uniforms;
|
|
|
|
// Per-wire constants (std430). Must match `WireConst` in wire_gpu.rs.
|
|
struct WireConst {
|
|
color: vec4<f32>,
|
|
pat0: vec4<f32>,
|
|
pat1: vec4<f32>,
|
|
half_width: f32,
|
|
pattern_length: f32,
|
|
draw_depth: f32,
|
|
align_end: f32,
|
|
align_total: f32,
|
|
world_half_width: f32,
|
|
is_tapered: f32,
|
|
hidden: f32,
|
|
marker_origin_high: vec4<f32>,
|
|
marker_origin_low: vec4<f32>,
|
|
marker_normal_scale: vec4<f32>,
|
|
}
|
|
@group(1) @binding(0) var<storage, read> wire_consts: array<WireConst>;
|
|
|
|
struct InstanceIn {
|
|
@location(0) pos_a: vec3<f32>,
|
|
@location(1) pos_b: vec3<f32>,
|
|
@location(2) pos_a_low: vec3<f32>,
|
|
@location(3) pos_b_low: vec3<f32>,
|
|
@location(4) distance_a: f32,
|
|
@location(5) distance_b: f32,
|
|
@location(6) wire_id: u32,
|
|
// Per-endpoint width / per-wire maximum width. Vertex UNORM16 conversion
|
|
// expands this to 0..1; zero keeps the constant-width fallback.
|
|
@location(7) taper_ratio: vec2<f32>,
|
|
}
|
|
|
|
const MODEL_LINEWEIGHT_BOOST: f32 = 2.0;
|
|
const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0;
|
|
// Must match `MITER_LIMIT` in wire_gpu.rs.
|
|
const MITER_LIMIT: f32 = 8.0;
|
|
|
|
struct VertexOut {
|
|
@builtin(position) clip_pos: vec4<f32>,
|
|
@location(0) color: vec4<f32>,
|
|
@location(1) distance: f32,
|
|
@location(2) pattern_length: f32,
|
|
@location(3) pat0: vec4<f32>,
|
|
@location(4) pat1: vec4<f32>,
|
|
@location(5) @interpolate(flat) min_elem: f32,
|
|
@location(6) @interpolate(flat) align_end: f32,
|
|
@location(7) @interpolate(flat) align_total: f32,
|
|
// Round-cap support: (along, across) of this fragment in screen pixels,
|
|
// where `along` runs -hw_a … seg_len+hw_b over the extended quad and
|
|
// `across` is the signed distance from the centreline.
|
|
@location(8) cap: vec2<f32>,
|
|
// (segment pixel length, end half-width at A, end half-width at B).
|
|
@location(9) @interpolate(flat) cap_ends: vec3<f32>,
|
|
}
|
|
|
|
// Half-width of one segment end: a tapered band's own end width wins, then a
|
|
// constant world-unit band, then the screen-pixel lineweight (LWDISPLAY off
|
|
// collapses to a hairline).
|
|
// The selection pass widens highlighted lines by `highlight_widen` pixels;
|
|
// every other pass uploads zero.
|
|
fn resolve_hw(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 {
|
|
return resolve_hw_base(taper_ratio, world_hw, px_hw) + u.highlight_widen * 0.5;
|
|
}
|
|
|
|
fn resolve_hw_base(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 {
|
|
if taper_ratio > 0.0 {
|
|
return max((taper_ratio * world_hw) / u.world_per_pixel, 0.5);
|
|
}
|
|
if world_hw > 0.0 { return max(world_hw / u.world_per_pixel, 0.5); }
|
|
if world_hw < 0.0 { return max(-world_hw, 0.5); }
|
|
var display_hw = max(px_hw * u.lineweight_scale, 0.5);
|
|
if u.lineweight_scale < 0.0 {
|
|
let scale = -u.lineweight_scale;
|
|
let base_hw = select(
|
|
min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5),
|
|
0.5,
|
|
px_hw <= 0.5,
|
|
);
|
|
display_hw = max(base_hw * scale, 0.5);
|
|
}
|
|
return select(0.5, display_hw, u.lwdisplay_enable > 0.5);
|
|
}
|
|
|
|
fn marker_relative(position_high: vec3<f32>, position_low: vec3<f32>, c: WireConst) -> vec3<f32> {
|
|
if c.marker_normal_scale.w <= 0.0 {
|
|
return (position_high - u.eye_high) + (position_low - u.eye_low);
|
|
}
|
|
let origin_high = c.marker_origin_high.xyz;
|
|
let origin_low = c.marker_origin_low.xyz;
|
|
let origin_relative = (origin_high - u.eye_high) + (origin_low - u.eye_low);
|
|
let delta = (position_high - origin_high) + (position_low - origin_low);
|
|
let normal = normalize(c.marker_normal_scale.xyz);
|
|
let axial = normal * dot(delta, normal);
|
|
let planar = delta - axial;
|
|
let origin_clip = u.view_rot * vec4<f32>(origin_relative, 1.0);
|
|
let projection_scale = max(length(vec3<f32>(
|
|
u.view_rot[0].y,
|
|
u.view_rot[1].y,
|
|
u.view_rot[2].y,
|
|
)), 1e-12);
|
|
let view_height = 2.0 * max(abs(origin_clip.w), 1e-6) / projection_scale;
|
|
let world_size = c.marker_normal_scale.w * 0.01 * view_height;
|
|
return origin_relative + axial + planar * world_size;
|
|
}
|
|
|
|
@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut {
|
|
let c = wire_consts[in.wire_id];
|
|
|
|
let which_end_arr = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
|
|
let side_arr = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
|
|
let which_end = which_end_arr[vid];
|
|
let side = side_arr[vid];
|
|
|
|
let rel_a = marker_relative(in.pos_a, in.pos_a_low, c);
|
|
let rel_b = marker_relative(in.pos_b, in.pos_b_low, c);
|
|
let is_tapered = c.is_tapered > 0.5;
|
|
let is_world = c.world_half_width > 0.0;
|
|
|
|
var final_clip: vec4<f32>;
|
|
var out_dist: f32;
|
|
var out_cap: vec2<f32>;
|
|
var out_cap_ends: vec3<f32>;
|
|
|
|
if is_world {
|
|
// Wide polylines have physical world-unit width (world_half_width or taper > 0).
|
|
// Expand the quad in 3D world space on the entity's plane (perpendicular to
|
|
// the segment and the plane normal), so the rectangle remains hosted rigidly
|
|
// on the 3D plane when the camera rotates, orbits, or tilts, matching CAD
|
|
// behavior and circle.wgsl planar arcs.
|
|
let world_hw_a = select(c.world_half_width, in.taper_ratio.x * c.world_half_width, is_tapered);
|
|
let world_hw_b = select(c.world_half_width, in.taper_ratio.y * c.world_half_width, is_tapered);
|
|
let cur_world_hw = mix(world_hw_a, world_hw_b, which_end);
|
|
let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel);
|
|
|
|
let world_delta = rel_b - rel_a;
|
|
let world_len = length(world_delta);
|
|
var world_dir = vec3<f32>(1.0, 0.0, 0.0);
|
|
if world_len > 1e-6 {
|
|
world_dir = world_delta / world_len;
|
|
}
|
|
|
|
var norm = vec3<f32>(0.0, 0.0, 1.0);
|
|
if length(c.marker_normal_scale.xyz) > 1e-4 {
|
|
norm = normalize(c.marker_normal_scale.xyz);
|
|
}
|
|
|
|
var perp_world = cross(norm, world_dir);
|
|
if length(perp_world) < 1e-4 {
|
|
perp_world = cross(vec3<f32>(0.0, 1.0, 0.0), world_dir);
|
|
if length(perp_world) < 1e-4 {
|
|
perp_world = cross(vec3<f32>(1.0, 0.0, 0.0), world_dir);
|
|
}
|
|
}
|
|
perp_world = normalize(perp_world);
|
|
|
|
// A constant-width band slides each corner along the segment onto
|
|
// its joint's bisector (miter), so neighbours meet without a notch.
|
|
let miter = select(in.taper_ratio, vec2<f32>(0.0), is_tapered) * MITER_LIMIT;
|
|
let along = mix(miter.x, miter.y, which_end) * eff_hw * side;
|
|
let pos_rel = mix(rel_a, rel_b, which_end);
|
|
let world_pos = pos_rel + perp_world * (eff_hw * side) + world_dir * along;
|
|
var clip_pos = u.view_rot * vec4<f32>(world_pos, 1.0);
|
|
clip_pos = apply_draw_order(clip_pos, c.draw_depth);
|
|
|
|
final_clip = clip_pos;
|
|
out_dist = mix(in.distance_a, in.distance_b, which_end) + along * (in.distance_b - in.distance_a) / max(world_len, 1e-6);
|
|
out_cap = vec2<f32>(which_end * world_len, eff_hw * side);
|
|
out_cap_ends = vec3<f32>(world_len, world_hw_a, world_hw_b);
|
|
} else {
|
|
// Thin wires expand in screen pixels facing the camera with rounded end caps / joints.
|
|
let clip_a = u.view_rot * vec4<f32>(rel_a, 1.0);
|
|
let clip_b = u.view_rot * vec4<f32>(rel_b, 1.0);
|
|
|
|
let ndc_a = clip_a.xy / clip_a.w;
|
|
let ndc_b = clip_b.xy / clip_b.w;
|
|
|
|
let screen_a = ndc_a * u.viewport_size * 0.5;
|
|
let screen_b = ndc_b * u.viewport_size * 0.5;
|
|
|
|
let seg = screen_b - screen_a;
|
|
let seg_len = length(seg);
|
|
var dir: vec2<f32>;
|
|
if seg_len > 1e-4 {
|
|
dir = seg / seg_len;
|
|
} else {
|
|
dir = vec2<f32>(1.0, 0.0);
|
|
}
|
|
let perp = vec2<f32>(-dir.y, dir.x);
|
|
|
|
let clip_pos = mix(clip_a, clip_b, which_end);
|
|
|
|
let hw = resolve_hw(0.0, c.world_half_width, c.half_width);
|
|
let ext = which_end * 2.0 - 1.0;
|
|
let offset_px = perp * hw * side + dir * hw * ext;
|
|
let ndc_offset = offset_px / (u.viewport_size * 0.5);
|
|
var clip_pos_out = clip_pos + vec4<f32>(ndc_offset * clip_pos.w, 0.0, 0.0);
|
|
clip_pos_out = apply_draw_order(clip_pos_out, c.draw_depth);
|
|
|
|
final_clip = clip_pos_out;
|
|
out_dist = mix(in.distance_a, in.distance_b, which_end) + ext * hw * u.world_per_pixel;
|
|
out_cap = vec2<f32>(which_end * seg_len + ext * hw, hw * side);
|
|
out_cap_ends = vec3<f32>(seg_len, hw, hw);
|
|
}
|
|
|
|
let lt_scale = u.linetype_scale;
|
|
var min_elem: f32 = c.pattern_length * lt_scale;
|
|
let elems = array<f32, 8>(
|
|
c.pat0.x * lt_scale, c.pat0.y * lt_scale,
|
|
c.pat0.z * lt_scale, c.pat0.w * lt_scale,
|
|
c.pat1.x * lt_scale, c.pat1.y * lt_scale,
|
|
c.pat1.z * lt_scale, c.pat1.w * lt_scale,
|
|
);
|
|
for (var i = 0u; i < 8u; i++) {
|
|
let e = abs(elems[i]);
|
|
if e > 0.0 && e < min_elem { min_elem = e; }
|
|
}
|
|
|
|
var out: VertexOut;
|
|
out.clip_pos = final_clip;
|
|
out.color = c.color;
|
|
out.distance = out_dist;
|
|
out.cap = out_cap;
|
|
out.cap_ends = out_cap_ends;
|
|
out.pattern_length = c.pattern_length * lt_scale;
|
|
out.pat0 = c.pat0 * lt_scale;
|
|
out.pat1 = c.pat1 * lt_scale;
|
|
out.min_elem = min_elem;
|
|
out.align_end = c.align_end * lt_scale;
|
|
out.align_total = c.align_total;
|
|
// A hidden entity's arena slab stays resident; collapse it outside the
|
|
// clip volume and take the tombstone discard.
|
|
if c.hidden != 0.0 {
|
|
out.clip_pos = vec4<f32>(0.0, 0.0, -2.0, 1.0);
|
|
out.pattern_length = -1.0;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>, align_end: f32, align_total: f32) -> bool {
|
|
let elems = array<f32, 8>(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w);
|
|
var count = 0u;
|
|
for (var i = 0u; i < 8u; i++) {
|
|
if elems[i] != 0.0 { count = i + 1u; }
|
|
}
|
|
|
|
var d: f32;
|
|
if align_total > 0.0 {
|
|
// "A"-type alignment: the line begins and ends with a solid dash of
|
|
// length `align_end`. Force the two end regions lit, then phase the
|
|
// interior so the element AFTER the first dash resumes exactly at
|
|
// `align_end` (the interior meets each end dash on a gap boundary).
|
|
if dist <= align_end || dist >= align_total - align_end {
|
|
return true;
|
|
}
|
|
var first_dash = 0.0;
|
|
for (var i = 0u; i < count; i++) {
|
|
if elems[i] > 0.0 { first_dash = elems[i]; break; }
|
|
}
|
|
d = ((dist - align_end + first_dash) % pat_len + pat_len) % pat_len;
|
|
} else {
|
|
d = ((dist % pat_len) + pat_len) % pat_len;
|
|
}
|
|
|
|
var pos = 0.0f;
|
|
let dot_half = u.world_per_pixel * 0.75;
|
|
for (var i = 0u; i < count; i++) {
|
|
let elem = elems[i];
|
|
if elem == 0.0 {
|
|
let dd = abs(d - pos);
|
|
if min(dd, pat_len - dd) <= dot_half { return true; }
|
|
} else if elem > 0.0 {
|
|
if d >= pos && d < pos + elem { return true; }
|
|
pos += elem;
|
|
} else {
|
|
pos += -elem;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Round the cap overhang off: outside the segment span only pixels within
|
|
// the end's half-width radius survive, giving round joints and end caps.
|
|
fn cap_clipped(cap: vec2<f32>, cap_ends: vec3<f32>) -> bool {
|
|
if cap.x < 0.0 {
|
|
return length(cap) > cap_ends.y;
|
|
}
|
|
if cap.x > cap_ends.x {
|
|
return length(vec2<f32>(cap.x - cap_ends.x, cap.y)) > cap_ends.z;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@fragment fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
|
|
// Negative pattern length is the persistent-arena tombstone sentinel.
|
|
// Discard before cap/alpha work so deleted slabs cannot write color/depth.
|
|
if in.pattern_length < 0.0 {
|
|
discard;
|
|
}
|
|
if cap_clipped(in.cap, in.cap_ends) {
|
|
discard;
|
|
}
|
|
if in.pattern_length > 0.0 {
|
|
if in.min_elem >= u.world_per_pixel {
|
|
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1, in.align_end, in.align_total) {
|
|
discard;
|
|
}
|
|
}
|
|
}
|
|
let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5);
|
|
return vec4<f32>(in.color.rgb, alpha);
|
|
}
|
|
|
|
// Black variant: used for 3D mesh outline edges in filled render modes so the
|
|
// mesh reads as a shaded surface framed by black edges. Keeps the dash/LOD
|
|
// logic identical to `fs_main`; only the RGB is forced to black.
|
|
@fragment fn fs_black(in: VertexOut) -> @location(0) vec4<f32> {
|
|
if in.pattern_length < 0.0 {
|
|
discard;
|
|
}
|
|
if cap_clipped(in.cap, in.cap_ends) {
|
|
discard;
|
|
}
|
|
if in.pattern_length > 0.0 {
|
|
if in.min_elem >= u.world_per_pixel {
|
|
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1, in.align_end, in.align_total) {
|
|
discard;
|
|
}
|
|
}
|
|
}
|
|
let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5);
|
|
return vec4<f32>(0.0, 0.0, 0.0, alpha);
|
|
}
|