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