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 (_adapter, device, queue) = pipeline::acquire_offscreen_device()?;
405
406    // Reject oversized targets with a clean error rather than tripping wgpu's
407    // internal validation (mirrors the solid path).
408    let max = device.limits().max_texture_dimension_2d;
409    if opts.width > max || opts.height > max {
410        return Err(RenderError::SizeTooLarge {
411            width: opts.width,
412            height: opts.height,
413            max,
414        });
415    }
416
417    // --- Grid sizing -------------------------------------------------------
418    // `full` is the single source of truth for the seam decision: it is uploaded
419    // to the shader (see GpuDescriptor::full) so the two compute entry points
420    // never recompute the `(span ≈ 2π)` test in f32. A full revolution shares
421    // the u = 0 / u = 2π columns, so it emits only `n_u` columns (the wrap quad
422    // reuses column 0); a partial arc emits `n_u + 1`.
423    let full = (desc.u1 - desc.u0 - TAU).abs() < 1.0e-6;
424    let cols = if full { tess.n_u } else { tess.n_u + 1 };
425    let rows = tess.n_v + 1;
426    // With both dims clamped to MAX_TESS, the worst case is MAX_TESS²·6 ≈ 1.6e9,
427    // comfortably inside u32, so the index cast for `draw_indexed` cannot wrap.
428    let vertex_count = u64::from(cols) * u64::from(rows);
429    let index_count = u64::from(tess.n_u) * u64::from(tess.n_v) * 6;
430    // The MAX_TESS clamp guarantees this fits in u32 (worst case ≈ 1.6e9); the
431    // checked `try_from` keeps the draw count from ever silently wrapping even if
432    // that invariant is later weakened.
433    let index_count_u32 = u32::try_from(index_count).unwrap_or(u32::MAX);
434    let vert_bytes = vertex_count * WORDS_PER_VERT * 4; // 4 bytes per u32 word
435    let index_bytes = index_count * 4;
436
437    // --- Descriptor uniform -----------------------------------------------
438    #[allow(clippy::cast_possible_truncation)]
439    let gpu_desc = GpuDescriptor {
440        center: pt_f32(desc.center),
441        radius: desc.radius as f32,
442        axis_origin: pt_f32(desc.axis_origin),
443        v0: desc.v0 as f32,
444        axis: vec_f32(desc.axis),
445        v1: desc.v1 as f32,
446        x_ref: vec_f32(desc.x_ref),
447        u0: desc.u0 as f32,
448        y_ref: vec_f32(desc.y_ref),
449        u1: desc.u1 as f32,
450        n_u: tess.n_u,
451        n_v: tess.n_v,
452        face_id,
453        full: u32::from(full),
454    };
455    let desc_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
456        label: Some("cylinder descriptor"),
457        contents: bytemuck::bytes_of(&gpu_desc),
458        usage: wgpu::BufferUsages::UNIFORM,
459    });
460
461    // --- Compute output buffers (also used directly as draw inputs) --------
462    let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor {
463        label: Some("compute vertices"),
464        size: vert_bytes,
465        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
466        mapped_at_creation: false,
467    });
468    let index_buf = device.create_buffer(&wgpu::BufferDescriptor {
469        label: Some("compute indices"),
470        size: index_bytes,
471        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDEX,
472        mapped_at_creation: false,
473    });
474
475    // --- Compute pipeline --------------------------------------------------
476    let compute_shader =
477        device.create_shader_module(wgpu::include_wgsl!("../shaders/quadric_mesh.wgsl"));
478    let compute_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
479        label: Some("compute mesher layout"),
480        entries: &[
481            wgpu::BindGroupLayoutEntry {
482                binding: 0,
483                visibility: wgpu::ShaderStages::COMPUTE,
484                ty: wgpu::BindingType::Buffer {
485                    ty: wgpu::BufferBindingType::Uniform,
486                    has_dynamic_offset: false,
487                    min_binding_size: None,
488                },
489                count: None,
490            },
491            storage_entry(1),
492            storage_entry(2),
493        ],
494    });
495    let compute_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
496        label: Some("compute mesher bind group"),
497        layout: &compute_bgl,
498        entries: &[
499            wgpu::BindGroupEntry {
500                binding: 0,
501                resource: desc_buf.as_entire_binding(),
502            },
503            wgpu::BindGroupEntry {
504                binding: 1,
505                resource: vertex_buf.as_entire_binding(),
506            },
507            wgpu::BindGroupEntry {
508                binding: 2,
509                resource: index_buf.as_entire_binding(),
510            },
511        ],
512    });
513    let compute_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
514        label: Some("compute pipeline layout"),
515        bind_group_layouts: &[Some(&compute_bgl)],
516        immediate_size: 0,
517    });
518    let vertex_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
519        label: Some("cylinder vertex mesher"),
520        layout: Some(&compute_layout),
521        module: &compute_shader,
522        entry_point: Some("cs_vertices"),
523        compilation_options: wgpu::PipelineCompilationOptions::default(),
524        cache: None,
525    });
526    let index_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
527        label: Some("cylinder index mesher"),
528        layout: Some(&compute_layout),
529        module: &compute_shader,
530        entry_point: Some("cs_indices"),
531        compilation_options: wgpu::PipelineCompilationOptions::default(),
532        cache: None,
533    });
534
535    // --- Draw resources (shared mesh shader) -------------------------------
536    let draw = build_draw_resources(&device, desc, cam, opts);
537
538    // --- Targets -----------------------------------------------------------
539    let (width, height) = (opts.width, opts.height);
540    let targets = RenderTargets::new(&device, width, height);
541
542    // --- Encode ------------------------------------------------------------
543    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
544        label: Some("compute + draw encoder"),
545    });
546    {
547        let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
548            label: Some("cylinder mesher"),
549            timestamp_writes: None,
550        });
551        cpass.set_bind_group(0, &compute_bind_group, &[]);
552        let groups_x = cols.div_ceil(8).max(1);
553        let groups_y = rows.div_ceil(8).max(1);
554        cpass.set_pipeline(&vertex_pipeline);
555        cpass.dispatch_workgroups(groups_x, groups_y, 1);
556        let igx = tess.n_u.div_ceil(8).max(1);
557        let igy = tess.n_v.div_ceil(8).max(1);
558        cpass.set_pipeline(&index_pipeline);
559        cpass.dispatch_workgroups(igx, igy, 1);
560    }
561
562    {
563        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
564            label: Some("compute mesh pass"),
565            color_attachments: &[
566                Some(wgpu::RenderPassColorAttachment {
567                    view: &targets.color_view,
568                    depth_slice: None,
569                    resolve_target: None,
570                    ops: wgpu::Operations {
571                        load: wgpu::LoadOp::Clear(wgpu::Color {
572                            r: f64::from(opts.background[0]),
573                            g: f64::from(opts.background[1]),
574                            b: f64::from(opts.background[2]),
575                            a: f64::from(opts.background[3]),
576                        }),
577                        store: wgpu::StoreOp::Store,
578                    },
579                }),
580                Some(wgpu::RenderPassColorAttachment {
581                    view: &targets.id_view,
582                    depth_slice: None,
583                    resolve_target: None,
584                    ops: wgpu::Operations {
585                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
586                        store: wgpu::StoreOp::Store,
587                    },
588                }),
589            ],
590            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
591                view: &targets.depth_view,
592                depth_ops: Some(wgpu::Operations {
593                    load: wgpu::LoadOp::Clear(1.0),
594                    store: wgpu::StoreOp::Store,
595                }),
596                stencil_ops: None,
597            }),
598            timestamp_writes: None,
599            occlusion_query_set: None,
600            multiview_mask: None,
601        });
602        pass.set_bind_group(0, &draw.bind_group, &[]);
603        pass.set_pipeline(&draw.pipeline);
604        pass.set_vertex_buffer(0, vertex_buf.slice(..));
605        pass.set_index_buffer(index_buf.slice(..), wgpu::IndexFormat::Uint32);
606        pass.draw_indexed(0..index_count_u32, 0, 0..1);
607    }
608
609    let color_bpr = pipeline::padded_bytes_per_row(width, 4);
610    let id_bpr = pipeline::padded_bytes_per_row(width, 4);
611    let color_readback = device.create_buffer(&wgpu::BufferDescriptor {
612        label: Some("color readback"),
613        size: u64::from(color_bpr) * u64::from(height),
614        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
615        mapped_at_creation: false,
616    });
617    let id_readback = device.create_buffer(&wgpu::BufferDescriptor {
618        label: Some("id readback"),
619        size: u64::from(id_bpr) * u64::from(height),
620        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
621        mapped_at_creation: false,
622    });
623    let extent = wgpu::Extent3d {
624        width,
625        height,
626        depth_or_array_layers: 1,
627    };
628    encoder.copy_texture_to_buffer(
629        wgpu::TexelCopyTextureInfo {
630            texture: &targets.color_tex,
631            mip_level: 0,
632            origin: wgpu::Origin3d::ZERO,
633            aspect: wgpu::TextureAspect::All,
634        },
635        wgpu::TexelCopyBufferInfo {
636            buffer: &color_readback,
637            layout: wgpu::TexelCopyBufferLayout {
638                offset: 0,
639                bytes_per_row: Some(color_bpr),
640                rows_per_image: Some(height),
641            },
642        },
643        extent,
644    );
645    encoder.copy_texture_to_buffer(
646        wgpu::TexelCopyTextureInfo {
647            texture: &targets.id_tex,
648            mip_level: 0,
649            origin: wgpu::Origin3d::ZERO,
650            aspect: wgpu::TextureAspect::All,
651        },
652        wgpu::TexelCopyBufferInfo {
653            buffer: &id_readback,
654            layout: wgpu::TexelCopyBufferLayout {
655                offset: 0,
656                bytes_per_row: Some(id_bpr),
657                rows_per_image: Some(height),
658            },
659        },
660        extent,
661    );
662
663    queue.submit(Some(encoder.finish()));
664
665    let color_bytes = pipeline::map_and_read(&device, &color_readback)?;
666    let id_bytes = pipeline::map_and_read(&device, &id_readback)?;
667    let color = pipeline::unpad_to_rgba(&color_bytes, width, height, color_bpr);
668    let id_buffer = pipeline::unpad_to_u32(&id_bytes, width, height, id_bpr);
669
670    Ok(RenderOutput {
671        color,
672        id_buffer,
673        width,
674        height,
675    })
676}
677
678/// Render a compute-meshed cylinder with the tessellation chosen automatically
679/// from its projected screen size (view-dependent LOD).
680///
681/// Computes a [`screen_space_tess_factor`] from `cam` and the render dimensions
682/// in `opts` (bounding the silhouette chord error to `target_px` pixels — pass
683/// [`DEFAULT_TARGET_PX`] for the sub-pixel default), then meshes and renders
684/// exactly as [`render_cylinder_compute_offscreen`]. A near view yields a fine
685/// mesh, a far view a coarse one, both staying within the pixel budget.
686///
687/// # Errors
688///
689/// Same as [`render_cylinder_compute_offscreen`].
690pub fn render_cylinder_compute_screen_lod(
691    desc: &CylinderDescriptor,
692    face_id: u32,
693    cam: &Camera,
694    opts: &RenderOpts,
695    target_px: f64,
696) -> Result<RenderOutput, RenderError> {
697    let tess = screen_space_tess_factor(desc, cam, (opts.width, opts.height), target_px);
698    render_cylinder_compute_offscreen(desc, tess, face_id, cam, opts)
699}
700
701/// A read-write storage-buffer bind-group-layout entry visible to compute.
702fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
703    wgpu::BindGroupLayoutEntry {
704        binding,
705        visibility: wgpu::ShaderStages::COMPUTE,
706        ty: wgpu::BindingType::Buffer {
707            ty: wgpu::BufferBindingType::Storage { read_only: false },
708            has_dynamic_offset: false,
709            min_binding_size: None,
710        },
711        count: None,
712    }
713}
714
715/// Mesh-draw pipeline + bind group reusing the solid path's `mesh.wgsl`.
716struct DrawResources {
717    pipeline: wgpu::RenderPipeline,
718    bind_group: wgpu::BindGroup,
719}
720
721/// Build the globals uniform, bind group, and mesh-draw pipeline for the
722/// compute-generated vertex buffer (same vertex layout + shader as the solid
723/// path).
724fn build_draw_resources(
725    device: &wgpu::Device,
726    desc: &CylinderDescriptor,
727    cam: &Camera,
728    opts: &RenderOpts,
729) -> DrawResources {
730    let view_proj = crate::camera::view_proj_rtc(cam, desc.center);
731    let view_dir = cam.view_direction();
732    #[allow(clippy::cast_possible_truncation)]
733    let globals = pipeline::Globals {
734        view_proj,
735        view_dir: [
736            view_dir.x() as f32,
737            view_dir.y() as f32,
738            view_dir.z() as f32,
739            0.0,
740        ],
741        ambient: opts.ambient,
742        selected_id: 0,
743        _pad: [0.0; 2],
744    };
745    let globals_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
746        label: Some("globals"),
747        contents: bytemuck::bytes_of(&globals),
748        usage: wgpu::BufferUsages::UNIFORM,
749    });
750    let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
751        label: Some("globals layout"),
752        entries: &[wgpu::BindGroupLayoutEntry {
753            binding: 0,
754            visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
755            ty: wgpu::BindingType::Buffer {
756                ty: wgpu::BufferBindingType::Uniform,
757                has_dynamic_offset: false,
758                min_binding_size: None,
759            },
760            count: None,
761        }],
762    });
763    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
764        label: Some("globals bind group"),
765        layout: &bgl,
766        entries: &[wgpu::BindGroupEntry {
767            binding: 0,
768            resource: globals_buf.as_entire_binding(),
769        }],
770    });
771    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
772        label: Some("draw pipeline layout"),
773        bind_group_layouts: &[Some(&bgl)],
774        immediate_size: 0,
775    });
776    let shader = device.create_shader_module(wgpu::include_wgsl!("../shaders/mesh.wgsl"));
777    let color_targets = [
778        Some(wgpu::ColorTargetState {
779            format: pipeline::COLOR_FORMAT_OFFSCREEN,
780            blend: None,
781            write_mask: wgpu::ColorWrites::ALL,
782        }),
783        Some(wgpu::ColorTargetState {
784            format: pipeline::ID_FORMAT,
785            blend: None,
786            write_mask: wgpu::ColorWrites::ALL,
787        }),
788    ];
789    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
790        label: Some("compute mesh draw pipeline"),
791        layout: Some(&layout),
792        vertex: wgpu::VertexState {
793            module: &shader,
794            entry_point: Some("vs_main"),
795            buffers: &[Some(wgpu::VertexBufferLayout {
796                array_stride: 28, // 7 words: pos(3) + normal(3) + face_id(1)
797                step_mode: wgpu::VertexStepMode::Vertex,
798                attributes: &[
799                    wgpu::VertexAttribute {
800                        format: wgpu::VertexFormat::Float32x3,
801                        offset: 0,
802                        shader_location: 0,
803                    },
804                    wgpu::VertexAttribute {
805                        format: wgpu::VertexFormat::Float32x3,
806                        offset: 12,
807                        shader_location: 1,
808                    },
809                    wgpu::VertexAttribute {
810                        format: wgpu::VertexFormat::Uint32,
811                        offset: 24,
812                        shader_location: 2,
813                    },
814                ],
815            })],
816            compilation_options: wgpu::PipelineCompilationOptions::default(),
817        },
818        primitive: wgpu::PrimitiveState {
819            topology: wgpu::PrimitiveTopology::TriangleList,
820            cull_mode: None,
821            ..Default::default()
822        },
823        depth_stencil: Some(wgpu::DepthStencilState {
824            format: pipeline::DEPTH_FORMAT,
825            depth_write_enabled: Some(true),
826            depth_compare: Some(wgpu::CompareFunction::Less),
827            stencil: wgpu::StencilState::default(),
828            bias: wgpu::DepthBiasState::default(),
829        }),
830        multisample: wgpu::MultisampleState::default(),
831        fragment: Some(wgpu::FragmentState {
832            module: &shader,
833            entry_point: Some("fs_main"),
834            targets: &color_targets,
835            compilation_options: wgpu::PipelineCompilationOptions::default(),
836        }),
837        multiview_mask: None,
838        cache: None,
839    });
840    DrawResources {
841        pipeline,
842        bind_group,
843    }
844}
845
846/// The offscreen color/depth/id targets and their views.
847struct RenderTargets {
848    color_tex: wgpu::Texture,
849    id_tex: wgpu::Texture,
850    color_view: wgpu::TextureView,
851    depth_view: wgpu::TextureView,
852    id_view: wgpu::TextureView,
853}
854
855impl RenderTargets {
856    fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
857        let extent = wgpu::Extent3d {
858            width,
859            height,
860            depth_or_array_layers: 1,
861        };
862        let color_tex = device.create_texture(&wgpu::TextureDescriptor {
863            label: Some("color target"),
864            size: extent,
865            mip_level_count: 1,
866            sample_count: 1,
867            dimension: wgpu::TextureDimension::D2,
868            format: pipeline::COLOR_FORMAT_OFFSCREEN,
869            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
870            view_formats: &[],
871        });
872        let depth_tex = device.create_texture(&wgpu::TextureDescriptor {
873            label: Some("depth target"),
874            size: extent,
875            mip_level_count: 1,
876            sample_count: 1,
877            dimension: wgpu::TextureDimension::D2,
878            format: pipeline::DEPTH_FORMAT,
879            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
880            view_formats: &[],
881        });
882        let id_tex = device.create_texture(&wgpu::TextureDescriptor {
883            label: Some("id target"),
884            size: extent,
885            mip_level_count: 1,
886            sample_count: 1,
887            dimension: wgpu::TextureDimension::D2,
888            format: pipeline::ID_FORMAT,
889            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
890            view_formats: &[],
891        });
892        let color_view = color_tex.create_view(&wgpu::TextureViewDescriptor::default());
893        let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
894        let id_view = id_tex.create_view(&wgpu::TextureViewDescriptor::default());
895        Self {
896            color_tex,
897            id_tex,
898            color_view,
899            depth_view,
900            id_view,
901        }
902    }
903}
904
905#[allow(clippy::cast_possible_truncation)]
906fn vec_f32(v: Vec3) -> [f32; 3] {
907    [v.x() as f32, v.y() as f32, v.z() as f32]
908}
909
910#[allow(clippy::cast_possible_truncation)]
911fn pt_f32(p: Point3) -> [f32; 3] {
912    [p.x() as f32, p.y() as f32, p.z() as f32]
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    #[test]
920    fn tess_factor_clamps_below_minimum() {
921        let t = TessFactor::new(0, 0);
922        assert_eq!(t.n_u, 3, "n_u floors at 3 (degenerate below)");
923        assert_eq!(t.n_v, 1, "n_v floors at 1");
924    }
925
926    #[test]
927    fn tess_factor_clamps_above_maximum() {
928        let t = TessFactor::new(u32::MAX, u32::MAX);
929        assert_eq!(t.n_u, MAX_TESS, "n_u caps at MAX_TESS");
930        assert_eq!(t.n_v, MAX_TESS, "n_v caps at MAX_TESS");
931    }
932
933    #[test]
934    fn tess_factor_passes_through_valid_range() {
935        let t = TessFactor::new(48, 4);
936        assert_eq!((t.n_u, t.n_v), (48, 4));
937    }
938
939    #[test]
940    fn max_tess_keeps_index_and_vertex_counts_within_u32() {
941        // The buffer index math (vertex `slot`, index `quad`) runs in u32 on the
942        // GPU and the draw count is u32; the clamp must keep every derived count
943        // strictly inside u32 so nothing wraps. Worst case: full grid at MAX_TESS.
944        let n = u64::from(MAX_TESS);
945        let cols = n + 1; // partial-arc column count (the larger of the two)
946        let rows = n + 1;
947        let vertex_count = cols * rows;
948        let index_count = n * n * 6;
949        assert!(
950            u32::try_from(vertex_count).is_ok(),
951            "vertex_count {vertex_count} exceeds u32"
952        );
953        assert!(
954            u32::try_from(index_count).is_ok(),
955            "index_count {index_count} exceeds u32"
956        );
957        // The vertex word stream (7 words/vertex) also must not overflow u32
958        // element indexing in the shader (`slot * WORDS_PER_VERT`).
959        assert!(
960            u32::try_from(vertex_count * WORDS_PER_VERT).is_ok(),
961            "vertex word count exceeds u32"
962        );
963    }
964
965    /// A unit cylinder of `radius` centered at the origin, axis +Z.
966    fn unit_cylinder(radius: f64) -> CylinderDescriptor {
967        CylinderDescriptor {
968            center: Point3::new(0.0, 0.0, 0.0),
969            axis_origin: Point3::new(0.0, 0.0, -1.0),
970            axis: Vec3::new(0.0, 0.0, 1.0),
971            x_ref: Vec3::new(1.0, 0.0, 0.0),
972            y_ref: Vec3::new(0.0, 1.0, 0.0),
973            radius,
974            v0: 0.0,
975            v1: 2.0,
976            u0: 0.0,
977            u1: TAU,
978        }
979    }
980
981    /// A camera at distance `dist` along +X looking back at the origin.
982    fn camera_at(dist: f64) -> Camera {
983        Camera {
984            eye: Point3::new(dist, 0.0, 0.0),
985            target: Point3::new(0.0, 0.0, 0.0),
986            up: Vec3::new(0.0, 0.0, 1.0),
987            fov_y: 45.0_f64.to_radians(),
988            aspect: 1.0,
989            near: 0.1,
990            far: dist * 10.0,
991        }
992    }
993
994    #[test]
995    fn screen_lod_increases_when_closer() {
996        let desc = unit_cylinder(5.0);
997        let viewport = (512, 512);
998        let near = screen_space_tess_factor(&desc, &camera_at(20.0), viewport, 0.5);
999        let far = screen_space_tess_factor(&desc, &camera_at(200.0), viewport, 0.5);
1000        assert!(
1001            near.n_u > far.n_u,
1002            "closer camera should subdivide more: near {} far {}",
1003            near.n_u,
1004            far.n_u
1005        );
1006        assert_eq!(near.n_v, 1, "ruled axial direction stays at 1");
1007        assert_eq!(far.n_v, 1);
1008    }
1009
1010    #[test]
1011    fn screen_lod_increases_with_radius() {
1012        let viewport = (512, 512);
1013        let cam = camera_at(50.0);
1014        let small = screen_space_tess_factor(&unit_cylinder(2.0), &cam, viewport, 0.5);
1015        let large = screen_space_tess_factor(&unit_cylinder(40.0), &cam, viewport, 0.5);
1016        assert!(
1017            large.n_u > small.n_u,
1018            "larger projected radius should subdivide more: small {} large {}",
1019            small.n_u,
1020            large.n_u
1021        );
1022    }
1023
1024    #[test]
1025    fn screen_lod_floors_at_minimum_when_subpixel() {
1026        // A tiny cylinder very far away projects to under a pixel: the coarsest
1027        // mesh (the TessFactor minimum) already satisfies any sane budget.
1028        let desc = unit_cylinder(0.01);
1029        let t = screen_space_tess_factor(&desc, &camera_at(5_000.0), (256, 256), 0.5);
1030        assert_eq!(t.n_u, 3, "sub-pixel cylinder floors at the minimum");
1031    }
1032
1033    #[test]
1034    fn screen_lod_tighter_budget_subdivides_more() {
1035        let desc = unit_cylinder(5.0);
1036        let cam = camera_at(40.0);
1037        let coarse = screen_space_tess_factor(&desc, &cam, (512, 512), 2.0);
1038        let fine = screen_space_tess_factor(&desc, &cam, (512, 512), 0.25);
1039        assert!(
1040            fine.n_u > coarse.n_u,
1041            "a tighter pixel budget should subdivide more: coarse {} fine {}",
1042            coarse.n_u,
1043            fine.n_u
1044        );
1045    }
1046
1047    #[test]
1048    fn screen_lod_handles_degenerate_inputs() {
1049        let desc = unit_cylinder(5.0);
1050        let viewport = (512, 512);
1051
1052        // Zero / non-finite target budget can't bound anything → max detail.
1053        let t0 = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.0);
1054        assert_eq!(
1055            t0.n_u, MAX_TESS,
1056            "zero pixel budget requests the maximum LOD"
1057        );
1058        let t_nan = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, f64::NAN);
1059        assert_eq!(t_nan.n_u, MAX_TESS, "NaN budget falls back to maximum");
1060    }
1061
1062    #[test]
1063    fn screen_lod_engulfing_camera_requests_maximum() {
1064        // A camera engulfed by / sitting on the cylinder has zero view-space
1065        // depth, so the projected radius is unbounded (r_px → +∞): it must
1066        // tessellate FINELY (max), not coarsely (the bug this guards against —
1067        // the prior code treated +∞ as sub-pixel and returned the minimum).
1068        let desc = unit_cylinder(5.0);
1069        let viewport = (512, 512);
1070        let mut on_center = camera_at(40.0);
1071        on_center.eye = desc.center;
1072        assert_eq!(
1073            screen_space_tess_factor(&desc, &on_center, viewport, 0.5).n_u,
1074            MAX_TESS,
1075            "a camera engulfed by the cylinder (depth 0) must request the maximum LOD"
1076        );
1077    }
1078
1079    #[test]
1080    fn screen_lod_clamps_extreme_fov_to_bounded_high_lod() {
1081        // A near-zero FOV (extreme telephoto zoom) is clamped to a valid minimum
1082        // rather than poisoning the projection: it yields a high but *bounded*
1083        // tessellation, not a degenerate one.
1084        let desc = unit_cylinder(5.0);
1085        let viewport = (512, 512);
1086        let mut tiny_fov = camera_at(40.0);
1087        tiny_fov.fov_y = 1.0e-12;
1088        let normal = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.5);
1089        let zoomed = screen_space_tess_factor(&desc, &tiny_fov, viewport, 0.5);
1090        assert!(
1091            zoomed.n_u > normal.n_u,
1092            "extreme zoom should subdivide far more than the normal fov: zoomed {} normal {}",
1093            zoomed.n_u,
1094            normal.n_u
1095        );
1096    }
1097
1098    #[test]
1099    fn screen_lod_behind_camera_floors_at_minimum() {
1100        // The cylinder behind the camera (negative view-space depth) does not
1101        // render meaningfully → the coarsest mesh. `camera_at` looks down −X at
1102        // the origin, so a center placed further down +X is behind the eye.
1103        let viewport = (512, 512);
1104        let mut desc = unit_cylinder(5.0);
1105        let cam = camera_at(40.0); // eye at (40,0,0) looking toward −X
1106        desc.center = Point3::new(80.0, 0.0, 0.0); // behind the camera
1107        desc.axis_origin = Point3::new(80.0, 0.0, -1.0);
1108        let depth_is_negative = cam.view_direction().dot(desc.center - cam.eye) < 0.0;
1109        assert!(
1110            depth_is_negative,
1111            "test setup: center should be behind the camera"
1112        );
1113        assert_eq!(
1114            screen_space_tess_factor(&desc, &cam, viewport, 0.5).n_u,
1115            3,
1116            "a cylinder behind the camera floors at the minimum LOD"
1117        );
1118    }
1119}