forked from 1b-agent/opencadstudio-upstream
Block wires: pack definitions into shared buffers # Conflicts: # src/scene/pipeline/mod.rs # src/scene/pipeline/wire_gpu.rs
6239 lines
274 KiB
Rust
6239 lines
274 KiB
Rust
// WGSL has no includes. Compose one shared draw-order function into each 2D
|
||
// shader at compile time, including the sources used by shader validation tests.
|
||
macro_rules! draw_order_shader {
|
||
($file:literal) => {
|
||
concat!(
|
||
include_str!(concat!(
|
||
env!("CARGO_MANIFEST_DIR"),
|
||
"/src/shaders/draw_order.wgsl"
|
||
)),
|
||
"\n",
|
||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders/", $file))
|
||
)
|
||
};
|
||
}
|
||
|
||
pub mod circle_gpu;
|
||
#[cfg(test)]
|
||
mod depth_tests;
|
||
mod device_capabilities;
|
||
pub mod ellipse_gpu;
|
||
pub mod face3d_gpu;
|
||
pub mod gpu_budget;
|
||
#[cfg(test)]
|
||
mod gpu_tests;
|
||
pub mod gpu_upload;
|
||
pub mod hatch_gpu;
|
||
pub mod image_gpu;
|
||
pub mod mesh_gpu;
|
||
pub mod point_cloud_gpu;
|
||
pub mod text_gpu;
|
||
pub mod uniforms;
|
||
pub mod viewcube;
|
||
pub mod wipeout_gpu;
|
||
/// Persistent per-entity wire instance arena. Its indexed-storage and packed
|
||
/// adapters share the same patch/cull lifecycle across native, WebGPU, and
|
||
/// WebGL2.
|
||
pub mod wire_arena;
|
||
pub mod wire_gpu;
|
||
|
||
use iced::wgpu;
|
||
use iced::{Rectangle, Size};
|
||
|
||
pub use circle_gpu::{CircleGpu, CircleInstance};
|
||
pub use ellipse_gpu::{EllipseGpu, EllipseInstance};
|
||
pub use face3d_gpu::Face3DGpu;
|
||
pub use image_gpu::ImageGpu;
|
||
pub use uniforms::Uniforms;
|
||
pub use viewcube::ViewCubePipeline;
|
||
pub use wipeout_gpu::WipeoutGpu;
|
||
pub use wire_gpu::{BlockWireGpu, WireGpu};
|
||
|
||
use crate::scene::model::hatch_model::HatchModel;
|
||
use crate::scene::model::image_model::ImageModel;
|
||
use crate::scene::model::mesh_model::MeshLodSet;
|
||
use crate::scene::model::wire_model::WireModel;
|
||
|
||
/// Worst-case raster depth bias (in 24-bit quanta, toward the camera) block
|
||
/// text is required to clear. The wipeout pipeline currently carries no bias
|
||
/// (a raster bias can jump a block's narrow depth band and erase its
|
||
/// foreground), so this is a safety budget: the block-text regression asserts
|
||
/// a larger margin over whatever bias the pipeline could reintroduce.
|
||
pub const WIPEOUT_DEPTH_BIAS_QUANTA: i32 = 2;
|
||
|
||
struct SilhouetteChunk {
|
||
vertex_buffer: wgpu::Buffer,
|
||
instance_buffer: wgpu::Buffer,
|
||
vertex_count: u32,
|
||
instance_count: u32,
|
||
}
|
||
|
||
struct SilhouetteSourceGroup {
|
||
color: [f32; 4],
|
||
sources: Vec<kernel::brep::mesh::SilhouetteSource>,
|
||
instance_buffers: Vec<(wgpu::Buffer, u32)>,
|
||
}
|
||
use device_capabilities::DeviceCapabilities;
|
||
|
||
/// MSAA sample count for the main drawing pipelines.
|
||
const MSAA_SAMPLES: u32 = 4;
|
||
const SHADOW_MAP_SIZE: u32 = 2048;
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
enum MeshHighlightKind {
|
||
Selected,
|
||
Hover,
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct MeshResidentRange {
|
||
chunk: usize,
|
||
dynamic: bool,
|
||
index_start: u32,
|
||
index_count: u32,
|
||
transparent: bool,
|
||
instance_start: u32,
|
||
instance_count: u32,
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct MeshHighlightDraw {
|
||
range: MeshResidentRange,
|
||
kind: MeshHighlightKind,
|
||
}
|
||
|
||
pub struct Pipeline {
|
||
gpu_error_epoch: usize,
|
||
background_pipeline: wgpu::RenderPipeline,
|
||
shadow_pipeline: wgpu::RenderPipeline,
|
||
shadow_plain_pipeline: wgpu::RenderPipeline,
|
||
wire_pipeline: wgpu::RenderPipeline,
|
||
circle_pipeline: wgpu::RenderPipeline,
|
||
ellipse_pipeline: wgpu::RenderPipeline,
|
||
block_wire_pipeline: wgpu::RenderPipeline,
|
||
/// Stamps clip-boundary polygons into the stencil buffer (viewports + XCLIP).
|
||
clip_mask_pipeline: wgpu::RenderPipeline,
|
||
/// Black-fragment variant of `wire_pipeline` for 3D mesh outline edges in
|
||
/// filled render modes.
|
||
wire_black_pipeline: wgpu::RenderPipeline,
|
||
block_wire_black_pipeline: wgpu::RenderPipeline,
|
||
/// Same shader as wire_pipeline but depth_compare=Greater, depth_write_enabled=false.
|
||
/// Used to draw ghost copies of selected wires through occluding geometry.
|
||
wire_xray_pipeline: wgpu::RenderPipeline,
|
||
circle_xray_pipeline: wgpu::RenderPipeline,
|
||
ellipse_xray_pipeline: wgpu::RenderPipeline,
|
||
block_wire_xray_pipeline: wgpu::RenderPipeline,
|
||
/// Layout for the per-wire `WireConst` storage buffer (group 1 of the wire /
|
||
/// xray pipelines). `Some` on any storage-capable device; `None` in packed
|
||
/// compatibility mode. Passed to `WireGpu::from_run` / `from_batch`.
|
||
pub(crate) wire_const_bgl: Option<wgpu::BindGroupLayout>,
|
||
block_wire_const_bgl: wgpu::BindGroupLayout,
|
||
/// Which block-wire pipeline was built. Decides whether a definition's
|
||
/// segments go to a vertex buffer six times over or to a storage buffer
|
||
/// once.
|
||
block_wire_mode: wire_gpu::WirePipelineMode,
|
||
wipeout_pipeline: wgpu::RenderPipeline,
|
||
/// Capability-selected hatch renderer. Storage and texture transports are
|
||
/// private backends behind one upload/LOD/draw lifecycle.
|
||
hatch_gpu: hatch_gpu::HatchGpu,
|
||
image_pipeline: wgpu::RenderPipeline,
|
||
point_cloud_pipeline: wgpu::RenderPipeline,
|
||
/// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the
|
||
/// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts).
|
||
text_pipeline: wgpu::RenderPipeline,
|
||
block_text_pipeline: wgpu::RenderPipeline,
|
||
/// Depth-independent variant used by selection / rollover highlighting.
|
||
text_highlight_pipeline: wgpu::RenderPipeline,
|
||
block_text_highlight_pipeline: wgpu::RenderPipeline,
|
||
mesh_pipeline: wgpu::RenderPipeline,
|
||
mesh_plain_pipeline: wgpu::RenderPipeline,
|
||
/// Depth-write-disabled variant of `mesh_pipeline` for non-opaque solids.
|
||
mesh_transparent_pipeline: wgpu::RenderPipeline,
|
||
mesh_plain_transparent_pipeline: wgpu::RenderPipeline,
|
||
mesh_selected_pipeline: wgpu::RenderPipeline,
|
||
mesh_plain_selected_pipeline: wgpu::RenderPipeline,
|
||
mesh_hover_pipeline: wgpu::RenderPipeline,
|
||
mesh_plain_hover_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
|
||
/// triangle edges instead of filled faces.
|
||
mesh_wireframe_pipeline: wgpu::RenderPipeline,
|
||
/// Edge pipeline that forces black, for the edge overlay in filled modes.
|
||
mesh_edge_black_pipeline: wgpu::RenderPipeline,
|
||
silhouette_pipeline: wgpu::RenderPipeline,
|
||
silhouette_black_pipeline: wgpu::RenderPipeline,
|
||
/// Depth-only variant of the mesh pipeline (TriangleList, no color
|
||
/// writes, writes depth). Used in HiddenLine mode so 3D solids
|
||
/// occlude wires behind them without painting visible pixels.
|
||
mesh_depth_pipeline: wgpu::RenderPipeline,
|
||
mesh_plain_depth_pipeline: wgpu::RenderPipeline,
|
||
mesh_material_bgl: wgpu::BindGroupLayout,
|
||
mesh_default_material_bind_group: wgpu::BindGroup,
|
||
face3d_pipeline: wgpu::RenderPipeline,
|
||
block_face3d_pipeline: wgpu::RenderPipeline,
|
||
/// Depth-only variant of the face3d pipeline (no color writes,
|
||
/// writes depth). Paired with `mesh_depth_pipeline` for HiddenLine.
|
||
face3d_depth_pipeline: wgpu::RenderPipeline,
|
||
block_face3d_depth_pipeline: wgpu::RenderPipeline,
|
||
uniform_buffer: wgpu::Buffer,
|
||
frame_bgl: wgpu::BindGroupLayout,
|
||
uniform_bind_group: wgpu::BindGroup,
|
||
shadow_uniform_bind_group: wgpu::BindGroup,
|
||
background_texture: wgpu::Texture,
|
||
environment_texture: wgpu::Texture,
|
||
background_sampler: wgpu::Sampler,
|
||
background_source_id: usize,
|
||
environment_source_id: usize,
|
||
/// The shadow depth target, allocated only while this viewport actually
|
||
/// casts shadows. `SHADOW_MAP_SIZE` squared at `Depth32Float` is 16 MiB,
|
||
/// and every slot used to hold one whether or not its visual style enabled
|
||
/// shadows — 128 MiB across eight viewports, none of it ever sampled.
|
||
shadow_full: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||
/// Bound at the frame group's shadow slot while `shadow_full` is `None`.
|
||
/// The binding must be filled for the layout to be satisfied, but nothing
|
||
/// samples it when shadows are off, so 1x1 is the whole requirement.
|
||
_shadow_fallback_texture: wgpu::Texture,
|
||
shadow_fallback_view: wgpu::TextureView,
|
||
shadow_sampler: wgpu::Sampler,
|
||
shadow_enabled: bool,
|
||
wipeout_bgl1: wgpu::BindGroupLayout,
|
||
image_bgl1: wgpu::BindGroupLayout,
|
||
/// Group-1 layout for the text pipeline (atlas texture + sampler).
|
||
text_atlas_bgl: wgpu::BindGroupLayout,
|
||
/// GPU glyph atlas (texture + sampler + bind group). Rebuilt when the shared
|
||
/// CPU atlas grows (new glyphs baked). `None` until the first text upload.
|
||
text_atlas_gpu: Option<text_gpu::TextAtlasGpu>,
|
||
/// Glyph-quad vertices for the frame, split at whole glyph boundaries.
|
||
text_gpu: Vec<text_gpu::TextGpu>,
|
||
block_text_gpu: Vec<text_gpu::BlockTextGpu>,
|
||
block_text_highlight_gpu: Vec<text_gpu::BlockTextGpu>,
|
||
/// Tinted glyph quads of just the selected / hovered text, drawn over the
|
||
/// base text pass so a selection / rollover recolours the glyphs (the text
|
||
/// analogue of the selected-wire xray overlay). Rebuilt on selection change.
|
||
text_highlight_gpu: Vec<text_gpu::TextGpu>,
|
||
/// Live grip-drag / command-preview SDF glyph quads. The base text buffer
|
||
/// only re-uploads on a geometry-epoch change, but a grip drag hides the
|
||
/// dragged entity from the base wire set and shows it as a per-frame
|
||
/// overlay — so its glyphs ride here, re-uploaded every frame like
|
||
/// `gpu_preview_wires`, or the dragged text vanishes until release re-tesses
|
||
/// it back into the base set (issue #316).
|
||
text_preview_gpu: Vec<text_gpu::TextGpu>,
|
||
/// Per-frame silhouette line lists from the kernel mesh and current view.
|
||
silhouette_chunks: Vec<SilhouetteChunk>,
|
||
silhouette_source_key: (usize, usize, u64),
|
||
silhouette_source_groups: Vec<SilhouetteSourceGroup>,
|
||
/// Last requested render size (the full viewport rect, in pixels). The
|
||
/// geometry passes render at this size; the blit UV is scaled by
|
||
/// `depth_texture_size / alloc_size` so it samples only the filled region.
|
||
depth_texture_size: Size<u32>,
|
||
pub(in crate::scene) viewport: Option<crate::scene::view::render::PhysicalViewport>,
|
||
/// Actual allocated size of the depth / MSAA / resolve textures. Rounded
|
||
/// up from the requested size to a coarse grid so a divider drag (which
|
||
/// changes the pane size a few pixels every frame) doesn't recreate these
|
||
/// textures every frame. Recreation is what hangs the ANGLE → D3D11 path
|
||
/// on Windows-Firefox (issue #191); grow-only bucketing makes it rare.
|
||
alloc_size: Size<u32>,
|
||
depth_view: wgpu::TextureView,
|
||
/// 4× MSAA color buffer for the main drawing passes.
|
||
msaa_view: wgpu::TextureView,
|
||
/// Single-sample texture that receives the MSAA resolve result.
|
||
resolve_view: wgpu::TextureView,
|
||
/// Pipeline + resources for blitting the resolve texture to the surface target.
|
||
blit_pipeline: wgpu::RenderPipeline,
|
||
blit_bind_group_layout: wgpu::BindGroupLayout,
|
||
blit_sampler: wgpu::Sampler,
|
||
blit_bind_group: wgpu::BindGroup,
|
||
/// UV transform (offset + scale) consumed by the blit shader so a
|
||
/// partially off-canvas viewport still composites the right portion of
|
||
/// its resolve texture to the visible portion of the surface.
|
||
blit_uniform_buffer: wgpu::Buffer,
|
||
/// Cached texture format (needed to recreate MSAA / depth textures on resize).
|
||
surface_format: wgpu::TextureFormat,
|
||
/// The resident wire batches, shared by `std::sync::Arc` with every other
|
||
/// pipeline slot rendering the *same* `wire_content_id` (see
|
||
/// `MultiPipeline::wire_buffer_cache`). A `wgpu::Buffer` is internally
|
||
/// ref-counted, so N paper viewports (or Model tiles) drawing one identical
|
||
/// resident set hold one copy of the GPU vertex buffers between them —
|
||
/// their camera uniforms + scissor stay per-slot, only the geometry is
|
||
/// deduplicated. Never mutated in place; an arena-backed slot may also
|
||
/// replace this thin draw-range list on camera changes without touching the
|
||
/// shared resident buffer.
|
||
pub(crate) gpu_wires: std::sync::Arc<Vec<WireGpu>>,
|
||
pub(crate) gpu_circles: std::sync::Arc<Vec<CircleGpu>>,
|
||
pub(crate) gpu_ellipses: std::sync::Arc<Vec<EllipseGpu>>,
|
||
pub(crate) gpu_block_wires: std::sync::Arc<Vec<BlockWireGpu>>,
|
||
/// Persistent per-entity wire instance arena (capability-selected format).
|
||
/// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an
|
||
/// edit patches one entity's slab in place instead of rebuilding every wire.
|
||
pub(crate) wire_arena: Option<wire_arena::PersistentWireArena>,
|
||
/// Second arena for the mesh/solid EDGE wires (drawn with the mesh-edge skip /
|
||
/// black treatment); the resident set is split into this + `wire_arena` so
|
||
/// both patch incrementally. Shares `wire_arena_id`.
|
||
pub(crate) wire_arena_mesh: Option<wire_arena::PersistentWireArena>,
|
||
/// Chunked resident buffers for whichever arena partition exceeded one
|
||
/// GPU buffer. `Some(false)` = regular wires, `Some(true)` = mesh edges.
|
||
pub(crate) wire_arena_fallback: std::sync::Arc<Vec<WireGpu>>,
|
||
pub(crate) wire_arena_fallback_kind: Option<bool>,
|
||
pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet<codec::Handle>,
|
||
/// The Model content id both arenas currently mirror (`u64::MAX` = none).
|
||
pub(crate) wire_arena_id: u64,
|
||
/// Handles that contributed to this slot's retained analytical uploads.
|
||
pub(crate) partition_contributors: rustc_hash::FxHashSet<codec::Handle>,
|
||
/// The draw-depth generation those uploads baked. A full depth rebuild
|
||
/// reassigns every label, so they stop being reusable when it moves.
|
||
pub(crate) partition_depth_generation: u64,
|
||
/// Last content/camera/viewport tuple used to derive visible instance
|
||
/// ranges from the resident arena.
|
||
pub(crate) wire_cull_key: (u64, u64, u32, u32),
|
||
/// View/source keys for CPU visibility passes.
|
||
pub(crate) hatch_lod_key: (usize, u64, u32, u32, bool),
|
||
pub(crate) wipeout_lod_key: (usize, u64, u32, u32, bool),
|
||
pub(crate) silhouette_key: (usize, u64, [u32; 3], bool),
|
||
/// This content viewport's non-rectangular clip boundary as a triangle-fan
|
||
/// vertex buffer in the render target's normalized device coords (`None` =
|
||
/// rectangular / unclipped, where the viewport's own render rectangle does
|
||
/// the clipping). Stamped into the stencil once per frame; every content
|
||
/// pass then draws with stencil reference 1 so only the interior survives.
|
||
clip_boundary: Option<(wgpu::Buffer, u32)>,
|
||
/// Ghost copies (25% alpha) of selected wires for the X-ray depth pass.
|
||
gpu_selected_wires: Vec<WireGpu>,
|
||
gpu_selected_circles: Vec<CircleGpu>,
|
||
gpu_selected_ellipses: Vec<EllipseGpu>,
|
||
gpu_selected_block_wires: Vec<BlockWireGpu>,
|
||
/// Command-preview / interim / grip-drag overlay wires. Re-uploaded every
|
||
/// frame they are present (small), drawn on top of the base wire pass — so
|
||
/// a live drag never re-uploads the resident base buffer.
|
||
gpu_preview_wires: Vec<WireGpu>,
|
||
gpu_preview_circles: Vec<CircleGpu>,
|
||
gpu_preview_ellipses: Vec<EllipseGpu>,
|
||
/// Wipeout masks — solid fills rendered after wires in a separate pass via
|
||
/// the legacy per-primitive `WipeoutGpu` renderer.
|
||
gpu_wipeouts: Vec<WipeoutGpu>,
|
||
/// Per-wipeout draw-time skip flag (Phase 2.3 frustum cull). `true`
|
||
/// when the wipeout's projected AABB sits entirely outside the
|
||
/// viewport rect. Recomputed by `compute_wipeout_lod`.
|
||
wipeout_skip_flags: Vec<bool>,
|
||
gpu_images: Vec<ImageGpu>,
|
||
gpu_point_clouds: point_cloud_gpu::PointCloudGpu,
|
||
/// 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. Hover / selection never re-pack it.
|
||
gpu_mesh_batch: Vec<mesh_gpu::MeshBatchChunk>,
|
||
/// Small mutable working set rebuilt after entity-only edits. Static chunks
|
||
/// touched by an edit are disabled wholesale and their current handles are
|
||
/// repacked here, keeping the rest of a multi-million-triangle scene resident.
|
||
gpu_mesh_dynamic: Vec<mesh_gpu::MeshBatchChunk>,
|
||
mesh_disabled_chunks: rustc_hash::FxHashSet<usize>,
|
||
mesh_dynamic_handles: rustc_hash::FxHashSet<codec::Handle>,
|
||
/// Wire content generation whose geometry the static+dynamic mesh state
|
||
/// mirrors. It gates replay of the same per-entity journal handoff.
|
||
pub cached_mesh_content_id: u64,
|
||
/// Draw ranges for each entity inside the resident mesh chunks. Highlight
|
||
/// overlays reuse these buffers instead of uploading duplicate geometry.
|
||
mesh_ranges_by_handle: rustc_hash::FxHashMap<codec::Handle, Vec<MeshResidentRange>>,
|
||
mesh_highlight_draws: Vec<MeshHighlightDraw>,
|
||
/// `(geometry_epoch, selection_generation)` the highlight overlay was built for.
|
||
pub cached_highlight_key: (u64, u64),
|
||
/// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire).
|
||
gpu_face3d_fill: Option<Face3DGpu>,
|
||
gpu_face3d_edges: Vec<WireGpu>,
|
||
pub viewcube: ViewCubePipeline,
|
||
/// Strong source guards for category-specific GPU uploads. Holding the old
|
||
/// Arc makes pointer identity ABA-safe: an unchanged category reuses the
|
||
/// same Arc even when an unrelated entity advances `geometry_epoch`.
|
||
pub cached_hatch_source: Option<std::sync::Arc<Vec<HatchModel>>>,
|
||
pub cached_preview_hatch_source: Option<std::sync::Arc<Vec<HatchModel>>>,
|
||
pub cached_wipeout_source: Option<std::sync::Arc<Vec<HatchModel>>>,
|
||
pub cached_image_source: Option<std::sync::Arc<Vec<ImageModel>>>,
|
||
pub cached_point_cloud_source:
|
||
Option<std::sync::Arc<crate::scene::model::point_cloud::PointCloudSet>>,
|
||
pub cached_text_source: Option<std::sync::Arc<Vec<text_gpu::TextVertex>>>,
|
||
pub cached_annotation_highlight_source: Option<std::sync::Arc<Vec<WireModel>>>,
|
||
pub cached_mesh_source: Option<std::sync::Arc<Vec<MeshLodSet>>>,
|
||
pub cached_face3d_source: Option<std::sync::Arc<Vec<WireModel>>>,
|
||
pub cached_face3d_depth_source: Option<std::sync::Weak<rustc_hash::FxHashMap<u64, [f32; 2]>>>,
|
||
pub cached_fill_mode: bool,
|
||
/// Last `(geometry_epoch, camera_generation)` value for which GPU buffers
|
||
/// were uploaded. We re-upload when either side changes — pan/zoom bumps
|
||
/// camera_generation, which triggers re-culling and a fresh upload.
|
||
/// `(geometry_epoch, camera_generation, selection_generation)` of the
|
||
/// last static-buffer upload. Selection is tracked so the selected-hatch
|
||
/// tint refreshes on select / deselect (issue #71).
|
||
pub cached_epoch: (u64, u64, u64),
|
||
/// Content id of the wire buffer currently resident on the GPU (Phase
|
||
/// 3.2). When the incoming `ViewportData.wire_content_id` matches, the
|
||
/// world-space wire vertices are unchanged (e.g. a pure pan reused the
|
||
/// Model-tile tessellation) and `upload_wires` is skipped. `u64::MAX` =
|
||
/// nothing uploaded yet.
|
||
pub cached_wire_id: u64,
|
||
/// `(wire_content_id, selection_generation)` the selection xray overlay
|
||
/// (`gpu_selected_wires`) was last built for. Rebuilt when either changes —
|
||
/// a pick bumps only `selection_generation`, refreshing the overlay without
|
||
/// touching the main wire buffers.
|
||
pub cached_selection: (u64, u64),
|
||
/// Face upload key; keeps the resident wire Arc uniquely owned by Scene.
|
||
pub cached_face3d_key: (u64, bool, bool, u64),
|
||
/// Cached planar-solid visibility for `(wire_content_id, view direction)`.
|
||
pub cached_solid_visibility: (u64, [u32; 3], u64),
|
||
/// 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
|
||
/// scanning and string-parsing every wire on each hover change. Shared by
|
||
/// `std::sync::Arc` alongside `gpu_wires` — built once per `wire_content_id`
|
||
/// and cloned into every slot that renders it.
|
||
pub(crate) wire_handle_index: std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
|
||
/// Signature of the last frame's rendered scene (camera uniforms, geometry
|
||
/// / selection generations, wire content id, render-mode flags, clip size,
|
||
/// live preview). When the next frame's signature matches, the image is
|
||
/// pixel-identical, so `render` skips every geometry pass + the MSAA
|
||
/// resolve and only re-blits the resolve texture (which still holds it).
|
||
/// This turns a pure cursor move over a large drawing from an O(N)
|
||
/// re-rasterization into a single fullscreen blit. `u64::MAX` = nothing
|
||
/// rendered yet (forces the first frame to render fully).
|
||
pub render_sig: u64,
|
||
/// Set by `prepare` each frame: `true` when this frame's signature matched
|
||
/// `render_sig`, so `render` may skip the geometry passes. Read (never
|
||
/// written) by `render`, which always runs after `prepare` for the frame.
|
||
pub skip_geometry: bool,
|
||
/// Interaction LOD: set by `prepare` when the view is actively navigating, so
|
||
/// `render` skips the (per-pixel, GPU-dominating) hatch pass this frame. The
|
||
/// scene-render cache holds the full-quality frame once the view settles.
|
||
pub skip_hatch_frame: bool,
|
||
/// Skip the canvas entirely — no clear colour, no background pass — so
|
||
/// whatever was drawn underneath this viewport shows through it.
|
||
pub skip_background: bool,
|
||
/// Stable identity of the viewport that last used this (index-addressed)
|
||
/// pipeline slot. The renderer's viewport list drops off-canvas viewports,
|
||
/// so a slot can be reused by a *different* viewport across frames; when the
|
||
/// occupant changes, all cache keys above belong to the previous one and are
|
||
/// reset so every buffer re-uploads. `u64::MAX` = never used.
|
||
pub slot_id: u64,
|
||
}
|
||
|
||
impl Pipeline {
|
||
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
|
||
// ── Shared frame uniform buffer (view_proj etc.) ───────────────────
|
||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("viewer.uniform_buffer"),
|
||
size: std::mem::size_of::<Uniforms>() as u64,
|
||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||
mapped_at_creation: false,
|
||
});
|
||
|
||
// Bind group layout 0 — shared by wire and hatch pipelines.
|
||
let frame_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||
label: Some("viewer.frame_bgl"),
|
||
entries: &[
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 0,
|
||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: None,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 1,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 2,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 3,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 4,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 5,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Depth,
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 6,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||
count: None,
|
||
},
|
||
],
|
||
});
|
||
let shadow_frame_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||
label: Some("shadow.frame_bgl"),
|
||
entries: &[wgpu::BindGroupLayoutEntry {
|
||
binding: 0,
|
||
visibility: wgpu::ShaderStages::VERTEX,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: None,
|
||
},
|
||
count: None,
|
||
}],
|
||
});
|
||
|
||
let placeholder_texture = |label: &'static str, rgba: [u8; 4]| {
|
||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some(label),
|
||
size: wgpu::Extent3d {
|
||
width: 1,
|
||
height: 1,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: 1,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||
view_formats: &[],
|
||
});
|
||
queue.write_texture(
|
||
texture.as_image_copy(),
|
||
&rgba,
|
||
wgpu::TexelCopyBufferLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(4),
|
||
rows_per_image: Some(1),
|
||
},
|
||
wgpu::Extent3d {
|
||
width: 1,
|
||
height: 1,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
texture
|
||
};
|
||
let background_texture = placeholder_texture("background.placeholder", [0, 0, 0, 255]);
|
||
let environment_texture =
|
||
placeholder_texture("environment.placeholder", [128, 128, 128, 255]);
|
||
let background_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||
label: Some("background.sampler"),
|
||
address_mode_u: wgpu::AddressMode::Repeat,
|
||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||
mag_filter: wgpu::FilterMode::Linear,
|
||
min_filter: wgpu::FilterMode::Linear,
|
||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||
..Default::default()
|
||
});
|
||
let shadow_fallback_texture = create_shadow_texture(device, 1);
|
||
let shadow_fallback_view =
|
||
shadow_fallback_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||
let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||
label: Some("shadow.sampler"),
|
||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||
mag_filter: wgpu::FilterMode::Linear,
|
||
min_filter: wgpu::FilterMode::Linear,
|
||
..Default::default()
|
||
});
|
||
let background_view =
|
||
background_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||
let environment_view =
|
||
environment_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||
let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||
label: Some("viewer.bind_group"),
|
||
layout: &frame_bgl,
|
||
entries: &[
|
||
wgpu::BindGroupEntry {
|
||
binding: 0,
|
||
resource: uniform_buffer.as_entire_binding(),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 1,
|
||
resource: wgpu::BindingResource::TextureView(&background_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 2,
|
||
resource: wgpu::BindingResource::Sampler(&background_sampler),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 3,
|
||
resource: wgpu::BindingResource::TextureView(&environment_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 4,
|
||
resource: wgpu::BindingResource::Sampler(&background_sampler),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 5,
|
||
resource: wgpu::BindingResource::TextureView(&shadow_fallback_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 6,
|
||
resource: wgpu::BindingResource::Sampler(&shadow_sampler),
|
||
},
|
||
],
|
||
});
|
||
let shadow_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||
label: Some("shadow.frame_bind_group"),
|
||
layout: &shadow_frame_bgl,
|
||
entries: &[wgpu::BindGroupEntry {
|
||
binding: 0,
|
||
resource: uniform_buffer.as_entire_binding(),
|
||
}],
|
||
});
|
||
|
||
// ── Wire pipeline ──────────────────────────────────────────────────
|
||
// Select the renderer tier from device limits.
|
||
let device_caps = DeviceCapabilities::detect(device);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let force_compat_renderer = crate::cli::gui_config().compat_renderer;
|
||
#[cfg(target_arch = "wasm32")]
|
||
let force_compat_renderer = false;
|
||
let wire_mode = wire_gpu::WirePipelineMode::select(device_caps, force_compat_renderer);
|
||
let wire_const_bgl = wire_mode
|
||
.uses_storage()
|
||
.then(|| wire_gpu::WireConst::bind_group_layout(device));
|
||
let mut wire_bgls: Vec<Option<&wgpu::BindGroupLayout>> = vec![Some(&frame_bgl)];
|
||
if let Some(bgl) = &wire_const_bgl {
|
||
wire_bgls.push(Some(bgl));
|
||
}
|
||
let wire_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("wire.pipeline_layout"),
|
||
bind_group_layouts: &wire_bgls,
|
||
immediate_size: 0,
|
||
});
|
||
let block_wire_const_bgl =
|
||
wire_gpu::block_const_bind_group_layout(device, wire_mode.uses_storage());
|
||
let block_wire_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("block_wire.pipeline_layout"),
|
||
bind_group_layouts: &[Some(&frame_bgl), Some(&block_wire_const_bgl)],
|
||
immediate_size: 0,
|
||
});
|
||
|
||
let depth_tex = create_depth_texture(device, Size::new(1, 1));
|
||
let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||
|
||
let wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("wire.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(match wire_mode {
|
||
wire_gpu::WirePipelineMode::IndexedStorage => {
|
||
draw_order_shader!("wire_indexed.wgsl")
|
||
}
|
||
wire_gpu::WirePipelineMode::Packed => draw_order_shader!("wire.wgsl"),
|
||
})),
|
||
});
|
||
let block_wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("block_wire.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(
|
||
if wire_mode.uses_storage() {
|
||
draw_order_shader!("block_wire_storage.wgsl")
|
||
} else {
|
||
draw_order_shader!("block_wire.wgsl")
|
||
},
|
||
)),
|
||
});
|
||
|
||
// Stencil test shared by every paper content pipeline: draw only where
|
||
// the stencil equals the bound reference. Non-clipped content binds
|
||
// reference 0 (matching the frame's stencil, cleared to 0); a clip
|
||
// region's content binds reference 1 after its boundary has been stamped
|
||
// into the stencil by the clip-mask pipeline. In Model space nothing
|
||
// masks the stencil, so it stays 0 and reference 0 always passes.
|
||
let content_stencil_face = wgpu::StencilFaceState {
|
||
compare: wgpu::CompareFunction::Equal,
|
||
fail_op: wgpu::StencilOperation::Keep,
|
||
depth_fail_op: wgpu::StencilOperation::Keep,
|
||
pass_op: wgpu::StencilOperation::Keep,
|
||
};
|
||
let content_stencil = wgpu::StencilState {
|
||
front: content_stencil_face,
|
||
back: content_stencil_face,
|
||
read_mask: 0xff,
|
||
write_mask: 0x00,
|
||
};
|
||
|
||
let background_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("background.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||
"../../shaders/background.wgsl"
|
||
))),
|
||
});
|
||
let background_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("background.pipeline_layout"),
|
||
bind_group_layouts: &[Some(&frame_bgl)],
|
||
immediate_size: 0,
|
||
});
|
||
let background_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("background.pipeline"),
|
||
layout: Some(&background_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &background_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[],
|
||
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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(false),
|
||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &background_shader,
|
||
entry_point: Some("fs_main"),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: Some(wgpu::BlendState::REPLACE),
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
let wire_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("wire.pipeline"),
|
||
layout: Some(&wire_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &wire_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[wire_mode.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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &wire_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_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
// ── Clip-mask pipeline ─────────────────────────────────────────────
|
||
// Stamps a viewport / XCLIP boundary polygon into the stencil buffer:
|
||
// no colour, no depth, stencil `Invert` (even-odd fill → interior marked
|
||
// for any polygon, convex or not). The boundary is drawn as a triangle
|
||
// fan in paper coordinates and transformed exactly like wires.
|
||
let clip_mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("clip_mask.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||
"../../shaders/clip_mask.wgsl"
|
||
))),
|
||
});
|
||
let clip_mask_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("clip_mask.pipeline_layout"),
|
||
bind_group_layouts: &[],
|
||
immediate_size: 0,
|
||
});
|
||
let clip_mask_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("clip_mask.pipeline"),
|
||
layout: Some(&clip_mask_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &clip_mask_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[wgpu::VertexBufferLayout {
|
||
array_stride: 8,
|
||
step_mode: wgpu::VertexStepMode::Vertex,
|
||
attributes: &[wgpu::VertexAttribute {
|
||
offset: 0,
|
||
shader_location: 0,
|
||
format: wgpu::VertexFormat::Float32x2,
|
||
}],
|
||
}],
|
||
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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(false),
|
||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||
stencil: wgpu::StencilState {
|
||
front: wgpu::StencilFaceState {
|
||
compare: wgpu::CompareFunction::Always,
|
||
fail_op: wgpu::StencilOperation::Keep,
|
||
depth_fail_op: wgpu::StencilOperation::Keep,
|
||
pass_op: wgpu::StencilOperation::Invert,
|
||
},
|
||
back: wgpu::StencilFaceState {
|
||
compare: wgpu::CompareFunction::Always,
|
||
fail_op: wgpu::StencilOperation::Keep,
|
||
depth_fail_op: wgpu::StencilOperation::Keep,
|
||
pass_op: wgpu::StencilOperation::Invert,
|
||
},
|
||
read_mask: 0xff,
|
||
write_mask: 0xff,
|
||
},
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &clip_mask_shader,
|
||
entry_point: Some("fs_main"),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: None,
|
||
write_mask: wgpu::ColorWrites::empty(),
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
// Black variant of `wire_pipeline` — same geometry/depth, black fragment
|
||
// — for 3D mesh outline edges in filled modes (see the wire draw loop).
|
||
let wire_black_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("wire.black.pipeline"),
|
||
layout: Some(&wire_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &wire_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[wire_mode.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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &wire_shader,
|
||
entry_point: Some("fs_black"),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
// Selection overlay variant: renders selected wires on top of everything
|
||
// (depth_compare=Always), without writing depth. Ensures selected entities
|
||
// are always fully visible regardless of occluding geometry.
|
||
let wire_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("wire_xray.pipeline"),
|
||
layout: Some(&wire_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &wire_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[wire_mode.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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(false),
|
||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &wire_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_mask: None,
|
||
cache: None,
|
||
});
|
||
let block_wire_buffers: &[wgpu::VertexBufferLayout<'_>] = if wire_mode.uses_storage() {
|
||
&[wire_gpu::BlockWireInstance::layout()]
|
||
} else {
|
||
&[
|
||
wire_gpu::BlockWireVertex::layout(),
|
||
wire_gpu::BlockWireInstance::layout(),
|
||
]
|
||
};
|
||
let make_block_wire_pipeline =
|
||
|label: &'static str,
|
||
fragment: &'static str,
|
||
depth_write_enabled: bool,
|
||
depth_compare: wgpu::CompareFunction| {
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some(label),
|
||
layout: Some(&block_wire_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &block_wire_shader,
|
||
entry_point: Some("vs_main"),
|
||
// Storage mode has no geometry vertex buffer: the
|
||
// instances become slot 0 and the segments arrive through
|
||
// the bind group.
|
||
buffers: block_wire_buffers,
|
||
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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(depth_write_enabled),
|
||
depth_compare: Some(depth_compare),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &block_wire_shader,
|
||
entry_point: Some(fragment),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
})
|
||
};
|
||
let block_wire_pipeline = make_block_wire_pipeline(
|
||
"block_wire.pipeline",
|
||
"fs_main",
|
||
true,
|
||
wgpu::CompareFunction::LessEqual,
|
||
);
|
||
let block_wire_black_pipeline = make_block_wire_pipeline(
|
||
"block_wire.black.pipeline",
|
||
"fs_black",
|
||
true,
|
||
wgpu::CompareFunction::LessEqual,
|
||
);
|
||
let block_wire_xray_pipeline = make_block_wire_pipeline(
|
||
"block_wire.xray.pipeline",
|
||
"fs_main",
|
||
false,
|
||
wgpu::CompareFunction::Always,
|
||
);
|
||
|
||
// ── Hatch pipeline ─────────────────────────────────────────────────
|
||
// Binding 0 carries the fill plane; boundary and family data are
|
||
// fragment-only.
|
||
let hatch_entry = |binding: u32, vis: wgpu::ShaderStages| wgpu::BindGroupLayoutEntry {
|
||
binding,
|
||
visibility: vis,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: None,
|
||
},
|
||
count: None,
|
||
};
|
||
let frag = wgpu::ShaderStages::FRAGMENT;
|
||
let vert_frag = wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT;
|
||
let wipeout_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||
label: Some("wipeout.bgl1"),
|
||
entries: &[
|
||
hatch_entry(0, vert_frag),
|
||
hatch_entry(1, frag),
|
||
hatch_entry(2, frag),
|
||
],
|
||
});
|
||
|
||
let wipeout_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("wipeout.pipeline_layout"),
|
||
bind_group_layouts: &[&frame_bgl, &wipeout_bgl1].map(Some),
|
||
immediate_size: 0,
|
||
});
|
||
|
||
let wipeout_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("wipeout.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!(
|
||
"wipeout.wgsl"
|
||
))),
|
||
});
|
||
|
||
let wipeout_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("wipeout.pipeline"),
|
||
layout: Some(&wipeout_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &wipeout_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[
|
||
wipeout_gpu::HatchVertex::layout(),
|
||
wipeout_gpu::WipeoutPlacement::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
// The shader already applies draw order. A raster bias can
|
||
// jump across a block's narrow range and erase its foreground
|
||
// — block text clears its siblings' wipes by only 9-16 depth
|
||
// quanta, so the pipeline must stay bias-free. Coincident ties
|
||
// still resolve toward the mask, which draws later under
|
||
// LessEqual. WIPEOUT_DEPTH_BIAS_QUANTA is the budget the
|
||
// block-text regression asserts against if a bias ever
|
||
// returns.
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &wipeout_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_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
// ── Hatch renderer ─────────────────────────────────────────────────
|
||
// The façade owns capability selection plus both backend lifecycles.
|
||
let hatch_gpu = hatch_gpu::HatchGpu::new(
|
||
device,
|
||
format,
|
||
&frame_bgl,
|
||
&content_stencil,
|
||
device_caps,
|
||
force_compat_renderer,
|
||
);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if std::env::var_os("RUST_LOG").is_some() {
|
||
eprintln!(
|
||
"renderer pipelines: wire={} hatch={} mesh={} (storage buffers/stage: {})",
|
||
if wire_mode.uses_storage() {
|
||
"storage"
|
||
} else {
|
||
"packed"
|
||
},
|
||
hatch_gpu.backend_name(),
|
||
"vertex",
|
||
device.limits().max_storage_buffers_per_shader_stage
|
||
);
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
log::info!(
|
||
"renderer pipelines: wire={} hatch={} mesh={} (storage buffers/stage: {})",
|
||
if wire_mode.uses_storage() {
|
||
"storage"
|
||
} else {
|
||
"packed"
|
||
},
|
||
hatch_gpu.backend_name(),
|
||
"vertex",
|
||
device.limits().max_storage_buffers_per_shader_stage
|
||
);
|
||
|
||
// ── Mesh pipeline ──────────────────────────────────────────────────
|
||
let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("mesh.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||
"../../shaders/mesh.wgsl"
|
||
))),
|
||
});
|
||
let mesh_material_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||
label: Some("mesh.material.bgl"),
|
||
entries: &[
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 0,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 1,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 2,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 3,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 4,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 5,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 6,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 7,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 8,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 9,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 10,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 11,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 12,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 13,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 14,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: None,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 16,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: None,
|
||
},
|
||
count: None,
|
||
},
|
||
],
|
||
});
|
||
let mesh_default_material_bind_group =
|
||
mesh_gpu::create_material_bind_group(device, queue, &mesh_material_bgl, None);
|
||
|
||
let mesh_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("mesh.pipeline_layout"),
|
||
bind_group_layouts: &[&frame_bgl, &mesh_material_bgl].map(Some),
|
||
immediate_size: 0,
|
||
});
|
||
|
||
let shadow_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("shadow.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||
"../../shaders/shadow.wgsl"
|
||
))),
|
||
});
|
||
let shadow_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("shadow.pipeline_layout"),
|
||
bind_group_layouts: &[&shadow_frame_bgl, &mesh_material_bgl].map(Some),
|
||
immediate_size: 0,
|
||
});
|
||
let shadow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("shadow.pipeline"),
|
||
layout: Some(&shadow_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &shadow_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[
|
||
mesh_gpu::MeshVertex::layout(),
|
||
mesh_gpu::MeshInstanceGpu::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: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: wgpu::StencilState::default(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 2,
|
||
slope_scale: 2.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
multisample: wgpu::MultisampleState::default(),
|
||
fragment: None,
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
let shadow_plain_pipeline =
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("shadow.plain.pipeline"),
|
||
layout: Some(&shadow_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &shadow_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[
|
||
mesh_gpu::MeshPlainVertex::layout(),
|
||
mesh_gpu::MeshInstanceGpu::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: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: wgpu::StencilState::default(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 2,
|
||
slope_scale: 2.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
multisample: wgpu::MultisampleState::default(),
|
||
fragment: None,
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
let mesh_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("mesh.pipeline"),
|
||
layout: Some(&mesh_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &mesh_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[
|
||
mesh_gpu::MeshVertex::layout(),
|
||
mesh_gpu::MeshInstanceGpu::layout(),
|
||
],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
},
|
||
primitive: wgpu::PrimitiveState {
|
||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||
cull_mode: None, // two-sided: ACIS import winding is unreliable
|
||
..Default::default()
|
||
},
|
||
depth_stencil: Some(wgpu::DepthStencilState {
|
||
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 1,
|
||
slope_scale: 1.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
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_mask: None,
|
||
cache: None,
|
||
});
|
||
let mesh_plain_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("mesh.plain.pipeline"),
|
||
layout: Some(&mesh_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &mesh_shader,
|
||
entry_point: Some("vs_main_plain"),
|
||
buffers: &[
|
||
mesh_gpu::MeshPlainVertex::layout(),
|
||
mesh_gpu::MeshInstanceGpu::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 1,
|
||
slope_scale: 1.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
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_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
// Transparent variant — identical to `mesh_pipeline` but with depth
|
||
// writes disabled. Non-opaque solids are drawn after the opaque fills
|
||
// with this pipeline so they blend over the geometry behind them (which
|
||
// has already written depth) instead of erasing it.
|
||
let mesh_transparent_pipeline =
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("mesh.transparent.pipeline"),
|
||
layout: Some(&mesh_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &mesh_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[
|
||
mesh_gpu::MeshVertex::layout(),
|
||
mesh_gpu::MeshInstanceGpu::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(false),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 1,
|
||
slope_scale: 1.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
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_mask: None,
|
||
cache: None,
|
||
});
|
||
let mesh_plain_transparent_pipeline =
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("mesh.plain.transparent.pipeline"),
|
||
layout: Some(&mesh_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &mesh_shader,
|
||
entry_point: Some("vs_main_plain"),
|
||
buffers: &[
|
||
mesh_gpu::MeshPlainVertex::layout(),
|
||
mesh_gpu::MeshInstanceGpu::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(false),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 1,
|
||
slope_scale: 1.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
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_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
// Highlight variants reuse the resident mesh buffers and differ only in
|
||
// their fixed fragment tint. No selected mesh geometry is re-uploaded.
|
||
let make_mesh_highlight_pipeline =
|
||
|label: &'static str,
|
||
vertex_entry: &'static str,
|
||
vertex_layout: wgpu::VertexBufferLayout<'static>,
|
||
fragment_entry: &'static str| {
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some(label),
|
||
layout: Some(&mesh_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &mesh_shader,
|
||
entry_point: Some(vertex_entry),
|
||
buffers: &[vertex_layout, mesh_gpu::MeshInstanceGpu::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(false),
|
||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||
stencil: content_stencil.clone(),
|
||
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(fragment_entry),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
})
|
||
};
|
||
let mesh_selected_pipeline = make_mesh_highlight_pipeline(
|
||
"mesh.highlight.selected.pipeline",
|
||
"vs_main",
|
||
mesh_gpu::MeshVertex::layout(),
|
||
"fs_highlight_selected",
|
||
);
|
||
let mesh_plain_selected_pipeline = make_mesh_highlight_pipeline(
|
||
"mesh.plain.highlight.selected.pipeline",
|
||
"vs_main_plain",
|
||
mesh_gpu::MeshPlainVertex::layout(),
|
||
"fs_highlight_selected",
|
||
);
|
||
let mesh_hover_pipeline = make_mesh_highlight_pipeline(
|
||
"mesh.highlight.hover.pipeline",
|
||
"vs_main",
|
||
mesh_gpu::MeshVertex::layout(),
|
||
"fs_highlight_hover",
|
||
);
|
||
let mesh_plain_hover_pipeline = make_mesh_highlight_pipeline(
|
||
"mesh.plain.highlight.hover.pipeline",
|
||
"vs_main_plain",
|
||
mesh_gpu::MeshPlainVertex::layout(),
|
||
"fs_highlight_hover",
|
||
);
|
||
|
||
// 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
|
||
// faces, one of which would otherwise hide the edge).
|
||
// Edge/wireframe pipeline (LineList). `fs_edge` outputs the flat entity
|
||
// colour — no lighting — for the lines-only modes. A `fs_edge_black`
|
||
// twin (below) forces black for the edge overlay in filled modes.
|
||
let make_edge_pipeline =
|
||
|label: &'static str,
|
||
fs: &'static str,
|
||
vertex_layout: wgpu::VertexBufferLayout<'static>| {
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some(label),
|
||
layout: Some(&mesh_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &mesh_shader,
|
||
entry_point: Some("vs_edge"),
|
||
buffers: &[vertex_layout, mesh_gpu::MeshInstanceGpu::layout()],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
},
|
||
primitive: wgpu::PrimitiveState {
|
||
topology: wgpu::PrimitiveTopology::LineList,
|
||
cull_mode: None,
|
||
..Default::default()
|
||
},
|
||
depth_stencil: Some(wgpu::DepthStencilState {
|
||
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
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),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
})
|
||
};
|
||
let mesh_wireframe_pipeline = make_edge_pipeline(
|
||
"mesh.wireframe.pipeline",
|
||
"fs_edge",
|
||
mesh_gpu::MeshVertex::edge_layout(),
|
||
);
|
||
let mesh_edge_black_pipeline = make_edge_pipeline(
|
||
"mesh.edge_black.pipeline",
|
||
"fs_edge_black",
|
||
mesh_gpu::MeshVertex::edge_layout(),
|
||
);
|
||
let silhouette_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("mesh.silhouette.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||
"../../shaders/silhouette.wgsl"
|
||
))),
|
||
});
|
||
let silhouette_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("mesh.silhouette.layout"),
|
||
bind_group_layouts: &[Some(&frame_bgl)],
|
||
immediate_size: 0,
|
||
});
|
||
let make_silhouette_pipeline = |label: &'static str, fragment: &'static str| {
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some(label),
|
||
layout: Some(&silhouette_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &silhouette_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[
|
||
mesh_gpu::SilhouetteVertex::layout(),
|
||
mesh_gpu::SilhouetteInstance::layout(),
|
||
],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
},
|
||
primitive: wgpu::PrimitiveState {
|
||
topology: wgpu::PrimitiveTopology::LineList,
|
||
cull_mode: None,
|
||
..Default::default()
|
||
},
|
||
depth_stencil: Some(wgpu::DepthStencilState {
|
||
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &silhouette_shader,
|
||
entry_point: Some(fragment),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
})
|
||
};
|
||
let silhouette_pipeline = make_silhouette_pipeline("mesh.silhouette.pipeline", "fs_main");
|
||
let silhouette_black_pipeline =
|
||
make_silhouette_pipeline("mesh.silhouette_black.pipeline", "fs_black");
|
||
|
||
// Depth-only variant — TriangleList, back-face culling stays on
|
||
// (we only want front-facing fragments to write depth so wires
|
||
// on the far side of the mesh stay hidden), `write_mask` zero
|
||
// so no fragment ever reaches the colour buffer.
|
||
let mesh_depth_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("mesh.depth.pipeline"),
|
||
layout: Some(&mesh_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &mesh_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[
|
||
mesh_gpu::MeshVertex::layout(),
|
||
mesh_gpu::MeshInstanceGpu::layout(),
|
||
],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
},
|
||
primitive: wgpu::PrimitiveState {
|
||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||
cull_mode: None, // two-sided: ACIS import winding is unreliable
|
||
..Default::default()
|
||
},
|
||
depth_stencil: Some(wgpu::DepthStencilState {
|
||
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 1,
|
||
slope_scale: 1.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
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: None,
|
||
write_mask: wgpu::ColorWrites::empty(),
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
let mesh_plain_depth_pipeline =
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("mesh.plain.depth.pipeline"),
|
||
layout: Some(&mesh_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &mesh_shader,
|
||
entry_point: Some("vs_main_plain"),
|
||
buffers: &[
|
||
mesh_gpu::MeshPlainVertex::layout(),
|
||
mesh_gpu::MeshInstanceGpu::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 1,
|
||
slope_scale: 1.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
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: None,
|
||
write_mask: wgpu::ColorWrites::empty(),
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
// ── Face3D pipeline ────────────────────────────────────────────────
|
||
let face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("face3d.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!(
|
||
"face3d.wgsl"
|
||
))),
|
||
});
|
||
let block_face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("block_face3d.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!(
|
||
"block_face3d.wgsl"
|
||
))),
|
||
});
|
||
|
||
let face3d_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("face3d.pipeline_layout"),
|
||
bind_group_layouts: &[&frame_bgl].map(Some),
|
||
immediate_size: 0,
|
||
});
|
||
|
||
let face3d_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("face3d.pipeline"),
|
||
layout: Some(&face3d_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &face3d_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[face3d_gpu::Face3DVertex::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 1,
|
||
slope_scale: 1.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &face3d_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_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
// Depth-only variant — write_mask zero, no blend. The face3d
|
||
// shader still runs but its colour output is discarded, so we
|
||
// get a pure depth prepass for HiddenLine.
|
||
let face3d_depth_pipeline =
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("face3d.depth.pipeline"),
|
||
layout: Some(&face3d_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &face3d_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[face3d_gpu::Face3DVertex::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 1,
|
||
slope_scale: 1.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &face3d_shader,
|
||
entry_point: Some("fs_main"),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: None,
|
||
write_mask: wgpu::ColorWrites::empty(),
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
let make_block_face3d_pipeline = |label: &'static str, depth_only: bool| {
|
||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some(label),
|
||
layout: Some(&face3d_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &block_face3d_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[
|
||
face3d_gpu::Face3DVertex::layout(),
|
||
face3d_gpu::Face3DInstance::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState {
|
||
constant: 1,
|
||
slope_scale: 1.0,
|
||
clamp: 0.0,
|
||
},
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &block_face3d_shader,
|
||
entry_point: Some("fs_main"),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: (!depth_only).then_some(wgpu::BlendState::ALPHA_BLENDING),
|
||
write_mask: if depth_only {
|
||
wgpu::ColorWrites::empty()
|
||
} else {
|
||
wgpu::ColorWrites::ALL
|
||
},
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
})
|
||
};
|
||
let block_face3d_pipeline = make_block_face3d_pipeline("block_face3d.pipeline", false);
|
||
let block_face3d_depth_pipeline =
|
||
make_block_face3d_pipeline("block_face3d.depth.pipeline", true);
|
||
|
||
// ── Image pipeline ─────────────────────────────────────────────────
|
||
let image_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||
label: Some("image.bgl1"),
|
||
entries: &[
|
||
// binding 0: texture
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 0,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
// binding 1: sampler
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 1,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
// binding 2: ImageParams uniform (read in vertex for the
|
||
// draw-order z bias and in fragment for opacity).
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 2,
|
||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: None,
|
||
},
|
||
count: None,
|
||
},
|
||
],
|
||
});
|
||
|
||
let image_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("image.pipeline_layout"),
|
||
bind_group_layouts: &[&frame_bgl, &image_bgl1].map(Some),
|
||
immediate_size: 0,
|
||
});
|
||
|
||
let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("image.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!(
|
||
"image.wgsl"
|
||
))),
|
||
});
|
||
|
||
let image_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("image.pipeline"),
|
||
layout: Some(&image_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &image_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[
|
||
image_gpu::ImageVertex::layout(),
|
||
image_gpu::ImageInstance::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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &image_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_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
// ── Point cloud sprites ────────────────────────────────────────────
|
||
let point_cloud_bgl1 = point_cloud_gpu::params_layout(device);
|
||
let point_cloud_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("point_cloud.pipeline_layout"),
|
||
bind_group_layouts: &[&frame_bgl, &point_cloud_bgl1].map(Some),
|
||
immediate_size: 0,
|
||
});
|
||
let point_cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("point_cloud.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(concat!(
|
||
env!("CARGO_MANIFEST_DIR"),
|
||
"/src/shaders/point_cloud.wgsl"
|
||
)))),
|
||
});
|
||
let point_cloud_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("point_cloud.pipeline"),
|
||
layout: Some(&point_cloud_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &point_cloud_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[point_cloud_gpu::instance_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::Depth24PlusStencil8,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||
stencil: content_stencil.clone(),
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState {
|
||
count: MSAA_SAMPLES,
|
||
mask: !0,
|
||
alpha_to_coverage_enabled: false,
|
||
},
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &point_cloud_shader,
|
||
entry_point: Some("fs_main"),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: None,
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
let gpu_point_clouds = point_cloud_gpu::PointCloudGpu::new(device, &point_cloud_bgl1);
|
||
|
||
// ── Text (SDF glyph quads) ─────────────────────────────────────────
|
||
let text_atlas_bgl = text_gpu::TextAtlasGpu::bind_group_layout(device);
|
||
let (
|
||
text_pipeline,
|
||
text_highlight_pipeline,
|
||
block_text_pipeline,
|
||
block_text_highlight_pipeline,
|
||
) = text_gpu::create_pipelines(
|
||
device,
|
||
&frame_bgl,
|
||
&text_atlas_bgl,
|
||
format,
|
||
MSAA_SAMPLES,
|
||
&content_stencil,
|
||
);
|
||
|
||
let (circle_pipeline, circle_xray_pipeline) = circle_gpu::create_pipelines(
|
||
device,
|
||
&frame_bgl,
|
||
format,
|
||
MSAA_SAMPLES,
|
||
&content_stencil,
|
||
);
|
||
|
||
let (ellipse_pipeline, ellipse_xray_pipeline) = ellipse_gpu::create_pipelines(
|
||
device,
|
||
&frame_bgl,
|
||
format,
|
||
MSAA_SAMPLES,
|
||
&content_stencil,
|
||
);
|
||
|
||
let viewcube = ViewCubePipeline::new(device, queue, format);
|
||
|
||
let init_size = Size::new(1, 1);
|
||
let msaa_view = create_msaa_texture(device, init_size, format)
|
||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||
let resolve_tex = create_resolve_texture(device, init_size, format);
|
||
let resolve_view = resolve_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||
|
||
// ── Blit pipeline (resolve texture → surface target) ──────────────
|
||
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("blit.shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||
"../../shaders/blit.wgsl"
|
||
))),
|
||
});
|
||
|
||
let blit_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||
label: Some("blit.bgl"),
|
||
entries: &[
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 0,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 1,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 2,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: None,
|
||
},
|
||
count: None,
|
||
},
|
||
],
|
||
});
|
||
|
||
// UV crop uniform: [uv_offset_x, uv_offset_y, uv_scale_x, uv_scale_y]
|
||
// padded to 16 bytes (std140 vec2 alignment). Defaulted to the
|
||
// identity crop (offset 0, scale 1) for the common on-canvas case.
|
||
let blit_uniform_buffer = gpu_upload::upload_buffer(
|
||
device,
|
||
queue,
|
||
"blit.uniform_buffer",
|
||
&[0.0f32, 0.0, 1.0, 1.0],
|
||
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||
);
|
||
|
||
let blit_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("blit.pipeline_layout"),
|
||
bind_group_layouts: &[&blit_bgl].map(Some),
|
||
immediate_size: 0,
|
||
});
|
||
|
||
let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("blit.pipeline"),
|
||
layout: Some(&blit_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &blit_shader,
|
||
entry_point: Some("vs_main"),
|
||
buffers: &[],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
},
|
||
primitive: wgpu::PrimitiveState {
|
||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||
..Default::default()
|
||
},
|
||
depth_stencil: None,
|
||
multisample: wgpu::MultisampleState::default(),
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &blit_shader,
|
||
entry_point: Some("fs_main"),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
// Premultiplied-alpha blend: the geometry passes already
|
||
// wrote into a transparent MSAA target with standard
|
||
// `SrcAlpha / 1-SrcAlpha` blending, so AA-edge fragments
|
||
// sit as `(rgb * a, a)` in the resolve texture. Treating
|
||
// that as straight alpha during the surface blit would
|
||
// multiply by alpha a second time and darken thin lines
|
||
// / curves. `PREMULTIPLIED_ALPHA_BLENDING` uses `One` as
|
||
// the source colour factor and leaves the dst weighted
|
||
// by `1-SrcAlpha`, which is the correct compositing
|
||
// operator for already-premultiplied content.
|
||
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
let blit_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||
label: Some("blit.sampler"),
|
||
mag_filter: wgpu::FilterMode::Nearest,
|
||
min_filter: wgpu::FilterMode::Nearest,
|
||
..Default::default()
|
||
});
|
||
|
||
let blit_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||
label: Some("blit.bind_group"),
|
||
layout: &blit_bgl,
|
||
entries: &[
|
||
wgpu::BindGroupEntry {
|
||
binding: 0,
|
||
resource: wgpu::BindingResource::TextureView(&resolve_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 1,
|
||
resource: wgpu::BindingResource::Sampler(&blit_sampler),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 2,
|
||
resource: blit_uniform_buffer.as_entire_binding(),
|
||
},
|
||
],
|
||
});
|
||
|
||
Self {
|
||
background_pipeline,
|
||
shadow_pipeline,
|
||
shadow_plain_pipeline,
|
||
wire_pipeline,
|
||
block_wire_pipeline,
|
||
clip_mask_pipeline,
|
||
wire_black_pipeline,
|
||
block_wire_black_pipeline,
|
||
wire_xray_pipeline,
|
||
circle_pipeline,
|
||
circle_xray_pipeline,
|
||
ellipse_pipeline,
|
||
ellipse_xray_pipeline,
|
||
block_wire_xray_pipeline,
|
||
wire_const_bgl,
|
||
block_wire_const_bgl,
|
||
block_wire_mode: wire_mode,
|
||
gpu_error_epoch: gpu_errors_seen(),
|
||
wipeout_pipeline,
|
||
hatch_gpu,
|
||
image_pipeline,
|
||
point_cloud_pipeline,
|
||
text_pipeline,
|
||
block_text_pipeline,
|
||
text_highlight_pipeline,
|
||
block_text_highlight_pipeline,
|
||
text_atlas_bgl,
|
||
text_atlas_gpu: None,
|
||
text_gpu: Vec::new(),
|
||
block_text_gpu: Vec::new(),
|
||
block_text_highlight_gpu: Vec::new(),
|
||
text_highlight_gpu: Vec::new(),
|
||
text_preview_gpu: Vec::new(),
|
||
silhouette_chunks: Vec::new(),
|
||
silhouette_source_key: (usize::MAX, usize::MAX, u64::MAX),
|
||
silhouette_source_groups: Vec::new(),
|
||
mesh_pipeline,
|
||
mesh_plain_pipeline,
|
||
mesh_transparent_pipeline,
|
||
mesh_plain_transparent_pipeline,
|
||
mesh_selected_pipeline,
|
||
mesh_plain_selected_pipeline,
|
||
mesh_hover_pipeline,
|
||
mesh_plain_hover_pipeline,
|
||
mesh_wireframe_pipeline,
|
||
mesh_edge_black_pipeline,
|
||
silhouette_pipeline,
|
||
silhouette_black_pipeline,
|
||
mesh_depth_pipeline,
|
||
mesh_plain_depth_pipeline,
|
||
mesh_material_bgl,
|
||
mesh_default_material_bind_group,
|
||
face3d_pipeline,
|
||
block_face3d_pipeline,
|
||
face3d_depth_pipeline,
|
||
block_face3d_depth_pipeline,
|
||
uniform_buffer,
|
||
frame_bgl,
|
||
uniform_bind_group,
|
||
shadow_uniform_bind_group,
|
||
background_texture,
|
||
environment_texture,
|
||
background_sampler,
|
||
background_source_id: 0,
|
||
environment_source_id: 0,
|
||
shadow_full: None,
|
||
_shadow_fallback_texture: shadow_fallback_texture,
|
||
shadow_fallback_view,
|
||
shadow_sampler,
|
||
shadow_enabled: false,
|
||
wipeout_bgl1,
|
||
image_bgl1,
|
||
depth_texture_size: Size::new(1, 1),
|
||
viewport: None,
|
||
// (0, 0) forces the first `ensure_depth_texture` to allocate at the
|
||
// real rounded size — the constructor textures above are placeholders.
|
||
alloc_size: Size::new(0, 0),
|
||
depth_view,
|
||
msaa_view,
|
||
resolve_view,
|
||
blit_pipeline,
|
||
blit_bind_group_layout: blit_bgl,
|
||
blit_sampler,
|
||
blit_bind_group,
|
||
blit_uniform_buffer,
|
||
surface_format: format,
|
||
gpu_wires: std::sync::Arc::new(vec![]),
|
||
gpu_circles: std::sync::Arc::new(vec![]),
|
||
gpu_ellipses: std::sync::Arc::new(vec![]),
|
||
gpu_block_wires: std::sync::Arc::new(vec![]),
|
||
wire_arena: None,
|
||
wire_arena_mesh: None,
|
||
wire_arena_fallback: std::sync::Arc::new(Vec::new()),
|
||
wire_arena_fallback_kind: None,
|
||
wire_arena_fallback_handles: rustc_hash::FxHashSet::default(),
|
||
wire_arena_id: u64::MAX,
|
||
partition_contributors: rustc_hash::FxHashSet::default(),
|
||
partition_depth_generation: u64::MAX,
|
||
wire_cull_key: (u64::MAX, u64::MAX, 0, 0),
|
||
hatch_lod_key: (usize::MAX, u64::MAX, 0, 0, false),
|
||
wipeout_lod_key: (usize::MAX, u64::MAX, 0, 0, false),
|
||
silhouette_key: (usize::MAX, u64::MAX, [u32::MAX; 3], false),
|
||
clip_boundary: None,
|
||
gpu_selected_wires: vec![],
|
||
gpu_selected_circles: vec![],
|
||
gpu_selected_ellipses: vec![],
|
||
gpu_selected_block_wires: vec![],
|
||
gpu_preview_wires: vec![],
|
||
gpu_preview_circles: vec![],
|
||
gpu_preview_ellipses: vec![],
|
||
gpu_wipeouts: vec![],
|
||
wipeout_skip_flags: vec![],
|
||
gpu_images: vec![],
|
||
gpu_point_clouds,
|
||
gpu_mesh_batch: vec![],
|
||
gpu_mesh_dynamic: vec![],
|
||
mesh_disabled_chunks: rustc_hash::FxHashSet::default(),
|
||
mesh_dynamic_handles: rustc_hash::FxHashSet::default(),
|
||
cached_mesh_content_id: u64::MAX,
|
||
mesh_ranges_by_handle: rustc_hash::FxHashMap::default(),
|
||
mesh_highlight_draws: vec![],
|
||
cached_highlight_key: (u64::MAX, u64::MAX),
|
||
gpu_face3d_fill: None,
|
||
gpu_face3d_edges: vec![],
|
||
viewcube,
|
||
cached_hatch_source: None,
|
||
cached_preview_hatch_source: None,
|
||
cached_wipeout_source: None,
|
||
cached_image_source: None,
|
||
cached_point_cloud_source: None,
|
||
cached_text_source: None,
|
||
cached_annotation_highlight_source: None,
|
||
cached_mesh_source: None,
|
||
cached_face3d_source: None,
|
||
cached_face3d_depth_source: None,
|
||
cached_fill_mode: false,
|
||
cached_epoch: (u64::MAX, u64::MAX, u64::MAX),
|
||
cached_wire_id: u64::MAX,
|
||
cached_selection: (u64::MAX, u64::MAX),
|
||
cached_face3d_key: (u64::MAX, false, false, u64::MAX),
|
||
cached_solid_visibility: (u64::MAX, [u32::MAX; 3], u64::MAX),
|
||
wire_handle_index: std::sync::Arc::new(rustc_hash::FxHashMap::default()),
|
||
render_sig: u64::MAX,
|
||
skip_geometry: false,
|
||
skip_hatch_frame: false,
|
||
skip_background: false,
|
||
slot_id: u64::MAX,
|
||
}
|
||
}
|
||
|
||
/// Upload block placements while reusing unchanged geometry.
|
||
pub fn upload_block_wires(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
wires: &[&WireModel],
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
block_geometry: &mut wire_gpu::BlockGeometryCache,
|
||
) -> Vec<BlockWireGpu> {
|
||
BlockWireGpu::from_wires(
|
||
device,
|
||
queue,
|
||
wires,
|
||
depth_map,
|
||
None,
|
||
wire_gpu::BlockWireTarget {
|
||
const_bgl: &self.block_wire_const_bgl,
|
||
mode: self.block_wire_mode,
|
||
},
|
||
Some(block_geometry),
|
||
)
|
||
}
|
||
|
||
/// Upload GPU-instanced analytical circles from a wire slice.
|
||
pub fn upload_circles(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
wires: &[WireModel],
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
) -> Vec<CircleGpu> {
|
||
let mut instances: Vec<CircleInstance> = Vec::new();
|
||
for wire in wires {
|
||
if !wire.display_visible {
|
||
continue;
|
||
}
|
||
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
|
||
if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) {
|
||
instances.extend(insts);
|
||
}
|
||
}
|
||
self.upload_circles_from_instances(device, queue, &instances)
|
||
}
|
||
|
||
/// Upload GPU-instanced analytical circles from pre-extracted instances.
|
||
pub fn upload_circles_from_instances(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
instances: &[CircleInstance],
|
||
) -> Vec<CircleGpu> {
|
||
if instances.is_empty() {
|
||
vec![]
|
||
} else {
|
||
let mut sorted = instances.to_vec();
|
||
sorted.sort_by(|a, b| {
|
||
a.params[2]
|
||
.partial_cmp(&b.params[2])
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
vec![CircleGpu::from_instances(
|
||
device,
|
||
queue,
|
||
"viewer.circles",
|
||
&sorted,
|
||
)]
|
||
}
|
||
}
|
||
|
||
/// Upload GPU-instanced analytical ellipses from a wire slice.
|
||
pub fn upload_ellipses(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
wires: &[WireModel],
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
) -> Vec<EllipseGpu> {
|
||
let mut instances: Vec<EllipseInstance> = Vec::new();
|
||
for wire in wires {
|
||
if !wire.display_visible {
|
||
continue;
|
||
}
|
||
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
|
||
if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) {
|
||
instances.extend(insts);
|
||
}
|
||
}
|
||
self.upload_ellipses_from_instances(device, queue, &instances)
|
||
}
|
||
|
||
/// Upload GPU-instanced analytical ellipses from pre-extracted instances.
|
||
pub fn upload_ellipses_from_instances(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
instances: &[EllipseInstance],
|
||
) -> Vec<EllipseGpu> {
|
||
if instances.is_empty() {
|
||
vec![]
|
||
} else {
|
||
let mut sorted = instances.to_vec();
|
||
sorted.sort_by(|a, b| {
|
||
a.params[2]
|
||
.partial_cmp(&b.params[2])
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
vec![EllipseGpu::from_instances(
|
||
device,
|
||
queue,
|
||
"viewer.ellipses",
|
||
&sorted,
|
||
)]
|
||
}
|
||
}
|
||
|
||
/// Build resident batches and a handle index shared across viewport slots.
|
||
pub fn build_wire_buffers(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
wires: &[WireModel],
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
block_geometry: &mut wire_gpu::BlockGeometryCache,
|
||
) -> (
|
||
std::sync::Arc<Vec<WireGpu>>,
|
||
std::sync::Arc<Vec<BlockWireGpu>>,
|
||
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
|
||
std::sync::Arc<Vec<CircleGpu>>,
|
||
std::sync::Arc<Vec<EllipseGpu>>,
|
||
) {
|
||
// Batch the wire pass: instead of one GPU buffer + one draw call per
|
||
// WireModel (tens of thousands on a large drawing), merge maximal runs
|
||
// of *consecutive* wires that share scissor + mesh-edge state into one
|
||
// concatenated instance buffer each. The draw loop then issues one draw
|
||
// per run. Runs must be consecutive (not regrouped) so the original
|
||
// wire order — already sorted by draw order — is preserved; depth bias
|
||
// and alpha blending both depend on it. Scissor and mesh-edge stay
|
||
// grouping keys because the draw loop sets one scissor per batch and
|
||
let mut batches: Vec<WireGpu> = Vec::new();
|
||
let mut block_wires: Vec<&WireModel> = Vec::new();
|
||
let mut i = 0;
|
||
while i < wires.len() {
|
||
let kind = wire_arena::classify_wire(&wires[i]);
|
||
match kind {
|
||
wire_arena::WireKind::Invisible
|
||
| wire_arena::WireKind::Circle
|
||
| wire_arena::WireKind::Ellipse
|
||
| wire_arena::WireKind::Empty => {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
wire_arena::WireKind::Block => {
|
||
block_wires.push(&wires[i]);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
wire_arena::WireKind::Regular | wire_arena::WireKind::MeshEdge => {
|
||
let mesh_edge = kind == wire_arena::WireKind::MeshEdge;
|
||
let mut j = i + 1;
|
||
while j < wires.len() && wire_arena::classify_wire(&wires[j]) == kind {
|
||
j += 1;
|
||
}
|
||
let refs: Vec<&WireModel> = wires[i..j].iter().collect();
|
||
batches.extend(WireGpu::from_run_refs(
|
||
device,
|
||
queue,
|
||
&refs,
|
||
depth_map,
|
||
mesh_edge,
|
||
self.wire_const_bgl.as_ref(),
|
||
));
|
||
i = j;
|
||
}
|
||
}
|
||
}
|
||
let block_batches = BlockWireGpu::from_wires(
|
||
device,
|
||
queue,
|
||
&block_wires,
|
||
depth_map,
|
||
None,
|
||
wire_gpu::BlockWireTarget {
|
||
const_bgl: &self.block_wire_const_bgl,
|
||
mode: self.block_wire_mode,
|
||
},
|
||
Some(block_geometry),
|
||
);
|
||
|
||
// Parse handles in parallel, preserving wire order for hover slot lists.
|
||
let t_index = crate::perf::enabled().then(iced::time::Instant::now);
|
||
let parsed: Vec<(u64, u32)> = {
|
||
use crate::par::prelude::*;
|
||
wires
|
||
.par_iter()
|
||
.enumerate()
|
||
.filter_map(|(idx, w)| wire_gpu::fast_parse_u64(&w.name).map(|h| (h, idx as u32)))
|
||
.collect()
|
||
};
|
||
let mut index: rustc_hash::FxHashMap<u64, Vec<u32>> = rustc_hash::FxHashMap::default();
|
||
index.reserve(parsed.len());
|
||
for (handle, idx) in parsed {
|
||
index.entry(handle).or_default().push(idx);
|
||
}
|
||
if let Some(started) = t_index {
|
||
crate::perf_record!(
|
||
"[perf] wire-handle-index {:.1}ms wires={}",
|
||
started.elapsed().as_secs_f64() * 1000.0,
|
||
wires.len(),
|
||
);
|
||
}
|
||
let circle_batches = self.upload_circles(device, queue, wires, depth_map);
|
||
let ellipse_batches = self.upload_ellipses(device, queue, wires, depth_map);
|
||
(
|
||
std::sync::Arc::new(batches),
|
||
std::sync::Arc::new(block_batches),
|
||
std::sync::Arc::new(index),
|
||
std::sync::Arc::new(circle_batches),
|
||
std::sync::Arc::new(ellipse_batches),
|
||
)
|
||
}
|
||
|
||
/// Build the selection xray overlay: full-brightness copies of the wires
|
||
/// whose entity handle is in `highlight`, drawn on top so the selection is
|
||
/// always visible. Selection is no longer baked into the wire tessellation,
|
||
/// so this is driven by the live highlight set and rebuilt only when the
|
||
/// selection (or the underlying wire content) changes — picking an entity
|
||
/// refreshes just this overlay instead of re-tessellating the model. The
|
||
/// xray pass applies neither scissor nor mesh-edge skip, so everything
|
||
/// merges into one order-preserving run.
|
||
/// Split highlighted wires into analytic instances and the wires that need
|
||
/// real geometry, in one extraction pass per wire.
|
||
#[allow(clippy::type_complexity)]
|
||
fn classify_highlight_wires<'a>(
|
||
wires: &[&'a WireModel],
|
||
color: Option<[f32; 4]>,
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
) -> (
|
||
Vec<CircleInstance>,
|
||
Vec<EllipseInstance>,
|
||
Vec<&'a WireModel>,
|
||
Vec<&'a WireModel>,
|
||
) {
|
||
use crate::par::prelude::*;
|
||
// Pure per-wire work, so it fans out; `collect` on an indexed
|
||
// parallel iterator keeps the order, and the order is what decides
|
||
// the instance layout.
|
||
let per: Vec<(
|
||
Option<Vec<CircleInstance>>,
|
||
Option<Vec<EllipseInstance>>,
|
||
bool,
|
||
)> = wires
|
||
.par_iter()
|
||
.map(|&wire| {
|
||
if wire.render_instance.is_some() {
|
||
return (None, None, true);
|
||
}
|
||
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
|
||
let mut circles = circle_gpu::extract_circle_instances(wire, depth);
|
||
let mut ellipses = if circles.is_none() {
|
||
ellipse_gpu::extract_ellipse_instances(wire, depth)
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(color) = color {
|
||
if let Some(instances) = circles.as_mut() {
|
||
for instance in instances {
|
||
instance.color = color;
|
||
}
|
||
}
|
||
if let Some(instances) = ellipses.as_mut() {
|
||
for instance in instances {
|
||
instance.color = color;
|
||
}
|
||
}
|
||
}
|
||
(circles, ellipses, false)
|
||
})
|
||
.collect();
|
||
|
||
let mut circles: Vec<CircleInstance> = Vec::new();
|
||
let mut ellipses: Vec<EllipseInstance> = Vec::new();
|
||
let mut regular: Vec<&'a WireModel> = Vec::new();
|
||
let mut blocks: Vec<&'a WireModel> = Vec::new();
|
||
for (&wire, (wire_circles, wire_ellipses, is_block)) in wires.iter().zip(per) {
|
||
if is_block {
|
||
blocks.push(wire);
|
||
continue;
|
||
}
|
||
let shaped = wire_circles.is_some() || wire_ellipses.is_some();
|
||
if let Some(instances) = wire_circles {
|
||
circles.extend(instances);
|
||
}
|
||
if let Some(instances) = wire_ellipses {
|
||
ellipses.extend(instances);
|
||
}
|
||
if !shaped {
|
||
regular.push(wire);
|
||
}
|
||
}
|
||
(circles, ellipses, regular, blocks)
|
||
}
|
||
|
||
pub fn upload_selected_wires(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
wires: &[WireModel],
|
||
selected: &rustc_hash::FxHashSet<codec::Handle>,
|
||
hovered: &rustc_hash::FxHashSet<codec::Handle>,
|
||
annotation_context_wires: &[WireModel],
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
selected_tint: Option<[f32; 4]>,
|
||
) {
|
||
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
|
||
if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() {
|
||
self.gpu_selected_wires = vec![];
|
||
self.gpu_selected_block_wires = vec![];
|
||
self.gpu_selected_circles = vec![];
|
||
self.gpu_selected_ellipses = vec![];
|
||
return;
|
||
}
|
||
// Gather borrowed highlighted wires via the prebuilt index —
|
||
// O(highlighted), no per-wire string parse or deep geometry clone.
|
||
let mut selected_wires: Vec<&WireModel> = Vec::new();
|
||
let mut hover_wires: Vec<&WireModel> = Vec::new();
|
||
for h in selected {
|
||
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
|
||
debug_assert!(
|
||
idxs.windows(2).all(|pair| pair[0] <= pair[1]),
|
||
"wire_handle_index slots must stay ascending",
|
||
);
|
||
for &i in idxs {
|
||
if let Some(w) = wires.get(i as usize) {
|
||
// An unseen pick hull (a point cloud's extents box) shows while
|
||
// highlighted, as the reference does.
|
||
if !w.display_visible && (w.pick_tris.is_empty() || w.snap_only) {
|
||
continue;
|
||
}
|
||
selected_wires.push(w);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for h in hovered.iter().filter(|handle| !selected.contains(handle)) {
|
||
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
|
||
for &i in idxs {
|
||
if let Some(w) = wires.get(i as usize) {
|
||
// An unseen pick hull (a point cloud's extents box) shows while
|
||
// highlighted, as the reference does.
|
||
if !w.display_visible && (w.pick_tris.is_empty() || w.snap_only) {
|
||
continue;
|
||
}
|
||
hover_wires.push(w);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for wire in annotation_context_wires {
|
||
if !wire.display_visible {
|
||
continue;
|
||
}
|
||
if wire.selected {
|
||
selected_wires.push(wire);
|
||
} else {
|
||
hover_wires.push(wire);
|
||
}
|
||
}
|
||
let t_gather = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
|
||
let (mut selected_circles, mut selected_ellipses, selected_regular, selected_blocks) =
|
||
Self::classify_highlight_wires(
|
||
&selected_wires,
|
||
Some(selected_tint.unwrap_or(WireModel::SELECTED)),
|
||
depth_map,
|
||
);
|
||
let (hover_circles, hover_ellipses, hover_regular, hover_blocks) =
|
||
Self::classify_highlight_wires(&hover_wires, Some(WireModel::HOVER), depth_map);
|
||
selected_circles.extend(hover_circles);
|
||
selected_ellipses.extend(hover_ellipses);
|
||
|
||
let t_shapes = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
|
||
self.gpu_selected_circles = if selected_circles.is_empty() {
|
||
vec![]
|
||
} else {
|
||
vec![CircleGpu::from_instances(
|
||
device,
|
||
queue,
|
||
"viewer.selected_circles",
|
||
&selected_circles,
|
||
)]
|
||
};
|
||
self.gpu_selected_ellipses = if selected_ellipses.is_empty() {
|
||
vec![]
|
||
} else {
|
||
vec![EllipseGpu::from_instances(
|
||
device,
|
||
queue,
|
||
"viewer.selected_ellipses",
|
||
&selected_ellipses,
|
||
)]
|
||
};
|
||
let t_split = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
|
||
let mut gpu = if let Some(tint) = selected_tint {
|
||
WireGpu::from_highlight_refs(
|
||
device,
|
||
queue,
|
||
&selected_regular,
|
||
tint,
|
||
depth_map,
|
||
self.wire_const_bgl.as_ref(),
|
||
)
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
gpu.extend(WireGpu::from_highlight_refs(
|
||
device,
|
||
queue,
|
||
&hover_regular,
|
||
WireModel::HOVER,
|
||
depth_map,
|
||
self.wire_const_bgl.as_ref(),
|
||
));
|
||
let t_regular = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
|
||
self.gpu_selected_wires = gpu;
|
||
let mut block_gpu = if let Some(tint) = selected_tint {
|
||
BlockWireGpu::from_wires(
|
||
device,
|
||
queue,
|
||
&selected_blocks,
|
||
depth_map,
|
||
Some(tint),
|
||
wire_gpu::BlockWireTarget {
|
||
const_bgl: &self.block_wire_const_bgl,
|
||
mode: self.block_wire_mode,
|
||
},
|
||
None,
|
||
)
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
block_gpu.extend(BlockWireGpu::from_wires(
|
||
device,
|
||
queue,
|
||
&hover_blocks,
|
||
depth_map,
|
||
Some(WireModel::HOVER),
|
||
wire_gpu::BlockWireTarget {
|
||
const_bgl: &self.block_wire_const_bgl,
|
||
mode: self.block_wire_mode,
|
||
},
|
||
None,
|
||
));
|
||
self.gpu_selected_block_wires = block_gpu;
|
||
if let Some(started) = perf_started {
|
||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||
if elapsed_ms >= 1.0 {
|
||
let gather = t_gather.unwrap_or(0.0);
|
||
let classify = t_shapes.unwrap_or(0.0);
|
||
let analytic = t_split.unwrap_or(0.0);
|
||
let regular = t_regular.unwrap_or(0.0);
|
||
crate::perf_record!(
|
||
"[perf] wire-highlight-detail gather={gather:.1} classify={:.1} \
|
||
analytic={:.1} regular={:.1} blocks={:.1}",
|
||
classify - gather,
|
||
analytic - classify,
|
||
regular - analytic,
|
||
elapsed_ms - regular,
|
||
);
|
||
crate::perf_record!(
|
||
"[perf] wire-highlight {:>7.1}ms handles={} wires={}",
|
||
elapsed_ms,
|
||
selected.len()
|
||
+ hovered
|
||
.iter()
|
||
.filter(|handle| !selected.contains(handle))
|
||
.count(),
|
||
selected_wires.len() + hover_wires.len(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build the text-highlight overlay: the glyph quads of just the selected /
|
||
/// hovered text, recoloured (selection blue, hover orange) and drawn over
|
||
/// the base text pass. Uses the same handle→wire index as the selected-wire
|
||
/// overlay, so it is O(highlighted). Empty when nothing is highlighted.
|
||
pub fn upload_text_highlight(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
wires: &[WireModel],
|
||
selected: &rustc_hash::FxHashSet<codec::Handle>,
|
||
hovered: &rustc_hash::FxHashSet<codec::Handle>,
|
||
annotation_context_wires: &[WireModel],
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
selected_tint: Option<[f32; 4]>,
|
||
) {
|
||
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
|
||
if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() {
|
||
self.text_highlight_gpu.clear();
|
||
self.block_text_highlight_gpu.clear();
|
||
return;
|
||
}
|
||
let mut out: Vec<text_gpu::TextVertex> = Vec::new();
|
||
let mut selected_blocks: Vec<&WireModel> = Vec::new();
|
||
let mut hover_blocks: Vec<&WireModel> = Vec::new();
|
||
fn push<'a>(
|
||
index: &rustc_hash::FxHashMap<u64, Vec<u32>>,
|
||
handle_val: u64,
|
||
tint: [f32; 4],
|
||
wires: &'a [WireModel],
|
||
out: &mut Vec<text_gpu::TextVertex>,
|
||
blocks: &mut Vec<&'a WireModel>,
|
||
) {
|
||
if let Some(idxs) = index.get(&handle_val) {
|
||
for &i in idxs {
|
||
if let Some(w) = wires.get(i as usize) {
|
||
if w.render_instance.is_some() {
|
||
blocks.push(w);
|
||
continue;
|
||
}
|
||
for v in &w.text_verts {
|
||
out.push(text_gpu::TextVertex {
|
||
color: [tint[0], tint[1], tint[2], v.color[3]],
|
||
..*v
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some(tint) = selected_tint {
|
||
for h in selected {
|
||
push(
|
||
&self.wire_handle_index,
|
||
h.value(),
|
||
tint,
|
||
wires,
|
||
&mut out,
|
||
&mut selected_blocks,
|
||
);
|
||
}
|
||
}
|
||
for h in hovered.iter().filter(|handle| !selected.contains(handle)) {
|
||
push(
|
||
&self.wire_handle_index,
|
||
h.value(),
|
||
WireModel::HOVER,
|
||
wires,
|
||
&mut out,
|
||
&mut hover_blocks,
|
||
);
|
||
}
|
||
for wire in annotation_context_wires {
|
||
let tint = if wire.selected {
|
||
selected_tint.unwrap_or(WireModel::SELECTED)
|
||
} else {
|
||
WireModel::HOVER
|
||
};
|
||
if !wire.selected || selected_tint.is_some() {
|
||
for vertex in &wire.text_verts {
|
||
out.push(text_gpu::TextVertex {
|
||
color: [tint[0], tint[1], tint[2], vertex.color[3]],
|
||
..*vertex
|
||
});
|
||
}
|
||
}
|
||
}
|
||
self.text_highlight_gpu = text_gpu::upload_vertices(device, queue, &out);
|
||
let mut block_gpu = if let Some(tint) = selected_tint {
|
||
text_gpu::upload_block_vertex_refs(
|
||
device,
|
||
queue,
|
||
&selected_blocks,
|
||
depth_map,
|
||
Some(tint),
|
||
)
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
block_gpu.extend(text_gpu::upload_block_vertex_refs(
|
||
device,
|
||
queue,
|
||
&hover_blocks,
|
||
depth_map,
|
||
Some(WireModel::HOVER),
|
||
));
|
||
self.block_text_highlight_gpu = block_gpu;
|
||
if let Some(started) = perf_started {
|
||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||
if elapsed_ms >= 1.0 {
|
||
crate::perf_record!(
|
||
"[perf] text-highlight {:>7.1}ms vertices={}",
|
||
elapsed_ms,
|
||
out.len(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Upload the live overlay (command preview / interim / grip-drag) wires.
|
||
/// Small and refreshed each frame they're present; kept separate from the
|
||
/// base wire buffer so a drag never re-uploads the resident base set.
|
||
pub fn upload_preview_wires(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
wires: &[WireModel],
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
) {
|
||
if wires.is_empty() {
|
||
self.gpu_preview_wires.clear();
|
||
self.gpu_preview_circles.clear();
|
||
self.gpu_preview_ellipses.clear();
|
||
} else {
|
||
let partitioned = wire_arena::partition_wires(wires, depth_map);
|
||
let mut non_analytical = partitioned.regular;
|
||
non_analytical.extend(partitioned.mesh);
|
||
self.gpu_preview_wires = if non_analytical.is_empty() {
|
||
vec![]
|
||
} else {
|
||
WireGpu::from_run_refs(
|
||
device,
|
||
queue,
|
||
&non_analytical,
|
||
depth_map,
|
||
false,
|
||
self.wire_const_bgl.as_ref(),
|
||
)
|
||
};
|
||
self.gpu_preview_circles = if partitioned.circle_instances.is_empty() {
|
||
vec![]
|
||
} else {
|
||
vec![CircleGpu::from_instances(
|
||
device,
|
||
queue,
|
||
"preview.circles",
|
||
&partitioned.circle_instances,
|
||
)]
|
||
};
|
||
self.gpu_preview_ellipses = if partitioned.ellipse_instances.is_empty() {
|
||
vec![]
|
||
} else {
|
||
vec![EllipseGpu::from_instances(
|
||
device,
|
||
queue,
|
||
"preview.ellipses",
|
||
&partitioned.ellipse_instances,
|
||
)]
|
||
};
|
||
}
|
||
}
|
||
|
||
/// Upload the live grip-drag / command-preview SDF glyph quads. Re-uploaded
|
||
/// every frame they're present, kept separate from the epoch-cached base
|
||
/// text buffer so a drag never re-uploads the (potentially huge) resident
|
||
/// text set — and so the dragged entity's glyphs (hidden from the base set
|
||
/// while the drag is live) still reach the GPU each frame (issue #316).
|
||
pub fn upload_preview_text(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
verts: &[text_gpu::TextVertex],
|
||
) {
|
||
if verts.is_empty() {
|
||
self.text_preview_gpu.clear();
|
||
return;
|
||
}
|
||
// Ensure the glyph atlas exists / is current even when the base text
|
||
// pass didn't run this frame (e.g. the dragged text is the only text).
|
||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||
if self.text_atlas_gpu.is_none() || atlas.is_dirty() {
|
||
self.text_atlas_gpu = Some(text_gpu::TextAtlasGpu::upload(
|
||
device,
|
||
queue,
|
||
&atlas,
|
||
&self.text_atlas_bgl,
|
||
));
|
||
atlas.clear_dirty();
|
||
}
|
||
}
|
||
self.text_preview_gpu = text_gpu::upload_vertices(device, queue, verts);
|
||
}
|
||
|
||
// The math lives outside the GPU method so it can be unit-tested; see the
|
||
// module test below.
|
||
|
||
/// Rebuild view-dependent silhouette lines through the kernel.
|
||
pub fn upload_silhouettes(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
sets: &[crate::scene::model::mesh_model::MeshLodSet],
|
||
content_id: u64,
|
||
view_dir: glam::Vec3,
|
||
) {
|
||
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
|
||
let view = glam::DVec3::new(view_dir.x as f64, view_dir.y as f64, view_dir.z as f64)
|
||
.normalize_or(glam::DVec3::NEG_Z);
|
||
use crate::scene::pipeline::mesh_gpu::{SilhouetteInstance, SilhouetteVertex};
|
||
let chunk_bytes = gpu_budget::buffer_budget(device);
|
||
let max_vertices = chunk_bytes / std::mem::size_of::<SilhouetteVertex>() / 2 * 2;
|
||
if max_vertices < 2 {
|
||
self.silhouette_chunks.clear();
|
||
return;
|
||
}
|
||
let source_key = (sets.as_ptr() as usize, sets.len(), content_id);
|
||
if self.silhouette_source_key != source_key {
|
||
let mut slots = rustc_hash::FxHashMap::default();
|
||
let mut groups: Vec<Vec<&crate::scene::model::mesh_model::MeshLodSet>> = Vec::new();
|
||
for (index, set) in sets.iter().enumerate() {
|
||
let color = set.display_color().unwrap_or([0.0, 0.0, 0.0, 1.0]);
|
||
let key = match (&set.instance_source, set.instance_transform) {
|
||
(Some(source), Some(transform)) => {
|
||
let matrix = &transform.matrix.m;
|
||
(
|
||
true,
|
||
source.handle.value(),
|
||
[
|
||
matrix[0][0].to_bits(),
|
||
matrix[0][1].to_bits(),
|
||
matrix[0][2].to_bits(),
|
||
matrix[1][0].to_bits(),
|
||
matrix[1][1].to_bits(),
|
||
matrix[1][2].to_bits(),
|
||
matrix[2][0].to_bits(),
|
||
matrix[2][1].to_bits(),
|
||
matrix[2][2].to_bits(),
|
||
],
|
||
color.map(f32::to_bits),
|
||
)
|
||
}
|
||
_ => (false, index as u64, [0; 9], color.map(f32::to_bits)),
|
||
};
|
||
let slot = *slots.entry(key).or_insert_with(|| {
|
||
let slot = groups.len();
|
||
groups.push(Vec::new());
|
||
slot
|
||
});
|
||
groups[slot].push(set);
|
||
}
|
||
|
||
let prepare_group = |group: Vec<&crate::scene::model::mesh_model::MeshLodSet>| {
|
||
let source = *group.first()?;
|
||
let translation = |set: &crate::scene::model::mesh_model::MeshLodSet| {
|
||
set.instance_transform.map_or([0.0; 3], |transform| {
|
||
let matrix = &transform.matrix.m;
|
||
[matrix[0][3], matrix[1][3], matrix[2][3]]
|
||
})
|
||
};
|
||
let base = translation(source);
|
||
let instances: Vec<SilhouetteInstance> = group
|
||
.iter()
|
||
.map(|set| {
|
||
let placement = translation(set);
|
||
let delta = [
|
||
placement[0] - base[0],
|
||
placement[1] - base[1],
|
||
placement[2] - base[2],
|
||
];
|
||
let high = delta.map(|value| value as f32);
|
||
SilhouetteInstance {
|
||
translation: high,
|
||
translation_low: [
|
||
(delta[0] - high[0] as f64) as f32,
|
||
(delta[1] - high[1] as f64) as f32,
|
||
(delta[2] - high[2] as f64) as f32,
|
||
],
|
||
}
|
||
})
|
||
.collect();
|
||
let max_instances = chunk_bytes / std::mem::size_of::<SilhouetteInstance>();
|
||
let instance_buffers = instances
|
||
.chunks(max_instances.max(1))
|
||
.map(|instances| {
|
||
(
|
||
gpu_upload::upload_buffer(
|
||
device,
|
||
queue,
|
||
"mesh.silhouette.instances",
|
||
instances,
|
||
wgpu::BufferUsages::VERTEX,
|
||
),
|
||
instances.len() as u32,
|
||
)
|
||
})
|
||
.collect();
|
||
let generators = source
|
||
.instance_source
|
||
.as_ref()
|
||
.map_or(source.curved_gens.as_slice(), |instance| {
|
||
instance.curved_gens.as_slice()
|
||
});
|
||
let mut sources = Vec::with_capacity(generators.len());
|
||
for generator in generators {
|
||
if let Some(transform) = source.instance_transform {
|
||
let origin = transform.apply(codec::types::Vector3::ZERO);
|
||
let vectors = [
|
||
transform.apply_rotation(codec::types::Vector3::UNIT_X),
|
||
transform.apply_rotation(codec::types::Vector3::UNIT_Y),
|
||
transform.apply_rotation(codec::types::Vector3::UNIT_Z),
|
||
];
|
||
if let Some(transformed) = kernel::brep::mesh::transform_silhouette_affine(
|
||
&generator.source,
|
||
vectors.map(|vector| [vector.x, vector.y, vector.z]),
|
||
[origin.x, origin.y, origin.z],
|
||
) {
|
||
sources.push(transformed);
|
||
}
|
||
} else {
|
||
sources.push(generator.source.clone());
|
||
}
|
||
}
|
||
Some(SilhouetteSourceGroup {
|
||
color: source.display_color().unwrap_or([0.0, 0.0, 0.0, 1.0]),
|
||
sources,
|
||
instance_buffers,
|
||
})
|
||
};
|
||
self.silhouette_source_groups = groups.into_iter().filter_map(prepare_group).collect();
|
||
self.silhouette_source_key = source_key;
|
||
}
|
||
|
||
let source_count = self.silhouette_source_groups.len();
|
||
let compute_group = |group: &SilhouetteSourceGroup| {
|
||
let mut points = Vec::new();
|
||
for source in &group.sources {
|
||
points.extend(kernel::brep::mesh::silhouette(
|
||
source,
|
||
[view.x, view.y, view.z],
|
||
));
|
||
}
|
||
points
|
||
};
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let points: Vec<_> = {
|
||
use rayon::prelude::*;
|
||
self.silhouette_source_groups
|
||
.par_iter()
|
||
.map(compute_group)
|
||
.collect()
|
||
};
|
||
#[cfg(target_arch = "wasm32")]
|
||
let points: Vec<_> = self
|
||
.silhouette_source_groups
|
||
.iter()
|
||
.map(compute_group)
|
||
.collect();
|
||
let mut source_vertex_count = 0usize;
|
||
let mut chunks = Vec::new();
|
||
for (group, points) in self.silhouette_source_groups.iter().zip(points) {
|
||
let color = group.color;
|
||
let mk = |w: glam::DVec3| -> SilhouetteVertex {
|
||
let (hx, hy, hz) = (w.x as f32, w.y as f32, w.z as f32);
|
||
SilhouetteVertex {
|
||
position: [hx, hy, hz],
|
||
color,
|
||
position_low: [
|
||
(w.x - hx as f64) as f32,
|
||
(w.y - hy as f64) as f32,
|
||
(w.z - hz as f64) as f32,
|
||
],
|
||
}
|
||
};
|
||
let mut verts: Vec<SilhouetteVertex> = Vec::with_capacity(max_vertices);
|
||
let push_chunk = |verts: &[SilhouetteVertex], chunks: &mut Vec<SilhouetteChunk>| {
|
||
let vertex_count = verts.len() / 2 * 2;
|
||
if vertex_count == 0 {
|
||
return;
|
||
}
|
||
let vertex_buffer = gpu_upload::upload_buffer(
|
||
device,
|
||
queue,
|
||
"mesh.silhouette.vbuf",
|
||
&verts[..vertex_count],
|
||
wgpu::BufferUsages::VERTEX,
|
||
);
|
||
for (instance_buffer, instance_count) in &group.instance_buffers {
|
||
chunks.push(SilhouetteChunk {
|
||
vertex_buffer: vertex_buffer.clone(),
|
||
instance_buffer: instance_buffer.clone(),
|
||
vertex_count: vertex_count as u32,
|
||
instance_count: *instance_count,
|
||
});
|
||
}
|
||
};
|
||
source_vertex_count += points.len();
|
||
for point in points {
|
||
verts.push(mk(glam::DVec3::from_array(point)));
|
||
if verts.len() == max_vertices {
|
||
push_chunk(&verts, &mut chunks);
|
||
verts.clear();
|
||
}
|
||
}
|
||
push_chunk(&verts, &mut chunks);
|
||
}
|
||
self.silhouette_chunks = chunks;
|
||
if let Some(started) = perf_started {
|
||
crate::perf_record!(
|
||
"[perf] silhouettes {:>7.1}ms sets={} sources={} source_vertices={} chunks={}",
|
||
started.elapsed().as_secs_f64() * 1000.0,
|
||
sets.len(),
|
||
source_count,
|
||
source_vertex_count,
|
||
self.silhouette_chunks.len(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Upload this content viewport's non-rectangular clip boundary as a
|
||
/// triangle-fan vertex buffer in render-target NDC. The boundary is already
|
||
/// projected (paper shape → the same visible-sub-rect crop as the content),
|
||
/// so the mask pipeline stamps it straight into the stencil with `Invert`
|
||
/// (even-odd fill → interior marked, any convexity). Empty input clears the
|
||
/// boundary so the viewport renders unclipped (its render rectangle clips).
|
||
pub fn upload_clip_boundary(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
boundary_ndc: &[[f32; 2]],
|
||
) {
|
||
if boundary_ndc.len() < 3 {
|
||
self.clip_boundary = None;
|
||
return;
|
||
}
|
||
// Triangle fan (p0, pi, pi+1).
|
||
let mut verts: Vec<f32> = Vec::with_capacity((boundary_ndc.len() - 2) * 6);
|
||
for i in 1..boundary_ndc.len() - 1 {
|
||
for &p in &[boundary_ndc[0], boundary_ndc[i], boundary_ndc[i + 1]] {
|
||
verts.extend_from_slice(&[p[0], p[1]]);
|
||
}
|
||
}
|
||
if verts.is_empty() {
|
||
self.clip_boundary = None;
|
||
return;
|
||
}
|
||
let vbuf = gpu_upload::upload_buffer(
|
||
device,
|
||
queue,
|
||
"clip_boundary.vbuf",
|
||
&verts,
|
||
wgpu::BufferUsages::VERTEX,
|
||
);
|
||
self.clip_boundary = Some((vbuf, (verts.len() / 2) as u32));
|
||
}
|
||
|
||
/// Per-frame visibility refresh for the batched hatch path.
|
||
/// Combines Phase 3.3 sub-pixel LOD skip with Phase 2.3 frustum
|
||
/// cull and pushes the resulting 0/1 mask through to the GPU
|
||
/// `visibility_buffer`. Vertex shader maps 0 → out-of-NDC clip,
|
||
/// so the rasterizer culls the primitive before any fragment
|
||
/// runs.
|
||
pub fn compute_hatch_lod(
|
||
&mut self,
|
||
queue: &wgpu::Queue,
|
||
view_rot: glam::Mat4,
|
||
eye: glam::DVec3,
|
||
clip_w: u32,
|
||
clip_h: u32,
|
||
) {
|
||
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
|
||
let instance_count = self.hatch_gpu.update_visibility(queue, |aabb| {
|
||
!aabb_below_pixel(aabb, view_rot, eye, clip_w, clip_h, 2.0)
|
||
&& !aabb_offscreen(aabb, view_rot, eye, clip_w, clip_h)
|
||
});
|
||
if instance_count == 0 {
|
||
return;
|
||
}
|
||
if let Some(started) = perf_started {
|
||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||
if elapsed_ms >= 1.0 {
|
||
crate::perf_record!(
|
||
"[perf] hatch-lod {:>7.1}ms instances={}",
|
||
elapsed_ms,
|
||
instance_count,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Per-frame wipeout frustum-skip flag (Phase 2.3). Mirrors
|
||
/// `compute_hatch_lod`'s frustum branch. No sub-pixel skip:
|
||
/// wipeouts mask, so dropping a sub-pixel one wouldn't be wrong
|
||
/// but also wouldn't pay off — they're usually few.
|
||
pub fn compute_wipeout_lod(
|
||
&mut self,
|
||
view_rot: glam::Mat4,
|
||
eye: glam::DVec3,
|
||
clip_w: u32,
|
||
clip_h: u32,
|
||
) {
|
||
self.wipeout_skip_flags = self
|
||
.gpu_wipeouts
|
||
.iter()
|
||
.map(|h| aabb_offscreen(h.world_aabb, view_rot, eye, clip_w, clip_h))
|
||
.collect();
|
||
}
|
||
|
||
/// Upload all 3DFACE entities as two batched GPU objects:
|
||
/// - `gpu_face3d_fill`: filled triangles (1 buffer, 1 draw call)
|
||
/// - `gpu_face3d_edges`: merged edge wires (1 buffer, 1 draw call)
|
||
pub fn upload_face3d(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
face3d_wires: &[WireModel],
|
||
all_wires: &[WireModel],
|
||
wireframe_only: bool,
|
||
show_2d_solid_fills: bool,
|
||
view_dir: glam::Vec3,
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
) {
|
||
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
|
||
// Edge buffer is always built from `face3d_wires`, so 3DFACE
|
||
// outlines stay on the screen regardless of mode.
|
||
self.gpu_face3d_edges = WireGpu::from_batch(
|
||
device,
|
||
queue,
|
||
face3d_wires,
|
||
depth_map,
|
||
self.wire_const_bgl.as_ref(),
|
||
);
|
||
// Fill buffer split: 3D quads + PolyfaceMesh / PolygonMesh face
|
||
// tris go to `chunks_3d` (gated by `keep_3d_mesh_fills`);
|
||
// 2D fills (text-LOD greek, MultiLeader background, dimension arrows) go to
|
||
// `chunks_2d`. The 3-D wireframe additionally removes only legacy
|
||
// planar SOLID interiors; HATCH is handled by a separate pass.
|
||
let keep_3d_mesh_fills = !wireframe_only;
|
||
let solid_fill_hidden = |wire: &WireModel| !show_2d_solid_fills && wire.fill_is_2d_solid;
|
||
let has_any_2d_fill = all_wires
|
||
.iter()
|
||
.any(|w| !w.fill_tris.is_empty() && !w.fill_is_3d && !solid_fill_hidden(w));
|
||
let has_any_3d_fill = !face3d_wires.is_empty()
|
||
|| all_wires
|
||
.iter()
|
||
.any(|w| !w.fill_tris.is_empty() && w.fill_is_3d);
|
||
let has_fills = has_any_2d_fill || (keep_3d_mesh_fills && has_any_3d_fill);
|
||
if !has_fills {
|
||
self.gpu_face3d_fill = None;
|
||
} else {
|
||
self.gpu_face3d_fill = Some(Face3DGpu::from_wires(
|
||
device,
|
||
queue,
|
||
face3d_wires,
|
||
all_wires,
|
||
keep_3d_mesh_fills,
|
||
show_2d_solid_fills,
|
||
view_dir,
|
||
depth_map,
|
||
));
|
||
}
|
||
if let Some(started) = perf_started {
|
||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||
if elapsed_ms >= 1.0 {
|
||
crate::perf_record!(
|
||
"[perf] face3d-upload {:>7.1}ms face-wires={} all-wires={}",
|
||
elapsed_ms,
|
||
face3d_wires.len(),
|
||
all_wires.len(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
queue: &wgpu::Queue,
|
||
meshes: &[MeshLodSet],
|
||
) {
|
||
let started = iced::time::Instant::now();
|
||
let (mut chunks, triangles) = mesh_gpu::build_mesh_batch(device, queue, meshes);
|
||
let built_at = iced::time::Instant::now();
|
||
mesh_gpu::upload_chunk_material_bind_groups(
|
||
device,
|
||
queue,
|
||
&self.mesh_material_bgl,
|
||
&mut chunks,
|
||
);
|
||
let materials_at = iced::time::Instant::now();
|
||
self.mesh_ranges_by_handle.clear();
|
||
for (chunk_index, chunk) in chunks.iter().enumerate() {
|
||
for range in &chunk.highlight_ranges {
|
||
self.mesh_ranges_by_handle
|
||
.entry(range.handle)
|
||
.or_default()
|
||
.push(MeshResidentRange {
|
||
chunk: chunk_index,
|
||
dynamic: false,
|
||
index_start: range.index_start,
|
||
index_count: range.index_count,
|
||
transparent: range.transparent,
|
||
instance_start: range.instance_start,
|
||
instance_count: range.instance_count,
|
||
});
|
||
}
|
||
}
|
||
self.mesh_highlight_draws.clear();
|
||
self.cached_highlight_key = (u64::MAX, u64::MAX);
|
||
self.gpu_mesh_dynamic.clear();
|
||
self.mesh_disabled_chunks.clear();
|
||
self.mesh_dynamic_handles.clear();
|
||
self.gpu_mesh_batch = chunks;
|
||
let indexed_at = iced::time::Instant::now();
|
||
if crate::perf::enabled() {
|
||
let instances: u64 = self
|
||
.gpu_mesh_batch
|
||
.iter()
|
||
.map(|chunk| chunk.instance_count as u64)
|
||
.sum();
|
||
let compact_chunks = self
|
||
.gpu_mesh_batch
|
||
.iter()
|
||
.filter(|chunk| chunk.compact_vertices)
|
||
.count();
|
||
crate::perf_record!(
|
||
"[perf] mesh-batch {:>7.1}ms build={:.1} material={:.1} index={:.1} sets={} chunks={} compact={} instances={} triangles={}",
|
||
started.elapsed().as_secs_f64() * 1000.0,
|
||
built_at.duration_since(started).as_secs_f64() * 1000.0,
|
||
materials_at.duration_since(built_at).as_secs_f64() * 1000.0,
|
||
indexed_at.duration_since(materials_at).as_secs_f64() * 1000.0,
|
||
meshes.len(),
|
||
self.gpu_mesh_batch.len(),
|
||
compact_chunks,
|
||
instances,
|
||
triangles,
|
||
);
|
||
}
|
||
}
|
||
|
||
fn rebuild_mesh_range_map(&mut self) {
|
||
self.mesh_ranges_by_handle.clear();
|
||
for (chunk_index, chunk) in self.gpu_mesh_batch.iter().enumerate() {
|
||
if self.mesh_disabled_chunks.contains(&chunk_index) {
|
||
continue;
|
||
}
|
||
for range in &chunk.highlight_ranges {
|
||
self.mesh_ranges_by_handle
|
||
.entry(range.handle)
|
||
.or_default()
|
||
.push(MeshResidentRange {
|
||
chunk: chunk_index,
|
||
dynamic: false,
|
||
index_start: range.index_start,
|
||
index_count: range.index_count,
|
||
transparent: range.transparent,
|
||
instance_start: range.instance_start,
|
||
instance_count: range.instance_count,
|
||
});
|
||
}
|
||
}
|
||
for (chunk_index, chunk) in self.gpu_mesh_dynamic.iter().enumerate() {
|
||
for range in &chunk.highlight_ranges {
|
||
self.mesh_ranges_by_handle
|
||
.entry(range.handle)
|
||
.or_default()
|
||
.push(MeshResidentRange {
|
||
chunk: chunk_index,
|
||
dynamic: true,
|
||
index_start: range.index_start,
|
||
index_count: range.index_count,
|
||
transparent: range.transparent,
|
||
instance_start: range.instance_start,
|
||
instance_count: range.instance_count,
|
||
});
|
||
}
|
||
}
|
||
self.mesh_highlight_draws.clear();
|
||
self.cached_highlight_key = (u64::MAX, u64::MAX);
|
||
}
|
||
|
||
/// Patch an entity-only geometry change without rebuilding the resident
|
||
/// mesh set. Any 32 MiB static chunk containing a changed handle is retired;
|
||
/// the current versions of all its handles are rebuilt into a small dynamic
|
||
/// working set. A bounded threshold falls back to a full rebuild before the
|
||
/// working set can grow into a second copy of the drawing.
|
||
pub fn patch_mesh_batch(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
meshes: &[MeshLodSet],
|
||
changes: &[(codec::Handle, crate::scene::ChangeKind)],
|
||
) -> bool {
|
||
let started = iced::time::Instant::now();
|
||
if self.gpu_mesh_batch.is_empty() || changes.is_empty() {
|
||
return false;
|
||
}
|
||
let current_handles: rustc_hash::FxHashSet<_> = meshes
|
||
.iter()
|
||
.filter_map(MeshLodSet::entity_handle)
|
||
.collect();
|
||
let changed: rustc_hash::FxHashSet<_> = changes
|
||
.iter()
|
||
.map(|(handle, _)| *handle)
|
||
.filter(|handle| {
|
||
current_handles.contains(handle)
|
||
|| self.mesh_dynamic_handles.contains(handle)
|
||
|| self
|
||
.gpu_mesh_batch
|
||
.iter()
|
||
.any(|chunk| chunk.handles.contains(handle))
|
||
})
|
||
.collect();
|
||
if changed.is_empty() {
|
||
return true;
|
||
}
|
||
for (chunk_index, chunk) in self.gpu_mesh_batch.iter().enumerate() {
|
||
if chunk.handles.iter().any(|handle| changed.contains(handle)) {
|
||
self.mesh_disabled_chunks.insert(chunk_index);
|
||
self.mesh_dynamic_handles
|
||
.extend(chunk.handles.iter().copied());
|
||
}
|
||
}
|
||
self.mesh_dynamic_handles.extend(changed.iter().copied());
|
||
let disabled_limit = self.gpu_mesh_batch.len().div_ceil(2).max(8);
|
||
if self.mesh_disabled_chunks.len() > disabled_limit
|
||
|| self.mesh_dynamic_handles.len() > 4096
|
||
{
|
||
return false;
|
||
}
|
||
let (mut chunks, _) = mesh_gpu::build_mesh_batch_filtered(
|
||
device,
|
||
queue,
|
||
meshes,
|
||
Some(&self.mesh_dynamic_handles),
|
||
);
|
||
mesh_gpu::upload_chunk_material_bind_groups(
|
||
device,
|
||
queue,
|
||
&self.mesh_material_bgl,
|
||
&mut chunks,
|
||
);
|
||
self.gpu_mesh_dynamic = chunks;
|
||
self.rebuild_mesh_range_map();
|
||
if crate::perf::enabled() {
|
||
crate::perf_record!(
|
||
"[perf] mesh-patch {:>7.1}ms changed={} retired={} dynamic_handles={} dynamic_chunks={}",
|
||
started.elapsed().as_secs_f64() * 1000.0,
|
||
changed.len(),
|
||
self.mesh_disabled_chunks.len(),
|
||
self.mesh_dynamic_handles.len(),
|
||
self.gpu_mesh_dynamic.len(),
|
||
);
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Refresh selection/hover draw ranges. The overlay references resident
|
||
/// chunk buffers; changing hover never allocates or uploads mesh geometry.
|
||
pub fn update_mesh_highlight(
|
||
&mut self,
|
||
selected: &rustc_hash::FxHashSet<codec::Handle>,
|
||
hovered: &rustc_hash::FxHashSet<codec::Handle>,
|
||
edge_wires: &[WireModel],
|
||
) {
|
||
let edge_handles: rustc_hash::FxHashSet<codec::Handle> = edge_wires
|
||
.iter()
|
||
.filter_map(|wire| wire.name.strip_prefix("mesh-edge:"))
|
||
.filter_map(|value| value.parse::<u64>().ok())
|
||
.map(codec::Handle::new)
|
||
.collect();
|
||
let mut out = Vec::new();
|
||
for handle in selected
|
||
.iter()
|
||
.filter(|handle| !edge_handles.contains(handle))
|
||
{
|
||
if let Some(ranges) = self.mesh_ranges_by_handle.get(handle) {
|
||
out.extend(ranges.iter().copied().map(|range| MeshHighlightDraw {
|
||
range,
|
||
kind: MeshHighlightKind::Selected,
|
||
}));
|
||
}
|
||
}
|
||
for handle in hovered
|
||
.iter()
|
||
.filter(|handle| !selected.contains(handle) && !edge_handles.contains(handle))
|
||
{
|
||
if let Some(ranges) = self.mesh_ranges_by_handle.get(&handle) {
|
||
out.extend(ranges.iter().copied().map(|range| MeshHighlightDraw {
|
||
range,
|
||
kind: MeshHighlightKind::Hover,
|
||
}));
|
||
}
|
||
}
|
||
self.mesh_highlight_draws = out;
|
||
}
|
||
|
||
fn active_mesh_chunks_indexed(
|
||
&self,
|
||
) -> impl Iterator<Item = (usize, &mesh_gpu::MeshBatchChunk)> {
|
||
let dynamic_offset = self.gpu_mesh_batch.len();
|
||
self.gpu_mesh_batch
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(index, _)| !self.mesh_disabled_chunks.contains(index))
|
||
.chain(
|
||
self.gpu_mesh_dynamic
|
||
.iter()
|
||
.enumerate()
|
||
.map(move |(index, chunk)| (dynamic_offset + index, chunk)),
|
||
)
|
||
}
|
||
|
||
pub fn upload_hatches(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
hatches: &[HatchModel],
|
||
) {
|
||
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
|
||
let renderable_count = hatches
|
||
.iter()
|
||
.filter(|hatch| hatch.boundary.len() >= 3)
|
||
.count();
|
||
self.hatch_gpu.upload(device, queue, hatches);
|
||
if let Some(started) = perf_started {
|
||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||
if elapsed_ms >= 1.0 {
|
||
crate::perf_record!(
|
||
"[perf] hatch-upload {:>7.1}ms models={}",
|
||
elapsed_ms,
|
||
renderable_count,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn upload_preview_hatches(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
hatches: &[HatchModel],
|
||
) {
|
||
self.hatch_gpu.upload_preview(device, queue, hatches);
|
||
}
|
||
|
||
pub fn upload_wipeouts(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
wipeouts: &[HatchModel],
|
||
) {
|
||
let renderable: Vec<HatchModel> = wipeouts
|
||
.iter()
|
||
.filter(|h| h.boundary.len() >= 3)
|
||
.cloned()
|
||
.collect();
|
||
self.gpu_wipeouts = WipeoutGpu::from_models(device, queue, &renderable, &self.wipeout_bgl1);
|
||
}
|
||
|
||
pub fn upload_images(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
images: &[ImageModel],
|
||
) {
|
||
self.gpu_images = ImageGpu::from_models(device, queue, images, &self.image_bgl1);
|
||
}
|
||
|
||
pub fn upload_point_clouds(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
set: &crate::scene::model::point_cloud::PointCloudSet,
|
||
) {
|
||
self.gpu_point_clouds.upload(device, queue, set);
|
||
}
|
||
|
||
pub fn set_point_cloud_view(&mut self, queue: &wgpu::Queue, light: [f32; 12]) {
|
||
self.gpu_point_clouds.set_view(queue, light);
|
||
}
|
||
|
||
/// Upload the frame's SDF text-quad vertices, and (re)build the GPU glyph
|
||
/// atlas from the shared CPU atlas when it grew (new glyphs baked by the
|
||
/// text collector). `verts` empty (flag off) leaves nothing to draw.
|
||
pub fn upload_text(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
verts: &[text_gpu::TextVertex],
|
||
wires: &[WireModel],
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
) {
|
||
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
|
||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||
if self.text_atlas_gpu.is_none() || atlas.is_dirty() {
|
||
self.text_atlas_gpu = Some(text_gpu::TextAtlasGpu::upload(
|
||
device,
|
||
queue,
|
||
&atlas,
|
||
&self.text_atlas_bgl,
|
||
));
|
||
atlas.clear_dirty();
|
||
}
|
||
}
|
||
self.text_gpu = text_gpu::upload_vertices(device, queue, verts);
|
||
self.block_text_gpu = text_gpu::upload_block_vertices(device, queue, wires, depth_map);
|
||
if let Some(started) = perf_started {
|
||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||
if elapsed_ms >= 1.0 {
|
||
crate::perf_record!(
|
||
"[perf] text-upload {:>7.1}ms vertices={}",
|
||
elapsed_ms,
|
||
verts.len(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whatever is bound at the frame group's shadow slot: the real depth
|
||
/// target when this viewport casts shadows, the 1x1 placeholder otherwise.
|
||
fn shadow_view(&self) -> &wgpu::TextureView {
|
||
match &self.shadow_full {
|
||
Some((_, view)) => view,
|
||
None => &self.shadow_fallback_view,
|
||
}
|
||
}
|
||
|
||
/// Rebuild the frame bind group from whatever this slot currently holds.
|
||
///
|
||
/// Three things can change what belongs in it — a background image, an
|
||
/// environment image, and whether the shadow target exists — and it used to
|
||
/// be assembled inline at each. One builder means a shadow transition
|
||
/// cannot forget the background the slot already had.
|
||
fn rebuild_frame_bind_group(&mut self, device: &wgpu::Device) {
|
||
let background_view = self
|
||
.background_texture
|
||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||
let environment_view = self
|
||
.environment_texture
|
||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||
self.uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||
label: Some("viewer.bind_group"),
|
||
layout: &self.frame_bgl,
|
||
entries: &[
|
||
wgpu::BindGroupEntry {
|
||
binding: 0,
|
||
resource: self.uniform_buffer.as_entire_binding(),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 1,
|
||
resource: wgpu::BindingResource::TextureView(&background_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 2,
|
||
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 3,
|
||
resource: wgpu::BindingResource::TextureView(&environment_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 4,
|
||
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 5,
|
||
resource: wgpu::BindingResource::TextureView(self.shadow_view()),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 6,
|
||
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
/// Allocate or drop the shadow target to match `shadow_enabled`.
|
||
///
|
||
/// Only on a transition: the frame bind group has to be rebuilt with the
|
||
/// new view, and a viewport that keeps its shadow setting should not pay
|
||
/// for that every frame.
|
||
fn sync_shadow_target(&mut self, device: &wgpu::Device) {
|
||
match (self.shadow_enabled, self.shadow_full.is_some()) {
|
||
(true, false) => {
|
||
let errors_before = gpu_errors_seen();
|
||
let texture = create_shadow_texture(device, SHADOW_MAP_SIZE);
|
||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||
if gpu_errors_seen() == errors_before {
|
||
self.shadow_full = Some((texture, view));
|
||
}
|
||
}
|
||
(false, true) => self.shadow_full = None,
|
||
_ => return,
|
||
}
|
||
self.rebuild_frame_bind_group(device);
|
||
}
|
||
|
||
pub fn upload_uniforms(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
uniforms: &Uniforms,
|
||
) {
|
||
self.shadow_enabled = uniforms.shadow_params[0] > 0.5;
|
||
self.sync_shadow_target(device);
|
||
let mut uniforms = *uniforms;
|
||
if self.shadow_full.is_none() {
|
||
uniforms.shadow_params[0] = 0.0;
|
||
}
|
||
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms));
|
||
}
|
||
|
||
pub fn upload_background_images(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
background: Option<&crate::scene::model::image_model::DecodedImage>,
|
||
environment: Option<&crate::scene::model::image_model::DecodedImage>,
|
||
) {
|
||
let source_id = |image: Option<&crate::scene::model::image_model::DecodedImage>| {
|
||
image
|
||
.map(|image| std::sync::Arc::as_ptr(&image.pixels) as usize)
|
||
.unwrap_or(0)
|
||
};
|
||
let background_id = source_id(background);
|
||
let environment_id = source_id(environment);
|
||
if background_id == self.background_source_id
|
||
&& environment_id == self.environment_source_id
|
||
{
|
||
return;
|
||
}
|
||
let upload = |label: &'static str,
|
||
image: Option<&crate::scene::model::image_model::DecodedImage>,
|
||
fallback: [u8; 4]| {
|
||
let (width, height, pixels) = image
|
||
.map(|image| (image.width, image.height, image.pixels.as_slice()))
|
||
.unwrap_or((1, 1, fallback.as_slice()));
|
||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some(label),
|
||
size: wgpu::Extent3d {
|
||
width: width.max(1),
|
||
height: height.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: 1,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||
view_formats: &[],
|
||
});
|
||
queue.write_texture(
|
||
texture.as_image_copy(),
|
||
pixels,
|
||
wgpu::TexelCopyBufferLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(4 * width.max(1)),
|
||
rows_per_image: Some(height.max(1)),
|
||
},
|
||
wgpu::Extent3d {
|
||
width: width.max(1),
|
||
height: height.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
texture
|
||
};
|
||
self.background_texture = upload("background.texture", background, [0, 0, 0, 255]);
|
||
self.environment_texture = upload("environment.texture", environment, [128, 128, 128, 255]);
|
||
self.rebuild_frame_bind_group(device);
|
||
self.background_source_id = background_id;
|
||
self.environment_source_id = environment_id;
|
||
}
|
||
|
||
/// Write the blit shader's UV crop uniform. Call in `prepare` (the only
|
||
/// place with a `&Queue`) — `render` then just submits the draw call.
|
||
pub fn upload_blit_uv(&self, queue: &wgpu::Queue, uv_offset: [f32; 2], uv_scale: [f32; 2]) {
|
||
// The geometry passes render at `depth_texture_size` into the top-left
|
||
// corner of the (possibly larger) `alloc_size` resolve texture, so the
|
||
// rendered image occupies UV [0, render/alloc]. Scale the incoming crop
|
||
// — computed in full-image-normalized units — down into that region.
|
||
let fx = self.depth_texture_size.width as f32 / self.alloc_size.width.max(1) as f32;
|
||
let fy = self.depth_texture_size.height as f32 / self.alloc_size.height.max(1) as f32;
|
||
queue.write_buffer(
|
||
&self.blit_uniform_buffer,
|
||
0,
|
||
bytemuck::cast_slice(&[
|
||
uv_offset[0] * fx,
|
||
uv_offset[1] * fy,
|
||
uv_scale[0] * fx,
|
||
uv_scale[1] * fy,
|
||
]),
|
||
);
|
||
}
|
||
|
||
/// Render geometry at its fractional pixel rectangle and blit the resolve
|
||
/// to `surface_dest` on the swap-chain. The UV crop is read from the
|
||
/// blit uniform buffer (written by `upload_blit_uv` during `prepare`)
|
||
/// so a viewport that hangs off the canvas still composites the correct
|
||
/// sub-rectangle to the visible portion of the surface.
|
||
pub fn render(
|
||
&self,
|
||
encoder: &mut wgpu::CommandEncoder,
|
||
target: &wgpu::TextureView,
|
||
raster: Rectangle,
|
||
surface_dest: Rectangle<u32>,
|
||
surface_clip: Rectangle<u32>,
|
||
bg_color: [f32; 4],
|
||
mesh_wireframe: bool,
|
||
hidden_line: bool,
|
||
show_3d_edges: bool,
|
||
) {
|
||
let msaa = &self.msaa_view;
|
||
let [r, g, b, a] = bg_color;
|
||
let clear_color = if self.clip_boundary.is_some() || self.skip_background {
|
||
wgpu::Color::TRANSPARENT
|
||
} else {
|
||
wgpu::Color {
|
||
r: r as f64,
|
||
g: g as f64,
|
||
b: b as f64,
|
||
a: a as f64,
|
||
}
|
||
};
|
||
// Non-rectangular viewport clip: the boundary is stamped into the
|
||
// just-cleared (0x00) stencil with `Invert`, so an odd (interior)
|
||
// coverage becomes 0xFF. Every content pass then draws with reference
|
||
// 0xFF so only the interior survives. Rectangular / unclipped viewports
|
||
// leave the stencil at 0 and draw with reference 0 (the viewport's own
|
||
// render rectangle does the clipping).
|
||
let stencil_ref: u32 = if self.clip_boundary.is_some() {
|
||
0xFF
|
||
} else {
|
||
0
|
||
};
|
||
|
||
// Render shadows only when a full target was allocated.
|
||
if let Some(shadow_target) = self
|
||
.shadow_full
|
||
.as_ref()
|
||
.map(|(_, view)| view)
|
||
.filter(|_| self.shadow_enabled && !self.skip_geometry && !mesh_wireframe)
|
||
{
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("shadow.render_pass"),
|
||
color_attachments: &[],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: shadow_target,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Clear(1.0),
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: None,
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_bind_group(0, &self.shadow_uniform_bind_group, &[]);
|
||
for (_, chunk) in self.active_mesh_chunks_indexed() {
|
||
pass.set_pipeline(if chunk.compact_vertices {
|
||
&self.shadow_plain_pipeline
|
||
} else {
|
||
&self.shadow_pipeline
|
||
});
|
||
pass.set_bind_group(
|
||
1,
|
||
chunk
|
||
.material_bind_group
|
||
.as_ref()
|
||
.unwrap_or(&self.mesh_default_material_bind_group),
|
||
&[],
|
||
);
|
||
pass.set_vertex_buffer(0, chunk.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, chunk.instance_buffer.slice(..));
|
||
if chunk.index_count != 0 {
|
||
pass.set_index_buffer(chunk.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||
pass.draw_indexed(0..chunk.index_count, 0, 0..chunk.instance_count);
|
||
}
|
||
if chunk.transp_index_count != 0 {
|
||
pass.set_index_buffer(
|
||
chunk.transp_index_buffer.slice(..),
|
||
wgpu::IndexFormat::Uint32,
|
||
);
|
||
pass.draw_indexed(0..chunk.transp_index_count, 0, 0..chunk.instance_count);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Scene-render cache: when `prepare` found this frame pixel-identical
|
||
// to the last (unchanged view / geometry / selection / preview), the
|
||
// resolve texture already holds the image. Skip every geometry pass +
|
||
// the MSAA resolve and fall straight through to the blit below. This
|
||
// is what makes a pure cursor move cost one fullscreen blit instead of
|
||
// re-rasterizing the whole drawing every frame.
|
||
if !self.skip_geometry {
|
||
// ── Pass 1: hatch fills ────────────────────────────────────────────
|
||
{
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("hatch.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
// Clear MSAA to background color on the first pass.
|
||
load: wgpu::LoadOp::Clear(clear_color),
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Clear(1.0),
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
// Clip stencil starts at 0 (= "unclipped", passes content
|
||
// bound to reference 0); clip masks stamp 1 into interiors.
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Clear(0),
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
// Stamp the viewport clip boundary into the just-cleared stencil
|
||
// (interior → 1) before any content draws, so every pass below can
|
||
// clip to the shape with reference 1.
|
||
if let Some((vbuf, vcount)) = &self.clip_boundary {
|
||
pass.set_pipeline(&self.clip_mask_pipeline);
|
||
pass.set_vertex_buffer(0, vbuf.slice(..));
|
||
pass.draw(0..*vcount, 0..1);
|
||
}
|
||
// The background shader returns an opaque colour whatever alpha it
|
||
// is handed, so a see-through viewport cannot be asked for as a
|
||
// transparent background — the pass has to not run.
|
||
if !self.skip_background {
|
||
pass.set_pipeline(&self.background_pipeline);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
pass.draw(0..3, 0..1);
|
||
}
|
||
// The capability-selected façade dispatches storage or texture
|
||
// draws before wires so outlines remain on top in either backend.
|
||
// Skipped while navigating because per-pixel hatch work dominates
|
||
// hatch-heavy drawings.
|
||
if !self.skip_hatch_frame {
|
||
self.hatch_gpu
|
||
.draw(&mut pass, &self.uniform_bind_group, stencil_ref);
|
||
}
|
||
}
|
||
|
||
// ── Pass 2: raster images ─────────────────────────────────────────
|
||
if !self.gpu_images.is_empty() {
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("image.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_pipeline(&self.image_pipeline);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
for img in self.gpu_images.iter() {
|
||
pass.set_bind_group(1, &img.bind_group, &[]);
|
||
pass.set_vertex_buffer(0, img.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, img.instance_buffer.slice(..));
|
||
pass.draw(0..img.vertex_count, 0..img.instance_count);
|
||
}
|
||
}
|
||
|
||
// ── Pass 3: point clouds ──────────────────────────────────────────
|
||
if !self.gpu_point_clouds.is_empty() {
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("point_cloud.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_pipeline(&self.point_cloud_pipeline);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
self.gpu_point_clouds.draw(&mut pass);
|
||
}
|
||
|
||
// ── Pass 4: solid meshes (batched) ────────────────────────────────
|
||
if !self.gpu_mesh_batch.is_empty() || !self.gpu_mesh_dynamic.is_empty() {
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("mesh.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
// Four draw paths share this pass:
|
||
// - Solid: `mesh_pipeline` + triangle index buf.
|
||
// - Wireframe: `mesh_wireframe_pipeline` + the
|
||
// pre-built expanded wire vertex buffer.
|
||
// - HiddenLine: depth prepass (`mesh_depth_pipeline`,
|
||
// writes Z, no colour) → wire overlay.
|
||
// - Solid+Edges: `mesh_pipeline` shaded fill → wire
|
||
// overlay; LessEqual depth test on the
|
||
// 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.
|
||
if hidden_line {
|
||
// Depth-only prepass: every solid surface occludes hidden edges,
|
||
// so both the opaque and the transparent tris write depth here.
|
||
for (_, c) in self.active_mesh_chunks_indexed() {
|
||
pass.set_pipeline(if c.compact_vertices {
|
||
&self.mesh_plain_depth_pipeline
|
||
} else {
|
||
&self.mesh_depth_pipeline
|
||
});
|
||
pass.set_bind_group(
|
||
1,
|
||
c.material_bind_group
|
||
.as_ref()
|
||
.unwrap_or(&self.mesh_default_material_bind_group),
|
||
&[],
|
||
);
|
||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, c.instance_buffer.slice(..));
|
||
if c.index_count != 0 {
|
||
pass.set_index_buffer(
|
||
c.index_buffer.slice(..),
|
||
wgpu::IndexFormat::Uint32,
|
||
);
|
||
pass.draw_indexed(0..c.index_count, 0, 0..c.instance_count);
|
||
}
|
||
if c.transp_index_count != 0 {
|
||
pass.set_index_buffer(
|
||
c.transp_index_buffer.slice(..),
|
||
wgpu::IndexFormat::Uint32,
|
||
);
|
||
pass.draw_indexed(0..c.transp_index_count, 0, 0..c.instance_count);
|
||
}
|
||
}
|
||
pass.set_pipeline(&self.mesh_wireframe_pipeline);
|
||
pass.set_bind_group(1, &self.mesh_default_material_bind_group, &[]);
|
||
for (_, c) in self.active_mesh_chunks_indexed() {
|
||
pass.set_bind_group(
|
||
1,
|
||
c.material_bind_group
|
||
.as_ref()
|
||
.unwrap_or(&self.mesh_default_material_bind_group),
|
||
&[],
|
||
);
|
||
pass.set_vertex_buffer(1, c.instance_buffer.slice(..));
|
||
// Plain-mesh triangulation edges.
|
||
if c.wire_vertex_count != 0 {
|
||
pass.set_vertex_buffer(0, c.wire_vertex_buffer.slice(..));
|
||
pass.draw(0..c.wire_vertex_count, 0..c.instance_count);
|
||
}
|
||
// ACIS solid B-rep feature edges (LineList, non-indexed).
|
||
if c.edge_vertex_count != 0 {
|
||
pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..));
|
||
pass.draw(0..c.edge_vertex_count, 0..c.instance_count);
|
||
}
|
||
}
|
||
pass.set_pipeline(&self.silhouette_black_pipeline);
|
||
for chunk in &self.silhouette_chunks {
|
||
pass.set_vertex_buffer(0, chunk.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, chunk.instance_buffer.slice(..));
|
||
pass.draw(0..chunk.vertex_count, 0..chunk.instance_count);
|
||
}
|
||
} else {
|
||
if mesh_wireframe {
|
||
pass.set_pipeline(&self.mesh_wireframe_pipeline);
|
||
pass.set_bind_group(1, &self.mesh_default_material_bind_group, &[]);
|
||
for (_, c) in self.active_mesh_chunks_indexed() {
|
||
pass.set_bind_group(
|
||
1,
|
||
c.material_bind_group
|
||
.as_ref()
|
||
.unwrap_or(&self.mesh_default_material_bind_group),
|
||
&[],
|
||
);
|
||
pass.set_vertex_buffer(1, c.instance_buffer.slice(..));
|
||
if c.wire_vertex_count != 0 {
|
||
pass.set_vertex_buffer(0, c.wire_vertex_buffer.slice(..));
|
||
pass.draw(0..c.wire_vertex_count, 0..c.instance_count);
|
||
}
|
||
if c.edge_vertex_count != 0 {
|
||
pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..));
|
||
pass.draw(0..c.edge_vertex_count, 0..c.instance_count);
|
||
}
|
||
}
|
||
pass.set_pipeline(&self.silhouette_pipeline);
|
||
for chunk in &self.silhouette_chunks {
|
||
pass.set_vertex_buffer(0, chunk.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, chunk.instance_buffer.slice(..));
|
||
pass.draw(0..chunk.vertex_count, 0..chunk.instance_count);
|
||
}
|
||
} else {
|
||
// Opaque fills first (they write depth).
|
||
for (_, c) in self.active_mesh_chunks_indexed() {
|
||
pass.set_pipeline(if c.compact_vertices {
|
||
&self.mesh_plain_pipeline
|
||
} else {
|
||
&self.mesh_pipeline
|
||
});
|
||
pass.set_bind_group(
|
||
1,
|
||
c.material_bind_group
|
||
.as_ref()
|
||
.unwrap_or(&self.mesh_default_material_bind_group),
|
||
&[],
|
||
);
|
||
if c.index_count == 0 {
|
||
continue;
|
||
}
|
||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, c.instance_buffer.slice(..));
|
||
pass.set_index_buffer(
|
||
c.index_buffer.slice(..),
|
||
wgpu::IndexFormat::Uint32,
|
||
);
|
||
pass.draw_indexed(0..c.index_count, 0, 0..c.instance_count);
|
||
}
|
||
// Transparent fills last, with depth writes disabled, so they
|
||
// blend over the opaque geometry behind them instead of
|
||
// culling it via the depth buffer.
|
||
for (_, c) in self.active_mesh_chunks_indexed() {
|
||
pass.set_pipeline(if c.compact_vertices {
|
||
&self.mesh_plain_transparent_pipeline
|
||
} else {
|
||
&self.mesh_transparent_pipeline
|
||
});
|
||
pass.set_bind_group(
|
||
1,
|
||
c.material_bind_group
|
||
.as_ref()
|
||
.unwrap_or(&self.mesh_default_material_bind_group),
|
||
&[],
|
||
);
|
||
if c.transp_index_count == 0 {
|
||
continue;
|
||
}
|
||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, c.instance_buffer.slice(..));
|
||
pass.set_index_buffer(
|
||
c.transp_index_buffer.slice(..),
|
||
wgpu::IndexFormat::Uint32,
|
||
);
|
||
pass.draw_indexed(0..c.transp_index_count, 0, 0..c.instance_count);
|
||
}
|
||
}
|
||
// Selection / hover highlight reuses index ranges already
|
||
// resident in the chunk buffers; hover never uploads geometry.
|
||
for kind in [MeshHighlightKind::Selected, MeshHighlightKind::Hover] {
|
||
// Hidden-line views keep the depth-only faces colourless;
|
||
// visible selected edges are tinted in the pass below.
|
||
if hidden_line {
|
||
continue;
|
||
}
|
||
if !self
|
||
.mesh_highlight_draws
|
||
.iter()
|
||
.any(|draw| draw.kind == kind)
|
||
{
|
||
continue;
|
||
}
|
||
for draw in self
|
||
.mesh_highlight_draws
|
||
.iter()
|
||
.filter(|draw| draw.kind == kind)
|
||
{
|
||
let chunk = if draw.range.dynamic {
|
||
self.gpu_mesh_dynamic.get(draw.range.chunk)
|
||
} else {
|
||
self.gpu_mesh_batch.get(draw.range.chunk)
|
||
};
|
||
let Some(chunk) = chunk else {
|
||
continue;
|
||
};
|
||
if draw.range.index_count == 0 {
|
||
continue;
|
||
}
|
||
pass.set_pipeline(match (kind, chunk.compact_vertices) {
|
||
(MeshHighlightKind::Selected, false) => {
|
||
&self.mesh_selected_pipeline
|
||
}
|
||
(MeshHighlightKind::Selected, true) => {
|
||
&self.mesh_plain_selected_pipeline
|
||
}
|
||
(MeshHighlightKind::Hover, false) => &self.mesh_hover_pipeline,
|
||
(MeshHighlightKind::Hover, true) => &self.mesh_plain_hover_pipeline,
|
||
});
|
||
pass.set_bind_group(
|
||
1,
|
||
chunk
|
||
.material_bind_group
|
||
.as_ref()
|
||
.unwrap_or(&self.mesh_default_material_bind_group),
|
||
&[],
|
||
);
|
||
pass.set_vertex_buffer(0, chunk.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, chunk.instance_buffer.slice(..));
|
||
if draw.range.transparent {
|
||
pass.set_index_buffer(
|
||
chunk.transp_index_buffer.slice(..),
|
||
wgpu::IndexFormat::Uint32,
|
||
);
|
||
} else {
|
||
pass.set_index_buffer(
|
||
chunk.index_buffer.slice(..),
|
||
wgpu::IndexFormat::Uint32,
|
||
);
|
||
}
|
||
pass.draw_indexed(
|
||
draw.range.index_start
|
||
..draw.range.index_start + draw.range.index_count,
|
||
0,
|
||
draw.range.instance_start
|
||
..draw.range.instance_start + draw.range.instance_count,
|
||
);
|
||
}
|
||
}
|
||
// *WithEdges variants: overlay edge segments on top of the shaded
|
||
// fill in black (mesh_edge_black_pipeline). The LessEqual depth
|
||
// test keeps the edges visible over the fragments the fill wrote.
|
||
if want_solid_with_edges {
|
||
pass.set_pipeline(&self.mesh_edge_black_pipeline);
|
||
for (_, c) in self.active_mesh_chunks_indexed() {
|
||
pass.set_bind_group(
|
||
1,
|
||
c.material_bind_group
|
||
.as_ref()
|
||
.unwrap_or(&self.mesh_default_material_bind_group),
|
||
&[],
|
||
);
|
||
pass.set_vertex_buffer(1, c.instance_buffer.slice(..));
|
||
if c.wire_vertex_count != 0 {
|
||
pass.set_vertex_buffer(0, c.wire_vertex_buffer.slice(..));
|
||
pass.draw(0..c.wire_vertex_count, 0..c.instance_count);
|
||
}
|
||
if c.edge_vertex_count != 0 {
|
||
pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..));
|
||
pass.draw(0..c.edge_vertex_count, 0..c.instance_count);
|
||
}
|
||
}
|
||
pass.set_pipeline(&self.silhouette_black_pipeline);
|
||
for chunk in &self.silhouette_chunks {
|
||
pass.set_vertex_buffer(0, chunk.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, chunk.instance_buffer.slice(..));
|
||
pass.draw(0..chunk.vertex_count, 0..chunk.instance_count);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Pass 5a: 3DFACE fills (3D + 2D split) ─────────────────────────
|
||
// 3D quads + PolyfaceMesh face tris go through the depth-only
|
||
// pipeline in HiddenLine so wires hidden behind them disappear.
|
||
// 2D fills (text greek, MultiLeader bg) always draw with colour.
|
||
if let Some(ref fill) = self.gpu_face3d_fill {
|
||
if !fill.chunks_3d.is_empty()
|
||
|| !fill.chunks_2d.is_empty()
|
||
|| !fill.block_chunks_3d.is_empty()
|
||
|| !fill.block_chunks_2d.is_empty()
|
||
{
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("face3d.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
if !fill.chunks_3d.is_empty() {
|
||
if hidden_line {
|
||
pass.set_pipeline(&self.face3d_depth_pipeline);
|
||
} else {
|
||
pass.set_pipeline(&self.face3d_pipeline);
|
||
}
|
||
for c in &fill.chunks_3d {
|
||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||
pass.draw(0..c.vertex_count, 0..1);
|
||
}
|
||
}
|
||
if !fill.block_chunks_3d.is_empty() {
|
||
if hidden_line {
|
||
pass.set_pipeline(&self.block_face3d_depth_pipeline);
|
||
} else {
|
||
pass.set_pipeline(&self.block_face3d_pipeline);
|
||
}
|
||
for c in &fill.block_chunks_3d {
|
||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, c.instance_buffer.slice(..));
|
||
pass.draw(0..c.vertex_count, 0..c.instance_count);
|
||
}
|
||
}
|
||
if !fill.chunks_2d.is_empty() {
|
||
pass.set_pipeline(&self.face3d_pipeline);
|
||
for c in &fill.chunks_2d {
|
||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||
pass.draw(0..c.vertex_count, 0..1);
|
||
}
|
||
}
|
||
if !fill.block_chunks_2d.is_empty() {
|
||
pass.set_pipeline(&self.block_face3d_pipeline);
|
||
for c in &fill.block_chunks_2d {
|
||
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, c.instance_buffer.slice(..));
|
||
pass.draw(0..c.vertex_count, 0..c.instance_count);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Pass 5b: 3DFACE edges (batched, possibly multiple chunks) ────
|
||
// FlatShaded / GouraudShaded hide the 3DFACE outline (the user
|
||
// chose a clean shaded look); every other mode keeps it.
|
||
if show_3d_edges && !self.gpu_face3d_edges.is_empty() {
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("face3d_edges.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_pipeline(&self.wire_pipeline);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
for edges in &self.gpu_face3d_edges {
|
||
if edges.instance_count > 0 {
|
||
if let Some(bg) = &edges.const_bind_group {
|
||
pass.set_bind_group(1, bg.as_ref(), &[]);
|
||
}
|
||
pass.set_vertex_buffer(0, edges.instance_buffer.slice(..));
|
||
pass.draw(
|
||
0..6,
|
||
edges.first_instance..edges.first_instance + edges.instance_count,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Pass 5: wires ─────────────────────────────────────────────────
|
||
{
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("wire.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_pipeline(&self.wire_pipeline);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
// In a filled-with-edges mode the mesh outline edges frame the shaded
|
||
// fill and should read black; wireframe / hidden-line keep the entity
|
||
// colour. `wire_black_pipeline` shares the wire layout, so the switch
|
||
// needs no bind-group rebind.
|
||
let want_solid_with_edges = !hidden_line && !mesh_wireframe && show_3d_edges;
|
||
let mut black_active = false;
|
||
pass.set_stencil_reference(stencil_ref);
|
||
for wire in self.gpu_wires.iter() {
|
||
if wire.instance_count == 0 {
|
||
continue;
|
||
}
|
||
// PolyfaceMesh / PolygonMesh outline edges live in
|
||
// `gpu_wires` (their `WireModel` has both `points` and
|
||
// `fill_tris`). In FlatShaded / GouraudShaded the user
|
||
// wants a clean shaded surface, so the wire pass skips
|
||
// these instances; the *WithEdges and pure wireframe
|
||
// modes leave the flag at true and draw them.
|
||
if !show_3d_edges && wire.is_3d_mesh_edge {
|
||
continue;
|
||
}
|
||
let use_black = want_solid_with_edges && wire.is_3d_mesh_edge;
|
||
|
||
if use_black != black_active {
|
||
pass.set_pipeline(if use_black {
|
||
&self.wire_black_pipeline
|
||
} else {
|
||
&self.wire_pipeline
|
||
});
|
||
black_active = use_black;
|
||
}
|
||
if let Some(bg) = &wire.const_bind_group {
|
||
pass.set_bind_group(1, bg.as_ref(), &[]);
|
||
}
|
||
pass.set_vertex_buffer(0, wire.instance_buffer.slice(..));
|
||
pass.draw(
|
||
0..6,
|
||
wire.first_instance..wire.first_instance + wire.instance_count,
|
||
);
|
||
}
|
||
let mut block_black_active = false;
|
||
pass.set_pipeline(&self.block_wire_pipeline);
|
||
for wire in self.gpu_block_wires.iter() {
|
||
if wire.instance_count == 0 || (!show_3d_edges && wire.is_3d_mesh_edge) {
|
||
continue;
|
||
}
|
||
let use_black = want_solid_with_edges && wire.is_3d_mesh_edge;
|
||
if use_black != block_black_active {
|
||
pass.set_pipeline(if use_black {
|
||
&self.block_wire_black_pipeline
|
||
} else {
|
||
&self.block_wire_pipeline
|
||
});
|
||
block_black_active = use_black;
|
||
}
|
||
bind_and_draw_block_wire(&mut pass, wire);
|
||
}
|
||
// Analytical GPU circles (instanced screen-space quads)
|
||
if self.gpu_circles.iter().any(|cg| cg.instance_count > 0) {
|
||
pass.set_pipeline(&self.circle_pipeline);
|
||
for cg in self.gpu_circles.iter() {
|
||
if cg.instance_count > 0 {
|
||
pass.set_vertex_buffer(0, cg.instance_buffer.slice(..));
|
||
pass.draw(0..6, 0..cg.instance_count);
|
||
}
|
||
}
|
||
}
|
||
// Analytical GPU ellipses (instanced screen-space quads)
|
||
if self.gpu_ellipses.iter().any(|eg| eg.instance_count > 0) {
|
||
pass.set_pipeline(&self.ellipse_pipeline);
|
||
for eg in self.gpu_ellipses.iter() {
|
||
if eg.instance_count > 0 {
|
||
pass.set_vertex_buffer(0, eg.instance_buffer.slice(..));
|
||
pass.draw(0..6, 0..eg.instance_count);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Pass 5c: SDF text quads (drawn over wires) ────────────────────
|
||
// Selection / rollover text is drawn later with the selected-wire xray
|
||
// overlay, after wipeouts, so normal text cannot hide its own tint.
|
||
if let Some(atlas) = &self.text_atlas_gpu {
|
||
let have_base = !self.text_gpu.is_empty();
|
||
let have_blocks = !self.block_text_gpu.is_empty();
|
||
if have_base || have_blocks {
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("text.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_pipeline(&self.text_pipeline);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
pass.set_bind_group(1, &atlas.bind_group, &[]);
|
||
for text in &self.text_gpu {
|
||
pass.set_vertex_buffer(0, text.vertex_buffer.slice(..));
|
||
pass.draw(0..text.vertex_count, 0..1);
|
||
}
|
||
if have_blocks {
|
||
pass.set_pipeline(&self.block_text_pipeline);
|
||
for text in &self.block_text_gpu {
|
||
pass.set_vertex_buffer(0, text.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, text.instance_buffer.slice(..));
|
||
pass.draw(0..text.vertex_count, 0..text.instance_count);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Pass 6: wipeout fills (drawn after wires to mask them) ────────
|
||
if !self.gpu_wipeouts.is_empty() {
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("wipeout.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_pipeline(&self.wipeout_pipeline);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
for (i, wipeout) in self.gpu_wipeouts.iter().enumerate() {
|
||
if self.wipeout_skip_flags.get(i).copied().unwrap_or(false) {
|
||
continue;
|
||
}
|
||
pass.set_bind_group(1, &wipeout.bind_group, &[]);
|
||
pass.set_vertex_buffer(0, wipeout.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, wipeout.instance_buffer.slice(..));
|
||
pass.draw(0..6, 0..wipeout.instance_count);
|
||
}
|
||
}
|
||
|
||
// ── Pass 6b: live command/grip preview overlay ────────────────────
|
||
// Wipeouts intentionally mask resident drawing geometry, but they must
|
||
// never mask temporary construction geometry belonging to an active
|
||
// command or grip edit. Draw those previews after the wipeout pass.
|
||
//
|
||
// The xray variants keep previews independent of resident depth while
|
||
// the shared content stencil still respects viewport/XCLIP clipping.
|
||
let have_preview_overlay = self
|
||
.gpu_preview_wires
|
||
.iter()
|
||
.any(|wire| wire.instance_count > 0)
|
||
|| self
|
||
.gpu_preview_circles
|
||
.iter()
|
||
.any(|circle| circle.instance_count > 0)
|
||
|| self
|
||
.gpu_preview_ellipses
|
||
.iter()
|
||
.any(|ellipse| ellipse.instance_count > 0)
|
||
|| !self.text_preview_gpu.is_empty();
|
||
|
||
if have_preview_overlay {
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("preview_overlay.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
|
||
if self
|
||
.gpu_preview_wires
|
||
.iter()
|
||
.any(|wire| wire.instance_count > 0)
|
||
{
|
||
pass.set_pipeline(&self.wire_xray_pipeline);
|
||
for wire in &self.gpu_preview_wires {
|
||
if wire.instance_count == 0 {
|
||
continue;
|
||
}
|
||
if let Some(bg) = &wire.const_bind_group {
|
||
pass.set_bind_group(1, bg.as_ref(), &[]);
|
||
}
|
||
pass.set_vertex_buffer(0, wire.instance_buffer.slice(..));
|
||
pass.draw(
|
||
0..6,
|
||
wire.first_instance..wire.first_instance + wire.instance_count,
|
||
);
|
||
}
|
||
}
|
||
|
||
if self
|
||
.gpu_preview_circles
|
||
.iter()
|
||
.any(|circle| circle.instance_count > 0)
|
||
{
|
||
pass.set_pipeline(&self.circle_xray_pipeline);
|
||
for circle in &self.gpu_preview_circles {
|
||
if circle.instance_count > 0 {
|
||
pass.set_vertex_buffer(0, circle.instance_buffer.slice(..));
|
||
pass.draw(0..6, 0..circle.instance_count);
|
||
}
|
||
}
|
||
}
|
||
|
||
if self
|
||
.gpu_preview_ellipses
|
||
.iter()
|
||
.any(|ellipse| ellipse.instance_count > 0)
|
||
{
|
||
pass.set_pipeline(&self.ellipse_xray_pipeline);
|
||
for ellipse in &self.gpu_preview_ellipses {
|
||
if ellipse.instance_count > 0 {
|
||
pass.set_vertex_buffer(0, ellipse.instance_buffer.slice(..));
|
||
pass.draw(0..6, 0..ellipse.instance_count);
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(atlas) = &self.text_atlas_gpu {
|
||
if !self.text_preview_gpu.is_empty() {
|
||
// Same glyph shader as ordinary text, but depth-independent:
|
||
// previews are temporary UI geometry, not resident drawing.
|
||
pass.set_pipeline(&self.text_highlight_pipeline);
|
||
pass.set_bind_group(1, &atlas.bind_group, &[]);
|
||
for text in &self.text_preview_gpu {
|
||
pass.set_vertex_buffer(0, text.vertex_buffer.slice(..));
|
||
pass.draw(0..text.vertex_count, 0..1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Pass 7: selection overlay pass ───────────────────────────────
|
||
// Redraws selected wires and text with depth_compare=Always so both
|
||
// appear on top of all other geometry at full brightness — unless
|
||
// faces are drawn: then edges behind a surface stay hidden while
|
||
// selected, as they are when not (#1031).
|
||
let occluded = hidden_line || !mesh_wireframe;
|
||
let have_text_highlight =
|
||
(!self.text_highlight_gpu.is_empty()) || !self.block_text_highlight_gpu.is_empty();
|
||
if !self.gpu_selected_wires.is_empty()
|
||
|| !self.gpu_selected_block_wires.is_empty()
|
||
|| !self.gpu_selected_circles.is_empty()
|
||
|| !self.gpu_selected_ellipses.is_empty()
|
||
|| have_text_highlight
|
||
{
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("selection_xray.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: &self.depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
pass.set_stencil_reference(stencil_ref);
|
||
if !self.gpu_selected_wires.is_empty() {
|
||
pass.set_pipeline(if occluded {
|
||
&self.wire_pipeline
|
||
} else {
|
||
&self.wire_xray_pipeline
|
||
});
|
||
for wire in &self.gpu_selected_wires {
|
||
if wire.instance_count > 0 {
|
||
if let Some(bg) = &wire.const_bind_group {
|
||
pass.set_bind_group(1, bg.as_ref(), &[]);
|
||
}
|
||
pass.set_vertex_buffer(0, wire.instance_buffer.slice(..));
|
||
pass.draw(
|
||
0..6,
|
||
wire.first_instance..wire.first_instance + wire.instance_count,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if !self.gpu_selected_block_wires.is_empty() {
|
||
pass.set_pipeline(if occluded {
|
||
&self.block_wire_pipeline
|
||
} else {
|
||
&self.block_wire_xray_pipeline
|
||
});
|
||
for wire in &self.gpu_selected_block_wires {
|
||
if wire.instance_count == 0 {
|
||
continue;
|
||
}
|
||
bind_and_draw_block_wire(&mut pass, wire);
|
||
}
|
||
}
|
||
if !self.gpu_selected_circles.is_empty() {
|
||
pass.set_pipeline(if occluded {
|
||
&self.circle_pipeline
|
||
} else {
|
||
&self.circle_xray_pipeline
|
||
});
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
for cg in &self.gpu_selected_circles {
|
||
if cg.instance_count > 0 {
|
||
pass.set_vertex_buffer(0, cg.instance_buffer.slice(..));
|
||
pass.draw(0..6, 0..cg.instance_count);
|
||
}
|
||
}
|
||
}
|
||
if !self.gpu_selected_ellipses.is_empty() {
|
||
pass.set_pipeline(if occluded {
|
||
&self.ellipse_pipeline
|
||
} else {
|
||
&self.ellipse_xray_pipeline
|
||
});
|
||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||
for eg in &self.gpu_selected_ellipses {
|
||
if eg.instance_count > 0 {
|
||
pass.set_vertex_buffer(0, eg.instance_buffer.slice(..));
|
||
pass.draw(0..6, 0..eg.instance_count);
|
||
}
|
||
}
|
||
}
|
||
if let Some(atlas) = &self.text_atlas_gpu {
|
||
if !self.text_highlight_gpu.is_empty() {
|
||
pass.set_pipeline(&self.text_highlight_pipeline);
|
||
pass.set_bind_group(1, &atlas.bind_group, &[]);
|
||
for text in &self.text_highlight_gpu {
|
||
pass.set_vertex_buffer(0, text.vertex_buffer.slice(..));
|
||
pass.draw(0..text.vertex_count, 0..1);
|
||
}
|
||
}
|
||
}
|
||
if let Some(atlas) = &self.text_atlas_gpu {
|
||
if !self.block_text_highlight_gpu.is_empty() {
|
||
pass.set_pipeline(&self.block_text_highlight_pipeline);
|
||
pass.set_bind_group(1, &atlas.bind_group, &[]);
|
||
for text in &self.block_text_highlight_gpu {
|
||
pass.set_vertex_buffer(0, text.vertex_buffer.slice(..));
|
||
pass.set_vertex_buffer(1, text.instance_buffer.slice(..));
|
||
pass.draw(0..text.vertex_count, 0..text.instance_count);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Resolve MSAA → resolve texture ────────────────────────────────
|
||
// Both are the same (rounded `alloc_size`) offscreen texture, so the
|
||
// resolve never touches the surface. Only the drawn [0, render_size]
|
||
// corner holds content; the rounded border stays the cleared bg color
|
||
// and is never sampled (the blit UV is scaled to render/alloc).
|
||
{
|
||
let _resolve = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("msaa.resolve_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: msaa,
|
||
depth_slice: None,
|
||
resolve_target: Some(&self.resolve_view),
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Discard,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: None,
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
// No draw calls — the pass itself triggers the MSAA resolve.
|
||
}
|
||
} // end `if !self.skip_geometry`
|
||
|
||
// ── Blit resolve texture → surface target at surface_dest position ──
|
||
// The viewport maps the NDC quad to exactly `surface_dest` in the
|
||
// swap-chain; `uv_offset` + `uv_scale` (passed through the blit
|
||
// uniform) crop the resolve so we sample only the visible portion
|
||
// of the full viewport's MSAA texture.
|
||
if surface_dest.width > 0 && surface_dest.height > 0 {
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("blit.render_pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: target,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: None,
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
pass.set_viewport(
|
||
surface_dest.x as f32,
|
||
surface_dest.y as f32,
|
||
surface_dest.width as f32,
|
||
surface_dest.height as f32,
|
||
0.0,
|
||
1.0,
|
||
);
|
||
pass.set_pipeline(&self.blit_pipeline);
|
||
pass.set_scissor_rect(
|
||
surface_clip.x,
|
||
surface_clip.y,
|
||
surface_clip.width,
|
||
surface_clip.height,
|
||
);
|
||
pass.set_bind_group(0, &self.blit_bind_group, &[]);
|
||
pass.draw(0..6, 0..1);
|
||
}
|
||
}
|
||
|
||
/// Reset every cache key that describes this slot's uploaded content.
|
||
pub(crate) fn forget_cached_keys(&mut self) {
|
||
self.cached_epoch = (u64::MAX, u64::MAX, u64::MAX);
|
||
self.cached_wire_id = u64::MAX;
|
||
self.cached_selection = (u64::MAX, u64::MAX);
|
||
self.cached_highlight_key = (u64::MAX, u64::MAX);
|
||
self.cached_mesh_content_id = u64::MAX;
|
||
self.cached_face3d_key = (u64::MAX, false, false, u64::MAX);
|
||
self.cached_solid_visibility = (u64::MAX, [u32::MAX; 3], u64::MAX);
|
||
self.cached_hatch_source = None;
|
||
self.cached_preview_hatch_source = None;
|
||
self.cached_wipeout_source = None;
|
||
self.cached_image_source = None;
|
||
self.cached_point_cloud_source = None;
|
||
self.cached_text_source = None;
|
||
self.cached_mesh_source = None;
|
||
self.cached_face3d_source = None;
|
||
self.cached_face3d_depth_source = None;
|
||
self.wire_cull_key = (u64::MAX, u64::MAX, 0, 0);
|
||
self.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
|
||
self.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
|
||
self.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false);
|
||
self.silhouette_source_key = (usize::MAX, usize::MAX, u64::MAX);
|
||
self.render_sig = u64::MAX;
|
||
// The retained-upload record is part of the slot's cached state.
|
||
self.partition_contributors.clear();
|
||
self.partition_depth_generation = u64::MAX;
|
||
}
|
||
|
||
/// Catch errors delivered after the previous upload/submit completed.
|
||
pub(crate) fn sync_gpu_error_epoch(&mut self) {
|
||
let errors = gpu_errors_seen();
|
||
if errors == self.gpu_error_epoch {
|
||
return;
|
||
}
|
||
self.gpu_error_epoch = errors;
|
||
self.forget_cached_keys();
|
||
self.wire_arena = None;
|
||
self.wire_arena_mesh = None;
|
||
self.wire_arena_id = u64::MAX;
|
||
self.partition_contributors.clear();
|
||
self.partition_depth_generation = u64::MAX;
|
||
self.alloc_size = Size::new(0, 0);
|
||
self.shadow_full = None;
|
||
self.background_source_id = usize::MAX;
|
||
self.environment_source_id = usize::MAX;
|
||
}
|
||
|
||
/// Give this slot's device memory back and forget what it held.
|
||
///
|
||
/// A slot nobody is drawing still owns its wire arena, its render targets,
|
||
/// its text atlas and every category buffer — tens of megabytes each. Held
|
||
/// while another viewport is asking for an allocation, that is the
|
||
/// difference between a degraded frame and a session that cannot recover.
|
||
///
|
||
/// Everything released here is derived state with a rebuild path guarded by
|
||
/// a cache key, and every one of those keys is reset, so the next frame that
|
||
/// needs this slot builds it again. The cost of releasing a slot that turns
|
||
/// out to be needed is one slow frame.
|
||
pub(crate) fn release_heavy_resources(&mut self, device: &wgpu::Device) {
|
||
self.forget_cached_keys();
|
||
|
||
self.gpu_wires = std::sync::Arc::new(Vec::new());
|
||
self.gpu_block_wires = std::sync::Arc::new(Vec::new());
|
||
self.gpu_circles = std::sync::Arc::new(Vec::new());
|
||
self.gpu_ellipses = std::sync::Arc::new(Vec::new());
|
||
self.wire_handle_index = std::sync::Arc::new(rustc_hash::FxHashMap::default());
|
||
self.wire_arena = None;
|
||
self.wire_arena_mesh = None;
|
||
self.wire_arena_fallback = std::sync::Arc::new(Vec::new());
|
||
self.wire_arena_fallback_kind = None;
|
||
self.wire_arena_fallback_handles.clear();
|
||
self.wire_arena_id = u64::MAX;
|
||
self.partition_contributors.clear();
|
||
self.partition_depth_generation = u64::MAX;
|
||
|
||
self.gpu_selected_wires.clear();
|
||
self.gpu_selected_block_wires.clear();
|
||
self.gpu_selected_circles.clear();
|
||
self.gpu_selected_ellipses.clear();
|
||
self.gpu_preview_wires.clear();
|
||
self.gpu_preview_circles.clear();
|
||
self.gpu_preview_ellipses.clear();
|
||
self.hatch_gpu.clear();
|
||
self.gpu_wipeouts.clear();
|
||
self.wipeout_skip_flags.clear();
|
||
self.gpu_images.clear();
|
||
self.gpu_mesh_batch.clear();
|
||
self.gpu_mesh_dynamic.clear();
|
||
self.mesh_disabled_chunks.clear();
|
||
self.mesh_dynamic_handles.clear();
|
||
self.mesh_ranges_by_handle.clear();
|
||
self.mesh_highlight_draws.clear();
|
||
self.silhouette_chunks.clear();
|
||
self.silhouette_source_groups.clear();
|
||
self.clip_boundary = None;
|
||
|
||
// A cold slot's shadow target goes back too. `shadow_enabled` is left
|
||
// alone, so `sync_shadow_target` reallocates on the next frame that
|
||
// still wants shadows.
|
||
if self.shadow_full.take().is_some() {
|
||
self.rebuild_frame_bind_group(device);
|
||
}
|
||
|
||
self.text_atlas_gpu = None;
|
||
self.text_gpu.clear();
|
||
self.block_text_gpu.clear();
|
||
self.block_text_highlight_gpu.clear();
|
||
self.text_highlight_gpu.clear();
|
||
self.text_preview_gpu.clear();
|
||
|
||
// Back to the smallest allocation the rounding allows (128x128, about
|
||
// 0.6 MiB against the ~70 MiB a full-canvas slot holds). Going through
|
||
// `ensure_depth_texture` rather than reaching for the textures directly
|
||
// keeps the blit bind group consistent with the views it samples.
|
||
self.ensure_depth_texture(device, Size::new(1, 1));
|
||
// ...and make the next real size a mismatch, so it reallocates.
|
||
self.alloc_size = Size::new(0, 0);
|
||
}
|
||
|
||
pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size<u32>) {
|
||
// Record the requested render size every frame (the blit UV scale reads
|
||
// it); only reallocate the textures when the *rounded* size changes.
|
||
self.depth_texture_size = size;
|
||
let alloc = Size::new(round_up_tex(size.width), round_up_tex(size.height));
|
||
if self.alloc_size != alloc {
|
||
let size = alloc;
|
||
let depth_tex = create_depth_texture(device, size);
|
||
self.depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||
let msaa_tex = create_msaa_texture(device, size, self.surface_format);
|
||
self.msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||
let resolve_tex = create_resolve_texture(device, size, self.surface_format);
|
||
let resolve_view = resolve_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||
self.blit_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||
label: Some("blit.bind_group"),
|
||
layout: &self.blit_bind_group_layout,
|
||
entries: &[
|
||
wgpu::BindGroupEntry {
|
||
binding: 0,
|
||
resource: wgpu::BindingResource::TextureView(&resolve_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 1,
|
||
resource: wgpu::BindingResource::Sampler(&self.blit_sampler),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 2,
|
||
resource: self.blit_uniform_buffer.as_entire_binding(),
|
||
},
|
||
],
|
||
});
|
||
self.resolve_view = resolve_view;
|
||
self.alloc_size = alloc;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Round a texture dimension up to a coarse grid so a live divider drag
|
||
/// doesn't recreate the depth / MSAA / resolve textures on every frame. See
|
||
/// `alloc_size` — this is the mitigation for the Windows-Firefox freeze (#191).
|
||
fn round_up_tex(n: u32) -> u32 {
|
||
const GRID: u32 = 128;
|
||
((n.max(1) + GRID - 1) / GRID) * GRID
|
||
}
|
||
|
||
/// `true` when the world-XY AABB projects entirely outside the
|
||
/// 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. `WipeoutGpu.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_rot: glam::Mat4,
|
||
eye: glam::DVec3,
|
||
clip_w: u32,
|
||
clip_h: u32,
|
||
) -> bool {
|
||
let [x0, y0, x1, y1] = aabb;
|
||
if !x0.is_finite() || !y0.is_finite() || !x1.is_finite() || !y1.is_finite() {
|
||
return false;
|
||
}
|
||
let w = clip_w as f32;
|
||
let h = clip_h as f32;
|
||
let corners = [
|
||
view_rot.project_point3((glam::DVec3::new(x0 as f64, y0 as f64, 0.0) - eye).as_vec3()),
|
||
view_rot.project_point3((glam::DVec3::new(x1 as f64, y0 as f64, 0.0) - eye).as_vec3()),
|
||
view_rot.project_point3((glam::DVec3::new(x0 as f64, y1 as f64, 0.0) - eye).as_vec3()),
|
||
view_rot.project_point3((glam::DVec3::new(x1 as f64, y1 as f64, 0.0) - eye).as_vec3()),
|
||
];
|
||
let mut min_px = f32::INFINITY;
|
||
let mut max_px = f32::NEG_INFINITY;
|
||
let mut min_py = f32::INFINITY;
|
||
let mut max_py = f32::NEG_INFINITY;
|
||
for c in &corners {
|
||
let px = (c.x + 1.0) * 0.5 * w;
|
||
let py = (1.0 - c.y) * 0.5 * h;
|
||
if px < min_px {
|
||
min_px = px;
|
||
}
|
||
if px > max_px {
|
||
max_px = px;
|
||
}
|
||
if py < min_py {
|
||
min_py = py;
|
||
}
|
||
if py > max_py {
|
||
max_py = py;
|
||
}
|
||
}
|
||
// 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
|
||
/// given pixel threshold. Used by LOD passes (hatch skip, etc.) to drop
|
||
/// draw calls that wouldn't contribute a visible pixel.
|
||
fn aabb_below_pixel(
|
||
aabb: [f32; 4],
|
||
view_rot: glam::Mat4,
|
||
eye: glam::DVec3,
|
||
clip_w: u32,
|
||
clip_h: u32,
|
||
threshold_px: f32,
|
||
) -> bool {
|
||
let [x0, y0, x1, y1] = aabb;
|
||
if !x0.is_finite() || !y0.is_finite() || !x1.is_finite() || !y1.is_finite() {
|
||
return false;
|
||
}
|
||
let w = clip_w as f32;
|
||
let h = clip_h as f32;
|
||
let corners = [
|
||
view_rot.project_point3((glam::DVec3::new(x0 as f64, y0 as f64, 0.0) - eye).as_vec3()),
|
||
view_rot.project_point3((glam::DVec3::new(x1 as f64, y0 as f64, 0.0) - eye).as_vec3()),
|
||
view_rot.project_point3((glam::DVec3::new(x0 as f64, y1 as f64, 0.0) - eye).as_vec3()),
|
||
view_rot.project_point3((glam::DVec3::new(x1 as f64, y1 as f64, 0.0) - eye).as_vec3()),
|
||
];
|
||
let mut min_px = f32::INFINITY;
|
||
let mut max_px = f32::NEG_INFINITY;
|
||
let mut min_py = f32::INFINITY;
|
||
let mut max_py = f32::NEG_INFINITY;
|
||
for c in &corners {
|
||
let px = (c.x + 1.0) * 0.5 * w;
|
||
let py = (1.0 - c.y) * 0.5 * h;
|
||
if px < min_px {
|
||
min_px = px;
|
||
}
|
||
if px > max_px {
|
||
max_px = px;
|
||
}
|
||
if py < min_py {
|
||
min_py = py;
|
||
}
|
||
if py > max_py {
|
||
max_py = py;
|
||
}
|
||
}
|
||
(max_px - min_px).max(max_py - min_py) < threshold_px
|
||
}
|
||
|
||
/// Device bytes a renderer is holding, counted from the sizes it allocated
|
||
/// rather than from process RSS — the allocator and the driver both keep
|
||
/// high-water memory that RSS cannot distinguish from live resources.
|
||
///
|
||
/// Partial by construction: it covers the categories large enough to decide
|
||
/// where the next fix goes, and says so rather than pretending to be a total.
|
||
#[derive(Default, Clone, Copy, PartialEq)]
|
||
pub(crate) struct GpuLiveBytes {
|
||
pub slots: usize,
|
||
pub shadow: u64,
|
||
pub render_targets: u64,
|
||
pub text_atlas: u64,
|
||
pub wire_arena: u64,
|
||
pub block_geometry: u64,
|
||
}
|
||
|
||
impl GpuLiveBytes {
|
||
pub(crate) fn total(&self) -> u64 {
|
||
self.shadow + self.render_targets + self.text_atlas + self.wire_arena + self.block_geometry
|
||
}
|
||
}
|
||
|
||
impl Pipeline {
|
||
/// This slot's share. `alloc_size` is the real allocation, which is rounded
|
||
/// up from the requested size and grows only.
|
||
fn gpu_live_bytes(&self) -> GpuLiveBytes {
|
||
// 4x MSAA colour (4 B/sample) + 4x MSAA depth-stencil (4 B/sample) +
|
||
// one single-sample resolve target.
|
||
const BYTES_PER_PIXEL: u64 = (MSAA_SAMPLES as u64) * 4 + (MSAA_SAMPLES as u64) * 4 + 4;
|
||
let pixels = u64::from(self.alloc_size.width) * u64::from(self.alloc_size.height);
|
||
GpuLiveBytes {
|
||
slots: 1,
|
||
// Only while this viewport casts shadows; the placeholder that
|
||
// stands in otherwise is 4 bytes.
|
||
shadow: match &self.shadow_full {
|
||
Some(_) => u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4,
|
||
None => 4,
|
||
},
|
||
render_targets: pixels * BYTES_PER_PIXEL,
|
||
text_atlas: self
|
||
.text_atlas_gpu
|
||
.as_ref()
|
||
.map(text_gpu::TextAtlasGpu::gpu_bytes)
|
||
.unwrap_or(0),
|
||
wire_arena: self
|
||
.wire_arena
|
||
.as_ref()
|
||
.map(wire_arena::PersistentWireArena::gpu_bytes)
|
||
.unwrap_or(0)
|
||
+ self
|
||
.wire_arena_mesh
|
||
.as_ref()
|
||
.map(wire_arena::PersistentWireArena::gpu_bytes)
|
||
.unwrap_or(0),
|
||
block_geometry: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl MultiPipeline {
|
||
/// Release cold slots after 64 frames, or two frames under memory pressure.
|
||
/// Current and previous frame slots remain reserved for sibling widgets.
|
||
pub(crate) fn release_idle_slots(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
reserved: &[usize],
|
||
urgent: bool,
|
||
) -> usize {
|
||
const IDLE_FRAMES: u64 = 64;
|
||
// Other shader widgets may prepare later in this frame. Keep every
|
||
// slot used in this or the previous frame, even under memory pressure.
|
||
let idle_frames = if urgent { 2 } else { IDLE_FRAMES };
|
||
let mut released = 0;
|
||
for index in 0..self.inners.len() {
|
||
if reserved.contains(&index) {
|
||
continue;
|
||
}
|
||
let idle = self.slot_clock.saturating_sub(self.slot_last_used[index]);
|
||
if idle < idle_frames {
|
||
continue;
|
||
}
|
||
// Nothing to give back — releasing again would only churn the
|
||
// render targets it just rebuilt at their smallest size.
|
||
let held = self.inners[index].gpu_live_bytes();
|
||
if held.wire_arena == 0
|
||
&& held.shadow <= 4
|
||
&& self.inners[index].alloc_size == Size::new(0, 0)
|
||
{
|
||
continue;
|
||
}
|
||
self.inners[index].release_heavy_resources(device);
|
||
released += 1;
|
||
}
|
||
if released > 0 {
|
||
self.wire_buffer_cache.retain(|_, entry| {
|
||
std::sync::Arc::strong_count(&entry.0) > 1
|
||
|| std::sync::Arc::strong_count(&entry.1) > 1
|
||
|| std::sync::Arc::strong_count(&entry.3) > 1
|
||
});
|
||
// Block records are host memory a released slot no longer needs;
|
||
// the next build re-emits what it still draws.
|
||
self.block_geometry.clear();
|
||
}
|
||
released
|
||
}
|
||
|
||
/// Sum across slots, plus the caches held once for all of them.
|
||
pub(crate) fn gpu_live_bytes(&self) -> GpuLiveBytes {
|
||
let mut total = GpuLiveBytes::default();
|
||
// Block arenas belong to the batches, and a cached build hands the same
|
||
// arenas to several slots, so each one is counted once.
|
||
let mut counted = rustc_hash::FxHashSet::default();
|
||
for inner in &self.inners {
|
||
let slot = inner.gpu_live_bytes();
|
||
total.slots += slot.slots;
|
||
total.shadow += slot.shadow;
|
||
total.render_targets += slot.render_targets;
|
||
total.text_atlas += slot.text_atlas;
|
||
total.wire_arena += slot.wire_arena;
|
||
total.block_geometry +=
|
||
wire_gpu::block_arena_bytes(&inner.gpu_block_wires, &mut counted)
|
||
+ wire_gpu::block_arena_bytes(
|
||
&inner.gpu_selected_block_wires,
|
||
&mut counted,
|
||
);
|
||
}
|
||
for entry in self.wire_buffer_cache.values() {
|
||
total.block_geometry += wire_gpu::block_arena_bytes(&entry.1, &mut counted);
|
||
}
|
||
total
|
||
}
|
||
}
|
||
|
||
/// Bind one block-wire batch and draw it.
|
||
///
|
||
/// The two pipeline modes lay the vertex slots out differently — packed puts
|
||
/// the six-per-segment geometry in slot 0 and the placements in slot 1, while
|
||
/// storage has no geometry vertex buffer at all and the placements become slot
|
||
/// 0. Both draw loops go through here so they cannot drift apart.
|
||
/// Many definitions share one arena, so the batch addresses its own slice of
|
||
/// it: a dynamic offset for its constants, `first_vertex` for its segments and
|
||
/// `first_instance` for its placements.
|
||
fn bind_and_draw_block_wire(pass: &mut wgpu::RenderPass<'_>, wire: &wire_gpu::BlockWireGpu) {
|
||
let arena = wire.arena.as_ref();
|
||
pass.set_bind_group(1, arena.bind_group.as_ref(), &[wire.const_offset]);
|
||
match &arena.vertex_buffer {
|
||
Some(vertices) => {
|
||
pass.set_vertex_buffer(0, vertices.slice(..));
|
||
pass.set_vertex_buffer(1, arena.instance_buffer.slice(..));
|
||
}
|
||
None => pass.set_vertex_buffer(0, arena.instance_buffer.slice(..)),
|
||
}
|
||
pass.draw(
|
||
wire.first_vertex..wire.first_vertex + wire.vertex_count,
|
||
wire.first_instance..wire.first_instance + wire.instance_count,
|
||
);
|
||
}
|
||
|
||
/// A square `Depth32Float` render target for the shadow pass. `side` is 1 for
|
||
/// the placeholder that keeps the frame bind group satisfied while shadows are
|
||
/// off, and `SHADOW_MAP_SIZE` for the real thing.
|
||
fn create_shadow_texture(device: &wgpu::Device, side: u32) -> wgpu::Texture {
|
||
device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("shadow.texture"),
|
||
size: wgpu::Extent3d {
|
||
width: side,
|
||
height: side,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: 1,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format: wgpu::TextureFormat::Depth32Float,
|
||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||
view_formats: &[],
|
||
})
|
||
}
|
||
|
||
fn create_depth_texture(device: &wgpu::Device, size: Size<u32>) -> wgpu::Texture {
|
||
device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("viewer.depth_texture"),
|
||
size: wgpu::Extent3d {
|
||
width: size.width.max(1),
|
||
height: size.height.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: MSAA_SAMPLES,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||
view_formats: &[],
|
||
})
|
||
}
|
||
|
||
fn create_resolve_texture(
|
||
device: &wgpu::Device,
|
||
size: Size<u32>,
|
||
format: wgpu::TextureFormat,
|
||
) -> wgpu::Texture {
|
||
device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("viewer.resolve_texture"),
|
||
size: wgpu::Extent3d {
|
||
width: size.width.max(1),
|
||
height: size.height.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: 1,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format,
|
||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||
view_formats: &[],
|
||
})
|
||
}
|
||
|
||
fn create_msaa_texture(
|
||
device: &wgpu::Device,
|
||
size: Size<u32>,
|
||
format: wgpu::TextureFormat,
|
||
) -> wgpu::Texture {
|
||
device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("viewer.msaa_texture"),
|
||
size: wgpu::Extent3d {
|
||
width: size.width.max(1),
|
||
height: size.height.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: MSAA_SAMPLES,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format,
|
||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||
view_formats: &[],
|
||
})
|
||
}
|
||
|
||
/// Holds one inner `Pipeline` per viewport drawn this frame. A single
|
||
/// shader widget owns one `MultiPipeline`; the unified renderer grows the
|
||
/// `inners` vector to match the viewport count and draws each into its own
|
||
/// screen rectangle. Inner `Pipeline` code (upload / LOD / render / blit)
|
||
/// is unchanged — it just runs once per viewport.
|
||
pub struct MultiPipeline {
|
||
pub(crate) inners: Vec<Pipeline>,
|
||
format: wgpu::TextureFormat,
|
||
/// Stable viewport identity → pipeline slot map. Paper viewports used to
|
||
/// occupy slots by their current list position, so switching layouts or
|
||
/// scrolling a sheet could assign an existing viewport to a different
|
||
/// slot and throw away all of its GPU caches. Keep the association across
|
||
/// tab switches and only recycle genuinely cold slots.
|
||
pub(crate) slot_by_instance: rustc_hash::FxHashMap<u64, usize>,
|
||
slot_last_used: Vec<u64>,
|
||
slot_clock: u64,
|
||
pub(crate) frame_rendered: std::sync::atomic::AtomicBool,
|
||
pub(crate) gpu_error_epoch: usize,
|
||
/// Definition geometry shared across viewport slots.
|
||
pub(crate) block_geometry: wire_gpu::BlockGeometryCache,
|
||
/// Shared resident batches, keyed by wire content identity.
|
||
pub(crate) wire_buffer_cache: rustc_hash::FxHashMap<
|
||
u64,
|
||
(
|
||
std::sync::Arc<Vec<WireGpu>>,
|
||
std::sync::Arc<Vec<BlockWireGpu>>,
|
||
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
|
||
std::sync::Arc<Vec<CircleGpu>>,
|
||
std::sync::Arc<Vec<EllipseGpu>>,
|
||
),
|
||
>,
|
||
}
|
||
|
||
impl MultiPipeline {
|
||
/// Grow slot storage without disturbing sibling shader widgets.
|
||
pub(crate) fn ensure_len(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, n: usize) {
|
||
let n = n.max(1);
|
||
while self.inners.len() < n {
|
||
self.inners.push(Pipeline::new(device, queue, self.format));
|
||
self.slot_last_used.push(0);
|
||
}
|
||
}
|
||
|
||
/// Preserve viewport slots across frames; recycle only cold slots above the limit.
|
||
pub(crate) fn resolve_slots(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
instance_ids: &[u64],
|
||
) -> Vec<usize> {
|
||
const SOFT_LIMIT: usize = 32;
|
||
const HOT_WINDOW: u64 = 8;
|
||
|
||
if self
|
||
.frame_rendered
|
||
.swap(false, std::sync::atomic::Ordering::Relaxed)
|
||
|| self.slot_clock == 0
|
||
{
|
||
self.slot_clock = self.slot_clock.wrapping_add(1).max(1);
|
||
}
|
||
let now = self.slot_clock;
|
||
let reserved: rustc_hash::FxHashSet<u64> = instance_ids.iter().copied().collect();
|
||
// `inner.slot_id` is updated later in `prepare`, after this whole
|
||
// method returns. Without a per-call claim set, two new viewports in
|
||
// the same primitive both see the freshly-grown slot as `MAX` and get
|
||
// assigned to it. Paper then blits the final occupant's resolve texture
|
||
// once as the full sheet and again as the floating viewport.
|
||
let mut claimed: rustc_hash::FxHashSet<usize> = rustc_hash::FxHashSet::default();
|
||
let mut slots = Vec::with_capacity(instance_ids.len());
|
||
|
||
for &instance_id in instance_ids {
|
||
let existing = self
|
||
.slot_by_instance
|
||
.get(&instance_id)
|
||
.copied()
|
||
.filter(|slot| !claimed.contains(slot));
|
||
let slot = if let Some(slot) = existing {
|
||
slot
|
||
} else {
|
||
self.slot_by_instance.remove(&instance_id);
|
||
let vacant = self
|
||
.inners
|
||
.iter()
|
||
.enumerate()
|
||
.find(|(slot, inner)| !claimed.contains(slot) && inner.slot_id == u64::MAX)
|
||
.map(|(slot, _)| slot);
|
||
let recyclable = vacant.or_else(|| {
|
||
(self.inners.len() >= SOFT_LIMIT)
|
||
.then(|| {
|
||
self.inners
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(slot, _)| !claimed.contains(slot))
|
||
.filter(|(_, inner)| !reserved.contains(&inner.slot_id))
|
||
.filter(|(slot, _)| {
|
||
now.saturating_sub(self.slot_last_used[*slot]) > HOT_WINDOW
|
||
})
|
||
.min_by_key(|(slot, _)| self.slot_last_used[*slot])
|
||
.map(|(slot, _)| slot)
|
||
})
|
||
.flatten()
|
||
});
|
||
let slot = recyclable.unwrap_or_else(|| {
|
||
let slot = self.inners.len();
|
||
self.ensure_len(device, queue, slot + 1);
|
||
slot
|
||
});
|
||
let old_id = self.inners[slot].slot_id;
|
||
if old_id != u64::MAX {
|
||
self.slot_by_instance.remove(&old_id);
|
||
}
|
||
self.slot_by_instance.insert(instance_id, slot);
|
||
slot
|
||
};
|
||
claimed.insert(slot);
|
||
self.slot_last_used[slot] = now;
|
||
slots.push(slot);
|
||
}
|
||
slots
|
||
}
|
||
}
|
||
|
||
/// Send wgpu's uncaptured validation errors to stderr instead of the default
|
||
/// handler, which panics — and inside iced's main-thread redraw that panic
|
||
/// aborts the process, taking the user's unsaved work with it (#358). A bad
|
||
/// frame must degrade, not end the session.
|
||
///
|
||
/// The handler slot is per-device and single: this also catches iced's own draw
|
||
/// errors, and nothing else can report them, so it must stay loud enough to be
|
||
/// noticed. A validation error normally repeats on every frame, though, so a
|
||
/// plain print would flood stderr at refresh rate and bury the first (most
|
||
/// useful) message. Log the 1st, 2nd, 4th, 8th … occurrence instead: the error
|
||
/// is never hidden, the count shows it is ongoing, and the output stays finite.
|
||
///
|
||
/// Installed from `MultiPipeline::new`, which runs once per device — NOT from
|
||
/// `Pipeline::new`, which runs again for every pane `ensure_len` adds. If iced
|
||
/// ever rebuilds the device it builds a new `MultiPipeline` too, so the fresh
|
||
/// device is covered (and the throttle restarts with it).
|
||
static GPU_ERRORS_SEEN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||
|
||
/// How many uncaptured device errors have been reported since this device was
|
||
/// created.
|
||
///
|
||
/// Callers snapshot it around an allocation to find out whether what they just
|
||
/// built is usable. Best effort: wgpu surfaces some errors asynchronously, so a
|
||
/// move means "something failed", while no move is not proof that nothing did.
|
||
/// That is the right asymmetry here — the cost of believing a bad buffer is a
|
||
/// frozen viewport, the cost of rebuilding a good one is a frame.
|
||
pub(crate) fn gpu_errors_seen() -> usize {
|
||
GPU_ERRORS_SEEN.load(std::sync::atomic::Ordering::Relaxed)
|
||
}
|
||
|
||
fn install_gpu_error_handler(device: &wgpu::Device) {
|
||
use std::sync::atomic::Ordering;
|
||
GPU_ERRORS_SEEN.store(0, Ordering::Relaxed);
|
||
device.on_uncaptured_error(std::sync::Arc::new(|e: wgpu::Error| {
|
||
let n = GPU_ERRORS_SEEN.fetch_add(1, Ordering::Relaxed) + 1;
|
||
if n.is_power_of_two() {
|
||
eprintln!("[gpu] uncaptured wgpu error #{n} (frame degraded, session kept alive): {e}");
|
||
}
|
||
}));
|
||
}
|
||
|
||
/// The adapter wgpu ended up drawing on.
|
||
///
|
||
/// iced chooses it and names it only under `RUST_LOG`, so a fallback to a
|
||
/// software rasterizer used to be invisible: an NVIDIA userspace update
|
||
/// applied without a reboot left the Vulkan ICD unable to talk to the loaded
|
||
/// kernel module, wgpu quietly took Mesa's `llvmpipe`, and a day of pan/zoom
|
||
/// on the CPU read as a regression in the drawing code. `record_gpu_adapter`
|
||
/// now reports one `[gpu] adapter:` line per device and keeps a copy for the
|
||
/// app, which turns it into a [`GpuStatus`] the user can see.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct GpuAdapter {
|
||
pub name: String,
|
||
pub backend: wgpu::Backend,
|
||
pub device_type: wgpu::DeviceType,
|
||
}
|
||
|
||
impl GpuAdapter {
|
||
/// A software rasterizer: every frame is drawn by the CPU, which on a
|
||
/// large drawing is the difference between interactive and unusable.
|
||
///
|
||
/// `DeviceType::Cpu` is what Vulkan says for llvmpipe, DX12 for WARP and
|
||
/// GL for anything whose renderer string gives it away. WebGPU never says
|
||
/// it — wgpu reports every browser adapter as `Other` (gfx-rs/wgpu#8819)
|
||
/// — so the names browsers give their fallbacks are matched as well, the
|
||
/// same way wgpu's own GL backend classifies them.
|
||
pub fn is_software(&self) -> bool {
|
||
if self.device_type == wgpu::DeviceType::Cpu {
|
||
return true;
|
||
}
|
||
let name = self.name.to_ascii_lowercase();
|
||
SOFTWARE_RASTERIZER_NAMES
|
||
.iter()
|
||
.any(|marker| name.contains(marker))
|
||
}
|
||
}
|
||
|
||
/// Renderer names that mean "no GPU", lower-cased: Mesa's llvmpipe (also
|
||
/// exposed as lavapipe under Vulkan), Google's SwiftShader (Chrome's WebGL /
|
||
/// WebGPU fallback), and Microsoft's WARP, which DXGI names as a driver.
|
||
const SOFTWARE_RASTERIZER_NAMES: [&str; 5] = [
|
||
"llvmpipe",
|
||
"lavapipe",
|
||
"softpipe",
|
||
"swiftshader",
|
||
"microsoft basic render",
|
||
];
|
||
|
||
/// What is drawing the scene, as far as the app can tell.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum GpuStatus {
|
||
/// No scene frame has completed yet, so nothing is known.
|
||
Unknown,
|
||
/// A GPU, through wgpu. The normal case; nothing to say.
|
||
Hardware(GpuAdapter),
|
||
/// A CPU rasterizer, through wgpu — llvmpipe, WARP, SwiftShader. Every
|
||
/// frame works, slowly, and the driver that should be here is not.
|
||
Software(GpuAdapter),
|
||
/// wgpu found no adapter at all. iced then draws the interface with
|
||
/// tiny-skia and drops the scene primitive with a `log::warn!` nobody
|
||
/// sees, so the viewport stays blank while everything else works.
|
||
NoRenderer,
|
||
}
|
||
|
||
impl GpuStatus {
|
||
/// True when the user should be told: the scene is slow or missing.
|
||
pub fn is_degraded(&self) -> bool {
|
||
matches!(self, Self::Software(_) | Self::NoRenderer)
|
||
}
|
||
|
||
/// A stable name for "this situation", so the popup can be silenced for
|
||
/// one adapter without silencing the next one.
|
||
pub fn identity(&self) -> Option<String> {
|
||
match self {
|
||
Self::Software(adapter) => Some(format!("software:{}", adapter.name)),
|
||
Self::NoRenderer => Some("no-renderer".to_string()),
|
||
Self::Unknown | Self::Hardware(_) => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The adapter of the most recently created device.
|
||
static GPU_ADAPTER: std::sync::Mutex<Option<GpuAdapter>> = std::sync::Mutex::new(None);
|
||
|
||
/// How many times the viewport has asked the scene for a primitive. Counted
|
||
/// in `ViewportPane::draw`, which every renderer calls; only wgpu goes on to
|
||
/// `prepare` and build a device. Draws without a device are therefore the
|
||
/// evidence that the scene is being dropped.
|
||
static SCENE_DRAWS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||
|
||
/// Draws the viewport may request before a still-missing device counts as
|
||
/// `NoRenderer`. Under wgpu the device exists before the first draw's frame
|
||
/// ends, so one would do; the margin covers a frame that was drawn but never
|
||
/// presented (an outdated surface, an occluded window).
|
||
const SCENE_DRAWS_BEFORE_NO_RENDERER: u32 = 3;
|
||
|
||
/// Bumped whenever the verdict of [`gpu_status`] can have changed: a device
|
||
/// was created, or the draw count reached its threshold. The app compares it
|
||
/// against what it last saw, so the per-message check is one atomic load.
|
||
static GPU_STATUS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||
|
||
/// Called by the viewport's `shader::Program::draw`, whichever renderer runs it.
|
||
pub(crate) fn note_scene_draw() {
|
||
use std::sync::atomic::Ordering;
|
||
if SCENE_DRAWS.fetch_add(1, Ordering::Relaxed) + 1 == SCENE_DRAWS_BEFORE_NO_RENDERER {
|
||
GPU_STATUS_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
}
|
||
|
||
/// One line for whoever reads logs: stderr natively, the browser console on
|
||
/// the web, where wasm has no stderr to write to.
|
||
pub(crate) fn report_gpu_line(line: &str) {
|
||
#[cfg(target_arch = "wasm32")]
|
||
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(line));
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
eprintln!("{line}");
|
||
}
|
||
|
||
fn record_gpu_adapter(device: &wgpu::Device) {
|
||
let info = device.adapter_info();
|
||
report_gpu_line(&format!(
|
||
"[gpu] adapter: {} ({:?}, {:?}, driver: {} {})",
|
||
info.name, info.backend, info.device_type, info.driver, info.driver_info
|
||
));
|
||
let adapter = GpuAdapter {
|
||
name: info.name,
|
||
backend: info.backend,
|
||
device_type: info.device_type,
|
||
};
|
||
if let Ok(mut slot) = GPU_ADAPTER.lock() {
|
||
*slot = Some(adapter);
|
||
}
|
||
GPU_STATUS_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
|
||
/// The verdict from what has been recorded so far. Pure, so it can be tested
|
||
/// without a device: `adapter` is the last device's adapter, `scene_draws`
|
||
/// how often the viewport asked to be drawn.
|
||
fn gpu_status_from(adapter: Option<GpuAdapter>, scene_draws: u32) -> GpuStatus {
|
||
match adapter {
|
||
Some(adapter) if adapter.is_software() => GpuStatus::Software(adapter),
|
||
Some(adapter) => GpuStatus::Hardware(adapter),
|
||
None if scene_draws >= SCENE_DRAWS_BEFORE_NO_RENDERER => GpuStatus::NoRenderer,
|
||
None => GpuStatus::Unknown,
|
||
}
|
||
}
|
||
|
||
/// The current verdict.
|
||
pub(crate) fn gpu_status() -> GpuStatus {
|
||
let adapter = GPU_ADAPTER.lock().ok().and_then(|slot| slot.clone());
|
||
gpu_status_from(
|
||
adapter,
|
||
SCENE_DRAWS.load(std::sync::atomic::Ordering::Relaxed),
|
||
)
|
||
}
|
||
|
||
/// The verdict, but only when it may have moved since `seen` — the app calls
|
||
/// this after every message, and almost every call is one atomic load.
|
||
pub(crate) fn gpu_status_if_changed(seen: &mut u64) -> Option<GpuStatus> {
|
||
let generation = GPU_STATUS_GENERATION.load(std::sync::atomic::Ordering::Relaxed);
|
||
if generation == *seen {
|
||
return None;
|
||
}
|
||
*seen = generation;
|
||
Some(gpu_status())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod gpu_status_tests {
|
||
use super::*;
|
||
|
||
fn adapter(name: &str, device_type: wgpu::DeviceType) -> GpuAdapter {
|
||
GpuAdapter {
|
||
name: name.to_string(),
|
||
backend: wgpu::Backend::Vulkan,
|
||
device_type,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn software_is_the_device_type_or_a_known_fallback_name() {
|
||
assert!(adapter("llvmpipe (LLVM 21.1.8, 256 bits)", wgpu::DeviceType::Cpu).is_software());
|
||
// WebGPU reports every adapter as Other, so the name has to carry it.
|
||
assert!(adapter(
|
||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)",
|
||
wgpu::DeviceType::Other
|
||
)
|
||
.is_software());
|
||
assert!(adapter("Microsoft Basic Render Driver", wgpu::DeviceType::Other).is_software());
|
||
assert!(!adapter("NVIDIA GeForce GT 1030", wgpu::DeviceType::DiscreteGpu).is_software());
|
||
assert!(!adapter("Apple M2", wgpu::DeviceType::IntegratedGpu).is_software());
|
||
assert!(!adapter("", wgpu::DeviceType::Other).is_software());
|
||
}
|
||
|
||
#[test]
|
||
fn the_verdict_needs_either_a_device_or_enough_dropped_draws() {
|
||
assert_eq!(gpu_status_from(None, 0), GpuStatus::Unknown);
|
||
assert_eq!(
|
||
gpu_status_from(None, SCENE_DRAWS_BEFORE_NO_RENDERER - 1),
|
||
GpuStatus::Unknown
|
||
);
|
||
assert_eq!(
|
||
gpu_status_from(None, SCENE_DRAWS_BEFORE_NO_RENDERER),
|
||
GpuStatus::NoRenderer
|
||
);
|
||
let gpu = adapter("NVIDIA GeForce GT 1030", wgpu::DeviceType::DiscreteGpu);
|
||
assert_eq!(
|
||
gpu_status_from(Some(gpu.clone()), 100),
|
||
GpuStatus::Hardware(gpu)
|
||
);
|
||
let cpu = adapter("llvmpipe", wgpu::DeviceType::Cpu);
|
||
assert_eq!(
|
||
gpu_status_from(Some(cpu.clone()), 0),
|
||
GpuStatus::Software(cpu)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn only_degraded_verdicts_carry_an_identity() {
|
||
assert!(GpuStatus::Unknown.identity().is_none());
|
||
assert!(
|
||
GpuStatus::Hardware(adapter("x", wgpu::DeviceType::DiscreteGpu))
|
||
.identity()
|
||
.is_none()
|
||
);
|
||
assert_eq!(
|
||
GpuStatus::NoRenderer.identity().as_deref(),
|
||
Some("no-renderer")
|
||
);
|
||
let software = GpuStatus::Software(adapter("llvmpipe", wgpu::DeviceType::Cpu));
|
||
assert!(software.is_degraded());
|
||
assert_eq!(software.identity().as_deref(), Some("software:llvmpipe"));
|
||
}
|
||
}
|
||
|
||
impl iced::widget::shader::Pipeline for MultiPipeline {
|
||
fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
|
||
install_gpu_error_handler(device);
|
||
record_gpu_adapter(device);
|
||
Self {
|
||
block_geometry: Default::default(),
|
||
inners: vec![Pipeline::new(device, queue, format)],
|
||
format,
|
||
slot_by_instance: rustc_hash::FxHashMap::default(),
|
||
slot_last_used: vec![0],
|
||
slot_clock: 0,
|
||
frame_rendered: std::sync::atomic::AtomicBool::new(false),
|
||
gpu_error_epoch: gpu_errors_seen(),
|
||
wire_buffer_cache: rustc_hash::FxHashMap::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod highlight_classification_tests {
|
||
use super::*;
|
||
use crate::scene::model::wire_model::TangentGeom;
|
||
|
||
fn plain(name: &str) -> WireModel {
|
||
WireModel::solid(
|
||
name.to_string(),
|
||
vec![[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
|
||
[1.0; 4],
|
||
false,
|
||
)
|
||
}
|
||
|
||
fn circle(name: &str) -> WireModel {
|
||
let mut wire = plain(name);
|
||
wire.tangent_geoms.push(TangentGeom::PlanarCircle {
|
||
center: [0.0, 0.0, 0.0],
|
||
axis_x: [1.0, 0.0, 0.0],
|
||
axis_y: [0.0, 1.0, 0.0],
|
||
radius: 2.0,
|
||
});
|
||
wire
|
||
}
|
||
|
||
fn ellipse(name: &str) -> WireModel {
|
||
let mut wire = plain(name);
|
||
wire.tangent_geoms.push(TangentGeom::PlanarEllipse {
|
||
center: [0.0, 0.0, 0.0],
|
||
major_axis: [2.0, 0.0, 0.0],
|
||
normal: [0.0, 0.0, 1.0],
|
||
minor_axis_ratio: 0.5,
|
||
start_param: 0.0,
|
||
end_param: std::f64::consts::TAU,
|
||
});
|
||
wire
|
||
}
|
||
|
||
#[test]
|
||
fn each_wire_lands_in_the_bucket_the_old_predicate_chose() {
|
||
let wires = [plain("1"), circle("2"), ellipse("3"), plain("4")];
|
||
let refs: Vec<&WireModel> = wires.iter().collect();
|
||
let depth_map = rustc_hash::FxHashMap::default();
|
||
|
||
let (circles, ellipses, regular, blocks) =
|
||
Pipeline::classify_highlight_wires(&refs, None, &depth_map);
|
||
|
||
for wire in &refs {
|
||
let was_regular = wire.render_instance.is_none()
|
||
&& circle_gpu::extract_circle_instances(wire, 0.0).is_none()
|
||
&& ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none();
|
||
assert_eq!(
|
||
was_regular,
|
||
regular.iter().any(|kept| std::ptr::eq(*kept, *wire)),
|
||
"wire {} disagrees with the old predicate",
|
||
wire.name,
|
||
);
|
||
}
|
||
|
||
assert_eq!(regular.len(), 2, "two plain lines");
|
||
assert_eq!(circles.len(), 1, "one circle instance");
|
||
assert_eq!(ellipses.len(), 1, "one ellipse instance");
|
||
assert!(blocks.is_empty(), "no block wires in this sample");
|
||
}
|
||
|
||
// Hover recolours every instance; a selection only recolours when a tint is
|
||
// configured. Both go through the same argument, so it has to actually
|
||
// reach the instances.
|
||
#[test]
|
||
fn the_colour_override_reaches_the_instances() {
|
||
let wires = [circle("2"), ellipse("3")];
|
||
let refs: Vec<&WireModel> = wires.iter().collect();
|
||
let depth_map = rustc_hash::FxHashMap::default();
|
||
|
||
let (tinted_circles, tinted_ellipses, _, _) =
|
||
Pipeline::classify_highlight_wires(&refs, Some(WireModel::HOVER), &depth_map);
|
||
assert_eq!(tinted_circles[0].color, WireModel::HOVER);
|
||
assert_eq!(tinted_ellipses[0].color, WireModel::HOVER);
|
||
|
||
let (plain_circles, _, _, _) = Pipeline::classify_highlight_wires(&refs, None, &depth_map);
|
||
assert_ne!(
|
||
plain_circles[0].color,
|
||
WireModel::HOVER,
|
||
"without a tint the extractor's own colour stands",
|
||
);
|
||
}
|
||
}
|