From 6ae3a9d91d19a0c42072addb59446fe41366d667 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 17 May 2026 15:36:15 +0300 Subject: [PATCH] fix(pipeline): hatch frustum cull tests AABB in wrong space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- src/scene/pipeline/hatch_gpu.rs | 19 ++++++++++++++++++- src/scene/pipeline/mod.rs | 22 +++++++++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index a41a8c3c4..88e7bdc6f 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -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, diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 358352a12..e9d7c1275 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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