1use std::cell::Cell;
11use std::rc::Rc;
12use std::sync::{Arc, Mutex};
13
14use glam::{Mat4, Vec2, Vec3};
15use repose_canvas::{Canvas, DrawScope, Embedded};
16use repose_core::locals::effective_density_scale;
17use repose_core::{Color, Modifier, Rect, View};
18use repose_render_wgpu::{Callback, CallbackResources, ScreenDescriptor, WgpuCallback};
19use repose_ui::Box as UiBox;
20use repose_ui::ViewExt;
21
22pub mod batch;
23use batch::draw_batch;
24pub use batch::{AtlasUpload, BatchDesc, SpriteBatch, TextureFilter, frame_uv, instance_rows, screen_camera, sprite_aabb};
25
26pub mod fullscreen;
27pub use fullscreen::{FullscreenDesc, FullscreenPass, FullscreenTexture};
28
29pub mod post;
30
31#[derive(Clone, Copy, Debug)]
40pub struct Camera2d {
41 pub center: Vec2,
48 pub offset: Vec2,
56 pub units_per_pixel: f32,
64 pub zoom: f32,
70}
71
72impl Default for Camera2d {
73 fn default() -> Self {
74 Self {
75 center: Vec2::ZERO,
76 offset: Vec2::ZERO,
77 units_per_pixel: 1.0,
78 zoom: 1.0,
79 }
80 }
81}
82
83#[derive(Clone, Copy, Debug)]
90pub struct CameraLimits {
91 pub left: f32,
93 pub top: f32,
95 pub right: f32,
97 pub bottom: f32,
99 pub enabled: bool,
101}
102
103impl Default for CameraLimits {
104 fn default() -> Self {
105 Self {
106 left: 0.0,
107 top: 0.0,
108 right: 0.0,
109 bottom: 0.0,
110 enabled: false,
111 }
112 }
113}
114
115pub fn apply_limits(center: [f32; 2], limits: CameraLimits) -> [f32; 2] {
129 if !limits.enabled {
130 return center;
131 }
132 [
133 center[0].clamp(limits.left.min(limits.right), limits.left.max(limits.right)),
134 center[1].clamp(limits.top.min(limits.bottom), limits.top.max(limits.bottom)),
135 ]
136}
137
138pub fn smooth_toward(current: [f32; 2], target: [f32; 2], speed: f32, dt: f32) -> [f32; 2] {
155 if dt <= 0.0 {
156 return current;
157 }
158 if speed <= 0.0 || !speed.is_finite() {
159 return target;
160 }
161 let t = 1.0 - (-speed * dt).exp();
162 [
163 current[0] + (target[0] - current[0]) * t,
164 current[1] + (target[1] - current[1]) * t,
165 ]
166}
167
168impl Camera2d {
169 pub fn effective_center(&self) -> [f32; 2] {
175 [self.center.x + self.offset.x, self.center.y + self.offset.y]
176 }
177
178 pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
183 let vp_w = viewport_px[0].max(1.0);
184 let vp_h = viewport_px[1].max(1.0);
185 let zoom = sane_positive(self.zoom);
186 let upp = sane_positive(self.units_per_pixel);
187 let w = vp_w * upp / zoom;
188 let h = vp_h * upp / zoom;
189 let proj = glam::camera::rh::proj::directx::orthographic(
190 -w / 2.0,
191 w / 2.0,
192 h / 2.0,
193 -h / 2.0,
194 -1000.0,
195 1000.0,
196 );
197 let view = Mat4::from_translation(Vec3::new(
198 -(self.center.x + self.offset.x),
199 -(self.center.y + self.offset.y),
200 0.0,
201 ));
202 proj * view
203 }
204
205 pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
208 if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
209 let [cx, cy] = self.effective_center();
210 return Vec2::new(cx, cy);
211 }
212 let ndc = Vec2::new(
213 (px[0] / viewport_px[0]) * 2.0 - 1.0,
214 1.0 - (px[1] / viewport_px[1]) * 2.0,
215 );
216 let inv = self.view_proj(viewport_px).inverse();
217 let world = inv.project_point3(ndc.extend(0.0));
218 Vec2::new(world.x, world.y)
219 }
220}
221
222#[derive(Clone, Copy, Debug)]
224pub struct SpriteInstance {
225 pub center: Vec2,
227 pub rotation: f32,
228 pub size: Vec2,
229 pub anchor: Vec2,
233 pub flip_x: bool,
237 pub flip_y: bool,
238 pub uv_min: Vec2,
239 pub uv_max: Vec2,
240 pub color: [f32; 4],
243 pub page: u32,
245}
246
247impl Default for SpriteInstance {
248 fn default() -> Self {
249 Self {
250 center: Vec2::ZERO,
251 rotation: 0.0,
252 size: Vec2::ONE,
253 anchor: Vec2::new(0.5, 0.5),
254 flip_x: false,
255 flip_y: false,
256 uv_min: Vec2::ZERO,
257 uv_max: Vec2::ONE,
258 color: [1.0, 1.0, 1.0, 1.0],
259 page: 0,
260 }
261 }
262}
263
264#[derive(Clone, Debug, Default)]
266pub struct WorldText {
267 pub text: String,
268 pub pos: Vec2,
270 pub color: [f32; 4],
272 pub size: f32,
274}
275
276#[derive(Clone, Default, Debug)]
284pub struct FrameInput {
285 pub cam: Camera2d,
286 pub world_size: [f32; 2],
288 pub viewport_px: [f32; 2],
293 pub sprites: Vec<SpriteInstance>,
294 pub texts: Vec<WorldText>,
296 pub background: Option<[f32; 4]>,
298 pub overlay_color: Option<[f32; 4]>,
301 pub chroma: f32,
307}
308
309#[derive(Clone, Debug)]
311pub enum PickEvent {
312 Click { world: Vec2, screen: [f32; 2] },
313 Hover { world: Vec2 },
314 TouchDown { id: u64, screen: [f32; 2] },
318 TouchMove { id: u64, screen: [f32; 2] },
320 TouchUp { id: u64 },
322}
323
324fn is_touch(ev: &repose_core::input::PointerEvent) -> bool {
327 matches!(
328 ev.kind,
329 repose_core::input::PointerKind::Touch | repose_core::input::PointerKind::Pen
330 )
331}
332
333fn screen_of(ev: &repose_core::input::PointerEvent) -> [f32; 2] {
335 let p = ev.position_in_window();
336 [p.x, p.y]
337}
338
339fn sane_positive(v: f32) -> f32 {
343 if v.is_finite() && v > 1e-6 { v } else { 1.0 }
344}
345
346pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
351 if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
352 return (1.0, 0.0, 0.0);
353 }
354 let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
355 if !s.is_finite() || s <= 1e-6 {
356 return (1.0, 0.0, 0.0);
357 }
358 (
359 s,
360 (canvas_dp[0] - world[0] * s) * 0.5,
361 (canvas_dp[1] - world[1] * s) * 0.5,
362 )
363}
364
365pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
370 let (base, _, _) = contain_fit(canvas_dp, world);
371 let zoom = sane_positive(cam.zoom);
372 let upp = sane_positive(cam.units_per_pixel);
373 let s = base * zoom / upp;
374 if !s.is_finite() || s <= 1e-6 {
375 return (1.0, 0.0, 0.0);
376 }
377 (
378 s,
379 (canvas_dp[0] - world[0] * s) * 0.5,
380 (canvas_dp[1] - world[1] * s) * 0.5,
381 )
382}
383
384pub fn fit_view_proj(
389 canvas_dp: [f32; 2],
390 world_size: [f32; 2],
391 cam_center: [f32; 2],
392 fit: (f32, f32, f32),
393) -> Mat4 {
394 let w = canvas_dp[0].max(1.0);
395 let h = canvas_dp[1].max(1.0);
396 let (s, ox, oy) = fit;
397 let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
398 let lx = cam_center[0] - world_size[0] * 0.5;
399 let ly = cam_center[1] - world_size[1] * 0.5;
400 let sx = 2.0 * s / w;
401 let sy = -2.0 * s / h;
402 let tx = 2.0 * (ox - lx * s) / w - 1.0;
403 let ty = 1.0 - 2.0 * (oy - ly * s) / h;
404 Mat4::from_cols(
405 glam::Vec4::new(sx, 0.0, 0.0, 0.0),
406 glam::Vec4::new(0.0, sy, 0.0, 0.0),
407 glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
408 glam::Vec4::new(tx, ty, 0.0, 1.0),
409 )
410}
411
412pub fn world_to_dp(
416 world: [f32; 2],
417 world_size: [f32; 2],
418 cam_center: [f32; 2],
419 fit: (f32, f32, f32),
420) -> [f32; 2] {
421 let (s, ox, oy) = fit;
422 [
423 ox + (world[0] - (cam_center[0] - world_size[0] * 0.5)) * s,
424 oy + (world[1] - (cam_center[1] - world_size[1] * 0.5)) * s,
425 ]
426}
427
428pub fn dp_to_world(
431 dp: [f32; 2],
432 world_size: [f32; 2],
433 cam_center: [f32; 2],
434 fit: (f32, f32, f32),
435) -> [f32; 2] {
436 let (s, ox, oy) = fit;
437 if !s.is_finite() || s.abs() < 1e-6 {
438 return cam_center;
439 }
440 [
441 (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
442 (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
443 ]
444}
445
446pub fn pick_world(local_px: [f32; 2], geom: FrameGeom, world_size: [f32; 2]) -> [f32; 2] {
451 let d = if geom.density.is_finite() && geom.density > 1e-6 {
452 geom.density
453 } else {
454 1.0
455 };
456 dp_to_world(
457 [local_px[0] / d, local_px[1] / d],
458 world_size,
459 [
460 geom.look[0] + world_size[0] * 0.5,
461 geom.look[1] + world_size[1] * 0.5,
462 ],
463 geom.fit,
464 )
465}
466
467#[derive(Clone, Copy, Debug)]
473pub struct FrameGeom {
474 pub fit: (f32, f32, f32),
477 pub look: [f32; 2],
480 pub density: f32,
482 pub viewport_px: [f32; 2],
484}
485
486impl Default for FrameGeom {
487 fn default() -> Self {
488 Self {
489 fit: (1.0, 0.0, 0.0),
490 look: [0.0, 0.0],
491 density: 1.0,
492 viewport_px: [1.0, 1.0],
493 }
494 }
495}
496
497pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
500 let (s, ox, oy) = geom.fit;
501 (
502 [
503 ox + (center[0] - size[0] * 0.5 - geom.look[0]) * s,
504 oy + (center[1] - size[1] * 0.5 - geom.look[1]) * s,
505 ],
506 [size[0] * s, size[1] * s],
507 )
508}
509
510fn rgba8(c: [f32; 4]) -> Color {
511 Color::from_rgba(
512 (c[0].clamp(0.0, 1.0) * 255.0) as u8,
513 (c[1].clamp(0.0, 1.0) * 255.0) as u8,
514 (c[2].clamp(0.0, 1.0) * 255.0) as u8,
515 (c[3].clamp(0.0, 1.0) * 255.0) as u8,
516 )
517}
518
519#[allow(non_snake_case)] pub fn Viewport2d(
538 input: FrameInput,
539 geom_out: Rc<Cell<FrameGeom>>,
540 on_event: impl Fn(PickEvent) + 'static,
541) -> View {
542 let input = Rc::new(input);
543 let world_size = input.world_size;
544 let pick_geom = geom_out.clone();
545 let move_geom = geom_out.clone();
546 let draw_input = input.clone();
547 let draw_geom = geom_out.clone();
548 let on_event = Rc::new(on_event);
549 let on_down = on_event.clone();
550 let on_touch_down = on_event.clone();
551 let on_move = on_event.clone();
552 let on_touch_move = on_event.clone();
553 let on_up = on_event.clone();
554 let on_leave = on_event;
555
556 let modifier = Modifier::new()
557 .fill_max_size()
558 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
559 let p = ev.position;
560 let g = pick_geom.get();
561 let world = pick_world([p.x, p.y], g, world_size);
562 on_down(PickEvent::Click {
563 world: Vec2::new(world[0], world[1]),
564 screen: [p.x, p.y],
565 });
566 if is_touch(&ev) {
567 on_touch_down(PickEvent::TouchDown {
568 id: ev.id.0,
569 screen: screen_of(&ev),
570 });
571 }
572 })
573 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
574 let p = ev.position;
575 let g = move_geom.get();
576 let world = pick_world([p.x, p.y], g, world_size);
577 on_move(PickEvent::Hover {
578 world: Vec2::new(world[0], world[1]),
579 });
580 if is_touch(&ev) {
581 on_touch_move(PickEvent::TouchMove {
582 id: ev.id.0,
583 screen: screen_of(&ev),
584 });
585 }
586 })
587 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
588 if is_touch(&ev) {
589 on_up(PickEvent::TouchUp { id: ev.id.0 });
590 }
591 })
592 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
593 if is_touch(&ev) {
594 on_leave(PickEvent::TouchUp { id: ev.id.0 });
595 }
596 });
597 Canvas(modifier, move |scope: &mut DrawScope| {
598 let d = effective_density_scale();
599 let cam = draw_input.cam;
600 let cam_center = cam.effective_center();
601 let fit = effective_fit(
602 [scope.size.width / d, scope.size.height / d],
603 draw_input.world_size,
604 &cam,
605 );
606 draw_geom.set(FrameGeom {
607 fit,
608 look: [
609 cam_center[0] - draw_input.world_size[0] * 0.5,
610 cam_center[1] - draw_input.world_size[1] * 0.5,
611 ],
612 density: d,
613 viewport_px: [scope.size.width, scope.size.height],
614 });
615 let project = |wx: f32, wy: f32| -> [f32; 2] {
616 let [dx, dy] = world_to_dp(
617 [wx, wy],
618 draw_input.world_size,
619 cam_center,
620 (fit.0, fit.1, fit.2),
621 );
622 [dx * d, dy * d]
623 };
624 if let Some(bg) = draw_input.background {
625 scope.draw_rect(
626 Rect {
627 x: 0.0,
628 y: 0.0,
629 w: scope.size.width,
630 h: scope.size.height,
631 },
632 rgba8(bg),
633 0.0,
634 );
635 }
636 for spr in draw_input.sprites.iter() {
637 if spr.rotation.abs() > 1e-4 {
638 debug_assert!(
639 false,
640 "canvas viewport draws rotated sprites as AABB; use GPU viewport for exact rotation"
641 );
642 }
643 let (row0, row1) = batch::instance_rows(
644 [spr.center.x, spr.center.y],
645 [spr.size.x, spr.size.y],
646 spr.rotation,
647 [spr.anchor.x, spr.anchor.y],
648 spr.flip_x,
649 spr.flip_y,
650 );
651 let mut min_x = f32::INFINITY;
652 let mut min_y = f32::INFINITY;
653 let mut max_x = f32::NEG_INFINITY;
654 let mut max_y = f32::NEG_INFINITY;
655 for corner in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]] {
656 let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
657 let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
658 let [px, py] = project(wx, wy);
659 min_x = min_x.min(px);
660 min_y = min_y.min(py);
661 max_x = max_x.max(px);
662 max_y = max_y.max(py);
663 }
664 scope.draw_rect(
665 Rect {
666 x: min_x,
667 y: min_y,
668 w: (max_x - min_x).max(0.0),
669 h: (max_y - min_y).max(0.0),
670 },
671 rgba8(spr.color),
672 0.0,
673 );
674 }
675 for t in draw_input.texts.iter() {
676 let [tx, ty] = project(t.pos.x, t.pos.y);
677 scope.draw_text(
678 t.text.clone(),
679 repose_core::Vec2 { x: tx, y: ty },
680 rgba8(t.color),
681 t.size * fit.0 * d,
682 );
683 }
684 if let Some(tint) = draw_input.overlay_color {
685 scope.draw_rect(
686 Rect {
687 x: 0.0,
688 y: 0.0,
689 w: scope.size.width,
690 h: scope.size.height,
691 },
692 rgba8(tint),
693 0.0,
694 );
695 }
696 })
697}
698
699#[allow(non_snake_case)]
722pub fn Viewport2dGpu(
723 input: FrameInput,
724 geom_out: Arc<Mutex<FrameGeom>>,
725 uploads: Vec<AtlasUpload>,
726 desc: BatchDesc,
727 on_event: impl Fn(PickEvent) + 'static,
728) -> View {
729 let input = Arc::new(input);
730 let world_size = input.world_size;
731 let pick_geom = geom_out.clone();
732 let move_geom = geom_out.clone();
733 let on_event = Arc::new(on_event);
734 let on_down = on_event.clone();
735 let on_touch_down = on_event.clone();
736 let on_move = on_event.clone();
737 let on_touch_move = on_event.clone();
738 let on_up = on_event.clone();
739 let on_leave = on_event;
740 let payload = GpuViewport {
741 input: input.clone(),
742 geom: geom_out,
743 uploads,
744 desc,
745 bg: FullscreenPass::new(
746 "viewport2d.background",
747 fullscreen::SOLID_WGSL,
748 FullscreenDesc {
749 texture_slots: 0,
750 filter: TextureFilter::Nearest,
751 },
752 ),
753 };
754 let modifier = Modifier::new()
755 .fill_max_size()
756 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
757 let p = ev.position;
758 let Ok(g) = pick_geom.lock() else {
759 return;
760 };
761 let world = pick_world([p.x, p.y], *g, world_size);
762 on_down(PickEvent::Click {
763 world: Vec2::new(world[0], world[1]),
764 screen: [p.x, p.y],
765 });
766 if is_touch(&ev) {
767 on_touch_down(PickEvent::TouchDown {
768 id: ev.id.0,
769 screen: screen_of(&ev),
770 });
771 }
772 })
773 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
774 let p = ev.position;
775 let Ok(g) = move_geom.lock() else {
776 return;
777 };
778 let world = pick_world([p.x, p.y], *g, world_size);
779 on_move(PickEvent::Hover {
780 world: Vec2::new(world[0], world[1]),
781 });
782 if is_touch(&ev) {
783 on_touch_move(PickEvent::TouchMove {
784 id: ev.id.0,
785 screen: screen_of(&ev),
786 });
787 }
788 })
789 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
790 if is_touch(&ev) {
791 on_up(PickEvent::TouchUp { id: ev.id.0 });
792 }
793 })
794 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
795 if is_touch(&ev) {
796 on_leave(PickEvent::TouchUp { id: ev.id.0 });
797 }
798 });
799 Embedded(modifier, Callback::new(payload))
800}
801
802struct GpuViewport {
803 input: Arc<FrameInput>,
804 geom: Arc<Mutex<FrameGeom>>,
805 uploads: Vec<AtlasUpload>,
806 desc: BatchDesc,
807 bg: FullscreenPass,
808}
809
810impl WgpuCallback for GpuViewport {
811 fn prepare(
812 &self,
813 device: &wgpu::Device,
814 queue: &wgpu::Queue,
815 encoder: &mut wgpu::CommandEncoder,
816 screen: &ScreenDescriptor,
817 resources: &mut CallbackResources,
818 ) -> Vec<wgpu::CommandBuffer> {
819 let (vp_phys, density) = self
820 .geom
821 .lock()
822 .map(|g| {
823 let d = if g.density.is_finite() && g.density > 1e-6 {
824 g.density
825 } else {
826 1.0
827 };
828 (g.viewport_px, d)
829 })
830 .unwrap_or(([1.0, 1.0], 1.0));
831 let dp = if vp_phys[0] > 1.0 && vp_phys[1] > 1.0 {
832 [vp_phys[0] / density, vp_phys[1] / density]
833 } else {
834 self.input.viewport_px
835 };
836 let fit = effective_fit(dp, self.input.world_size, &self.input.cam);
837 let cam_center = self.input.cam.effective_center();
838 let mut batch = SpriteBatch::new(self.desc);
839 batch.set_camera(fit_view_proj(dp, self.input.world_size, cam_center, fit));
840 for s in &self.input.sprites {
841 batch.push_sprite(s);
842 }
843 batch.extend_uploads(self.uploads.clone());
844 batch.prepare(device, queue, encoder, screen, resources);
845 if let Some(bg) = self.input.background {
849 let words = [bg[0], bg[1], bg[2], bg[3]];
850 self.bg.prepare_with(
851 device,
852 queue,
853 screen,
854 resources,
855 bytemuck::cast_slice(&words),
856 &[],
857 );
858 }
859 if post::use_composite(self.input.chroma) {
860 let w = vp_phys[0].max(1.0) as u32;
861 let h = vp_phys[1].max(1.0) as u32;
862 post::prepare_composite(
863 device,
864 queue,
865 encoder,
866 screen,
867 resources,
868 w,
869 h,
870 self.input.chroma,
871 self.input.background,
872 );
873 }
874 Vec::new()
875 }
876
877 fn paint(
878 &self,
879 info: repose_core::PaintCallbackInfo,
880 rpass: &mut wgpu::RenderPass<'static>,
881 resources: &CallbackResources,
882 ) {
883 let d = if info.pixels_per_point.is_finite() && info.pixels_per_point > 1e-6 {
884 info.pixels_per_point
885 } else {
886 1.0
887 };
888 let vp = [info.viewport.w, info.viewport.h];
889 let fit = effective_fit(
890 [vp[0] / d, vp[1] / d],
891 self.input.world_size,
892 &self.input.cam,
893 );
894 let cam = self.input.cam.effective_center();
895 if let Ok(mut g) = self.geom.lock() {
896 *g = FrameGeom {
897 fit,
898 look: [
899 cam[0] - self.input.world_size[0] * 0.5,
900 cam[1] - self.input.world_size[1] * 0.5,
901 ],
902 density: d,
903 viewport_px: vp,
904 };
905 }
906 if post::use_composite(self.input.chroma) {
907 post::paint_composite(rpass, resources);
908 } else {
909 if self.input.background.is_some() {
912 self.bg.paint(info, rpass, resources);
913 }
914 draw_batch(rpass, resources);
915 }
916 }
917}
918
919#[allow(non_snake_case)]
926pub fn ActorFrame(
927 center: [f32; 2],
928 size: [f32; 2],
929 geom: Rc<Cell<FrameGeom>>,
930 mirror_x: bool,
931 child: View,
932) -> View {
933 let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
934 let mut modifier = Modifier::new()
937 .size(w, h)
938 .absolute()
939 .offset(Some(ox), Some(oy), None, None);
940 if mirror_x {
941 modifier = modifier.scale2(-1.0, 1.0);
942 }
943 UiBox(modifier).child(child)
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949 use repose_core::locals::{Density, set_density_default};
950
951 #[test]
952 fn contain_fit_roundtrip_at_density_125() {
953 set_density_default(Density { scale: 1.25 });
956 let d = effective_density_scale();
957 let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
959 let cam = [400.0, 300.0];
960 let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
962 let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
964 set_density_default(Density { scale: 1.0 });
965 assert!(
966 (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
967 "dp fit, got {fit:?}"
968 );
969 assert!(
970 (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
971 "draw forward, got ({dx},{dy})"
972 );
973 assert!(
974 (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
975 "pick inverse, got ({wx},{wy})"
976 );
977 let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
979 let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
980 assert!(
981 (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
982 "shifted round trip, got {back:?}"
983 );
984 }
985
986 #[test]
987 fn gpu_viewport_honors_cam_and_background() {
988 use repose_core::{Color, Rect, Scene, SceneNode};
993 use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
994
995 let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
996 Ok(r) => r,
997 Err(e) => {
998 eprintln!("SKIP gpu viewport test (no GPU): {e}");
999 return;
1000 }
1001 };
1002 let page = [255, 0, 255, 255].repeat(4);
1004 let uploads = vec![AtlasUpload {
1005 page: 0,
1006 x: 0,
1007 y: 0,
1008 w: 2,
1009 h: 2,
1010 rgba: page,
1011 }];
1012 let cam = Camera2d {
1013 center: Vec2::new(64.0, 64.0),
1014 offset: Vec2::ZERO,
1015 units_per_pixel: 0.5,
1016 zoom: 1.0,
1017 };
1018 let input = FrameInput {
1019 cam,
1020 world_size: [128.0, 128.0],
1021 viewport_px: [256.0, 256.0],
1022 sprites: vec![SpriteInstance {
1023 center: Vec2::new(64.0, 64.0),
1024 size: Vec2::new(32.0, 32.0),
1025 uv_min: Vec2::new(0.0, 0.0),
1026 uv_max: Vec2::new(1.0, 1.0),
1027 color: [1.0, 1.0, 1.0, 1.0],
1028 ..Default::default()
1029 }],
1030 texts: Vec::new(),
1031 background: Some([0.0, 0.0, 1.0, 1.0]),
1032 overlay_color: None,
1033 chroma: 0.0,
1034 };
1035 let geom = Arc::new(Mutex::new(FrameGeom {
1036 fit: (1.0, 0.0, 0.0),
1037 look: [0.0, 0.0],
1038 density: 1.0,
1039 viewport_px: [256.0, 256.0],
1040 }));
1041 let payload = GpuViewport {
1042 input: Arc::new(input),
1043 geom,
1044 uploads,
1045 desc: BatchDesc {
1046 layer_size: 2,
1047 layers: 1,
1048 filter: TextureFilter::Nearest,
1049 },
1050 bg: FullscreenPass::new(
1051 "test.viewport2d.background",
1052 fullscreen::SOLID_WGSL,
1053 FullscreenDesc {
1054 texture_slots: 0,
1055 filter: TextureFilter::Nearest,
1056 },
1057 ),
1058 };
1059 let scene = Scene {
1060 clear_color: Color::from_rgba(0, 0, 0, 255),
1061 nodes: vec![SceneNode::Callback {
1062 rect: Rect {
1063 x: 0.0,
1064 y: 0.0,
1065 w: 256.0,
1066 h: 256.0,
1067 },
1068 payload: Callback::new(payload),
1069 }],
1070 };
1071 let px = renderer
1072 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1073 .expect("offscreen render");
1074 let at = |x: u32, y: u32| -> [u8; 4] {
1075 let i = ((y * 256 + x) * 4) as usize;
1076 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1077 };
1078 assert_eq!(at(8, 8), [0, 0, 255, 255], "background fill");
1080 assert_eq!(at(247, 247), [0, 0, 255, 255], "background fill");
1081 assert_eq!(at(128, 128), [255, 0, 255, 255], "cam-centered quad");
1083 }
1084
1085 #[test]
1086 fn surface_dp_matches_board_point_under_shake() {
1087 let geom = FrameGeom {
1091 fit: (1.0, 100.0, 0.0),
1092 look: [0.0, 0.0],
1093 density: 1.25,
1094 viewport_px: [1250.0, 750.0],
1095 };
1096 let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
1097 assert!(
1098 (ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
1099 "offset, got ({ox},{oy})"
1100 );
1101 assert!(
1102 (w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
1103 "size, got ({w},{h})"
1104 );
1105 let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
1107 assert!(
1108 (ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
1109 "surface glued to board point"
1110 );
1111 let shaken = FrameGeom {
1112 look: [10.0, -10.0],
1113 ..geom
1114 };
1115 let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
1116 let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
1117 assert!(
1118 (sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
1119 "surface glued under shake"
1120 );
1121 }
1122
1123 #[test]
1124 fn contain_fit_has_no_silent_clamp() {
1125 for (canvas, world) in [
1126 ([8000.0, 6000.0], [80.0, 60.0]),
1127 ([80.0, 60.0], [8000.0, 6000.0]),
1128 ([1000.0, 750.0], [800.0, 600.0]),
1129 ] {
1130 let fit = contain_fit(canvas, world);
1131 let cam = [world[0] * 0.5, world[1] * 0.5];
1132 let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
1133 let back = dp_to_world(dp, world, cam, fit);
1134 assert!(
1135 (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
1136 "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
1137 );
1138 }
1139 assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
1140 assert_eq!(
1141 dp_to_world(
1142 [10.0, 10.0],
1143 [800.0, 600.0],
1144 [400.0, 300.0],
1145 (0.0, 0.0, 0.0)
1146 ),
1147 [400.0, 300.0]
1148 );
1149 }
1150
1151 #[test]
1152 fn camera_guards_never_div_by_zero() {
1153 let bad = Camera2d {
1154 center: Vec2::ZERO,
1155 offset: Vec2::ZERO,
1156 units_per_pixel: 0.0,
1157 zoom: 0.0,
1158 };
1159 let m = bad.view_proj([0.0, 0.0]);
1160 assert!(m.is_finite(), "view_proj must stay finite, got {m:?}");
1161 assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
1162 }
1163
1164 #[test]
1165 fn fit_matrix_canvas_gpu_picks_agree() {
1166 let cam = Camera2d {
1167 center: Vec2::new(410.0, 290.0),
1168 offset: Vec2::ZERO,
1169 units_per_pixel: 1.0,
1170 zoom: 2.0,
1171 };
1172 let world_size = [800.0, 600.0];
1173 let density = 1.25;
1174 let vp_phys = [1250.0, 750.0];
1175 let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
1176 let cam_center = cam.effective_center();
1177 let fit = effective_fit(canvas_dp, world_size, &cam);
1178 assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
1179 let vp = fit_view_proj(canvas_dp, world_size, cam_center, fit);
1180 assert!(vp.is_finite());
1181 for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
1182 let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
1183 let px = [dx * density, dy * density];
1184 let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
1185 let gpu_px = [
1186 (ndc.x + 1.0) * 0.5 * vp_phys[0],
1187 (1.0 - ndc.y) * 0.5 * vp_phys[1],
1188 ];
1189 assert!(
1190 (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
1191 "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
1192 );
1193 let back = dp_to_world(
1194 [px[0] / density, px[1] / density],
1195 world_size,
1196 cam_center,
1197 fit,
1198 );
1199 assert!(
1200 (back[0] - world[0]).abs() < 1e-3 && (back[1] - world[1]).abs() < 1e-3,
1201 "pick round trip for {world:?} gave {back:?}"
1202 );
1203 }
1204 }
1205
1206 #[test]
1207 fn picks_ignore_viewport_origin() {
1208 let geom = FrameGeom {
1209 fit: (1.0, 100.0, 0.0),
1210 look: [0.0, 0.0],
1211 density: 1.25,
1212 viewport_px: [1250.0, 750.0],
1213 };
1214 let world_size = [800.0, 600.0];
1215 let a = pick_world([125.0, 75.0], geom, world_size);
1216 let b = pick_world([125.0, 75.0], geom, world_size);
1217 assert_eq!(a, b);
1218 let [wx, wy] = pick_world([125.0, 75.0], geom, world_size);
1219 assert!(
1220 (wx - 0.0).abs() < 1e-4 && (wy - 60.0).abs() < 1e-4,
1221 "local pick, got ({wx},{wy})"
1222 );
1223 }
1224
1225 #[test]
1226 fn offset_shifts_look_and_draw_together() {
1227 let world_size = [800.0, 600.0];
1230 let canvas_dp = [1000.0, 600.0];
1231 let mut cam = Camera2d {
1232 center: Vec2::new(400.0, 300.0),
1233 offset: Vec2::new(10.0, -10.0),
1234 units_per_pixel: 1.0,
1235 zoom: 1.0,
1236 };
1237 assert_eq!(cam.effective_center(), [410.0, 290.0]);
1238 let fit = effective_fit(canvas_dp, world_size, &cam);
1239 let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1240 let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
1241 assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
1242 cam.offset = Vec2::ZERO;
1243 let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1244 assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
1246 assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
1247 }
1248
1249 #[test]
1250 fn limits_clamp_center_not_offset() {
1251 let limits = CameraLimits {
1252 left: 100.0,
1253 top: 100.0,
1254 right: 700.0,
1255 bottom: 500.0,
1256 enabled: true,
1257 };
1258 assert_eq!(apply_limits([50.0, 300.0], limits), [100.0, 300.0]);
1259 assert_eq!(apply_limits([400.0, 900.0], limits), [400.0, 500.0]);
1260 assert_eq!(apply_limits([400.0, 300.0], limits), [400.0, 300.0]);
1261 assert_eq!(
1262 apply_limits([50.0, 50.0], CameraLimits { enabled: false, ..limits }),
1263 [50.0, 50.0]
1264 );
1265 }
1266
1267 #[test]
1268 fn smoothing_converges_without_overshoot() {
1269 let target = [100.0, 0.0];
1270 let p1 = smooth_toward([0.0, 0.0], target, 5.0, 0.016);
1271 assert!(p1[0] > 0.0 && p1[0] < 100.0, "eases forward, got {p1:?}");
1272 let mut p = [0.0, 0.0];
1273 for _ in 0..600 {
1274 p = smooth_toward(p, target, 5.0, 0.016);
1275 }
1276 assert!((p[0] - 100.0).abs() < 1e-2, "settles, got {p:?}");
1277 assert_eq!(smooth_toward([1.0, 2.0], target, 0.0, 0.016), target);
1278 assert_eq!(smooth_toward([1.0, 2.0], target, 5.0, 0.0), [1.0, 2.0]);
1279 }
1280
1281 #[test]
1282 fn touch_gate_and_screen_space() {
1283 use repose_core::input::{
1284 Modifiers, PointerButton, PointerEvent, PointerEventKind, PointerId, PointerKind,
1285 };
1286 use repose_core::Vec2 as RVec2;
1287 let ev_of = |kind: PointerKind| {
1288 let mut ev = PointerEvent::new(
1289 PointerId(3),
1290 kind,
1291 PointerEventKind::Down(PointerButton::Primary),
1292 RVec2 { x: 10.0, y: 20.0 },
1293 1.0,
1294 Modifiers::default(),
1295 );
1296 ev.origin = RVec2 { x: 5.0, y: 7.0 };
1297 ev
1298 };
1299 assert!(is_touch(&ev_of(PointerKind::Touch)));
1301 assert!(is_touch(&ev_of(PointerKind::Pen)));
1302 assert!(!is_touch(&ev_of(PointerKind::Mouse)));
1303 assert_eq!(screen_of(&ev_of(PointerKind::Touch)), [15.0, 27.0]);
1305 }
1306}