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/// NDC depth nudge for boundary edges (they must win the z-fight with their
33/// own faces; faces also carry a rasterizer depth bias pushing them back).
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#[repr(C)]
45#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
46struct StyleParams {
47    color: [f32; 4],
48    params: [f32; 4],
49}
50
51#[repr(C)]
52#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
53struct MeshVertex {
54    position: [f32; 3],
55    normal: [f32; 3],
56}
57
58#[repr(C)]
59#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
60struct EdgeInstance {
61    p0: [f32; 3],
62    p1: [f32; 3],
63}
64
65#[repr(C)]
66#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
67struct PointInstance {
68    center: [f32; 3],
69}
70
71/// One overlay-widget line segment (per-instance colored, screen-constant width).
72#[repr(C)]
73#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
74struct OverlayLineInstance {
75    p0: [f32; 3],
76    p1: [f32; 3],
77    color: [f32; 4],
78}
79
80/// One overlay-widget triangle vertex (per-vertex colored, shaded).
81#[repr(C)]
82#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
83struct OverlayTriVertex {
84    position: [f32; 3],
85    normal: [f32; 3],
86    color: [f32; 4],
87}
88
89/// One uniform style buffer + its bind group (a material variant).
90struct StyleBuf {
91    buf: wgpu::Buffer,
92    bind: wgpu::BindGroup,
93}
94
95impl StyleBuf {
96    fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, label: &str) -> Self {
97        let buf = device.create_buffer(&wgpu::BufferDescriptor {
98            label: Some(label),
99            size: std::mem::size_of::<StyleParams>() as u64,
100            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
101            mapped_at_creation: false,
102        });
103        let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
104            label: Some(label),
105            layout,
106            entries: &[wgpu::BindGroupEntry {
107                binding: 0,
108                resource: buf.as_entire_binding(),
109            }],
110        });
111        Self { buf, bind }
112    }
113
114    fn write(&self, queue: &wgpu::Queue, color: Rgba, params: [f32; 4]) {
115        queue.write_buffer(&self.buf, 0, bytemuck::bytes_of(&StyleParams { color, params }));
116    }
117}
118
119/// The global material variants (updated from [`RenderSettings`] each frame).
120struct GlobalStyles {
121    face_selected: StyleBuf,
122    face_hovered: StyleBuf,
123    edge_base: StyleBuf,
124    edge_selected: StyleBuf,
125    edge_hovered: StyleBuf,
126    edge_hidden: StyleBuf,
127    /// A display-only (AUX) edge at rest — a board's copper outline.
128    edge_aux: StyleBuf,
129    boundary: StyleBuf,
130    point_base: StyleBuf,
131    point_selected: StyleBuf,
132    point_hovered: StyleBuf,
133    axis_x: StyleBuf,
134    axis_y: StyleBuf,
135    axis_z: StyleBuf,
136    /// Overlay-widget line width (CSS px in `params.x`); color is per-instance.
137    overlay_line: StyleBuf,
138}
139
140struct FaceRange {
141    first_index: u32,
142    index_count: u32,
143    /// This face's slice of the WIREFRAME index buffer. Not derivable from
144    /// `first_index`: the wire buffer holds one segment per DISTINCT mesh edge
145    /// of the face, so its length depends on how the face's triangles share
146    /// edges, not on how many there are.
147    wire_first: u32,
148    wire_count: u32,
149    /// Index into [`GpuSolid::face_styles`] when this face carries its OWN
150    /// colour (a `color` metadata attribute on the face name); `None` = shade
151    /// with the solid's `base_style`.
152    style: Option<u32>,
153}
154
155/// The hasher behind the wireframe's per-face edge de-duplication.
156///
157/// The keys are pairs of mesh vertex INDICES — dense, small, and entirely under
158/// our control, so there is no adversary for SipHash to defend against, and its
159/// per-key cost is the whole cost of the de-duplication (three probes per
160/// triangle, at every solid upload). This is the multiply-xor mix `rustc-hash`
161/// uses, written out rather than depended on: `rustc-hash` only rides into this
162/// build through wgpu's internals, and a published crate should not take a
163/// direct dependency for fifteen lines.
164#[derive(Default)]
165struct EdgeHasher(u64);
166
167impl std::hash::Hasher for EdgeHasher {
168    fn write(&mut self, bytes: &[u8]) {
169        for &byte in bytes {
170            self.write_u32(u32::from(byte));
171        }
172    }
173
174    fn write_u32(&mut self, value: u32) {
175        // 2^64 / golden ratio: the odd multiplier spreads low-index bits into
176        // the high ones, which is where `finish` folds them back down from.
177        self.0 = (self.0 ^ u64::from(value)).wrapping_mul(0x9E37_79B9_7F4A_7C15);
178    }
179
180    fn finish(&self) -> u64 {
181        self.0 ^ (self.0 >> 32)
182    }
183}
184
185type BuildEdgeHasher = std::hash::BuildHasherDefault<EdgeHasher>;
186
187struct EdgeRange {
188    first_instance: u32,
189    instance_count: u32,
190}
191
192struct GpuSolid {
193    revision: u64,
194    vertex_buf: wgpu::Buffer,
195    index_buf: wgpu::Buffer,
196    /// Line-list indices for the wireframe view (each triangle → its 3 edges).
197    wire_index_buf: wgpu::Buffer,
198    wire_index_count: u32,
199    faces: Vec<FaceRange>,
200    edge_buf: Option<wgpu::Buffer>,
201    edges: Vec<EdgeRange>,
202    edge_instances: u32,
203    /// The instances drawn by the OCCLUDED-edge pass: every edge's up to the
204    /// first AUX one. A display-only outline (a board's copper) is not dimmed
205    /// through what hides it — the bottom copper would ghost through the board
206    /// — and aux edges trail the real ones, so it is a prefix.
207    occludable_edge_instances: u32,
208    point_buf: Option<wgpu::Buffer>,
209    point_count: u32,
210    /// Per-solid base face style (uniform or hashed color; metadata override).
211    base_style: StyleBuf,
212    /// De-duplicated palette of PER-FACE styles — one entry per distinct face
213    /// colour on this solid, referenced by [`FaceRange::style`]. Empty for the
214    /// overwhelmingly common solid whose faces are all one colour, which is what
215    /// keeps the whole-mesh fast path in `frame.rs` alive.
216    face_styles: Vec<StyleBuf>,
217    /// Selected/hovered face boundary outline segments (cache keyed by the
218    /// emphasis generation).
219    boundary: Option<BoundaryBuf>,
220}
221
222struct BoundaryBuf {
223    emphasis_generation: u64,
224    revision: u64,
225    buf: Option<wgpu::Buffer>,
226    count: u32,
227}
228
229/// A scene uploaded to GPU buffers, retained across frames; sync with
230/// [`RenderCore::sync_scene`].
231#[derive(Default)]
232pub struct GpuScene {
233    solids: HashMap<String, GpuSolid>,
234    /// Draw order (scene insertion order at last sync).
235    order: Vec<String>,
236    settings_generation: u64,
237    axis_buf: Option<wgpu::Buffer>,
238    /// Count of solid (re)uploads (R10 observability: a reused solid must NOT
239    /// bump this across a sync).
240    uploads: u64,
241}
242
243impl GpuScene {
244    /// Total solid buffer uploads since creation (test/telemetry hook for the
245    /// reused-buffer fast path).
246    pub fn upload_count(&self) -> u64 {
247        self.uploads
248    }
249}
250
251struct CachedTargets {
252    width: u32,
253    height: u32,
254    msaa_view: wgpu::TextureView,
255    depth_view: wgpu::TextureView,
256}
257
258/// Everything a frame needs beyond the scene buffers.
259pub struct FrameParams<'a> {
260    pub camera: &'a Camera,
261    /// Physical pixel size of the target.
262    pub width: u32,
263    pub height: u32,
264    /// Device pixel ratio (CSS px → physical px) for line/point widths.
265    pub dpr: f32,
266    pub settings: &'a RenderSettings,
267    pub emphasis: &'a Emphasis,
268    /// World units per CSS pixel (for the screen-sized axis helper).
269    pub world_per_pixel: f64,
270    /// In-scene overlay widgets: transform gizmo, ViewCube, datum /
271    /// dimension / curve visuals. `None` on the artifact/headless path.
272    pub overlay: Option<&'a crate::widgets::WidgetOverlay>,
273}
274
275/// Create a headless device: adapter picked by `BREP_RENDER_ADAPTER`
276/// (case-insensitive substring of the adapter name, e.g. "llvmpipe") or wgpu's
277/// high-performance default. Native only — the browser shell gets its device
278/// from the canvas context.
279#[cfg(not(target_arch = "wasm32"))]
280pub fn create_headless_device() -> Result<(wgpu::Device, wgpu::Queue, String), String> {
281    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
282    let want = std::env::var("BREP_RENDER_ADAPTER").ok();
283    let adapter = if let Some(want) = &want {
284        let want_lower = want.to_lowercase();
285        pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
286            .into_iter()
287            .find(|adapter| adapter.get_info().name.to_lowercase().contains(&want_lower))
288            .ok_or_else(|| format!("no adapter matching BREP_RENDER_ADAPTER={want}"))?
289    } else {
290        pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
291            power_preference: wgpu::PowerPreference::HighPerformance,
292            compatible_surface: None,
293            force_fallback_adapter: false,
294        }))
295        .map_err(|error| format!("no wgpu adapter available: {error}"))?
296    };
297    let info = adapter.get_info();
298    let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
299        label: Some("brep-render"),
300        ..Default::default()
301    }))
302    .map_err(|error| format!("wgpu device request failed: {error}"))?;
303    Ok((device, queue, format!("{} ({:?})", info.name, info.backend)))
304}
305
306/// The window-agnostic renderer: pipelines + layouts for one color format.
307pub struct RenderCore {
308    pub device: wgpu::Device,
309    pub queue: wgpu::Queue,
310    format: wgpu::TextureFormat,
311    globals_buf: wgpu::Buffer,
312    globals_bind: wgpu::BindGroup,
313    style_layout: wgpu::BindGroupLayout,
314    mesh_pipeline: wgpu::RenderPipeline,
315    wire_pipeline: wgpu::RenderPipeline,
316    edge_visible_pipeline: wgpu::RenderPipeline,
317    edge_hidden_pipeline: wgpu::RenderPipeline,
318    point_pipeline: wgpu::RenderPipeline,
319    /// Overlay-widget passes: shaded per-vertex tris + screen-constant
320    /// per-instance lines, drawn over the solids in a depth-cleared pass.
321    overlay_tri_pipeline: wgpu::RenderPipeline,
322    /// Same as `overlay_tri_pipeline` but WITHOUT depth-write — for the datum/
323    /// construction PLANES, which are translucent UI aids that must NOT occlude
324    /// the gizmos/dimensions drawn after them (a plane no longer hides an offset
325    /// arrow behind it). The gizmo tris still use the depth-writing pipeline so
326    /// they self-occlude correctly.
327    overlay_tri_nodepth_pipeline: wgpu::RenderPipeline,
328    overlay_line_pipeline: wgpu::RenderPipeline,
329    /// Second globals buffer for the ViewCube pass (its own mini-camera).
330    vc_globals_buf: wgpu::Buffer,
331    vc_globals_bind: wgpu::BindGroup,
332    styles: GlobalStyles,
333    targets: Option<CachedTargets>,
334}
335
336/// 8-bit RGB PNG encode (no text/time chunks — byte-deterministic).
337#[cfg(not(target_arch = "wasm32"))]
338fn encode_png(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
339    let mut out = Vec::new();
340    {
341        let mut encoder = png::Encoder::new(&mut out, width, height);
342        encoder.set_color(png::ColorType::Rgb);
343        encoder.set_depth(png::BitDepth::Eight);
344        let mut writer = encoder
345            .write_header()
346            .map_err(|error| format!("png header: {error}"))?;
347        writer
348            .write_image_data(rgb)
349            .map_err(|error| format!("png data: {error}"))?;
350    }
351    Ok(out)
352}
353
354mod pipelines;
355mod scene_sync;
356mod frame;