fix(pipeline): batched-hatch pattern math + invisible-instance clip

Two issues in the step-3 batched path surfaced during testing:

1. WGSL rejects compile-time NaN literals (`0.0 / 0.0`), so the
   visibility-skip path that emitted a NaN clip position failed
   `Device::create_shader_module` validation. Replaced with an
   out-of-frustum clip position `(2, 2, 2, 1)` — the GPU rasterizer
   clips the primitive and the fragment shader never runs.

2. Pattern hatches rendered with the wrong spacing / direction. The
   batched builder was rotating `perp_step` / `along_step` into the
   global frame, but the per-hatch shader (`hatch.wgsl`) expects them
   in QCAD PAT local-frame convention: `perp_step = family.dy`,
   `along_step = family.dx`, with the cos_off/sin_off rotation applied
   inside the shader. Now matches `build_family_batch` from
   `hatch_gpu.rs` line-for-line. `line_width` also reset to 0 (the
   shader picks 1 px from a screen-space derivative; the stored field
   is unused).

3. Padding's `max_spacing` likewise dropped its bogus rotation and
   now uses `|dy|` per family, matching the new perp_step convention.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-17 20:04:16 +03:00
1 parent 3593e9dabc
commit cfe53bd789
2 files changed
+21 -13

No files matched your search

+15 -10
View File
@@ -192,11 +192,16 @@ impl HatchBatchedGpu {
dashes.push(d);
}
let n_dashes = (dashes.len() as u32 - dash_offset).min(u32::MAX);
let perp_step = -fam.dx * fam.angle_deg.to_radians().sin()
+ fam.dy * fam.angle_deg.to_radians().cos();
let along_step = fam.dx * fam.angle_deg.to_radians().cos()
+ fam.dy * fam.angle_deg.to_radians().sin();
let line_width = perp_step.abs() * 0.08;
// QCAD PAT local-frame convention (mirrors
// `build_family_batch` in hatch_gpu.rs): `dy` is
// the perpendicular spacing, `dx` is the along-line
// phase shift — both in family-local coords. The
// shader applies cos_off/sin_off to rotate them.
let perp_step = fam.dy;
let along_step = fam.dx;
// Screen-space derivative drives 1-px line width
// in the shader; this stored field is unused.
let line_width = 0.0_f32;
let period: f32 = fam.dashes.iter().map(|d| d.abs()).sum();
families.push(LineFamilyGpu {
cos_a: fam.angle_deg.to_radians().cos(),
@@ -208,7 +213,7 @@ impl HatchBatchedGpu {
perp_step,
along_step,
line_width,
period,
period: if n_dashes > 0 { period } else { 0.0 },
n_dashes,
dash_offset,
});
@@ -263,13 +268,13 @@ impl HatchBatchedGpu {
// family origin. Mirrors the per-hatch shader's quad sizing
// logic — `diag * 0.8 + max_spacing * 2 * scale`.
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
// `perp_step.abs()` per family — uses the same QCAD local-
// frame convention as `LineFamilyGpu.perp_step` above so
// the quad padding matches what the shader will sample.
let max_spacing = match &h.pattern {
HatchPattern::Pattern(fs) => fs
.iter()
.map(|f| {
let a = f.angle_deg.to_radians();
(-f.dx * a.sin() + f.dy * a.cos()).abs()
})
.map(|f| f.dy.abs())
.fold(0.0f32, f32::max),
_ => 5.0,
};
+6 -3
View File
@@ -100,10 +100,13 @@ fn corner_xy(c: u32, aabb: vec4<f32>) -> vec2<f32> {
var o: VOut;
let inst = instances[v.instance_index];
// CPU-driven visibility flag (Phase 4-B frustum skip). NaN clip
// position degenerates the triangle so no fragment runs for it.
// CPU-driven visibility flag (Phase 4-B frustum skip). Emit a clip
// position whose x/y exceed |w|, so the GPU frustum-culls the
// primitive and no fragment runs for it. (WGSL forbids literal NaN
// so we can't go that route; pushing the vertex out of the unit
// cube is the equivalent degenerate-triangle trick.)
if inst.visible == 0u {
o.clip = vec4<f32>(0.0 / 0.0, 0.0 / 0.0, 0.0 / 0.0, 1.0);
o.clip = vec4<f32>(2.0, 2.0, 2.0, 1.0);
o.xz = vec2<f32>(0.0, 0.0);
o.instance_index = v.instance_index;
return o;