From cfe53bd7895e61d6ee3d9e1a7ed7abbcb19267ea Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 17 May 2026 20:04:16 +0300 Subject: [PATCH] fix(pipeline): batched-hatch pattern math + invisible-instance clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scene/pipeline/hatch_batched_gpu.rs | 25 +++++++++++++++---------- src/shaders/hatch_batched.wgsl | 9 ++++++--- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/scene/pipeline/hatch_batched_gpu.rs b/src/scene/pipeline/hatch_batched_gpu.rs index 17c4bbe6d..5899e2a57 100644 --- a/src/scene/pipeline/hatch_batched_gpu.rs +++ b/src/scene/pipeline/hatch_batched_gpu.rs @@ -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, }; diff --git a/src/shaders/hatch_batched.wgsl b/src/shaders/hatch_batched.wgsl index 8754fcab6..d914ee930 100644 --- a/src/shaders/hatch_batched.wgsl +++ b/src/shaders/hatch_batched.wgsl @@ -100,10 +100,13 @@ fn corner_xy(c: u32, aabb: vec4) -> vec2 { 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(0.0 / 0.0, 0.0 / 0.0, 0.0 / 0.0, 1.0); + 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;