Skip to main content

arcane_core/renderer/
mod.rs

1mod gpu;
2mod sprite;
3mod texture;
4pub mod camera;
5mod tilemap;
6mod lighting;
7pub mod font;
8pub mod msdf;
9pub mod shader;
10pub mod postprocess;
11pub mod radiance;
12pub mod geometry;
13pub mod rendertarget;
14// Test harness is always public for integration tests
15pub mod test_harness;
16
17pub use gpu::GpuContext;
18pub use sprite::{SpriteCommand, SpritePipeline};
19pub use texture::{TextureId, TextureStore};
20pub use camera::Camera2D;
21pub use tilemap::{Tilemap, TilemapStore};
22pub use lighting::{LightingState, LightingUniform, PointLight, LightData, MAX_LIGHTS};
23pub use msdf::{MsdfFont, MsdfFontStore, MsdfGlyph};
24pub use shader::ShaderStore;
25pub use postprocess::PostProcessPipeline;
26pub use radiance::{RadiancePipeline, RadianceState, EmissiveSurface, Occluder, DirectionalLight, SpotLight};
27pub use geometry::GeometryBatch;
28pub use rendertarget::RenderTargetStore;
29
30use crate::scripting::geometry_ops::GeoCommand;
31use anyhow::Result;
32
33/// A single step in the interleaved render schedule.
34/// Sprites and geometry are merged by layer so that layer ordering is respected
35/// across both pipeline types.
36enum RenderOp {
37    /// Render a contiguous range of sorted sprite commands.
38    Sprites { start: usize, end: usize },
39    /// Render a contiguous range of sorted geometry commands.
40    Geometry { start: usize, end: usize },
41}
42
43/// Build an interleaved render schedule from sorted sprite and geometry commands.
44///
45/// Both input slices must be pre-sorted by layer. The schedule merges them so that
46/// lower layers render first. At the same layer, sprites render before geometry.
47fn build_render_schedule(
48    sprites: &[SpriteCommand],
49    geo: &[GeoCommand],
50) -> Vec<RenderOp> {
51    let mut schedule = Vec::new();
52    let mut si = 0;
53    let mut gi = 0;
54
55    while si < sprites.len() || gi < geo.len() {
56        // Decide whether to emit sprites or geometry next.
57        // At the same layer, sprites go first.
58        let do_sprites = if si >= sprites.len() {
59            false
60        } else if gi >= geo.len() {
61            true
62        } else {
63            sprites[si].layer <= geo[gi].layer()
64        };
65
66        if do_sprites {
67            let start = si;
68            // Consume sprites whose layer is <= the next geo command's layer.
69            // This groups all sprites that should render before the next geo block.
70            let bound = if gi < geo.len() { geo[gi].layer() } else { i32::MAX };
71            while si < sprites.len() && sprites[si].layer <= bound {
72                si += 1;
73            }
74            schedule.push(RenderOp::Sprites { start, end: si });
75        } else {
76            let start = gi;
77            // Consume geo commands whose layer is strictly < the next sprite's layer.
78            // Strict < because at the same layer, sprites go first.
79            let bound = if si < sprites.len() { sprites[si].layer } else { i32::MAX };
80            while gi < geo.len() && geo[gi].layer() < bound {
81                gi += 1;
82            }
83            schedule.push(RenderOp::Geometry { start, end: gi });
84        }
85    }
86
87    schedule
88}
89
90/// Top-level renderer that owns the GPU context, sprite pipeline, and textures.
91pub struct Renderer {
92    pub gpu: GpuContext,
93    pub sprites: SpritePipeline,
94    pub geometry: GeometryBatch,
95    pub shaders: ShaderStore,
96    pub postprocess: PostProcessPipeline,
97    pub textures: TextureStore,
98    pub camera: Camera2D,
99    pub lighting: LightingState,
100    pub radiance: RadiancePipeline,
101    pub radiance_state: RadianceState,
102    /// Off-screen render targets (owns the GPU textures; bind groups in TextureStore).
103    pub render_targets: RenderTargetStore,
104    /// Sprite commands queued for the current frame.
105    pub frame_commands: Vec<SpriteCommand>,
106    /// Geometry commands queued for the current frame (drained from GeoState).
107    pub geo_commands: Vec<GeoCommand>,
108    /// Display scale factor (e.g. 2.0 on Retina). Used to convert physical → logical pixels.
109    pub scale_factor: f32,
110    /// Clear color for the render pass background. Default: dark blue-gray.
111    pub clear_color: [f32; 4],
112}
113
114impl Renderer {
115    /// Create a new renderer attached to a winit window.
116    pub fn new(window: std::sync::Arc<winit::window::Window>) -> Result<Self> {
117        let scale_factor = window.scale_factor() as f32;
118        let gpu = GpuContext::new(window)?;
119        let sprites = SpritePipeline::new(&gpu);
120        let geometry = GeometryBatch::new(&gpu);
121        let shaders = ShaderStore::new(&gpu);
122        let postprocess = PostProcessPipeline::new(&gpu);
123        let radiance_pipeline = RadiancePipeline::new(&gpu);
124        let textures = TextureStore::new();
125        // Set camera viewport to logical pixels so world units are DPI-independent
126        let logical_w = gpu.config.width as f32 / scale_factor;
127        let logical_h = gpu.config.height as f32 / scale_factor;
128        let camera = Camera2D {
129            viewport_size: [logical_w, logical_h],
130            ..Camera2D::default()
131        };
132        Ok(Self {
133            gpu,
134            sprites,
135            geometry,
136            shaders,
137            postprocess,
138            radiance: radiance_pipeline,
139            radiance_state: RadianceState::new(),
140            textures,
141            camera,
142            lighting: LightingState::default(),
143            render_targets: RenderTargetStore::new(),
144            frame_commands: Vec::new(),
145            geo_commands: Vec::new(),
146            scale_factor,
147            clear_color: [0.1, 0.1, 0.15, 1.0],
148        })
149    }
150
151    /// Set geometry commands for the current frame (drained from GeoState in dev.rs).
152    pub fn set_geo_commands(&mut self, cmds: Vec<GeoCommand>) {
153        self.geo_commands = cmds;
154    }
155
156    /// Render the current frame's sprite and geometry commands, interleaved by layer.
157    pub fn render_frame(&mut self) -> Result<()> {
158        let output = self.gpu.surface.get_current_texture()?;
159        let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
160
161        let mut encoder = self.gpu.device.create_command_encoder(
162            &wgpu::CommandEncoderDescriptor { label: Some("frame_encoder") },
163        );
164
165        // Sort sprites by layer → shader_id → blend_mode → texture_id for batching
166        self.frame_commands.sort_by(|a, b| {
167            a.layer
168                .cmp(&b.layer)
169                .then(a.shader_id.cmp(&b.shader_id))
170                .then(a.blend_mode.cmp(&b.blend_mode))
171                .then(a.texture_id.cmp(&b.texture_id))
172        });
173
174        // Sort geometry commands by layer
175        self.geo_commands.sort_by_key(|c| c.layer());
176
177        // Build interleaved render schedule
178        let schedule = build_render_schedule(&self.frame_commands, &self.geo_commands);
179
180        // Flush dirty custom shader uniforms
181        self.shaders.flush(&self.gpu.queue);
182
183        let lighting_uniform = self.lighting.to_uniform();
184        let clear_color = wgpu::Color {
185            r: self.clear_color[0] as f64,
186            g: self.clear_color[1] as f64,
187            b: self.clear_color[2] as f64,
188            a: self.clear_color[3] as f64,
189        };
190
191        // Write camera + lighting uniforms once for the whole frame
192        self.sprites.prepare(&self.gpu.device, &self.gpu.queue, &self.camera, &lighting_uniform);
193
194        // Run radiance cascade GI compute pass (if enabled)
195        let gi_active = self.radiance.compute(
196            &self.gpu,
197            &mut encoder,
198            &self.radiance_state,
199            &self.lighting,
200            self.camera.x,
201            self.camera.y,
202            self.camera.viewport_size[0],
203            self.camera.viewport_size[1],
204        );
205
206        if self.postprocess.has_effects() {
207            // Render to offscreen target, then apply effects to surface
208            {
209                let sprite_target = self.postprocess.sprite_target(&self.gpu);
210                let camera_bg = self.sprites.camera_bind_group();
211
212                if schedule.is_empty() {
213                    // No commands at all — still need to clear
214                    self.sprites.render(
215                        &self.gpu.device, &self.gpu.queue, &self.textures, &self.shaders,
216                        &[], sprite_target, &mut encoder, Some(clear_color),
217                    );
218                } else {
219                    let mut first = true;
220                    for op in &schedule {
221                        let cc = if first { Some(clear_color) } else { None };
222                        first = false;
223                        match op {
224                            RenderOp::Sprites { start, end } => {
225                                self.sprites.render(
226                                    &self.gpu.device, &self.gpu.queue, &self.textures, &self.shaders,
227                                    &self.frame_commands[*start..*end],
228                                    sprite_target, &mut encoder, cc,
229                                );
230                            }
231                            RenderOp::Geometry { start, end } => {
232                                self.geometry.flush_commands(
233                                    &self.gpu.device, &mut encoder, sprite_target,
234                                    camera_bg, &self.geo_commands[*start..*end], cc,
235                                );
236                            }
237                        }
238                    }
239                }
240            }
241            // Apply GI light texture to the offscreen target before post-processing
242            if gi_active {
243                let sprite_target = self.postprocess.sprite_target(&self.gpu);
244                self.radiance.compose(&mut encoder, sprite_target);
245            }
246            self.postprocess.apply(&self.gpu, &mut encoder, &view);
247        } else {
248            // No effects — render directly to surface
249            let camera_bg = self.sprites.camera_bind_group();
250
251            if schedule.is_empty() {
252                // No commands at all — still need to clear
253                self.sprites.render(
254                    &self.gpu.device, &self.gpu.queue, &self.textures, &self.shaders,
255                    &[], &view, &mut encoder, Some(clear_color),
256                );
257            } else {
258                let mut first = true;
259                for op in &schedule {
260                    let cc = if first { Some(clear_color) } else { None };
261                    first = false;
262                    match op {
263                        RenderOp::Sprites { start, end } => {
264                            self.sprites.render(
265                                &self.gpu.device, &self.gpu.queue, &self.textures, &self.shaders,
266                                &self.frame_commands[*start..*end],
267                                &view, &mut encoder, cc,
268                            );
269                        }
270                        RenderOp::Geometry { start, end } => {
271                            self.geometry.flush_commands(
272                                &self.gpu.device, &mut encoder, &view,
273                                camera_bg, &self.geo_commands[*start..*end], cc,
274                            );
275                        }
276                    }
277                }
278            }
279            // Apply GI light texture to the surface
280            if gi_active {
281                self.radiance.compose(&mut encoder, &view);
282            }
283        }
284
285        self.gpu.queue.submit(std::iter::once(encoder.finish()));
286        output.present();
287
288        self.frame_commands.clear();
289        self.geo_commands.clear();
290        Ok(())
291    }
292
293    /// Resize the surface when the window size changes.
294    /// GPU surface uses physical pixels; camera viewport uses logical pixels.
295    pub fn resize(&mut self, physical_width: u32, physical_height: u32, scale_factor: f32) {
296        if physical_width > 0 && physical_height > 0 {
297            self.scale_factor = scale_factor;
298            self.gpu.config.width = physical_width;
299            self.gpu.config.height = physical_height;
300            self.gpu.surface.configure(&self.gpu.device, &self.gpu.config);
301            // Camera uses logical pixels so 1 world unit ≈ 1 logical pixel at zoom 1
302            self.camera.viewport_size = [
303                physical_width as f32 / scale_factor,
304                physical_height as f32 / scale_factor,
305            ];
306        }
307    }
308
309    // ── Render target helpers ──────────────────────────────────────────────
310
311    /// Allocate a new off-screen render target and register it as a samplable texture.
312    pub fn create_render_target(&mut self, id: u32, width: u32, height: u32) {
313        let surface_format = self.gpu.config.format;
314        self.render_targets.create(&self.gpu.device, id, width, height, surface_format);
315        if let Some(view) = self.render_targets.get_view(id) {
316            self.textures.register_render_target(
317                &self.gpu.device,
318                &self.sprites.texture_bind_group_layout,
319                id,
320                view,
321                width,
322                height,
323            );
324        }
325    }
326
327    /// Free a render target's GPU resources and remove it from the texture store.
328    pub fn destroy_render_target(&mut self, id: u32) {
329        self.render_targets.destroy(id);
330        self.textures.unregister_render_target(id);
331    }
332
333    /// Render sprite commands into each queued render target (off-screen pre-pass).
334    ///
335    /// Call this BEFORE `render_frame()` so targets are ready as sprite inputs.
336    /// Uses a separate command encoder + GPU submit to avoid ordering conflicts.
337    pub fn render_targets_prepass(
338        &mut self,
339        target_queues: std::collections::HashMap<u32, Vec<SpriteCommand>>,
340    ) {
341        if target_queues.is_empty() {
342            return;
343        }
344
345        let mut encoder = self.gpu.device.create_command_encoder(
346            &wgpu::CommandEncoderDescriptor { label: Some("rt_encoder") },
347        );
348        let lighting_uniform = self.lighting.to_uniform();
349
350        for (target_id, mut cmds) in target_queues {
351            let view = self.render_targets.get_view(target_id);
352            let dims = self.render_targets.get_dims(target_id);
353            if let (Some(view), Some((tw, th))) = (view, dims) {
354                // Sort by layer → shader_id → blend_mode → texture_id
355                cmds.sort_by(|a, b| {
356                    a.layer
357                        .cmp(&b.layer)
358                        .then(a.shader_id.cmp(&b.shader_id))
359                        .then(a.blend_mode.cmp(&b.blend_mode))
360                        .then(a.texture_id.cmp(&b.texture_id))
361                });
362                // Orthographic camera: (0,0) = top-left of the render target
363                let target_camera = Camera2D {
364                    x: tw as f32 / 2.0,
365                    y: th as f32 / 2.0,
366                    zoom: 1.0,
367                    viewport_size: [tw as f32, th as f32],
368                    ..Camera2D::default()
369                };
370                self.sprites.prepare(&self.gpu.device, &self.gpu.queue, &target_camera, &lighting_uniform);
371                self.sprites.render(
372                    &self.gpu.device,
373                    &self.gpu.queue,
374                    &self.textures,
375                    &self.shaders,
376                    &cmds,
377                    view,
378                    &mut encoder,
379                    Some(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }),
380                );
381            }
382        }
383
384        self.gpu.queue.submit(std::iter::once(encoder.finish()));
385    }
386}