diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index d1ce2a00..15c24b3c 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -319,27 +319,31 @@ fn check_family( return mix(inst.color2, inst.color, t); } - // 3. Pattern LOD: when the densest family's spacing projects below - // 2 px, lines blur into a solid fill — return color instead of - // iterating every family (mirrors Phase 3.3 LOD in hatch.wgsl). - if u.world_per_pixel > 0.0 && inst.family_count > 0u { - var min_spacing_world: f32 = 1.0e30; + // 3. 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 s = abs(families[inst.family_offset + i].perp_step) * inst.scale; - if s > 0.0 && s < min_spacing_world { - min_spacing_world = s; + 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 min_spacing_world / u.world_per_pixel < 2.0 { - return inst.color; - } + } + if all_families_subpixel { + return inst.color; } // 4. 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++) { - if check_family(v.xz, ddx_xz, ddy_xz, families[inst.family_offset + i], cos_off, sin_off, inst.scale) { + let fam = families[inst.family_offset + i]; + if check_family(v.xz, ddx_xz, ddy_xz, fam, cos_off, sin_off, inst.scale) { return inst.color; } } diff --git a/src/shaders/hatch_texture.wgsl b/src/shaders/hatch_texture.wgsl index 94e7f602..ac13f2d2 100644 --- a/src/shaders/hatch_texture.wgsl +++ b/src/shaders/hatch_texture.wgsl @@ -272,25 +272,29 @@ fn check_family( return mix(h.color2, h.color, t); } - // Pattern LOD: when the densest family projects below 2 px, lines blur into - // a solid fill — return solid instead of looping every family. - if u.world_per_pixel > 0.0 && h.n_families > 0u { - var min_spacing_world: f32 = 1.0e30; + // 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 = h.n_families > 0u && u.world_per_pixel > 0.0; + if all_families_subpixel { for (var i = 0u; i < h.n_families; i++) { - let s = abs(load_family(i).perp_step) * h.scale; - if s > 0.0 && s < min_spacing_world { - min_spacing_world = s; + let spacing_world = abs(load_family(i).perp_step) * h.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 min_spacing_world / u.world_per_pixel < 2.0 { - return h.color; - } + } + if all_families_subpixel { + return h.color; } let cos_off = cos(h.angle_offset); let sin_off = sin(h.angle_offset); for (var i = 0u; i < h.n_families; i++) { - if check_family(v.xz, ddx_xz, ddy_xz, load_family(i), cos_off, sin_off, h.scale) { + let fam = load_family(i); + if check_family(v.xz, ddx_xz, ddy_xz, fam, cos_off, sin_off, h.scale) { return h.color; } } diff --git a/tests/hatch_shader_lod.rs b/tests/hatch_shader_lod.rs new file mode 100644 index 00000000..9d46b607 --- /dev/null +++ b/tests/hatch_shader_lod.rs @@ -0,0 +1,40 @@ +const STORAGE_SHADER: &str = include_str!("../src/shaders/hatch.wgsl"); +const TEXTURE_SHADER: &str = include_str!("../src/shaders/hatch_texture.wgsl"); + +fn assert_all_family_lod(source: &str, name: &str) { + let module = naga::front::wgsl::parse_str(source) + .unwrap_or_else(|error| panic!("{name} WGSL must parse: {error}")); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::empty(), + ) + .validate(&module) + .unwrap_or_else(|error| panic!("{name} WGSL must validate: {error}")); + + assert!( + source.contains("var all_families_subpixel"), + "{name} must track the all-family LOD transition" + ); + assert!( + source.contains("if all_families_subpixel"), + "{name} must solid-fill only at the all-family transition" + ); + assert!( + !source.contains("if is_subpixel") && !source.contains("visible_families"), + "{name} must not skip individual families before LOD activates" + ); + assert!( + !source.contains("min_spacing_world"), + "{name} must not solid-fill a complex pattern because of one dense family" + ); +} + +#[test] +fn storage_hatch_lod_waits_for_every_family() { + assert_all_family_lod(STORAGE_SHADER, "storage hatch"); +} + +#[test] +fn texture_hatch_lod_waits_for_every_family() { + assert_all_family_lod(TEXTURE_SHADER, "texture hatch"); +}