Skip to main content

brep_render/
render.rs

1//! The wgpu render core — windowing-agnostic (the dual-target seam).
2//!
3//! [`RenderCore::render_to_view`] draws a [`RenderScene`] into ANY
4//! `wgpu::TextureView` (MSAA 4x): shaded faces with per-face selection/hover
5//! emphasis (R17), screen-constant-width edges with occluded portions dimmed
6//! rather than dropped (R17), selected-face boundary outlines, vertex point
7//! sprites, and the world-axis helper (R20). Presentation shells stay thin:
8//! - headless: [`RenderCore::render_to_png`] (render-to-texture → readback →
9//!   PNG bytes) — the artifact binary and R34 screenshot capture;
10//! - desktop: the winit shell hands the surface texture's view here;
11//! - web: the wasm canvas shell does the same.
12//!
13//! GPU buffers are retained per solid and reused while the solid's scene
14//! revision is unchanged (R10 — reused solids keep their buffers across
15//! history reruns). Determinism (R33): same scene + camera + size + styles ⇒
16//! the same command stream on the same device ⇒ identical PNG bytes.
17
18use crate::camera::Camera;
19use crate::color::{solid_color_srgb, srgb_to_linear};
20use crate::scene::{RenderScene, SolidDisplay};
21use crate::style::{Emphasis, EmphasisState, FaceColorMode, RenderSettings, Rgba};
22use std::collections::{HashMap, HashSet};
23use wgpu::util::DeviceExt;
24
25/// MSAA sample count (R7-equivalent quality).
26pub const SAMPLES: u32 = 4;
27/// Offscreen color format. Non-sRGB: the shaders encode explicitly, so the
28/// clear color's bytes land in the PNG exactly.
29pub const COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
30const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
31
32/// Boundary-edge depth bias: NDC in orthographic, scaled by the remaining
33/// depth range in perspective (see vs_edge). Faces also carry rasterizer bias.
34const EDGE_NUDGE: f32 = 2e-4;
35
36#[repr(C)]
37#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
38struct Globals {
39    view_proj: [[f32; 4]; 4],
40    viewport: [f32; 4],
41    forward: [f32; 4],
42}
43
44/// A solid's pose on the GPU: the model matrix its buffers are drawn
45/// through, and the inverse transpose of its linear part for the normals.
46#[repr(C)]
47#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
48struct ModelParams {
49    model: [f32; 16],
50    normal: [f32; 16],
51}
52
53const IDENTITY4: [f32; 16] = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
54
55impl ModelParams {
56    fn of(pose: Option<&[f32; 16]>) -> Self {
57        let Some(m) = pose else { return Self { model: IDENTITY4, normal: IDENTITY4 } };
58        // The normals' matrix: the inverse transpose of the 3×3 linear part
59        // (equal to it for a rotation; it keeps them normal under a scale).
60        let a = |row: usize, col: usize| m[col * 4 + row] as f64;
61        let cof = |r0: usize, r1: usize, c0: usize, c1: usize| a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0);
62        let det = a(0, 0) * cof(1, 2, 1, 2) - a(0, 1) * cof(1, 2, 0, 2) + a(0, 2) * cof(1, 2, 0, 1);
63        let mut normal = IDENTITY4;
64        if det.abs() > 1e-30 {
65            // (A⁻¹)ᵀ = cofactor(A) / det.
66            let c = [
67                [cof(1, 2, 1, 2), -cof(1, 2, 0, 2), cof(1, 2, 0, 1)],
68                [-cof(0, 2, 1, 2), cof(0, 2, 0, 2), -cof(0, 2, 0, 1)],
69                [cof(0, 1, 1, 2), -cof(0, 1, 0, 2), cof(0, 1, 0, 1)],
70            ];
71            for row in 0..3 {
72                for col in 0..3 {
73                    normal[col * 4 + row] = (c[row][col] / det) as f32;
74                }
75            }
76        }
77        Self { model: *m, normal }
78    }
79}
80
81/// Every solid pose of one GPU scene in ONE uniform buffer, a [`MODEL_STRIDE`]
82/// slot each, bound at the solid's offset (group 2, dynamic offset). A frame
83/// that moves 500 solids writes the changed range once: a write per solid was
84/// most of the frame (~0.25-0.8 ms a write under a software adapter).
85struct ModelArena {
86    buf: wgpu::Buffer,
87    bind: wgpu::BindGroup,
88    capacity: u32,
89    data: Vec<u8>,
90    free: Vec<u32>,
91    next: u32,
92    /// The byte range written since the last flush.
93    dirty: Option<(usize, usize)>,
94    /// The buffer is new (grown): the whole of it is written.
95    fresh: bool,
96}
97
98/// One pose slot: the uniform offset alignment every adapter honours.
99const MODEL_STRIDE: u64 = 256;
100
101fn model_binding(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, buf: &wgpu::Buffer, label: &str) -> wgpu::BindGroup {
102    device.create_bind_group(&wgpu::BindGroupDescriptor {
103        label: Some(label),
104        layout,
105        entries: &[wgpu::BindGroupEntry {
106            binding: 0,
107            resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
108                buffer: buf,
109                offset: 0,
110                size: std::num::NonZeroU64::new(std::mem::size_of::<ModelParams>() as u64),
111            }),
112        }],
113    })
114}
115
116fn model_buffer(device: &wgpu::Device, slots: u32, label: &str) -> wgpu::Buffer {
117    device.create_buffer(&wgpu::BufferDescriptor {
118        label: Some(label),
119        size: slots as u64 * MODEL_STRIDE,
120        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
121        mapped_at_creation: false,
122    })
123}
124
125impl ModelArena {
126    fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, capacity: u32) -> Self {
127        let capacity = capacity.max(16);
128        let buf = model_buffer(device, capacity, "solid poses");
129        let bind = model_binding(device, layout, &buf, "solid poses");
130        Self { buf, bind, capacity, data: vec![0; capacity as usize * MODEL_STRIDE as usize], free: Vec::new(), next: 0, dirty: None, fresh: true }
131    }
132
133    fn alloc(&mut self) -> u32 {
134        self.free.pop().unwrap_or_else(|| {
135            self.next += 1;
136            self.next - 1
137        })
138    }
139
140    fn release(&mut self, slot: u32) {
141        self.free.push(slot);
142    }
143
144    fn set(&mut self, slot: u32, pose: Option<&[f32; 16]>) {
145        let need = (slot as usize + 1) * MODEL_STRIDE as usize;
146        if self.data.len() < need {
147            self.data.resize(need.next_power_of_two(), 0);
148        }
149        let at = slot as usize * MODEL_STRIDE as usize;
150        let params = ModelParams::of(pose);
151        let bytes = bytemuck::bytes_of(&params);
152        self.data[at..at + bytes.len()].copy_from_slice(bytes);
153        let (lo, hi) = self.dirty.unwrap_or((at, at + bytes.len()));
154        self.dirty = Some((lo.min(at), hi.max(at + bytes.len())));
155    }
156
157    fn offset(slot: u32) -> u32 {
158        (slot as u64 * MODEL_STRIDE) as u32
159    }
160
161    /// Put what changed on the GPU: one write (or a new, larger buffer).
162    fn flush(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout) {
163        let slots = (self.data.len() / MODEL_STRIDE as usize) as u32;
164        if slots > self.capacity {
165            self.capacity = slots;
166            self.buf = model_buffer(device, slots, "solid poses");
167            self.bind = model_binding(device, layout, &self.buf, "solid poses");
168            self.fresh = true;
169        }
170        if std::mem::take(&mut self.fresh) {
171            queue.write_buffer(&self.buf, 0, &self.data[..self.capacity as usize * MODEL_STRIDE as usize]);
172        } else if let Some((lo, hi)) = self.dirty {
173            let hi = hi.next_multiple_of(4);
174            queue.write_buffer(&self.buf, lo as u64, &self.data[lo..hi]);
175        }
176        self.dirty = None;
177    }
178}
179
180/// A single identity pose for what the solid pipelines draw that is not a
181/// solid's own buffers (bound at offset 0).
182struct IdentityModel {
183    _buf: wgpu::Buffer,
184    bind: wgpu::BindGroup,
185}
186
187impl IdentityModel {
188    fn new(device: &wgpu::Device, queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout) -> Self {
189        let buf = model_buffer(device, 1, "identity pose");
190        queue.write_buffer(&buf, 0, bytemuck::bytes_of(&ModelParams::of(None)));
191        let bind = model_binding(device, layout, &buf, "identity pose");
192        Self { _buf: buf, bind }
193    }
194}
195
196#[repr(C)]
197#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
198struct StyleParams {
199    color: [f32; 4],
200    params: [f32; 4],
201}
202
203#[repr(C)]
204#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
205struct MeshVertex {
206    position: [f32; 3],
207    normal: [f32; 3],
208}
209
210#[repr(C)]
211#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
212struct EdgeInstance {
213    p0: [f32; 3],
214    p1: [f32; 3],
215}
216
217#[repr(C)]
218#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
219struct PointInstance {
220    center: [f32; 3],
221}
222
223/// One overlay-widget line segment (per-instance colored, screen-constant width).
224#[repr(C)]
225#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
226struct OverlayLineInstance {
227    p0: [f32; 3],
228    p1: [f32; 3],
229    color: [f32; 4],
230}
231
232/// One overlay-widget triangle vertex (per-vertex colored, shaded).
233#[repr(C)]
234#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
235struct OverlayTriVertex {
236    position: [f32; 3],
237    normal: [f32; 3],
238    color: [f32; 4],
239}
240
241/// One uniform style buffer + its bind group (a material variant).
242struct StyleBuf {
243    buf: wgpu::Buffer,
244    bind: wgpu::BindGroup,
245}
246
247impl StyleBuf {
248    fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, label: &str) -> Self {
249        let buf = device.create_buffer(&wgpu::BufferDescriptor {
250            label: Some(label),
251            size: std::mem::size_of::<StyleParams>() as u64,
252            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
253            mapped_at_creation: false,
254        });
255        let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
256            label: Some(label),
257            layout,
258            entries: &[wgpu::BindGroupEntry {
259                binding: 0,
260                resource: buf.as_entire_binding(),
261            }],
262        });
263        Self { buf, bind }
264    }
265
266    fn write(&self, queue: &wgpu::Queue, color: Rgba, params: [f32; 4]) {
267        queue.write_buffer(&self.buf, 0, bytemuck::bytes_of(&StyleParams { color, params }));
268    }
269}
270
271/// The global material variants (updated from [`RenderSettings`] each frame).
272struct GlobalStyles {
273    face_selected: StyleBuf,
274    face_hovered: StyleBuf,
275    edge_base: StyleBuf,
276    edge_selected: StyleBuf,
277    edge_hovered: StyleBuf,
278    edge_hidden: StyleBuf,
279    /// A display-only (AUX) edge at rest — a board's copper outline.
280    edge_aux: StyleBuf,
281    boundary: StyleBuf,
282    point_base: StyleBuf,
283    point_selected: StyleBuf,
284    point_hovered: StyleBuf,
285    axis_x: StyleBuf,
286    axis_y: StyleBuf,
287    axis_z: StyleBuf,
288    /// Overlay-widget line width (CSS px in `params.x`); color is per-instance.
289    overlay_line: StyleBuf,
290}
291
292struct FaceRange {
293    first_index: u32,
294    index_count: u32,
295    /// This face's slice of the WIREFRAME index buffer. Not derivable from
296    /// `first_index`: the wire buffer holds one segment per DISTINCT mesh edge
297    /// of the face, so its length depends on how the face's triangles share
298    /// edges, not on how many there are.
299    wire_first: u32,
300    wire_count: u32,
301    /// Index into [`GpuSolid::face_styles`] when this face carries its OWN
302    /// colour (a `color` metadata attribute on the face name); `None` = shade
303    /// with the solid's `base_style`.
304    style: Option<u32>,
305}
306
307/// The hasher behind the wireframe's per-face edge de-duplication.
308///
309/// The keys are pairs of mesh vertex INDICES — dense, small, and entirely under
310/// our control, so there is no adversary for SipHash to defend against, and its
311/// per-key cost is the whole cost of the de-duplication (three probes per
312/// triangle, at every solid upload). This is the multiply-xor mix `rustc-hash`
313/// uses, written out rather than depended on: `rustc-hash` only rides into this
314/// build through wgpu's internals, and a published crate should not take a
315/// direct dependency for fifteen lines.
316#[derive(Default)]
317struct EdgeHasher(u64);
318
319impl std::hash::Hasher for EdgeHasher {
320    fn write(&mut self, bytes: &[u8]) {
321        for &byte in bytes {
322            self.write_u32(u32::from(byte));
323        }
324    }
325
326    fn write_u32(&mut self, value: u32) {
327        // 2^64 / golden ratio: the odd multiplier spreads low-index bits into
328        // the high ones, which is where `finish` folds them back down from.
329        self.0 = (self.0 ^ u64::from(value)).wrapping_mul(0x9E37_79B9_7F4A_7C15);
330    }
331
332    fn finish(&self) -> u64 {
333        self.0 ^ (self.0 >> 32)
334    }
335}
336
337type BuildEdgeHasher = std::hash::BuildHasherDefault<EdgeHasher>;
338
339struct EdgeRange {
340    first_instance: u32,
341    instance_count: u32,
342}
343
344struct GpuSolid {
345    revision: u64,
346    /// The shape revision these buffers were built from (`shape_revision`, or
347    /// the revision itself for an unposed display): a display of the same
348    /// shape is re-posed by rewriting `vertex_buf`, `edge_buf` and `point_buf`.
349    shape: u64,
350    vertex_count: u32,
351    vertex_buf: wgpu::Buffer,
352    index_buf: wgpu::Buffer,
353    /// Line-list indices for the wireframe view (each triangle → its 3 edges).
354    wire_index_buf: wgpu::Buffer,
355    wire_index_count: u32,
356    faces: Vec<FaceRange>,
357    edge_buf: Option<wgpu::Buffer>,
358    edges: Vec<EdgeRange>,
359    edge_instances: u32,
360    /// The instances drawn by the OCCLUDED-edge pass: every edge's up to the
361    /// first AUX one. A display-only outline (a board's copper) is not dimmed
362    /// through what hides it — the bottom copper would ghost through the board
363    /// — and aux edges trail the real ones, so it is a prefix.
364    occludable_edge_instances: u32,
365    point_buf: Option<wgpu::Buffer>,
366    point_count: u32,
367    /// Per-solid base face style (uniform or hashed color; metadata override).
368    base_style: StyleBuf,
369    /// De-duplicated palette of PER-FACE styles — one entry per distinct face
370    /// colour on this solid, referenced by [`FaceRange::style`]. Empty for the
371    /// overwhelmingly common solid whose faces are all one colour, which is what
372    /// keeps the whole-mesh fast path in `frame.rs` alive.
373    face_styles: Vec<StyleBuf>,
374    /// Selected/hovered face boundary outline segments (cache keyed by the
375    /// emphasis generation).
376    boundary: Option<BoundaryBuf>,
377    /// This solid's slot in its scene's [`ModelArena`] (the pose its buffers
378    /// are drawn through; identity unless posed).
379    model_slot: u32,
380    /// The buffers hold the geometry of `shape` itself, as uploaded: a pose
381    /// of that shape is then a matrix write. False once a re-pose rewrote
382    /// them (a drag), until the next upload.
383    holds_shape: bool,
384}
385
386struct BoundaryBuf {
387    emphasis_generation: u64,
388    revision: u64,
389    buf: Option<wgpu::Buffer>,
390    count: u32,
391}
392
393/// The Work part context tint: assembly components around the part being
394/// edited are drawn in this colour, alpha-blended without depth writes and
395/// without pick handles.
396pub const WORK_PART_CONTEXT_RGBA: [f32; 4] = [0.45, 0.55, 0.65, 0.22];
397
398/// A scene uploaded to GPU buffers, retained across frames; sync with
399/// [`RenderCore::sync_scene`].
400#[derive(Default)]
401pub struct GpuScene {
402    solids: HashMap<String, GpuSolid>,
403    /// Draw order (scene insertion order at last sync).
404    order: Vec<String>,
405    settings_generation: u64,
406    axis_buf: Option<wgpu::Buffer>,
407    /// Count of solid (re)uploads (R10 observability: a reused solid must NOT
408    /// bump this across a sync).
409    uploads: u64,
410    /// Count of in-place re-poses (positions rewritten, nothing rebuilt).
411    reposes: u64,
412    /// Count of poses drawn through a matrix (a 128-byte write, no vertex).
413    pose_writes: u64,
414    /// Every solid's pose, one buffer (made on the first sync).
415    models: Option<ModelArena>,
416}
417
418impl GpuScene {
419    /// Total solid buffer uploads since creation (test/telemetry hook for the
420    /// reused-buffer fast path).
421    pub fn upload_count(&self) -> u64 {
422        self.uploads
423    }
424
425    /// Total in-place re-poses since creation (a rigidly moved solid whose
426    /// buffers were rewritten rather than rebuilt).
427    pub fn repose_count(&self) -> u64 {
428        self.reposes
429    }
430
431    /// Total poses taken by a matrix write alone (the buffers kept the
432    /// shape's geometry and nothing of it was rewritten).
433    pub fn pose_write_count(&self) -> u64 {
434        self.pose_writes
435    }
436}
437
438struct CachedTargets {
439    width: u32,
440    height: u32,
441    msaa_view: wgpu::TextureView,
442    depth_view: wgpu::TextureView,
443}
444
445/// Everything a frame needs beyond the scene buffers.
446pub struct FrameParams<'a> {
447    pub camera: &'a Camera,
448    /// Physical pixel size of the target.
449    pub width: u32,
450    pub height: u32,
451    /// Device pixel ratio (CSS px → physical px) for line/point widths.
452    pub dpr: f32,
453    pub settings: &'a RenderSettings,
454    pub emphasis: &'a Emphasis,
455    /// World units per CSS pixel (for the screen-sized axis helper).
456    pub world_per_pixel: f64,
457    /// In-scene overlay widgets: transform gizmo, ViewCube, datum /
458    /// dimension / curve visuals. `None` on the artifact/headless path.
459    pub overlay: Option<&'a crate::widgets::WidgetOverlay>,
460}
461
462/// Create a headless device: adapter picked by `BREP_RENDER_ADAPTER`
463/// (case-insensitive substring of the adapter name, e.g. "llvmpipe") or wgpu's
464/// high-performance default. Native only — the browser shell gets its device
465/// from the canvas context.
466#[cfg(not(target_arch = "wasm32"))]
467pub fn create_headless_device() -> Result<(wgpu::Device, wgpu::Queue, String), String> {
468    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
469    let want = std::env::var("BREP_RENDER_ADAPTER").ok();
470    let adapter = if let Some(want) = &want {
471        let want_lower = want.to_lowercase();
472        pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
473            .into_iter()
474            .find(|adapter| adapter.get_info().name.to_lowercase().contains(&want_lower))
475            .ok_or_else(|| format!("no adapter matching BREP_RENDER_ADAPTER={want}"))?
476    } else {
477        pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
478            power_preference: wgpu::PowerPreference::HighPerformance,
479            compatible_surface: None,
480            force_fallback_adapter: false,
481        }))
482        .map_err(|error| format!("no wgpu adapter available: {error}"))?
483    };
484    let info = adapter.get_info();
485    let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
486        label: Some("brep-render"),
487        ..Default::default()
488    }))
489    .map_err(|error| format!("wgpu device request failed: {error}"))?;
490    Ok((device, queue, format!("{} ({:?})", info.name, info.backend)))
491}
492
493/// The window-agnostic renderer: pipelines + layouts for one color format.
494pub struct RenderCore {
495    pub device: wgpu::Device,
496    pub queue: wgpu::Queue,
497    format: wgpu::TextureFormat,
498    globals_buf: wgpu::Buffer,
499    globals_bind: wgpu::BindGroup,
500    style_layout: wgpu::BindGroupLayout,
501    /// Group 2 of the solid pipelines: a solid's pose in its scene's
502    /// [`ModelArena`], at a dynamic offset.
503    model_layout: wgpu::BindGroupLayout,
504    /// Bound for whatever the solid pipelines draw that is not a solid's own
505    /// buffers (face boundaries, the world axes).
506    identity_model: IdentityModel,
507    mesh_pipeline: wgpu::RenderPipeline,
508    context_pipeline: wgpu::RenderPipeline,
509    context_style: StyleBuf,
510    /// A selected / hovered context face: the selection colours, still
511    /// translucent (the context pass blends without depth writes).
512    context_selected: StyleBuf,
513    context_hovered: StyleBuf,
514    wire_pipeline: wgpu::RenderPipeline,
515    edge_visible_pipeline: wgpu::RenderPipeline,
516    edge_hidden_pipeline: wgpu::RenderPipeline,
517    point_pipeline: wgpu::RenderPipeline,
518    /// Overlay-widget passes: shaded per-vertex tris + screen-constant
519    /// per-instance lines, drawn over the solids in a depth-cleared pass.
520    overlay_tri_pipeline: wgpu::RenderPipeline,
521    /// Same as `overlay_tri_pipeline` but WITHOUT depth-write — for the datum/
522    /// construction PLANES, which are translucent UI aids that must NOT occlude
523    /// the gizmos/dimensions drawn after them (a plane no longer hides an offset
524    /// arrow behind it). The gizmo tris still use the depth-writing pipeline so
525    /// they self-occlude correctly.
526    overlay_tri_nodepth_pipeline: wgpu::RenderPipeline,
527    overlay_line_pipeline: wgpu::RenderPipeline,
528    /// Second globals buffer for the ViewCube pass (its own mini-camera).
529    vc_globals_buf: wgpu::Buffer,
530    vc_globals_bind: wgpu::BindGroup,
531    styles: GlobalStyles,
532    targets: Option<CachedTargets>,
533}
534
535/// 8-bit RGB PNG encode (no text/time chunks — byte-deterministic).
536#[cfg(not(target_arch = "wasm32"))]
537fn encode_png(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
538    brep_gizmos::raster::encode_png(rgb, width, height, png::ColorType::Rgb)
539}
540
541mod pipelines;
542mod scene_sync;
543mod frame;