1use std::cell::RefCell;
11use std::collections::HashMap;
12use std::rc::Rc;
13use std::sync::{Arc, Mutex};
14
15use glam::{Mat4, Vec2, Vec3};
16use repose_canvas::{Canvas, DrawScope, Embedded};
17use repose_core::locals::effective_density_scale;
18use repose_core::{Color, Dp, Modifier, Px, Rect, View};
19use repose_render_wgpu::{
20 Callback, CallbackRenderPass, CallbackResources, ScreenDescriptor, WgpuCallback,
21};
22use repose_ui::Box as UiBox;
23use repose_ui::ViewExt;
24
25pub mod batch;
26pub use batch::{
27 AtlasUpload, BatchDesc, SpriteBatch, TextureFilter, draw_batch, draw_batch_with_id, frame_uv,
28 instance_rows, screen_camera, sprite_aabb,
29};
30
31pub mod fullscreen;
32pub use fullscreen::{
33 FullscreenDesc, FullscreenPass, FullscreenTexture, FullscreenTextureRef,
34 FullscreenTextureUpload,
35};
36
37pub mod light2d;
38pub use light2d::{Light2d, LightStop, Occluder2d, angular_shadow_map};
39
40pub mod post;
41
42pub mod sprite_image;
43pub use sprite_image::{
44 SpriteImage, SpriteImageAsset, SpriteImageDef, SpriteImageError, SpriteImageFrameDef,
45};
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum FitMode {
51 #[default]
52 Keep,
53 Expand,
57}
58
59#[derive(Clone, Copy, Debug)]
63pub struct Camera2d {
64 pub center: Vec2,
67 pub offset: Vec2,
70 pub units_per_pixel: f32,
74 pub zoom: f32,
77 pub roll: f32,
80 pub fit: FitMode,
83}
84
85impl Default for Camera2d {
86 fn default() -> Self {
87 Self {
88 center: Vec2::ZERO,
89 offset: Vec2::ZERO,
90 units_per_pixel: 1.0,
91 zoom: 1.0,
92 roll: 0.0,
93 fit: FitMode::Keep,
94 }
95 }
96}
97
98impl Camera2d {
101 pub fn effective_center(&self) -> [f32; 2] {
104 [self.center.x + self.offset.x, self.center.y + self.offset.y]
105 }
106
107 pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
110 let fit = effective_fit(canvas_dp, world_size, self);
111 fit_view_proj_with_roll(
112 canvas_dp,
113 world_size,
114 self.effective_center(),
115 fit,
116 self.roll,
117 )
118 }
119
120 pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
123 let fit = effective_fit(canvas_dp, world_size, self);
124 let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
125 Vec2::new(w[0], w[1])
126 }
127
128 pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
131 let vp_w = viewport_px[0].max(1.0);
132 let vp_h = viewport_px[1].max(1.0);
133 let zoom = sane_positive(self.zoom);
134 let upp = sane_positive(self.units_per_pixel);
135 let w = vp_w * upp / zoom;
136 let h = vp_h * upp / zoom;
137 let proj = glam::camera::rh::proj::directx::orthographic(
138 -w / 2.0,
139 w / 2.0,
140 h / 2.0,
141 -h / 2.0,
142 -1000.0,
143 1000.0,
144 );
145 let view = Mat4::from_translation(Vec3::new(
146 -(self.center.x + self.offset.x),
147 -(self.center.y + self.offset.y),
148 0.0,
149 ));
150 proj * view
151 }
152
153 #[deprecated(
158 since = "0.1.8",
159 note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
160 )]
161 pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
162 self.view_proj_screen(viewport_px)
163 }
164
165 #[deprecated(
169 since = "0.1.8",
170 note = "ignores world_size; use pick_world over the painted FrameGeom instead"
171 )]
172 #[allow(deprecated)]
173 pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
174 if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
175 let [cx, cy] = self.effective_center();
176 return Vec2::new(cx, cy);
177 }
178 let ndc = Vec2::new(
179 (px[0] / viewport_px[0]) * 2.0 - 1.0,
180 1.0 - (px[1] / viewport_px[1]) * 2.0,
181 );
182 let inv = self.view_proj(viewport_px).inverse();
183 let world = inv.project_point3(ndc.extend(0.0));
184 Vec2::new(world.x, world.y)
185 }
186}
187
188#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
192pub enum SpriteBlend {
193 #[default]
194 Alpha,
195 Additive,
196 Multiply,
197}
198
199#[derive(Clone, Copy, Debug)]
201pub struct SpriteInstance {
202 pub center: Vec2,
204 pub rotation: f32,
205 pub size: Vec2,
206 pub anchor: Vec2,
209 pub flip_x: bool,
212 pub flip_y: bool,
213 pub uv_min: Vec2,
214 pub uv_max: Vec2,
215 pub color: [f32; 4],
217 pub page: u32,
219 pub z: f32,
224 pub blend: SpriteBlend,
225}
226
227impl Default for SpriteInstance {
228 fn default() -> Self {
229 Self {
230 center: Vec2::ZERO,
231 rotation: 0.0,
232 size: Vec2::ONE,
233 anchor: Vec2::new(0.5, 0.5),
234 flip_x: false,
235 flip_y: false,
236 uv_min: Vec2::ZERO,
237 uv_max: Vec2::ONE,
238 color: [1.0, 1.0, 1.0, 1.0],
239 page: 0,
240 z: 0.0,
241 blend: SpriteBlend::Alpha,
242 }
243 }
244}
245
246#[derive(Clone, Debug, Default)]
248pub struct WorldText {
249 pub text: String,
250 pub pos: Vec2,
252 pub color: [f32; 4],
254 pub size: f32,
256 pub font_family: Option<&'static str>,
260}
261
262#[derive(Clone, Default, Debug)]
267pub struct FrameInput {
268 pub cam: Camera2d,
269 pub world_size: [f32; 2],
271 pub viewport_dp: [f32; 2],
275 pub sprites: Vec<SpriteInstance>,
276 pub texts: Vec<WorldText>,
279 pub background: Option<[f32; 4]>,
281 pub overlay_color: Option<[f32; 4]>,
284 pub chroma: f32,
288 pub lights: Vec<Light2d>,
293 pub occluders: std::sync::Arc<[Occluder2d]>,
296 pub ambient: Option<[f32; 3]>,
301}
302
303#[derive(Clone, Debug)]
307pub enum PickEvent {
308 Press {
309 world: Vec2,
310 screen: [f32; 2],
311 button: repose_core::input::PointerButton,
312 },
313 Click {
314 world: Vec2,
315 screen: [f32; 2],
316 button: repose_core::input::PointerButton,
317 },
318 Release {
324 world: Vec2,
325 screen: [f32; 2],
326 button: repose_core::input::PointerButton,
327 },
328 Hover {
329 world: Vec2,
330 screen: [f32; 2],
336 },
337 TouchDown { id: u64, screen: [f32; 2] },
340 TouchMove { id: u64, screen: [f32; 2] },
342 TouchUp { id: u64 },
344}
345
346fn is_touch(ev: &repose_core::input::PointerEvent) -> bool {
352 matches!(
353 ev.kind,
354 repose_core::input::PointerKind::Touch | repose_core::input::PointerKind::Pen
355 )
356}
357
358fn screen_of(ev: &repose_core::input::PointerEvent) -> [f32; 2] {
360 let p = ev.position_in_window();
361 [p.x, p.y]
362}
363
364fn button_of(ev: &repose_core::input::PointerEvent) -> repose_core::input::PointerButton {
367 match ev.event {
368 repose_core::input::PointerEventKind::Down(b)
369 | repose_core::input::PointerEventKind::Up(b) => b,
370 _ => repose_core::input::PointerButton::Primary,
371 }
372}
373
374fn sane_positive(v: f32) -> f32 {
377 if v.is_finite() && v > 1e-6 { v } else { 1.0 }
378}
379
380fn fitted_box_scissor(
384 info: &repose_core::PaintCallbackInfo,
385 world: [f32; 2],
386 fit: (f32, f32, f32),
387 roll: f32,
388) -> Option<(u32, u32, u32, u32)> {
389 fitted_box_scissor_in(
390 world,
391 fit,
392 roll,
393 info.pixels_per_point,
394 [info.viewport.x, info.viewport.y],
395 [
396 info.clip_rect.x,
397 info.clip_rect.y,
398 info.clip_rect.w,
399 info.clip_rect.h,
400 ],
401 )
402}
403
404fn fitted_box_scissor_in(
409 world: [f32; 2],
410 fit: (f32, f32, f32),
411 roll: f32,
412 density: f32,
413 origin: [f32; 2],
414 clip: [f32; 4],
415) -> Option<(u32, u32, u32, u32)> {
416 let d = if density.is_finite() && density > 1e-6 {
417 density
418 } else {
419 1.0
420 };
421 let (s, ox, oy) = fit;
422 if !(s.is_finite() && s > 1e-6) {
423 return None;
424 }
425 let (bw, bh) = (world[0] * s * d, world[1] * s * d);
428 let cx = origin[0] + ox * d + bw * 0.5;
429 let cy = origin[1] + oy * d + bh * 0.5;
430 let (hw, hh) = (bw * 0.5, bh * 0.5);
431 let (c, sn) = (roll.cos().abs(), roll.sin().abs());
432 let (ex, ey) = (hw * c + hh * sn, hw * sn + hh * c);
433 let lx = (cx - ex).max(clip[0]);
439 let ty = (cy - ey).max(clip[1]);
440 let rx = (cx + ex).min(clip[0] + clip[2]);
441 let by = (cy + ey).min(clip[1] + clip[3]);
442 if !(rx > lx && by > ty) {
443 return None;
444 }
445 let sx = lx.ceil().max(0.0) as u32;
446 let sy = ty.ceil().max(0.0) as u32;
447 let ex2 = rx.floor().max(0.0) as u32;
448 let ey2 = by.floor().max(0.0) as u32;
449 if ex2 <= sx || ey2 <= sy {
450 return None;
451 }
452 Some((sx, sy, ex2 - sx, ey2 - sy))
453}
454
455pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
459 if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
460 return (1.0, 0.0, 0.0);
461 }
462 let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
463 if !s.is_finite() || s <= 1e-6 {
464 return (1.0, 0.0, 0.0);
465 }
466 (
467 s,
468 (canvas_dp[0] - world[0] * s) * 0.5,
469 (canvas_dp[1] - world[1] * s) * 0.5,
470 )
471}
472
473pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
482 let (base, _, _) = contain_fit(canvas_dp, world);
483 let zoom = sane_positive(cam.zoom);
484 let upp = sane_positive(cam.units_per_pixel);
485 let s = base * zoom / upp;
486 if !s.is_finite() || s <= 1e-6 {
487 return (1.0, 0.0, 0.0);
488 }
489 let (ox, oy) = match cam.fit {
490 FitMode::Keep => (
491 (canvas_dp[0] - world[0] * s) * 0.5,
492 (canvas_dp[1] - world[1] * s) * 0.5,
493 ),
494 FitMode::Expand => (0.0, 0.0),
495 };
496 (s, ox, oy)
497}
498
499pub fn fit_view_proj(
502 canvas_dp: [f32; 2],
503 world_size: [f32; 2],
504 cam_center: [f32; 2],
505 fit: (f32, f32, f32),
506) -> Mat4 {
507 fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
508}
509
510pub fn fit_view_proj_with_roll(
513 canvas_dp: [f32; 2],
514 world_size: [f32; 2],
515 cam_center: [f32; 2],
516 fit: (f32, f32, f32),
517 roll: f32,
518) -> Mat4 {
519 let w = canvas_dp[0].max(1.0);
520 let h = canvas_dp[1].max(1.0);
521 let (s, ox, oy) = fit;
522 let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
523 let lx = cam_center[0] - world_size[0] * 0.5;
524 let ly = cam_center[1] - world_size[1] * 0.5;
525 let sx = 2.0 * s / w;
526 let sy = -2.0 * s / h;
527 let tx = 2.0 * (ox - lx * s) / w - 1.0;
528 let ty = 1.0 - 2.0 * (oy - ly * s) / h;
529 let base = Mat4::from_cols(
530 glam::Vec4::new(sx, 0.0, 0.0, 0.0),
531 glam::Vec4::new(0.0, sy, 0.0, 0.0),
532 glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
533 glam::Vec4::new(tx, ty, 0.0, 1.0),
534 );
535 if !roll.is_finite() || roll.abs() < 1e-7 {
536 return base;
537 }
538 let (c, r) = (roll.cos(), roll.sin());
539 let rot = Mat4::from_cols(
540 glam::Vec4::new(c, r, 0.0, 0.0),
541 glam::Vec4::new(-r, c, 0.0, 0.0),
542 glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
543 glam::Vec4::new(
544 cam_center[0] - c * cam_center[0] + r * cam_center[1],
545 cam_center[1] - r * cam_center[0] - c * cam_center[1],
546 0.0,
547 1.0,
548 ),
549 );
550 base * rot
551}
552
553pub fn world_to_dp(
556 world: [f32; 2],
557 world_size: [f32; 2],
558 cam_center: [f32; 2],
559 fit: (f32, f32, f32),
560) -> [f32; 2] {
561 world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
562}
563
564pub fn world_to_dp_with_roll(
566 world: [f32; 2],
567 world_size: [f32; 2],
568 cam_center: [f32; 2],
569 fit: (f32, f32, f32),
570 roll: f32,
571) -> [f32; 2] {
572 let (s, ox, oy) = fit;
573 let [wx, wy] = rotate_about(world, cam_center, roll);
574 [
575 ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
576 oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
577 ]
578}
579
580pub fn dp_to_world(
582 dp: [f32; 2],
583 world_size: [f32; 2],
584 cam_center: [f32; 2],
585 fit: (f32, f32, f32),
586) -> [f32; 2] {
587 dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
588}
589
590pub fn dp_to_world_with_roll(
592 dp: [f32; 2],
593 world_size: [f32; 2],
594 cam_center: [f32; 2],
595 fit: (f32, f32, f32),
596 roll: f32,
597) -> [f32; 2] {
598 let (s, ox, oy) = fit;
599 if !s.is_finite() || s.abs() < 1e-6 {
600 return cam_center;
601 }
602 let base = [
603 (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
604 (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
605 ];
606 rotate_about(base, cam_center, -roll)
607}
608
609fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
610 if !roll.is_finite() || roll.abs() < 1e-7 {
611 return p;
612 }
613 let (c, r) = (roll.cos(), roll.sin());
614 let dx = p[0] - center[0];
615 let dy = p[1] - center[1];
616 [center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
617}
618
619pub fn pick_world(local_px: [f32; 2], geom: FrameGeom, world_size: [f32; 2]) -> [f32; 2] {
630 let d = if geom.density.is_finite() && geom.density > 1e-6 {
631 geom.density
632 } else {
633 1.0
634 };
635 dp_to_world_with_roll(
636 [local_px[0] / d, local_px[1] / d],
637 world_size,
638 geom.pivot,
639 geom.fit,
640 geom.roll,
641 )
642}
643
644pub fn aim_world(
650 local_px: [f32; 2],
651 look_unshaken: [f32; 2],
652 roll_unshaken: f32,
653 fit: (f32, f32, f32),
654 density: f32,
655 world_size: [f32; 2],
656) -> [f32; 2] {
657 let d = if density.is_finite() && density > 1e-6 {
658 density
659 } else {
660 1.0
661 };
662 dp_to_world_with_roll(
663 [local_px[0] / d, local_px[1] / d],
664 world_size,
665 look_unshaken,
666 fit,
667 roll_unshaken,
668 )
669}
670
671pub fn aim_world_unshaken(
676 local_px: [f32; 2],
677 cam: &Camera2d,
678 canvas_dp: [f32; 2],
679 world_size: [f32; 2],
680 density: f32,
681) -> [f32; 2] {
682 let fit = effective_fit(canvas_dp, world_size, cam);
683 aim_world(
684 local_px,
685 [cam.center.x, cam.center.y],
686 0.0,
687 fit,
688 density,
689 world_size,
690 )
691}
692
693#[derive(Clone, Copy, Debug)]
703pub struct FrameGeom {
704 pub fit: (f32, f32, f32),
707 pub look: [f32; 2],
710 pub density: f32,
712 pub viewport_px: [f32; 2],
714 pub roll: f32,
716 pub pivot: [f32; 2],
719}
720
721impl Default for FrameGeom {
722 fn default() -> Self {
723 Self {
724 fit: (1.0, 0.0, 0.0),
725 look: [0.0, 0.0],
726 density: 1.0,
727 viewport_px: [1.0, 1.0],
728 roll: 0.0,
729 pivot: [0.0, 0.0],
730 }
731 }
732}
733
734#[derive(Clone, Default)]
737pub struct GeomHandle(Arc<Mutex<FrameGeom>>);
738
739impl GeomHandle {
740 pub fn new() -> Self {
741 Self(Arc::new(Mutex::new(FrameGeom::default())))
742 }
743
744 pub fn get(&self) -> FrameGeom {
745 self.0.lock().map(|g| *g).unwrap_or_default()
746 }
747
748 pub fn set(&self, g: FrameGeom) {
749 if let Ok(mut slot) = self.0.lock() {
750 *slot = g;
751 }
752 }
753
754 pub fn arc(&self) -> Arc<Mutex<FrameGeom>> {
755 self.0.clone()
756 }
757}
758
759impl From<FrameGeom> for GeomHandle {
760 fn from(g: FrameGeom) -> Self {
761 Self(Arc::new(Mutex::new(g)))
762 }
763}
764
765impl From<std::sync::Arc<std::sync::Mutex<FrameGeom>>> for GeomHandle {
766 fn from(arc: std::sync::Arc<std::sync::Mutex<FrameGeom>>) -> Self {
768 Self(arc)
769 }
770}
771
772impl From<std::rc::Rc<std::cell::Cell<FrameGeom>>> for GeomHandle {
773 fn from(rc: std::rc::Rc<std::cell::Cell<FrameGeom>>) -> Self {
776 Self(Arc::new(Mutex::new(rc.get())))
777 }
778}
779
780pub const CLICK_SLOP_DP: f32 = 12.0;
785pub const CLICK_SLOP_PX: f32 = CLICK_SLOP_DP;
788
789#[allow(dead_code)]
792fn click_within_slop(a: [f32; 2], b: [f32; 2]) -> bool {
793 click_within_slop_dp(a, b, 1.0)
794}
795
796pub fn click_within_slop_dp(a: [f32; 2], b: [f32; 2], density: f32) -> bool {
799 let d = if density.is_finite() && density > 1e-6 {
800 density
801 } else {
802 1.0
803 };
804 let dx = (a[0] - b[0]) / d;
805 let dy = (a[1] - b[1]) / d;
806 dx * dx + dy * dy <= CLICK_SLOP_DP * CLICK_SLOP_DP
807}
808
809pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
813 let (s, ox, oy) = geom.fit;
814 let r = rotate_about(center, geom.pivot, geom.roll);
815 (
816 [
817 ox + (r[0] - size[0] * 0.5 - geom.look[0]) * s,
818 oy + (r[1] - size[1] * 0.5 - geom.look[1]) * s,
819 ],
820 [size[0] * s, size[1] * s],
821 )
822}
823
824fn rgba8(c: [f32; 4]) -> Color {
825 Color::from_rgba(
826 (c[0].clamp(0.0, 1.0) * 255.0) as u8,
827 (c[1].clamp(0.0, 1.0) * 255.0) as u8,
828 (c[2].clamp(0.0, 1.0) * 255.0) as u8,
829 (c[3].clamp(0.0, 1.0) * 255.0) as u8,
830 )
831}
832
833#[allow(non_snake_case)] pub fn Viewport2d(
843 input: FrameInput,
844 geom_out: GeomHandle,
845 on_event: impl Fn(PickEvent) + 'static,
846) -> View {
847 Viewport2dShared(Arc::new(input), geom_out, on_event)
848}
849
850#[allow(non_snake_case)]
851pub fn Viewport2dShared(
852 input: Arc<FrameInput>,
853 geom_out: GeomHandle,
854 on_event: impl Fn(PickEvent) + 'static,
855) -> View {
856 let world_size = input.world_size;
857 let pick_geom = geom_out.clone();
858 let move_geom = geom_out.clone();
859 let release_geom = geom_out.clone();
860 let draw_input = input.clone();
861 let draw_geom = geom_out.clone();
862 let on_event = Rc::new(on_event);
863 let on_down = on_event.clone();
864 let on_touch_down = on_event.clone();
865 let on_move = on_event.clone();
866 let on_touch_move = on_event.clone();
867 let on_up = on_event.clone();
868 let on_up_click = on_event.clone();
869 let on_leave = on_event;
870 let press: Rc<RefCell<HashMap<u64, [f32; 2]>>> = Rc::new(RefCell::new(HashMap::new()));
871 let press_down = press.clone();
872 let press_up = press.clone();
873 let press_leave = press.clone();
874
875 let modifier = Modifier::new()
876 .fill_max_size()
877 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
878 let p = ev.position;
879 let g = pick_geom.get();
880 let world = pick_world([p.x, p.y], g, world_size);
881 press_down.borrow_mut().insert(ev.id.0, [p.x, p.y]);
882 let w = ev.position_in_window();
883 let button = button_of(&ev);
884 on_down(PickEvent::Press {
885 world: Vec2::new(world[0], world[1]),
886 screen: [w.x, w.y],
887 button,
888 });
889 if is_touch(&ev) {
890 on_touch_down(PickEvent::TouchDown {
891 id: ev.id.0,
892 screen: screen_of(&ev),
893 });
894 }
895 })
896 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
897 let p = ev.position;
898 let g = move_geom.get();
899 let world = pick_world([p.x, p.y], g, world_size);
900 let w = ev.position_in_window();
901 on_move(PickEvent::Hover {
902 world: Vec2::new(world[0], world[1]),
903 screen: [w.x, w.y],
904 });
905 if is_touch(&ev) {
906 on_touch_move(PickEvent::TouchMove {
907 id: ev.id.0,
908 screen: screen_of(&ev),
909 });
910 }
911 })
912 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
913 let p = ev.position;
914 let start = press_up.borrow_mut().remove(&ev.id.0);
915 {
916 let g = release_geom.get();
917 let world = pick_world([p.x, p.y], g, world_size);
918 let w = ev.position_in_window();
919 on_up_click(PickEvent::Release {
920 world: Vec2::new(world[0], world[1]),
921 screen: [w.x, w.y],
922 button: button_of(&ev),
923 });
924 }
925 if let Some(start) = start
926 && click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
927 {
928 let g = release_geom.get();
929 let world = pick_world([p.x, p.y], g, world_size);
930 let w = ev.position_in_window();
931 let button = button_of(&ev);
932 on_up_click(PickEvent::Click {
933 world: Vec2::new(world[0], world[1]),
934 screen: [w.x, w.y],
935 button,
936 });
937 }
938 if is_touch(&ev) {
939 on_up(PickEvent::TouchUp { id: ev.id.0 });
940 }
941 })
942 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
943 press_leave.borrow_mut().remove(&ev.id.0);
944 if is_touch(&ev) {
945 on_leave(PickEvent::TouchUp { id: ev.id.0 });
946 }
947 });
948 Canvas(modifier, move |scope: &mut DrawScope| {
949 let d = effective_density_scale();
950 let cam = draw_input.cam;
951 let cam_center = cam.effective_center();
952 let fit = effective_fit(
953 [scope.size.width / d, scope.size.height / d],
954 draw_input.world_size,
955 &cam,
956 );
957 draw_geom.set(FrameGeom {
958 fit,
959 look: [
960 cam_center[0] - draw_input.world_size[0] * 0.5,
961 cam_center[1] - draw_input.world_size[1] * 0.5,
962 ],
963 density: d,
964 viewport_px: [scope.size.width, scope.size.height],
965 roll: cam.roll,
966 pivot: cam_center,
967 });
968 let project = |wx: f32, wy: f32| -> [f32; 2] {
969 let [dx, dy] = world_to_dp_with_roll(
970 [wx, wy],
971 draw_input.world_size,
972 cam_center,
973 (fit.0, fit.1, fit.2),
974 cam.roll,
975 );
976 [dx * d, dy * d]
977 };
978 if let Some(bg) = draw_input.background {
979 scope.draw_rect(
980 Rect {
981 x: 0.0,
982 y: 0.0,
983 w: scope.size.width,
984 h: scope.size.height,
985 },
986 rgba8(bg),
987 Px(0.0),
988 );
989 }
990 for spr in draw_input.sprites.iter() {
991 let px_per_world = fit.0 * d;
996 let [px, py] = project(spr.center.x, spr.center.y);
997 let ax = if spr.flip_x {
998 1.0 - spr.anchor.x
999 } else {
1000 spr.anchor.x
1001 };
1002 let ay = if spr.flip_y {
1003 1.0 - spr.anchor.y
1004 } else {
1005 spr.anchor.y
1006 };
1007 scope.draw_rect_rotated(
1008 Rect {
1009 x: px - ax * spr.size.x * px_per_world,
1010 y: py - ay * spr.size.y * px_per_world,
1011 w: (spr.size.x * px_per_world).max(0.0),
1012 h: (spr.size.y * px_per_world).max(0.0),
1013 },
1014 rgba8(spr.color),
1015 Px(0.0),
1016 cam.roll + spr.rotation,
1017 repose_core::Vec2 { x: px, y: py },
1018 );
1019 }
1020 for t in draw_input.texts.iter() {
1021 let [tx, ty] = project(t.pos.x, t.pos.y);
1022 scope.draw_text_with_family(
1023 t.text.clone(),
1024 repose_core::Vec2 { x: tx, y: ty },
1025 rgba8(t.color),
1026 Px(t.size * fit.0 * d),
1027 t.font_family,
1028 );
1029 }
1030 if let Some(tint) = draw_input.overlay_color {
1031 scope.draw_rect(
1032 Rect {
1033 x: 0.0,
1034 y: 0.0,
1035 w: scope.size.width,
1036 h: scope.size.height,
1037 },
1038 rgba8(tint),
1039 Px(0.0),
1040 );
1041 }
1042 })
1043}
1044
1045#[allow(non_snake_case)]
1055pub fn Viewport2dGpu(
1056 input: FrameInput,
1057 geom_out: GeomHandle,
1058 uploads: Arc<[AtlasUpload]>,
1059 desc: BatchDesc,
1060 on_event: impl Fn(PickEvent) + 'static,
1061) -> View {
1062 Viewport2dGpuWithIdShared(
1063 Arc::new(input),
1064 geom_out,
1065 uploads,
1066 desc,
1067 "viewport2d.main",
1068 on_event,
1069 )
1070}
1071
1072#[allow(non_snake_case)]
1075pub fn Viewport2dGpuWithId(
1076 input: FrameInput,
1077 geom_out: GeomHandle,
1078 uploads: Arc<[AtlasUpload]>,
1079 desc: BatchDesc,
1080 batch_id: impl Into<String>,
1081 on_event: impl Fn(PickEvent) + 'static,
1082) -> View {
1083 Viewport2dGpuWithIdShared(Arc::new(input), geom_out, uploads, desc, batch_id, on_event)
1084}
1085
1086#[allow(non_snake_case)]
1087pub fn Viewport2dGpuWithIdShared(
1088 input: Arc<FrameInput>,
1089 geom_out: GeomHandle,
1090 uploads: Arc<[AtlasUpload]>,
1091 desc: BatchDesc,
1092 batch_id: impl Into<String>,
1093 on_event: impl Fn(PickEvent) + 'static,
1094) -> View {
1095 viewport2d_gpu_with_id_shared(input, geom_out, uploads, desc, batch_id, on_event, false)
1096}
1097
1098fn viewport2d_gpu_with_id_shared(
1099 input: Arc<FrameInput>,
1100 geom_out: GeomHandle,
1101 uploads: Arc<[AtlasUpload]>,
1102 desc: BatchDesc,
1103 batch_id: impl Into<String>,
1104 on_event: impl Fn(PickEvent) + 'static,
1105 overlay_separate: bool,
1106) -> View {
1107 let batch_id: String = batch_id.into();
1108 let bg_id = format!("{batch_id}.background");
1109 let overlay_id = format!("{batch_id}.overlay");
1110 let world_size = input.world_size;
1111 let pick_geom = geom_out.clone();
1112 let move_geom = geom_out.clone();
1113 let release_geom = geom_out.clone();
1114 let size_geom = geom_out.clone();
1115 let size_input = input.clone();
1116 let geom_for_payload = geom_out.clone();
1117 let on_event = Arc::new(on_event);
1118 let on_down = on_event.clone();
1119 let on_touch_down = on_event.clone();
1120 let on_move = on_event.clone();
1121 let on_touch_move = on_event.clone();
1122 let on_up = on_event.clone();
1123 let on_up_click = on_event.clone();
1124 let on_leave = on_event;
1125 let press: Arc<Mutex<HashMap<u64, [f32; 2]>>> = Arc::new(Mutex::new(HashMap::new()));
1126 let press_down = press.clone();
1127 let press_up = press.clone();
1128 let press_leave = press.clone();
1129 let payload = GpuViewport {
1130 input: input.clone(),
1131 geom: geom_for_payload.arc(),
1132 batch_id,
1133 uploads,
1134 desc,
1135 overlay_separate,
1136 bg: FullscreenPass::new(
1137 bg_id,
1138 fullscreen::SOLID_WGSL,
1139 FullscreenDesc {
1140 texture_slots: 0,
1141 filter: TextureFilter::Nearest,
1142 },
1143 ),
1144 overlay: FullscreenPass::new(
1145 overlay_id,
1146 fullscreen::SOLID_WGSL,
1147 FullscreenDesc {
1148 texture_slots: 0,
1149 filter: TextureFilter::Nearest,
1150 },
1151 ),
1152 };
1153 let modifier = Modifier::new()
1154 .fill_max_size()
1155 .on_size_changed(move |dp: repose_core::Vec2| {
1156 let d = effective_density_scale();
1157 let d = if d.is_finite() && d > 1e-6 { d } else { 1.0 };
1158 let fit = effective_fit([dp.x, dp.y], size_input.world_size, &size_input.cam);
1159 let center = size_input.cam.effective_center();
1160 size_geom.set(FrameGeom {
1161 viewport_px: [dp.x * d, dp.y * d],
1162 density: d,
1163 fit,
1164 look: [
1165 center[0] - size_input.world_size[0] * 0.5,
1166 center[1] - size_input.world_size[1] * 0.5,
1167 ],
1168 roll: size_input.cam.roll,
1169 pivot: center,
1170 });
1171 })
1172 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
1173 let p = ev.position;
1174 let g = pick_geom.get();
1175 let world = pick_world([p.x, p.y], g, world_size);
1176 if let Ok(mut slots) = press_down.lock() {
1177 slots.insert(ev.id.0, [p.x, p.y]);
1178 }
1179 let w = ev.position_in_window();
1180 let button = button_of(&ev);
1181 on_down(PickEvent::Press {
1182 world: Vec2::new(world[0], world[1]),
1183 screen: [w.x, w.y],
1184 button,
1185 });
1186 if is_touch(&ev) {
1187 on_touch_down(PickEvent::TouchDown {
1188 id: ev.id.0,
1189 screen: screen_of(&ev),
1190 });
1191 }
1192 })
1193 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
1194 let p = ev.position;
1195 let g = move_geom.get();
1196 let world = pick_world([p.x, p.y], g, world_size);
1197 let w = ev.position_in_window();
1198 on_move(PickEvent::Hover {
1199 world: Vec2::new(world[0], world[1]),
1200 screen: [w.x, w.y],
1201 });
1202 if is_touch(&ev) {
1203 on_touch_move(PickEvent::TouchMove {
1204 id: ev.id.0,
1205 screen: screen_of(&ev),
1206 });
1207 }
1208 })
1209 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
1210 let p = ev.position;
1211 let start = press_up.lock().ok().and_then(|mut s| s.remove(&ev.id.0));
1212 {
1213 let g = release_geom.get();
1214 let world = pick_world([p.x, p.y], g, world_size);
1215 let w = ev.position_in_window();
1216 on_up_click(PickEvent::Release {
1217 world: Vec2::new(world[0], world[1]),
1218 screen: [w.x, w.y],
1219 button: button_of(&ev),
1220 });
1221 }
1222 if let Some(start) = start
1223 && click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
1224 {
1225 let g = release_geom.get();
1226 let world = pick_world([p.x, p.y], g, world_size);
1227 let w = ev.position_in_window();
1228 let button = button_of(&ev);
1229 on_up_click(PickEvent::Click {
1230 world: Vec2::new(world[0], world[1]),
1231 screen: [w.x, w.y],
1232 button,
1233 });
1234 }
1235 if is_touch(&ev) {
1236 on_up(PickEvent::TouchUp { id: ev.id.0 });
1237 }
1238 })
1239 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
1240 if let Ok(mut s) = press_leave.lock() {
1241 s.remove(&ev.id.0);
1242 }
1243 if is_touch(&ev) {
1244 on_leave(PickEvent::TouchUp { id: ev.id.0 });
1245 }
1246 });
1247 Embedded(modifier, Callback::new(payload))
1248}
1249
1250struct GpuViewport {
1251 input: Arc<FrameInput>,
1252 geom: Arc<Mutex<FrameGeom>>,
1253 batch_id: String,
1254 uploads: Arc<[AtlasUpload]>,
1255 desc: BatchDesc,
1256 overlay_separate: bool,
1257 bg: FullscreenPass,
1258 overlay: FullscreenPass,
1259}
1260
1261impl WgpuCallback for GpuViewport {
1262 fn resource_key(&self) -> Option<&str> {
1263 Some("repame.viewport2d.resources")
1264 }
1265
1266 fn prepare(
1267 &self,
1268 device: &wgpu::Device,
1269 queue: &wgpu::Queue,
1270 encoder: &mut wgpu::CommandEncoder,
1271 screen: &ScreenDescriptor,
1272 resources: &mut CallbackResources,
1273 ) -> Vec<wgpu::CommandBuffer> {
1274 let (vp_phys, density) = self
1275 .geom
1276 .lock()
1277 .map(|g| {
1278 let d = if g.density.is_finite() && g.density > 1e-6 {
1279 g.density
1280 } else {
1281 1.0
1282 };
1283 (g.viewport_px, d)
1284 })
1285 .unwrap_or(([1.0, 1.0], 1.0));
1286 let dp = if vp_phys[0] > 1.0 && vp_phys[1] > 1.0 {
1287 [vp_phys[0] / density, vp_phys[1] / density]
1288 } else {
1289 self.input.viewport_dp
1290 };
1291 let mut batch = SpriteBatch::with_id(self.batch_id.clone(), self.desc);
1292 batch.set_camera(self.input.cam.fit_matrix(dp, self.input.world_size));
1293 for s in &self.input.sprites {
1294 batch.push_sprite(s);
1295 }
1296 batch.prepare_with_uploads(
1297 device,
1298 queue,
1299 encoder,
1300 screen,
1301 resources,
1302 self.uploads.as_ref(),
1303 );
1304 let composite = post::needs_post(&self.input);
1305 if let Some(bg) = self.input.background
1307 && !composite
1308 {
1309 let words = rgba8(bg).to_linear();
1313 self.bg.prepare_with(
1314 device,
1315 queue,
1316 screen,
1317 resources,
1318 bytemuck::cast_slice(&words),
1319 &[] as &[FullscreenTexture],
1320 );
1321 }
1322 if !self.overlay_separate
1323 && let Some(ov) = self.input.overlay_color
1324 {
1325 let words = rgba8(ov).to_linear();
1326 self.overlay.prepare_with(
1327 device,
1328 queue,
1329 screen,
1330 resources,
1331 bytemuck::cast_slice(&words),
1332 &[] as &[FullscreenTexture],
1333 );
1334 }
1335 if composite {
1336 let w = vp_phys[0].max(1.0) as u32;
1337 let h = vp_phys[1].max(1.0) as u32;
1338 let fit = effective_fit(dp, self.input.world_size, &self.input.cam);
1342 let roll = self.input.cam.roll;
1343 let center = self.input.cam.effective_center();
1344 let mut shadow_maps: Vec<Vec<[f32; 2]>> = Vec::new();
1347 let lights: &[Light2d] = if self.input.lights.is_empty() {
1348 &[]
1349 } else {
1350 let n = self.input.lights.len().min(light2d::MAX_LIGHTS);
1351 for (light, bins) in self.input.lights[..n]
1352 .iter()
1353 .zip(light2d::resolve_shadow_bins(&self.input.lights))
1354 {
1355 if bins == 0 {
1356 shadow_maps.push(Vec::new());
1357 continue;
1358 }
1359 let mut resolved = *light;
1360 resolved.shadow_bins = bins;
1361 let mut map = Vec::new();
1362 light2d::angular_shadow_map(&resolved, &self.input.occluders, &mut map);
1363 shadow_maps.push(map);
1364 }
1365 &self.input.lights[..n]
1366 };
1367 post::prepare_composite(
1368 device,
1369 queue,
1370 encoder,
1371 screen,
1372 resources,
1373 w,
1374 h,
1375 &post::CompositeDesc {
1376 batch_id: self.batch_id.as_str(),
1377 amount: self.input.chroma,
1378 background: self.input.background,
1379 ambient: self.input.ambient.unwrap_or([0.0, 0.0, 0.0]),
1380 lights,
1381 shadow_maps: &shadow_maps,
1382 fit,
1383 roll,
1384 world: self.input.world_size,
1385 center,
1386 density,
1387 scissor: fitted_box_scissor_in(
1388 self.input.world_size,
1389 fit,
1390 roll,
1391 density,
1392 [0.0, 0.0],
1393 [0.0, 0.0, w as f32, h as f32],
1394 ),
1395 },
1396 );
1397 }
1398 Vec::new()
1399 }
1400
1401 fn paint(
1402 &self,
1403 info: repose_core::PaintCallbackInfo,
1404 rpass: &mut CallbackRenderPass<'_, '_>,
1405 resources: &CallbackResources,
1406 ) {
1407 let d = if info.pixels_per_point.is_finite() && info.pixels_per_point > 1e-6 {
1408 info.pixels_per_point
1409 } else {
1410 1.0
1411 };
1412 let vp = [info.viewport.w, info.viewport.h];
1413 let fit = effective_fit(
1414 [vp[0] / d, vp[1] / d],
1415 self.input.world_size,
1416 &self.input.cam,
1417 );
1418 let cam = self.input.cam.effective_center();
1419 let roll = self.input.cam.roll;
1420 if let Ok(mut g) = self.geom.lock() {
1421 *g = FrameGeom {
1422 fit,
1423 look: [
1424 cam[0] - self.input.world_size[0] * 0.5,
1425 cam[1] - self.input.world_size[1] * 0.5,
1426 ],
1427 density: d,
1428 viewport_px: vp,
1429 roll,
1430 pivot: cam,
1431 };
1432 }
1433 if post::needs_post(&self.input) {
1434 post::paint_composite(self.batch_id.as_str(), rpass, resources);
1435 } else {
1436 if self.input.background.is_some() {
1437 self.bg.paint(info, rpass, resources);
1438 }
1439 let restore = (
1447 info.clip_rect.x.ceil().max(0.0) as u32,
1448 info.clip_rect.y.ceil().max(0.0) as u32,
1449 ((info.clip_rect.x + info.clip_rect.w).floor().max(0.0) as u32)
1450 .saturating_sub(info.clip_rect.x.ceil().max(0.0) as u32),
1451 ((info.clip_rect.y + info.clip_rect.h).floor().max(0.0) as u32)
1452 .saturating_sub(info.clip_rect.y.ceil().max(0.0) as u32),
1453 );
1454 match fitted_box_scissor(&info, self.input.world_size, fit, roll) {
1455 Some(sc) => {
1456 rpass.set_scissor_rect(sc.0, sc.1, sc.2, sc.3);
1457 batch::draw_batch_with_id_callback(self.batch_id.as_str(), rpass, resources);
1458 rpass.set_scissor_rect(restore.0, restore.1, restore.2, restore.3);
1459 }
1460 None => {
1461 batch::draw_batch_with_id_callback(self.batch_id.as_str(), rpass, resources);
1462 }
1463 }
1464 }
1465 if !self.overlay_separate && self.input.overlay_color.is_some() {
1466 self.overlay.paint(info, rpass, resources);
1467 }
1468 }
1469}
1470
1471#[allow(non_snake_case)]
1483pub fn Viewport2dGpuWithHud(
1484 input: FrameInput,
1485 geom: GeomHandle,
1486 uploads: Arc<[AtlasUpload]>,
1487 desc: BatchDesc,
1488 on_event: impl Fn(PickEvent) + 'static,
1489) -> View {
1490 Viewport2dGpuWithHudShared(Arc::new(input), geom, uploads, desc, on_event)
1491}
1492
1493#[allow(non_snake_case)]
1494pub fn Viewport2dGpuWithHudShared(
1495 input: Arc<FrameInput>,
1496 geom: GeomHandle,
1497 uploads: Arc<[AtlasUpload]>,
1498 desc: BatchDesc,
1499 on_event: impl Fn(PickEvent) + 'static,
1500) -> View {
1501 use repose_ui::ViewExt as _;
1502 let hud_input = input.clone();
1503 let hud_geom = geom.clone();
1504 let hud = Canvas(
1505 Modifier::new().fill_max_size().hit_passthrough(),
1506 move |scope: &mut DrawScope| {
1507 let input = hud_input.as_ref();
1508 let cam = input.cam;
1509 let world_size = input.world_size;
1510 let d = effective_density_scale();
1511 let cam_center = cam.effective_center();
1512 let fit = effective_fit(
1513 [scope.size.width / d, scope.size.height / d],
1514 world_size,
1515 &cam,
1516 );
1517 hud_geom.set(FrameGeom {
1518 fit,
1519 look: [
1520 cam_center[0] - world_size[0] * 0.5,
1521 cam_center[1] - world_size[1] * 0.5,
1522 ],
1523 density: d,
1524 viewport_px: [scope.size.width, scope.size.height],
1525 roll: cam.roll,
1526 pivot: cam_center,
1527 });
1528 let project = |wx: f32, wy: f32| -> [f32; 2] {
1529 let [dx, dy] = world_to_dp_with_roll(
1530 [wx, wy],
1531 world_size,
1532 cam_center,
1533 (fit.0, fit.1, fit.2),
1534 cam.roll,
1535 );
1536 [dx * d, dy * d]
1537 };
1538 for t in input.texts.iter() {
1539 let [tx, ty] = project(t.pos.x, t.pos.y);
1540 scope.draw_text_with_family(
1541 t.text.clone(),
1542 repose_core::Vec2 { x: tx, y: ty },
1543 rgba8(t.color),
1544 Px(t.size * fit.0 * d),
1545 t.font_family,
1546 );
1547 }
1548 if let Some(tint) = input.overlay_color {
1549 scope.draw_rect(
1550 Rect {
1551 x: 0.0,
1552 y: 0.0,
1553 w: scope.size.width,
1554 h: scope.size.height,
1555 },
1556 rgba8(tint),
1557 Px(0.0),
1558 );
1559 }
1560 },
1561 );
1562 repose_ui::ZStack(Modifier::new().fill_max_size())
1563 .child(viewport2d_gpu_with_id_shared(
1564 input,
1565 geom,
1566 uploads,
1567 desc,
1568 "viewport2d.main",
1569 on_event,
1570 true,
1571 ))
1572 .child(hud)
1573}
1574#[allow(non_snake_case)]
1582pub fn ActorFrame(
1583 center: [f32; 2],
1584 size: [f32; 2],
1585 geom: GeomHandle,
1586 mirror_x: bool,
1587 child: View,
1588) -> View {
1589 ActorFrameWithOrigin(center, size, geom, mirror_x, [0.5, 0.5], child)
1590}
1591
1592#[allow(non_snake_case)]
1598pub fn ActorFrameWithOrigin(
1599 center: [f32; 2],
1600 size: [f32; 2],
1601 geom: GeomHandle,
1602 mirror_x: bool,
1603 origin: [f32; 2],
1604 child: View,
1605) -> View {
1606 let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
1607 let mut modifier = Modifier::new().size(Dp(w), Dp(h)).absolute().offset(
1610 Some(Dp(ox)),
1611 Some(Dp(oy)),
1612 None,
1613 None,
1614 );
1615 if mirror_x {
1616 modifier = modifier
1617 .transform_origin(origin[0].clamp(0.0, 1.0), origin[1].clamp(0.0, 1.0))
1618 .scale2(-1.0, 1.0);
1619 }
1620 UiBox(modifier).child(child)
1621}
1622
1623#[cfg(test)]
1624mod tests {
1625 use super::*;
1626 use repose_core::locals::{Density, set_density_default};
1627
1628 #[test]
1629 fn contain_fit_roundtrip_at_density_125() {
1630 set_density_default(Density { scale: 1.25 });
1632 let d = effective_density_scale();
1633 let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
1635 let cam = [400.0, 300.0];
1636 let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
1638 let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
1640 set_density_default(Density { scale: 1.0 });
1641 assert!(
1642 (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
1643 "dp fit, got {fit:?}"
1644 );
1645 assert!(
1646 (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
1647 "draw forward, got ({dx},{dy})"
1648 );
1649 assert!(
1650 (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
1651 "pick inverse, got ({wx},{wy})"
1652 );
1653 let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
1655 let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
1656 assert!(
1657 (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
1658 "shifted round trip, got {back:?}"
1659 );
1660 }
1661
1662 #[test]
1663 fn gpu_viewport_honors_cam_and_background() {
1664 use repose_core::{Color, Rect, Scene, SceneNode};
1667 use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1668
1669 let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1670 Ok(r) => r,
1671 Err(e) => {
1672 eprintln!("SKIP gpu viewport test (no GPU): {e}");
1673 return;
1674 }
1675 };
1676 let page = [255, 0, 255, 255].repeat(4);
1678 let uploads = vec![AtlasUpload {
1679 page: 0,
1680 x: 0,
1681 y: 0,
1682 w: 2,
1683 h: 2,
1684 rgba: page,
1685 }];
1686 let cam = Camera2d {
1687 center: Vec2::new(64.0, 64.0),
1688 offset: Vec2::ZERO,
1689 units_per_pixel: 0.5,
1690 zoom: 1.0,
1691 roll: 0.0,
1692 fit: FitMode::Keep,
1693 };
1694 let input = FrameInput {
1695 cam,
1696 world_size: [128.0, 128.0],
1697 viewport_dp: [256.0, 256.0],
1698 sprites: vec![SpriteInstance {
1699 center: Vec2::new(64.0, 64.0),
1700 size: Vec2::new(32.0, 32.0),
1701 uv_min: Vec2::new(0.0, 0.0),
1702 uv_max: Vec2::new(1.0, 1.0),
1703 color: [1.0, 1.0, 1.0, 1.0],
1704 ..Default::default()
1705 }],
1706 texts: Vec::new(),
1707 background: Some([0.0, 0.0, 1.0, 1.0]),
1708 overlay_color: None,
1709 chroma: 0.0,
1710 ..Default::default()
1711 };
1712 let geom = Arc::new(Mutex::new(FrameGeom {
1713 fit: (1.0, 0.0, 0.0),
1714 look: [0.0, 0.0],
1715 density: 1.0,
1716 viewport_px: [256.0, 256.0],
1717 roll: 0.0,
1718 pivot: [64.0, 64.0],
1719 }));
1720 let payload = GpuViewport {
1721 input: Arc::new(input.clone()),
1722 geom,
1723 batch_id: "test.viewport2d.main".to_string(),
1724 uploads: Arc::from(uploads),
1725 desc: BatchDesc {
1726 layer_size: 2,
1727 layers: 1,
1728 filter: TextureFilter::Nearest,
1729 ..Default::default()
1730 },
1731 overlay_separate: false,
1732 bg: FullscreenPass::new(
1733 "test.viewport2d.background",
1734 fullscreen::SOLID_WGSL,
1735 FullscreenDesc {
1736 texture_slots: 0,
1737 filter: TextureFilter::Nearest,
1738 },
1739 ),
1740 overlay: FullscreenPass::new(
1741 "test.viewport2d.overlay",
1742 fullscreen::SOLID_WGSL,
1743 FullscreenDesc {
1744 texture_slots: 0,
1745 filter: TextureFilter::Nearest,
1746 },
1747 ),
1748 };
1749 let scene = Scene {
1750 clear_color: Color::from_rgba(0, 0, 0, 255),
1751 nodes: vec![SceneNode::Callback {
1752 rect: Rect {
1753 x: 0.0,
1754 y: 0.0,
1755 w: 256.0,
1756 h: 256.0,
1757 },
1758 payload: Callback::new(payload),
1759 }],
1760 };
1761 let px = renderer
1762 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1763 .expect("offscreen render");
1764 let at = |x: u32, y: u32| -> [u8; 4] {
1765 let i = ((y * 256 + x) * 4) as usize;
1766 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1767 };
1768 assert_eq!(at(8, 8), [0, 0, 255, 255], "background fill");
1770 assert_eq!(at(247, 247), [0, 0, 255, 255], "background fill");
1771 assert_eq!(at(128, 128), [255, 0, 255, 255], "cam-centered quad");
1772 let overlay_input = FrameInput {
1773 overlay_color: Some([1.0, 0.0, 0.0, 1.0]),
1774 ..input
1775 };
1776 let overlay_payload = GpuViewport {
1777 input: Arc::new(overlay_input),
1778 geom: Arc::new(Mutex::new(FrameGeom {
1779 fit: (1.0, 0.0, 0.0),
1780 look: [0.0, 0.0],
1781 density: 1.0,
1782 viewport_px: [256.0, 256.0],
1783 roll: 0.0,
1784 pivot: [64.0, 64.0],
1785 })),
1786 batch_id: "test.viewport2d.overlay".to_string(),
1787 uploads: Arc::from(vec![AtlasUpload {
1788 page: 0,
1789 x: 0,
1790 y: 0,
1791 w: 2,
1792 h: 2,
1793 rgba: [255, 0, 255, 255].repeat(4),
1794 }]),
1795 desc: BatchDesc {
1796 layer_size: 2,
1797 layers: 1,
1798 filter: TextureFilter::Nearest,
1799 ..Default::default()
1800 },
1801 overlay_separate: false,
1802 bg: FullscreenPass::new(
1803 "test.viewport2d.background",
1804 fullscreen::SOLID_WGSL,
1805 FullscreenDesc {
1806 texture_slots: 0,
1807 filter: TextureFilter::Nearest,
1808 },
1809 ),
1810 overlay: FullscreenPass::new(
1811 "test.viewport2d.overlay",
1812 fullscreen::SOLID_WGSL,
1813 FullscreenDesc {
1814 texture_slots: 0,
1815 filter: TextureFilter::Nearest,
1816 },
1817 ),
1818 };
1819 let scene = Scene {
1820 clear_color: Color::from_rgba(0, 0, 0, 255),
1821 nodes: vec![SceneNode::Callback {
1822 rect: Rect {
1823 x: 0.0,
1824 y: 0.0,
1825 w: 256.0,
1826 h: 256.0,
1827 },
1828 payload: Callback::new(overlay_payload),
1829 }],
1830 };
1831 let px = renderer
1832 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1833 .expect("offscreen render");
1834 let at = |x: u32, y: u32| -> [u8; 4] {
1835 let i = ((y * 256 + x) * 4) as usize;
1836 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1837 };
1838 assert_eq!(at(8, 8), [255, 0, 0, 255], "overlay covers background");
1839 assert_eq!(at(128, 128), [255, 0, 0, 255], "overlay covers sprite");
1840 }
1841
1842 #[test]
1843 fn single_layer_atlas_draws_tinted_sprites() {
1844 assert_eq!(
1850 crate::batch::atlas_shape(1),
1851 (wgpu::TextureViewDimension::D2, false)
1852 );
1853 assert_eq!(
1854 crate::batch::atlas_shape(4),
1855 (wgpu::TextureViewDimension::D2Array, true)
1856 );
1857 assert!(
1858 crate::batch::sprite_shader(1).contains("texture_2d<")
1859 && !crate::batch::sprite_shader(1).contains("texture_2d_array"),
1860 "single-layer shader samples a 2d atlas"
1861 );
1862 assert!(
1863 crate::batch::sprite_shader(4).contains("texture_2d_array"),
1864 "multi-layer shader samples an array atlas"
1865 );
1866
1867 use repose_core::{Color, Rect, Scene, SceneNode};
1868 use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1869 let mut renderer = match OffscreenRenderer::new_blocking(64, 64, 1) {
1870 Ok(r) => r,
1871 Err(e) => {
1872 eprintln!("SKIP single-layer atlas test (no GPU): {e}");
1873 return;
1874 }
1875 };
1876 let input = FrameInput {
1879 cam: Camera2d {
1880 center: Vec2::new(32.0, 32.0),
1881 offset: Vec2::ZERO,
1882 units_per_pixel: 1.0,
1883 zoom: 1.0,
1884 roll: 0.0,
1885 fit: FitMode::Keep,
1886 },
1887 world_size: [64.0, 64.0],
1888 viewport_dp: [64.0, 64.0],
1889 sprites: vec![SpriteInstance {
1890 center: Vec2::new(32.0, 32.0),
1891 size: Vec2::new(32.0, 32.0),
1892 uv_min: Vec2::new(0.5 / 64.0, 0.5 / 64.0),
1893 uv_max: Vec2::new(0.5 / 64.0, 0.5 / 64.0),
1894 color: [1.0, 0.0, 0.0, 1.0],
1895 ..Default::default()
1896 }],
1897 background: Some([0.0, 0.0, 1.0, 1.0]),
1898 ..Default::default()
1899 };
1900 let geom = Arc::new(Mutex::new(FrameGeom {
1901 fit: (1.0, 0.0, 0.0),
1902 look: [0.0, 0.0],
1903 density: 1.0,
1904 viewport_px: [64.0, 64.0],
1905 roll: 0.0,
1906 pivot: [32.0, 32.0],
1907 }));
1908 let payload = GpuViewport {
1909 input: Arc::new(input),
1910 geom,
1911 batch_id: "test.viewport2d.single_layer".to_string(),
1912 uploads: Arc::from(vec![AtlasUpload {
1913 page: 0,
1914 x: 0,
1915 y: 0,
1916 w: 1,
1917 h: 1,
1918 rgba: vec![255, 255, 255, 255],
1919 }]),
1920 desc: BatchDesc {
1921 layer_size: 64,
1922 layers: 1,
1923 filter: TextureFilter::Linear,
1924 ..Default::default()
1925 },
1926 overlay_separate: false,
1927 bg: FullscreenPass::new(
1928 "test.viewport2d.single_layer.background",
1929 fullscreen::SOLID_WGSL,
1930 FullscreenDesc {
1931 texture_slots: 0,
1932 filter: TextureFilter::Nearest,
1933 },
1934 ),
1935 overlay: FullscreenPass::new(
1936 "test.viewport2d.single_layer.overlay",
1937 fullscreen::SOLID_WGSL,
1938 FullscreenDesc {
1939 texture_slots: 0,
1940 filter: TextureFilter::Nearest,
1941 },
1942 ),
1943 };
1944 let scene = Scene {
1945 clear_color: Color::from_rgba(0, 0, 0, 255),
1946 nodes: vec![SceneNode::Callback {
1947 rect: Rect {
1948 x: 0.0,
1949 y: 0.0,
1950 w: 64.0,
1951 h: 64.0,
1952 },
1953 payload: Callback::new(payload),
1954 }],
1955 };
1956 let px = renderer
1957 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1958 .expect("offscreen render");
1959 let i = ((32 * 64 + 32) * 4) as usize;
1960 assert_eq!(
1961 [px[i], px[i + 1], px[i + 2], px[i + 3]],
1962 [255, 0, 0, 255],
1963 "single-layer atlas sprite must draw its tint"
1964 );
1965 }
1966
1967 #[test]
1968 fn surface_dp_matches_board_point_under_shake() {
1969 let geom = FrameGeom {
1972 fit: (1.0, 100.0, 0.0),
1973 look: [0.0, 0.0],
1974 density: 1.25,
1975 viewport_px: [1250.0, 750.0],
1976 roll: 0.0,
1977 pivot: [400.0, 300.0],
1978 };
1979 let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
1980 assert!(
1981 (ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
1982 "offset, got ({ox},{oy})"
1983 );
1984 assert!(
1985 (w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
1986 "size, got ({w},{h})"
1987 );
1988 let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
1990 assert!(
1991 (ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
1992 "surface glued to board point"
1993 );
1994 let shaken = FrameGeom {
1995 look: [10.0, -10.0],
1996 ..geom
1997 };
1998 let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
1999 let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
2000 assert!(
2001 (sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
2002 "surface glued under shake"
2003 );
2004 }
2005
2006 #[test]
2007 fn contain_fit_has_no_silent_clamp() {
2008 for (canvas, world) in [
2009 ([8000.0, 6000.0], [80.0, 60.0]),
2010 ([80.0, 60.0], [8000.0, 6000.0]),
2011 ([1000.0, 750.0], [800.0, 600.0]),
2012 ] {
2013 let fit = contain_fit(canvas, world);
2014 let cam = [world[0] * 0.5, world[1] * 0.5];
2015 let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
2016 let back = dp_to_world(dp, world, cam, fit);
2017 assert!(
2018 (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
2019 "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
2020 );
2021 }
2022 assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
2023 assert_eq!(
2024 dp_to_world(
2025 [10.0, 10.0],
2026 [800.0, 600.0],
2027 [400.0, 300.0],
2028 (0.0, 0.0, 0.0)
2029 ),
2030 [400.0, 300.0]
2031 );
2032 }
2033
2034 #[test]
2037 fn fit_mode_keep_is_contain_fit() {
2038 for (canvas, world) in [
2039 ([1600.0, 900.0], [800.0, 800.0]),
2040 ([900.0, 1600.0], [800.0, 800.0]),
2041 ([1000.0, 1000.0], [800.0, 600.0]),
2042 ] {
2043 let cam = Camera2d {
2044 center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
2045 fit: FitMode::Keep,
2046 ..Default::default()
2047 };
2048 let fit = effective_fit(canvas, world, &cam);
2049 assert_eq!(fit, contain_fit(canvas, world), "{canvas:?}/{world:?}");
2050 let (s, ox, oy) = fit;
2051 assert!((ox - (canvas[0] - world[0] * s) * 0.5).abs() < 1e-4);
2053 assert!((oy - (canvas[1] - world[1] * s) * 0.5).abs() < 1e-4);
2054 assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
2055 }
2056 }
2057
2058 #[test]
2062 fn fit_mode_expand_reveals_more_and_never_crops() {
2063 let canvas = [1600.0, 900.0];
2064 let world = [800.0, 800.0];
2065 let cam = Camera2d {
2066 center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
2067 fit: FitMode::Expand,
2068 ..Default::default()
2069 };
2070 let (s, ox, oy) = effective_fit(canvas, world, &cam);
2071 let keep = effective_fit(
2072 canvas,
2073 world,
2074 &Camera2d {
2075 fit: FitMode::Keep,
2076 ..cam
2077 },
2078 );
2079 assert_eq!(s, keep.0, "expand must not change the uniform scale");
2080 assert_eq!((ox, oy), (0.0, 0.0), "expand anchors at the origin");
2081 assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
2083 let visible = [canvas[0] / s, canvas[1] / s];
2086 let exact = [
2087 (visible[0] - world[0]).abs() < 1e-3,
2088 (visible[1] - world[1]).abs() < 1e-3,
2089 ];
2090 assert!(exact[0] ^ exact[1], "one axis exact: {visible:?}");
2091 if exact[0] {
2092 assert!(visible[1] > world[1], "y reveals: {visible:?}");
2093 } else {
2094 assert!(visible[0] > world[0], "x reveals: {visible:?}");
2095 }
2096 let dp = world_to_dp([100.0, 700.0], world, cam.effective_center(), (s, ox, oy));
2098 let back = dp_to_world(dp, world, cam.effective_center(), (s, ox, oy));
2099 assert!((back[0] - 100.0).abs() < 1e-3 && (back[1] - 700.0).abs() < 1e-3);
2100 }
2101
2102 #[test]
2103 fn canvas_rotated_rect_matches_instance_rows() {
2104 let cam = Camera2d {
2108 center: Vec2::new(400.0, 300.0),
2109 offset: Vec2::ZERO,
2110 units_per_pixel: 1.0,
2111 zoom: 1.0,
2112 roll: 0.3,
2113 fit: FitMode::Keep,
2114 };
2115 let world_size = [800.0, 600.0];
2116 let canvas_dp = [800.0, 600.0];
2117 let fit = effective_fit(canvas_dp, world_size, &cam);
2118 let center = cam.effective_center();
2119 let project = |wx: f32, wy: f32| -> [f32; 2] {
2120 world_to_dp_with_roll([wx, wy], world_size, center, fit, cam.roll)
2121 };
2122 let k = fit.0;
2123 for (rotation, flip_x, flip_y, anchor) in [
2124 (0.7f32, false, false, [0.5, 0.5]),
2125 (0.7, true, false, [0.5, 0.5]),
2126 (0.7, false, true, [0.5, 0.5]),
2127 (0.7, true, true, [0.5, 0.5]),
2128 (-1.2, true, false, [0.0, 0.0]),
2129 (2.1, false, true, [1.0, 0.25]),
2130 (0.0, true, false, [0.5, 0.5]),
2131 ] {
2132 let size = [64.0f32, 40.0];
2133 let ctr = [400.0f32, 300.0];
2134 let (row0, row1) = batch::instance_rows(ctr, size, rotation, anchor, flip_x, flip_y);
2136 let mut expect = [[0.0f32; 2]; 4];
2137 for (i, corner) in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]
2138 .iter()
2139 .enumerate()
2140 {
2141 let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
2142 let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
2143 expect[i] = project(wx, wy);
2144 }
2145 let ax = if flip_x { 1.0 - anchor[0] } else { anchor[0] };
2149 let ay = if flip_y { 1.0 - anchor[1] } else { anchor[1] };
2150 let [px, py] = project(ctr[0], ctr[1]);
2151 let angle = cam.roll + rotation;
2152 let (c, s) = (angle.cos(), angle.sin());
2153 let (ox, oy) = (px - ax * size[0] * k, py - ay * size[1] * k);
2154 let w = size[0] * k;
2155 let h = size[1] * k;
2156 for (i, corner) in [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].iter().enumerate() {
2157 let rx = px + (ox + corner[0] - px) * c - (oy + corner[1] - py) * s;
2158 let ry = py + (ox + corner[0] - px) * s + (oy + corner[1] - py) * c;
2159 let mut best = f32::INFINITY;
2160 for e in expect {
2161 best = best.min((rx - e[0]).hypot(ry - e[1]));
2162 }
2163 assert!(
2164 best < 1e-2,
2165 "rot={rotation} flip=({flip_x},{flip_y}) anchor={anchor:?}: corner {i} ({rx},{ry}) matches none of {expect:?}"
2166 );
2167 }
2168 }
2169 }
2170
2171 #[test]
2172 #[allow(deprecated)]
2173 fn camera_guards_div_by_zero() {
2174 let bad = Camera2d {
2175 center: Vec2::ZERO,
2176 offset: Vec2::ZERO,
2177 units_per_pixel: 0.0,
2178 zoom: 0.0,
2179 roll: 0.0,
2180 fit: FitMode::Keep,
2181 };
2182 let m = bad.view_proj([0.0, 0.0]);
2183 assert!(m.is_finite(), "view_proj stays finite, got {m:?}");
2184 assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
2185 }
2186
2187 #[test]
2188 fn fit_matrix_canvas_gpu_picks_agree() {
2189 let cam = Camera2d {
2190 center: Vec2::new(410.0, 290.0),
2191 offset: Vec2::ZERO,
2192 units_per_pixel: 1.0,
2193 zoom: 2.0,
2194 roll: 0.0,
2195 fit: FitMode::Keep,
2196 };
2197 let world_size = [800.0, 600.0];
2198 let density = 1.25;
2199 let vp_phys = [1250.0, 750.0];
2200 let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
2201 let cam_center = cam.effective_center();
2202 let fit = effective_fit(canvas_dp, world_size, &cam);
2203 assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
2204 let vp = cam.fit_matrix(canvas_dp, world_size);
2205 assert!(vp.is_finite());
2206 for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
2207 let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
2208 let px = [dx * density, dy * density];
2209 let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
2210 let gpu_px = [
2211 (ndc.x + 1.0) * 0.5 * vp_phys[0],
2212 (1.0 - ndc.y) * 0.5 * vp_phys[1],
2213 ];
2214 assert!(
2215 (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
2216 "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
2217 );
2218 let back =
2219 cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
2220 assert!(
2221 (back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
2222 "pick round trip for {world:?} gave {back:?}"
2223 );
2224 }
2225 }
2226
2227 #[test]
2228 fn letterbox_pick_matches_draw() {
2229 let cam = Camera2d {
2231 center: Vec2::new(400.0, 400.0),
2232 ..Default::default()
2233 };
2234 let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
2235 let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
2236 let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
2237 assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
2238 let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
2240 let plain = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
2241 let dm = m - plain;
2242 let max = dm
2243 .to_cols_array()
2244 .iter()
2245 .fold(0.0f32, |a, b| a.max(b.abs()));
2246 assert!(max < 1e-5);
2247 }
2248
2249 #[test]
2250 fn picks_ignore_viewport_origin() {
2251 let geom = FrameGeom {
2252 fit: (1.0, 100.0, 0.0),
2253 look: [0.0, 0.0],
2254 density: 1.25,
2255 viewport_px: [1250.0, 750.0],
2256 roll: 0.0,
2257 pivot: [400.0, 300.0],
2258 };
2259 let world_size = [800.0, 600.0];
2260 let a = pick_world([125.0, 75.0], geom, world_size);
2261 let b = pick_world([125.0, 75.0], geom, world_size);
2262 assert_eq!(a, b);
2263 let [wx, wy] = pick_world([125.0, 75.0], geom, world_size);
2264 assert!(
2265 (wx - 0.0).abs() < 1e-4 && (wy - 60.0).abs() < 1e-4,
2266 "local pick, got ({wx},{wy})"
2267 );
2268 }
2269
2270 #[test]
2271 fn offset_shifts_look_and_draw_together() {
2272 let world_size = [800.0, 600.0];
2275 let canvas_dp = [1000.0, 600.0];
2276 let mut cam = Camera2d {
2277 center: Vec2::new(400.0, 300.0),
2278 offset: Vec2::new(10.0, -10.0),
2279 units_per_pixel: 1.0,
2280 zoom: 1.0,
2281 roll: 0.0,
2282 fit: FitMode::Keep,
2283 };
2284 assert_eq!(cam.effective_center(), [410.0, 290.0]);
2285 let fit = effective_fit(canvas_dp, world_size, &cam);
2286 let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
2287 let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
2288 assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
2289 cam.offset = Vec2::ZERO;
2290 let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
2291 assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
2293 assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
2294 }
2295
2296 #[test]
2297 fn touch_gate_and_screen_space() {
2298 use repose_core::Vec2 as RVec2;
2299 use repose_core::input::{
2300 Modifiers, PointerButton, PointerEvent, PointerEventKind, PointerId, PointerKind,
2301 };
2302 let ev_of = |kind: PointerKind| {
2303 let mut ev = PointerEvent::new(
2304 PointerId(3),
2305 kind,
2306 PointerEventKind::Down(PointerButton::Primary),
2307 RVec2 { x: 15.0, y: 27.0 },
2308 1.0,
2309 Modifiers::default(),
2310 );
2311 ev.origin = RVec2 { x: 5.0, y: 7.0 };
2312 ev.position = RVec2 { x: 10.0, y: 20.0 };
2313 ev
2314 };
2315 assert!(is_touch(&ev_of(PointerKind::Touch)));
2317 assert!(is_touch(&ev_of(PointerKind::Pen)));
2318 assert!(!is_touch(&ev_of(PointerKind::Mouse)));
2319 let ev = ev_of(PointerKind::Touch);
2323 assert_eq!(ev.origin + ev.position, ev.position_in_window());
2324 assert_eq!(screen_of(&ev), [15.0, 27.0]);
2325 }
2326
2327 #[test]
2328 fn presses_track_per_pointer_id() {
2329 use std::cell::RefCell;
2330 use std::collections::HashMap;
2331 use std::rc::Rc;
2332 let presses: Rc<RefCell<HashMap<u64, [f32; 2]>>> = Rc::new(RefCell::new(HashMap::new()));
2333 let down = presses.clone();
2334 let up = presses.clone();
2335 let leave = presses.clone();
2336 let press_down = move |id: u64, at: [f32; 2]| {
2337 down.borrow_mut().insert(id, at);
2338 };
2339 let press_up = move |id: u64| up.borrow_mut().remove(&id);
2340 let press_leave = move |id: u64| {
2341 leave.borrow_mut().remove(&id);
2342 };
2343 press_down(1, [10.0, 10.0]);
2344 press_down(2, [200.0, 200.0]);
2345 assert_eq!(press_up(1), Some([10.0, 10.0]));
2346 assert_eq!(
2347 presses.borrow().get(&2),
2348 Some(&[200.0, 200.0]),
2349 "second finger press survives first finger lift"
2350 );
2351 press_leave(2);
2352 assert!(presses.borrow().is_empty());
2353 }
2354
2355 #[test]
2356 fn click_slop_cancels_drags() {
2357 assert!(click_within_slop([100.0, 100.0], [105.0, 105.0]));
2358 assert!(click_within_slop_dp([100.0, 100.0], [105.0, 105.0], 1.0));
2359 assert!(!click_within_slop_dp(
2360 [100.0, 100.0],
2361 [100.0 + CLICK_SLOP_DP + 1.0, 100.0],
2362 1.0
2363 ));
2364 }
2365
2366 #[test]
2367 fn click_slop_is_density_independent() {
2368 assert!(click_within_slop_dp([0.0, 0.0], [36.0, 0.0], 3.0));
2370 assert!(!click_within_slop_dp([0.0, 0.0], [37.0, 0.0], 3.0));
2371 assert!(click_within_slop_dp([0.0, 0.0], [12.0, 0.0], 1.0));
2372 assert!(!click_within_slop_dp([0.0, 0.0], [13.0, 0.0], 1.0));
2373 }
2374
2375 #[test]
2376 fn aim_ignores_shake_while_pick_follows_it() {
2377 use super::{aim_world_unshaken, pick_world};
2378 let world_size = [800.0, 600.0];
2379 let canvas_dp = [800.0, 600.0];
2380 let mut cam = Camera2d {
2381 center: Vec2::new(400.0, 300.0),
2382 offset: Vec2::new(10.0, -5.0),
2383 units_per_pixel: 1.0,
2384 zoom: 1.0,
2385 roll: 0.0,
2386 fit: FitMode::Keep,
2387 };
2388 let fit = effective_fit(canvas_dp, world_size, &cam);
2389 let shaken = FrameGeom {
2390 fit,
2391 look: [
2392 cam.effective_center()[0] - world_size[0] * 0.5,
2393 cam.effective_center()[1] - world_size[1] * 0.5,
2394 ],
2395 density: 1.0,
2396 viewport_px: [800.0, 600.0],
2397 roll: 0.0,
2398 pivot: cam.effective_center(),
2399 };
2400 let px = [400.0, 300.0];
2401 let pick = pick_world(px, shaken, world_size);
2402 let aim = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
2403 assert!(
2404 (pick[0] - aim[0] - 10.0).abs() < 1e-3 && (pick[1] - aim[1] + 5.0).abs() < 1e-3,
2405 "pick follows shake, aim does not: pick {pick:?} aim {aim:?}"
2406 );
2407 cam.offset = Vec2::ZERO;
2408 let aim_still = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
2409 assert!(
2410 (aim[0] - aim_still[0]).abs() < 1e-3 && (aim[1] - aim_still[1]).abs() < 1e-3,
2411 "aim independent of shake: {aim:?} vs {aim_still:?}"
2412 );
2413 }
2414
2415 #[test]
2416 fn roll_roundtrips_and_moves_pixels() {
2417 let cam = Camera2d {
2418 center: Vec2::new(400.0, 300.0),
2419 offset: Vec2::ZERO,
2420 units_per_pixel: 1.0,
2421 zoom: 1.0,
2422 roll: std::f32::consts::FRAC_PI_2,
2423 fit: FitMode::Keep,
2424 };
2425 let world = [800.0, 600.0];
2426 let dp = [1000.0, 600.0];
2427 let fit = effective_fit(dp, world, &cam);
2428 for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
2429 let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
2430 let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
2431 assert!(
2432 (back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
2433 "roll roundtrip for {p:?} gave {back:?}"
2434 );
2435 }
2436 let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
2437 let rolled =
2438 world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
2439 assert!(
2440 (plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
2441 "roll must move pixels, plain {plain:?} rolled {rolled:?}"
2442 );
2443 let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
2444 let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
2445 assert_eq!(m0, legacy);
2446 }
2447
2448 #[test]
2449 fn surface_tracks_roll_about_pivot() {
2450 let geom = FrameGeom {
2451 fit: (1.0, 0.0, 0.0),
2452 look: [0.0, 0.0],
2453 density: 1.0,
2454 viewport_px: [800.0, 600.0],
2455 roll: std::f32::consts::FRAC_PI_2,
2456 pivot: [400.0, 300.0],
2457 };
2458 let ([ox, oy], _) = surface_dp([500.0, 300.0], [10.0, 10.0], geom);
2459 assert!(
2460 (ox - 395.0).abs() < 1e-3 && (oy - 395.0).abs() < 1e-3,
2461 "rolled surface, got ({ox},{oy})"
2462 );
2463 }
2464
2465 #[test]
2466 fn geom_handle_compat_wraps_legacy_handles() {
2467 use std::cell::Cell;
2468 use std::rc::Rc;
2469 let rc = Rc::new(Cell::new(FrameGeom {
2470 fit: (2.0, 1.0, 2.0),
2471 ..Default::default()
2472 }));
2473 let h: GeomHandle = rc.into();
2474 assert_eq!(h.get().fit, (2.0, 1.0, 2.0));
2475 let arc = Arc::new(Mutex::new(FrameGeom::default()));
2476 let h2: GeomHandle = arc.into();
2477 assert_eq!(h2.get().density, 1.0);
2478 }
2479}
2480
2481#[derive(Clone, Debug, PartialEq, Eq)]
2483pub struct CursorFrame {
2484 pub rgba: Vec<u8>,
2485 pub size: [u16; 2],
2486 pub hotspot: [u16; 2],
2487 pub key: (i32, [u32; 3], u64, u32),
2488}
2489
2490pub fn cursor_frame(
2491 strip: &[u8],
2492 strip_w: u32,
2493 strip_h: u32,
2494 frames: u32,
2495 frame: i32,
2496 cell: (u32, u32),
2497 origin: (f32, f32),
2498 tint: [f32; 4],
2499 mag: u32,
2500 strip_hash: u64,
2501) -> Option<CursorFrame> {
2502 let frames = frames.max(1) as i32;
2503 let frame = frame.clamp(0, frames - 1);
2504 let (cw, ch) = (cell.0.max(1), cell.1.max(1));
2505 if strip_w < cw * (frame as u32 + 1) || strip_h < ch {
2506 return None;
2507 }
2508 let mag = mag.clamp(1, 8);
2509 let tint_u8 = [
2510 (tint[0].clamp(0.0, 1.0) * 255.0) as u32,
2511 (tint[1].clamp(0.0, 1.0) * 255.0) as u32,
2512 (tint[2].clamp(0.0, 1.0) * 255.0) as u32,
2513 ];
2514 let key = (frame, tint_u8, strip_hash, mag);
2515 let fx = (frame as u32 * cw) as usize;
2516 let (dw, dh) = (cw * mag, ch * mag);
2517 if dw > 2048 || dh > 2048 {
2518 return None;
2519 }
2520 let mut px = Vec::with_capacity((dw * dh * 4) as usize);
2521 for row in 0..ch {
2522 let start = ((row * strip_w) as usize + fx) * 4;
2523 let end = start + (cw as usize) * 4;
2524 let cell = strip.get(start..end)?;
2525 let mut mag_row = Vec::with_capacity((cw * mag * 4) as usize);
2526 for pix in cell.chunks_exact(4) {
2527 let t = [
2528 (pix[0] as u32 * tint_u8[0] / 255) as u8,
2529 (pix[1] as u32 * tint_u8[1] / 255) as u8,
2530 (pix[2] as u32 * tint_u8[2] / 255) as u8,
2531 pix[3],
2532 ];
2533 for _ in 0..mag {
2534 mag_row.extend_from_slice(&t);
2535 }
2536 }
2537 for _ in 0..mag {
2538 px.extend_from_slice(&mag_row);
2539 }
2540 }
2541 let (w16, h16) = (dw.min(2048) as u16, dh.min(2048) as u16);
2542 Some(CursorFrame {
2543 rgba: px,
2544 size: [w16, h16],
2545 hotspot: [
2546 (origin.0 * mag as f32)
2547 .round()
2548 .clamp(0.0, w16.saturating_sub(1) as f32) as u16,
2549 (origin.1 * mag as f32)
2550 .round()
2551 .clamp(0.0, h16.saturating_sub(1) as f32) as u16,
2552 ],
2553 key,
2554 })
2555}
2556
2557pub fn unproject_px(
2559 px: Vec2,
2560 viewport_dp: [f32; 2],
2561 world_size: [f32; 2],
2562 density: f32,
2563 cam: &Camera2d,
2564) -> Vec2 {
2565 let d = if density.is_finite() && density > 1e-6 {
2566 density
2567 } else {
2568 1.0
2569 };
2570 let dp = [px.x / d, px.y / d];
2571 cam.dp_to_world_pt(viewport_dp, world_size, dp)
2572}
2573
2574#[cfg(test)]
2575mod unproject_tests {
2576 use super::*;
2577
2578 #[test]
2579 fn px_round_trips_through_fit() {
2580 let cam = Camera2d {
2581 center: Vec2::new(213.0, 120.0),
2582 offset: Vec2::ZERO,
2583 units_per_pixel: 1.0 / 3.0,
2584 zoom: 1.0,
2585 roll: 0.0,
2586 fit: FitMode::Keep,
2587 };
2588 let world = Vec2::new(100.0, 60.0);
2589 let viewport_dp = [1024.0, 600.0];
2590 let world_size = [1024.0, 600.0];
2591 let (s, ox, oy) = effective_fit(viewport_dp, world_size, &cam);
2592 let [dx, dy] = world_to_dp(
2593 [world.x, world.y],
2594 world_size,
2595 cam.effective_center(),
2596 (s, ox, oy),
2597 );
2598 let px = Vec2::new(dx * 1.25, dy * 1.25);
2599 let back = unproject_px(px, viewport_dp, world_size, 1.25, &cam);
2600 assert!(
2601 (back - world).length() < 0.01,
2602 "round trip, got {back:?} for {world:?}"
2603 );
2604 }
2605}