Files
OneCAD-Draft/src/shaders/wire.wgsl
T

379 lines
16 KiB
WebGPU Shading Language
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Wire shader — renders 1-D CAD entities as screen-aligned quads.
// Topology: TriangleList, 6 vertices drawn per INSTANCE.
//
// One instance = one segment. The six vertex IDs map to the corners of a
// two-triangle quad; the vertex shader derives `which_end` (0=A end, 1=B end)
// and `side` (±1 perpendicular) from `@builtin(vertex_index)` and expands the
// quad by `half_width` pixels perpendicular to the segment direction in
// screen space.
//
// Linetype is applied entirely on the GPU:
// • distance = cumulative arc-length, linearly interpolated from
// (distance_a, distance_b) by `which_end`.
// • pattern_length > 0 enables the dash test; 0 = solid (no discard).
// • pat0/pat1 encode up to 8 elements: positive=dash, negative=gap,
// exactly 0=dot (rendered as a fixed ~1 px mark). Trailing 0.0 slots are
// padding; the real element count is (index of last non-zero) + 1.
struct Uniforms {
viewport_size: vec2<f32>,
world_per_pixel: f32,
// LWDISPLAY toggle: 0.0 = force 1 px (half_width 0.5), 1.0 = use the
// per-instance baked half_width. Lets the LWT button switch without
// retessellating.
lwdisplay_enable: f32,
// Mesh flat-shade flag (unused here; kept so the field offsets match
// the shared Uniforms buffer layout).
flat_shade: f32,
// Transparency-display toggle: 1.0 = honour baked alpha, 0.0 = force
// every line opaque.
transparency_enable: f32,
linetype_scale: f32,
lineweight_scale: f32,
// ── Relative-to-eye (double-single) ──────────────────────────────────
// view_rot is the rotation-only view-projection; vertices subtract the eye
// (eye_high + eye_low, two f32 emulating f64) before transforming, so the
// large eye translation never enters the f32 matrix → no large-coordinate
// jitter on pan / zoom / rotate.
view_rot: mat4x4<f32>,
eye_high: vec3<f32>,
_pad_eh: f32,
eye_low: vec3<f32>,
_pad_el: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
// Scalars are packed into vec4 attributes: WebGL2 / WebGPU cap vertex
// attributes at 16, and the unpacked layout had grown to 17 — the wire
// pipeline failed to build and the web viewport drew no lines at all (#414).
struct InstanceIn {
@location(0) pos_a: vec3<f32>,
@location(1) pos_b: vec3<f32>,
@location(2) color: vec4<f32>,
// distance_a, distance_b, half_width, pattern_length
@location(3) dists: vec4<f32>,
@location(4) pat0: vec4<f32>,
@location(5) pat1: vec4<f32>,
// draw_depth, align_end ("A"-type end-dash length), align_total (total
// wire length), world_half_width (wide-polyline band; 0 = normal wire)
@location(6) misc: vec4<f32>,
// Double-single low residuals of the endpoints.
@location(7) pos_a_low: vec3<f32>,
@location(8) pos_b_low: vec3<f32>,
// Per-endpoint world half-width for a tapered band (0 = use the constant).
@location(9) taper: vec2<f32>,
@location(10) marker_origin_high: vec4<f32>,
@location(11) marker_origin_low: vec4<f32>,
@location(12) marker_normal_scale: vec4<f32>,
}
const MODEL_LINEWEIGHT_BOOST: f32 = 2.0;
const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.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>,
// World length of the smallest non-zero dash / gap element of this
// instance. Flat-interpolated (constant per instance) so the
// fragment stage can short-circuit the dash test when every gap
// projects below one pixel on screen. See the LOD branch in
// `fs_main`.
@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).
fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 {
if taper >= 0.0 { return max(taper / 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>, instance: InstanceIn) -> vec3<f32> {
if instance.marker_normal_scale.w <= 0.0 {
return (position_high - u.eye_high) + (position_low - u.eye_low);
}
let origin_high = instance.marker_origin_high.xyz;
let origin_low = instance.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(instance.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 = instance.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 {
// Two-triangle quad corner table:
// vid 0,1,2 = (A,-1) (B,-1) (B,+1)
// vid 3,4,5 = (A,-1) (B,+1) (A,+1)
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];
// Double-single relative-to-eye: subtract the eye from each endpoint with
// both halves of the f64-emulating pair, then transform by the rotation-only
// view-projection. (pos_high − eye_high) is exact in f32 for same-magnitude
// operands (Sterbenz); adding (pos_low − eye_low) restores the residual both
// the vertex and the eye would otherwise lose — so geometry stays put at
// UTM-scale coordinates and after a cross-drawing paste, with no jitter.
let rel_a = marker_relative(in.pos_a, in.pos_a_low, in);
let rel_b = marker_relative(in.pos_b, in.pos_b_low, in);
let is_world = in.misc.w > 0.0 || in.taper.x >= 0.0 || in.taper.y >= 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 (in.misc.w or in.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(in.misc.w, in.taper.x, in.taper.x >= 0.0);
let world_hw_b = select(in.misc.w, in.taper.y, in.taper.y >= 0.0);
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(in.marker_normal_scale.xyz) > 1e-4 {
norm = normalize(in.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);
let pos_rel = mix(rel_a, rel_b, which_end);
let world_pos = pos_rel + perp_world * (eff_hw * side);
var clip_pos = u.view_rot * vec4<f32>(world_pos, 1.0);
clip_pos = apply_draw_order(clip_pos, in.misc.x);
final_clip = clip_pos;
out_dist = mix(in.dists.x, in.dists.y, which_end);
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(-1.0, in.misc.w, in.dists.z);
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, in.misc.x);
final_clip = clip_pos_out;
out_dist = mix(in.dists.x, in.dists.y, 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);
}
// Smallest non-zero dash / gap element, in world units. Used by
// the fragment stage to decide when the pattern's finest feature
// would render below one pixel and should collapse to a solid line.
let lt_scale = u.linetype_scale;
var min_elem: f32 = in.dists.w * lt_scale;
let elems = array<f32, 8>(
in.pat0.x * lt_scale, in.pat0.y * lt_scale,
in.pat0.z * lt_scale, in.pat0.w * lt_scale,
in.pat1.x * lt_scale, in.pat1.y * lt_scale,
in.pat1.z * lt_scale, in.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 = in.color;
out.distance = out_dist;
out.cap = out_cap;
out.cap_ends = out_cap_ends;
out.pattern_length = in.dists.w * lt_scale;
out.pat0 = in.pat0 * lt_scale;
out.pat1 = in.pat1 * lt_scale;
out.min_elem = min_elem;
out.align_end = in.misc.y * lt_scale;
out.align_total = in.misc.z;
return out;
}
// Returns true if arc-length `dist` falls inside a dash or on a dot.
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);
// Real element count = (index of last non-zero) + 1. Trailing 0.0 slots
// are padding; a 0.0 within this range is a real dot.
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;
// A dot is a zero-length element: render it as a fixed ~1.5 px mark
// (half-width ~0.75 px in world units) so it stays visible at any zoom
// instead of vanishing with its zero world-length. Mirrors the hatch
// shader's pixel-snapped dot. (#149)
let dot_half = u.world_per_pixel * 0.75;
for (var i = 0u; i < count; i++) {
let elem = elems[i];
if elem == 0.0 {
// Dot centred at `pos` (zero length); light a small mark, wrapped
// around the pattern so a dot at 0 also covers the seam at pat_len.
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; } // inside a dash
pos += elem;
} else {
pos += -elem; // skip a gap
}
}
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 {
// LOD: once the pattern's smallest feature drops below ~1 px
// on screen, dash gaps alias / shimmer (or vanish completely)
// and the user reads the line as solid anyway. Skip the dash
// test and return solid colour — also saves the per-fragment
// arc-length math + `discard`.
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;
}
}
}
// Transparency display off → force the line opaque.
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);
}