forked from 1b-agent/opencadstudio-upstream
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models
Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
27896e3401
commit
a994a148fe
10 files changed
+572
-146
No files matched your search
@@ -0,0 +1,93 @@
|
||||
# Entity tessellation paths
|
||||
|
||||
How each acadrust `EntityType` becomes drawable geometry in OpenCADStudio, and
|
||||
whether it passes through the [truck](https://github.com/ricosjp/truck) modeling
|
||||
kernel.
|
||||
|
||||
There are **two distinct meanings of "through truck"** in the codebase, and they
|
||||
must not be conflated:
|
||||
|
||||
1. **truck B-rep meshing** — the entity is rebuilt as a truck `Shell`/`Solid`
|
||||
and triangulated by `MeshableShape::triangulation()`. This is the only path
|
||||
that produces filled *mesh* triangles from a kernel.
|
||||
2. **truck curve topology** — the entity is expressed as a truck `Edge`/`Wire`/
|
||||
`Vertex` and sampled into line segments by truck's `ParameterDivision1D`. It
|
||||
touches truck *topology* but never triangulates a surface; the output is a
|
||||
polyline.
|
||||
|
||||
Everything else is **direct**: points/segments/triangles are emitted straight
|
||||
from the entity's own data without any truck object.
|
||||
|
||||
Entry points: `src/scene/convert/tess.rs` (`tessellate_entity`, the per-entity
|
||||
dispatcher) → `src/scene/convert/tessellate.rs` (`tessellate`, the `TruckObject`
|
||||
router) and `src/scene/convert/truck_tess.rs` (curve sampling).
|
||||
|
||||
## Summary
|
||||
|
||||
| Path | Entities |
|
||||
|---|---|
|
||||
| **truck B-rep mesh** (Shell → `triangulation`, with a direct per-surface fallback) | `Solid3D`, `Region`, `Body`, `Surface` |
|
||||
| **truck curve topology** (Edge/Wire → `ParameterDivision1D` sampling) | `Line`, `Arc`, `Circle`, `Ellipse`, `Spline`, `LwPolyline`, `Polyline`, `Polyline2D`, `Polyline3D`, `Point` — *non-thick variants only* |
|
||||
| **direct** (no truck — segments/triangles emitted directly) | everything else |
|
||||
|
||||
> The Model/Design-tab primitives (BOX, SPHERE, CYLINDER, CONE, WEDGE, TORUS,
|
||||
> PYRAMID, extrude/revolve/loft/sweep) are also true truck B-rep meshes
|
||||
> (`truck_tess::tessellate_solid`), but they are created as `Solid3D`
|
||||
> placeholders carrying a truck B-rep, not as their own `EntityType`.
|
||||
|
||||
The only call site of `shell.triangulation()` for an imported entity is
|
||||
`acis_to_truck::tessellate_sat_truck`. The four ACIS solids dispatch through
|
||||
`solid3d_tess::tessellate_acis`, which tries that truck kernel first and falls
|
||||
back to a bespoke per-surface LOD sampler (`tessellate_sat_lods`) when truck
|
||||
cannot rebuild a face — so their path is **truck-with-direct-fallback**.
|
||||
|
||||
Curved-surface tessellation density is radius-relative (`CURVE_REL_TOL`, a
|
||||
fraction of the surface radius) so a cylinder's facet count matches the
|
||||
circle/arc wire tessellation instead of exploding on large radii.
|
||||
|
||||
## Full table
|
||||
|
||||
| Entity | Output | Path | Notes |
|
||||
|---|---|---|---|
|
||||
| Arc | wire | direct (truck curve topology) | non-thick: truck `Edge` (`builder::circle_arc`) sampled via `ParameterDivision1D`; thick: direct `Lines` |
|
||||
| AttributeDefinition | wire | direct | routes through the Text/MText LFF glyph stroke pipeline |
|
||||
| AttributeEntity | wire | direct | same as AttributeDefinition; values supplied per Insert |
|
||||
| Block | none | n/a | block-definition sentinel; not tessellated (referenced via Insert) |
|
||||
| BlockEnd | none | n/a | block-definition end marker; no output |
|
||||
| Body | mesh | truck-with-direct-fallback | 3D ACIS body → truck `Shell` → `triangulation`; fallback per-surface sampler |
|
||||
| Circle | wire | direct (truck curve topology) | non-thick: truck `Wire` of two half-circle edges; thick: direct `Lines` |
|
||||
| Dimension | wire | direct | baked-block path recurses on `D###` sub-entities; synthesis path emits lines/arrows/LFF text |
|
||||
| Ellipse | wire | direct (truck curve topology) | truck `Wire`/`Edge` over a `BSplineCurve`, `ParameterDivision1D` sampled |
|
||||
| Face3D | both | direct | edge `Lines` + direct fan-triangulated `fill_tris`; no truck B-rep |
|
||||
| Hatch | both | direct | boundary outline not emitted to the wire set (#131 OOM); fill rasterized on GPU |
|
||||
| Insert | wire | direct | expands block children and tessellates each via its own path; XCLIP filter applied |
|
||||
| Leader | wire | direct | leader path + arrowhead + landing, direct `Lines` |
|
||||
| Line | wire | direct (truck curve topology) | non-thick: truck `Edge` (`builder::line`); thick: direct `Lines` |
|
||||
| LwPolyline | wire | direct (truck curve topology) | `plinegen` true: truck `Contour` with bulge arcs; else direct `SegmentedLines` |
|
||||
| Mesh | both | direct | SubD mesh: edge `Lines` + direct fan-triangulated `fill_tris` |
|
||||
| MLine | wire | direct | spine + offset lines + caps, direct `Lines` |
|
||||
| MText | wire | direct | wrap-aware multi-line LFF glyph layout; inline formatting codes |
|
||||
| MultiLeader | wire | direct | leader + landing + LFF text + frame + fill |
|
||||
| Ole2Frame | wire | direct | bounding rectangle + diagonal cross |
|
||||
| Point | wire | direct (truck topology) | truck `Vertex` → cross marker sized by PDSIZE |
|
||||
| PolyfaceMesh | both | direct | face list: closed-polyline edges + direct fan-triangulated `fill_tris` |
|
||||
| PolygonMesh | both | direct | M×N grid wireframe + direct fan-triangulated `fill_tris` |
|
||||
| Polyline | wire | direct (truck curve topology) | heavy 3D polyline; truck `Contour` + bulge arcs |
|
||||
| Polyline2D | wire | direct (truck curve topology) | 2D polyline with bulge; truck `Contour` |
|
||||
| Polyline3D | wire | direct (truck curve topology) | linear-edge truck `Contour`, no bulge/thickness |
|
||||
| RasterImage | wire | direct | boundary rectangle / clipping polygon |
|
||||
| Ray | wire | direct | two-point `[base, base + dir×1e6]`, no sampling |
|
||||
| Region | mesh | truck-with-direct-fallback | 2D planar ACIS body; same truck path as Solid3D |
|
||||
| Seqend | none | n/a | vertex-sequence terminator sentinel; no output |
|
||||
| Shape | wire | direct | small diamond marker at the insertion point |
|
||||
| Solid | wire | direct | 2D SOLID: four quad edges as direct `Lines` |
|
||||
| Solid3D | mesh | truck-with-direct-fallback | 3DSOLID: parse SAT/SAB → truck `Shell` → `triangulation`; fallback sampler |
|
||||
| Spline | wire | direct (truck curve topology) | NURBS/BSpline truck `Edge`, `ParameterDivision1D` sampled |
|
||||
| Table | both | direct | cell fills (`fill_tris`) + LFF cell text + grid lines |
|
||||
| Text | wire | direct | LFF-font stroked glyph polylines |
|
||||
| Tolerance | wire | direct | feature-control frame grid + per-cell LFF symbols |
|
||||
| Underlay | wire | direct | boundary rectangle of the PDF/DWF reference |
|
||||
| Unknown | none | n/a | unrecognized-entity sentinel; no output |
|
||||
| Viewport | wire | direct | content-viewport frame rectangle (sheet viewport skipped) |
|
||||
| Wipeout | wire | direct | boundary rectangle / clipping polygon |
|
||||
| XLine | wire | direct | three-point infinite line, no sampling |
|
||||
@@ -2255,7 +2255,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||
// parent Insert (AutoCAD behaviour).
|
||||
scene::pick::hit_test::click_hit_insert_hatch(
|
||||
p,
|
||||
&self.tabs[i].scene.insert_hatches_for_click(),
|
||||
&self.tabs[i].scene.insert_hatches_for_click()[..],
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
@@ -2781,7 +2781,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||
.or_else(|| {
|
||||
scene::pick::hit_test::click_hit_insert_hatch(
|
||||
p,
|
||||
&self.tabs[i].scene.insert_hatches_for_click(),
|
||||
&self.tabs[i].scene.insert_hatches_for_click()[..],
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
|
||||
@@ -28,15 +28,21 @@ use acadrust::entities::acis::{
|
||||
|
||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||
use crate::scene::convert::solid3d_tess::{
|
||||
apply_body_transform, body_transform, collect_face_loops, cone_axis_span, mesh_aabb,
|
||||
apply_body_transform, body_transform, collect_face_loops, cone_axis_span,
|
||||
};
|
||||
|
||||
/// Slightly over 2π so revolution builders close the loop.
|
||||
const FULL: f64 = std::f64::consts::TAU + 0.2;
|
||||
/// Boundary sampling density for planar faces with curved (circular) edges.
|
||||
const BOUNDARY_SEGS: usize = 64;
|
||||
/// Triangle mesh chord tolerance (world units).
|
||||
/// Triangle mesh chord tolerance (world units) for planar faces, where the
|
||||
/// surface itself adds no curvature.
|
||||
const MESH_TOL: f64 = 0.01;
|
||||
/// Chord tolerance for curved faces, as a fraction of the surface radius, so
|
||||
/// the facet count is radius-independent instead of exploding on large radii.
|
||||
/// `0.1` = 10% chord error (~7 facets per full circle) — a deliberately coarse
|
||||
/// setting that keeps the solid triangle budget low.
|
||||
const CURVE_REL_TOL: f64 = 0.1;
|
||||
|
||||
/// Analytic outward-normal rule for a face, used to orient triangles and
|
||||
/// supply smooth per-vertex normals (truck's face orientation is unreliable
|
||||
@@ -121,14 +127,14 @@ pub fn tessellate_sat_truck(
|
||||
let Some(surf_rec) = sat.resolve(face.surface()) else {
|
||||
continue;
|
||||
};
|
||||
let Some((faces, outward)) = build_face_group(sat, &face, surf_rec) else {
|
||||
let Some((faces, outward, tol)) = build_face_group(sat, &face, surf_rec) else {
|
||||
continue;
|
||||
};
|
||||
if faces.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let shell: Shell = faces.into();
|
||||
let poly = shell.triangulation(MESH_TOL).to_polygon();
|
||||
let poly = shell.triangulation(tol).to_polygon();
|
||||
append_group(&mut mesh, &poly, &outward);
|
||||
}
|
||||
|
||||
@@ -143,11 +149,7 @@ pub fn tessellate_sat_truck(
|
||||
if let Some((m, tr, scale)) = body_transform(sat) {
|
||||
apply_body_transform(&mut mesh, &m, &tr, scale);
|
||||
}
|
||||
let world_aabb = mesh_aabb(&mesh);
|
||||
Some(MeshLodSet {
|
||||
lods: vec![mesh],
|
||||
world_aabb,
|
||||
})
|
||||
Some(MeshLodSet::from_lods(vec![mesh]))
|
||||
}
|
||||
|
||||
/// Build the truck face(s) + outward rule for one analytic ACIS face.
|
||||
@@ -155,7 +157,11 @@ fn build_face_group(
|
||||
sat: &SatDocument,
|
||||
face: &SatFace,
|
||||
surf_rec: &acadrust::entities::acis::SatRecord,
|
||||
) -> Option<(Vec<Face>, Outward)> {
|
||||
) -> Option<(Vec<Face>, Outward, f64)> {
|
||||
// Chord tolerance scaled to a curved surface's radius, so the facet count
|
||||
// is radius-independent (matching the circle/arc wire tessellation) rather
|
||||
// than exploding on large radii.
|
||||
let curve_tol = |radius: f64| (radius.abs() * CURVE_REL_TOL).max(1e-6);
|
||||
match surf_rec.entity_type.as_str() {
|
||||
"plane-surface" => {
|
||||
let plane = SatPlaneSurface::from_record(surf_rec)?;
|
||||
@@ -166,28 +172,37 @@ fn build_face_group(
|
||||
} else {
|
||||
[nx, ny, nz]
|
||||
};
|
||||
Some((vec![f], Outward::Const(vnorm(n))))
|
||||
Some((vec![f], Outward::Const(vnorm(n)), MESH_TOL))
|
||||
}
|
||||
"cone-surface" => {
|
||||
let cone = SatConeSurface::from_record(surf_rec)?;
|
||||
let tol = curve_tol(cone.radius());
|
||||
let (faces, out) = cone_faces(sat, &cone)?;
|
||||
Some((faces, out))
|
||||
Some((faces, out, tol))
|
||||
}
|
||||
"sphere-surface" => {
|
||||
let sphere = SatSphereSurface::from_record(surf_rec)?;
|
||||
let (cx, cy, cz) = sphere.center();
|
||||
Some((sphere_faces(&sphere), Outward::Sphere { center: [cx, cy, cz] }))
|
||||
let tol = curve_tol(sphere.radius());
|
||||
Some((
|
||||
sphere_faces(&sphere),
|
||||
Outward::Sphere { center: [cx, cy, cz] },
|
||||
tol,
|
||||
))
|
||||
}
|
||||
"torus-surface" => {
|
||||
let torus = SatTorusSurface::from_record(surf_rec)?;
|
||||
let (cx, cy, cz) = torus.center();
|
||||
let (nx, ny, nz) = torus.normal();
|
||||
// Tube (minor) curvature is the tighter of the two, so sampling it
|
||||
// to the circle tolerance keeps the whole torus at least that smooth.
|
||||
let tol = curve_tol(torus.minor_radius());
|
||||
let out = Outward::Torus {
|
||||
center: [cx, cy, cz],
|
||||
axis: vnorm([nx, ny, nz]),
|
||||
major: torus.major_radius(),
|
||||
};
|
||||
Some((torus_faces(&torus), out))
|
||||
Some((torus_faces(&torus), out, tol))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -181,8 +181,7 @@ fn tessellate_sat_lods(
|
||||
if lods.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let world_aabb = mesh_aabb(&lods[0]);
|
||||
Some(MeshLodSet { lods, world_aabb })
|
||||
Some(MeshLodSet::from_lods(lods))
|
||||
}
|
||||
|
||||
/// Extract the body's placement transform from the SAT document: a row-major
|
||||
@@ -243,6 +242,7 @@ fn scale_lod(base: LodConfig, facet_res: f64) -> LodConfig {
|
||||
|
||||
/// World-XY AABB of the mesh — used by the render-pipeline LOD selector
|
||||
/// to pick a level based on projected pixel diagonal.
|
||||
#[allow(dead_code)] // superseded by mesh_model::compute_mesh_aabb (3D); kept for reference
|
||||
pub(crate) fn mesh_aabb(mesh: &MeshModel) -> [f32; 4] {
|
||||
let mut min_x = f32::INFINITY;
|
||||
let mut min_y = f32::INFINITY;
|
||||
|
||||
+79
-30
@@ -570,6 +570,7 @@ fn offset_mesh_lod_set(mut set: MeshLodSet) -> MeshLodSet {
|
||||
if min_x.is_finite() {
|
||||
set.world_aabb = [min_x, min_y, max_x, max_y];
|
||||
}
|
||||
set.recompute_aabb();
|
||||
set
|
||||
}
|
||||
|
||||
@@ -626,6 +627,7 @@ fn transform_block_mesh_lod_set(
|
||||
if min_x.is_finite() {
|
||||
out.world_aabb = [min_x, min_y, max_x, max_y];
|
||||
}
|
||||
out.recompute_aabb();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -762,6 +764,12 @@ pub struct Scene {
|
||||
image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>,
|
||||
/// Cached mesh models, keyed by geometry_epoch.
|
||||
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshLodSet>>)>>,
|
||||
/// Cached block-INSERT hatches for hit-testing, keyed by geometry_epoch.
|
||||
/// Building this explodes every model-space INSERT, so without the cache a
|
||||
/// heavy block-instanced drawing re-explodes thousands of inserts on every
|
||||
/// hover. The set is geometry-derived, so a camera move / hover never
|
||||
/// invalidates it.
|
||||
insert_hatch_cache: RefCell<Option<(u64, Arc<Vec<(Handle, HatchModel)>>)>>,
|
||||
/// Per-viewport wire cache for paper-space rendering.
|
||||
/// Maps vp_handle → (geometry_epoch, Arc<Vec<WireModel>>).
|
||||
viewport_wire_cache: RefCell<HashMap<Handle, ((u64, u32, u64), Arc<Vec<WireModel>>)>>,
|
||||
@@ -935,6 +943,7 @@ impl Scene {
|
||||
wipeout_cache: RefCell::new(None),
|
||||
image_cache: RefCell::new(None),
|
||||
mesh_cache: RefCell::new(None),
|
||||
insert_hatch_cache: RefCell::new(None),
|
||||
viewport_wire_cache: RefCell::new(HashMap::default()),
|
||||
paper_sheet_cache: RefCell::new(None),
|
||||
paper_projected_cache: RefCell::new(HashMap::default()),
|
||||
@@ -2528,7 +2537,43 @@ impl Scene {
|
||||
/// handle so a click on a block-internal hatch can select the parent
|
||||
/// Insert (AutoCAD behaviour: sub-entities of a block aren't directly
|
||||
/// selectable; the click resolves to the Insert).
|
||||
pub fn insert_hatches_for_click(&self) -> Vec<(Handle, HatchModel)> {
|
||||
/// Whether a block definition (recursively) contains any Hatch. Memoised in
|
||||
/// `memo` across calls; lets the pick path skip exploding solid-only blocks.
|
||||
fn block_has_hatch(
|
||||
&self,
|
||||
block_name: &str,
|
||||
memo: &mut std::collections::HashMap<String, bool>,
|
||||
) -> bool {
|
||||
if let Some(&v) = memo.get(block_name) {
|
||||
return v;
|
||||
}
|
||||
// Seed `false` first so a cyclic block reference terminates.
|
||||
memo.insert(block_name.to_string(), false);
|
||||
let result = self
|
||||
.document
|
||||
.block_records
|
||||
.get(block_name)
|
||||
.map(|br| {
|
||||
br.entity_handles.iter().any(|&h| match self.document.get_entity(h) {
|
||||
Some(EntityType::Hatch(_)) => true,
|
||||
Some(EntityType::Insert(ins)) => self.block_has_hatch(&ins.block_name, memo),
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
memo.insert(block_name.to_string(), result);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn insert_hatches_for_click(&self) -> Arc<Vec<(Handle, HatchModel)>> {
|
||||
{
|
||||
let c = self.insert_hatch_cache.borrow();
|
||||
if let Some((epoch, ref arc)) = *c {
|
||||
if epoch == self.geometry_epoch {
|
||||
return Arc::clone(arc);
|
||||
}
|
||||
}
|
||||
}
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let layer_hidden = |layer: &str| {
|
||||
self.document
|
||||
@@ -2538,6 +2583,10 @@ impl Scene {
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut out: Vec<(Handle, HatchModel)> = Vec::new();
|
||||
// Exploding an INSERT to find block-internal hatches is expensive, so
|
||||
// skip blocks that contain no hatch at all (the common case for solid-
|
||||
// only blocks). The hatch-presence test is memoised across inserts.
|
||||
let mut hatch_memo: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
|
||||
for entity in self.document.entities() {
|
||||
let EntityType::Insert(ins) = entity else {
|
||||
continue;
|
||||
@@ -2552,6 +2601,9 @@ impl Scene {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if !self.block_has_hatch(&ins.block_name, &mut hatch_memo) {
|
||||
continue;
|
||||
}
|
||||
for sub in ins
|
||||
.explode_from_document(&self.document)
|
||||
.into_iter()
|
||||
@@ -2569,7 +2621,9 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
let arc = Arc::new(out);
|
||||
*self.insert_hatch_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||||
arc
|
||||
}
|
||||
|
||||
/// Wires that should participate in hit-testing, snapping, and selection.
|
||||
@@ -2672,35 +2726,30 @@ impl Scene {
|
||||
eye: glam::DVec3,
|
||||
bounds: iced::Rectangle,
|
||||
) -> Option<Handle> {
|
||||
// Block-internal instances must be owned (transformed copies); keep
|
||||
// them in a Vec and chain references alongside the top-level meshes so
|
||||
// neither set is cloned wholesale.
|
||||
let mut block_owned: Vec<(Handle, crate::scene::model::mesh_model::MeshModel)> = Vec::new();
|
||||
if !self.block_meshes.is_empty() {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
for e in self.document.entities() {
|
||||
if e.common().owner_handle != layout_block {
|
||||
continue;
|
||||
}
|
||||
let EntityType::Insert(ins) = e else { continue };
|
||||
if !self.mesh_entity_visible(ins.common.handle) {
|
||||
continue;
|
||||
}
|
||||
let mut sets = Vec::new();
|
||||
self.expand_block_meshes(&ins.block_name, &ins.get_transform(), 0, None, &mut sets);
|
||||
for set in sets {
|
||||
if let Some(m) = set.lods.into_iter().next() {
|
||||
block_owned.push((ins.common.handle, m));
|
||||
}
|
||||
}
|
||||
// Reuse the renderer's expanded mesh set (top-level solids + per-INSERT
|
||||
// block instances), cached per geometry epoch — so hover no longer
|
||||
// re-expands every block instance on each move. Every `MeshLodSet`
|
||||
// carries its handle (in `mesh.name`) and a 3D AABB.
|
||||
let meshes = self.meshes_arc();
|
||||
// Broad-phase: project each solid's 3D AABB and skip the per-triangle
|
||||
// ray test for those whose footprint isn't under the cursor — O(solids)
|
||||
// cheap projections instead of O(total triangles) on every hover.
|
||||
let candidates = meshes.iter().filter_map(|set| {
|
||||
let m = set.lods.first()?;
|
||||
if !pick::hit_test::aabb_under_cursor(
|
||||
set.world_aabb,
|
||||
set.z_aabb,
|
||||
cursor,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let top = self
|
||||
.meshes
|
||||
.iter()
|
||||
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m)));
|
||||
let blk = block_owned.iter().map(|(h, m)| (*h, m));
|
||||
pick::hit_test::mesh_click_hit(cursor, top.chain(blk), view_rot, eye, bounds)
|
||||
let handle = Handle::new(m.name.parse::<u64>().ok()?);
|
||||
Some((handle, m))
|
||||
});
|
||||
pick::hit_test::mesh_click_hit(cursor, candidates, view_rot, eye, bounds)
|
||||
}
|
||||
|
||||
/// Parent INSERT handles whose block-internal solid meshes fall in a
|
||||
|
||||
@@ -43,30 +43,55 @@ pub struct MeshLodSet {
|
||||
/// by the per-frame LOD selector to compute the projected pixel
|
||||
/// diagonal.
|
||||
pub world_aabb: [f32; 4],
|
||||
/// World Z extent `[min_z, max_z]`. With `world_aabb` this is the full 3D
|
||||
/// box, which the pick path projects to a screen rect to skip solids whose
|
||||
/// footprint isn't under the cursor (O(solids) instead of ray-testing every
|
||||
/// triangle). `verts` carry only the high half of the double-single
|
||||
/// position, so the bound is f32-precise — fine for a conservative cull.
|
||||
pub z_aabb: [f32; 2],
|
||||
}
|
||||
|
||||
/// 3D bounds of every LOD's vertices: `([min_x, min_y, max_x, max_y], [min_z, max_z])`.
|
||||
pub fn compute_mesh_aabb(lods: &[MeshModel]) -> ([f32; 4], [f32; 2]) {
|
||||
let (mut min_x, mut min_y, mut min_z) = (f32::INFINITY, f32::INFINITY, f32::INFINITY);
|
||||
let (mut max_x, mut max_y, mut max_z) =
|
||||
(f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
|
||||
for lod in lods {
|
||||
for &[x, y, z] in &lod.verts {
|
||||
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
|
||||
continue;
|
||||
}
|
||||
min_x = min_x.min(x);
|
||||
min_y = min_y.min(y);
|
||||
min_z = min_z.min(z);
|
||||
max_x = max_x.max(x);
|
||||
max_y = max_y.max(y);
|
||||
max_z = max_z.max(z);
|
||||
}
|
||||
}
|
||||
([min_x, min_y, max_x, max_y], [min_z, max_z])
|
||||
}
|
||||
|
||||
impl MeshLodSet {
|
||||
/// Build a set from its LODs, computing the 3D AABB.
|
||||
pub fn from_lods(lods: Vec<MeshModel>) -> Self {
|
||||
let (world_aabb, z_aabb) = compute_mesh_aabb(&lods);
|
||||
Self { lods, world_aabb, z_aabb }
|
||||
}
|
||||
|
||||
/// Wrap a single MeshModel as a one-LOD set. Used by interactive
|
||||
/// commands that only produce one tessellation (e.g. truck-based
|
||||
/// BOX/CYLINDER creation). The LOD selector will pick slot 0 for
|
||||
/// every zoom level.
|
||||
pub fn from_single(mesh: MeshModel) -> Self {
|
||||
let mut min_x = f32::INFINITY;
|
||||
let mut min_y = f32::INFINITY;
|
||||
let mut max_x = f32::NEG_INFINITY;
|
||||
let mut max_y = f32::NEG_INFINITY;
|
||||
for &[x, y, _] in &mesh.verts {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if x < min_x { min_x = x; }
|
||||
if y < min_y { min_y = y; }
|
||||
if x > max_x { max_x = x; }
|
||||
if y > max_y { max_y = y; }
|
||||
}
|
||||
Self {
|
||||
lods: vec![mesh],
|
||||
world_aabb: [min_x, min_y, max_x, max_y],
|
||||
}
|
||||
Self::from_lods(vec![mesh])
|
||||
}
|
||||
|
||||
/// Recompute `world_aabb` / `z_aabb` after the LODs' vertices were rewritten
|
||||
/// (relative-to-eye re-split, INSERT transform).
|
||||
pub fn recompute_aabb(&mut self) {
|
||||
let (xy, z) = compute_mesh_aabb(&self.lods);
|
||||
self.world_aabb = xy;
|
||||
self.z_aabb = z;
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,51 @@ pub fn click_hits_all<'a>(
|
||||
/// mesh triangle to screen space and test whether the cursor lands inside it.
|
||||
/// Returns the front-most hit (smallest projected depth). Lets meshed solids
|
||||
/// (whose only wire geometry is thin edges) be selected on their faces.
|
||||
/// Conservative broad-phase: does the solid's 3D AABB project to a screen rect
|
||||
/// that the cursor falls within (± `CLICK_THRESHOLD_PX`)? Lets the caller skip
|
||||
/// ray-testing the triangles of solids whose footprint isn't under the cursor —
|
||||
/// O(solids) cheap projections instead of O(triangles) per hover. Returns
|
||||
/// `true` (don't cull) whenever any corner is at/behind the camera, where the
|
||||
/// projected rect can't be trusted.
|
||||
///
|
||||
/// `world_aabb` is `[min_x, min_y, max_x, max_y]`, `z_aabb` is `[min_z, max_z]`,
|
||||
/// and `view_proj` is the same matrix `mesh_click_hit` projects vertices with.
|
||||
pub fn aabb_under_cursor(
|
||||
world_aabb: [f32; 4],
|
||||
z_aabb: [f32; 2],
|
||||
cursor: Point,
|
||||
view_proj: Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: Rectangle,
|
||||
) -> bool {
|
||||
let [min_x, min_y, max_x, max_y] = world_aabb;
|
||||
let [min_z, max_z] = z_aabb;
|
||||
if !min_x.is_finite() || !min_z.is_finite() {
|
||||
return true; // degenerate bound — don't cull
|
||||
}
|
||||
let (mut sx0, mut sy0, mut sx1, mut sy1) =
|
||||
(f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
|
||||
for &z in &[min_z, max_z] {
|
||||
for &(x, y) in &[(min_x, min_y), (max_x, min_y), (min_x, max_y), (max_x, max_y)] {
|
||||
let rel = glam::DVec3::new(x as f64 - eye.x, y as f64 - eye.y, z as f64 - eye.z)
|
||||
.as_vec3();
|
||||
let clip = view_proj * rel.extend(1.0);
|
||||
if clip.w <= 1e-6 {
|
||||
return true; // corner at/behind the camera — can't bound; keep it
|
||||
}
|
||||
let ndc = clip.truncate() / clip.w;
|
||||
let px = (ndc.x + 1.0) * 0.5 * bounds.width;
|
||||
let py = (1.0 - ndc.y) * 0.5 * bounds.height;
|
||||
sx0 = sx0.min(px);
|
||||
sy0 = sy0.min(py);
|
||||
sx1 = sx1.max(px);
|
||||
sy1 = sy1.max(py);
|
||||
}
|
||||
}
|
||||
let m = CLICK_THRESHOLD_PX;
|
||||
cursor.x >= sx0 - m && cursor.x <= sx1 + m && cursor.y >= sy0 - m && cursor.y <= sy1 + m
|
||||
}
|
||||
|
||||
pub fn mesh_click_hit<'a>(
|
||||
cursor: Point,
|
||||
meshes: impl Iterator<Item = (Handle, &'a MeshModel)>,
|
||||
|
||||
@@ -65,7 +65,9 @@ pub struct MeshGpu {
|
||||
/// `(a,b)(b,c)(c,a)`. Used by the wireframe-mode render path so 3D
|
||||
/// solids draw as their triangle edges without needing the
|
||||
/// `POLYGON_MODE_LINE` device feature.
|
||||
#[allow(dead_code)] // only the highlight overlay builds MeshGpu now (fill only)
|
||||
pub wire_index_buffer: wgpu::Buffer,
|
||||
#[allow(dead_code)]
|
||||
pub wire_index_count: u32,
|
||||
}
|
||||
|
||||
@@ -79,6 +81,7 @@ pub struct MeshLodGpu {
|
||||
/// How a solid mesh is highlighted this frame.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
#[allow(dead_code)] // the highlight overlay only builds Selected / Hover
|
||||
None,
|
||||
/// Hovered — light orange wash.
|
||||
Hover,
|
||||
@@ -97,7 +100,104 @@ impl Highlight {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batched mesh buffers ──────────────────────────────────────────────────
|
||||
//
|
||||
// One MeshGpu per solid means one vertex/index bind + draw call per solid —
|
||||
// ~10k draw calls a frame on a heavy 3D model, which strangles the GPU front
|
||||
// end. The batch concatenates every solid's LOD0 geometry into a handful of
|
||||
// large buffers (split only to stay under the 256 MB per-buffer cap), so the
|
||||
// whole mesh set draws in a few calls. Vertices already carry their own colour,
|
||||
// so no per-mesh state is needed between draws. Built once per geometry epoch —
|
||||
// selection/hover no longer rebuild it (that tint is dropped in the batch path).
|
||||
|
||||
/// Cap on vertices per batch buffer: 40 B/vertex × 6 M ≈ 240 MB, under the
|
||||
/// default 256 MB `max_buffer_size`. Solids beyond the cap spill into the next
|
||||
/// chunk.
|
||||
const BATCH_MAX_VERTS: usize = 6_000_000;
|
||||
|
||||
pub struct MeshBatchChunk {
|
||||
pub vertex_buffer: wgpu::Buffer,
|
||||
pub index_buffer: wgpu::Buffer,
|
||||
pub index_count: u32,
|
||||
pub wire_index_buffer: wgpu::Buffer,
|
||||
pub wire_index_count: u32,
|
||||
}
|
||||
|
||||
fn make_chunk(
|
||||
device: &wgpu::Device,
|
||||
verts: &[MeshVertex],
|
||||
indices: &[u32],
|
||||
wire_indices: &[u32],
|
||||
) -> MeshBatchChunk {
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mesh.batch.vbuf"),
|
||||
contents: bytemuck::cast_slice(verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mesh.batch.ibuf"),
|
||||
contents: bytemuck::cast_slice(indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
let wire_index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mesh.batch.wire_ibuf"),
|
||||
contents: bytemuck::cast_slice(wire_indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
MeshBatchChunk {
|
||||
vertex_buffer,
|
||||
index_buffer,
|
||||
index_count: indices.len() as u32,
|
||||
wire_index_buffer,
|
||||
wire_index_count: wire_indices.len() as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Concatenate every set's first non-empty LOD into a few large GPU buffers.
|
||||
/// Returns the chunks plus the total triangle count drawn (for diagnostics).
|
||||
pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec<MeshBatchChunk>, u64) {
|
||||
let mut chunks = Vec::new();
|
||||
let mut verts: Vec<MeshVertex> = Vec::new();
|
||||
let mut indices: Vec<u32> = Vec::new();
|
||||
let mut wire_indices: Vec<u32> = Vec::new();
|
||||
let mut total_tris = 0u64;
|
||||
for set in sets {
|
||||
let Some(mesh) = set.lods.iter().find(|m| !m.indices.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
if !verts.is_empty() && verts.len() + mesh.verts.len() > BATCH_MAX_VERTS {
|
||||
chunks.push(make_chunk(device, &verts, &indices, &wire_indices));
|
||||
verts.clear();
|
||||
indices.clear();
|
||||
wire_indices.clear();
|
||||
}
|
||||
let base = verts.len() as u32;
|
||||
let has_normals = mesh.normals.len() == mesh.verts.len();
|
||||
for (i, &pos) in mesh.verts.iter().enumerate() {
|
||||
verts.push(MeshVertex {
|
||||
position: pos,
|
||||
normal: if has_normals { mesh.normals[i] } else { [0.0, 1.0, 0.0] },
|
||||
color: mesh.color,
|
||||
position_low: mesh.verts_low.get(i).copied().unwrap_or([0.0; 3]),
|
||||
});
|
||||
}
|
||||
for &idx in &mesh.indices {
|
||||
indices.push(base + idx);
|
||||
}
|
||||
for tri in mesh.indices.chunks_exact(3) {
|
||||
let (a, b, c) = (base + tri[0], base + tri[1], base + tri[2]);
|
||||
wire_indices.extend_from_slice(&[a, b, b, c, c, a]);
|
||||
}
|
||||
total_tris += (mesh.indices.len() / 3) as u64;
|
||||
}
|
||||
if !indices.is_empty() {
|
||||
chunks.push(make_chunk(device, &verts, &indices, &wire_indices));
|
||||
}
|
||||
(chunks, total_tris)
|
||||
}
|
||||
|
||||
impl MeshLodGpu {
|
||||
#[allow(dead_code)] // built by the bypassed per-mesh upload_meshes path
|
||||
pub fn new(device: &wgpu::Device, set: &MeshLodSet, highlight: Highlight) -> Self {
|
||||
Self {
|
||||
lods: set
|
||||
|
||||
+155
-73
@@ -45,6 +45,7 @@ pub struct Pipeline {
|
||||
hatch_batched_pipeline: Option<wgpu::RenderPipeline>,
|
||||
image_pipeline: wgpu::RenderPipeline,
|
||||
mesh_pipeline: wgpu::RenderPipeline,
|
||||
mesh_highlight_pipeline: wgpu::RenderPipeline,
|
||||
/// Wireframe variant of the mesh pipeline (LineList topology, same
|
||||
/// vertex layout / shader). Used when the active render mode is
|
||||
/// Wireframe 2D or Wireframe 3D so 3D solids draw as their
|
||||
@@ -109,6 +110,19 @@ pub struct Pipeline {
|
||||
/// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame.
|
||||
image_pixel_scissors: Vec<Option<[u32; 4]>>,
|
||||
gpu_meshes: Vec<MeshLodGpu>,
|
||||
/// Batched mesh geometry — every solid's LOD0 concatenated into a few large
|
||||
/// buffers so the whole set draws in a handful of calls instead of one per
|
||||
/// solid. Rebuilt only when the geometry epoch changes (see
|
||||
/// `cached_mesh_batch_epoch`), so hover / selection never re-pack it.
|
||||
gpu_mesh_batch: Vec<mesh_gpu::MeshBatchChunk>,
|
||||
/// `geometry_epoch` the batch was built for. `u64::MAX` = not built yet.
|
||||
pub cached_mesh_batch_epoch: u64,
|
||||
/// Tinted copies of just the selected / hovered solids, drawn over the
|
||||
/// static base batch so highlight shows without re-packing the whole set.
|
||||
/// Rebuilt only when the selection/hover or geometry changes — O(highlighted).
|
||||
gpu_mesh_highlight: Vec<mesh_gpu::MeshGpu>,
|
||||
/// `(geometry_epoch, selection_generation)` the highlight overlay was built for.
|
||||
pub cached_highlight_key: (u64, u64),
|
||||
/// Per-mesh LOD level (0=high, 1=mid, 2=low) picked each frame from
|
||||
/// the projected pixel diagonal. Mirrors `hatch_pixel_scissors` —
|
||||
/// recomputed in `compute_mesh_lod`.
|
||||
@@ -143,7 +157,15 @@ pub struct Pipeline {
|
||||
/// `(geometry_epoch, selection_generation)` the resident mesh buffers were
|
||||
/// uploaded for. Bumps on a selection/hover change so highlighted solids
|
||||
/// re-upload with their tint.
|
||||
#[allow(dead_code)] // per-mesh LOD path, bypassed by the batched mesh draw
|
||||
pub cached_mesh_key: (u64, u64),
|
||||
/// `(geometry_epoch, face3d_fill_active)` the Face3D edge/fill buffers were
|
||||
/// uploaded for. The buffers are world-space and selection-independent, so
|
||||
/// they only change when the geometry changes or the 3D-fill mode toggles —
|
||||
/// never on a pan/orbit. Keyed separately from `cached_epoch` (which carries
|
||||
/// `camera_generation`) so a camera move no longer re-walks every wire to
|
||||
/// rebuild the Face3D fill buffer.
|
||||
pub cached_face3d_key: (u64, bool),
|
||||
/// Handle → indices into the resident wire set, built once per wire upload
|
||||
/// (when `cached_wire_id` changes). Lets the selection/hover xray overlay
|
||||
/// gather just the highlighted entity's wires (`O(highlighted)`) instead of
|
||||
@@ -510,6 +532,50 @@ impl Pipeline {
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Highlight variant — same shader / vertex layout, but `depth_compare =
|
||||
// Always` and no depth write, so a hovered / selected solid's tint is
|
||||
// drawn on top of everything (it shows through occluding geometry) and
|
||||
// doesn't disturb the depth buffer for later passes.
|
||||
let mesh_highlight_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("mesh.highlight.pipeline"),
|
||||
layout: Some(&mesh_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &mesh_shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[mesh_gpu::MeshVertex::layout()],
|
||||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: false,
|
||||
depth_compare: wgpu::CompareFunction::Always,
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: MSAA_SAMPLES,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &mesh_shader,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||
}),
|
||||
multiview: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Wireframe variant — same shader / vertex layout / depth state,
|
||||
// only the input topology changes (LineList) and back-face
|
||||
// culling drops out (each triangle edge is shared between two
|
||||
@@ -937,6 +1003,7 @@ impl Pipeline {
|
||||
hatch_batched_pipeline,
|
||||
image_pipeline,
|
||||
mesh_pipeline,
|
||||
mesh_highlight_pipeline,
|
||||
mesh_wireframe_pipeline,
|
||||
mesh_depth_pipeline,
|
||||
face3d_pipeline,
|
||||
@@ -967,6 +1034,10 @@ impl Pipeline {
|
||||
gpu_images: vec![],
|
||||
image_pixel_scissors: vec![],
|
||||
gpu_meshes: vec![],
|
||||
gpu_mesh_batch: vec![],
|
||||
cached_mesh_batch_epoch: u64::MAX,
|
||||
gpu_mesh_highlight: vec![],
|
||||
cached_highlight_key: (u64::MAX, u64::MAX),
|
||||
mesh_lod_levels: vec![],
|
||||
mesh_visible: vec![],
|
||||
gpu_face3d_fill: None,
|
||||
@@ -976,6 +1047,7 @@ impl Pipeline {
|
||||
cached_wire_id: u64::MAX,
|
||||
cached_selection: (u64::MAX, u64::MAX),
|
||||
cached_mesh_key: (u64::MAX, u64::MAX),
|
||||
cached_face3d_key: (u64::MAX, false),
|
||||
wire_handle_index: rustc_hash::FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
@@ -1194,6 +1266,7 @@ impl Pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // per-mesh upload path, bypassed by upload_mesh_batch
|
||||
pub fn upload_meshes(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -1222,6 +1295,46 @@ impl Pipeline {
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Build the batched mesh buffers (a few large buffers for the whole solid
|
||||
/// set, drawn in a handful of calls). Selection/hover tint is intentionally
|
||||
/// not applied here — the batch is geometry-only and stays resident across
|
||||
/// camera moves and pick changes.
|
||||
pub fn upload_mesh_batch(&mut self, device: &wgpu::Device, meshes: &[MeshLodSet]) {
|
||||
let (chunks, _tris) = mesh_gpu::build_mesh_batch(device, meshes);
|
||||
self.gpu_mesh_batch = chunks;
|
||||
}
|
||||
|
||||
/// Build tinted copies of just the selected / hovered solids. Drawn over the
|
||||
/// static base batch with the LessEqual mesh pipeline so the tint shows on
|
||||
/// top at the same depth. O(highlighted), so a pick never touches the rest.
|
||||
pub fn upload_mesh_highlight(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
meshes: &[MeshLodSet],
|
||||
selected: &rustc_hash::FxHashSet<acadrust::Handle>,
|
||||
hover: Option<acadrust::Handle>,
|
||||
) {
|
||||
use mesh_gpu::{Highlight, MeshGpu};
|
||||
let mut out = Vec::new();
|
||||
if selected.is_empty() && hover.is_none() {
|
||||
self.gpu_mesh_highlight = out;
|
||||
return;
|
||||
}
|
||||
for set in meshes {
|
||||
let Some(m) = set.lods.iter().find(|m| !m.indices.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let h = m.name.parse::<u64>().ok().map(acadrust::Handle::new);
|
||||
let mode = match h {
|
||||
Some(h) if selected.contains(&h) => Highlight::Selected,
|
||||
Some(h) if Some(h) == hover => Highlight::Hover,
|
||||
_ => continue,
|
||||
};
|
||||
out.push(MeshGpu::new(device, m, mode));
|
||||
}
|
||||
self.gpu_mesh_highlight = out;
|
||||
}
|
||||
|
||||
/// Per-frame mesh LOD selector. Picks slot 0/1/2 based on the
|
||||
/// projected pixel diagonal of each mesh's `world_aabb` (Phase 3.4
|
||||
/// ladder: >200 px → 0, 50–200 → 1, <50 → 2). Falls back to the
|
||||
@@ -1413,8 +1526,8 @@ impl Pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 4: solid meshes ──────────────────────────────────────────
|
||||
if !self.gpu_meshes.is_empty() {
|
||||
// ── Pass 4: solid meshes (batched) ────────────────────────────────
|
||||
if !self.gpu_mesh_batch.is_empty() {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mesh.render_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -1450,51 +1563,31 @@ impl Pipeline {
|
||||
// wire pass keeps the edges crisp on
|
||||
// top of the shaded surface.
|
||||
let want_solid_with_edges = !hidden_line && !mesh_wireframe && show_3d_edges;
|
||||
// Each path now binds a chunk's buffers and draws the whole chunk in
|
||||
// one call — a handful of draws total instead of one per solid. No
|
||||
// per-solid LOD / frustum cull in the batched path (the batch is
|
||||
// resident in full); that is reintroduced separately if needed.
|
||||
if hidden_line {
|
||||
pass.set_pipeline(&self.mesh_depth_pipeline);
|
||||
for (i, set) in self.gpu_meshes.iter().enumerate() {
|
||||
if !self.mesh_visible.get(i).copied().unwrap_or(true) {
|
||||
for c in &self.gpu_mesh_batch {
|
||||
if c.index_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let level = self
|
||||
.mesh_lod_levels
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(set.lods.len().saturating_sub(1));
|
||||
let Some(mesh) = set.lods.get(level) else {
|
||||
continue;
|
||||
};
|
||||
if mesh.index_count == 0 {
|
||||
continue;
|
||||
}
|
||||
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..mesh.index_count, 0, 0..1);
|
||||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(c.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..c.index_count, 0, 0..1);
|
||||
}
|
||||
pass.set_pipeline(&self.mesh_wireframe_pipeline);
|
||||
for (i, set) in self.gpu_meshes.iter().enumerate() {
|
||||
if !self.mesh_visible.get(i).copied().unwrap_or(true) {
|
||||
for c in &self.gpu_mesh_batch {
|
||||
if c.wire_index_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let level = self
|
||||
.mesh_lod_levels
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(set.lods.len().saturating_sub(1));
|
||||
let Some(mesh) = set.lods.get(level) else {
|
||||
continue;
|
||||
};
|
||||
if mesh.wire_index_count == 0 {
|
||||
continue;
|
||||
}
|
||||
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(
|
||||
mesh.wire_index_buffer.slice(..),
|
||||
c.wire_index_buffer.slice(..),
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
pass.draw_indexed(0..mesh.wire_index_count, 0, 0..1);
|
||||
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
|
||||
}
|
||||
} else {
|
||||
if mesh_wireframe {
|
||||
@@ -1502,59 +1595,48 @@ impl Pipeline {
|
||||
} else {
|
||||
pass.set_pipeline(&self.mesh_pipeline);
|
||||
}
|
||||
for (i, set) in self.gpu_meshes.iter().enumerate() {
|
||||
if !self.mesh_visible.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let level = self
|
||||
.mesh_lod_levels
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(set.lods.len().saturating_sub(1));
|
||||
let Some(mesh) = set.lods.get(level) else {
|
||||
continue;
|
||||
};
|
||||
for c in &self.gpu_mesh_batch {
|
||||
let (ibuf, icount) = if mesh_wireframe {
|
||||
(&mesh.wire_index_buffer, mesh.wire_index_count)
|
||||
(&c.wire_index_buffer, c.wire_index_count)
|
||||
} else {
|
||||
(&mesh.index_buffer, mesh.index_count)
|
||||
(&c.index_buffer, c.index_count)
|
||||
};
|
||||
if icount == 0 {
|
||||
continue;
|
||||
}
|
||||
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..icount, 0, 0..1);
|
||||
}
|
||||
// *WithEdges variants: overlay wire-edge segments on top
|
||||
// of the shaded fill. The LessEqual depth test on the
|
||||
// wireframe pipeline keeps the edges visible at the
|
||||
// fragments that just got written by the solid pass.
|
||||
// Selection / hover highlight: tinted copies of the picked
|
||||
// solids, drawn last with the Always-depth highlight pipeline so
|
||||
// the tint shows on top even when the solid is behind others.
|
||||
if !self.gpu_mesh_highlight.is_empty() {
|
||||
pass.set_pipeline(&self.mesh_highlight_pipeline);
|
||||
for g in &self.gpu_mesh_highlight {
|
||||
if g.index_count == 0 {
|
||||
continue;
|
||||
}
|
||||
pass.set_vertex_buffer(0, g.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(g.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..g.index_count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
// *WithEdges variants: overlay wire-edge segments on top of the
|
||||
// shaded fill. The LessEqual depth test on the wireframe pipeline
|
||||
// keeps the edges visible over the fragments the solid pass wrote.
|
||||
if want_solid_with_edges {
|
||||
pass.set_pipeline(&self.mesh_wireframe_pipeline);
|
||||
for (i, set) in self.gpu_meshes.iter().enumerate() {
|
||||
if !self.mesh_visible.get(i).copied().unwrap_or(true) {
|
||||
for c in &self.gpu_mesh_batch {
|
||||
if c.wire_index_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let level = self
|
||||
.mesh_lod_levels
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(set.lods.len().saturating_sub(1));
|
||||
let Some(mesh) = set.lods.get(level) else {
|
||||
continue;
|
||||
};
|
||||
if mesh.wire_index_count == 0 {
|
||||
continue;
|
||||
}
|
||||
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(
|
||||
mesh.wire_index_buffer.slice(..),
|
||||
c.wire_index_buffer.slice(..),
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
pass.draw_indexed(0..mesh.wire_index_count, 0, 0..1);
|
||||
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +263,16 @@ impl shader::Primitive for Primitive {
|
||||
if geo_changed {
|
||||
inner.upload_images(device, queue, &vp.images[..]);
|
||||
}
|
||||
inner.cached_epoch = cur_key;
|
||||
}
|
||||
// Face3D edge/fill buffers are world-space and selection-independent
|
||||
// (upload_face3d takes no selection input), so they only change with
|
||||
// the geometry or the 3D-fill toggle — never on a pan/orbit. Gating
|
||||
// here on `(geometry_epoch, face3d_fill_active)` instead of inside the
|
||||
// `cur_key` block (which carries `camera_generation`) stops a camera
|
||||
// move from re-walking every wire to rebuild the Face3D fill buffer.
|
||||
let face3d_key = (vp.geometry_epoch, face3d_fill_active);
|
||||
if face3d_key != inner.cached_face3d_key {
|
||||
inner.upload_face3d(
|
||||
device,
|
||||
&vp.face3d_wires[..],
|
||||
@@ -270,7 +280,7 @@ impl shader::Primitive for Primitive {
|
||||
!face3d_fill_active,
|
||||
&vp.draw_depths,
|
||||
);
|
||||
inner.cached_epoch = cur_key;
|
||||
inner.cached_face3d_key = face3d_key;
|
||||
}
|
||||
// Wire buffers are world-space, so a camera move alone doesn't
|
||||
// change them — only the view_proj uniform (uploaded every frame).
|
||||
@@ -298,18 +308,25 @@ impl shader::Primitive for Primitive {
|
||||
);
|
||||
inner.cached_selection = sel_key;
|
||||
}
|
||||
// Solid meshes carry their selection/hover tint baked into the
|
||||
// vertex colours, so re-upload them when the geometry OR the
|
||||
// highlight set changes.
|
||||
let mesh_key = (vp.geometry_epoch, vp.selection_generation);
|
||||
if mesh_key != inner.cached_mesh_key {
|
||||
inner.upload_meshes(
|
||||
// Batched solid meshes — geometry-only, so they ride the geometry
|
||||
// epoch alone and stay resident across camera moves and selection /
|
||||
// hover changes (no per-pick rebuild of the whole solid set).
|
||||
if vp.geometry_epoch != inner.cached_mesh_batch_epoch {
|
||||
inner.upload_mesh_batch(device, &vp.meshes[..]);
|
||||
inner.cached_mesh_batch_epoch = vp.geometry_epoch;
|
||||
}
|
||||
// Selection / hover highlight overlay — tinted copies of just the
|
||||
// picked solids, rebuilt only when the highlight set (or geometry)
|
||||
// changes. Drawn over the static batch so the base never re-packs.
|
||||
let hl_key = (vp.geometry_epoch, vp.selection_generation);
|
||||
if hl_key != inner.cached_highlight_key {
|
||||
inner.upload_mesh_highlight(
|
||||
device,
|
||||
&vp.meshes[..],
|
||||
&vp.selected_handles,
|
||||
vp.hover_handle,
|
||||
);
|
||||
inner.cached_mesh_key = mesh_key;
|
||||
inner.cached_highlight_key = hl_key;
|
||||
}
|
||||
// Live overlay (command preview / interim / grip drag) — small and
|
||||
// refreshed every frame it's present, so a drag never re-uploads
|
||||
|
||||
Reference in new issue
Block a user