1use crate::vertex::{InstanceData, Vertex};
3use cvkg_core::Rect;
4use lru::LruCache;
5use std::num::NonZeroUsize;
6use std::sync::Arc;
7
8pub mod budget;
9pub mod golden;
10pub mod lod;
11pub mod shader_features;
12pub mod thermal;
13pub mod virtualization;
14
15pub use budget::OffscreenBudget;
16pub use golden::{GoldenImageComparator, GoldenImageConfig, GoldenImageResult};
17pub use lod::EffectLod;
18pub use shader_features::ShaderFeatureFlags;
19pub use thermal::{ThermalConfig, ThermalState};
20pub use virtualization::{Frustum, SpatialCell, SpatialHash};
21
22#[derive(Clone, Debug)]
26pub struct SvgModel {
27 pub vertices: Vec<Vertex>,
29 pub indices: Vec<u32>,
31 pub view_box: Rect,
33 pub paths: Vec<SvgPath>,
35 pub animations: Vec<SvgAnimation>,
37}
38
39#[derive(Clone, Debug)]
43pub struct SvgPath {
44 pub id: String,
46 pub vertex_range: std::ops::Range<usize>,
48 pub index_range: std::ops::Range<usize>,
50 pub local_transform: SvgTransform,
53}
54
55#[derive(Clone, Debug, Default)]
57pub struct SvgTransform {
58 pub translate: [f32; 2],
60 pub rotation: f32,
62 pub scale: f32,
64}
65
66#[derive(Clone, Debug)]
67pub struct SvgAnimation {
68 pub target_id: String,
69 pub attribute_name: String,
70 pub keyframe_values: Vec<f32>,
73 pub key_times: Vec<f32>,
75 pub duration: f32,
76 pub vertex_range: std::ops::Range<usize>,
77}
78
79impl SvgAnimation {
80 pub fn evaluate(&self, t: f32) -> f32 {
82 let vals = &self.keyframe_values;
83 if vals.is_empty() {
84 return 0.0;
85 }
86 if vals.len() == 1 {
87 return vals[0];
88 }
89 if vals.len() == 2 {
90 return vals[0] + (vals[1] - vals[0]) * t;
91 }
92 let times = if self.key_times.len() == vals.len() {
94 &self.key_times
95 } else {
96 return self.evaluate_uniform(t);
98 };
99 let t = t.clamp(0.0, 1.0);
101 for i in 0..times.len() - 1 {
102 if t >= times[i] && t <= times[i + 1] {
103 let seg_t = (t - times[i]) / (times[i + 1] - times[i]);
104 return vals[i] + (vals[i + 1] - vals[i]) * seg_t;
105 }
106 }
107 vals[vals.len() - 1]
108 }
109
110 fn evaluate_uniform(&self, t: f32) -> f32 {
111 let vals = &self.keyframe_values;
112 let n = vals.len() - 1;
113 let t = t.clamp(0.0, 1.0);
114 let idx_f = t * n as f32;
115 let idx = idx_f.floor() as usize;
116 let frac = idx_f - idx as f32;
117 if idx >= n {
118 vals[n]
119 } else {
120 vals[idx] + (vals[idx + 1] - vals[idx]) * frac
121 }
122 }
123}
124
125#[derive(Debug, Clone)]
128pub(crate) struct DrawCall {
129 pub texture_id: Option<u32>,
130 pub scissor_rect: Option<Rect>,
131 pub index_start: u32,
132 pub index_count: u32,
133 pub instance_count: u32,
137 pub material: cvkg_core::DrawMaterial,
140 pub target_id: Option<u64>,
141 pub instance_start: u32,
142 pub draw_order: i32,
145}
146
147#[derive(Debug, Clone)]
158pub(crate) struct MemoEntry {
159 pub hash: u64,
160 pub frame_gen: u64,
161 pub vertices: Vec<crate::vertex::Vertex>,
162 pub indices: Vec<u32>,
163 pub instance_data: Vec<crate::vertex::InstanceData>,
164 pub draw_calls: Vec<DrawCall>,
165}
166
167pub struct OffscreenEffectConfig {
168 pub target_id: u64,
169 pub effect: String,
170 pub blend_mode: u32,
171 pub effect_args: [f32; 16],
172}
173
174#[derive(Debug, Clone, Copy)]
175pub(crate) struct ShadowState {
176 pub radius: f32,
177 pub color: [f32; 4],
178 pub _offset: [f32; 2],
179}
180
181pub(crate) struct SurfaceContext {
182 pub(crate) surface: wgpu::Surface<'static>,
183 pub(crate) config: wgpu::SurfaceConfiguration,
184 pub(crate) scene_texture: wgpu::TextureView,
185 pub(crate) scene_msaa_texture: wgpu::TextureView,
186 pub(crate) scene_bind_group: wgpu::BindGroup,
187 pub(crate) scene_texture_bind_group: wgpu::BindGroup,
188 pub(crate) depth_texture_view: wgpu::TextureView,
189 pub(crate) blur_tex_a: crate::kvasir::resource::ResourceId,
190 pub(crate) blur_tex_b: crate::kvasir::resource::ResourceId,
191 pub(crate) bloom_tex_a: crate::kvasir::resource::ResourceId,
192 pub(crate) bloom_tex_b: crate::kvasir::resource::ResourceId,
193 pub(crate) blur_env_bind_group_a: wgpu::BindGroup,
194 pub(crate) blur_env_bind_group_b: wgpu::BindGroup,
195 pub(crate) bloom_env_bind_group_a: wgpu::BindGroup,
196 pub(crate) bloom_env_bind_group_b: wgpu::BindGroup,
197 pub(crate) scale_factor: f32,
198 pub(crate) sampler: wgpu::Sampler,
199}
200
201pub struct HeadlessContext {
203 pub scene_texture: wgpu::TextureView,
204 pub scene_msaa_texture: wgpu::TextureView,
205 pub scene_bind_group: wgpu::BindGroup,
206 pub scene_texture_bind_group: wgpu::BindGroup,
207 pub depth_texture_view: wgpu::TextureView,
208 pub blur_tex_a: crate::kvasir::resource::ResourceId,
209 pub blur_tex_b: crate::kvasir::resource::ResourceId,
210 pub bloom_tex_a: crate::kvasir::resource::ResourceId,
211 pub bloom_tex_b: crate::kvasir::resource::ResourceId,
212 pub blur_env_bind_group_a: wgpu::BindGroup,
213 pub blur_env_bind_group_b: wgpu::BindGroup,
214 pub bloom_env_bind_group_a: wgpu::BindGroup,
215 pub bloom_env_bind_group_b: wgpu::BindGroup,
216 pub scale_factor: f32,
217 pub sampler: wgpu::Sampler,
218 pub width: u32,
219 pub height: u32,
220 pub output_texture: wgpu::Texture,
221 pub output_view: wgpu::TextureView,
222}
223
224pub(crate) const MAX_VERTICES: usize = 100_000;
225pub(crate) const MAX_INDICES: usize = 150_000;
226
227pub(crate) const MAX_PARTICLES: usize = 65536;
229
230#[repr(C)]
234#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
235pub struct GpuParticle {
236 pub pos_vel: [f32; 4],
237 pub color_life: [f32; 4],
238}
239
240#[repr(C)]
243#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
244pub struct ParticleUniforms {
245 pub dt: f32,
246 pub _pad: [f32; 7],
247}
248
249#[repr(C)]
250#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
251pub struct EffectUniforms {
252 pub time: f32,
253 pub pad0: f32,
254 pub size: [f32; 2],
255 pub args: [f32; 16],
256}
257
258#[repr(C)]
262#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
263pub struct GlassInstanceUniforms {
264 pub tint_override: [f32; 4],
267 pub ior_override: f32,
269 pub blur_multiplier: f32,
271 pub frost_override: f32,
273 pub scissor_px: [f32; 4],
276 pub portal_index: f32,
279 pub _pad: f32,
280}
281
282impl Default for GlassInstanceUniforms {
283 fn default() -> Self {
284 Self {
285 tint_override: [0.0; 4],
286 ior_override: 0.0,
287 blur_multiplier: 1.0,
288 frost_override: 0.0,
289 scissor_px: [0.0; 4],
290 portal_index: 0.0,
291 _pad: 0.0,
292 }
293 }
294}
295
296pub struct GeometryBuffers {
310 pub vertex_buffer: wgpu::Buffer,
312 pub index_buffer: wgpu::Buffer,
314 pub instance_buffer: wgpu::Buffer,
316 pub max_vertices: usize,
318 pub max_indices: usize,
320}
321
322impl GeometryBuffers {
323 pub fn forge(device: &wgpu::Device, max_vertices: usize, max_indices: usize) -> Self {
326 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
327 label: Some("Surtr Vertex Anvil"),
328 size: (max_vertices * std::mem::size_of::<Vertex>()) as u64,
329 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
330 mapped_at_creation: false,
331 });
332 let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
333 label: Some("Surtr Index Anvil"),
334 size: (max_indices * std::mem::size_of::<u32>()) as u64,
335 usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
336 mapped_at_creation: false,
337 });
338 let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor {
339 label: Some("Surtr Instance Anvil"),
340 size: (max_vertices / 4 * std::mem::size_of::<InstanceData>()) as u64,
341 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
342 mapped_at_creation: false,
343 });
344 Self {
345 vertex_buffer,
346 index_buffer,
347 instance_buffer,
348 max_vertices,
349 max_indices,
350 }
351 }
352
353 pub fn vram_bytes(&self) -> u64 {
355 let vertex_bytes = self.max_vertices * std::mem::size_of::<Vertex>();
356 let index_bytes = self.max_indices * std::mem::size_of::<u32>();
357 let instance_bytes = (self.max_vertices / 4) * std::mem::size_of::<InstanceData>();
358 (vertex_bytes + index_bytes + instance_bytes) as u64
359 }
360
361 pub fn grow_vertex_buffer(
366 &mut self,
367 device: &wgpu::Device,
368 min_capacity: usize,
369 max_capacity: usize,
370 ) -> bool {
371 let current = self.vertex_buffer.size() as usize / std::mem::size_of::<Vertex>();
372 if min_capacity <= current {
373 return false;
374 }
375 let new_capacity = min_capacity.min(max_capacity);
376 if new_capacity <= current {
377 return false;
378 }
379 self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
380 label: Some("Vertex Buffer (Grown)"),
381 size: (new_capacity * std::mem::size_of::<Vertex>()) as u64,
382 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
383 mapped_at_creation: false,
384 });
385 true
386 }
387
388 pub fn grow_index_buffer(
392 &mut self,
393 device: &wgpu::Device,
394 min_capacity: usize,
395 max_capacity: usize,
396 ) -> bool {
397 let current = self.index_buffer.size() as usize / std::mem::size_of::<u32>();
398 if min_capacity <= current {
399 return false;
400 }
401 let new_capacity = min_capacity.min(max_capacity);
402 if new_capacity <= current {
403 return false;
404 }
405 self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
406 label: Some("Index Buffer (Grown)"),
407 size: (new_capacity * std::mem::size_of::<u32>()) as u64,
408 usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
409 mapped_at_creation: false,
410 });
411 true
412 }
413}
414
415pub struct TextSubsystem {
426 pub engine: cvkg_runic_text::TextEngine,
429 pub glyph_cache: LruCache<u64, (cvkg_core::Rect, f32, f32, f32, f32)>,
432 pub shaped_cache: LruCache<(String, u32), std::sync::Arc<cvkg_runic_text::ShapedText>>,
436 pub atlas_size: u32,
438}
439
440impl TextSubsystem {
441 pub fn forge(glyph_cache_capacity: NonZeroUsize) -> Self {
444 Self::with_atlas_size(glyph_cache_capacity, 4096)
445 }
446
447 pub fn with_atlas_size(glyph_cache_capacity: NonZeroUsize, atlas_size: u32) -> Self {
449 Self {
450 engine: cvkg_runic_text::TextEngine::default(),
451 glyph_cache: LruCache::new(glyph_cache_capacity),
452 shaped_cache: LruCache::new(NonZeroUsize::new(2048).unwrap()),
453 atlas_size,
454 }
455 }
456
457 pub fn atlas_size(&self) -> u32 {
459 self.atlas_size
460 }
461
462 pub fn clear_caches(&mut self) {
464 self.shaped_cache.clear();
465 }
469}
470
471pub struct SvgSubsystem {
482 pub model_cache: LruCache<String, SvgModel>,
484 pub tree_cache: LruCache<String, usvg::Tree>,
486 pub filter_engine: Option<cvkg_svg_filters::FilterEngine>,
488 pub filter_batches: Vec<cvkg_svg_filters::FilterNode>,
490 dirty_elements: std::collections::HashSet<String>,
493 dirty_sources: std::collections::HashSet<String>,
495}
496
497impl SvgSubsystem {
498 pub fn forge(
502 device: &Arc<wgpu::Device>,
503 queue: &Arc<wgpu::Queue>,
504 model_cache_capacity: NonZeroUsize,
505 tree_cache_capacity: NonZeroUsize,
506 ) -> Self {
507 let filter_engine = cvkg_svg_filters::FilterEngine::new(cvkg_svg_filters::GpuContext {
508 device: device.clone(),
509 queue: queue.clone(),
510 })
511 .ok();
512 Self {
513 model_cache: LruCache::new(model_cache_capacity),
514 tree_cache: LruCache::new(tree_cache_capacity),
515 filter_engine,
516 filter_batches: Vec::new(),
517 dirty_elements: std::collections::HashSet::new(),
518 dirty_sources: std::collections::HashSet::new(),
519 }
520 }
521
522 pub fn clear_filter_batches(&mut self) {
525 self.filter_batches.clear();
526 }
527
528 pub fn mark_element_dirty(&mut self, element_id: &str) {
532 self.dirty_elements.insert(element_id.to_string());
533 }
534
535 pub fn mark_source_dirty(&mut self, source_name: &str) {
537 self.dirty_sources.insert(source_name.to_string());
538 self.model_cache.pop(source_name);
540 }
541
542 pub fn is_element_dirty(&self, element_id: &str) -> bool {
544 self.dirty_elements.contains(element_id) || self.dirty_sources.contains(element_id)
545 }
546
547 pub fn is_source_dirty(&self, source_name: &str) -> bool {
549 self.dirty_sources.contains(source_name)
550 }
551
552 pub fn clear_dirty(&mut self) {
554 self.dirty_elements.clear();
555 self.dirty_sources.clear();
556 }
557
558 pub fn dirty_count(&self) -> usize {
560 self.dirty_elements.len() + self.dirty_sources.len()
561 }
562}
563
564pub struct ParticleSubsystem {
577 pub staging: Vec<GpuParticle>,
580 pub count: u32,
582 pub write_head: u32,
585 pub last_compact: std::time::Instant,
587}
588
589impl ParticleSubsystem {
590 pub fn forge() -> Self {
592 Self {
593 staging: Vec::new(),
594 count: 0,
595 write_head: 0,
596 last_compact: std::time::Instant::now(),
597 }
598 }
599}
600
601#[cfg(test)]
602mod p1_1_geometry_buffers_tests {
603 use super::*;
604
605 #[test]
611 fn vram_bytes_is_sum_of_three_buffers() {
612 let max_vertices = 1000usize;
615 let max_indices = 1500usize;
616 let vertex_bytes = max_vertices * std::mem::size_of::<Vertex>();
617 let index_bytes = max_indices * std::mem::size_of::<u32>();
618 let instance_bytes = (max_vertices / 4) * std::mem::size_of::<InstanceData>();
619 let expected = (vertex_bytes + index_bytes + instance_bytes) as u64;
620 assert!(expected > 0, "expected vram bytes > 0");
624 assert!(std::mem::size_of::<Vertex>() >= 16);
626 assert!(std::mem::size_of::<InstanceData>() >= 16);
628 }
629
630 #[test]
631 fn size_of_vertex_is_known() {
632 let size = std::mem::size_of::<Vertex>();
638 assert_eq!(size % 4, 0, "Vertex size must be 4-byte aligned");
640 }
641}
642
643#[cfg(test)]
644mod p1_1_text_subsystem_tests {
645 use super::TextSubsystem;
646 use std::num::NonZeroUsize;
647
648 #[test]
649 fn forge_creates_glyph_cache_with_given_capacity() {
650 let cap = NonZeroUsize::new(100).unwrap();
653 let subsystem = TextSubsystem::forge(cap);
654 assert_eq!(subsystem.glyph_cache.cap().get(), 100);
655 assert!(subsystem.shaped_cache.is_empty());
657 }
658
659 #[test]
660 fn clear_caches_empties_shaped_but_keeps_glyph() {
661 let cap = NonZeroUsize::new(10).unwrap();
665 let mut subsystem = TextSubsystem::forge(cap);
666 subsystem.clear_caches();
676 assert!(subsystem.shaped_cache.is_empty());
677 assert_eq!(subsystem.glyph_cache.cap().get(), 10);
679 }
680
681 #[test]
682 fn text_subsystem_default_atlas_size() {
683 use std::num::NonZeroUsize;
684 let sub = TextSubsystem::forge(NonZeroUsize::new(1024).unwrap());
685 assert_eq!(sub.atlas_size(), 4096, "Default atlas size should be 4096");
686 }
687
688 #[test]
689 fn text_subsystem_custom_atlas_size() {
690 use std::num::NonZeroUsize;
691 let sub = TextSubsystem::with_atlas_size(NonZeroUsize::new(1024).unwrap(), 2048);
692 assert_eq!(sub.atlas_size(), 2048, "Custom atlas size should be 2048");
693 }
694
695 #[test]
696 fn default_capacity_is_8192_matching_p1_5() {
697 let cap = NonZeroUsize::new(8192).unwrap();
701 let subsystem = TextSubsystem::forge(cap);
702 assert_eq!(subsystem.glyph_cache.cap().get(), 8192);
703 }
704}
705
706#[cfg(test)]
707mod p1_1_particle_subsystem_tests {
708 use super::ParticleSubsystem;
709
710 #[test]
711 fn forge_creates_empty_state() {
712 let p = ParticleSubsystem::forge();
715 assert!(p.staging.is_empty());
716 assert_eq!(p.count, 0);
717 assert_eq!(p.write_head, 0);
718 }
719
720 #[test]
721 fn fields_are_publicly_mutable() {
722 let mut p = ParticleSubsystem::forge();
726 p.staging.push(Default::default());
727 p.count = 1;
728 p.write_head = 1;
729 assert_eq!(p.staging.len(), 1);
730 assert_eq!(p.count, 1);
731 assert_eq!(p.write_head, 1);
732 }
733}
734
735#[cfg(test)]
738mod p1_24_incremental_svg_tests {
739 use super::SvgSubsystem;
740 use std::num::NonZeroUsize;
741 use std::sync::Arc;
742
743 #[test]
749 fn dirty_count_starts_at_zero() {
750 let dirty_elements: std::collections::HashSet<String> = std::collections::HashSet::new();
754 let dirty_sources: std::collections::HashSet<String> = std::collections::HashSet::new();
755 assert_eq!(dirty_elements.len() + dirty_sources.len(), 0);
756 }
757
758 #[test]
759 fn mark_dirty_increments_count() {
760 let mut dirty = std::collections::HashSet::new();
761 dirty.insert("path1".to_string());
762 dirty.insert("path2".to_string());
763 assert_eq!(dirty.len(), 2);
764 }
765
766 #[test]
767 fn source_dirty_implies_all_elements_dirty() {
768 let mut sources: std::collections::HashSet<String> = std::collections::HashSet::new();
769 sources.insert("my_icon.svg".to_string());
770 assert!(sources.contains("my_icon.svg"));
772 assert!(!sources.contains("other.svg"));
773 }
774}