Skip to main content

brepkit_render/
compute_mesh.rs

1//! GPU compute-shader mesher for analytic quadric surfaces.
2//!
3//! brepkit emits exact analytic surfaces (cylinder, cone, sphere, torus).
4//! Rather than CPU-tessellating one into thousands of triangles and uploading
5//! them, this path uploads the surface's *parameters* and lets a WGSL compute
6//! shader evaluate the parametric surface into a vertex grid at a caller-chosen
7//! tessellation factor (the LOD knob). The compute output then feeds the same
8//! offscreen mesh draw pass as the solid path (`shaders/mesh.wgsl`).
9//!
10//! WebGPU/wgpu have no tessellation or mesh shaders, so the per-vertex
11//! evaluation runs in a compute pass. This module currently meshes a cylinder;
12//! the descriptor + shader generalize to the other quadrics (see the crate
13//! docs for the extension plan).
14//!
15//! # Precision
16//!
17//! As with the solid path, positions are emitted relative to the model center
18//! (RTC) and the f64 center is folded into the camera matrix on the CPU, so the
19//! GPU never sees large absolute coordinates.
20
21use std::f64::consts::TAU;
22
23use bytemuck::{Pod, Zeroable};
24use wgpu::util::DeviceExt;
25
26use brepkit_math::surfaces::CylindricalSurface;
27use brepkit_math::vec::{Point3, Vec3};
28use brepkit_topology::Topology;
29use brepkit_topology::face::{FaceId, FaceSurface};
30
31use crate::camera::Camera;
32use crate::error::RenderError;
33use crate::pipeline;
34use crate::{RenderOpts, RenderOutput};
35
36/// Upper bound on each tessellation dimension.
37///
38/// Caps `n_u`/`n_v` so the derived vertex and index counts stay far below
39/// `u32::MAX` (the worst case `MAX_TESS² · 6 ≈ 1.6e9` indices) and so a single
40/// quadric can never request an absurd buffer. Already well past any sane LOD
41/// for one surface (a 16384-gon cross section is sub-pixel at any zoom).
42const MAX_TESS: u32 = 16_384;
43
44/// Words per emitted vertex in the flat `out_verts` storage buffer. Must match
45/// `WORDS_PER_VERT` in `quadric_mesh.wgsl` and the 28-byte draw `Vertex` stride:
46/// pos(3) + normal(3) + face_id(1).
47const WORDS_PER_VERT: u64 = 7;
48
49/// Tessellation factor (level of detail) for the compute mesher.
50///
51/// `n_u` angular steps around the surface and `n_v` steps along it. Higher
52/// values produce more triangles and a rounder silhouette at the cost of a
53/// larger vertex buffer; for a cylinder the chord error of the circular cross
54/// section falls off as `1 - cos(π / n_u)`.
55#[derive(Debug, Clone, Copy)]
56pub struct TessFactor {
57    /// Angular subdivisions around the surface (clamped to `[3, MAX_TESS]`).
58    pub n_u: u32,
59    /// Axial subdivisions along the surface (clamped to `[1, MAX_TESS]`).
60    pub n_v: u32,
61}
62
63impl TessFactor {
64    /// Create a tessellation factor, clamping each dimension into the range
65    /// that yields a non-degenerate closed mesh without overflowing the GPU
66    /// buffer index math: `n_u ∈ [3, MAX_TESS]`, `n_v ∈ [1, MAX_TESS]`.
67    #[must_use]
68    pub fn new(n_u: u32, n_v: u32) -> Self {
69        Self {
70            n_u: n_u.clamp(3, MAX_TESS),
71            n_v: n_v.clamp(1, MAX_TESS),
72        }
73    }
74}
75
76/// Default screen-space chord-error budget, in pixels.
77///
78/// Sub-pixel: the faceting of a cylinder tessellated to this bound is invisible
79/// at the rendered resolution.
80pub const DEFAULT_TARGET_PX: f64 = 0.5;
81
82/// Derive a [`TessFactor`] from the cylinder's *projected screen size* so the
83/// silhouette's chord error stays within `target_px` pixels at the given view.
84///
85/// A zoomed-in cylinder (large projected radius) gets a fine mesh; a distant one
86/// (small projected radius) gets a coarse mesh — view-dependent LOD, the payoff
87/// of meshing analytic surfaces on the GPU from their parameters.
88///
89/// # Math
90///
91/// The chord error of an `n_u`-gon inscribed in a circle of radius `r` is
92/// `ε = r·(1 − cos(π/n_u))`. Projecting `r` to pixels under perspective,
93/// `r_px = r · (H/2) / (d · tan(fov_y/2))` where `H` is the viewport height and
94/// `d` is the center's *view-space depth* (its projection onto the view
95/// direction, `view_dir · (center − eye)`) — not the Euclidean eye distance, so
96/// an off-axis cylinder at the same depth is not under-tessellated. Bounding the
97/// *screen-space* error `r_px·(1 − cos(π/n_u)) ≤ target_px` and solving:
98/// `n_u = ceil(π / acos(1 − clamp(target_px / r_px, 0, 2)))`. A sub-pixel
99/// cylinder (`r_px ≤ target_px`) floors to the [`TessFactor`] minimum; a cylinder
100/// engulfing the camera (`r_px → ∞`) requests the maximum.
101///
102/// `n_v` is fixed at 1: a cylinder's lateral face is *ruled* (straight and of
103/// constant normal along the axis), so one axial division is geometrically and
104/// shading-exact. Sphere/torus surfaces will later need `n_v` adaptivity too,
105/// since they curve in both parametric directions.
106///
107/// The result always passes through [`TessFactor::new`], so the
108/// `[3, MAX_TESS]` clamp and the buffer-overflow guard still apply.
109#[must_use]
110pub fn screen_space_tess_factor(
111    desc: &CylinderDescriptor,
112    cam: &Camera,
113    viewport: (u32, u32),
114    target_px: f64,
115) -> TessFactor {
116    let n_u = angular_subdivisions_for_screen_error(desc, cam, viewport, target_px);
117    TessFactor::new(n_u, 1)
118}
119
120/// Angular subdivisions needed to keep the projected chord error within
121/// `target_px`. Returns a raw count (the caller clamps via [`TessFactor::new`]).
122///
123/// Edge cases collapse so the clamp lands on a valid factor: an unbounded
124/// projection (`r_px → ∞`, the camera engulfed by the surface) → the maximum;
125/// a sub-pixel projection, a center behind the camera, or a non-finite/≤0 budget
126/// → the minimum.
127fn angular_subdivisions_for_screen_error(
128    desc: &CylinderDescriptor,
129    cam: &Camera,
130    viewport: (u32, u32),
131    target_px: f64,
132) -> u32 {
133    let (_, height) = viewport;
134
135    // Clamp the FOV into the valid open interval `(0, π)` so `tan(fov/2)` is
136    // always finite-positive (matching a sane render); an out-of-range fov must
137    // not poison the projection.
138    let fov_y = cam.fov_y.clamp(1.0e-4, std::f64::consts::PI - 1.0e-4);
139    let half_fov_tan = (fov_y * 0.5).tan();
140
141    // Perspective scale is set by the *view-space depth* of the center (its
142    // projection onto the view axis), not the Euclidean eye distance: an
143    // off-axis cylinder at the same depth must not be under-tessellated.
144    let depth = cam.view_direction().dot(desc.center - cam.eye);
145
146    // A non-finite or non-positive budget can't bound anything → max detail.
147    if !(target_px.is_finite() && target_px > 0.0) {
148        return MAX_TESS;
149    }
150    let r_px = desc.radius * (f64::from(height) * 0.5) / (depth * half_fov_tan);
151    // Classify the projected radius:
152    //   +∞  → the surface engulfs/fills the screen (depth → 0): finest mesh.
153    //   ≤ 0 or NaN → behind the camera or degenerate: won't render → coarsest.
154    //   finite > 0 → the normal screen-size formula below.
155    if r_px.is_infinite() && r_px > 0.0 {
156        return MAX_TESS;
157    }
158    if !(r_px.is_finite() && r_px > 0.0) {
159        return 3;
160    }
161
162    // ratio ∈ [0, 2] keeps the acos argument (1 − ratio) in [−1, 1].
163    let ratio = (target_px / r_px).clamp(0.0, 2.0);
164    let theta = (1.0 - ratio).acos(); // half the per-facet angle bound
165    if !(theta.is_finite() && theta > 0.0) {
166        // r_px ≤ target_px (sub-pixel facets already): minimum tessellation.
167        return 3;
168    }
169    let n = (std::f64::consts::PI / theta).ceil();
170    // n is finite and ≥ 1 here; clamp into u32 range before TessFactor re-clamps.
171    if n >= f64::from(MAX_TESS) {
172        MAX_TESS
173    } else {
174        // Safe: 1 ≤ n < MAX_TESS ≤ u32::MAX, and n is finite.
175        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
176        let v = n as u32;
177        v
178    }
179}
180
181/// A cylinder surface packed for GPU evaluation.
182///
183/// Extracted from a [`FaceSurface::Cylinder`] face via
184/// [`extract_cylinder_descriptor`]: the axis frame and radius come from the
185/// [`CylindricalSurface`]; the parametric trim range (`v0..v1` axial,
186/// `u0..u1` angular) comes from the face's boundary.
187#[derive(Debug, Clone, Copy)]
188pub struct CylinderDescriptor {
189    /// RTC origin — positions are emitted relative to this point. Defaults to
190    /// the descriptor's own AABB center via [`extract_cylinder_descriptor`].
191    pub center: Point3,
192    /// Cylinder axis origin (a point on the axis at `v = 0`).
193    pub axis_origin: Point3,
194    /// Cylinder axis direction (unit, points along increasing `v`).
195    pub axis: Vec3,
196    /// First radial reference direction (unit; `u = 0` points here).
197    pub x_ref: Vec3,
198    /// Second radial reference direction (unit; `axis × x_ref`).
199    pub y_ref: Vec3,
200    /// Cylinder radius.
201    pub radius: f64,
202    /// Axial parameter at the lower trim boundary.
203    pub v0: f64,
204    /// Axial parameter at the upper trim boundary.
205    pub v1: f64,
206    /// Angular parameter at the start of the trim (radians).
207    pub u0: f64,
208    /// Angular parameter at the end of the trim (radians); `u1 - u0 == 2π` for
209    /// a full cylinder.
210    pub u1: f64,
211}
212
213impl CylinderDescriptor {
214    /// World-space point on the surface at parameters `(u, v)`, the same
215    /// parameterization the GPU shader evaluates:
216    /// `pos(u, v) = axis_origin + radius·(cos u · x_ref + sin u · y_ref) + v·axis`.
217    ///
218    /// Note this returns an absolute world point; the GPU emits it minus
219    /// [`center`](Self::center) (RTC). Used to compute the descriptor's AABB and
220    /// available for CPU-side geometric checks.
221    #[must_use]
222    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
223        let radial = self.x_ref * (self.radius * u.cos()) + self.y_ref * (self.radius * u.sin());
224        self.axis_origin + radial + self.axis * v
225    }
226
227    /// Axis-aligned bounding box of the trimmed cylinder, sampled around the
228    /// angular range and across the two axial caps.
229    fn aabb(&self) -> (Point3, Point3) {
230        let mut min = [f64::INFINITY; 3];
231        let mut max = [f64::NEG_INFINITY; 3];
232        let samples = 64;
233        for k in 0..=samples {
234            let t = f64::from(k) / f64::from(samples);
235            let u = self.u0 + (self.u1 - self.u0) * t;
236            for &v in &[self.v0, self.v1] {
237                let p = self.evaluate(u, v);
238                let c = [p.x(), p.y(), p.z()];
239                for axis in 0..3 {
240                    min[axis] = min[axis].min(c[axis]);
241                    max[axis] = max[axis].max(c[axis]);
242                }
243            }
244        }
245        (
246            Point3::new(min[0], min[1], min[2]),
247            Point3::new(max[0], max[1], max[2]),
248        )
249    }
250
251    /// Number of triangles a given tessellation factor produces (`2·n_u·n_v`).
252    #[must_use]
253    pub fn triangle_count(tess: TessFactor) -> usize {
254        2 * tess.n_u as usize * tess.n_v as usize
255    }
256}
257
258/// Extract a [`CylinderDescriptor`] from a cylindrical face.
259///
260/// Reads the [`CylindricalSurface`] frame and radius, then derives the axial
261/// trim range `v0..v1` by projecting the face's outer-wire vertices onto the
262/// axis. The angular range is taken as a full revolution (`0..2π`) — the M2
263/// scope is a full cylinder (e.g. [`make_cylinder`](brepkit_operations::primitives::make_cylinder)),
264/// whose lateral face wraps the entire circle via a degenerate seam wire.
265///
266/// `center` is set to the descriptor's own AABB center, so the returned
267/// descriptor renders correctly on its own.
268///
269/// # Errors
270///
271/// - [`RenderError::Operations`] if `face` is not a cylindrical face.
272/// - [`RenderError::Topology`] if the face's wire/edge/vertex lookups fail.
273pub fn extract_cylinder_descriptor(
274    topo: &Topology,
275    face: FaceId,
276) -> Result<CylinderDescriptor, RenderError> {
277    let face_data = topo.face(face)?;
278    let FaceSurface::Cylinder(cyl) = face_data.surface() else {
279        return Err(RenderError::Operations(
280            brepkit_operations::OperationsError::InvalidInput {
281                reason: "extract_cylinder_descriptor: face is not a cylindrical surface".into(),
282            },
283        ));
284    };
285
286    let (v0, v1) = axial_range(topo, face, cyl)?;
287
288    let mut desc = CylinderDescriptor {
289        center: Point3::new(0.0, 0.0, 0.0),
290        axis_origin: cyl.origin(),
291        axis: cyl.axis(),
292        x_ref: cyl.x_axis(),
293        y_ref: cyl.y_axis(),
294        radius: cyl.radius(),
295        v0,
296        v1,
297        u0: 0.0,
298        u1: TAU,
299    };
300    let (min, max) = desc.aabb();
301    desc.center = Point3::new(
302        (min.x() + max.x()) * 0.5,
303        (min.y() + max.y()) * 0.5,
304        (min.z() + max.z()) * 0.5,
305    );
306    Ok(desc)
307}
308
309/// Project every vertex of the face's outer wire onto the cylinder axis to find
310/// the axial parameter span `[v0, v1]`.
311fn axial_range(
312    topo: &Topology,
313    face: FaceId,
314    cyl: &CylindricalSurface,
315) -> Result<(f64, f64), RenderError> {
316    let face_data = topo.face(face)?;
317    let wire = topo.wire(face_data.outer_wire())?;
318    let axis = cyl.axis();
319    let origin = cyl.origin();
320
321    let mut min_v = f64::INFINITY;
322    let mut max_v = f64::NEG_INFINITY;
323    for oe in wire.edges() {
324        let edge = topo.edge(oe.edge())?;
325        for vid in [edge.start(), edge.end()] {
326            let p = topo.vertex(vid)?.point();
327            let v = axis.dot(p - origin);
328            min_v = min_v.min(v);
329            max_v = max_v.max(v);
330        }
331    }
332    if !(min_v.is_finite() && max_v.is_finite()) || (max_v - min_v).abs() < f64::EPSILON {
333        return Err(RenderError::Operations(
334            brepkit_operations::OperationsError::InvalidInput {
335                reason: "extract_cylinder_descriptor: degenerate axial range on cylinder face"
336                    .into(),
337            },
338        ));
339    }
340    Ok((min_v, max_v))
341}
342
343/// GPU descriptor uniform. Field order and padding match the WGSL `Descriptor`
344/// struct in `quadric_mesh.wgsl` (vec3 fields are 16-byte aligned, with the
345/// trailing scalar packed into the 4th word of each 16-byte slot; the final
346/// group fills its 16 bytes exactly).
347#[repr(C)]
348#[derive(Debug, Clone, Copy, Pod, Zeroable)]
349struct GpuDescriptor {
350    center: [f32; 3],
351    radius: f32,
352    axis_origin: [f32; 3],
353    v0: f32,
354    axis: [f32; 3],
355    v1: f32,
356    x_ref: [f32; 3],
357    u0: f32,
358    y_ref: [f32; 3],
359    u1: f32,
360    n_u: u32,
361    n_v: u32,
362    face_id: u32,
363    /// `1` for a full revolution (seam columns shared), `0` for a partial arc.
364    /// Computed once on the CPU so the two compute entry points never re-derive
365    /// the `(span ≈ 2π)` test in f32 (which could disagree with this f64 one).
366    full: u32,
367}
368
369/// Render a compute-meshed cylinder offscreen to a shaded color image + face-id
370/// buffer.
371///
372/// The cylinder is meshed entirely on the GPU from `desc` at the `tess` LOD: a
373/// compute pass evaluates the parametric surface into vertex + index storage
374/// buffers, which are then drawn by the same offscreen mesh pass as the solid
375/// path. Every emitted vertex carries `face_id` (use `1` if you have no real
376/// face).
377///
378/// # Errors
379///
380/// - [`RenderError::InvalidSize`] if `opts.width` or `opts.height` is zero.
381/// - [`RenderError::NoAdapter`] / [`RenderError::DeviceRequest`] on GPU setup.
382/// - [`RenderError::BufferMap`] / [`RenderError::Poll`] on readback failure.
383#[allow(clippy::too_many_lines)]
384pub fn render_cylinder_compute_offscreen(
385    desc: &CylinderDescriptor,
386    tess: TessFactor,
387    face_id: u32,
388    cam: &Camera,
389    opts: &RenderOpts,
390) -> Result<RenderOutput, RenderError> {
391    if opts.width == 0 || opts.height == 0 {
392        return Err(RenderError::InvalidSize {
393            width: opts.width,
394            height: opts.height,
395        });
396    }
397
398    // Normalize the factor at the boundary: the public `TessFactor::new` clamps
399    // to `[3, MAX_TESS]` / `[1, MAX_TESS]`, but the fields are `pub`, so a
400    // struct-literal could bypass it. Re-clamping here makes every downstream
401    // count (and the u32 index cast) provably within range.
402    let tess = TessFactor::new(tess.n_u, tess.n_v);
403
404    let instance = wgpu::Instance::default();
405    let (_adapter, device, queue) = pipeline::acquire_device(&instance, None)?;
406
407    // Reject oversized targets with a clean error rather than tripping wgpu's
408    // internal validation (mirrors the solid path).
409    let max = device.limits().max_texture_dimension_2d;
410    if opts.width > max || opts.height > max {
411        return Err(RenderError::SizeTooLarge {
412            width: opts.width,
413            height: opts.height,
414            max,
415        });
416    }
417
418    // --- Grid sizing -------------------------------------------------------
419    // `full` is the single source of truth for the seam decision: it is uploaded
420    // to the shader (see GpuDescriptor::full) so the two compute entry points
421    // never recompute the `(span ≈ 2π)` test in f32. A full revolution shares
422    // the u = 0 / u = 2π columns, so it emits only `n_u` columns (the wrap quad
423    // reuses column 0); a partial arc emits `n_u + 1`.
424    let full = (desc.u1 - desc.u0 - TAU).abs() < 1.0e-6;
425    let cols = if full { tess.n_u } else { tess.n_u + 1 };
426    let rows = tess.n_v + 1;
427    // With both dims clamped to MAX_TESS, the worst case is MAX_TESS²·6 ≈ 1.6e9,
428    // comfortably inside u32, so the index cast for `draw_indexed` cannot wrap.
429    let vertex_count = u64::from(cols) * u64::from(rows);
430    let index_count = u64::from(tess.n_u) * u64::from(tess.n_v) * 6;
431    // The MAX_TESS clamp guarantees this fits in u32 (worst case ≈ 1.6e9); the
432    // checked `try_from` keeps the draw count from ever silently wrapping even if
433    // that invariant is later weakened.
434    let index_count_u32 = u32::try_from(index_count).unwrap_or(u32::MAX);
435    let vert_bytes = vertex_count * WORDS_PER_VERT * 4; // 4 bytes per u32 word
436    let index_bytes = index_count * 4;
437
438    // --- Descriptor uniform -----------------------------------------------
439    #[allow(clippy::cast_possible_truncation)]
440    let gpu_desc = GpuDescriptor {
441        center: pt_f32(desc.center),
442        radius: desc.radius as f32,
443        axis_origin: pt_f32(desc.axis_origin),
444        v0: desc.v0 as f32,
445        axis: vec_f32(desc.axis),
446        v1: desc.v1 as f32,
447        x_ref: vec_f32(desc.x_ref),
448        u0: desc.u0 as f32,
449        y_ref: vec_f32(desc.y_ref),
450        u1: desc.u1 as f32,
451        n_u: tess.n_u,
452        n_v: tess.n_v,
453        face_id,
454        full: u32::from(full),
455    };
456    let desc_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
457        label: Some("cylinder descriptor"),
458        contents: bytemuck::bytes_of(&gpu_desc),
459        usage: wgpu::BufferUsages::UNIFORM,
460    });
461
462    // --- Compute output buffers (also used directly as draw inputs) --------
463    let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor {
464        label: Some("compute vertices"),
465        size: vert_bytes,
466        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
467        mapped_at_creation: false,
468    });
469    let index_buf = device.create_buffer(&wgpu::BufferDescriptor {
470        label: Some("compute indices"),
471        size: index_bytes,
472        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDEX,
473        mapped_at_creation: false,
474    });
475
476    // --- Compute pipeline --------------------------------------------------
477    let compute_shader =
478        device.create_shader_module(wgpu::include_wgsl!("../shaders/quadric_mesh.wgsl"));
479    let compute_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
480        label: Some("compute mesher layout"),
481        entries: &[
482            wgpu::BindGroupLayoutEntry {
483                binding: 0,
484                visibility: wgpu::ShaderStages::COMPUTE,
485                ty: wgpu::BindingType::Buffer {
486                    ty: wgpu::BufferBindingType::Uniform,
487                    has_dynamic_offset: false,
488                    min_binding_size: None,
489                },
490                count: None,
491            },
492            storage_entry(1),
493            storage_entry(2),
494        ],
495    });
496    let compute_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
497        label: Some("compute mesher bind group"),
498        layout: &compute_bgl,
499        entries: &[
500            wgpu::BindGroupEntry {
501                binding: 0,
502                resource: desc_buf.as_entire_binding(),
503            },
504            wgpu::BindGroupEntry {
505                binding: 1,
506                resource: vertex_buf.as_entire_binding(),
507            },
508            wgpu::BindGroupEntry {
509                binding: 2,
510                resource: index_buf.as_entire_binding(),
511            },
512        ],
513    });
514    let compute_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
515        label: Some("compute pipeline layout"),
516        bind_group_layouts: &[Some(&compute_bgl)],
517        immediate_size: 0,
518    });
519    let vertex_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
520        label: Some("cylinder vertex mesher"),
521        layout: Some(&compute_layout),
522        module: &compute_shader,
523        entry_point: Some("cs_vertices"),
524        compilation_options: wgpu::PipelineCompilationOptions::default(),
525        cache: None,
526    });
527    let index_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
528        label: Some("cylinder index mesher"),
529        layout: Some(&compute_layout),
530        module: &compute_shader,
531        entry_point: Some("cs_indices"),
532        compilation_options: wgpu::PipelineCompilationOptions::default(),
533        cache: None,
534    });
535
536    // --- Draw resources (shared mesh shader) -------------------------------
537    let draw = build_draw_resources(&device, desc, cam, opts);
538
539    // --- Targets -----------------------------------------------------------
540    let (width, height) = (opts.width, opts.height);
541    let targets = RenderTargets::new(&device, width, height);
542
543    // --- Encode ------------------------------------------------------------
544    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
545        label: Some("compute + draw encoder"),
546    });
547    {
548        let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
549            label: Some("cylinder mesher"),
550            timestamp_writes: None,
551        });
552        cpass.set_bind_group(0, &compute_bind_group, &[]);
553        let groups_x = cols.div_ceil(8).max(1);
554        let groups_y = rows.div_ceil(8).max(1);
555        cpass.set_pipeline(&vertex_pipeline);
556        cpass.dispatch_workgroups(groups_x, groups_y, 1);
557        let igx = tess.n_u.div_ceil(8).max(1);
558        let igy = tess.n_v.div_ceil(8).max(1);
559        cpass.set_pipeline(&index_pipeline);
560        cpass.dispatch_workgroups(igx, igy, 1);
561    }
562
563    {
564        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
565            label: Some("compute mesh pass"),
566            color_attachments: &[
567                Some(wgpu::RenderPassColorAttachment {
568                    view: &targets.color_view,
569                    depth_slice: None,
570                    resolve_target: None,
571                    ops: wgpu::Operations {
572                        load: wgpu::LoadOp::Clear(wgpu::Color {
573                            r: f64::from(opts.background[0]),
574                            g: f64::from(opts.background[1]),
575                            b: f64::from(opts.background[2]),
576                            a: f64::from(opts.background[3]),
577                        }),
578                        store: wgpu::StoreOp::Store,
579                    },
580                }),
581                Some(wgpu::RenderPassColorAttachment {
582                    view: &targets.id_view,
583                    depth_slice: None,
584                    resolve_target: None,
585                    ops: wgpu::Operations {
586                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
587                        store: wgpu::StoreOp::Store,
588                    },
589                }),
590            ],
591            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
592                view: &targets.depth_view,
593                depth_ops: Some(wgpu::Operations {
594                    load: wgpu::LoadOp::Clear(1.0),
595                    store: wgpu::StoreOp::Store,
596                }),
597                stencil_ops: None,
598            }),
599            timestamp_writes: None,
600            occlusion_query_set: None,
601            multiview_mask: None,
602        });
603        pass.set_bind_group(0, &draw.bind_group, &[]);
604        pass.set_pipeline(&draw.pipeline);
605        pass.set_vertex_buffer(0, vertex_buf.slice(..));
606        pass.set_index_buffer(index_buf.slice(..), wgpu::IndexFormat::Uint32);
607        pass.draw_indexed(0..index_count_u32, 0, 0..1);
608    }
609
610    let color_bpr = pipeline::padded_bytes_per_row(width, 4);
611    let id_bpr = pipeline::padded_bytes_per_row(width, 4);
612    let color_readback = device.create_buffer(&wgpu::BufferDescriptor {
613        label: Some("color readback"),
614        size: u64::from(color_bpr) * u64::from(height),
615        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
616        mapped_at_creation: false,
617    });
618    let id_readback = device.create_buffer(&wgpu::BufferDescriptor {
619        label: Some("id readback"),
620        size: u64::from(id_bpr) * u64::from(height),
621        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
622        mapped_at_creation: false,
623    });
624    let extent = wgpu::Extent3d {
625        width,
626        height,
627        depth_or_array_layers: 1,
628    };
629    encoder.copy_texture_to_buffer(
630        wgpu::TexelCopyTextureInfo {
631            texture: &targets.color_tex,
632            mip_level: 0,
633            origin: wgpu::Origin3d::ZERO,
634            aspect: wgpu::TextureAspect::All,
635        },
636        wgpu::TexelCopyBufferInfo {
637            buffer: &color_readback,
638            layout: wgpu::TexelCopyBufferLayout {
639                offset: 0,
640                bytes_per_row: Some(color_bpr),
641                rows_per_image: Some(height),
642            },
643        },
644        extent,
645    );
646    encoder.copy_texture_to_buffer(
647        wgpu::TexelCopyTextureInfo {
648            texture: &targets.id_tex,
649            mip_level: 0,
650            origin: wgpu::Origin3d::ZERO,
651            aspect: wgpu::TextureAspect::All,
652        },
653        wgpu::TexelCopyBufferInfo {
654            buffer: &id_readback,
655            layout: wgpu::TexelCopyBufferLayout {
656                offset: 0,
657                bytes_per_row: Some(id_bpr),
658                rows_per_image: Some(height),
659            },
660        },
661        extent,
662    );
663
664    queue.submit(Some(encoder.finish()));
665
666    let color_bytes = pipeline::map_and_read(&device, &color_readback)?;
667    let id_bytes = pipeline::map_and_read(&device, &id_readback)?;
668    let color = pipeline::unpad_to_rgba(&color_bytes, width, height, color_bpr);
669    let id_buffer = pipeline::unpad_to_u32(&id_bytes, width, height, id_bpr);
670
671    Ok(RenderOutput {
672        color,
673        id_buffer,
674        width,
675        height,
676    })
677}
678
679/// Render a compute-meshed cylinder with the tessellation chosen automatically
680/// from its projected screen size (view-dependent LOD).
681///
682/// Computes a [`screen_space_tess_factor`] from `cam` and the render dimensions
683/// in `opts` (bounding the silhouette chord error to `target_px` pixels — pass
684/// [`DEFAULT_TARGET_PX`] for the sub-pixel default), then meshes and renders
685/// exactly as [`render_cylinder_compute_offscreen`]. A near view yields a fine
686/// mesh, a far view a coarse one, both staying within the pixel budget.
687///
688/// # Errors
689///
690/// Same as [`render_cylinder_compute_offscreen`].
691pub fn render_cylinder_compute_screen_lod(
692    desc: &CylinderDescriptor,
693    face_id: u32,
694    cam: &Camera,
695    opts: &RenderOpts,
696    target_px: f64,
697) -> Result<RenderOutput, RenderError> {
698    let tess = screen_space_tess_factor(desc, cam, (opts.width, opts.height), target_px);
699    render_cylinder_compute_offscreen(desc, tess, face_id, cam, opts)
700}
701
702/// A read-write storage-buffer bind-group-layout entry visible to compute.
703fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
704    wgpu::BindGroupLayoutEntry {
705        binding,
706        visibility: wgpu::ShaderStages::COMPUTE,
707        ty: wgpu::BindingType::Buffer {
708            ty: wgpu::BufferBindingType::Storage { read_only: false },
709            has_dynamic_offset: false,
710            min_binding_size: None,
711        },
712        count: None,
713    }
714}
715
716/// Mesh-draw pipeline + bind group reusing the solid path's `mesh.wgsl`.
717struct DrawResources {
718    pipeline: wgpu::RenderPipeline,
719    bind_group: wgpu::BindGroup,
720}
721
722/// Build the globals uniform, bind group, and mesh-draw pipeline for the
723/// compute-generated vertex buffer (same vertex layout + shader as the solid
724/// path).
725fn build_draw_resources(
726    device: &wgpu::Device,
727    desc: &CylinderDescriptor,
728    cam: &Camera,
729    opts: &RenderOpts,
730) -> DrawResources {
731    let view_proj = crate::camera::view_proj_rtc(cam, desc.center);
732    let view_dir = cam.view_direction();
733    #[allow(clippy::cast_possible_truncation)]
734    let globals = pipeline::Globals {
735        view_proj,
736        view_dir: [
737            view_dir.x() as f32,
738            view_dir.y() as f32,
739            view_dir.z() as f32,
740            0.0,
741        ],
742        ambient: opts.ambient,
743        selected_id: 0,
744        _pad: [0.0; 2],
745    };
746    let globals_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
747        label: Some("globals"),
748        contents: bytemuck::bytes_of(&globals),
749        usage: wgpu::BufferUsages::UNIFORM,
750    });
751    let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
752        label: Some("globals layout"),
753        entries: &[wgpu::BindGroupLayoutEntry {
754            binding: 0,
755            visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
756            ty: wgpu::BindingType::Buffer {
757                ty: wgpu::BufferBindingType::Uniform,
758                has_dynamic_offset: false,
759                min_binding_size: None,
760            },
761            count: None,
762        }],
763    });
764    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
765        label: Some("globals bind group"),
766        layout: &bgl,
767        entries: &[wgpu::BindGroupEntry {
768            binding: 0,
769            resource: globals_buf.as_entire_binding(),
770        }],
771    });
772    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
773        label: Some("draw pipeline layout"),
774        bind_group_layouts: &[Some(&bgl)],
775        immediate_size: 0,
776    });
777    let shader = device.create_shader_module(wgpu::include_wgsl!("../shaders/mesh.wgsl"));
778    let color_targets = [
779        Some(wgpu::ColorTargetState {
780            format: pipeline::COLOR_FORMAT_OFFSCREEN,
781            blend: None,
782            write_mask: wgpu::ColorWrites::ALL,
783        }),
784        Some(wgpu::ColorTargetState {
785            format: pipeline::ID_FORMAT,
786            blend: None,
787            write_mask: wgpu::ColorWrites::ALL,
788        }),
789    ];
790    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
791        label: Some("compute mesh draw pipeline"),
792        layout: Some(&layout),
793        vertex: wgpu::VertexState {
794            module: &shader,
795            entry_point: Some("vs_main"),
796            buffers: &[Some(wgpu::VertexBufferLayout {
797                array_stride: 28, // 7 words: pos(3) + normal(3) + face_id(1)
798                step_mode: wgpu::VertexStepMode::Vertex,
799                attributes: &[
800                    wgpu::VertexAttribute {
801                        format: wgpu::VertexFormat::Float32x3,
802                        offset: 0,
803                        shader_location: 0,
804                    },
805                    wgpu::VertexAttribute {
806                        format: wgpu::VertexFormat::Float32x3,
807                        offset: 12,
808                        shader_location: 1,
809                    },
810                    wgpu::VertexAttribute {
811                        format: wgpu::VertexFormat::Uint32,
812                        offset: 24,
813                        shader_location: 2,
814                    },
815                ],
816            })],
817            compilation_options: wgpu::PipelineCompilationOptions::default(),
818        },
819        primitive: wgpu::PrimitiveState {
820            topology: wgpu::PrimitiveTopology::TriangleList,
821            cull_mode: None,
822            ..Default::default()
823        },
824        depth_stencil: Some(wgpu::DepthStencilState {
825            format: pipeline::DEPTH_FORMAT,
826            depth_write_enabled: Some(true),
827            depth_compare: Some(wgpu::CompareFunction::Less),
828            stencil: wgpu::StencilState::default(),
829            bias: wgpu::DepthBiasState::default(),
830        }),
831        multisample: wgpu::MultisampleState::default(),
832        fragment: Some(wgpu::FragmentState {
833            module: &shader,
834            entry_point: Some("fs_main"),
835            targets: &color_targets,
836            compilation_options: wgpu::PipelineCompilationOptions::default(),
837        }),
838        multiview_mask: None,
839        cache: None,
840    });
841    DrawResources {
842        pipeline,
843        bind_group,
844    }
845}
846
847/// The offscreen color/depth/id targets and their views.
848struct RenderTargets {
849    color_tex: wgpu::Texture,
850    id_tex: wgpu::Texture,
851    color_view: wgpu::TextureView,
852    depth_view: wgpu::TextureView,
853    id_view: wgpu::TextureView,
854}
855
856impl RenderTargets {
857    fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
858        let extent = wgpu::Extent3d {
859            width,
860            height,
861            depth_or_array_layers: 1,
862        };
863        let color_tex = device.create_texture(&wgpu::TextureDescriptor {
864            label: Some("color target"),
865            size: extent,
866            mip_level_count: 1,
867            sample_count: 1,
868            dimension: wgpu::TextureDimension::D2,
869            format: pipeline::COLOR_FORMAT_OFFSCREEN,
870            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
871            view_formats: &[],
872        });
873        let depth_tex = device.create_texture(&wgpu::TextureDescriptor {
874            label: Some("depth target"),
875            size: extent,
876            mip_level_count: 1,
877            sample_count: 1,
878            dimension: wgpu::TextureDimension::D2,
879            format: pipeline::DEPTH_FORMAT,
880            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
881            view_formats: &[],
882        });
883        let id_tex = device.create_texture(&wgpu::TextureDescriptor {
884            label: Some("id target"),
885            size: extent,
886            mip_level_count: 1,
887            sample_count: 1,
888            dimension: wgpu::TextureDimension::D2,
889            format: pipeline::ID_FORMAT,
890            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
891            view_formats: &[],
892        });
893        let color_view = color_tex.create_view(&wgpu::TextureViewDescriptor::default());
894        let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
895        let id_view = id_tex.create_view(&wgpu::TextureViewDescriptor::default());
896        Self {
897            color_tex,
898            id_tex,
899            color_view,
900            depth_view,
901            id_view,
902        }
903    }
904}
905
906#[allow(clippy::cast_possible_truncation)]
907fn vec_f32(v: Vec3) -> [f32; 3] {
908    [v.x() as f32, v.y() as f32, v.z() as f32]
909}
910
911#[allow(clippy::cast_possible_truncation)]
912fn pt_f32(p: Point3) -> [f32; 3] {
913    [p.x() as f32, p.y() as f32, p.z() as f32]
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919
920    #[test]
921    fn tess_factor_clamps_below_minimum() {
922        let t = TessFactor::new(0, 0);
923        assert_eq!(t.n_u, 3, "n_u floors at 3 (degenerate below)");
924        assert_eq!(t.n_v, 1, "n_v floors at 1");
925    }
926
927    #[test]
928    fn tess_factor_clamps_above_maximum() {
929        let t = TessFactor::new(u32::MAX, u32::MAX);
930        assert_eq!(t.n_u, MAX_TESS, "n_u caps at MAX_TESS");
931        assert_eq!(t.n_v, MAX_TESS, "n_v caps at MAX_TESS");
932    }
933
934    #[test]
935    fn tess_factor_passes_through_valid_range() {
936        let t = TessFactor::new(48, 4);
937        assert_eq!((t.n_u, t.n_v), (48, 4));
938    }
939
940    #[test]
941    fn max_tess_keeps_index_and_vertex_counts_within_u32() {
942        // The buffer index math (vertex `slot`, index `quad`) runs in u32 on the
943        // GPU and the draw count is u32; the clamp must keep every derived count
944        // strictly inside u32 so nothing wraps. Worst case: full grid at MAX_TESS.
945        let n = u64::from(MAX_TESS);
946        let cols = n + 1; // partial-arc column count (the larger of the two)
947        let rows = n + 1;
948        let vertex_count = cols * rows;
949        let index_count = n * n * 6;
950        assert!(
951            u32::try_from(vertex_count).is_ok(),
952            "vertex_count {vertex_count} exceeds u32"
953        );
954        assert!(
955            u32::try_from(index_count).is_ok(),
956            "index_count {index_count} exceeds u32"
957        );
958        // The vertex word stream (7 words/vertex) also must not overflow u32
959        // element indexing in the shader (`slot * WORDS_PER_VERT`).
960        assert!(
961            u32::try_from(vertex_count * WORDS_PER_VERT).is_ok(),
962            "vertex word count exceeds u32"
963        );
964    }
965
966    /// A unit cylinder of `radius` centered at the origin, axis +Z.
967    fn unit_cylinder(radius: f64) -> CylinderDescriptor {
968        CylinderDescriptor {
969            center: Point3::new(0.0, 0.0, 0.0),
970            axis_origin: Point3::new(0.0, 0.0, -1.0),
971            axis: Vec3::new(0.0, 0.0, 1.0),
972            x_ref: Vec3::new(1.0, 0.0, 0.0),
973            y_ref: Vec3::new(0.0, 1.0, 0.0),
974            radius,
975            v0: 0.0,
976            v1: 2.0,
977            u0: 0.0,
978            u1: TAU,
979        }
980    }
981
982    /// A camera at distance `dist` along +X looking back at the origin.
983    fn camera_at(dist: f64) -> Camera {
984        Camera {
985            eye: Point3::new(dist, 0.0, 0.0),
986            target: Point3::new(0.0, 0.0, 0.0),
987            up: Vec3::new(0.0, 0.0, 1.0),
988            fov_y: 45.0_f64.to_radians(),
989            aspect: 1.0,
990            near: 0.1,
991            far: dist * 10.0,
992        }
993    }
994
995    #[test]
996    fn screen_lod_increases_when_closer() {
997        let desc = unit_cylinder(5.0);
998        let viewport = (512, 512);
999        let near = screen_space_tess_factor(&desc, &camera_at(20.0), viewport, 0.5);
1000        let far = screen_space_tess_factor(&desc, &camera_at(200.0), viewport, 0.5);
1001        assert!(
1002            near.n_u > far.n_u,
1003            "closer camera should subdivide more: near {} far {}",
1004            near.n_u,
1005            far.n_u
1006        );
1007        assert_eq!(near.n_v, 1, "ruled axial direction stays at 1");
1008        assert_eq!(far.n_v, 1);
1009    }
1010
1011    #[test]
1012    fn screen_lod_increases_with_radius() {
1013        let viewport = (512, 512);
1014        let cam = camera_at(50.0);
1015        let small = screen_space_tess_factor(&unit_cylinder(2.0), &cam, viewport, 0.5);
1016        let large = screen_space_tess_factor(&unit_cylinder(40.0), &cam, viewport, 0.5);
1017        assert!(
1018            large.n_u > small.n_u,
1019            "larger projected radius should subdivide more: small {} large {}",
1020            small.n_u,
1021            large.n_u
1022        );
1023    }
1024
1025    #[test]
1026    fn screen_lod_floors_at_minimum_when_subpixel() {
1027        // A tiny cylinder very far away projects to under a pixel: the coarsest
1028        // mesh (the TessFactor minimum) already satisfies any sane budget.
1029        let desc = unit_cylinder(0.01);
1030        let t = screen_space_tess_factor(&desc, &camera_at(5_000.0), (256, 256), 0.5);
1031        assert_eq!(t.n_u, 3, "sub-pixel cylinder floors at the minimum");
1032    }
1033
1034    #[test]
1035    fn screen_lod_tighter_budget_subdivides_more() {
1036        let desc = unit_cylinder(5.0);
1037        let cam = camera_at(40.0);
1038        let coarse = screen_space_tess_factor(&desc, &cam, (512, 512), 2.0);
1039        let fine = screen_space_tess_factor(&desc, &cam, (512, 512), 0.25);
1040        assert!(
1041            fine.n_u > coarse.n_u,
1042            "a tighter pixel budget should subdivide more: coarse {} fine {}",
1043            coarse.n_u,
1044            fine.n_u
1045        );
1046    }
1047
1048    #[test]
1049    fn screen_lod_handles_degenerate_inputs() {
1050        let desc = unit_cylinder(5.0);
1051        let viewport = (512, 512);
1052
1053        // Zero / non-finite target budget can't bound anything → max detail.
1054        let t0 = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.0);
1055        assert_eq!(
1056            t0.n_u, MAX_TESS,
1057            "zero pixel budget requests the maximum LOD"
1058        );
1059        let t_nan = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, f64::NAN);
1060        assert_eq!(t_nan.n_u, MAX_TESS, "NaN budget falls back to maximum");
1061    }
1062
1063    #[test]
1064    fn screen_lod_engulfing_camera_requests_maximum() {
1065        // A camera engulfed by / sitting on the cylinder has zero view-space
1066        // depth, so the projected radius is unbounded (r_px → +∞): it must
1067        // tessellate FINELY (max), not coarsely (the bug this guards against —
1068        // the prior code treated +∞ as sub-pixel and returned the minimum).
1069        let desc = unit_cylinder(5.0);
1070        let viewport = (512, 512);
1071        let mut on_center = camera_at(40.0);
1072        on_center.eye = desc.center;
1073        assert_eq!(
1074            screen_space_tess_factor(&desc, &on_center, viewport, 0.5).n_u,
1075            MAX_TESS,
1076            "a camera engulfed by the cylinder (depth 0) must request the maximum LOD"
1077        );
1078    }
1079
1080    #[test]
1081    fn screen_lod_clamps_extreme_fov_to_bounded_high_lod() {
1082        // A near-zero FOV (extreme telephoto zoom) is clamped to a valid minimum
1083        // rather than poisoning the projection: it yields a high but *bounded*
1084        // tessellation, not a degenerate one.
1085        let desc = unit_cylinder(5.0);
1086        let viewport = (512, 512);
1087        let mut tiny_fov = camera_at(40.0);
1088        tiny_fov.fov_y = 1.0e-12;
1089        let normal = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.5);
1090        let zoomed = screen_space_tess_factor(&desc, &tiny_fov, viewport, 0.5);
1091        assert!(
1092            zoomed.n_u > normal.n_u,
1093            "extreme zoom should subdivide far more than the normal fov: zoomed {} normal {}",
1094            zoomed.n_u,
1095            normal.n_u
1096        );
1097    }
1098
1099    #[test]
1100    fn screen_lod_behind_camera_floors_at_minimum() {
1101        // The cylinder behind the camera (negative view-space depth) does not
1102        // render meaningfully → the coarsest mesh. `camera_at` looks down −X at
1103        // the origin, so a center placed further down +X is behind the eye.
1104        let viewport = (512, 512);
1105        let mut desc = unit_cylinder(5.0);
1106        let cam = camera_at(40.0); // eye at (40,0,0) looking toward −X
1107        desc.center = Point3::new(80.0, 0.0, 0.0); // behind the camera
1108        desc.axis_origin = Point3::new(80.0, 0.0, -1.0);
1109        let depth_is_negative = cam.view_direction().dot(desc.center - cam.eye) < 0.0;
1110        assert!(
1111            depth_is_negative,
1112            "test setup: center should be behind the camera"
1113        );
1114        assert_eq!(
1115            screen_space_tess_factor(&desc, &cam, viewport, 0.5).n_u,
1116            3,
1117            "a cylinder behind the camera floors at the minimum LOD"
1118        );
1119    }
1120}