Skip to main content

arcane_core/renderer/
geometry.rs

1/// Geometry batch renderer: draws colored triangles and thick lines
2/// without textures, using a single TriangleList pipeline.
3///
4/// Lines are expanded into quads (2 triangles) on the CPU side.
5///
6/// ## Integration (Phase 2 — renderer/mod.rs + dev.rs)
7///
8/// In `Renderer`:
9/// ```text
10/// pub geometry: GeometryBatch,
11/// ```
12///
13/// In `Renderer::new()`:
14/// ```text
15/// let geometry = GeometryBatch::new(&gpu, sprites.camera_bind_group_layout());
16/// // NOTE: GeometryBatch shares the camera bind group from SpritePipeline.
17/// ```
18///
19/// In the frame callback (dev.rs), after sprite render:
20/// ```text
21/// // Drain GeoState commands
22/// let geo_cmds: Vec<GeoCommand> = {
23///     let geo = op_state.borrow::<Rc<RefCell<GeoState>>>();
24///     let mut gs = geo.borrow_mut();
25///     std::mem::take(&mut gs.commands)
26/// };
27///
28/// // Feed commands into GeometryBatch
29/// for cmd in &geo_cmds {
30///     match cmd {
31///         GeoCommand::Triangle { x1,y1,x2,y2,x3,y3,r,g,b,a,.. } =>
32///             renderer.geometry.add_triangle(*x1,*y1,*x2,*y2,*x3,*y3,*r,*g,*b,*a),
33///         GeoCommand::LineSeg { x1,y1,x2,y2,thickness,r,g,b,a,.. } =>
34///             renderer.geometry.add_line(*x1,*y1,*x2,*y2,*thickness,*r,*g,*b,*a),
35///     }
36/// }
37///
38/// // Flush geometry (renders after sprites, no clear)
39/// renderer.geometry.flush(&renderer.gpu, &mut encoder, &view, &sprites.camera_bind_group());
40/// ```
41
42use bytemuck::{Pod, Zeroable};
43use wgpu::util::DeviceExt;
44
45use super::gpu::GpuContext;
46
47/// Per-vertex data for the geometry pipeline: position + RGBA color.
48#[repr(C)]
49#[derive(Copy, Clone, Pod, Zeroable)]
50pub struct GeoVertex {
51    pub position: [f32; 2],
52    pub color: [f32; 4],
53}
54
55/// Maximum number of vertices per frame before flush.
56/// 65536 vertices = ~21845 triangles, more than enough for shape primitives.
57const MAX_VERTICES: usize = 65536;
58
59pub struct GeometryBatch {
60    pipeline: wgpu::RenderPipeline,
61    vertices: Vec<GeoVertex>,
62}
63
64impl GeometryBatch {
65    /// Create a geometry batch for headless testing.
66    pub fn new_headless(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
67        Self::new_internal(device, format)
68    }
69
70    /// Create a new geometry batch renderer.
71    ///
72    /// Shares the sprite pipeline's camera bind group at flush time so both pipelines
73    /// use the same view-projection matrix without duplicating the uniform buffer.
74    pub fn new(gpu: &GpuContext) -> Self {
75        Self::new_internal(&gpu.device, gpu.config.format)
76    }
77
78    fn new_internal(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
79        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
80            label: Some("geom_shader"),
81            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/geom.wgsl").into()),
82        });
83
84        let camera_bgl =
85            device
86                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
87                    label: Some("geom_camera_bind_group_layout"),
88                    entries: &[wgpu::BindGroupLayoutEntry {
89                        binding: 0,
90                        visibility: wgpu::ShaderStages::VERTEX,
91                        ty: wgpu::BindingType::Buffer {
92                            ty: wgpu::BufferBindingType::Uniform,
93                            has_dynamic_offset: false,
94                            min_binding_size: None,
95                        },
96                        count: None,
97                    }],
98                });
99
100        let pipeline_layout =
101            device
102                .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
103                    label: Some("geom_pipeline_layout"),
104                    bind_group_layouts: &[&camera_bgl],
105                    push_constant_ranges: &[],
106                });
107
108        let vertex_layout = wgpu::VertexBufferLayout {
109            array_stride: std::mem::size_of::<GeoVertex>() as wgpu::BufferAddress,
110            step_mode: wgpu::VertexStepMode::Vertex,
111            attributes: &[
112                // position: vec2<f32> at location 0
113                wgpu::VertexAttribute {
114                    offset: 0,
115                    shader_location: 0,
116                    format: wgpu::VertexFormat::Float32x2,
117                },
118                // color: vec4<f32> at location 1
119                wgpu::VertexAttribute {
120                    offset: 8,
121                    shader_location: 1,
122                    format: wgpu::VertexFormat::Float32x4,
123                },
124            ],
125        };
126
127        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
128            label: Some("geom_pipeline"),
129            layout: Some(&pipeline_layout),
130            vertex: wgpu::VertexState {
131                module: &shader,
132                entry_point: Some("vs_main"),
133                buffers: &[vertex_layout],
134                compilation_options: Default::default(),
135            },
136            fragment: Some(wgpu::FragmentState {
137                module: &shader,
138                entry_point: Some("fs_main"),
139                targets: &[Some(wgpu::ColorTargetState {
140                    format: surface_format,
141                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
142                    write_mask: wgpu::ColorWrites::ALL,
143                })],
144                compilation_options: Default::default(),
145            }),
146            primitive: wgpu::PrimitiveState {
147                topology: wgpu::PrimitiveTopology::TriangleList,
148                strip_index_format: None,
149                front_face: wgpu::FrontFace::Ccw,
150                cull_mode: None,
151                polygon_mode: wgpu::PolygonMode::Fill,
152                unclipped_depth: false,
153                conservative: false,
154            },
155            depth_stencil: None,
156            multisample: wgpu::MultisampleState::default(),
157            multiview: None,
158            cache: None,
159        });
160
161        Self {
162            pipeline,
163            vertices: Vec::with_capacity(MAX_VERTICES),
164        }
165    }
166
167    /// Push a single colored triangle (3 vertices).
168    pub fn add_triangle(
169        &mut self,
170        x1: f32, y1: f32,
171        x2: f32, y2: f32,
172        x3: f32, y3: f32,
173        r: f32, g: f32, b: f32, a: f32,
174    ) {
175        if self.vertices.len() + 3 > MAX_VERTICES {
176            return; // silently drop if full
177        }
178        let color = [r, g, b, a];
179        self.vertices.push(GeoVertex { position: [x1, y1], color });
180        self.vertices.push(GeoVertex { position: [x2, y2], color });
181        self.vertices.push(GeoVertex { position: [x3, y3], color });
182    }
183
184    /// Push a thick line segment as two triangles forming a quad.
185    /// The quad extends `thickness/2` on each side of the line.
186    pub fn add_line(
187        &mut self,
188        x1: f32, y1: f32,
189        x2: f32, y2: f32,
190        thickness: f32,
191        r: f32, g: f32, b: f32, a: f32,
192    ) {
193        if self.vertices.len() + 6 > MAX_VERTICES {
194            return;
195        }
196        let dx = x2 - x1;
197        let dy = y2 - y1;
198        let len = (dx * dx + dy * dy).sqrt();
199        if len < 1e-8 {
200            return; // degenerate line
201        }
202        // Perpendicular direction, normalized, scaled by half-thickness
203        let half = thickness * 0.5;
204        let nx = -dy / len * half;
205        let ny = dx / len * half;
206
207        let color = [r, g, b, a];
208        // Quad corners: p1+n, p1-n, p2-n, p2+n
209        let a0 = GeoVertex { position: [x1 + nx, y1 + ny], color };
210        let b0 = GeoVertex { position: [x1 - nx, y1 - ny], color };
211        let c0 = GeoVertex { position: [x2 - nx, y2 - ny], color };
212        let d0 = GeoVertex { position: [x2 + nx, y2 + ny], color };
213
214        // Two triangles: a0-b0-c0, a0-c0-d0
215        self.vertices.push(a0);
216        self.vertices.push(b0);
217        self.vertices.push(c0);
218        self.vertices.push(a0);
219        self.vertices.push(c0);
220        self.vertices.push(d0);
221    }
222
223    /// Upload vertices and draw. Call after all add_triangle/add_line for this frame.
224    /// Does NOT clear the render target (uses LoadOp::Load to layer over sprites).
225    pub fn flush(
226        &mut self,
227        device: &wgpu::Device,
228        encoder: &mut wgpu::CommandEncoder,
229        target: &wgpu::TextureView,
230        camera_bind_group: &wgpu::BindGroup,
231    ) {
232        if self.vertices.is_empty() {
233            return;
234        }
235
236        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
237            label: Some("geom_vertex_buffer"),
238            contents: bytemuck::cast_slice(&self.vertices),
239            usage: wgpu::BufferUsages::VERTEX,
240        });
241
242        let vertex_count = self.vertices.len() as u32;
243
244        {
245            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
246                label: Some("geom_render_pass"),
247                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
248                    view: target,
249                    resolve_target: None,
250                    ops: wgpu::Operations {
251                        load: wgpu::LoadOp::Load, // don't clear — overlay on top of sprites
252                        store: wgpu::StoreOp::Store,
253                    },
254                })],
255                depth_stencil_attachment: None,
256                timestamp_writes: None,
257                occlusion_query_set: None,
258            });
259
260            pass.set_pipeline(&self.pipeline);
261            pass.set_bind_group(0, camera_bind_group, &[]);
262            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
263            pass.draw(0..vertex_count, 0..1);
264        }
265
266        self.vertices.clear();
267    }
268
269    /// Render a slice of GeoCommands with configurable load op.
270    ///
271    /// `clear_color`: `Some(color)` → `LoadOp::Clear(color)` (first pass),
272    ///                 `None` → `LoadOp::Load` (subsequent passes).
273    pub fn flush_commands(
274        &mut self,
275        device: &wgpu::Device,
276        encoder: &mut wgpu::CommandEncoder,
277        target: &wgpu::TextureView,
278        camera_bind_group: &wgpu::BindGroup,
279        commands: &[crate::scripting::geometry_ops::GeoCommand],
280        clear_color: Option<wgpu::Color>,
281    ) {
282        if commands.is_empty() {
283            return;
284        }
285
286        // Convert GeoCommands to vertices
287        let mut verts: Vec<GeoVertex> = Vec::new();
288        for cmd in commands {
289            match cmd {
290                crate::scripting::geometry_ops::GeoCommand::Triangle {
291                    x1, y1, x2, y2, x3, y3, r, g, b, a, ..
292                } => {
293                    let color = [*r, *g, *b, *a];
294                    verts.push(GeoVertex { position: [*x1, *y1], color });
295                    verts.push(GeoVertex { position: [*x2, *y2], color });
296                    verts.push(GeoVertex { position: [*x3, *y3], color });
297                }
298                crate::scripting::geometry_ops::GeoCommand::LineSeg {
299                    x1, y1, x2, y2, thickness, r, g, b, a, ..
300                } => {
301                    let dx = x2 - x1;
302                    let dy = y2 - y1;
303                    let len = (dx * dx + dy * dy).sqrt();
304                    if len < 1e-8 {
305                        continue;
306                    }
307                    let half = thickness * 0.5;
308                    let nx = -dy / len * half;
309                    let ny = dx / len * half;
310                    let color = [*r, *g, *b, *a];
311                    let a0 = GeoVertex { position: [x1 + nx, y1 + ny], color };
312                    let b0 = GeoVertex { position: [x1 - nx, y1 - ny], color };
313                    let c0 = GeoVertex { position: [x2 - nx, y2 - ny], color };
314                    let d0 = GeoVertex { position: [x2 + nx, y2 + ny], color };
315                    verts.push(a0);
316                    verts.push(b0);
317                    verts.push(c0);
318                    verts.push(a0);
319                    verts.push(c0);
320                    verts.push(d0);
321                }
322            }
323        }
324
325        if verts.is_empty() {
326            return;
327        }
328
329        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
330            label: Some("geom_vertex_buffer"),
331            contents: bytemuck::cast_slice(&verts),
332            usage: wgpu::BufferUsages::VERTEX,
333        });
334
335        let vertex_count = verts.len() as u32;
336
337        let load_op = match clear_color {
338            Some(color) => wgpu::LoadOp::Clear(color),
339            None => wgpu::LoadOp::Load,
340        };
341
342        {
343            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
344                label: Some("geom_render_pass"),
345                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
346                    view: target,
347                    resolve_target: None,
348                    ops: wgpu::Operations {
349                        load: load_op,
350                        store: wgpu::StoreOp::Store,
351                    },
352                })],
353                depth_stencil_attachment: None,
354                timestamp_writes: None,
355                occlusion_query_set: None,
356            });
357
358            pass.set_pipeline(&self.pipeline);
359            pass.set_bind_group(0, camera_bind_group, &[]);
360            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
361            pass.draw(0..vertex_count, 0..1);
362        }
363    }
364
365    /// Discard all queued vertices without rendering.
366    pub fn clear(&mut self) {
367        self.vertices.clear();
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn geo_vertex_is_24_bytes() {
377        // position (2 * f32 = 8) + color (4 * f32 = 16) = 24 bytes
378        assert_eq!(std::mem::size_of::<GeoVertex>(), 24);
379    }
380
381    #[test]
382    fn line_quad_geometry_is_correct() {
383        // Verify the perpendicular math for a horizontal line
384        let (x1, y1, x2, y2) = (0.0f32, 0.0, 10.0, 0.0);
385        let thickness = 2.0f32;
386        let dx = x2 - x1;
387        let dy = y2 - y1;
388        let len = (dx * dx + dy * dy).sqrt();
389        let half = thickness * 0.5;
390        let nx = -dy / len * half;
391        let ny = dx / len * half;
392
393        // For a horizontal line, perpendicular is vertical
394        assert!((nx - 0.0).abs() < 1e-6, "nx should be 0 for horizontal line");
395        assert!((ny - 1.0).abs() < 1e-6, "ny should be 1 for horizontal line");
396    }
397
398    #[test]
399    fn diagonal_line_perpendicular() {
400        let (x1, y1, x2, y2) = (0.0f32, 0.0, 10.0, 10.0);
401        let thickness = 2.0f32;
402        let dx = x2 - x1;
403        let dy = y2 - y1;
404        let len = (dx * dx + dy * dy).sqrt();
405        let half = thickness * 0.5;
406        let nx = -dy / len * half;
407        let ny = dx / len * half;
408
409        // Perpendicular length should equal half-thickness
410        let perp_len = (nx * nx + ny * ny).sqrt();
411        assert!((perp_len - 1.0).abs() < 1e-6, "perpendicular length should be half-thickness");
412    }
413}