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 panel_id: Option<u64>,
145 pub instance_start: u32,
146 pub draw_order: i32,
149}
150
151#[derive(Debug, Clone)]
162pub(crate) struct MemoEntry {
163 pub hash: u64,
164 pub frame_gen: u64,
165 pub vertices: Vec<crate::vertex::Vertex>,
166 pub indices: Vec<u32>,
167 pub instance_data: Vec<crate::vertex::InstanceData>,
168 pub draw_calls: Vec<DrawCall>,
169}
170
171pub struct OffscreenEffectConfig {
172 pub target_id: u64,
173 pub effect: String,
174 pub blend_mode: u32,
175 pub effect_args: [f32; 16],
176}
177
178#[derive(Debug, Clone, Copy)]
179pub(crate) struct ShadowState {
180 pub radius: f32,
181 pub color: [f32; 4],
182 pub _offset: [f32; 2],
183}
184
185pub(crate) struct SurfaceContext {
186 pub(crate) surface: wgpu::Surface<'static>,
187 pub(crate) config: wgpu::SurfaceConfiguration,
188 pub(crate) scene_texture: wgpu::TextureView,
189 pub(crate) scene_msaa_texture: wgpu::TextureView,
190 pub(crate) scene_bind_group: wgpu::BindGroup,
191 pub(crate) scene_texture_bind_group: wgpu::BindGroup,
192 pub(crate) depth_texture_view: wgpu::TextureView,
193 pub(crate) blur_tex_a: crate::kvasir::resource::ResourceId,
194 pub(crate) blur_tex_b: crate::kvasir::resource::ResourceId,
195 pub(crate) bloom_tex_a: crate::kvasir::resource::ResourceId,
196 pub(crate) bloom_tex_b: crate::kvasir::resource::ResourceId,
197 pub(crate) blur_env_bind_group_a: wgpu::BindGroup,
198 pub(crate) blur_env_bind_group_b: wgpu::BindGroup,
199 pub(crate) bloom_env_bind_group_a: wgpu::BindGroup,
200 pub(crate) bloom_env_bind_group_b: wgpu::BindGroup,
201 pub(crate) scale_factor: f32,
202 pub(crate) sampler: wgpu::Sampler,
203}
204
205pub struct HeadlessContext {
207 pub scene_texture: wgpu::TextureView,
208 pub scene_msaa_texture: wgpu::TextureView,
209 pub scene_bind_group: wgpu::BindGroup,
210 pub scene_texture_bind_group: wgpu::BindGroup,
211 pub depth_texture_view: wgpu::TextureView,
212 pub blur_tex_a: crate::kvasir::resource::ResourceId,
213 pub blur_tex_b: crate::kvasir::resource::ResourceId,
214 pub bloom_tex_a: crate::kvasir::resource::ResourceId,
215 pub bloom_tex_b: crate::kvasir::resource::ResourceId,
216 pub blur_env_bind_group_a: wgpu::BindGroup,
217 pub blur_env_bind_group_b: wgpu::BindGroup,
218 pub bloom_env_bind_group_a: wgpu::BindGroup,
219 pub bloom_env_bind_group_b: wgpu::BindGroup,
220 pub scale_factor: f32,
221 pub sampler: wgpu::Sampler,
222 pub width: u32,
223 pub height: u32,
224 pub output_texture: wgpu::Texture,
225 pub output_view: wgpu::TextureView,
226}
227
228pub(crate) const MAX_VERTICES: usize = 100_000;
229pub(crate) const MAX_INDICES: usize = 150_000;
230
231pub(crate) const MAX_PARTICLES: usize = 65536;
233
234#[repr(C)]
238#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
239pub struct GpuParticle {
240 pub pos_vel: [f32; 4],
241 pub color_life: [f32; 4],
242}
243
244#[repr(C)]
247#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
248pub struct ParticleUniforms {
249 pub dt: f32,
250 pub _pad: [f32; 7],
251}
252
253#[repr(C)]
254#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
255pub struct EffectUniforms {
256 pub time: f32,
257 pub pad0: f32,
258 pub size: [f32; 2],
259 pub args: [f32; 16],
260}
261
262#[repr(C)]
266#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
267pub struct GlassInstanceUniforms {
268 pub tint_override: [f32; 4],
271 pub ior_override: f32,
273 pub blur_multiplier: f32,
275 pub frost_override: f32,
277 pub scissor_px: [f32; 4],
280 pub portal_index: f32,
283 pub _pad: f32,
284}
285
286impl Default for GlassInstanceUniforms {
287 fn default() -> Self {
288 Self {
289 tint_override: [0.0; 4],
290 ior_override: 0.0,
291 blur_multiplier: 1.0,
292 frost_override: 0.0,
293 scissor_px: [0.0; 4],
294 portal_index: 0.0,
295 _pad: 0.0,
296 }
297 }
298}
299
300pub struct GeometryBuffers {
314 pub vertex_buffer: wgpu::Buffer,
316 pub index_buffer: wgpu::Buffer,
318 pub instance_buffer: wgpu::Buffer,
320 pub max_vertices: usize,
322 pub max_indices: usize,
324}
325
326impl GeometryBuffers {
327 pub fn forge(device: &wgpu::Device, max_vertices: usize, max_indices: usize) -> Self {
330 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
331 label: Some("Surtr Vertex Anvil"),
332 size: (max_vertices * std::mem::size_of::<Vertex>()) as u64,
333 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
334 mapped_at_creation: false,
335 });
336 let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
337 label: Some("Surtr Index Anvil"),
338 size: (max_indices * std::mem::size_of::<u32>()) as u64,
339 usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
340 mapped_at_creation: false,
341 });
342 let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor {
343 label: Some("Surtr Instance Anvil"),
344 size: (max_vertices / 4 * std::mem::size_of::<InstanceData>()) as u64,
345 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
346 mapped_at_creation: false,
347 });
348 Self {
349 vertex_buffer,
350 index_buffer,
351 instance_buffer,
352 max_vertices,
353 max_indices,
354 }
355 }
356
357 pub fn vram_bytes(&self) -> u64 {
359 let vertex_bytes = self.max_vertices * std::mem::size_of::<Vertex>();
360 let index_bytes = self.max_indices * std::mem::size_of::<u32>();
361 let instance_bytes = (self.max_vertices / 4) * std::mem::size_of::<InstanceData>();
362 (vertex_bytes + index_bytes + instance_bytes) as u64
363 }
364
365 pub fn grow_vertex_buffer(
370 &mut self,
371 device: &wgpu::Device,
372 min_capacity: usize,
373 max_capacity: usize,
374 ) -> bool {
375 let current = self.vertex_buffer.size() as usize / std::mem::size_of::<Vertex>();
376 if min_capacity <= current {
377 return false;
378 }
379 let new_capacity = min_capacity.min(max_capacity);
380 if new_capacity <= current {
381 return false;
382 }
383 self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
384 label: Some("Vertex Buffer (Grown)"),
385 size: (new_capacity * std::mem::size_of::<Vertex>()) as u64,
386 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
387 mapped_at_creation: false,
388 });
389 true
390 }
391
392 pub fn grow_index_buffer(
396 &mut self,
397 device: &wgpu::Device,
398 min_capacity: usize,
399 max_capacity: usize,
400 ) -> bool {
401 let current = self.index_buffer.size() as usize / std::mem::size_of::<u32>();
402 if min_capacity <= current {
403 return false;
404 }
405 let new_capacity = min_capacity.min(max_capacity);
406 if new_capacity <= current {
407 return false;
408 }
409 self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
410 label: Some("Index Buffer (Grown)"),
411 size: (new_capacity * std::mem::size_of::<u32>()) as u64,
412 usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
413 mapped_at_creation: false,
414 });
415 true
416 }
417}
418
419pub struct TextSubsystem {
430 pub engine: cvkg_runic_text::TextEngine,
433 pub glyph_cache: LruCache<u64, (cvkg_core::Rect, f32, f32, f32, f32)>,
436 pub shaped_cache: LruCache<(String, u32), std::sync::Arc<cvkg_runic_text::ShapedText>>,
440 pub atlas_size: u32,
442}
443
444impl TextSubsystem {
445 pub fn forge(glyph_cache_capacity: NonZeroUsize) -> Self {
448 Self::with_atlas_size(glyph_cache_capacity, 4096)
449 }
450
451 pub fn with_atlas_size(glyph_cache_capacity: NonZeroUsize, atlas_size: u32) -> Self {
453 Self {
454 engine: cvkg_runic_text::TextEngine::default(),
455 glyph_cache: LruCache::new(glyph_cache_capacity),
456 shaped_cache: LruCache::new(NonZeroUsize::new(2048).unwrap()),
457 atlas_size,
458 }
459 }
460
461 pub fn atlas_size(&self) -> u32 {
463 self.atlas_size
464 }
465
466 pub fn clear_caches(&mut self) {
468 self.shaped_cache.clear();
469 }
473}
474
475pub struct SvgSubsystem {
486 pub model_cache: LruCache<String, SvgModel>,
488 pub tree_cache: LruCache<String, usvg::Tree>,
490 pub filter_engine: Option<cvkg_svg_filters::FilterEngine>,
492 pub filter_batches: Vec<cvkg_svg_filters::FilterNode>,
494 dirty_elements: std::collections::HashSet<String>,
497 dirty_sources: std::collections::HashSet<String>,
499}
500
501impl SvgSubsystem {
502 pub fn forge(
506 device: &Arc<wgpu::Device>,
507 queue: &Arc<wgpu::Queue>,
508 model_cache_capacity: NonZeroUsize,
509 tree_cache_capacity: NonZeroUsize,
510 ) -> Self {
511 let filter_engine = cvkg_svg_filters::FilterEngine::new(cvkg_svg_filters::GpuContext {
512 device: device.clone(),
513 queue: queue.clone(),
514 })
515 .ok();
516 Self {
517 model_cache: LruCache::new(model_cache_capacity),
518 tree_cache: LruCache::new(tree_cache_capacity),
519 filter_engine,
520 filter_batches: Vec::new(),
521 dirty_elements: std::collections::HashSet::new(),
522 dirty_sources: std::collections::HashSet::new(),
523 }
524 }
525
526 pub fn clear_filter_batches(&mut self) {
529 self.filter_batches.clear();
530 }
531
532 pub fn mark_element_dirty(&mut self, element_id: &str) {
536 self.dirty_elements.insert(element_id.to_string());
537 }
538
539 pub fn mark_source_dirty(&mut self, source_name: &str) {
541 self.dirty_sources.insert(source_name.to_string());
542 self.model_cache.pop(source_name);
544 }
545
546 pub fn is_element_dirty(&self, element_id: &str) -> bool {
548 self.dirty_elements.contains(element_id) || self.dirty_sources.contains(element_id)
549 }
550
551 pub fn is_source_dirty(&self, source_name: &str) -> bool {
553 self.dirty_sources.contains(source_name)
554 }
555
556 pub fn clear_dirty(&mut self) {
558 self.dirty_elements.clear();
559 self.dirty_sources.clear();
560 }
561
562 pub fn dirty_count(&self) -> usize {
564 self.dirty_elements.len() + self.dirty_sources.len()
565 }
566}
567
568pub struct ParticleSubsystem {
581 pub staging: Vec<GpuParticle>,
584 pub count: u32,
586 pub write_head: u32,
589 pub last_compact: std::time::Instant,
591}
592
593impl ParticleSubsystem {
594 pub fn forge() -> Self {
596 Self {
597 staging: Vec::new(),
598 count: 0,
599 write_head: 0,
600 last_compact: std::time::Instant::now(),
601 }
602 }
603}
604
605#[cfg(test)]
606mod p1_1_geometry_buffers_tests {
607 use super::*;
608
609 #[test]
615 fn vram_bytes_is_sum_of_three_buffers() {
616 let max_vertices = 1000usize;
619 let max_indices = 1500usize;
620 let vertex_bytes = max_vertices * std::mem::size_of::<Vertex>();
621 let index_bytes = max_indices * std::mem::size_of::<u32>();
622 let instance_bytes = (max_vertices / 4) * std::mem::size_of::<InstanceData>();
623 let expected = (vertex_bytes + index_bytes + instance_bytes) as u64;
624 assert!(expected > 0, "expected vram bytes > 0");
628 assert!(std::mem::size_of::<Vertex>() >= 16);
630 assert!(std::mem::size_of::<InstanceData>() >= 16);
632 }
633
634 #[test]
635 fn size_of_vertex_is_known() {
636 let size = std::mem::size_of::<Vertex>();
642 assert_eq!(size % 4, 0, "Vertex size must be 4-byte aligned");
644 }
645}
646
647#[cfg(test)]
648mod p1_1_text_subsystem_tests {
649 use super::TextSubsystem;
650 use std::num::NonZeroUsize;
651
652 #[test]
653 fn forge_creates_glyph_cache_with_given_capacity() {
654 let cap = NonZeroUsize::new(100).unwrap();
657 let subsystem = TextSubsystem::forge(cap);
658 assert_eq!(subsystem.glyph_cache.cap().get(), 100);
659 assert!(subsystem.shaped_cache.is_empty());
661 }
662
663 #[test]
664 fn clear_caches_empties_shaped_but_keeps_glyph() {
665 let cap = NonZeroUsize::new(10).unwrap();
669 let mut subsystem = TextSubsystem::forge(cap);
670 subsystem.clear_caches();
680 assert!(subsystem.shaped_cache.is_empty());
681 assert_eq!(subsystem.glyph_cache.cap().get(), 10);
683 }
684
685 #[test]
686 fn text_subsystem_default_atlas_size() {
687 use std::num::NonZeroUsize;
688 let sub = TextSubsystem::forge(NonZeroUsize::new(1024).unwrap());
689 assert_eq!(sub.atlas_size(), 4096, "Default atlas size should be 4096");
690 }
691
692 #[test]
693 fn text_subsystem_custom_atlas_size() {
694 use std::num::NonZeroUsize;
695 let sub = TextSubsystem::with_atlas_size(NonZeroUsize::new(1024).unwrap(), 2048);
696 assert_eq!(sub.atlas_size(), 2048, "Custom atlas size should be 2048");
697 }
698
699 #[test]
700 fn default_capacity_is_8192_matching_p1_5() {
701 let cap = NonZeroUsize::new(8192).unwrap();
705 let subsystem = TextSubsystem::forge(cap);
706 assert_eq!(subsystem.glyph_cache.cap().get(), 8192);
707 }
708}
709
710#[cfg(test)]
711mod p1_1_particle_subsystem_tests {
712 use super::ParticleSubsystem;
713
714 #[test]
715 fn forge_creates_empty_state() {
716 let p = ParticleSubsystem::forge();
719 assert!(p.staging.is_empty());
720 assert_eq!(p.count, 0);
721 assert_eq!(p.write_head, 0);
722 }
723
724 #[test]
725 fn fields_are_publicly_mutable() {
726 let mut p = ParticleSubsystem::forge();
730 p.staging.push(Default::default());
731 p.count = 1;
732 p.write_head = 1;
733 assert_eq!(p.staging.len(), 1);
734 assert_eq!(p.count, 1);
735 assert_eq!(p.write_head, 1);
736 }
737}
738
739#[cfg(test)]
742mod p1_24_incremental_svg_tests {
743 use super::SvgSubsystem;
744 use std::num::NonZeroUsize;
745 use std::sync::Arc;
746
747 #[test]
753 fn dirty_count_starts_at_zero() {
754 let dirty_elements: std::collections::HashSet<String> = std::collections::HashSet::new();
758 let dirty_sources: std::collections::HashSet<String> = std::collections::HashSet::new();
759 assert_eq!(dirty_elements.len() + dirty_sources.len(), 0);
760 }
761
762 #[test]
763 fn mark_dirty_increments_count() {
764 let mut dirty = std::collections::HashSet::new();
765 dirty.insert("path1".to_string());
766 dirty.insert("path2".to_string());
767 assert_eq!(dirty.len(), 2);
768 }
769
770 #[test]
771 fn source_dirty_implies_all_elements_dirty() {
772 let mut sources: std::collections::HashSet<String> = std::collections::HashSet::new();
773 sources.insert("my_icon.svg".to_string());
774 assert!(sources.contains("my_icon.svg"));
776 assert!(!sources.contains("other.svg"));
777 }
778}