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;
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    boundary: StyleBuf,
128    point_base: StyleBuf,
129    point_selected: StyleBuf,
130    point_hovered: StyleBuf,
131    axis_x: StyleBuf,
132    axis_y: StyleBuf,
133    axis_z: StyleBuf,
134    /// Overlay-widget line width (CSS px in `params.x`); color is per-instance.
135    overlay_line: StyleBuf,
136}
137
138struct FaceRange {
139    first_index: u32,
140    index_count: u32,
141    /// Index into [`GpuSolid::face_styles`] when this face carries its OWN
142    /// colour (a `color` metadata attribute on the face name); `None` = shade
143    /// with the solid's `base_style`.
144    style: Option<u32>,
145}
146
147struct EdgeRange {
148    first_instance: u32,
149    instance_count: u32,
150}
151
152struct GpuSolid {
153    revision: u64,
154    vertex_buf: wgpu::Buffer,
155    index_buf: wgpu::Buffer,
156    /// Line-list indices for the wireframe view (each triangle → its 3 edges).
157    wire_index_buf: wgpu::Buffer,
158    wire_index_count: u32,
159    faces: Vec<FaceRange>,
160    edge_buf: Option<wgpu::Buffer>,
161    edges: Vec<EdgeRange>,
162    edge_instances: u32,
163    point_buf: Option<wgpu::Buffer>,
164    point_count: u32,
165    /// Per-solid base face style (uniform or hashed color; metadata override).
166    base_style: StyleBuf,
167    /// De-duplicated palette of PER-FACE styles — one entry per distinct face
168    /// colour on this solid, referenced by [`FaceRange::style`]. Empty for the
169    /// overwhelmingly common solid whose faces are all one colour, which is what
170    /// keeps the whole-mesh fast path in `frame.rs` alive.
171    face_styles: Vec<StyleBuf>,
172    /// Selected/hovered face boundary outline segments (cache keyed by the
173    /// emphasis generation).
174    boundary: Option<BoundaryBuf>,
175}
176
177struct BoundaryBuf {
178    emphasis_generation: u64,
179    revision: u64,
180    buf: Option<wgpu::Buffer>,
181    count: u32,
182}
183
184/// A scene uploaded to GPU buffers, retained across frames; sync with
185/// [`RenderCore::sync_scene`].
186#[derive(Default)]
187pub struct GpuScene {
188    solids: HashMap<String, GpuSolid>,
189    /// Draw order (scene insertion order at last sync).
190    order: Vec<String>,
191    settings_generation: u64,
192    axis_buf: Option<wgpu::Buffer>,
193    /// Count of solid (re)uploads (R10 observability: a reused solid must NOT
194    /// bump this across a sync).
195    uploads: u64,
196}
197
198impl GpuScene {
199    /// Total solid buffer uploads since creation (test/telemetry hook for the
200    /// reused-buffer fast path).
201    pub fn upload_count(&self) -> u64 {
202        self.uploads
203    }
204}
205
206struct CachedTargets {
207    width: u32,
208    height: u32,
209    msaa_view: wgpu::TextureView,
210    depth_view: wgpu::TextureView,
211}
212
213/// Everything a frame needs beyond the scene buffers.
214pub struct FrameParams<'a> {
215    pub camera: &'a Camera,
216    /// Physical pixel size of the target.
217    pub width: u32,
218    pub height: u32,
219    /// Device pixel ratio (CSS px → physical px) for line/point widths.
220    pub dpr: f32,
221    pub settings: &'a RenderSettings,
222    pub emphasis: &'a Emphasis,
223    /// World units per CSS pixel (for the screen-sized axis helper).
224    pub world_per_pixel: f64,
225    /// In-scene overlay widgets: transform gizmo, ViewCube, datum /
226    /// dimension / curve visuals. `None` on the artifact/headless path.
227    pub overlay: Option<&'a crate::widgets::WidgetOverlay>,
228}
229
230/// Create a headless device: adapter picked by `BREP_RENDER_ADAPTER`
231/// (case-insensitive substring of the adapter name, e.g. "llvmpipe") or wgpu's
232/// high-performance default. Native only — the browser shell gets its device
233/// from the canvas context.
234#[cfg(not(target_arch = "wasm32"))]
235pub fn create_headless_device() -> Result<(wgpu::Device, wgpu::Queue, String), String> {
236    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
237    let want = std::env::var("BREP_RENDER_ADAPTER").ok();
238    let adapter = if let Some(want) = &want {
239        let want_lower = want.to_lowercase();
240        pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
241            .into_iter()
242            .find(|adapter| adapter.get_info().name.to_lowercase().contains(&want_lower))
243            .ok_or_else(|| format!("no adapter matching BREP_RENDER_ADAPTER={want}"))?
244    } else {
245        pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
246            power_preference: wgpu::PowerPreference::HighPerformance,
247            compatible_surface: None,
248            force_fallback_adapter: false,
249        }))
250        .map_err(|error| format!("no wgpu adapter available: {error}"))?
251    };
252    let info = adapter.get_info();
253    let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
254        label: Some("brep-render"),
255        ..Default::default()
256    }))
257    .map_err(|error| format!("wgpu device request failed: {error}"))?;
258    Ok((device, queue, format!("{} ({:?})", info.name, info.backend)))
259}
260
261/// The window-agnostic renderer: pipelines + layouts for one color format.
262pub struct RenderCore {
263    pub device: wgpu::Device,
264    pub queue: wgpu::Queue,
265    format: wgpu::TextureFormat,
266    globals_buf: wgpu::Buffer,
267    globals_bind: wgpu::BindGroup,
268    style_layout: wgpu::BindGroupLayout,
269    mesh_pipeline: wgpu::RenderPipeline,
270    wire_pipeline: wgpu::RenderPipeline,
271    edge_visible_pipeline: wgpu::RenderPipeline,
272    edge_hidden_pipeline: wgpu::RenderPipeline,
273    point_pipeline: wgpu::RenderPipeline,
274    /// Overlay-widget passes: shaded per-vertex tris + screen-constant
275    /// per-instance lines, drawn over the solids in a depth-cleared pass.
276    overlay_tri_pipeline: wgpu::RenderPipeline,
277    /// Same as `overlay_tri_pipeline` but WITHOUT depth-write — for the datum/
278    /// construction PLANES, which are translucent UI aids that must NOT occlude
279    /// the gizmos/dimensions drawn after them (a plane no longer hides an offset
280    /// arrow behind it). The gizmo tris still use the depth-writing pipeline so
281    /// they self-occlude correctly.
282    overlay_tri_nodepth_pipeline: wgpu::RenderPipeline,
283    overlay_line_pipeline: wgpu::RenderPipeline,
284    /// Second globals buffer for the ViewCube pass (its own mini-camera).
285    vc_globals_buf: wgpu::Buffer,
286    vc_globals_bind: wgpu::BindGroup,
287    styles: GlobalStyles,
288    targets: Option<CachedTargets>,
289}
290
291/// 8-bit RGB PNG encode (no text/time chunks — byte-deterministic).
292#[cfg(not(target_arch = "wasm32"))]
293fn encode_png(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
294    let mut out = Vec::new();
295    {
296        let mut encoder = png::Encoder::new(&mut out, width, height);
297        encoder.set_color(png::ColorType::Rgb);
298        encoder.set_depth(png::BitDepth::Eight);
299        let mut writer = encoder
300            .write_header()
301            .map_err(|error| format!("png header: {error}"))?;
302        writer
303            .write_image_data(rgb)
304            .map_err(|error| format!("png data: {error}"))?;
305    }
306    Ok(out)
307}
308
309mod pipelines;
310mod scene_sync;
311mod frame;
312#[cfg(all(test, not(target_arch = "wasm32")))]
313mod tests;