1use std::collections::{HashMap, HashSet};
59use std::sync::Arc;
60
61use bytemuck::{Pod, Zeroable};
62use glam::{Mat4, Vec2};
63use repose_render_wgpu::{CallbackResources, ScreenDescriptor, WgpuCallback};
64use wgpu::util::DeviceExt;
65
66use super::TextureFilter;
67use super::light::{
68 AmbientLight, Light2d, LightBlend, LightBudget, LightingPlan, MAX_MASK_TAPS, Occluder2d,
69 plan_lighting, visible_world_rect,
70};
71
72#[derive(Clone, Debug)]
78pub struct LightTextureUpload {
79 pub slot: u32,
81 pub w: u32,
82 pub h: u32,
83 pub rgba: Vec<u8>,
84}
85
86#[derive(Clone, Copy, Debug)]
88pub struct Light2dDesc {
89 pub lightmap_scale: f32,
93 pub mask_size: u32,
97 pub filter: TextureFilter,
99 pub textures_gen: u64,
102 pub budget: LightBudget,
105}
106
107impl Default for Light2dDesc {
108 fn default() -> Self {
109 Self {
110 lightmap_scale: 0.5,
111 mask_size: 512,
112 filter: TextureFilter::Linear,
113 textures_gen: 0,
114 budget: LightBudget::default(),
115 }
116 }
117}
118
119pub struct LightmapInfo {
127 pub view: wgpu::TextureView,
129 pub world_rect: [f32; 4],
131 pub layer_clamp: u32,
133 pub generation: u64,
137}
138
139pub fn lookup_lightmap(resources: &CallbackResources, id: &str) -> Option<Arc<LightmapInfo>> {
146 resources.get::<LightResources>()?.maps.get(id).cloned()
147}
148
149pub(crate) struct LightScope;
159
160pub(crate) const SHARED_CALLBACK_KEY: &str = "repame.sprite_batch.resources";
163
164pub struct Light2dPass {
211 id: String,
212 desc: Light2dDesc,
213 camera: Mat4,
214 lights: Vec<Light2d>,
215 occluders: Vec<Occluder2d>,
216 ambient: AmbientLight,
217 textures: Vec<LightTextureUpload>,
218}
219
220impl Light2dPass {
221 pub fn new(desc: Light2dDesc) -> Self {
224 Self::with_id("light2d.default", desc)
225 }
226
227 pub fn with_id(id: impl Into<String>, desc: Light2dDesc) -> Self {
228 Self {
229 id: id.into(),
230 desc,
231 camera: Mat4::IDENTITY,
232 lights: Vec::new(),
233 occluders: Vec::new(),
234 ambient: AmbientLight::default(),
235 textures: Vec::new(),
236 }
237 }
238
239 pub fn id(&self) -> &str {
242 &self.id
243 }
244
245 pub fn set_camera(&mut self, view_proj: Mat4) {
249 self.camera = view_proj;
250 }
251
252 pub fn clear(&mut self) {
255 self.lights.clear();
256 self.occluders.clear();
257 }
258
259 pub fn push_light(&mut self, light: Light2d) {
260 self.lights.push(light);
261 }
262
263 pub fn extend_lights(&mut self, lights: impl IntoIterator<Item = Light2d>) {
264 self.lights.extend(lights);
265 }
266
267 pub fn push_occluder(&mut self, occluder: Occluder2d) {
268 self.occluders.push(occluder);
269 }
270
271 pub fn extend_occluders(&mut self, occluders: impl IntoIterator<Item = Occluder2d>) {
272 self.occluders.extend(occluders);
273 }
274
275 pub fn set_ambient(&mut self, ambient: AmbientLight) {
277 self.ambient = ambient;
278 }
279
280 pub fn queue_texture(&mut self, upload: LightTextureUpload) {
282 self.textures.push(upload);
283 }
284
285 pub fn extend_textures(&mut self, uploads: impl IntoIterator<Item = LightTextureUpload>) {
286 self.textures.extend(uploads);
287 }
288
289 pub fn plan(&self) -> LightingPlan {
293 plan_lighting(
294 &self.lights,
295 &self.occluders,
296 &self.ambient,
297 visible_world_rect(self.camera).unwrap_or([0.0, 0.0, 1.0, 1.0]),
298 &self.desc.budget,
299 )
300 }
301
302 pub fn node(self, rect: repose_core::Rect) -> repose_core::SceneNode {
306 repose_core::SceneNode::Callback {
307 rect,
308 payload: repose_render_wgpu::Callback::new(self),
309 }
310 }
311
312 fn warn_once(resources: &mut CallbackResources, id: &str, message: String) {
315 let all = resources.get_or_insert_with::<LightResources>();
316 if all.warned.insert((id.to_string(), message.clone())) {
317 log::warn!("light2d[{id}]: {message}");
318 }
319 }
320}
321
322#[repr(C)]
332#[derive(Clone, Copy, Debug, Pod, Zeroable)]
333struct LightVertex {
334 clip: [f32; 2],
335 world: [f32; 2],
336 weights: [f32; MAX_MASK_TAPS],
337}
338
339#[repr(C)]
341#[derive(Clone, Copy, Debug, Pod, Zeroable)]
342struct LightUniform {
343 center_half: [f32; 4],
344 color_energy: [f32; 4],
345 params: [f32; 4],
346 mask_xform: [f32; 4],
347 tex: [f32; 4],
348 stop_at: [[f32; 4]; 2],
349 stop_rgba: [[f32; 4]; 8],
350}
351
352const _: () = assert!(size_of::<LightUniform>() == 240);
353const _: () = assert!(size_of::<LightVertex>() == 32);
354
355struct RampTexture {
356 texture: wgpu::Texture,
358 size: [u32; 2],
359}
360
361struct LightTarget {
362 size: [u32; 2],
363 layers: u32,
364 #[allow(dead_code)]
366 texture: wgpu::Texture,
367 views: Vec<wgpu::TextureView>,
368 info: Arc<LightmapInfo>,
369}
370
371struct PassEntry {
372 pipeline_mask: wgpu::RenderPipeline,
373 pipelines: [wgpu::RenderPipeline; 3],
375 ramp_layout: wgpu::BindGroupLayout,
376 vertices: wgpu::Buffer,
377 vertex_cap: usize,
378 uniforms: wgpu::Buffer,
379 uniform_bind: wgpu::BindGroup,
380 uniform_stride: u32,
381 uniform_slots: usize,
382 #[allow(dead_code)]
383 mask_texture: wgpu::Texture,
384 mask_view: wgpu::TextureView,
385 mask_bind: wgpu::BindGroup,
386 mask_bind_empty: wgpu::BindGroup,
387 ramp_bind_empty: wgpu::BindGroup,
388 ramp_binds: HashMap<u32, (RampTexture, wgpu::BindGroup)>,
389 applied_textures_gen: Option<u64>,
390 sampler: wgpu::Sampler,
391 target: Option<LightTarget>,
392 generation: u64,
393}
394
395struct LightResources {
396 passes: HashMap<String, PassEntry>,
397 maps: HashMap<String, Arc<LightmapInfo>>,
398 warned: HashSet<(String, String)>,
399}
400
401impl Default for LightResources {
402 fn default() -> Self {
403 Self {
404 passes: HashMap::new(),
405 maps: HashMap::new(),
406 warned: HashSet::new(),
407 }
408 }
409}
410
411const ADD: wgpu::BlendComponent = wgpu::BlendComponent {
412 src_factor: wgpu::BlendFactor::SrcAlpha,
413 dst_factor: wgpu::BlendFactor::One,
414 operation: wgpu::BlendOperation::Add,
415};
416
417const KEEP_MULTIPLIER: wgpu::BlendComponent = wgpu::BlendComponent {
418 src_factor: wgpu::BlendFactor::Zero,
419 dst_factor: wgpu::BlendFactor::One,
420 operation: wgpu::BlendOperation::Add,
421};
422
423fn blend_state(blend: LightBlend) -> wgpu::BlendState {
426 match blend {
427 LightBlend::Add => wgpu::BlendState {
428 color: ADD,
429 alpha: KEEP_MULTIPLIER,
430 },
431 LightBlend::Sub => wgpu::BlendState {
434 color: wgpu::BlendComponent {
435 operation: wgpu::BlendOperation::ReverseSubtract,
436 ..ADD
437 },
438 alpha: KEEP_MULTIPLIER,
439 },
440 LightBlend::Mix => wgpu::BlendState {
441 color: wgpu::BlendComponent {
442 src_factor: wgpu::BlendFactor::One,
443 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
444 operation: wgpu::BlendOperation::Add,
445 },
446 alpha: wgpu::BlendComponent {
447 src_factor: wgpu::BlendFactor::Zero,
448 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
449 operation: wgpu::BlendOperation::Add,
450 },
451 },
452 }
453}
454
455fn blend_index(blend: LightBlend) -> usize {
456 match blend {
457 LightBlend::Add => 0,
458 LightBlend::Sub => 1,
459 LightBlend::Mix => 2,
460 }
461}
462
463const MASK_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
466
467const MASK_BLEND: wgpu::BlendState = wgpu::BlendState {
475 color: wgpu::BlendComponent {
476 src_factor: wgpu::BlendFactor::One,
477 dst_factor: wgpu::BlendFactor::One,
478 operation: wgpu::BlendOperation::Max,
479 },
480 alpha: wgpu::BlendComponent {
481 src_factor: wgpu::BlendFactor::One,
482 dst_factor: wgpu::BlendFactor::One,
483 operation: wgpu::BlendOperation::Max,
484 },
485};
486
487fn sampler_desc(filter: TextureFilter, label: &'static str) -> wgpu::SamplerDescriptor<'static> {
488 let f = match filter {
489 TextureFilter::Nearest => wgpu::FilterMode::Nearest,
490 TextureFilter::Linear => wgpu::FilterMode::Linear,
491 };
492 wgpu::SamplerDescriptor {
493 label: Some(label),
494 address_mode_u: wgpu::AddressMode::ClampToEdge,
495 address_mode_v: wgpu::AddressMode::ClampToEdge,
496 address_mode_w: wgpu::AddressMode::ClampToEdge,
497 mag_filter: f,
498 min_filter: f,
499 mipmap_filter: wgpu::MipmapFilterMode::Nearest,
500 lod_min_clamp: 0.0,
501 lod_max_clamp: 1.0,
502 compare: None,
503 anisotropy_clamp: 1,
504 border_color: None,
505 }
506}
507
508fn lightmap_clip(world: Vec2, rect: [f32; 4]) -> [f32; 2] {
510 [
511 (world.x - rect[0]) * (2.0 / rect[2]) - 1.0,
512 1.0 - (world.y - rect[1]) * (2.0 / rect[3]),
513 ]
514}
515
516fn mask_clip(world: Vec2, center: Vec2, half_extent: Vec2) -> [f32; 2] {
519 [
520 (world.x - (center.x - half_extent.x)) / half_extent.x - 1.0,
521 1.0 - (world.y - (center.y - half_extent.y)) / half_extent.y,
522 ]
523}
524
525fn scissor_px(aabb: [f32; 4], rect: [f32; 4], size: [u32; 2]) -> Option<[u32; 4]> {
528 let sx = size[0] as f32 / rect[2];
529 let sy = size[1] as f32 / rect[3];
530 let x0 = ((aabb[0] - rect[0]) * sx).floor().max(0.0);
531 let y0 = ((aabb[1] - rect[1]) * sy).floor().max(0.0);
532 let x1 = ((aabb[2] - rect[0]) * sx).ceil().min(size[0] as f32);
533 let y1 = ((aabb[3] - rect[1]) * sy).ceil().min(size[1] as f32);
534 if !(x1 > x0 && y1 > y0) {
535 return None;
536 }
537 Some([x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32])
538}
539
540fn align_up(value: u32, align: u32) -> u32 {
541 let align = align.max(1);
542 value.div_ceil(align) * align
543}
544
545#[derive(Clone, Copy, Debug, PartialEq, Eq)]
547struct LightRanges {
548 quad: (u32, u32),
549 mask: Option<(u32, u32)>,
550}
551
552impl Light2dPass {
553 fn uniform_stride(device: &wgpu::Device) -> u32 {
554 align_up(
555 size_of::<LightUniform>() as u32,
556 device.limits().min_uniform_buffer_offset_alignment,
557 )
558 }
559
560 fn ensure_resources(
561 &self,
562 device: &wgpu::Device,
563 queue: &wgpu::Queue,
564 resources: &mut CallbackResources,
565 ) {
566 let stride = Self::uniform_stride(device);
567 let slots = self.desc.budget.max_lights.max(1);
568 let stale = resources
569 .get::<LightResources>()
570 .and_then(|all| all.passes.get(self.id.as_str()))
571 .is_some_and(|entry| entry.uniform_stride != stride || entry.uniform_slots != slots);
572 if stale {
573 log::warn!(
574 "light2d[{}]: rebuilding pipelines for a budget/alignment change",
575 self.id
576 );
577 if let Some(all) = resources.get_mut::<LightResources>() {
578 all.passes.remove(self.id.as_str());
579 }
580 }
581 let present = resources
582 .get::<LightResources>()
583 .is_some_and(|all| all.passes.contains_key(self.id.as_str()));
584 if present {
585 return;
586 }
587 let all = resources.get_or_insert_with::<LightResources>();
588 let generation = all
589 .passes
590 .values()
591 .map(|entry| entry.generation)
592 .max()
593 .unwrap_or(0)
594 + 1;
595 let entry = create_entry(self, device, queue, stride, slots, generation);
596 all.passes.insert(self.id.clone(), entry);
597 }
598
599 fn apply_textures(&self, device: &wgpu::Device, queue: &wgpu::Queue, entry: &mut PassEntry) {
601 if entry.applied_textures_gen == Some(self.desc.textures_gen) {
602 return;
603 }
604 entry.applied_textures_gen = Some(self.desc.textures_gen);
605 for upload in &self.textures {
606 let valid = upload.w > 0
607 && upload.h > 0
608 && upload.rgba.len() == upload.w as usize * upload.h as usize * 4;
609 if !valid {
610 log::warn!(
611 "light2d: dropping malformed falloff texture slot={} {}x{} ({} bytes)",
612 upload.slot,
613 upload.w,
614 upload.h,
615 upload.rgba.len()
616 );
617 continue;
618 }
619 let size = [upload.w, upload.h];
620 let reuse = entry
621 .ramp_binds
622 .get(&upload.slot)
623 .is_some_and(|(texture, _)| texture.size == size);
624 if !reuse {
625 let texture = device.create_texture(&wgpu::TextureDescriptor {
626 label: Some("light2d_ramp"),
627 size: wgpu::Extent3d {
628 width: upload.w,
629 height: upload.h,
630 depth_or_array_layers: 1,
631 },
632 mip_level_count: 1,
633 sample_count: 1,
634 dimension: wgpu::TextureDimension::D2,
635 format: wgpu::TextureFormat::Rgba8UnormSrgb,
636 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
637 view_formats: &[],
638 });
639 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
640 let bind = create_texture_bind(
641 device,
642 &entry.ramp_layout,
643 &view,
644 &entry.sampler,
645 "light2d_ramp_bg",
646 );
647 entry
648 .ramp_binds
649 .insert(upload.slot, (RampTexture { texture, size }, bind));
650 }
651 let (ramp, _) = entry
652 .ramp_binds
653 .get_mut(&upload.slot)
654 .expect("inserted above");
655 queue.write_texture(
656 wgpu::TexelCopyTextureInfo {
657 texture: &ramp.texture,
658 mip_level: 0,
659 origin: wgpu::Origin3d::ZERO,
660 aspect: wgpu::TextureAspect::All,
661 },
662 &upload.rgba,
663 wgpu::TexelCopyBufferLayout {
664 offset: 0,
665 bytes_per_row: Some(upload.w * 4),
666 rows_per_image: Some(upload.h),
667 },
668 wgpu::Extent3d {
669 width: upload.w,
670 height: upload.h,
671 depth_or_array_layers: 1,
672 },
673 );
674 }
675 }
676
677 fn prepare_frame(
678 &self,
679 device: &wgpu::Device,
680 queue: &wgpu::Queue,
681 encoder: &mut wgpu::CommandEncoder,
682 screen: &ScreenDescriptor,
683 resources: &mut CallbackResources,
684 ) {
685 let Some(world_rect) = visible_world_rect(self.camera) else {
686 Self::warn_once(
687 resources,
688 &self.id,
689 "camera does not map a visible world rect; skipping lights this frame".to_string(),
690 );
691 return;
692 };
693 let plan = plan_lighting(
694 &self.lights,
695 &self.occluders,
696 &self.ambient,
697 world_rect,
698 &self.desc.budget,
699 );
700 for issue in &plan.issues {
701 Self::warn_once(
702 resources,
703 &self.id,
704 format!("light {} dropped: {:?}", issue.source, issue.kind),
705 );
706 }
707 for reject in &plan.occluder_rejects {
708 Self::warn_once(
709 resources,
710 &self.id,
711 format!(
712 "occluder {} rejected: {:?}",
713 reject.index, reject.reason
714 ),
715 );
716 }
717
718 let scale = if self.desc.lightmap_scale.is_finite() {
719 self.desc.lightmap_scale.clamp(0.25, 1.0)
720 } else {
721 0.5
722 };
723 let max_dim = device.limits().max_texture_dimension_2d;
724 let size = [
725 ((screen.size_in_pixels[0] as f32 * scale).round() as u32).clamp(1, max_dim),
726 ((screen.size_in_pixels[1] as f32 * scale).round() as u32).clamp(1, max_dim),
727 ];
728
729 self.ensure_resources(device, queue, resources);
730 let stride = Self::uniform_stride(device) as usize;
731 let (vertices, ranges) = build_vertices(&plan, world_rect);
732 let (uniforms, light_count) = build_uniforms(&plan, stride);
733
734 let LightResources { passes, maps, .. } = resources
735 .get_mut::<LightResources>()
736 .expect("ensure_resources inserted them");
737 let Some(entry) = passes.get_mut(self.id.as_str()) else {
738 return;
739 };
740 if light_count > entry.uniform_slots {
741 log::error!(
742 "light2d[{}]: {} lights exceed the {} uniform slots; drawing {}",
743 self.id,
744 light_count,
745 entry.uniform_slots,
746 entry.uniform_slots
747 );
748 }
749 self.apply_textures(device, queue, entry);
750 Self::ensure_target(entry, device, size, plan.layers, world_rect);
751 Self::ensure_vertices(entry, device, vertices.len());
752 if !vertices.is_empty() {
753 queue.write_buffer(&entry.vertices, 0, bytemuck::cast_slice(&vertices));
754 }
755 if !uniforms.is_empty() {
756 queue.write_buffer(&entry.uniforms, 0, &uniforms);
757 }
758
759 let info = entry
760 .target
761 .as_ref()
762 .expect("target exists after ensure_target")
763 .info
764 .clone();
765 maps.insert(self.id.clone(), info);
766
767 let target = entry.target.as_ref().expect("target exists");
768 record_frame(
769 encoder,
770 entry,
771 target,
772 &plan,
773 &ranges,
774 &world_rect,
775 light_count,
776 );
777 }
778
779 fn ensure_vertices(entry: &mut PassEntry, device: &wgpu::Device, len: usize) {
780 if len <= entry.vertex_cap {
781 return;
782 }
783 let cap = len.next_power_of_two().max(64);
784 entry.vertices = device.create_buffer(&wgpu::BufferDescriptor {
785 label: Some("light2d_vertices"),
786 size: (cap * size_of::<LightVertex>()) as u64,
787 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
788 mapped_at_creation: false,
789 });
790 entry.vertex_cap = cap;
791 }
792
793 fn ensure_target(
794 entry: &mut PassEntry,
795 device: &wgpu::Device,
796 size: [u32; 2],
797 layers: u32,
798 world_rect: [f32; 4],
799 ) {
800 let stale = entry
801 .target
802 .as_ref()
803 .is_none_or(|target| target.size != size || target.layers != layers);
804 if stale {
805 let texture = device.create_texture(&wgpu::TextureDescriptor {
806 label: Some("light2d_map"),
807 size: wgpu::Extent3d {
808 width: size[0],
809 height: size[1],
810 depth_or_array_layers: layers,
811 },
812 mip_level_count: 1,
813 sample_count: 1,
814 dimension: wgpu::TextureDimension::D2,
815 format: wgpu::TextureFormat::Rgba8Unorm,
816 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
817 | wgpu::TextureUsages::TEXTURE_BINDING,
818 view_formats: &[],
819 });
820 let views: Vec<wgpu::TextureView> = (0..layers)
821 .map(|layer| {
822 texture.create_view(&wgpu::TextureViewDescriptor {
823 label: Some("light2d_map_layer"),
824 dimension: Some(wgpu::TextureViewDimension::D2),
825 base_array_layer: layer,
826 array_layer_count: Some(1),
827 ..Default::default()
828 })
829 })
830 .collect();
831 let array_view = texture.create_view(&wgpu::TextureViewDescriptor {
832 label: Some("light2d_map"),
833 dimension: Some(wgpu::TextureViewDimension::D2Array),
834 ..Default::default()
835 });
836 entry.generation += 1;
837 entry.target = Some(LightTarget {
838 size,
839 layers,
840 texture,
841 views,
842 info: Arc::new(LightmapInfo {
843 view: array_view,
844 world_rect,
845 layer_clamp: layers - 1,
846 generation: entry.generation,
847 }),
848 });
849 }
850 let target = entry.target.as_mut().expect("created above");
851 if target.info.world_rect != world_rect || target.info.layer_clamp != layers - 1 {
855 target.info = Arc::new(LightmapInfo {
856 view: target.info.view.clone(),
857 world_rect,
858 layer_clamp: layers - 1,
859 generation: target.info.generation,
860 });
861 }
862 }
863}
864
865fn create_entry(
866 pass: &Light2dPass,
867 device: &wgpu::Device,
868 queue: &wgpu::Queue,
869 uniform_stride: u32,
870 uniform_slots: usize,
871 generation: u64,
872) -> PassEntry {
873 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
874 label: Some("light2d_shader"),
875 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/light_disc.wgsl").into()),
876 });
877 let mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
878 label: Some("light2d_mask_shader"),
879 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/light_mask.wgsl").into()),
880 });
881 let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
882 label: Some("light2d_uniform_bgl"),
883 entries: &[wgpu::BindGroupLayoutEntry {
884 binding: 0,
885 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
886 ty: wgpu::BindingType::Buffer {
887 ty: wgpu::BufferBindingType::Uniform,
888 has_dynamic_offset: true,
889 min_binding_size: None,
890 },
891 count: None,
892 }],
893 });
894 let stereo_layout = |label: &'static str| {
895 device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
896 label: Some(label),
897 entries: &[
898 wgpu::BindGroupLayoutEntry {
899 binding: 0,
900 visibility: wgpu::ShaderStages::FRAGMENT,
901 ty: wgpu::BindingType::Texture {
902 sample_type: wgpu::TextureSampleType::Float { filterable: true },
903 view_dimension: wgpu::TextureViewDimension::D2,
904 multisampled: false,
905 },
906 count: None,
907 },
908 wgpu::BindGroupLayoutEntry {
909 binding: 1,
910 visibility: wgpu::ShaderStages::FRAGMENT,
911 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
912 count: None,
913 },
914 ],
915 })
916 };
917 let mask_layout = stereo_layout("light2d_mask_bgl");
918 let ramp_layout = stereo_layout("light2d_ramp_bgl");
919 let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
920 label: Some("light2d_uniforms"),
921 size: (uniform_slots as u32 * uniform_stride) as u64,
922 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
923 mapped_at_creation: false,
924 });
925 let uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
926 label: Some("light2d_uniform_bg"),
927 layout: &uniform_layout,
928 entries: &[wgpu::BindGroupEntry {
929 binding: 0,
930 resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
931 buffer: &uniforms,
932 offset: 0,
933 size: std::num::NonZeroU64::new(size_of::<LightUniform>() as u64),
934 }),
935 }],
936 });
937 let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
938 label: Some("light2d_vertices"),
939 contents: bytemuck::cast_slice(&[LightVertex::zeroed(); 6]),
940 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
941 });
942 let sampler = device.create_sampler(&sampler_desc(pass.desc.filter, "light2d_sampler"));
943 let mask_size = pass.desc.mask_size.clamp(64, 2048);
944 let mask_texture = device.create_texture(&wgpu::TextureDescriptor {
945 label: Some("light2d_mask"),
946 size: wgpu::Extent3d {
947 width: mask_size,
948 height: mask_size,
949 depth_or_array_layers: 1,
950 },
951 mip_level_count: 1,
952 sample_count: 1,
953 dimension: wgpu::TextureDimension::D2,
954 format: MASK_FORMAT,
955 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
956 view_formats: &[],
957 });
958 let mask_view = mask_texture.create_view(&wgpu::TextureViewDescriptor::default());
959 let mask_empty_view = solid_view(device, queue, [0, 0, 0, 0], MASK_FORMAT);
962 let ramp_empty_view = solid_view(
965 device,
966 queue,
967 [0, 0, 0, 0],
968 wgpu::TextureFormat::Rgba8UnormSrgb,
969 );
970 let mask_bind = create_texture_bind(
971 device,
972 &mask_layout,
973 &mask_view,
974 &sampler,
975 "light2d_mask_bg",
976 );
977 let mask_bind_empty = create_texture_bind(
978 device,
979 &mask_layout,
980 &mask_empty_view,
981 &sampler,
982 "light2d_mask_empty_bg",
983 );
984 let ramp_bind_empty = create_texture_bind(
985 device,
986 &ramp_layout,
987 &ramp_empty_view,
988 &sampler,
989 "light2d_ramp_empty_bg",
990 );
991 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
992 label: Some("light2d_pl"),
993 bind_group_layouts: &[
994 Some(&uniform_layout),
995 Some(&mask_layout),
996 Some(&ramp_layout),
997 ],
998 immediate_size: 0,
999 });
1000 let mask_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1001 label: Some("light2d_mask_pl"),
1002 bind_group_layouts: &[],
1005 immediate_size: 0,
1006 });
1007 let vertex_buffers = [Some(wgpu::VertexBufferLayout {
1008 array_stride: size_of::<LightVertex>() as u64,
1009 step_mode: wgpu::VertexStepMode::Vertex,
1010 attributes: &[
1011 wgpu::VertexAttribute {
1012 format: wgpu::VertexFormat::Float32x2,
1013 offset: 0,
1014 shader_location: 0,
1015 },
1016 wgpu::VertexAttribute {
1017 format: wgpu::VertexFormat::Float32x2,
1018 offset: 8,
1019 shader_location: 1,
1020 },
1021 wgpu::VertexAttribute {
1022 format: wgpu::VertexFormat::Float32x4,
1023 offset: 16,
1024 shader_location: 2,
1025 },
1026 ],
1027 })];
1028 let mk_pipeline = |label: &'static str,
1029 module: &wgpu::ShaderModule,
1030 layout: &wgpu::PipelineLayout,
1031 format: wgpu::TextureFormat,
1032 blend: wgpu::BlendState,
1033 write_mask: wgpu::ColorWrites|
1034 -> wgpu::RenderPipeline {
1035 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1036 label: Some(label),
1037 layout: Some(layout),
1038 vertex: wgpu::VertexState {
1039 module,
1040 entry_point: Some("vs_main"),
1041 buffers: &vertex_buffers,
1042 compilation_options: Default::default(),
1043 },
1044 fragment: Some(wgpu::FragmentState {
1045 module,
1046 entry_point: Some("fs_main"),
1047 targets: &[Some(wgpu::ColorTargetState {
1048 format,
1049 blend: Some(blend),
1050 write_mask,
1051 })],
1052 compilation_options: Default::default(),
1053 }),
1054 primitive: wgpu::PrimitiveState {
1055 topology: wgpu::PrimitiveTopology::TriangleList,
1056 ..Default::default()
1057 },
1058 depth_stencil: None,
1059 multisample: wgpu::MultisampleState {
1060 count: 1,
1061 mask: !0,
1062 alpha_to_coverage_enabled: false,
1063 },
1064 multiview_mask: None,
1065 cache: None,
1066 })
1067 };
1068 let pipeline_mask = mk_pipeline(
1069 "light2d_mask_pipeline",
1070 &mask_shader,
1071 &mask_pipeline_layout,
1072 MASK_FORMAT,
1073 MASK_BLEND,
1074 wgpu::ColorWrites::ALL,
1075 );
1076 let pipelines = [
1077 mk_pipeline(
1078 "light2d_add",
1079 &shader,
1080 &pipeline_layout,
1081 wgpu::TextureFormat::Rgba8Unorm,
1082 blend_state(LightBlend::Add),
1083 wgpu::ColorWrites::ALL,
1084 ),
1085 mk_pipeline(
1086 "light2d_sub",
1087 &shader,
1088 &pipeline_layout,
1089 wgpu::TextureFormat::Rgba8Unorm,
1090 blend_state(LightBlend::Sub),
1091 wgpu::ColorWrites::ALL,
1092 ),
1093 mk_pipeline(
1094 "light2d_mix",
1095 &shader,
1096 &pipeline_layout,
1097 wgpu::TextureFormat::Rgba8Unorm,
1098 blend_state(LightBlend::Mix),
1099 wgpu::ColorWrites::ALL,
1100 ),
1101 ];
1102 PassEntry {
1103 pipeline_mask,
1104 pipelines,
1105 ramp_layout,
1106 vertices,
1107 vertex_cap: 6,
1108 uniforms,
1109 uniform_bind,
1110 uniform_stride,
1111 uniform_slots,
1112 mask_texture,
1113 mask_view,
1114 mask_bind,
1115 mask_bind_empty,
1116 ramp_bind_empty,
1117 ramp_binds: HashMap::new(),
1118 applied_textures_gen: None,
1119 sampler,
1120 target: None,
1121 generation,
1122 }
1123}
1124
1125fn create_texture_bind(
1126 device: &wgpu::Device,
1127 layout: &wgpu::BindGroupLayout,
1128 view: &wgpu::TextureView,
1129 sampler: &wgpu::Sampler,
1130 label: &'static str,
1131) -> wgpu::BindGroup {
1132 device.create_bind_group(&wgpu::BindGroupDescriptor {
1133 label: Some(label),
1134 layout,
1135 entries: &[
1136 wgpu::BindGroupEntry {
1137 binding: 0,
1138 resource: wgpu::BindingResource::TextureView(view),
1139 },
1140 wgpu::BindGroupEntry {
1141 binding: 1,
1142 resource: wgpu::BindingResource::Sampler(sampler),
1143 },
1144 ],
1145 })
1146}
1147
1148fn solid_view(
1150 device: &wgpu::Device,
1151 queue: &wgpu::Queue,
1152 rgba: [u8; 4],
1153 format: wgpu::TextureFormat,
1154) -> wgpu::TextureView {
1155 let texture = device.create_texture(&wgpu::TextureDescriptor {
1156 label: Some("light2d_solid"),
1157 size: wgpu::Extent3d {
1158 width: 1,
1159 height: 1,
1160 depth_or_array_layers: 1,
1161 },
1162 mip_level_count: 1,
1163 sample_count: 1,
1164 dimension: wgpu::TextureDimension::D2,
1165 format,
1166 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1167 view_formats: &[],
1168 });
1169 queue.write_texture(
1170 wgpu::TexelCopyTextureInfo {
1171 texture: &texture,
1172 mip_level: 0,
1173 origin: wgpu::Origin3d::ZERO,
1174 aspect: wgpu::TextureAspect::All,
1175 },
1176 &rgba,
1177 wgpu::TexelCopyBufferLayout {
1178 offset: 0,
1179 bytes_per_row: Some(4),
1180 rows_per_image: Some(1),
1181 },
1182 wgpu::Extent3d {
1183 width: 1,
1184 height: 1,
1185 depth_or_array_layers: 1,
1186 },
1187 );
1188 texture.create_view(&wgpu::TextureViewDescriptor::default())
1189}
1190
1191fn build_vertices(
1195 plan: &LightingPlan,
1196 world_rect: [f32; 4],
1197) -> (Vec<LightVertex>, Vec<LightRanges>) {
1198 let mut vertices = Vec::with_capacity(plan.lights.len() * 6 + plan.wedge_vertices());
1199 let mut ranges = Vec::with_capacity(plan.lights.len());
1200 for light in &plan.lights {
1201 let quad_start = vertices.len() as u32;
1202 let corners = [
1203 light.center - light.half_extent,
1204 Vec2::new(
1205 light.center.x + light.half_extent.x,
1206 light.center.y - light.half_extent.y,
1207 ),
1208 light.center + light.half_extent,
1209 Vec2::new(
1210 light.center.x - light.half_extent.x,
1211 light.center.y + light.half_extent.y,
1212 ),
1213 ];
1214 for corner in [
1215 corners[0], corners[1], corners[2], corners[0], corners[2], corners[3],
1216 ] {
1217 vertices.push(LightVertex {
1218 clip: lightmap_clip(corner, world_rect),
1219 world: [corner.x, corner.y],
1220 weights: [0.0; MAX_MASK_TAPS],
1221 });
1222 }
1223 let mask = if light.shadowed {
1224 let start = vertices.len() as u32;
1225 for wedge in &light.wedges {
1226 let quad = wedge.quad;
1227 for corner in [
1228 quad[0], quad[1], quad[2], quad[0], quad[2], quad[3],
1229 ] {
1230 vertices.push(LightVertex {
1231 clip: mask_clip(corner, light.center, light.half_extent),
1232 world: [corner.x, corner.y],
1233 weights: wedge.mask_weights(),
1234 });
1235 }
1236 }
1237 Some((start, vertices.len() as u32 - start))
1238 } else {
1239 None
1240 };
1241 ranges.push(LightRanges {
1242 quad: (quad_start, 6),
1243 mask,
1244 });
1245 }
1246 (vertices, ranges)
1247}
1248
1249fn build_uniforms(plan: &LightingPlan, stride: usize) -> (Vec<u8>, usize) {
1251 let stride = stride.max(size_of::<LightUniform>());
1252 let mut out = Vec::with_capacity(plan.lights.len() * stride);
1253 for light in &plan.lights {
1254 let mut stop_at = [[0.0f32; 4]; 2];
1255 let mut stop_rgba = [[0.0f32; 4]; 8];
1256 let last = if light.stop_count == 0 {
1259 0
1260 } else {
1261 light.stop_count as usize - 1
1262 };
1263 for i in 0..8 {
1264 let stop = light.stops[i.min(last)];
1265 stop_at[i / 4][i % 4] = stop.at;
1266 stop_rgba[i] = stop.color;
1267 }
1268 let uniform = LightUniform {
1269 center_half: [
1270 light.center.x,
1271 light.center.y,
1272 1.0 / light.half_extent.x,
1273 1.0 / light.half_extent.y,
1274 ],
1275 color_energy: [
1276 light.color[0],
1277 light.color[1],
1278 light.color[2],
1279 light.energy,
1280 ],
1281 params: [
1282 if light.stop_count == 0 { 1.0 } else { 0.0 },
1283 light.stop_count as f32,
1284 0.0,
1285 0.0,
1286 ],
1287 mask_xform: [
1288 light.center.x - light.half_extent.x,
1289 light.center.y - light.half_extent.y,
1290 1.0 / (2.0 * light.half_extent.x),
1291 1.0 / (2.0 * light.half_extent.y),
1292 ],
1293 tex: [
1294 light.texture_offset.x,
1295 light.texture_offset.y,
1296 1.0 / light.texture_half_extent.x,
1297 1.0 / light.texture_half_extent.y,
1298 ],
1299 stop_at,
1300 stop_rgba,
1301 };
1302 out.extend_from_slice(bytemuck::bytes_of(&uniform));
1303 out.resize((out.len() + stride - 1) / stride * stride, 0);
1304 }
1305 (out, plan.lights.len())
1306}
1307
1308fn record_frame(
1309 encoder: &mut wgpu::CommandEncoder,
1310 entry: &PassEntry,
1311 target: &LightTarget,
1312 plan: &LightingPlan,
1313 ranges: &[LightRanges],
1314 world_rect: &[f32; 4],
1315 light_count: usize,
1316) {
1317 let clear_value = |layer: u32| {
1318 let rgb = plan.ambient_rgb(layer);
1319 wgpu::Color {
1320 r: rgb[0] as f64,
1321 g: rgb[1] as f64,
1322 b: rgb[2] as f64,
1323 a: 1.0,
1324 }
1325 };
1326 for (layer, view) in target.views.iter().enumerate() {
1330 let _clear = begin_pass(
1331 encoder,
1332 "light2d_clear",
1333 view,
1334 clear_value(layer as u32),
1335 None,
1336 );
1337 }
1338 for (slot, light) in plan
1339 .lights
1340 .iter()
1341 .enumerate()
1342 .take(light_count.min(entry.uniform_slots))
1343 {
1344 let Some(range) = ranges.get(slot) else {
1345 continue;
1346 };
1347 if let Some((start, count)) = range.mask {
1348 let mut pass = begin_pass(
1349 encoder,
1350 "light2d_mask",
1351 &entry.mask_view,
1352 wgpu::Color::TRANSPARENT,
1353 None,
1354 );
1355 pass.set_pipeline(&entry.pipeline_mask);
1356 pass.set_vertex_buffer(0, entry.vertices.slice(..));
1357 pass.draw(start..start + count, 0..1);
1358 }
1359 let aabb = [
1360 light.center.x - light.half_extent.x,
1361 light.center.y - light.half_extent.y,
1362 light.center.x + light.half_extent.x,
1363 light.center.y + light.half_extent.y,
1364 ];
1365 let scissor = scissor_px(aabb, *world_rect, target.size);
1366 let mask_bind = if range.mask.is_some() {
1367 &entry.mask_bind
1368 } else {
1369 &entry.mask_bind_empty
1370 };
1371 let ramp_bind = entry
1372 .ramp_binds
1373 .get(&light.texture_slot)
1374 .map(|(_, bind)| bind)
1375 .unwrap_or(&entry.ramp_bind_empty);
1376 for layer in light.layer_indices() {
1377 let Some(view) = target.views.get(layer as usize) else {
1378 continue;
1379 };
1380 let mut pass = begin_pass_load(encoder, "light2d_light", view, scissor);
1381 pass.set_pipeline(&entry.pipelines[blend_index(light.blend)]);
1382 pass.set_bind_group(0, &entry.uniform_bind, &[slot as u32 * entry.uniform_stride]);
1383 pass.set_bind_group(1, mask_bind, &[]);
1384 pass.set_bind_group(2, ramp_bind, &[]);
1385 pass.set_vertex_buffer(0, entry.vertices.slice(..));
1386 pass.draw(range.quad.0..range.quad.0 + range.quad.1, 0..1);
1387 }
1388 }
1389}
1390
1391fn begin_pass<'a>(
1392 encoder: &'a mut wgpu::CommandEncoder,
1393 label: &'static str,
1394 view: &'a wgpu::TextureView,
1395 clear: wgpu::Color,
1396 scissor: Option<[u32; 4]>,
1397) -> wgpu::RenderPass<'a> {
1398 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1399 label: Some(label),
1400 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1401 view,
1402 depth_slice: None,
1403 resolve_target: None,
1404 ops: wgpu::Operations {
1405 load: wgpu::LoadOp::Clear(clear),
1406 store: wgpu::StoreOp::Store,
1407 },
1408 })],
1409 depth_stencil_attachment: None,
1410 timestamp_writes: None,
1411 occlusion_query_set: None,
1412 multiview_mask: None,
1413 });
1414 if let Some([x, y, w, h]) = scissor {
1415 pass.set_scissor_rect(x, y, w, h);
1416 }
1417 pass
1418}
1419
1420fn begin_pass_load<'a>(
1421 encoder: &'a mut wgpu::CommandEncoder,
1422 label: &'static str,
1423 view: &'a wgpu::TextureView,
1424 scissor: Option<[u32; 4]>,
1425) -> wgpu::RenderPass<'a> {
1426 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1427 label: Some(label),
1428 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1429 view,
1430 depth_slice: None,
1431 resolve_target: None,
1432 ops: wgpu::Operations {
1433 load: wgpu::LoadOp::Load,
1434 store: wgpu::StoreOp::Store,
1435 },
1436 })],
1437 depth_stencil_attachment: None,
1438 timestamp_writes: None,
1439 occlusion_query_set: None,
1440 multiview_mask: None,
1441 });
1442 if let Some([x, y, w, h]) = scissor {
1443 pass.set_scissor_rect(x, y, w, h);
1444 }
1445 pass
1446}
1447
1448impl WgpuCallback for Light2dPass {
1449 fn resource_key(&self) -> Option<&str> {
1450 Some(SHARED_CALLBACK_KEY)
1451 }
1452
1453 fn resource_type_id(&self) -> std::any::TypeId {
1454 std::any::TypeId::of::<LightScope>()
1455 }
1456
1457 fn prepare(
1458 &self,
1459 device: &wgpu::Device,
1460 queue: &wgpu::Queue,
1461 encoder: &mut wgpu::CommandEncoder,
1462 screen: &ScreenDescriptor,
1463 resources: &mut CallbackResources,
1464 ) -> Vec<wgpu::CommandBuffer> {
1465 self.prepare_frame(device, queue, encoder, screen, resources);
1466 Vec::new()
1467 }
1468
1469 fn paint(
1470 &self,
1471 _info: repose_core::PaintCallbackInfo,
1472 _rpass: &mut repose_render_wgpu::CallbackRenderPass<'_, '_>,
1473 _resources: &CallbackResources,
1474 ) {
1475 }
1478}
1479
1480#[cfg(test)]
1481mod tests {
1482 use super::super::light::{
1483 AmbientLight, Light2d, LightBudget, LightFalloff, LightStop, stops_at,
1484 };
1485 use super::*;
1486
1487 fn light_plan(lights: &[Light2d], occluders: &[Occluder2d]) -> LightingPlan {
1488 plan_lighting(
1489 lights,
1490 occluders,
1491 &AmbientLight::default(),
1492 [-100.0, -50.0, 200.0, 100.0],
1493 &LightBudget::default(),
1494 )
1495 }
1496
1497 #[test]
1498 fn lightmap_clip_puts_the_world_rect_on_the_unit_square() {
1499 let rect = [100.0, 200.0, 40.0, 20.0];
1500 assert_eq!(lightmap_clip(Vec2::new(100.0, 200.0), rect), [-1.0, 1.0]);
1502 let br = lightmap_clip(Vec2::new(140.0, 220.0), rect);
1503 assert!(
1504 (br[0] - 1.0).abs() < 1e-5 && (br[1] + 1.0).abs() < 1e-5,
1505 "bottom-right lands on (1, -1): {br:?}"
1506 );
1507 let centre = lightmap_clip(Vec2::new(120.0, 210.0), rect);
1508 assert!(centre[0].abs() < 1e-6 && centre[1].abs() < 1e-6);
1509 }
1510
1511 #[test]
1512 fn mask_clip_stretches_the_footprint_over_the_mask() {
1513 let centre = Vec2::new(10.0, -4.0);
1514 let half = Vec2::new(8.0, 2.0);
1515 assert_eq!(mask_clip(centre - half, centre, half), [-1.0, 1.0]);
1516 assert_eq!(mask_clip(centre + half, centre, half), [1.0, -1.0]);
1517 let corner = Vec2::new(2.0, -6.0);
1519 let uv = (corner - (centre - half)) / (half * 2.0);
1520 let clip = mask_clip(corner, centre, half);
1521 assert!((clip[0] - (uv.x * 2.0 - 1.0)).abs() < 1e-6);
1522 assert!((clip[1] - (1.0 - uv.y * 2.0)).abs() < 1e-6);
1523 }
1524
1525 #[test]
1526 fn scissor_covers_the_footprint_and_clamps_to_the_map() {
1527 let rect = [0.0, 0.0, 100.0, 50.0];
1528 let size = [200, 100];
1529 assert_eq!(
1531 scissor_px([10.0, 10.0, 20.0, 20.0], rect, size),
1532 Some([20, 20, 20, 20])
1533 );
1534 assert_eq!(scissor_px([-50.0, -50.0, -10.0, -10.0], rect, size), None);
1536 assert_eq!(scissor_px([200.0, 0.0, 300.0, 10.0], rect, size), None);
1537 assert_eq!(
1539 scissor_px([-10.0, -10.0, 10.0, 10.0], rect, size),
1540 Some([0, 0, 20, 20])
1541 );
1542 assert_eq!(
1543 scissor_px([90.0, 40.0, 200.0, 100.0], rect, size),
1544 Some([180, 80, 20, 20])
1545 );
1546 }
1547
1548 #[test]
1549 fn vertex_packing_orders_quads_then_wedges_per_light() {
1550 let lights = [
1551 Light2d {
1552 position: Vec2::new(0.0, 0.0),
1553 radius: 10.0,
1554 shadows: true,
1555 ..Default::default()
1556 },
1557 Light2d {
1558 position: Vec2::new(30.0, 0.0),
1559 radius: 5.0,
1560 ..Default::default()
1561 },
1562 ];
1563 let occluders = [Occluder2d::rect(Vec2::new(2.0, -2.0), Vec2::new(4.0, 2.0))];
1564 let plan = light_plan(&lights, &occluders);
1565 assert_eq!(plan.lights.len(), 2);
1566 assert!(plan.lights[0].shadowed && !plan.lights[1].shadowed);
1567 let world_rect = plan.world_rect;
1568 let (vertices, ranges) = build_vertices(&plan, world_rect);
1569 assert_eq!(vertices.len(), 6 + 4 * 6 + 6, "two quads and four wedges");
1570 assert_eq!(ranges.len(), 2);
1571 assert_eq!(ranges[0].quad, (0, 6));
1572 assert_eq!(ranges[0].mask, Some((6, 24)));
1573 assert_eq!(ranges[1].quad, (30, 6));
1574 assert_eq!(ranges[1].mask, None);
1575 for vertex in &vertices {
1578 assert!(vertex.clip[0].is_finite() && vertex.clip[1].is_finite());
1579 }
1580 let top_left = vertices[0];
1581 assert_eq!(top_left.world, [-10.0, -10.0]);
1582 assert_eq!(
1583 top_left.clip,
1584 lightmap_clip(Vec2::new(-10.0, -10.0), world_rect)
1585 );
1586 assert_eq!(vertices[5].world, [-10.0, 10.0]);
1587 let wedge = vertices[6];
1590 assert_eq!(wedge.weights, [1.0, 0.0, 0.0, 0.0], "one hard tap");
1591 assert_eq!(
1592 wedge.clip,
1593 mask_clip(
1594 Vec2::from_array(wedge.world),
1595 Vec2::ZERO,
1596 Vec2::splat(10.0)
1597 )
1598 );
1599 }
1600
1601 #[test]
1602 fn uniform_packing_matches_the_shader_layout() {
1603 let light = Light2d {
1604 position: Vec2::new(4.0, -2.0),
1605 radius: 8.0,
1606 color: [1.0, 0.5, 0.25],
1607 energy: 0.75,
1608 shadows: true,
1609 falloff: LightFalloff::stops(vec![
1610 LightStop {
1611 at: 0.0,
1612 color: [1.0, 1.0, 1.0, 1.0],
1613 },
1614 LightStop {
1615 at: 1.0,
1616 color: [0.0, 0.0, 0.0, 0.0],
1617 },
1618 ]),
1619 ..Default::default()
1620 };
1621 let plan = light_plan(&[light], &[]);
1622 let stride = 512;
1623 let (bytes, count) = build_uniforms(&plan, stride);
1624 assert_eq!(count, 1);
1625 assert_eq!(bytes.len(), stride);
1626 let packed: &LightUniform = bytemuck::from_bytes(&bytes[..size_of::<LightUniform>()]);
1627 assert_eq!(packed.center_half, [4.0, -2.0, 0.125, 0.125]);
1628 assert_eq!(packed.color_energy, [1.0, 0.5, 0.25, 0.75]);
1629 assert_eq!(packed.params, [0.0, 2.0, 0.0, 0.0]);
1631 assert_eq!(packed.mask_xform, [-4.0, -10.0, 1.0 / 16.0, 1.0 / 16.0]);
1632 assert_eq!(packed.stop_at[0], [0.0, 1.0, 1.0, 1.0]);
1633 assert_eq!(packed.stop_rgba[1], [0.0, 0.0, 0.0, 0.0]);
1634 assert_eq!(packed.stop_rgba[7], [0.0, 0.0, 0.0, 0.0]);
1635 let (bytes, _) = build_uniforms(&plan, stride);
1637 assert!(bytes[size_of::<LightUniform>()..].iter().all(|b| *b == 0));
1638 }
1639
1640 #[test]
1641 fn packed_uniform_reproduces_the_shader_ramp_and_uvs() {
1642 let stops = vec![
1645 LightStop {
1646 at: 0.0,
1647 color: [1.0, 0.8, 0.6, 1.0],
1648 },
1649 LightStop {
1650 at: 0.4,
1651 color: [0.5, 0.4, 0.3, 0.5],
1652 },
1653 LightStop {
1654 at: 1.0,
1655 color: [0.0, 0.0, 0.0, 0.0],
1656 },
1657 ];
1658 let ramp_light = Light2d {
1659 position: Vec2::new(10.0, 20.0),
1660 radius: 40.0,
1661 falloff: LightFalloff::stops(stops.clone()),
1662 ..Default::default()
1663 };
1664 let plan = light_plan(&[ramp_light], &[]);
1665 let (bytes, _) = build_uniforms(&plan, 256);
1666 let packed: &LightUniform = bytemuck::from_bytes(&bytes[..size_of::<LightUniform>()]);
1667 assert_eq!(packed.params[0], 0.0, "stops mode");
1668 assert_eq!(packed.params[1], 3.0, "three live stops");
1669 let shader_ramp = |t: f32| {
1671 let mut ramp = packed.stop_rgba[0];
1672 let mut previous = 0.0f32;
1673 for i in 1..8 {
1674 if i as f32 >= packed.params[1] {
1675 break;
1676 }
1677 let at = packed.stop_at[i / 4][i % 4];
1678 let span = (at - previous).max(1e-6);
1679 let w = ((t - previous) / span).clamp(0.0, 1.0);
1680 for c in 0..4 {
1681 ramp[c] += (packed.stop_rgba[i][c] - ramp[c]) * w;
1682 }
1683 previous = at;
1684 }
1685 ramp
1686 };
1687 for t in [0.0, 0.1, 0.25, 0.4, 0.5, 0.75, 1.0, 1.5] {
1688 let cpu = stops_at(&stops, t);
1689 let gpu = shader_ramp(t);
1690 assert!(
1691 cpu.iter().zip(gpu.iter()).all(|(a, b)| (a - b).abs() < 1e-5),
1692 "t={t}: cpu {cpu:?} vs shader {gpu:?}"
1693 );
1694 }
1695
1696 let texture_light = Light2d {
1697 position: Vec2::new(10.0, 20.0),
1698 radius: 40.0,
1699 falloff: LightFalloff::Texture {
1700 slot: 3,
1701 half_extent: Vec2::new(16.0, 8.0),
1702 offset: Vec2::new(2.0, -1.0),
1703 },
1704 ..Default::default()
1705 };
1706 let plan = light_plan(&[texture_light.clone()], &[]);
1707 let (bytes, _) = build_uniforms(&plan, 256);
1708 let packed: &LightUniform = bytemuck::from_bytes(&bytes[..size_of::<LightUniform>()]);
1709 assert_eq!(packed.params[0], 1.0, "texture mode");
1710 let shader_uv = |world: Vec2| {
1712 let d = world - Vec2::new(packed.center_half[0], packed.center_half[1])
1713 - Vec2::new(packed.tex[0], packed.tex[1]);
1714 Vec2::new(d.x * packed.tex[2], d.y * packed.tex[3]) * 0.5 + Vec2::splat(0.5)
1715 };
1716 for world in [
1717 Vec2::new(12.0, 19.0),
1718 Vec2::new(11.0, 20.0),
1719 Vec2::new(26.0, 28.0),
1720 Vec2::new(-4.0, 12.0),
1721 ] {
1722 let cpu = super::super::light::texture_uv(
1723 texture_light.position,
1724 Vec2::new(2.0, -1.0),
1725 Vec2::new(16.0, 8.0),
1726 world,
1727 );
1728 let gpu = shader_uv(world);
1729 assert!(
1730 (cpu - gpu).length() < 1e-5,
1731 "uv mismatch at {world:?}: cpu {cpu:?} vs shader {gpu:?}"
1732 );
1733 }
1734 }
1735
1736 #[test]
1737 fn blend_states_are_godots_three_modes() {
1738 let add = blend_state(LightBlend::Add);
1740 assert_eq!(add.color, ADD);
1741 assert_eq!(add.alpha, KEEP_MULTIPLIER);
1742 let sub = blend_state(LightBlend::Sub);
1744 assert_eq!(sub.color.operation, wgpu::BlendOperation::ReverseSubtract);
1745 assert_eq!(sub.color.src_factor, wgpu::BlendFactor::SrcAlpha);
1746 let mix = blend_state(LightBlend::Mix);
1748 assert_eq!(mix.color.dst_factor, wgpu::BlendFactor::OneMinusSrcAlpha);
1749 assert_eq!(mix.color.src_factor, wgpu::BlendFactor::One);
1750 assert_eq!(mix.alpha.dst_factor, wgpu::BlendFactor::OneMinusSrcAlpha);
1751 assert_eq!(mix.alpha.src_factor, wgpu::BlendFactor::Zero);
1752 assert_eq!(
1753 [0, 1, 2],
1754 [
1755 blend_index(LightBlend::Add),
1756 blend_index(LightBlend::Sub),
1757 blend_index(LightBlend::Mix)
1758 ]
1759 );
1760 assert_eq!(MASK_BLEND.color.operation, wgpu::BlendOperation::Max);
1763 assert_eq!(MASK_BLEND.alpha.operation, wgpu::BlendOperation::Max);
1764 assert_eq!(MASK_FORMAT, wgpu::TextureFormat::Rgba8Unorm);
1765 assert_eq!(MAX_MASK_TAPS, 4, "one channel per Pcf4 tap");
1766 }
1767}