wgpu backend: per-instance LOD0/LOD1 pick in the cull

Stage 8 of the wgpu port. cullModelCpu now buckets each visible instance
by (mesh_id, lod) instead of (mesh_id), and emits one MeshDraw record
per non-empty bucket. LOD pick projects the instance's world-space
bounding sphere to pixels via

    projected_px = world_radius * focal_px / view_z

where focal_px = viewport_h / (2 * tan(fov_y/2)) and view_z is the
forward·(center-eye) depth. When projected_px < lod1_pixel_threshold_
AND the mesh has a baked LOD1 slice (MeshInfo.lod1_index_count > 0),
the instance draws the LOD1 index range instead of LOD0; baseVertex
and the vertex storage are shared between LODs.

mesh_draws can now grow to up to 2 × meshes.size() per frame (LOD0 + LOD1
slice per mesh). The visible_buffer layout per mesh becomes
[LOD0 instances | LOD1 instances] contiguous, with each MeshDraw
referencing its own firstInstance offset.

lod1_pixel_threshold_ defaults to 30 (mirrors AppSettings::
lod1PixelThreshold() in the GL backend); set to 0 to disable LOD1
entirely (always LOD0). AppSettings port lands in a later commit.

Verified: basic.ifc (3 tiny instances, no LOD1 baked by meshoptimizer
since each mesh is well under the 500-tri threshold) renders pixel-
identical to pre-stage-8 — proves the all-LOD0 path is preserved.
Real LOD switching needs a sidecar where buildLods produced LOD1 slices.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-27 14:47:23 +10:00
parent 4596f2e584
commit 244a145255
2 changed files with 102 additions and 22 deletions
+19 -4
View File
@@ -116,10 +116,20 @@ private:
bool computeSceneAabb(float mn[3], float mx[3]) const;
// Cull `m`'s instances against the supplied frustum planes (world-space,
// ax+by+cz+d >= 0 means inside), bucket survivors by mesh_id, and write
// the flat visible-index list into m.visible_buffer via wgpuQueueWriteBuffer.
// After return, m.mesh_draws is the per-mesh draw schedule for the frame.
void cullModelCpu(WgpuModelGpuData& m, const float planes[6][4]);
// ax+by+cz+d >= 0 means inside), bucket survivors by (mesh_id, lod), and
// write the flat visible-index list into m.visible_buffer via
// wgpuQueueWriteBuffer. After return, m.mesh_draws is the per-mesh,
// per-LOD draw schedule for the frame.
//
// `eye` and `forward` (forward = unit (target - eye)) are used to compute
// each instance's view-space depth for the LOD-pick projected-radius
// formula. `focal_px` = viewport_height / (2 * tan(fov_y / 2)). Instances
// whose projected bounding-sphere radius is below `lod1_threshold_px`
// get the mesh's LOD1 index slice when one was baked.
void cullModelCpu(WgpuModelGpuData& m,
const float planes[6][4],
const float eye[3], const float forward[3],
float focal_px, float lod1_threshold_px);
bool wgpu_initialized_ = false;
bool surface_configured_ = false;
@@ -170,6 +180,11 @@ private:
float camera_near_ = 0.1f;
float camera_far_ = 10000.0f;
// Switch to LOD1 when an instance's projected bounding-sphere radius
// drops below this many pixels. 0 disables (always LOD0). Defaults
// mirror AppSettings::lod1PixelThreshold() in the GL backend.
float lod1_pixel_threshold_ = 30.0f;
// Per-model state, keyed by viewport-assigned model_id.
std::unordered_map<uint32_t, WgpuModelGpuData> models_gpu_;
uint32_t next_model_id_ = 1;