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;
14pub mod sdf;
15// Test harness is always public for integration tests
16pub mod test_harness;
17
18pub use gpu::GpuContext;
19pub use sprite::{SpriteCommand, SpritePipeline};
20pub use texture::{TextureId, TextureStore};
21pub use camera::Camera2D;
22pub use tilemap::{Tilemap, TilemapStore};
23pub use lighting::{LightingState, LightingUniform, PointLight, LightData, MAX_LIGHTS};
24pub use msdf::{MsdfFont, MsdfFontStore, MsdfGlyph};
25pub use shader::ShaderStore;
26pub use postprocess::PostProcessPipeline;
27pub use radiance::{RadiancePipeline, RadianceState, EmissiveSurface, Occluder, DirectionalLight, SpotLight};
28pub use geometry::GeometryBatch;
29pub use rendertarget::RenderTargetStore;
30pub use sdf::{SdfPipelineStore, SdfCommand, SdfFill};
31
32use crate::scripting::geometry_ops::GeoCommand;
33use crate::scripting::sdf_ops::SdfDrawCommand;
34use anyhow::Result;
35
36/// Convert a scripting-layer SdfDrawCommand to a rendering-layer SdfCommand.
37fn convert_sdf_draw_command(c: SdfDrawCommand) -> SdfCommand {
38    let fill = match c.fill_type {
39        0 => SdfFill::Solid { color: c.color },
40        1 => SdfFill::Outline { color: c.color, thickness: c.fill_param },
41        2 => SdfFill::SolidWithOutline { fill: c.color, outline: c.color2, thickness: c.fill_param },
42        3 => SdfFill::Gradient { from: c.color, to: c.color2, angle: c.fill_param, scale: c.gradient_scale },
43        4 => SdfFill::Glow { color: c.color, intensity: c.fill_param },
44        5 => SdfFill::CosinePalette {
45            a: [c.palette_params[0], c.palette_params[1], c.palette_params[2]],
46            b: [c.palette_params[3], c.palette_params[4], c.palette_params[5]],
47            c: [c.palette_params[6], c.palette_params[7], c.palette_params[8]],
48            d: [c.palette_params[9], c.palette_params[10], c.palette_params[11]],
49        },
50        _ => SdfFill::Solid { color: c.color },
51    };
52    SdfCommand {
53        sdf_expr: c.sdf_expr,
54        fill,
55        x: c.x,
56        y: c.y,
57        bounds: c.bounds,
58        layer: c.layer,
59        rotation: c.rotation,
60        scale: c.scale,
61        opacity: c.opacity,
62    }
63}
64
65/// A single step in the interleaved render schedule.
66/// Sprites, geometry, and SDF commands are merged by layer so that layer ordering
67/// is respected across all pipeline types.
68#[derive(Debug, PartialEq)]
69enum RenderOp {
70    /// Render a contiguous range of sorted sprite commands.
71    Sprites { start: usize, end: usize },
72    /// Render a contiguous range of sorted geometry commands.
73    Geometry { start: usize, end: usize },
74    /// Render a contiguous range of sorted SDF commands.
75    Sdf { start: usize, end: usize },
76}
77
78/// Build an interleaved render schedule from sorted sprite, geometry, and SDF commands.
79///
80/// All input slices must be pre-sorted by layer. The schedule merges them so that
81/// lower layers render first. At the same layer, the order is: sprites, then geometry, then SDF.
82fn build_render_schedule(
83    sprites: &[SpriteCommand],
84    geo: &[GeoCommand],
85    sdf: &[SdfCommand],
86) -> Vec<RenderOp> {
87    let mut schedule = Vec::new();
88    let mut si = 0;
89    let mut gi = 0;
90    let mut di = 0;
91
92    while si < sprites.len() || gi < geo.len() || di < sdf.len() {
93        // Get current layer for each type (MAX if exhausted)
94        let sprite_layer = if si < sprites.len() { sprites[si].layer } else { i32::MAX };
95        let geo_layer = if gi < geo.len() { geo[gi].layer() } else { i32::MAX };
96        let sdf_layer = if di < sdf.len() { sdf[di].layer } else { i32::MAX };
97
98        // Find minimum layer
99        let min_layer = sprite_layer.min(geo_layer).min(sdf_layer);
100
101        // At the same layer: sprites first, then geo, then SDF
102        if sprite_layer == min_layer {
103            let start = si;
104            // Consume all sprites at or before the next geo/sdf layer
105            let bound = geo_layer.min(sdf_layer);
106            while si < sprites.len() && sprites[si].layer <= bound {
107                si += 1;
108            }
109            schedule.push(RenderOp::Sprites { start, end: si });
110        } else if geo_layer == min_layer {
111            let start = gi;
112            // Consume geo commands at layers < next sprite layer and <= next sdf layer
113            // (sprites come before geo at same layer, but geo comes before sdf)
114            let sprite_bound = if si < sprites.len() { sprites[si].layer } else { i32::MAX };
115            let sdf_bound = if di < sdf.len() { sdf[di].layer } else { i32::MAX };
116            while gi < geo.len() && geo[gi].layer() < sprite_bound && geo[gi].layer() <= sdf_bound {
117                gi += 1;
118            }
119            schedule.push(RenderOp::Geometry { start, end: gi });
120        } else {
121            let start = di;
122            // Consume SDF commands at layers < next sprite/geo layer
123            let sprite_bound = if si < sprites.len() { sprites[si].layer } else { i32::MAX };
124            let geo_bound = if gi < geo.len() { geo[gi].layer() } else { i32::MAX };
125            while di < sdf.len() && sdf[di].layer < sprite_bound && sdf[di].layer < geo_bound {
126                di += 1;
127            }
128            schedule.push(RenderOp::Sdf { start, end: di });
129        }
130    }
131
132    schedule
133}
134
135/// Top-level renderer that owns the GPU context, sprite pipeline, and textures.
136pub struct Renderer {
137    pub gpu: GpuContext,
138    pub sprites: SpritePipeline,
139    pub geometry: GeometryBatch,
140    pub shaders: ShaderStore,
141    pub postprocess: PostProcessPipeline,
142    pub textures: TextureStore,
143    pub camera: Camera2D,
144    pub lighting: LightingState,
145    pub radiance: RadiancePipeline,
146    pub radiance_state: RadianceState,
147    /// Off-screen render targets (owns the GPU textures; bind groups in TextureStore).
148    pub render_targets: RenderTargetStore,
149    /// Sprite commands queued for the current frame.
150    pub frame_commands: Vec<SpriteCommand>,
151    /// Geometry commands queued for the current frame (drained from GeoState).
152    pub geo_commands: Vec<GeoCommand>,
153    /// SDF commands queued for the current frame (drained from SdfState).
154    pub sdf_commands: Vec<SdfCommand>,
155    /// SDF pipeline store for rendering signed distance field shapes.
156    pub sdf_pipeline: SdfPipelineStore,
157    /// Display scale factor (e.g. 2.0 on Retina). Used to convert physical → logical pixels.
158    pub scale_factor: f32,
159    /// Clear color for the render pass background. Default: dark blue-gray.
160    pub clear_color: [f32; 4],
161    /// Elapsed time in seconds (accumulated, for shader built-ins).
162    pub elapsed_time: f32,
163    /// Frame delta time in seconds (for shader built-ins).
164    pub delta_time: f32,
165    /// Mouse position in screen pixels (for shader built-ins).
166    pub mouse_pos: [f32; 2],
167    /// When true, the next render_frame() will capture the surface to a PNG.
168    pub capture_pending: bool,
169    /// PNG bytes from the last capture (taken by the frame callback).
170    pub capture_result: Option<Vec<u8>>,
171}
172
173impl Renderer {
174    /// Create a new renderer attached to a winit window.
175    pub fn new(window: std::sync::Arc<winit::window::Window>) -> Result<Self> {
176        let scale_factor = window.scale_factor() as f32;
177        let gpu = GpuContext::new(window)?;
178        let sprites = SpritePipeline::new(&gpu);
179        let geometry = GeometryBatch::new(&gpu);
180        let shaders = ShaderStore::new(&gpu);
181        let postprocess = PostProcessPipeline::new(&gpu);
182        let sdf_pipeline = SdfPipelineStore::new(&gpu);
183        let radiance_pipeline = RadiancePipeline::new(&gpu);
184        let textures = TextureStore::new();
185        // Set camera viewport to logical pixels so world units are DPI-independent
186        let logical_w = gpu.config.width as f32 / scale_factor;
187        let logical_h = gpu.config.height as f32 / scale_factor;
188        let camera = Camera2D {
189            viewport_size: [logical_w, logical_h],
190            ..Camera2D::default()
191        };
192        Ok(Self {
193            gpu,
194            sprites,
195            geometry,
196            shaders,
197            postprocess,
198            radiance: radiance_pipeline,
199            radiance_state: RadianceState::new(),
200            textures,
201            camera,
202            lighting: LightingState::default(),
203            render_targets: RenderTargetStore::new(),
204            frame_commands: Vec::new(),
205            geo_commands: Vec::new(),
206            sdf_commands: Vec::new(),
207            sdf_pipeline,
208            scale_factor,
209            clear_color: [0.1, 0.1, 0.15, 1.0],
210            elapsed_time: 0.0,
211            delta_time: 0.0,
212            mouse_pos: [0.0, 0.0],
213            capture_pending: false,
214            capture_result: None,
215        })
216    }
217
218    /// Set geometry commands for the current frame (drained from GeoState in dev.rs).
219    pub fn set_geo_commands(&mut self, cmds: Vec<GeoCommand>) {
220        self.geo_commands = cmds;
221    }
222
223    /// Set SDF commands for the current frame.
224    /// Converts SdfDrawCommand (from scripting ops) to SdfCommand (for rendering).
225    pub fn set_sdf_commands(&mut self, cmds: Vec<SdfDrawCommand>) {
226        self.sdf_commands = cmds.into_iter().map(convert_sdf_draw_command).collect();
227    }
228
229    /// Render the current frame's sprite, geometry, and SDF commands, interleaved by layer.
230    pub fn render_frame(&mut self) -> Result<()> {
231        let output = self.gpu.surface.get_current_texture()?;
232        let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
233
234        let mut encoder = self.gpu.device.create_command_encoder(
235            &wgpu::CommandEncoderDescriptor { label: Some("frame_encoder") },
236        );
237
238        // Sort sprites by layer → shader_id → blend_mode → texture_id for batching
239        self.frame_commands.sort_by(|a, b| {
240            a.layer
241                .cmp(&b.layer)
242                .then(a.shader_id.cmp(&b.shader_id))
243                .then(a.blend_mode.cmp(&b.blend_mode))
244                .then(a.texture_id.cmp(&b.texture_id))
245        });
246
247        // Sort geometry commands by layer
248        self.geo_commands.sort_by_key(|c| c.layer());
249
250        // Sort SDF commands by layer
251        self.sdf_commands.sort_by_key(|c| c.layer);
252
253        // Build interleaved render schedule
254        let schedule = build_render_schedule(&self.frame_commands, &self.geo_commands, &self.sdf_commands);
255
256        // Flush custom shader uniforms with auto-injected built-ins
257        self.shaders.flush(
258            &self.gpu.queue,
259            self.elapsed_time,
260            self.delta_time,
261            self.camera.viewport_size,
262            self.mouse_pos,
263        );
264
265        let lighting_uniform = self.lighting.to_uniform();
266        let clear_color = wgpu::Color {
267            r: self.clear_color[0] as f64,
268            g: self.clear_color[1] as f64,
269            b: self.clear_color[2] as f64,
270            a: self.clear_color[3] as f64,
271        };
272
273        // Write camera + lighting uniforms once for the whole frame
274        self.sprites.prepare(&self.gpu.device, &self.gpu.queue, &self.camera, &lighting_uniform);
275        self.sdf_pipeline.prepare(&self.gpu.queue, &self.camera, 0.0);
276
277        // Run radiance cascade GI compute pass (if enabled)
278        let gi_active = self.radiance.compute(
279            &self.gpu,
280            &mut encoder,
281            &self.radiance_state,
282            &self.lighting,
283            self.camera.x,
284            self.camera.y,
285            self.camera.viewport_size[0],
286            self.camera.viewport_size[1],
287        );
288
289        if self.postprocess.has_effects() {
290            // Render to offscreen target, then apply effects to surface
291            {
292                let sprite_target = self.postprocess.sprite_target(&self.gpu);
293                let camera_bg = self.sprites.camera_bind_group();
294
295                if schedule.is_empty() {
296                    // No commands at all — still need to clear
297                    self.sprites.render(
298                        &self.gpu.device, &self.gpu.queue, &self.textures, &self.shaders,
299                        &[], sprite_target, &mut encoder, Some(clear_color),
300                    );
301                } else {
302                    let mut first = true;
303                    for op in &schedule {
304                        let cc = if first { Some(clear_color) } else { None };
305                        first = false;
306                        match op {
307                            RenderOp::Sprites { start, end } => {
308                                self.sprites.render(
309                                    &self.gpu.device, &self.gpu.queue, &self.textures, &self.shaders,
310                                    &self.frame_commands[*start..*end],
311                                    sprite_target, &mut encoder, cc,
312                                );
313                            }
314                            RenderOp::Geometry { start, end } => {
315                                self.geometry.flush_commands(
316                                    &self.gpu.device, &mut encoder, sprite_target,
317                                    camera_bg, &self.geo_commands[*start..*end], cc,
318                                );
319                            }
320                            RenderOp::Sdf { start, end } => {
321                                self.sdf_pipeline.render(
322                                    &self.gpu.device, &mut encoder, sprite_target,
323                                    &self.sdf_commands[*start..*end], cc,
324                                );
325                            }
326                        }
327                    }
328                }
329            }
330            // Apply GI light texture to the offscreen target before post-processing
331            if gi_active {
332                let sprite_target = self.postprocess.sprite_target(&self.gpu);
333                self.radiance.compose(&mut encoder, sprite_target);
334            }
335            self.postprocess.apply(&self.gpu, &mut encoder, &view);
336        } else {
337            // No effects — render directly to surface
338            let camera_bg = self.sprites.camera_bind_group();
339
340            if schedule.is_empty() {
341                // No commands at all — still need to clear
342                self.sprites.render(
343                    &self.gpu.device, &self.gpu.queue, &self.textures, &self.shaders,
344                    &[], &view, &mut encoder, Some(clear_color),
345                );
346            } else {
347                let mut first = true;
348                for op in &schedule {
349                    let cc = if first { Some(clear_color) } else { None };
350                    first = false;
351                    match op {
352                        RenderOp::Sprites { start, end } => {
353                            self.sprites.render(
354                                &self.gpu.device, &self.gpu.queue, &self.textures, &self.shaders,
355                                &self.frame_commands[*start..*end],
356                                &view, &mut encoder, cc,
357                            );
358                        }
359                        RenderOp::Geometry { start, end } => {
360                            self.geometry.flush_commands(
361                                &self.gpu.device, &mut encoder, &view,
362                                camera_bg, &self.geo_commands[*start..*end], cc,
363                            );
364                        }
365                        RenderOp::Sdf { start, end } => {
366                            self.sdf_pipeline.render(
367                                &self.gpu.device, &mut encoder, &view,
368                                &self.sdf_commands[*start..*end], cc,
369                            );
370                        }
371                    }
372                }
373            }
374            // Apply GI light texture to the surface
375            if gi_active {
376                self.radiance.compose(&mut encoder, &view);
377            }
378        }
379
380        self.gpu.queue.submit(std::iter::once(encoder.finish()));
381
382        // Capture the rendered frame if requested (before present consumes the surface)
383        if self.capture_pending {
384            self.capture_pending = false;
385            self.capture_result = self.capture_surface(&output.texture);
386        }
387
388        output.present();
389
390        self.frame_commands.clear();
391        self.geo_commands.clear();
392        self.sdf_commands.clear();
393        Ok(())
394    }
395
396    /// Resize the surface when the window size changes.
397    /// GPU surface uses physical pixels; camera viewport uses logical pixels.
398    pub fn resize(&mut self, physical_width: u32, physical_height: u32, scale_factor: f32) {
399        if physical_width > 0 && physical_height > 0 {
400            self.scale_factor = scale_factor;
401            self.gpu.config.width = physical_width;
402            self.gpu.config.height = physical_height;
403            self.gpu.surface.configure(&self.gpu.device, &self.gpu.config);
404            // Camera uses logical pixels so 1 world unit ≈ 1 logical pixel at zoom 1
405            self.camera.viewport_size = [
406                physical_width as f32 / scale_factor,
407                physical_height as f32 / scale_factor,
408            ];
409        }
410    }
411
412    // ── Frame capture ─────────────────────────────────────────────────────
413
414    /// Copy the surface texture to a CPU-side PNG. Returns None on failure.
415    fn capture_surface(&self, texture: &wgpu::Texture) -> Option<Vec<u8>> {
416        let width = self.gpu.config.width;
417        let height = self.gpu.config.height;
418        let bytes_per_pixel: u32 = 4;
419        let unpadded_bytes_per_row = width * bytes_per_pixel;
420        let padded_bytes_per_row = ((unpadded_bytes_per_row + 255) / 256) * 256;
421
422        let buffer = self.gpu.device.create_buffer(&wgpu::BufferDescriptor {
423            label: Some("capture_readback"),
424            size: (padded_bytes_per_row * height) as u64,
425            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
426            mapped_at_creation: false,
427        });
428
429        let mut encoder = self.gpu.device.create_command_encoder(
430            &wgpu::CommandEncoderDescriptor { label: Some("capture_encoder") },
431        );
432
433        encoder.copy_texture_to_buffer(
434            wgpu::TexelCopyTextureInfo {
435                texture,
436                mip_level: 0,
437                origin: wgpu::Origin3d::ZERO,
438                aspect: wgpu::TextureAspect::All,
439            },
440            wgpu::TexelCopyBufferInfo {
441                buffer: &buffer,
442                layout: wgpu::TexelCopyBufferLayout {
443                    offset: 0,
444                    bytes_per_row: Some(padded_bytes_per_row),
445                    rows_per_image: Some(height),
446                },
447            },
448            wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
449        );
450
451        self.gpu.queue.submit(std::iter::once(encoder.finish()));
452
453        // Map the buffer synchronously
454        let buffer_slice = buffer.slice(..);
455        let (tx, rx) = std::sync::mpsc::channel();
456        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
457            let _ = tx.send(result);
458        });
459        self.gpu.device.poll(wgpu::Maintain::Wait);
460
461        if rx.recv().ok()?.ok().is_none() {
462            return None;
463        }
464
465        let data = buffer_slice.get_mapped_range();
466
467        // Strip row padding and handle BGRA→RGBA if needed
468        let is_bgra = format!("{:?}", self.gpu.config.format).contains("Bgra");
469        let mut pixels = Vec::with_capacity((width * height * 4) as usize);
470        for y in 0..height {
471            let start = (y * padded_bytes_per_row) as usize;
472            let end = start + (width * 4) as usize;
473            let row = &data[start..end];
474            if is_bgra {
475                // Swap B↔R for each pixel
476                for chunk in row.chunks_exact(4) {
477                    pixels.extend_from_slice(&[chunk[2], chunk[1], chunk[0], chunk[3]]);
478                }
479            } else {
480                pixels.extend_from_slice(row);
481            }
482        }
483
484        drop(data);
485        buffer.unmap();
486
487        // Encode to PNG using the `image` crate
488        use image::ImageEncoder;
489        let mut png_bytes = Vec::new();
490        let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
491        if encoder.write_image(&pixels, width, height, image::ExtendedColorType::Rgba8).is_err() {
492            return None;
493        }
494
495        Some(png_bytes)
496    }
497
498    // ── Render target helpers ──────────────────────────────────────────────
499
500    /// Allocate a new off-screen render target and register it as a samplable texture.
501    pub fn create_render_target(&mut self, id: u32, width: u32, height: u32) {
502        let surface_format = self.gpu.config.format;
503        self.render_targets.create(&self.gpu.device, id, width, height, surface_format);
504        if let Some(view) = self.render_targets.get_view(id) {
505            self.textures.register_render_target(
506                &self.gpu.device,
507                &self.sprites.texture_bind_group_layout,
508                id,
509                view,
510                width,
511                height,
512            );
513        }
514    }
515
516    /// Free a render target's GPU resources and remove it from the texture store.
517    pub fn destroy_render_target(&mut self, id: u32) {
518        self.render_targets.destroy(id);
519        self.textures.unregister_render_target(id);
520    }
521
522    /// Render sprite commands into each queued render target (off-screen pre-pass).
523    ///
524    /// Call this BEFORE `render_frame()` so targets are ready as sprite inputs.
525    /// Uses a separate command encoder + GPU submit to avoid ordering conflicts.
526    pub fn render_targets_prepass(
527        &mut self,
528        target_queues: std::collections::HashMap<u32, Vec<SpriteCommand>>,
529    ) {
530        if target_queues.is_empty() {
531            return;
532        }
533
534        let mut encoder = self.gpu.device.create_command_encoder(
535            &wgpu::CommandEncoderDescriptor { label: Some("rt_encoder") },
536        );
537        let lighting_uniform = self.lighting.to_uniform();
538
539        for (target_id, mut cmds) in target_queues {
540            let view = self.render_targets.get_view(target_id);
541            let dims = self.render_targets.get_dims(target_id);
542            if let (Some(view), Some((tw, th))) = (view, dims) {
543                // Sort by layer → shader_id → blend_mode → texture_id
544                cmds.sort_by(|a, b| {
545                    a.layer
546                        .cmp(&b.layer)
547                        .then(a.shader_id.cmp(&b.shader_id))
548                        .then(a.blend_mode.cmp(&b.blend_mode))
549                        .then(a.texture_id.cmp(&b.texture_id))
550                });
551                // Orthographic camera: (0,0) = top-left of the render target
552                let target_camera = Camera2D {
553                    x: tw as f32 / 2.0,
554                    y: th as f32 / 2.0,
555                    zoom: 1.0,
556                    viewport_size: [tw as f32, th as f32],
557                    ..Camera2D::default()
558                };
559                self.sprites.prepare(&self.gpu.device, &self.gpu.queue, &target_camera, &lighting_uniform);
560                self.sprites.render(
561                    &self.gpu.device,
562                    &self.gpu.queue,
563                    &self.textures,
564                    &self.shaders,
565                    &cmds,
566                    view,
567                    &mut encoder,
568                    Some(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }),
569                );
570            }
571        }
572
573        self.gpu.queue.submit(std::iter::once(encoder.finish()));
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    // ── Test helpers ─────────────────────────────────────────────────────
582
583    fn sprite(layer: i32) -> SpriteCommand {
584        SpriteCommand {
585            texture_id: 1, x: 0.0, y: 0.0, w: 16.0, h: 16.0, layer,
586            uv_x: 0.0, uv_y: 0.0, uv_w: 1.0, uv_h: 1.0,
587            tint_r: 1.0, tint_g: 1.0, tint_b: 1.0, tint_a: 1.0,
588            rotation: 0.0, origin_x: 0.5, origin_y: 0.5,
589            flip_x: false, flip_y: false, opacity: 1.0,
590            blend_mode: 0, shader_id: 0,
591        }
592    }
593
594    fn geo(layer: i32) -> GeoCommand {
595        GeoCommand::Triangle {
596            x1: 0.0, y1: 0.0, x2: 16.0, y2: 0.0, x3: 8.0, y3: 16.0,
597            r: 1.0, g: 1.0, b: 1.0, a: 1.0, layer,
598        }
599    }
600
601    fn sdf(layer: i32) -> SdfCommand {
602        SdfCommand {
603            sdf_expr: "length(p) - 10.0".to_string(),
604            fill: SdfFill::Solid { color: [1.0, 0.0, 0.0, 1.0] },
605            x: 32.0, y: 32.0, bounds: 15.0, layer,
606            rotation: 0.0, scale: 1.0, opacity: 1.0,
607        }
608    }
609
610    fn sdf_draw(fill_type: u32) -> SdfDrawCommand {
611        SdfDrawCommand {
612            sdf_expr: "length(p) - 10.0".to_string(),
613            fill_type,
614            color: [1.0, 0.0, 0.0, 1.0],
615            color2: [0.0, 1.0, 0.0, 1.0],
616            fill_param: 2.0,
617            palette_params: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 1.0, 1.0, 1.0, 0.0, 0.33, 0.67],
618            gradient_scale: 1.5,
619            x: 10.0, y: 20.0, bounds: 30.0, layer: 5,
620            rotation: 0.1, scale: 2.0, opacity: 0.8,
621        }
622    }
623
624    // ── build_render_schedule tests ──────────────────────────────────────
625
626    #[test]
627    fn test_schedule_empty_inputs() {
628        let schedule = build_render_schedule(&[], &[], &[]);
629        assert!(schedule.is_empty());
630    }
631
632    #[test]
633    fn test_schedule_sprites_only() {
634        let sprites = vec![sprite(0), sprite(1)];
635        let schedule = build_render_schedule(&sprites, &[], &[]);
636        assert_eq!(schedule, vec![RenderOp::Sprites { start: 0, end: 2 }]);
637    }
638
639    #[test]
640    fn test_schedule_geo_only() {
641        let geo_cmds = vec![geo(0), geo(1)];
642        let schedule = build_render_schedule(&[], &geo_cmds, &[]);
643        assert_eq!(schedule, vec![RenderOp::Geometry { start: 0, end: 2 }]);
644    }
645
646    #[test]
647    fn test_schedule_sdf_only() {
648        let sdf_cmds = vec![sdf(0), sdf(1)];
649        let schedule = build_render_schedule(&[], &[], &sdf_cmds);
650        assert_eq!(schedule, vec![RenderOp::Sdf { start: 0, end: 2 }]);
651    }
652
653    #[test]
654    fn test_schedule_same_layer_order() {
655        // All at layer 0: sprites first, then geo, then sdf
656        let sprites = vec![sprite(0)];
657        let geo_cmds = vec![geo(0)];
658        let sdf_cmds = vec![sdf(0)];
659        let schedule = build_render_schedule(&sprites, &geo_cmds, &sdf_cmds);
660        assert_eq!(schedule.len(), 3);
661        assert_eq!(schedule[0], RenderOp::Sprites { start: 0, end: 1 });
662        assert_eq!(schedule[1], RenderOp::Geometry { start: 0, end: 1 });
663        assert_eq!(schedule[2], RenderOp::Sdf { start: 0, end: 1 });
664    }
665
666    #[test]
667    fn test_schedule_interleaved_layers() {
668        // sprites at 0, geo at 1, sdf at 2
669        let sprites = vec![sprite(0)];
670        let geo_cmds = vec![geo(1)];
671        let sdf_cmds = vec![sdf(2)];
672        let schedule = build_render_schedule(&sprites, &geo_cmds, &sdf_cmds);
673        assert_eq!(schedule.len(), 3);
674        assert_eq!(schedule[0], RenderOp::Sprites { start: 0, end: 1 });
675        assert_eq!(schedule[1], RenderOp::Geometry { start: 0, end: 1 });
676        assert_eq!(schedule[2], RenderOp::Sdf { start: 0, end: 1 });
677    }
678
679    #[test]
680    fn test_schedule_mixed_layers() {
681        // sprites at 0 and 2, geo at 1
682        let sprites = vec![sprite(0), sprite(2)];
683        let geo_cmds = vec![geo(1)];
684        let schedule = build_render_schedule(&sprites, &geo_cmds, &[]);
685        // Sprite at layer 0 first, then geo at layer 1, then sprite at layer 2
686        assert!(schedule.len() >= 2);
687        assert!(matches!(schedule[0], RenderOp::Sprites { .. }));
688    }
689
690    #[test]
691    fn test_schedule_all_consumed() {
692        // Verify all commands are consumed (no gaps in ranges)
693        let sprites = vec![sprite(0), sprite(0), sprite(1)];
694        let geo_cmds = vec![geo(0), geo(2)];
695        let sdf_cmds = vec![sdf(1)];
696        let schedule = build_render_schedule(&sprites, &geo_cmds, &sdf_cmds);
697
698        let mut sprite_count = 0;
699        let mut geo_count = 0;
700        let mut sdf_count = 0;
701        for op in &schedule {
702            match op {
703                RenderOp::Sprites { start, end } => sprite_count += end - start,
704                RenderOp::Geometry { start, end } => geo_count += end - start,
705                RenderOp::Sdf { start, end } => sdf_count += end - start,
706            }
707        }
708        assert_eq!(sprite_count, 3, "all sprites consumed");
709        assert_eq!(geo_count, 2, "all geo consumed");
710        assert_eq!(sdf_count, 1, "all sdf consumed");
711    }
712
713    // ── convert_sdf_draw_command tests ───────────────────────────────────
714
715    #[test]
716    fn test_convert_sdf_solid() {
717        let cmd = convert_sdf_draw_command(sdf_draw(0));
718        assert!(matches!(cmd.fill, SdfFill::Solid { color } if color == [1.0, 0.0, 0.0, 1.0]));
719    }
720
721    #[test]
722    fn test_convert_sdf_outline() {
723        let cmd = convert_sdf_draw_command(sdf_draw(1));
724        assert!(matches!(cmd.fill, SdfFill::Outline { color, thickness }
725            if color == [1.0, 0.0, 0.0, 1.0] && thickness == 2.0));
726    }
727
728    #[test]
729    fn test_convert_sdf_solid_with_outline() {
730        let cmd = convert_sdf_draw_command(sdf_draw(2));
731        assert!(matches!(cmd.fill, SdfFill::SolidWithOutline { fill, outline, thickness }
732            if fill == [1.0, 0.0, 0.0, 1.0] && outline == [0.0, 1.0, 0.0, 1.0] && thickness == 2.0));
733    }
734
735    #[test]
736    fn test_convert_sdf_gradient() {
737        let cmd = convert_sdf_draw_command(sdf_draw(3));
738        assert!(matches!(cmd.fill, SdfFill::Gradient { from, to, angle, scale }
739            if from == [1.0, 0.0, 0.0, 1.0] && to == [0.0, 1.0, 0.0, 1.0]
740            && angle == 2.0 && scale == 1.5));
741    }
742
743    #[test]
744    fn test_convert_sdf_glow() {
745        let cmd = convert_sdf_draw_command(sdf_draw(4));
746        assert!(matches!(cmd.fill, SdfFill::Glow { color, intensity }
747            if color == [1.0, 0.0, 0.0, 1.0] && intensity == 2.0));
748    }
749
750    #[test]
751    fn test_convert_sdf_cosine_palette() {
752        let cmd = convert_sdf_draw_command(sdf_draw(5));
753        assert!(matches!(cmd.fill, SdfFill::CosinePalette { a, b, c, d }
754            if a == [0.5, 0.5, 0.5] && b == [0.5, 0.5, 0.5]
755            && c == [1.0, 1.0, 1.0] && d == [0.0, 0.33, 0.67]));
756    }
757
758    #[test]
759    fn test_convert_sdf_unknown_fallback() {
760        let cmd = convert_sdf_draw_command(sdf_draw(99));
761        assert!(matches!(cmd.fill, SdfFill::Solid { color } if color == [1.0, 0.0, 0.0, 1.0]));
762    }
763
764    #[test]
765    fn test_convert_sdf_field_passthrough() {
766        let cmd = convert_sdf_draw_command(sdf_draw(0));
767        assert_eq!(cmd.sdf_expr, "length(p) - 10.0");
768        assert_eq!(cmd.x, 10.0);
769        assert_eq!(cmd.y, 20.0);
770        assert_eq!(cmd.bounds, 30.0);
771        assert_eq!(cmd.layer, 5);
772        assert_eq!(cmd.rotation, 0.1);
773        assert_eq!(cmd.scale, 2.0);
774        assert_eq!(cmd.opacity, 0.8);
775    }
776}