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, instance_rows, screen_camera};
25
26#[derive(Clone, Copy, Debug)]
29pub struct Camera2d {
30 pub center: Vec2,
32 pub units_per_pixel: f32,
35 pub zoom: f32,
36}
37
38impl Default for Camera2d {
39 fn default() -> Self {
40 Self {
41 center: Vec2::ZERO,
42 units_per_pixel: 1.0,
43 zoom: 1.0,
44 }
45 }
46}
47
48impl Camera2d {
49 pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
50 let w = viewport_px[0] * self.units_per_pixel / self.zoom;
51 let h = viewport_px[1] * self.units_per_pixel / self.zoom;
52 let proj = glam::camera::rh::proj::directx::orthographic(
54 -w / 2.0,
55 w / 2.0,
56 -h / 2.0,
57 h / 2.0,
58 -1000.0,
59 1000.0,
60 );
61 let view = Mat4::from_translation(Vec3::new(-self.center.x, -self.center.y, 0.0));
62 proj * view
63 }
64
65 pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
67 let ndc = Vec2::new(
68 (px[0] / viewport_px[0]) * 2.0 - 1.0,
69 1.0 - (px[1] / viewport_px[1]) * 2.0,
70 );
71 let inv = self.view_proj(viewport_px).inverse();
72 let world = inv.project_point3(ndc.extend(0.0));
73 Vec2::new(world.x, world.y)
74 }
75}
76
77#[derive(Clone, Copy, Debug)]
79pub struct SpriteInstance {
80 pub center: Vec2,
82 pub rotation: f32,
83 pub size: Vec2,
84 pub anchor: Vec2,
88 pub flip_x: bool,
92 pub flip_y: bool,
93 pub uv_min: Vec2,
94 pub uv_max: Vec2,
95 pub color: [f32; 4],
98 pub page: u32,
100}
101
102impl Default for SpriteInstance {
103 fn default() -> Self {
104 Self {
105 center: Vec2::ZERO,
106 rotation: 0.0,
107 size: Vec2::ONE,
108 anchor: Vec2::new(0.5, 0.5),
109 flip_x: false,
110 flip_y: false,
111 uv_min: Vec2::ZERO,
112 uv_max: Vec2::ONE,
113 color: [1.0, 1.0, 1.0, 1.0],
114 page: 0,
115 }
116 }
117}
118
119#[derive(Clone, Debug, Default)]
121pub struct WorldText {
122 pub text: String,
123 pub pos: Vec2,
125 pub color: [f32; 4],
127 pub size: f32,
129}
130
131#[derive(Clone, Default, Debug)]
133pub struct FrameInput {
134 pub cam: Camera2d,
135 pub world_size: [f32; 2],
140 pub viewport_px: [f32; 2],
141 pub sprites: Vec<SpriteInstance>,
142 pub texts: Vec<WorldText>,
144 pub background: Option<[f32; 4]>,
146 pub overlay_color: Option<[f32; 4]>,
149}
150
151#[derive(Clone, Debug)]
153pub enum PickEvent {
154 Click { world: Vec2, screen: [f32; 2] },
155 Hover { world: Vec2 },
156}
157
158pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
161 if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
162 return (1.0, 0.0, 0.0);
163 }
164 let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
165 let s = s.clamp(0.1, 8.0);
166 (
167 s,
168 (canvas_dp[0] - world[0] * s) * 0.5,
169 (canvas_dp[1] - world[1] * s) * 0.5,
170 )
171}
172
173pub fn world_to_dp(
177 world: [f32; 2],
178 world_size: [f32; 2],
179 cam_center: [f32; 2],
180 fit: (f32, f32, f32),
181) -> [f32; 2] {
182 let (s, ox, oy) = fit;
183 [
184 ox + (world[0] - (cam_center[0] - world_size[0] * 0.5)) * s,
185 oy + (world[1] - (cam_center[1] - world_size[1] * 0.5)) * s,
186 ]
187}
188
189pub fn dp_to_world(
191 dp: [f32; 2],
192 world_size: [f32; 2],
193 cam_center: [f32; 2],
194 fit: (f32, f32, f32),
195) -> [f32; 2] {
196 let (s, ox, oy) = fit;
197 [
198 (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
199 (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
200 ]
201}
202
203#[derive(Clone, Copy, Debug)]
209pub struct FrameGeom {
210 pub fit: (f32, f32, f32),
212 pub look: [f32; 2],
215 pub density: f32,
217 pub viewport_px: [f32; 2],
219}
220
221impl Default for FrameGeom {
222 fn default() -> Self {
223 Self {
224 fit: (1.0, 0.0, 0.0),
225 look: [0.0, 0.0],
226 density: 1.0,
227 viewport_px: [1.0, 1.0],
228 }
229 }
230}
231
232pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
235 let (s, ox, oy) = geom.fit;
236 (
237 [
238 ox + (center[0] - size[0] * 0.5 - geom.look[0]) * s,
239 oy + (center[1] - size[1] * 0.5 - geom.look[1]) * s,
240 ],
241 [size[0] * s, size[1] * s],
242 )
243}
244
245fn rgba8(c: [f32; 4]) -> Color {
246 Color::from_rgba(
247 (c[0].clamp(0.0, 1.0) * 255.0) as u8,
248 (c[1].clamp(0.0, 1.0) * 255.0) as u8,
249 (c[2].clamp(0.0, 1.0) * 255.0) as u8,
250 (c[3].clamp(0.0, 1.0) * 255.0) as u8,
251 )
252}
253
254#[allow(non_snake_case)] pub fn Viewport2d(
267 input: FrameInput,
268 geom_out: Rc<Cell<FrameGeom>>,
269 on_event: impl Fn(PickEvent) + 'static,
270) -> View {
271 let input = Rc::new(input);
272 let world_size = input.world_size;
273 let cam_center = [input.cam.center.x, input.cam.center.y];
274 let pick_geom = geom_out.clone();
275 let draw_input = input.clone();
276 let draw_geom = geom_out.clone();
277
278 let modifier = Modifier::new().fill_max_size().on_pointer_down(
279 move |ev: repose_core::input::PointerEvent| {
280 let w = ev.position_in_window();
283 let g = pick_geom.get();
284 let world = dp_to_world(
285 [w.x / g.density, w.y / g.density],
286 world_size,
287 [
288 g.look[0] + world_size[0] * 0.5,
289 g.look[1] + world_size[1] * 0.5,
290 ],
291 g.fit,
292 );
293 on_event(PickEvent::Click {
294 world: Vec2::new(world[0], world[1]),
295 screen: [w.x, w.y],
296 });
297 },
298 );
299 Canvas(modifier, move |scope: &mut DrawScope| {
300 let d = effective_density_scale();
304 let fit = contain_fit(
305 [scope.size.width / d, scope.size.height / d],
306 draw_input.world_size,
307 );
308 draw_geom.set(FrameGeom {
309 fit,
310 look: [
311 cam_center[0] - draw_input.world_size[0] * 0.5,
312 cam_center[1] - draw_input.world_size[1] * 0.5,
313 ],
314 density: d,
315 viewport_px: [scope.size.width, scope.size.height],
316 });
317 let s = fit.0 * d;
318 let project = |wx: f32, wy: f32| -> [f32; 2] {
319 let [dx, dy] = world_to_dp(
320 [wx, wy],
321 draw_input.world_size,
322 cam_center,
323 (fit.0, fit.1, fit.2),
324 );
325 [dx * d, dy * d]
326 };
327 if let Some(bg) = draw_input.background {
328 scope.draw_rect(
329 Rect {
330 x: 0.0,
331 y: 0.0,
332 w: scope.size.width,
333 h: scope.size.height,
334 },
335 rgba8(bg),
336 0.0,
337 );
338 }
339 for spr in draw_input.sprites.iter() {
340 let [tx, ty] = project(
343 spr.center.x - spr.anchor.x * spr.size.x,
344 spr.center.y - spr.anchor.y * spr.size.y,
345 );
346 scope.draw_rect(
347 Rect {
348 x: tx,
349 y: ty,
350 w: spr.size.x * s,
351 h: spr.size.y * s,
352 },
353 rgba8(spr.color),
354 0.0,
355 );
356 }
357 for t in draw_input.texts.iter() {
358 let [tx, ty] = project(t.pos.x, t.pos.y);
359 scope.draw_text(
360 t.text.clone(),
361 repose_core::Vec2 { x: tx, y: ty },
362 rgba8(t.color),
363 t.size * s,
364 );
365 }
366 if let Some(tint) = draw_input.overlay_color {
367 scope.draw_rect(
368 Rect {
369 x: 0.0,
370 y: 0.0,
371 w: scope.size.width,
372 h: scope.size.height,
373 },
374 rgba8(tint),
375 0.0,
376 );
377 }
378 })
379}
380
381#[allow(non_snake_case)]
396pub fn Viewport2dGpu(
397 input: FrameInput,
398 geom_out: Arc<Mutex<FrameGeom>>,
399 uploads: Vec<AtlasUpload>,
400 desc: BatchDesc,
401 on_event: impl Fn(PickEvent) + 'static,
402) -> View {
403 let input = Arc::new(input);
404 let world_size = input.world_size;
405 let pick_geom = geom_out.clone();
406 let payload = GpuViewport {
407 input: input.clone(),
408 geom: geom_out,
409 uploads,
410 desc,
411 };
412 let modifier = Modifier::new().fill_max_size().on_pointer_down(
413 move |ev: repose_core::input::PointerEvent| {
414 let w = ev.position_in_window();
415 let Ok(g) = pick_geom.lock() else {
416 return;
417 };
418 let world = dp_to_world(
419 [w.x / g.density, w.y / g.density],
420 world_size,
421 [
422 g.look[0] + world_size[0] * 0.5,
423 g.look[1] + world_size[1] * 0.5,
424 ],
425 g.fit,
426 );
427 on_event(PickEvent::Click {
428 world: Vec2::new(world[0], world[1]),
429 screen: [w.x, w.y],
430 });
431 },
432 );
433 Embedded(modifier, Callback::new(payload))
434}
435
436struct GpuViewport {
437 input: Arc<FrameInput>,
438 geom: Arc<Mutex<FrameGeom>>,
439 uploads: Vec<AtlasUpload>,
440 desc: BatchDesc,
441}
442
443impl WgpuCallback for GpuViewport {
444 fn prepare(
445 &self,
446 device: &wgpu::Device,
447 queue: &wgpu::Queue,
448 encoder: &mut wgpu::CommandEncoder,
449 screen: &ScreenDescriptor,
450 resources: &mut CallbackResources,
451 ) -> Vec<wgpu::CommandBuffer> {
452 let vp = self
453 .geom
454 .lock()
455 .map(|g| g.viewport_px)
456 .unwrap_or([1.0, 1.0]);
457 let vp = if vp[0] > 1.0 && vp[1] > 1.0 {
458 vp
459 } else {
460 self.input.viewport_px
461 };
462 let mut batch = SpriteBatch::new(self.desc);
463 batch.set_camera(screen_camera(vp));
464 for s in &self.input.sprites {
465 batch.push_sprite(s);
466 }
467 batch.extend_uploads(self.uploads.clone());
468 batch.prepare(device, queue, encoder, screen, resources)
469 }
470
471 fn paint(
472 &self,
473 info: repose_core::PaintCallbackInfo,
474 rpass: &mut wgpu::RenderPass<'static>,
475 resources: &CallbackResources,
476 ) {
477 let d = info.pixels_per_point.max(0.0001);
479 let vp = [info.viewport.w, info.viewport.h];
480 let fit = contain_fit([vp[0] / d, vp[1] / d], self.input.world_size);
481 let cam = [self.input.cam.center.x, self.input.cam.center.y];
482 if let Ok(mut g) = self.geom.lock() {
483 *g = FrameGeom {
484 fit,
485 look: [
486 cam[0] - self.input.world_size[0] * 0.5,
487 cam[1] - self.input.world_size[1] * 0.5,
488 ],
489 density: d,
490 viewport_px: vp,
491 };
492 }
493 draw_batch(rpass, resources);
494 }
495}
496
497#[allow(non_snake_case)]
504pub fn ActorFrame(
505 center: [f32; 2],
506 size: [f32; 2],
507 geom: Rc<Cell<FrameGeom>>,
508 mirror_x: bool,
509 child: View,
510) -> View {
511 let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
512 let mut modifier = Modifier::new()
515 .size(w, h)
516 .absolute()
517 .offset(Some(ox), Some(oy), None, None);
518 if mirror_x {
519 modifier = modifier.scale2(-1.0, 1.0);
520 }
521 UiBox(modifier).child(child)
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use repose_core::locals::{Density, set_density_default};
528
529 #[test]
530 fn contain_fit_roundtrip_at_density_125() {
531 set_density_default(Density { scale: 1.25 });
534 let d = effective_density_scale();
535 let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
537 let cam = [400.0, 300.0];
538 let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
540 let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
542 set_density_default(Density { scale: 1.0 });
543 assert!(
544 (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
545 "dp fit, got {fit:?}"
546 );
547 assert!(
548 (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
549 "draw forward, got ({dx},{dy})"
550 );
551 assert!(
552 (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
553 "pick inverse, got ({wx},{wy})"
554 );
555 let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
557 let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
558 assert!(
559 (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
560 "shifted round trip, got {back:?}"
561 );
562 }
563
564 #[test]
565 fn surface_dp_matches_board_point_under_shake() {
566 let geom = FrameGeom {
570 fit: (1.0, 100.0, 0.0),
571 look: [0.0, 0.0],
572 density: 1.25,
573 viewport_px: [1250.0, 750.0],
574 };
575 let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
576 assert!(
577 (ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
578 "offset, got ({ox},{oy})"
579 );
580 assert!(
581 (w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
582 "size, got ({w},{h})"
583 );
584 let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
586 assert!(
587 (ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
588 "surface glued to board point"
589 );
590 let shaken = FrameGeom {
591 look: [10.0, -10.0],
592 ..geom
593 };
594 let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
595 let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
596 assert!(
597 (sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
598 "surface glued under shake"
599 );
600 }
601}