perf(hatch): tessellate fills so navigation no longer flickers (#258)

The batched hatch pass drew a screen-covering AABB quad per hatch and,
for every fragment, ran a point-in-polygon ray-cast over the whole
boundary plus the pattern test, with a discard that disabled early-Z.
Zoomed into a hatch the quad covers the screen, so the shader re-ran
per pixel every frame — the cost the PR #273 interaction-LOD worked
around by suppressing hatches during navigation, which made fills
flicker on and off while zooming.

Triangulate each boundary once (lyon, even-odd — same coverage as the
old in_polygon, islands/holes included) and rasterize those triangles
instead: no screen-filling overdraw, no per-fragment boundary test. The
fragment shader keeps the boundary test only on a per-instance fallback
path (poly_test == 1) for boundaries that fail to tessellate.
Tessellation is cached with the batch build (geometry_epoch) in local
space, so the double-single relative-to-eye precision is preserved.

With the fast path the interaction-LOD is no longer needed; it is gated
behind OCS_HATCH_LOD (default off) as a safety net for now.
This commit is contained in:
sLuCHa committed 2026-07-07 15:23:54 +02:00
1 parent fd0b8fc1d4
commit b4152ae1fe
6 files changed
+236 -58

No files matched your search

+10
View File
@@ -1198,6 +1198,16 @@ impl Scene {
.map_or(false, |t| t.elapsed().as_millis() < Self::NAV_SETTLE_MS)
}
/// Whether the interaction-LOD hatch suppression is enabled (env
/// `OCS_HATCH_LOD`), read once. Default OFF: the tessellated hatch pass is
/// cheap enough that suppression — and its zoom flicker (#258) — is not
/// needed. Kept behind the flag as a safety net for pathological drawings.
pub fn hatch_lod_enabled(&self) -> bool {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var_os("OCS_HATCH_LOD").is_some())
}
/// True for a short window around navigation — used by the subscription to
/// keep requesting frames just past the settle point so the one full-quality
/// (hatched) frame actually renders after the cursor stops, even when no
+54 -29
View File
@@ -5,7 +5,7 @@
//
// Data layout — three storage buffers fed from `HatchModel`s:
//
// InstanceBuffer (binding 0) : HatchInstance[] (~96 B each)
// InstanceBuffer (binding 0) : HatchInstance[] (128 B each)
// color, color2, mode, gradient,
// pattern angle/scale, world_origin,
// boundary_offset, boundary_count,
@@ -22,13 +22,17 @@
// DashBuffer (binding 3) : f32[] (all dash
// lengths concatenated)
//
// The vertex buffer holds 6 corner indices repeated per-instance and a
// per-vertex `instance_index` (u32 attribute) — instance_index lets us
// avoid relying on `@builtin(instance_index)` for portability. The
// vertex shader reads the per-instance AABB from the InstanceBuffer
// and emits the quad corner for that instance. When the visibility
// flag is 0, it returns a degenerate position and the fragment shader
// runs zero times for that instance.
// The vertex buffer holds each hatch's tessellated boundary triangles as
// per-vertex local-space positions (`local_xy`) plus a per-vertex
// `instance_index` (u32 attribute) — instance_index lets us avoid relying
// on `@builtin(instance_index)` for portability. An instance whose
// boundary failed to tessellate instead gets an AABB quad here, with
// `poly_test == 1` on its `HatchInstance` so the fragment shader still
// runs the `in_polygon` test to clip the quad to the real shape; on the
// tessellated fast path (`poly_test == 0`) the triangles already bound
// the fill and the fragment shader skips that test. When the visibility
// flag is 0, the vertex shader returns a degenerate position and the
// fragment shader runs zero times for that instance.
//
// Two storage usages — vertex shader reads InstanceBuffer + Boundary
// for the AABB / boundary range; fragment shader reads
@@ -68,7 +72,10 @@ pub struct HatchInstance {
/// Signed draw-order depth (-1,1); 0.0 = neutral. Applied as a clip-z
/// bias in the vertex shader so this fill orders against other types.
pub draw_depth: f32, // 112
pub _pad0: u32, // 116 (pad to 128 = 16-byte stride)
/// 1 = run the per-fragment `in_polygon` boundary test (fallback path for
/// an instance whose boundary failed to tessellate); 0 = the rasterized
/// triangles already bound the fill, skip the test.
pub poly_test: u32, // 116
pub _pad1: u32, // 120
pub _pad2: u32, // 124
}
@@ -107,18 +114,18 @@ pub struct LineFamilyGpu {
const _: () = assert!(std::mem::size_of::<LineFamilyGpu>() == 48);
/// Per-vertex data — 6 verts per instance. Instance_index here so we
/// can match WebGL2 backends that don't expose builtin(instance_index)
/// uniformly.
/// Per-vertex data — tessellated boundary triangles in local (world_origin-
/// relative) space, each carrying its instance index (avoids relying on
/// `@builtin(instance_index)` across backends). Instances whose boundary
/// failed to tessellate emit an AABB quad here instead (see `build`).
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct HatchBatchedVertex {
pub corner: u32, // 0..6 — which AABB corner
pub instance_index: u32, // index into InstanceBuffer
pub local_xy: [f32; 2], // 0 — local-space position
pub instance_index: u32, // 8 — index into InstanceBuffer
}
impl HatchBatchedVertex {
#[allow(dead_code)]
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<HatchBatchedVertex>() as u64,
@@ -127,10 +134,10 @@ impl HatchBatchedVertex {
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Uint32,
format: wgpu::VertexFormat::Float32x2,
},
wgpu::VertexAttribute {
offset: 4,
offset: 8,
shader_location: 1,
format: wgpu::VertexFormat::Uint32,
},
@@ -167,7 +174,7 @@ pub struct HatchBatchedGpu {
#[allow(dead_code)] pub instance_count: u32,
/// CPU mirror — `update_visibility` re-uploads this whole slice
/// when any flag changes. ~4 B per hatch, so 40 KB / 10 k hatches
/// per pan tick. Far cheaper than touching the 112 B-per-instance
/// per pan tick. Far cheaper than touching the 128 B-per-instance
/// data.
pub visibility: Vec<u32>,
/// CPU-side mirror of each instance's local-space AABB (world-
@@ -192,6 +199,7 @@ impl HatchBatchedGpu {
}
let mut instances: Vec<HatchInstance> = Vec::with_capacity(hatches.len());
let mut instance_tris: Vec<Vec<[f32; 2]>> = Vec::with_capacity(hatches.len());
let mut boundary: Vec<[f32; 4]> = Vec::new();
let mut families: Vec<LineFamilyGpu> = Vec::new();
let mut dashes: Vec<f32> = Vec::new();
@@ -203,6 +211,12 @@ impl HatchBatchedGpu {
}
let boundary_count = boundary.len() as u32 - boundary_offset;
// Triangulate this boundary once (cached with the whole build, which
// only re-runs when geometry_epoch advances). Empty → fall back to the
// AABB-quad + in_polygon path for this instance.
let tris = crate::scene::pipeline::hatch_tess::tessellate_boundary(&h.boundary);
instance_tris.push(tris);
let family_offset = families.len() as u32;
let mut family_count = 0u32;
@@ -293,7 +307,7 @@ impl HatchBatchedGpu {
family_offset,
family_count,
draw_depth: h.draw_depth,
_pad0: 0,
poly_test: 1,
_pad1: 0,
_pad2: 0,
});
@@ -336,7 +350,7 @@ impl HatchBatchedGpu {
family_offset,
family_count,
draw_depth: h.draw_depth,
_pad0: 0,
poly_test: if instance_tris.last().map_or(true, |t| t.is_empty()) { 1 } else { 0 },
_pad1: 0,
_pad2: 0,
});
@@ -353,15 +367,26 @@ impl HatchBatchedGpu {
dashes.push(0.0);
}
// Vertex buffer — 6 verts per instance (two triangles for the
// AABB quad), indexed by (corner, instance_index).
let mut verts: Vec<HatchBatchedVertex> = Vec::with_capacity(instances.len() * 6);
for (i, _) in instances.iter().enumerate() {
for corner in 0u32..6 {
verts.push(HatchBatchedVertex {
corner,
instance_index: i as u32,
});
// Vertex buffer — tessellated triangles per instance, or an AABB quad
// fallback (poly_test==1) when tessellation produced nothing.
let mut verts: Vec<HatchBatchedVertex> = Vec::new();
for (i, inst) in instances.iter().enumerate() {
let tris = &instance_tris[i];
if !tris.is_empty() {
for &[x, y] in tris.iter() {
verts.push(HatchBatchedVertex { local_xy: [x, y], instance_index: i as u32 });
}
} else {
// Fallback: two triangles over the local-space AABB, same corner
// order as the old shader (BL,BR,TL, BR,TR,TL).
let [xmin, ymin, xmax, ymax] = inst.aabb;
let quad = [
[xmin, ymin], [xmax, ymin], [xmin, ymax],
[xmax, ymin], [xmax, ymax], [xmin, ymax],
];
for c in quad {
verts.push(HatchBatchedVertex { local_xy: c, instance_index: i as u32 });
}
}
}
+150
View File
@@ -0,0 +1,150 @@
//! CPU triangulation of hatch boundaries. Each hatch boundary is a flat
//! list of local-space verts with NaN markers separating sub-loops
//! (islands / holes). We split on the NaN markers into contours and fill
//! them with lyon's even-odd rule — identical coverage to the per-fragment
//! `in_polygon` ray-cast in `hatch_batched.wgsl` (including the #140 island
//! fix) — so the rasterized triangles can replace that test entirely.
use lyon_tessellation::math::point;
use lyon_tessellation::path::Path;
use lyon_tessellation::{
BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, VertexBuffers,
};
/// Split a NaN-separated boundary into finite contours.
fn boundary_to_contours(boundary: &[[f32; 2]]) -> Vec<Vec<[f32; 2]>> {
let mut contours = Vec::new();
let mut cur: Vec<[f32; 2]> = Vec::new();
for &[x, y] in boundary {
if x.is_finite() && y.is_finite() {
cur.push([x, y]);
} else if !cur.is_empty() {
contours.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
contours.push(cur);
}
contours
}
/// Triangulate a hatch boundary into a flat triangle-list in local space.
/// Empty on degenerate input or tessellation failure (caller falls back to
/// the AABB-quad + `in_polygon` path for that instance).
pub fn tessellate_boundary(boundary: &[[f32; 2]]) -> Vec<[f32; 2]> {
let contours = boundary_to_contours(boundary);
let mut builder = Path::builder();
for contour in &contours {
if contour.len() < 3 {
continue;
}
builder.begin(point(contour[0][0], contour[0][1]));
for p in &contour[1..] {
builder.line_to(point(p[0], p[1]));
}
builder.end(true);
}
let path = builder.build();
let mut geometry: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
let mut tess = FillTessellator::new();
if tess
.tessellate_path(
&path,
&FillOptions::default().with_fill_rule(FillRule::EvenOdd),
&mut BuffersBuilder::new(&mut geometry, |v: FillVertex| v.position().to_array()),
)
.is_err()
{
return Vec::new();
}
let mut tris = Vec::with_capacity(geometry.indices.len());
for &idx in &geometry.indices {
if let Some(&p) = geometry.vertices.get(idx as usize) {
tris.push(p);
}
}
tris
}
#[cfg(test)]
mod tests {
use super::*;
/// True if `p` lies inside any triangle of a flat triangle-list.
fn covered(tris: &[[f32; 2]], p: [f32; 2]) -> bool {
fn sign(a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> f32 {
(a[0] - c[0]) * (b[1] - c[1]) - (b[0] - c[0]) * (a[1] - c[1])
}
tris.chunks_exact(3).any(|t| {
let (a, b, c) = (t[0], t[1], t[2]);
let d1 = sign(p, a, b);
let d2 = sign(p, b, c);
let d3 = sign(p, c, a);
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
!(has_neg && has_pos)
})
}
#[test]
fn square_tessellates_and_covers_center() {
let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
let tris = tessellate_boundary(&sq);
assert!(!tris.is_empty(), "square should tessellate");
assert_eq!(tris.len() % 3, 0, "triangle-list length must be a multiple of 3");
assert!(covered(&tris, [5.0, 5.0]), "center must be covered");
assert!(!covered(&tris, [20.0, 20.0]), "outside point must not be covered");
}
#[test]
fn even_odd_hole_is_not_covered() {
// Outer 0..10 square, NaN separator, inner 3..7 square (a hole).
let nan = f32::NAN;
let boundary = [
[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0],
[nan, nan],
[3.0, 3.0], [7.0, 3.0], [7.0, 7.0], [3.0, 7.0],
];
let tris = tessellate_boundary(&boundary);
assert!(!tris.is_empty());
assert!(covered(&tris, [1.0, 1.0]), "point in the ring must be covered");
assert!(!covered(&tris, [5.0, 5.0]), "point in the hole must NOT be covered (even-odd)");
}
#[test]
fn degenerate_boundary_returns_empty() {
assert!(tessellate_boundary(&[[0.0, 0.0], [1.0, 1.0]]).is_empty());
assert!(tessellate_boundary(&[]).is_empty());
}
#[test]
fn nested_island_even_odd_fills_center() {
// Even-odd: outer ring filled, hole empty, island inside the hole filled again.
let nan = f32::NAN;
let boundary = [
[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], // outer
[nan, nan],
[2.0, 2.0], [8.0, 2.0], [8.0, 8.0], [2.0, 8.0], // hole
[nan, nan],
[3.0, 3.0], [7.0, 3.0], [7.0, 7.0], [3.0, 7.0], // island inside the hole
];
let tris = tessellate_boundary(&boundary);
assert!(!tris.is_empty());
assert!(covered(&tris, [5.0, 5.0]), "innermost island must be filled");
assert!(!covered(&tris, [2.5, 5.0]), "the hole ring must be empty");
assert!(covered(&tris, [0.5, 5.0]), "outer ring must be filled");
}
#[test]
fn self_intersecting_boundary_is_well_formed() {
// Bowtie / figure-8: a self-intersecting single loop. Must not panic and
// must return a well-formed triangle-list (possibly empty -> caller falls
// back to the AABB-quad + in_polygon path).
let bowtie = [[0.0, 0.0], [10.0, 10.0], [0.0, 10.0], [10.0, 0.0]];
let tris = tessellate_boundary(&bowtie);
assert_eq!(tris.len() % 3, 0);
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod face3d_gpu;
pub mod hatch_batched_gpu;
pub mod hatch_tess;
pub mod hatch_gpu;
pub mod image_gpu;
pub mod mesh_gpu;
+1 -1
View File
@@ -1202,7 +1202,7 @@ impl Scene {
// actively moving; the scene-render cache holds the full-quality
// (hatched) frame once it settles. Only applied to the on-screen
// Model / paper content — the paper *sheet* keeps its fills.
skip_hatch: !inst.paper_sheet && self.navigating_lod(),
skip_hatch: self.hatch_lod_enabled() && !inst.paper_sheet && self.navigating_lod(),
geometry_epoch: self.geometry_epoch,
camera_generation: self.camera_generation,
wire_content_id,
+20 -28
View File
@@ -1,19 +1,23 @@
// Phase 4-B — batched hatch shader. All hatches in one draw call;
// per-instance data fetched from storage buffers indexed by the
// `instance_index` vertex attribute (passed from the per-vertex
// (corner, instance_index) stream so we don't depend on
// @builtin(instance_index) edge cases across backends).
// (local_xy, instance_index) stream of tessellated boundary-triangle
// positions so we don't depend on @builtin(instance_index) edge cases
// across backends).
//
// Layout — matches `hatch_batched_gpu.rs`:
// group 1 binding 0 InstanceBuffer HatchInstance[] (112 B / inst)
// group 1 binding 0 InstanceBuffer HatchInstance[] (128 B / inst)
// group 1 binding 1 BoundaryBuffer vec4<f32>[] (xy in .xy)
// group 1 binding 2 FamilyBuffer LineFamilyGpu[] (48 B / fam)
// group 1 binding 3 DashBuffer f32[]
//
// Vertex shader emits a per-instance AABB quad (two triangles). A
// `visible == 0` instance gets a NaN clip position so the fragment
// shader never runs for it — that's the GPU-side cull (Phase 4-B
// equivalent of `compute_hatch_lod` writing `hatch_skip_flags`).
// The vertex shader emits tessellated boundary triangles in local space
// (an instance whose boundary failed to tessellate falls back to an
// AABB quad with `poly_test == 1`, so the fragment shader still runs
// `in_polygon` to clip it to the real shape). A `visible == 0` instance
// gets an out-of-NDC clip position so the fragment shader never runs
// for it — that's the GPU-side cull (Phase 4-B equivalent of
// `compute_hatch_lod` writing `hatch_skip_flags`).
// ── Group 0: shared frame uniforms (matches hatch.wgsl) ──────────────────
@@ -54,7 +58,7 @@ struct HatchInstance {
family_offset: u32,
family_count: u32,
draw_depth: f32, // signed (-1,1) draw-order bias; 0 = neutral
_pad0: u32,
poly_test: u32, // 1 = run in_polygon (fallback), 0 = skip
_pad1: u32,
_pad2: u32,
}
@@ -91,7 +95,7 @@ struct LineFamily {
// ── Vertex shader ────────────────────────────────────────────────────────
struct VIn {
@location(0) corner: u32,
@location(0) local_xy: vec2<f32>,
@location(1) instance_index: u32,
}
@@ -101,21 +105,6 @@ struct VOut {
@location(1) @interpolate(flat) instance_index: u32,
}
fn corner_xy(c: u32, aabb: vec4<f32>) -> vec2<f32> {
// Two-triangle quad covering the AABB:
// 0 BL, 1 BR, 2 TL, 3 BR, 4 TR, 5 TL
let xmin = aabb.x; let ymin = aabb.y;
let xmax = aabb.z; let ymax = aabb.w;
switch c {
case 0u: { return vec2<f32>(xmin, ymin); }
case 1u: { return vec2<f32>(xmax, ymin); }
case 2u: { return vec2<f32>(xmin, ymax); }
case 3u: { return vec2<f32>(xmax, ymin); }
case 4u: { return vec2<f32>(xmax, ymax); }
default: { return vec2<f32>(xmin, ymax); }
}
}
@vertex fn vs_main(v: VIn) -> VOut {
var o: VOut;
let inst = instances[v.instance_index];
@@ -132,7 +121,7 @@ fn corner_xy(c: u32, aabb: vec4<f32>) -> vec2<f32> {
return o;
}
let local = corner_xy(v.corner, inst.aabb);
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
@@ -270,9 +259,12 @@ fn check_family(
@fragment fn fs_main(v: VOut) -> @location(0) vec4<f32> {
let inst = instances[v.instance_index];
// 1. Boundary test.
if !in_polygon(v.xz, inst.boundary_offset, inst.boundary_count) {
discard;
// 1. Boundary test — only on the fallback path (poly_test==1). On the
// tessellated fast path the triangles already bound the fill.
if inst.poly_test == 1u {
if !in_polygon(v.xz, inst.boundary_offset, inst.boundary_count) {
discard;
}
}
// 2. Mode dispatch.