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 lod;
10pub mod shader_features;
11pub mod thermal;
12pub mod virtualization;
13pub mod golden;
14
15pub use budget::OffscreenBudget;
16pub use lod::EffectLod;
17pub use shader_features::ShaderFeatureFlags;
18pub use thermal::{ThermalState, ThermalConfig};
19pub use virtualization::{Frustum, SpatialCell, SpatialHash};
20pub use golden::{GoldenImageConfig, GoldenImageResult, GoldenImageComparator};
21
22
23#[derive(Clone, Debug)]
27pub struct SvgModel {
28 pub vertices: Vec<Vertex>,
30 pub indices: Vec<u32>,
32 pub view_box: Rect,
34 pub paths: Vec<SvgPath>,
36 pub animations: Vec<SvgAnimation>,
38}
39
40#[derive(Clone, Debug)]
44pub struct SvgPath {
45 pub id: String,
47 pub vertex_range: std::ops::Range<usize>,
49 pub index_range: std::ops::Range<usize>,
51 pub local_transform: SvgTransform,
54}
55
56#[derive(Clone, Debug, Default)]
58pub struct SvgTransform {
59 pub translate: [f32; 2],
61 pub rotation: f32,
63 pub scale: f32,
65}
66
67#[derive(Clone, Debug)]
68pub struct SvgAnimation {
69 pub target_id: String,
70 pub attribute_name: String,
71 pub keyframe_values: Vec<f32>,
74 pub key_times: Vec<f32>,
76 pub duration: f32,
77 pub vertex_range: std::ops::Range<usize>,
78}
79
80impl SvgAnimation {
81 pub fn evaluate(&self, t: f32) -> f32 {
83 let vals = &self.keyframe_values;
84 if vals.is_empty() {
85 return 0.0;
86 }
87 if vals.len() == 1 {
88 return vals[0];
89 }
90 if vals.len() == 2 {
91 return vals[0] + (vals[1] - vals[0]) * t;
92 }
93 let times = if self.key_times.len() == vals.len() {
95 &self.key_times
96 } else {
97 return self.evaluate_uniform(t);
99 };
100 let t = t.clamp(0.0, 1.0);
102 for i in 0..times.len() - 1 {
103 if t >= times[i] && t <= times[i + 1] {
104 let seg_t = (t - times[i]) / (times[i + 1] - times[i]);
105 return vals[i] + (vals[i + 1] - vals[i]) * seg_t;
106 }
107 }
108 vals[vals.len() - 1]
109 }
110
111 fn evaluate_uniform(&self, t: f32) -> f32 {
112 let vals = &self.keyframe_values;
113 let n = vals.len() - 1;
114 let t = t.clamp(0.0, 1.0);
115 let idx_f = t * n as f32;
116 let idx = idx_f.floor() as usize;
117 let frac = idx_f - idx as f32;
118 if idx >= n {
119 vals[n]
120 } else {
121 vals[idx] + (vals[idx + 1] - vals[idx]) * frac
122 }
123 }
124}
125
126#[derive(Debug, Clone)]
129pub(crate) struct DrawCall {
130 pub texture_id: Option<u32>,
131 pub scissor_rect: Option<Rect>,
132 pub index_start: u32,
133 pub index_count: u32,
134 pub instance_count: u32,
138 pub material: cvkg_core::DrawMaterial,
141 pub target_id: Option<u64>,
142 pub instance_start: u32,
143 pub draw_order: i32,
146}
147
148#[derive(Debug, Clone)]
159pub(crate) struct MemoEntry {
160 pub hash: u64,
161 pub frame_gen: u64,
162 pub vertices: Vec<crate::vertex::Vertex>,
163 pub indices: Vec<u32>,
164 pub instance_data: Vec<crate::vertex::InstanceData>,
165 pub draw_calls: Vec<DrawCall>,
166}
167
168pub struct OffscreenEffectConfig {
169 pub target_id: u64,
170 pub effect: String,
171 pub blend_mode: u32,
172 pub effect_args: [f32; 16],
173}
174
175#[derive(Debug, Clone, Copy)]
176pub(crate) struct ShadowState {
177 pub radius: f32,
178 pub color: [f32; 4],
179 pub _offset: [f32; 2],
180}
181
182pub(crate) struct SurfaceContext {
183 pub(crate) surface: wgpu::Surface<'static>,
184 pub(crate) config: wgpu::SurfaceConfiguration,
185 pub(crate) scene_texture: wgpu::TextureView,
186 pub(crate) scene_msaa_texture: wgpu::TextureView,
187 pub(crate) scene_bind_group: wgpu::BindGroup,
188 pub(crate) scene_texture_bind_group: wgpu::BindGroup,
189 pub(crate) depth_texture_view: wgpu::TextureView,
190 pub(crate) blur_tex_a: crate::kvasir::resource::ResourceId,
191 pub(crate) blur_tex_b: crate::kvasir::resource::ResourceId,
192 pub(crate) bloom_tex_a: crate::kvasir::resource::ResourceId,
193 pub(crate) bloom_tex_b: crate::kvasir::resource::ResourceId,
194 pub(crate) blur_env_bind_group_a: wgpu::BindGroup,
195 pub(crate) blur_env_bind_group_b: wgpu::BindGroup,
196 pub(crate) bloom_env_bind_group_a: wgpu::BindGroup,
197 pub(crate) bloom_env_bind_group_b: wgpu::BindGroup,
198 pub(crate) scale_factor: f32,
199 pub(crate) sampler: wgpu::Sampler,
200}
201
202pub struct HeadlessContext {
204 pub scene_texture: wgpu::TextureView,
205 pub scene_msaa_texture: wgpu::TextureView,
206 pub scene_bind_group: wgpu::BindGroup,
207 pub scene_texture_bind_group: wgpu::BindGroup,
208 pub depth_texture_view: wgpu::TextureView,
209 pub blur_tex_a: crate::kvasir::resource::ResourceId,
210 pub blur_tex_b: crate::kvasir::resource::ResourceId,
211 pub bloom_tex_a: crate::kvasir::resource::ResourceId,
212 pub bloom_tex_b: crate::kvasir::resource::ResourceId,
213 pub blur_env_bind_group_a: wgpu::BindGroup,
214 pub blur_env_bind_group_b: wgpu::BindGroup,
215 pub bloom_env_bind_group_a: wgpu::BindGroup,
216 pub bloom_env_bind_group_b: wgpu::BindGroup,
217 pub scale_factor: f32,
218 pub sampler: wgpu::Sampler,
219 pub width: u32,
220 pub height: u32,
221 pub output_texture: wgpu::Texture,
222 pub output_view: wgpu::TextureView,
223}
224
225pub(crate) const MAX_VERTICES: usize = 100_000;
226pub(crate) const MAX_INDICES: usize = 150_000;
227
228pub(crate) const MAX_PARTICLES: usize = 65536;
230
231#[repr(C)]
235#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
236pub struct GpuParticle {
237 pub pos_vel: [f32; 4],
238 pub color_life: [f32; 4],
239}
240
241#[repr(C)]
244#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
245pub struct ParticleUniforms {
246 pub dt: f32,
247 pub _pad: [f32; 7],
248}
249
250#[repr(C)]
251#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
252pub struct EffectUniforms {
253 pub time: f32,
254 pub pad0: f32,
255 pub size: [f32; 2],
256 pub args: [f32; 16],
257}
258
259#[repr(C)]
263#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
264pub struct GlassInstanceUniforms {
265 pub tint_override: [f32; 4],
268 pub ior_override: f32,
270 pub blur_multiplier: f32,
272 pub frost_override: f32,
274 pub scissor_px: [f32; 4],
277 pub portal_index: f32,
280 pub _pad: f32,
281}
282
283impl Default for GlassInstanceUniforms {
284 fn default() -> Self {
285 Self {
286 tint_override: [0.0; 4],
287 ior_override: 0.0,
288 blur_multiplier: 1.0,
289 frost_override: 0.0,
290 scissor_px: [0.0; 4],
291 portal_index: 0.0,
292 _pad: 0.0,
293 }
294 }
295}
296
297
298pub struct GeometryBuffers {
312 pub vertex_buffer: wgpu::Buffer,
314 pub index_buffer: wgpu::Buffer,
316 pub instance_buffer: wgpu::Buffer,
318 pub max_vertices: usize,
320 pub max_indices: usize,
322}
323
324impl GeometryBuffers {
325 pub fn forge(device: &wgpu::Device, max_vertices: usize, max_indices: usize) -> Self {
328 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
329 label: Some("Surtr Vertex Anvil"),
330 size: (max_vertices * std::mem::size_of::<Vertex>()) as u64,
331 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
332 mapped_at_creation: false,
333 });
334 let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
335 label: Some("Surtr Index Anvil"),
336 size: (max_indices * std::mem::size_of::<u32>()) as u64,
337 usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
338 mapped_at_creation: false,
339 });
340 let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor {
341 label: Some("Surtr Instance Anvil"),
342 size: (max_vertices / 4 * std::mem::size_of::<InstanceData>()) as u64,
343 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
344 mapped_at_creation: false,
345 });
346 Self {
347 vertex_buffer,
348 index_buffer,
349 instance_buffer,
350 max_vertices,
351 max_indices,
352 }
353 }
354
355 pub fn vram_bytes(&self) -> u64 {
357 let vertex_bytes = self.max_vertices * std::mem::size_of::<Vertex>();
358 let index_bytes = self.max_indices * std::mem::size_of::<u32>();
359 let instance_bytes = (self.max_vertices / 4) * std::mem::size_of::<InstanceData>();
360 (vertex_bytes + index_bytes + instance_bytes) as u64
361 }
362
363 pub fn grow_vertex_buffer(
368 &mut self,
369 device: &wgpu::Device,
370 min_capacity: usize,
371 max_capacity: usize,
372 ) -> bool {
373 let current = self.vertex_buffer.size() as usize / std::mem::size_of::<Vertex>();
374 if min_capacity <= current {
375 return false;
376 }
377 let new_capacity = min_capacity.min(max_capacity);
378 if new_capacity <= current {
379 return false;
380 }
381 self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
382 label: Some("Vertex Buffer (Grown)"),
383 size: (new_capacity * std::mem::size_of::<Vertex>()) as u64,
384 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
385 mapped_at_creation: false,
386 });
387 true
388 }
389
390 pub fn grow_index_buffer(
394 &mut self,
395 device: &wgpu::Device,
396 min_capacity: usize,
397 max_capacity: usize,
398 ) -> bool {
399 let current = self.index_buffer.size() as usize / std::mem::size_of::<u32>();
400 if min_capacity <= current {
401 return false;
402 }
403 let new_capacity = min_capacity.min(max_capacity);
404 if new_capacity <= current {
405 return false;
406 }
407 self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
408 label: Some("Index Buffer (Grown)"),
409 size: (new_capacity * std::mem::size_of::<u32>()) as u64,
410 usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
411 mapped_at_creation: false,
412 });
413 true
414 }
415}
416
417pub struct TextSubsystem {
428 pub engine: cvkg_runic_text::TextEngine,
431 pub glyph_cache: LruCache<u64, (cvkg_core::Rect, f32, f32, f32, f32)>,
434 pub shaped_cache:
438 LruCache<(String, u32), std::sync::Arc<cvkg_runic_text::ShapedText>>,
439}
440
441impl TextSubsystem {
442 pub fn forge(glyph_cache_capacity: NonZeroUsize) -> Self {
445 Self {
446 engine: cvkg_runic_text::TextEngine::default(),
447 glyph_cache: LruCache::new(glyph_cache_capacity),
448 shaped_cache: LruCache::new(NonZeroUsize::new(2048).unwrap()),
449 }
450 }
451
452 pub fn clear_caches(&mut self) {
454 self.shaped_cache.clear();
455 }
459}
460
461pub struct SvgSubsystem {
472 pub model_cache: LruCache<String, SvgModel>,
474 pub tree_cache: LruCache<String, usvg::Tree>,
476 pub filter_engine: Option<cvkg_svg_filters::FilterEngine>,
478 pub filter_batches: Vec<cvkg_svg_filters::FilterNode>,
480 dirty_elements: std::collections::HashSet<String>,
483 dirty_sources: std::collections::HashSet<String>,
485}
486
487impl SvgSubsystem {
488 pub fn forge(
492 device: &Arc<wgpu::Device>,
493 queue: &Arc<wgpu::Queue>,
494 model_cache_capacity: NonZeroUsize,
495 tree_cache_capacity: NonZeroUsize,
496 ) -> Self {
497 let filter_engine = cvkg_svg_filters::FilterEngine::new(cvkg_svg_filters::GpuContext {
498 device: device.clone(),
499 queue: queue.clone(),
500 })
501 .ok();
502 Self {
503 model_cache: LruCache::new(model_cache_capacity),
504 tree_cache: LruCache::new(tree_cache_capacity),
505 filter_engine,
506 filter_batches: Vec::new(),
507 dirty_elements: std::collections::HashSet::new(),
508 dirty_sources: std::collections::HashSet::new(),
509 }
510 }
511
512 pub fn clear_filter_batches(&mut self) {
515 self.filter_batches.clear();
516 }
517
518 pub fn mark_element_dirty(&mut self, element_id: &str) {
522 self.dirty_elements.insert(element_id.to_string());
523 }
524
525 pub fn mark_source_dirty(&mut self, source_name: &str) {
527 self.dirty_sources.insert(source_name.to_string());
528 self.model_cache.pop(source_name);
530 }
531
532 pub fn is_element_dirty(&self, element_id: &str) -> bool {
534 self.dirty_elements.contains(element_id)
535 || self.dirty_sources.contains(element_id)
536 }
537
538 pub fn is_source_dirty(&self, source_name: &str) -> bool {
540 self.dirty_sources.contains(source_name)
541 }
542
543 pub fn clear_dirty(&mut self) {
545 self.dirty_elements.clear();
546 self.dirty_sources.clear();
547 }
548
549 pub fn dirty_count(&self) -> usize {
551 self.dirty_elements.len() + self.dirty_sources.len()
552 }
553}
554
555pub struct ParticleSubsystem {
568 pub staging: Vec<GpuParticle>,
571 pub count: u32,
573 pub write_head: u32,
576 pub last_compact: std::time::Instant,
578}
579
580impl ParticleSubsystem {
581 pub fn forge() -> Self {
583 Self {
584 staging: Vec::new(),
585 count: 0,
586 write_head: 0,
587 last_compact: std::time::Instant::now(),
588 }
589 }
590}
591
592
593#[cfg(test)]
594mod p1_1_geometry_buffers_tests {
595 use super::*;
596
597 #[test]
603 fn vram_bytes_is_sum_of_three_buffers() {
604 let max_vertices = 1000usize;
607 let max_indices = 1500usize;
608 let vertex_bytes = max_vertices * std::mem::size_of::<Vertex>();
609 let index_bytes = max_indices * std::mem::size_of::<u32>();
610 let instance_bytes = (max_vertices / 4) * std::mem::size_of::<InstanceData>();
611 let expected = (vertex_bytes + index_bytes + instance_bytes) as u64;
612 assert!(expected > 0, "expected vram bytes > 0");
616 assert!(std::mem::size_of::<Vertex>() >= 16);
618 assert!(std::mem::size_of::<InstanceData>() >= 16);
620 }
621
622 #[test]
623 fn size_of_vertex_is_known() {
624 let size = std::mem::size_of::<Vertex>();
630 assert_eq!(size % 4, 0, "Vertex size must be 4-byte aligned");
632 }
633}
634
635
636#[cfg(test)]
637mod p1_1_text_subsystem_tests {
638 use super::TextSubsystem;
639 use std::num::NonZeroUsize;
640
641 #[test]
642 fn forge_creates_glyph_cache_with_given_capacity() {
643 let cap = NonZeroUsize::new(100).unwrap();
646 let subsystem = TextSubsystem::forge(cap);
647 assert_eq!(subsystem.glyph_cache.cap().get(), 100);
648 assert!(subsystem.shaped_cache.is_empty());
650 }
651
652 #[test]
653 fn clear_caches_empties_shaped_but_keeps_glyph() {
654 let cap = NonZeroUsize::new(10).unwrap();
658 let mut subsystem = TextSubsystem::forge(cap);
659 subsystem.clear_caches();
669 assert!(subsystem.shaped_cache.is_empty());
670 assert_eq!(subsystem.glyph_cache.cap().get(), 10);
672 }
673
674 #[test]
675 fn default_capacity_is_8192_matching_p1_5() {
676 let cap = NonZeroUsize::new(8192).unwrap();
680 let subsystem = TextSubsystem::forge(cap);
681 assert_eq!(subsystem.glyph_cache.cap().get(), 8192);
682 }
683}
684
685#[cfg(test)]
686mod p1_1_particle_subsystem_tests {
687 use super::ParticleSubsystem;
688
689 #[test]
690 fn forge_creates_empty_state() {
691 let p = ParticleSubsystem::forge();
694 assert!(p.staging.is_empty());
695 assert_eq!(p.count, 0);
696 assert_eq!(p.write_head, 0);
697 }
698
699 #[test]
700 fn fields_are_publicly_mutable() {
701 let mut p = ParticleSubsystem::forge();
705 p.staging.push(Default::default());
706 p.count = 1;
707 p.write_head = 1;
708 assert_eq!(p.staging.len(), 1);
709 assert_eq!(p.count, 1);
710 assert_eq!(p.write_head, 1);
711 }
712}
713
714#[cfg(test)]
717mod p1_24_incremental_svg_tests {
718 use super::SvgSubsystem;
719 use std::num::NonZeroUsize;
720 use std::sync::Arc;
721
722 #[test]
728 fn dirty_count_starts_at_zero() {
729 let dirty_elements: std::collections::HashSet<String> = std::collections::HashSet::new();
733 let dirty_sources: std::collections::HashSet<String> = std::collections::HashSet::new();
734 assert_eq!(dirty_elements.len() + dirty_sources.len(), 0);
735 }
736
737 #[test]
738 fn mark_dirty_increments_count() {
739 let mut dirty = std::collections::HashSet::new();
740 dirty.insert("path1".to_string());
741 dirty.insert("path2".to_string());
742 assert_eq!(dirty.len(), 2);
743 }
744
745 #[test]
746 fn source_dirty_implies_all_elements_dirty() {
747 let mut sources: std::collections::HashSet<String> = std::collections::HashSet::new();
748 sources.insert("my_icon.svg".to_string());
749 assert!(sources.contains("my_icon.svg"));
751 assert!(!sources.contains("other.svg"));
752 }
753}
754
755