// Batched hatch shader. Kernel mesh vertices carry their source instance. // // Layout — matches `hatch_gpu/storage.rs`: // group 1 binding 0 InstanceBuffer HatchInstance[] // group 1 binding 1 FamilyBuffer LineFamilyGpu[] // group 1 binding 2 DashBuffer f32[] // group 1 binding 3 Visibility u32[] // ── Group 0: shared frame uniforms (matches hatch.wgsl) ────────────────── struct Uniforms { viewport_size: vec2, world_per_pixel: f32, lwdisplay_enable: f32, flat_shade: f32, transparency_enable: f32, _linetype_scale: f32, lineweight_scale: f32, // Relative-to-eye (double-single): see wire.wgsl. view_rot: mat4x4, eye_high: vec3, _pad_eh: f32, eye_low: vec3, _pad_el: f32, } @group(0) @binding(0) var u: Uniforms; // ── Group 1: batched hatch storage ─────────────────────────────────────── struct HatchInstance { color: vec4, color2: vec4, aabb: vec4, // (xmin, ymin, xmax, ymax) — local space world_origin: vec2, // anchor high half world_origin_low: vec2, // anchor low residual (double-single) angle_offset: f32, scale: f32, grad_cos: f32, grad_sin: f32, grad_min: f32, grad_range: f32, mode: u32, // 0=pattern, 1=solid, 2=gradient visible: u32, // 0 = skip (CPU writes via compute_hatch_lod) family_offset: u32, family_count: u32, draw_depth: f32, // signed (-1,1) draw-order bias; 0 = neutral grad_kind: u32, // shape (0=linear,1=cyl,2=sph,3=hemi,4=curved), bit4=invert } const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; struct LineFamily { cos_a: f32, sin_a: f32, x0: f32, y0: f32, dx: f32, dy: f32, perp_step: f32, along_step: f32, line_width: f32, period: f32, n_dashes: u32, dash_offset: u32, } @group(1) @binding(0) var instances: array; @group(1) @binding(1) var families: array; @group(1) @binding(2) var dashes: array; // Per-instance visibility (Phase 4-B sub-pixel + frustum skip). // CPU writes `1` to draw / `0` to skip every frame; vertex shader // emits an out-of-NDC clip position for 0-instances so the GPU // rasterizer culls the primitive before any fragment runs. @group(1) @binding(3) var visibility: array; // ── Vertex shader ──────────────────────────────────────────────────────── struct VIn { @location(0) local_xy: vec2, @location(1) instance_index: u32, @location(2) translation: vec2, @location(3) translation_low: vec2, @location(4) draw_depth: f32, @location(5) visible: u32, } struct VOut { @builtin(position) clip: vec4, @location(0) xz: vec2, @location(1) @interpolate(flat) instance_index: u32, } @vertex fn vs_main(v: VIn) -> VOut { var o: VOut; let inst = instances[v.instance_index]; // Per-frame visibility (CPU-driven sub-pixel + frustum skip). // 0 → emit a clip position whose x/y exceed |w| so the GPU // frustum-culls the primitive and no fragment runs. (WGSL // forbids literal NaN so this out-of-NDC trick replaces the // usual NaN-degenerate-triangle.) if visibility[v.instance_index] == 0u || v.visible == 0u { o.clip = vec4(2.0, 2.0, 2.0, 1.0); o.xz = vec2(0.0, 0.0); o.instance_index = v.instance_index; return o; } let local = v.local_xy; // Double-single relative-to-eye: the anchor high half cancels exactly // against eye_high (Sterbenz); local + anchor low + (−eye_low) carry the // residual. `local` is small (boundary-relative), so adding it in the low // term keeps full precision at UTM-scale anchors. let hi = vec3(inst.world_origin.x + v.translation.x - u.eye_high.x, inst.world_origin.y + v.translation.y - u.eye_high.y, -u.eye_high.z); let lo = vec3(local.x + inst.world_origin_low.x + v.translation_low.x - u.eye_low.x, local.y + inst.world_origin_low.y + v.translation_low.y - u.eye_low.y, -u.eye_low.z); o.clip = u.view_rot * vec4(hi + lo, 1.0); o.clip = apply_draw_order(o.clip, inst.draw_depth + v.draw_depth); o.xz = local; o.instance_index = v.instance_index; return o; } // ── Per-family hatch test (same math as hatch.wgsl, dashes from // global DashBuffer instead of per-hatch FamilyBatch) ──────────────────── // `ddx_xz`/`ddy_xz` are screen-space derivatives of `xz`, taken once in // fs_main: derivative builtins must run in uniform control flow, and the // per-family loop's early return makes later iterations non-uniform. fn display_lineweight(width: f32) -> f32 { if u.lineweight_scale < 0.0 { let base = select( min(width * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX), 1.0, width <= 1.0, ); return max(base * -u.lineweight_scale, 1.0); } return max(width * u.lineweight_scale, 1.0); } fn check_family( xz: vec2, ddx_xz: vec2, ddy_xz: vec2, fam: LineFamily, cos_off: f32, sin_off: f32, scale: f32, ) -> bool { let cos_a = fam.cos_a * cos_off - fam.sin_a * sin_off; let sin_a = fam.sin_a * cos_off + fam.cos_a * sin_off; let ox = (fam.x0 * cos_off - fam.y0 * sin_off) * scale; let oz = (fam.x0 * sin_off + fam.y0 * cos_off) * scale; let px = xz.x - ox; let pz = xz.y - oz; let perp_step = fam.perp_step * scale; let perp = -px * sin_a + pz * cos_a; let k = round(perp / perp_step); let dperp = perp - k * perp_step; let d = abs(dperp); // perp is linear in xz (offsets are constant), so its derivatives are the // xz derivatives rotated into the family frame. let half_px = length(vec2( -ddx_xz.x * sin_a + ddx_xz.y * cos_a, -ddy_xz.x * sin_a + ddy_xz.y * cos_a, )) * 0.5; let width_px = select(1.0, display_lineweight(fam.line_width), u.lwdisplay_enable > 0.5); let half_line = half_px * width_px; // World units per screen pixel on each axis — used to light exactly the // one pixel that contains a dot's centre (pixel-snapped, so the dot stays // a steady single pixel at any pattern angle instead of flickering). let wpx = length(vec2(ddx_xz.x, ddy_xz.x)); let wpy = length(vec2(ddx_xz.y, ddy_xz.y)); // A fragment within ~1px of a line may be a dot; everything further out is // empty fill. (A dot's pixel sits on a line, so its perp offset is < 1px.) if d > max(half_line, half_px * 2.0) { return false; } if fam.n_dashes == 0u { return d <= half_line; } let along_step = fam.along_step * scale; let period = fam.period * scale; let along = px * cos_a + pz * sin_a; let t = along - k * along_step; let t_mod = ((t % period) + period) % period; var pos = 0.0; for (var j = 0u; j < fam.n_dashes; j++) { let sv = dashes[fam.dash_offset + j] * scale; if sv > 0.0 { if d <= half_line && t_mod >= pos && t_mod < pos + sv { return true; } pos = pos + sv; } else if sv < 0.0 { pos = pos - sv; } else { // Dot: signed distance to its lattice centre (along the line and // across lines), rotated back to world, then snapped to the pixel // grid. The dot grid rotates with the pattern; the lit pixel does // not, so it never thins/flickers. let dtv = (t - pos) - round((t - pos) / period) * period; let owx = -dtv * cos_a + dperp * sin_a; let owy = -dtv * sin_a - dperp * cos_a; let dot_half = width_px * 0.5; if abs(owx / wpx) <= dot_half && abs(owy / wpy) <= dot_half { return true; } } } return false; } // ── Fragment shader ────────────────────────────────────────────────────── // Entity transparency shows only while transparency display is on. fn shown(color: vec4) -> vec4 { return vec4(color.rgb, select(1.0, color.a, u.transparency_enable > 0.5)); } @fragment fn fs_main(v: VOut) -> @location(0) vec4 { // Taken here, in uniform control flow — see check_family. let ddx_xz = dpdx(v.xz); let ddy_xz = dpdy(v.xz); let inst = instances[v.instance_index]; // Mode dispatch. if inst.mode == 1u { return shown(inst.color); } else if inst.mode == 2u { let proj = v.xz.x * inst.grad_cos + v.xz.y * inst.grad_sin; var t = clamp((proj - inst.grad_min) / inst.grad_range, 0.0, 1.0); // Shape profile: cylinder mirrors around the middle, curved eases in. let k = inst.grad_kind & 15u; if k == 1u { t = 1.0 - abs(2.0 * t - 1.0); } else if k == 4u { t = t * t; } if (inst.grad_kind & 16u) != 0u { t = 1.0 - t; } return shown(mix(inst.color, inst.color2, t)); } else if inst.mode == 3u { // Radial gradient: centre is (grad_cos, grad_sin), radius is grad_range. let d = length(v.xz - vec2(inst.grad_cos, inst.grad_sin)); var t = clamp(d / inst.grad_range, 0.0, 1.0); // Hemispherical shades faster near the centre (dome profile). if (inst.grad_kind & 15u) == 3u { t = sqrt(t); } if (inst.grad_kind & 16u) != 0u { t = 1.0 - t; } // Radial stops run OUTSIDE-IN: colour 1 at the rim, colour 2 at the // centre; the INV bit above swaps them back. return shown(mix(inst.color2, inst.color, t)); } // Pattern LOD. Keep every family visible until all family spacings // project below 2 px, then substitute one solid fill. A single dense // family must neither hide itself nor turn a complex hatch solid. var all_families_subpixel = inst.family_count > 0u && u.world_per_pixel > 0.0; if all_families_subpixel { for (var i = 0u; i < inst.family_count; i++) { let spacing_world = abs(families[inst.family_offset + i].perp_step) * inst.scale; if spacing_world <= 0.0 { all_families_subpixel = false; } else if spacing_world / u.world_per_pixel >= 2.0 { all_families_subpixel = false; } } } if all_families_subpixel { return shown(inst.color); } // Pattern evaluation. let cos_off = cos(inst.angle_offset); let sin_off = sin(inst.angle_offset); for (var i = 0u; i < inst.family_count; i++) { let fam = families[inst.family_offset + i]; if check_family(v.xz, ddx_xz, ddy_xz, fam, cos_off, sin_off, inst.scale) { return shown(inst.color); } } discard; // Unreachable: `discard` kills the fragment before this runs, but // DX12/FXC reports E_FAIL X3507 ("not all control paths return a // value") without an explicit return after every terminal discard. return vec4(0.0); }