fix(pipeline): hatch frustum cull tests AABB in wrong space

`HatchGpu.world_aabb` was built by iterating `model.boundary` — but
those vertices are stored as f32 offsets from `model.world_origin`
(precision anchor for UTM-scale WCS). So the AABB was in
*origin-relative* space, not the local space `view_proj` projects
from. `aabb_offscreen` (Phase 2.3) then asked "is this rect near (0,0)
in NDC?" instead of "is this rect at the hatch's actual position?" —
which culled in-view hatches whenever the camera moved off the
origin and rendered out-of-view ones near it.

Sub-pixel LOD (`aabb_below_pixel`, Phase 3.3) survived because it
measures a projected diagonal, which is translation-invariant.

Fixes:
- Add `world_origin` back when building `HatchGpu.world_aabb`, so the
  stored rect is in absolute local space.
- 25 % margin on the viewport rect in `aabb_offscreen`, matching the
  wire path's `view_world_aabb` margin — keeps hatches at the edge
  rendered while panning.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-17 15:36:15 +03:00
1 parent 38f9733eb1
commit 6ae3a9d91d
2 files changed
+35 -6

No files matched your search

+18 -1
View File
@@ -349,11 +349,28 @@ impl HatchGpu {
],
});
// Boundary vertices are stored as f32 offsets from
// `model.world_origin` (so f32 keeps precision at UTM-scale
// WCS). The pipeline-level frustum cull (Phase 2.3
// `aabb_offscreen`) projects through `view_proj`, which expects
// local-space (world_offset-subtracted) coordinates — the same
// space as `world_origin`. Add the origin back so `world_aabb`
// is in that absolute local space rather than relative to the
// anchor. (Sub-pixel LOD via `aabb_below_pixel` is unaffected:
// it measures a projected diagonal, invariant under translation.)
let ox = model.world_origin[0] as f32;
let oy = model.world_origin[1] as f32;
let world_aabb = if min_x.is_finite() && min_y.is_finite() {
[min_x + ox, min_y + oy, max_x + ox, max_y + oy]
} else {
[min_x, min_y, max_x, max_y]
};
Self {
vertex_buffer,
bind_group,
vp_scissor: model.vp_scissor,
world_aabb: [min_x, min_y, max_x, max_y],
world_aabb,
_uniform_buf,
_boundary_buf,
_family_buf,
+17 -5
View File
@@ -1327,9 +1327,16 @@ fn aabb_diagonal_pixels(
}
/// `true` when the world-XY AABB projects entirely outside the
/// viewport rect. Phase 2.2 mesh-frustum cull. Equivalent to a 2D
/// bounding-box rejection test in NDC; same 4-corner projection used
/// for LOD picking, so the extra cost is negligible.
/// viewport rect (extended by `MARGIN_FRAC` to absorb pan inertia and
/// avoid edge pop-in). Phase 2.2 mesh-frustum / Phase 2.3 hatch +
/// wipeout cull. Equivalent to a 2D bounding-box rejection test in
/// NDC; uses the same 4-corner projection that LOD picking already
/// does, so the extra cost is negligible.
///
/// IMPORTANT: the AABB must be in the same local space (world_offset
/// subtracted) that `view_proj` expects. `HatchGpu.world_aabb` rebuilds
/// the absolute local-space rect from `model.world_origin + boundary
/// extents` for this reason; meshes already store an absolute rect.
fn aabb_offscreen(
aabb: [f32; 4],
view_proj: glam::Mat4,
@@ -1360,8 +1367,13 @@ fn aabb_offscreen(
if py < min_py { min_py = py; }
if py > max_py { max_py = py; }
}
// Reject if projected AABB sits fully to one side of the viewport.
max_px < 0.0 || min_px > w || max_py < 0.0 || min_py > h
// 25% pad on each side — matches `view_world_aabb` (wire path),
// keeps edge geometry rendered while panning before the next
// upload reaches the GPU.
const MARGIN_FRAC: f32 = 0.25;
let mx = w * MARGIN_FRAC;
let my = h * MARGIN_FRAC;
max_px < -mx || min_px > w + mx || max_py < -my || min_py > h + my
}
/// Return `true` when the world-XY AABB's screen-space size is below the