#[cfg(test)] mod gpu_tests; mod device_capabilities; pub mod circle_gpu; pub mod ellipse_gpu; pub mod face3d_gpu; pub mod gpu_budget; pub mod gpu_upload; pub mod hatch_gpu; pub mod wipeout_gpu; pub mod image_gpu; pub mod mesh_gpu; pub mod text_gpu; pub mod uniforms; pub mod viewcube; /// 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 wipeout_gpu::WipeoutGpu; pub use image_gpu::ImageGpu; pub use uniforms::Uniforms; pub use viewcube::ViewCubePipeline; 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; struct SilhouetteChunk { vertex_buffer: wgpu::Buffer, instance_buffer: wgpu::Buffer, vertex_count: u32, instance_count: u32, } struct SilhouetteSourceGroup { color: [f32; 4], sources: Vec, 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, 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, /// 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, /// Glyph-quad vertices for the frame, split at whole glyph boundaries. text_gpu: Vec, block_text_gpu: Vec, block_text_highlight_gpu: Vec, /// 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, /// 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, /// Per-frame silhouette line lists from the kernel mesh and current view. silhouette_chunks: Vec, silhouette_source_key: (usize, usize, u64), silhouette_source_groups: Vec, /// 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, /// 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, 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>, pub(crate) gpu_circles: std::sync::Arc>, pub(crate) gpu_ellipses: std::sync::Arc>, pub(crate) gpu_block_wires: std::sync::Arc>, /// 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, /// 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, /// 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>, pub(crate) wire_arena_fallback_kind: Option, pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet, /// The Model content id both arenas currently mirror (`u64::MAX` = none). pub(crate) wire_arena_id: 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, gpu_selected_circles: Vec, gpu_selected_ellipses: Vec, gpu_selected_block_wires: Vec, /// 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, /// Wipeout masks — solid fills rendered after wires in a separate pass via /// the legacy per-primitive `WipeoutGpu` renderer. gpu_wipeouts: Vec, /// 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, gpu_images: Vec, /// 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, /// 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_disabled_chunks: rustc_hash::FxHashSet, mesh_dynamic_handles: rustc_hash::FxHashSet, /// 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>, mesh_highlight_draws: Vec, /// `(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, gpu_face3d_edges: Vec, 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>>, pub cached_preview_hatch_source: Option>>, pub cached_wipeout_source: Option>>, pub cached_image_source: Option>>, pub cached_text_source: Option>>, pub cached_annotation_highlight_source: Option>>, pub cached_mesh_source: Option>>, pub cached_face3d_source: Option>>, pub cached_face3d_depth_source: Option>>, 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>>, /// 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::() 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> = 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 => { include_str!("../../shaders/wire_indexed.wgsl") } wire_gpu::WirePipelineMode::Packed => include_str!("../../shaders/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() { include_str!("../../shaders/block_wire_storage.wgsl") } else { include_str!("../../shaders/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(include_str!( "../../shaders/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(), // Bias TOWARD the camera: a wipeout must win against geometry at // its own depth (a block's wipeout + shapes are coincident at // Z=0). A positive bias pushed the mask behind, so LessEqual // rejected it and the geometry showed through. Tiny enough that // meaningfully-nearer geometry still occludes the mask. bias: wgpu::DepthBiasState { constant: -8, 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: &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(include_str!( "../../shaders/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(include_str!( "../../shaders/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(include_str!( "../../shaders/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, }); // ── 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, 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), // (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, 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_wipeouts: vec![], wipeout_skip_flags: vec![], gpu_images: vec![], 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_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, block_geometry: &mut wire_gpu::BlockGeometryCache, ) -> Vec { 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, ) -> Vec { let mut instances: Vec = 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 { if instances.is_empty() { vec![] } else { vec![CircleGpu::from_instances( device, queue, "viewer.circles", instances, )] } } /// 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, ) -> Vec { let mut instances: Vec = 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 { if instances.is_empty() { vec![] } else { vec![EllipseGpu::from_instances( device, queue, "viewer.ellipses", instances, )] } } /// 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, block_geometry: &mut wire_gpu::BlockGeometryCache, ) -> ( std::sync::Arc>, std::sync::Arc>, std::sync::Arc>>, std::sync::Arc>, std::sync::Arc>, ) { // 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 = 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)| w.name.parse::().ok().map(|h| (h, idx as u32))) .collect() }; let mut index: rustc_hash::FxHashMap> = 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. pub fn upload_selected_wires( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, wires: &[WireModel], selected: &rustc_hash::FxHashSet, hovered: &rustc_hash::FxHashSet, annotation_context_wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, 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()) { let mut slots = idxs.clone(); slots.sort_unstable(); for &i in &slots { if let Some(w) = wires.get(i as usize) { 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()) { let mut slots = idxs.clone(); slots.sort_unstable(); for &i in &slots { if let Some(w) = wires.get(i as usize) { hover_wires.push(w); } } } } for wire in annotation_context_wires { if wire.selected { selected_wires.push(wire); } else { hover_wires.push(wire); } } let mut selected_circles: Vec = Vec::new(); for &wire in &selected_wires { let depth = wire_gpu::wire_draw_depth(wire, depth_map); if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { for mut inst in insts { if let Some(tint) = selected_tint { inst.color = tint; } selected_circles.push(inst); } } } for &wire in &hover_wires { let depth = wire_gpu::wire_draw_depth(wire, depth_map); if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { for mut inst in insts { inst.color = WireModel::HOVER; selected_circles.push(inst); } } } self.gpu_selected_circles = if selected_circles.is_empty() { vec![] } else { vec![CircleGpu::from_instances( device, queue, "viewer.selected_circles", &selected_circles, )] }; let mut selected_ellipses: Vec = Vec::new(); for &wire in &selected_wires { let depth = wire_gpu::wire_draw_depth(wire, depth_map); if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { for mut inst in insts { if let Some(tint) = selected_tint { inst.color = tint; } selected_ellipses.push(inst); } } } for &wire in &hover_wires { let depth = wire_gpu::wire_draw_depth(wire, depth_map); if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { for mut inst in insts { inst.color = WireModel::HOVER; selected_ellipses.push(inst); } } } self.gpu_selected_ellipses = if selected_ellipses.is_empty() { vec![] } else { vec![EllipseGpu::from_instances( device, queue, "viewer.selected_ellipses", &selected_ellipses, )] }; let selected_regular: Vec<&WireModel> = selected_wires .iter() .copied() .filter(|wire| { 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() }) .collect(); let hover_regular: Vec<&WireModel> = hover_wires .iter() .copied() .filter(|wire| { 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() }) .collect(); let selected_blocks: Vec<&WireModel> = selected_wires .iter() .copied() .filter(|wire| wire.render_instance.is_some()) .collect(); let hover_blocks: Vec<&WireModel> = hover_wires .iter() .copied() .filter(|wire| wire.render_instance.is_some()) .collect(); 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(), )); 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 { 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, hovered: &rustc_hash::FxHashSet, annotation_context_wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, 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 = 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>, handle_val: u64, tint: [f32; 4], wires: &'a [WireModel], out: &mut Vec, 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, ) { self.gpu_preview_wires = if wires.is_empty() { vec![] } else { WireGpu::from_run(device, queue, wires, depth_map, false, self.wire_const_bgl.as_ref()) }; } /// 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::() / 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::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 = 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::(); 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(acadrust::types::Vector3::ZERO); let vectors = [ transform.apply_rotation( acadrust::types::Vector3::UNIT_X, ), transform.apply_rotation( acadrust::types::Vector3::UNIT_Y, ), transform.apply_rotation( acadrust::types::Vector3::UNIT_Z, ), ]; if let Some(transformed) = cadkernel::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(cadkernel::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 = Vec::with_capacity(max_vertices); let push_chunk = | verts: &[SilhouetteVertex], chunks: &mut Vec, | { 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 = 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, ) { 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: &[(acadrust::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, hovered: &rustc_hash::FxHashSet, edge_wires: &[WireModel], ) { let edge_handles: rustc_hash::FxHashSet = edge_wires .iter() .filter_map(|wire| wire.name.strip_prefix("mesh-edge:")) .filter_map(|value| value.parse::().ok()) .map(acadrust::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 { 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 = 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); } /// 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, ) { 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 the geometry passes at `vp_size` (the full viewport size — the /// MSAA / resolve textures are this size) and blit the resulting 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, vp_size: Size, surface_dest: Rectangle, bg_color: [f32; 4], mesh_wireframe: bool, hidden_line: bool, show_3d_edges: bool, ) { let vp = Rectangle:: { x: 0, y: 0, width: vp_size.width, height: vp_size.height, }; 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, }); // MSAA texture is clip-bounds-sized, so viewport starts at (0, 0). pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 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(0.0, 0.0, vp.width as f32, vp.height as f32, 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 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(0.0, 0.0, vp.width as f32, vp.height as f32, 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(0.0, 0.0, vp.width as f32, vp.height as f32, 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(0.0, 0.0, vp.width as f32, vp.height as f32, 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(0.0, 0.0, vp.width as f32, vp.height as f32, 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); } } } // Live overlay wires (command preview / interim / grip drag) always // on top: the xray pipeline (depth_compare=Always, no depth write) // keeps them visible through any occluding geometry — a 3D solid, or // 2D geometry drawn in front — so a command preview is never hidden. // No scissor. if self.gpu_preview_wires.iter().any(|pw| pw.instance_count > 0) { pass.set_pipeline(&self.wire_xray_pipeline); for pw in &self.gpu_preview_wires { if pw.instance_count > 0 { if let Some(bg) = &pw.const_bind_group { pass.set_bind_group(1, bg.as_ref(), &[]); } pass.set_vertex_buffer(0, pw.instance_buffer.slice(..)); pass.draw( 0..6, pw.first_instance..pw.first_instance + pw.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(); let have_preview = !self.text_preview_gpu.is_empty(); if have_base || have_blocks || have_preview { 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(0.0, 0.0, vp.width as f32, vp.height as f32, 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); } } // Grip-drag / command-preview glyphs, drawn over the base text. if have_preview { pass.set_pipeline(&self.text_pipeline); 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 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(0.0, 0.0, vp.width as f32, vp.height as f32, 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 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. 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() || 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(0.0, 0.0, vp.width as f32, vp.height as f32, 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 hidden_line { &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 hidden_line { &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 hidden_line { &self.circle_pipeline } else { &self.circle_xray_pipeline }); 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 hidden_line { &self.ellipse_pipeline } else { &self.ellipse_xray_pipeline }); 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_bind_group(0, &self.blit_bind_group, &[]); pass.draw(0..6, 0..1); } } /// Forget every cache key that describes this slot's uploaded content. /// /// Shared by slot reuse and by [`Self::release_heavy_resources`] so the two /// cannot disagree about what "this slot holds nothing you can trust" /// means. Keys only — the buffers themselves are dropped by the caller that /// wants the memory back. 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_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; } /// 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.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.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.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) { // 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 }); self.block_geometry.retain(|_, chunks| { chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1) }); } 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 { block_geometry: wire_gpu::block_geometry_bytes(&self.block_geometry), ..Default::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 } } /// 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. fn bind_and_draw_block_wire(pass: &mut wgpu::RenderPass<'_>, wire: &wire_gpu::BlockWireGpu) { pass.set_bind_group(1, wire.const_bind_group.as_ref(), &[]); match &wire.vertex_buffer { Some(vertices) => { pass.set_vertex_buffer(0, vertices.slice(..)); pass.set_vertex_buffer(1, wire.instance_buffer.slice(..)); } None => pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)), } pass.draw(0..wire.vertex_count, 0..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) -> 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, 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, 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, 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, slot_last_used: Vec, 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>, std::sync::Arc>, std::sync::Arc>>, std::sync::Arc>, std::sync::Arc>, ), >, } 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 { 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 = 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 = 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}"); } })); } impl iced::widget::shader::Pipeline for MultiPipeline { fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { install_gpu_error_handler(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(), } } }