Skip to main content

batched_renderer/
batched_renderer.rs

1//! Batched renderer example demonstrating the unified instance rendering system.
2//!
3//! Renders various shapes using the `BatchRenderer2D`:
4//! - Solid colored quads at different depths
5//! - Rounded rectangles with SDF corners
6//! - Border-only outlines
7//! - Semi-transparent overlapping quads
8//! - Animated position and color changes
9//!
10//! Run with: `cargo run -p astrelis-render --example batched_renderer`
11//!
12//! Select render tier with `--tier`:
13//!   `cargo run -p astrelis-render --example batched_renderer -- --tier auto`
14//!   `cargo run -p astrelis-render --example batched_renderer -- --tier 1`      (or `direct`)
15//!   `cargo run -p astrelis-render --example batched_renderer -- --tier 2`      (or `indirect`)
16//!   `cargo run -p astrelis-render --example batched_renderer -- --tier 3`      (or `bindless`)
17
18use std::collections::HashMap;
19use std::sync::Arc;
20
21use astrelis_core::logging;
22use astrelis_render::batched::{
23    create_batch_renderer_2d, BatchRenderer2D, BestBatchCapability2D, BindlessBatchCapability2D,
24    DirectBatchCapability2D, DrawBatch2D, DrawType2D, IndirectBatchCapability2D, RenderTier,
25    UnifiedInstance2D,
26};
27use astrelis_render::{
28    GraphicsContext, GraphicsContextDescriptor, RenderableWindow, WindowContextDescriptor,
29};
30use astrelis_winit::app::run_app;
31use astrelis_winit::window::{WindowBackend, WindowDescriptor, WinitPhysicalSize};
32use astrelis_winit::WindowId;
33
34const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
35
36struct App {
37    context: Arc<GraphicsContext>,
38    windows: HashMap<WindowId, RenderableWindow>,
39    renderer: Box<dyn BatchRenderer2D>,
40    depth_texture: wgpu::Texture,
41    depth_view: wgpu::TextureView,
42    depth_width: u32,
43    depth_height: u32,
44    frame_count: u64,
45}
46
47impl App {
48    fn ensure_depth_buffer(&mut self, width: u32, height: u32) {
49        if self.depth_width == width && self.depth_height == height {
50            return;
51        }
52        let w = width.max(1);
53        let h = height.max(1);
54        let texture = self.context.device().create_texture(&wgpu::TextureDescriptor {
55            label: Some("example_depth"),
56            size: wgpu::Extent3d {
57                width: w,
58                height: h,
59                depth_or_array_layers: 1,
60            },
61            mip_level_count: 1,
62            sample_count: 1,
63            dimension: wgpu::TextureDimension::D2,
64            format: DEPTH_FORMAT,
65            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
66            view_formats: &[],
67        });
68        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
69        self.depth_texture = texture;
70        self.depth_view = view;
71        self.depth_width = w;
72        self.depth_height = h;
73    }
74
75    /// Build an orthographic projection matrix for the given viewport size.
76    /// Maps (0,0) at top-left to (width, height) at bottom-right.
77    /// Z range: 0.0 (far) to 1.0 (near), matching GreaterEqual depth compare.
78    fn ortho_projection(width: f32, height: f32) -> [[f32; 4]; 4] {
79        [
80            [2.0 / width, 0.0, 0.0, 0.0],
81            [0.0, -2.0 / height, 0.0, 0.0],
82            [0.0, 0.0, 1.0, 0.0],
83            [-1.0, 1.0, 0.0, 1.0],
84        ]
85    }
86
87    /// Generate the demo instances for the current frame.
88    fn build_instances(&self, width: f32, height: f32) -> Vec<UnifiedInstance2D> {
89        let t = self.frame_count as f32 / 60.0;
90        let mut instances = Vec::new();
91
92        // --- Background panel (gray, full viewport, furthest back) ---
93        instances.push(UnifiedInstance2D {
94            position: [10.0, 10.0],
95            size: [width - 20.0, height - 20.0],
96            color: [0.15, 0.15, 0.18, 1.0],
97            border_radius: 12.0,
98            z_depth: 0.01,
99            draw_type: DrawType2D::Quad as u32,
100            ..Default::default()
101        });
102
103        // --- Grid of colored quads ---
104        let cols = 5;
105        let rows = 3;
106        let margin = 30.0;
107        let gap = 10.0;
108        let cell_w = (width - 2.0 * margin - (cols as f32 - 1.0) * gap) / cols as f32;
109        let cell_h = (height * 0.5 - margin - (rows as f32 - 1.0) * gap) / rows as f32;
110
111        for row in 0..rows {
112            for col in 0..cols {
113                let x = margin + col as f32 * (cell_w + gap);
114                let y = margin + row as f32 * (cell_h + gap);
115                let idx = row * cols + col;
116
117                // Hue-shift color based on grid position
118                let hue = (idx as f32 / (rows * cols) as f32) * 360.0;
119                let (r, g, b) = hsl_to_rgb(hue, 0.7, 0.55);
120
121                instances.push(UnifiedInstance2D {
122                    position: [x, y],
123                    size: [cell_w, cell_h],
124                    color: [r, g, b, 1.0],
125                    border_radius: 6.0,
126                    z_depth: 0.1 + idx as f32 * 0.001,
127                    draw_type: DrawType2D::Quad as u32,
128                    ..Default::default()
129                });
130            }
131        }
132
133        // --- Animated floating rounded rect ---
134        let float_x = width * 0.5 + (t * 0.8).sin() * width * 0.25 - 60.0;
135        let float_y = height * 0.35 + (t * 1.2).cos() * 30.0;
136        instances.push(UnifiedInstance2D {
137            position: [float_x, float_y],
138            size: [120.0, 50.0],
139            color: [1.0, 0.85, 0.2, 0.9],
140            border_radius: 25.0,
141            z_depth: 0.8,
142            draw_type: DrawType2D::Quad as u32,
143            ..Default::default()
144        });
145
146        // --- Border-only outlines (bottom area) ---
147        let outline_y = height * 0.6;
148        for i in 0..4 {
149            let x = margin + i as f32 * 140.0;
150            let thickness = 1.0 + i as f32;
151            let radius = 4.0 + i as f32 * 8.0;
152            instances.push(UnifiedInstance2D {
153                position: [x, outline_y],
154                size: [120.0, 80.0],
155                color: [0.4, 0.8, 1.0, 1.0],
156                border_radius: radius,
157                border_thickness: thickness,
158                z_depth: 0.5,
159                draw_type: DrawType2D::Quad as u32,
160                ..Default::default()
161            });
162        }
163
164        // --- Overlapping transparent quads (demonstrating depth + alpha) ---
165        let overlap_x = width * 0.5 - 100.0;
166        let overlap_y = height * 0.75;
167        let colors = [
168            [1.0, 0.3, 0.3, 0.6],
169            [0.3, 1.0, 0.3, 0.6],
170            [0.3, 0.3, 1.0, 0.6],
171        ];
172        for (i, color) in colors.iter().enumerate() {
173            let offset = i as f32 * 40.0;
174            instances.push(UnifiedInstance2D {
175                position: [overlap_x + offset, overlap_y + offset * 0.5],
176                size: [120.0, 80.0],
177                color: *color,
178                border_radius: 8.0,
179                z_depth: 0.6 + i as f32 * 0.05,
180                draw_type: DrawType2D::Quad as u32,
181                ..Default::default()
182            });
183        }
184
185        // --- Pulsing circle (via large border_radius) ---
186        let pulse = ((t * 2.0).sin() * 0.5 + 0.5) * 0.4 + 0.6;
187        let circle_size = 60.0 * pulse;
188        instances.push(UnifiedInstance2D {
189            position: [width - margin - circle_size, outline_y + 10.0],
190            size: [circle_size, circle_size],
191            color: [1.0, 0.5, 0.0, 0.95],
192            border_radius: circle_size * 0.5,
193            z_depth: 0.7,
194            draw_type: DrawType2D::Quad as u32,
195            ..Default::default()
196        });
197
198        // --- Small shader-clipped quad (demonstrating clip rect) ---
199        let clip_x = margin;
200        let clip_y = height * 0.75;
201        instances.push(UnifiedInstance2D {
202            position: [clip_x, clip_y],
203            size: [200.0, 60.0],
204            color: [0.9, 0.2, 0.7, 1.0],
205            border_radius: 4.0,
206            z_depth: 0.55,
207            draw_type: DrawType2D::Quad as u32,
208            // Clip to a smaller region to demonstrate shader clipping
209            clip_min: [clip_x + 20.0, clip_y + 10.0],
210            clip_max: [clip_x + 160.0, clip_y + 50.0],
211            ..Default::default()
212        });
213
214        instances
215    }
216}
217
218/// Simple HSL to RGB conversion.
219fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
220    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
221    let h_prime = h / 60.0;
222    let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
223    let (r1, g1, b1) = if h_prime < 1.0 {
224        (c, x, 0.0)
225    } else if h_prime < 2.0 {
226        (x, c, 0.0)
227    } else if h_prime < 3.0 {
228        (0.0, c, x)
229    } else if h_prime < 4.0 {
230        (0.0, x, c)
231    } else if h_prime < 5.0 {
232        (x, 0.0, c)
233    } else {
234        (c, 0.0, x)
235    };
236    let m = l - c * 0.5;
237    (r1 + m, g1 + m, b1 + m)
238}
239
240/// Parse the `--tier` CLI argument to select a render tier.
241///
242/// The three tiers represent increasing levels of GPU capability:
243/// - **Tier 1 (Direct):** One `draw()` call per texture group. Works on all hardware.
244/// - **Tier 2 (Indirect):** Uses `multi_draw_indirect()` per texture group, batching
245///   draw calls into GPU-side indirect buffers. Requires `MULTI_DRAW_INDIRECT`.
246/// - **Tier 3 (Bindless):** Single `multi_draw_indirect()` per frame using texture
247///   binding arrays. Requires `TEXTURE_BINDING_ARRAY` + `MULTI_DRAW_INDIRECT`.
248///
249/// Passing `--tier auto` (or omitting the flag) lets the engine choose the best
250/// tier supported by the current GPU.
251fn parse_tier() -> Option<RenderTier> {
252    let args: Vec<String> = std::env::args().collect();
253    for (i, arg) in args.iter().enumerate() {
254        if arg == "--tier" {
255            if let Some(value) = args.get(i + 1) {
256                return match value.as_str() {
257                    "1" | "direct" => Some(RenderTier::Direct),
258                    "2" | "indirect" => Some(RenderTier::Indirect),
259                    "3" | "bindless" => Some(RenderTier::Bindless),
260                    "auto" => None,
261                    other => {
262                        eprintln!(
263                            "Unknown tier '{other}'. Options: 1|direct, 2|indirect, 3|bindless, auto"
264                        );
265                        std::process::exit(1);
266                    }
267                };
268            }
269        }
270    }
271    // Default: auto-detect
272    None
273}
274
275fn main() {
276    logging::init();
277
278    run_app(|ctx| {
279        let tier_override = parse_tier();
280
281        // Use the capability API to configure GPU requirements.
282        // For auto-detect, request the best capability (graceful degradation).
283        // For a specific tier, require that tier's capability.
284        let descriptor = match tier_override {
285            None => GraphicsContextDescriptor::new()
286                .request_capability::<BestBatchCapability2D>(),
287            Some(RenderTier::Direct) => GraphicsContextDescriptor::new()
288                .require_capability::<DirectBatchCapability2D>(),
289            Some(RenderTier::Indirect) => GraphicsContextDescriptor::new()
290                .require_capability::<IndirectBatchCapability2D>(),
291            Some(RenderTier::Bindless) => GraphicsContextDescriptor::new()
292                .require_capability::<BindlessBatchCapability2D>(),
293        };
294        let graphics_ctx =
295            pollster::block_on(GraphicsContext::new_owned_with_descriptor(descriptor))
296                .expect("Failed to create graphics context");
297
298        let window = ctx
299            .create_window(WindowDescriptor {
300                title: "Batched Renderer Example".to_string(),
301                size: Some(WinitPhysicalSize::new(800.0, 600.0)),
302                ..Default::default()
303            })
304            .expect("Failed to create window");
305
306        let surface_format = wgpu::TextureFormat::Bgra8UnormSrgb;
307
308        let renderable_window = RenderableWindow::new_with_descriptor(
309            window,
310            graphics_ctx.clone(),
311            WindowContextDescriptor {
312                format: Some(surface_format),
313                ..Default::default()
314            },
315        )
316        .expect("Failed to create renderable window");
317
318        let window_id = renderable_window.id();
319
320        let renderer = create_batch_renderer_2d(
321            graphics_ctx.clone(),
322            surface_format,
323            tier_override,
324        );
325
326        tracing::info!("Using render tier: {}", renderer.tier());
327
328        // Create initial depth buffer
329        let depth_texture = graphics_ctx
330            .device()
331            .create_texture(&wgpu::TextureDescriptor {
332                label: Some("example_depth"),
333                size: wgpu::Extent3d {
334                    width: 1,
335                    height: 1,
336                    depth_or_array_layers: 1,
337                },
338                mip_level_count: 1,
339                sample_count: 1,
340                dimension: wgpu::TextureDimension::D2,
341                format: DEPTH_FORMAT,
342                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
343                view_formats: &[],
344            });
345        let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default());
346
347        let mut windows = HashMap::new();
348        windows.insert(window_id, renderable_window);
349
350        Box::new(App {
351            context: graphics_ctx,
352            windows,
353            renderer,
354            depth_texture,
355            depth_view,
356            depth_width: 1,
357            depth_height: 1,
358            frame_count: 0,
359        })
360    });
361}
362
363impl astrelis_winit::app::App for App {
364    fn update(
365        &mut self,
366        _ctx: &mut astrelis_winit::app::AppCtx,
367        _time: &astrelis_winit::FrameTime,
368    ) {
369        self.frame_count += 1;
370    }
371
372    fn render(
373        &mut self,
374        _ctx: &mut astrelis_winit::app::AppCtx,
375        window_id: WindowId,
376        events: &mut astrelis_winit::event::EventBatch,
377    ) {
378        // Handle resize and get dimensions (scoped to release window borrow)
379        let (phys_width, phys_height) = {
380            let Some(window) = self.windows.get_mut(&window_id) else {
381                return;
382            };
383
384            events.dispatch(|event| {
385                if let astrelis_winit::event::Event::WindowResized(size) = event {
386                    window.resized(*size);
387                    astrelis_winit::event::HandleStatus::consumed()
388                } else {
389                    astrelis_winit::event::HandleStatus::ignored()
390                }
391            });
392
393            let phys = window.physical_size();
394            (phys.width, phys.height)
395        };
396
397        let width = phys_width as f32;
398        let height = phys_height as f32;
399
400        if width < 1.0 || height < 1.0 {
401            return;
402        }
403
404        // Ensure depth buffer matches viewport
405        self.ensure_depth_buffer(phys_width, phys_height);
406
407        // Build instances and prepare GPU data
408        let instances = self.build_instances(width, height);
409        let batch = DrawBatch2D {
410            instances,
411            textures: vec![],
412            projection: Self::ortho_projection(width, height),
413        };
414        self.renderer.prepare(&batch);
415
416        let stats = self.renderer.stats();
417        if self.frame_count % 120 == 0 {
418            tracing::info!(
419                "Frame {}: {} instances ({} opaque, {} transparent), {} draw calls",
420                self.frame_count,
421                stats.instance_count,
422                stats.opaque_count,
423                stats.transparent_count,
424                stats.draw_calls,
425            );
426        }
427
428        // Re-borrow window for rendering
429        let window = self.windows.get_mut(&window_id).unwrap();
430        let mut frame = window.begin_drawing();
431
432        // Use RenderPassBuilder with depth stencil attachment
433        frame.with_pass(
434            astrelis_render::RenderPassBuilder::new()
435                .label("batched_example_pass")
436                .target(astrelis_render::RenderTarget::Surface)
437                .clear_color(astrelis_render::Color::rgba(0.08, 0.08, 0.1, 1.0))
438                .clear_depth(0.0) // 0.0 = far with GreaterEqual
439                .depth_stencil_attachment(
440                    &self.depth_view,
441                    Some(wgpu::Operations {
442                        load: wgpu::LoadOp::Clear(0.0),
443                        store: wgpu::StoreOp::Store,
444                    }),
445                    None,
446                ),
447            |pass| {
448                self.renderer.render(pass.wgpu_pass());
449            },
450        );
451
452        frame.finish();
453    }
454}