use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use glam::{Mat4, Vec2, Vec3};
use repose_canvas::{Canvas, DrawScope, Embedded};
use repose_core::locals::effective_density_scale;
use repose_core::{Color, Dp, Modifier, Px, Rect, View};
use repose_render_wgpu::{
Callback, CallbackRenderPass, CallbackResources, ScreenDescriptor, WgpuCallback,
};
use repose_ui::Box as UiBox;
use repose_ui::ViewExt;
pub mod batch;
pub use batch::{
AtlasUpload, BatchDesc, SpriteBatch, TextureFilter, draw_batch, draw_batch_with_id, frame_uv,
instance_rows, screen_camera, sprite_aabb,
};
pub mod fullscreen;
pub use fullscreen::{
FullscreenDesc, FullscreenPass, FullscreenTexture, FullscreenTextureRef,
FullscreenTextureUpload,
};
pub mod post;
pub mod sprite_image;
pub use sprite_image::{
SpriteImage, SpriteImageAsset, SpriteImageDef, SpriteImageError, SpriteImageFrameDef,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FitMode {
#[default]
Keep,
Expand,
}
#[derive(Clone, Copy, Debug)]
pub struct Camera2d {
pub center: Vec2,
pub offset: Vec2,
pub units_per_pixel: f32,
pub zoom: f32,
pub roll: f32,
pub fit: FitMode,
}
impl Default for Camera2d {
fn default() -> Self {
Self {
center: Vec2::ZERO,
offset: Vec2::ZERO,
units_per_pixel: 1.0,
zoom: 1.0,
roll: 0.0,
fit: FitMode::Keep,
}
}
}
impl Camera2d {
pub fn effective_center(&self) -> [f32; 2] {
[self.center.x + self.offset.x, self.center.y + self.offset.y]
}
pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
let fit = effective_fit(canvas_dp, world_size, self);
fit_view_proj_with_roll(
canvas_dp,
world_size,
self.effective_center(),
fit,
self.roll,
)
}
pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
let fit = effective_fit(canvas_dp, world_size, self);
let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
Vec2::new(w[0], w[1])
}
pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
let vp_w = viewport_px[0].max(1.0);
let vp_h = viewport_px[1].max(1.0);
let zoom = sane_positive(self.zoom);
let upp = sane_positive(self.units_per_pixel);
let w = vp_w * upp / zoom;
let h = vp_h * upp / zoom;
let proj = glam::camera::rh::proj::directx::orthographic(
-w / 2.0,
w / 2.0,
h / 2.0,
-h / 2.0,
-1000.0,
1000.0,
);
let view = Mat4::from_translation(Vec3::new(
-(self.center.x + self.offset.x),
-(self.center.y + self.offset.y),
0.0,
));
proj * view
}
#[deprecated(
since = "0.1.8",
note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
)]
pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
self.view_proj_screen(viewport_px)
}
#[deprecated(
since = "0.1.8",
note = "ignores world_size; use pick_world over the painted FrameGeom instead"
)]
#[allow(deprecated)]
pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
let [cx, cy] = self.effective_center();
return Vec2::new(cx, cy);
}
let ndc = Vec2::new(
(px[0] / viewport_px[0]) * 2.0 - 1.0,
1.0 - (px[1] / viewport_px[1]) * 2.0,
);
let inv = self.view_proj(viewport_px).inverse();
let world = inv.project_point3(ndc.extend(0.0));
Vec2::new(world.x, world.y)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SpriteBlend {
#[default]
Alpha,
Additive,
Multiply,
}
#[derive(Clone, Copy, Debug)]
pub struct SpriteInstance {
pub center: Vec2,
pub rotation: f32,
pub size: Vec2,
pub anchor: Vec2,
pub flip_x: bool,
pub flip_y: bool,
pub uv_min: Vec2,
pub uv_max: Vec2,
pub color: [f32; 4],
pub page: u32,
pub z: f32,
pub blend: SpriteBlend,
}
impl Default for SpriteInstance {
fn default() -> Self {
Self {
center: Vec2::ZERO,
rotation: 0.0,
size: Vec2::ONE,
anchor: Vec2::new(0.5, 0.5),
flip_x: false,
flip_y: false,
uv_min: Vec2::ZERO,
uv_max: Vec2::ONE,
color: [1.0, 1.0, 1.0, 1.0],
page: 0,
z: 0.0,
blend: SpriteBlend::Alpha,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct WorldText {
pub text: String,
pub pos: Vec2,
pub color: [f32; 4],
pub size: f32,
}
#[derive(Clone, Default, Debug)]
pub struct FrameInput {
pub cam: Camera2d,
pub world_size: [f32; 2],
pub viewport_dp: [f32; 2],
pub sprites: Vec<SpriteInstance>,
pub texts: Vec<WorldText>,
pub background: Option<[f32; 4]>,
pub overlay_color: Option<[f32; 4]>,
pub chroma: f32,
}
#[derive(Clone, Debug)]
pub enum PickEvent {
Press {
world: Vec2,
screen: [f32; 2],
button: repose_core::input::PointerButton,
},
Click {
world: Vec2,
screen: [f32; 2],
button: repose_core::input::PointerButton,
},
Hover {
world: Vec2,
screen: [f32; 2],
},
TouchDown { id: u64, screen: [f32; 2] },
TouchMove { id: u64, screen: [f32; 2] },
TouchUp { id: u64 },
}
fn is_touch(ev: &repose_core::input::PointerEvent) -> bool {
matches!(
ev.kind,
repose_core::input::PointerKind::Touch | repose_core::input::PointerKind::Pen
)
}
fn screen_of(ev: &repose_core::input::PointerEvent) -> [f32; 2] {
let p = ev.position_in_window();
[p.x, p.y]
}
fn button_of(ev: &repose_core::input::PointerEvent) -> repose_core::input::PointerButton {
match ev.event {
repose_core::input::PointerEventKind::Down(b)
| repose_core::input::PointerEventKind::Up(b) => b,
_ => repose_core::input::PointerButton::Primary,
}
}
fn sane_positive(v: f32) -> f32 {
if v.is_finite() && v > 1e-6 { v } else { 1.0 }
}
fn fitted_box_scissor(
info: &repose_core::PaintCallbackInfo,
world: [f32; 2],
fit: (f32, f32, f32),
roll: f32,
) -> Option<(u32, u32, u32, u32)> {
let d = if info.pixels_per_point.is_finite() && info.pixels_per_point > 1e-6 {
info.pixels_per_point
} else {
1.0
};
let (s, ox, oy) = fit;
if !(s.is_finite() && s > 1e-6) {
return None;
}
let (bw, bh) = (world[0] * s * d, world[1] * s * d);
let cx = info.viewport.x + ox * d + bw * 0.5;
let cy = info.viewport.y + oy * d + bh * 0.5;
let (hw, hh) = (bw * 0.5, bh * 0.5);
let (c, sn) = (roll.cos().abs(), roll.sin().abs());
let (ex, ey) = (hw * c + hh * sn, hw * sn + hh * c);
let lx = (cx - ex).max(info.clip_rect.x);
let ty = (cy - ey).max(info.clip_rect.y);
let rx = (cx + ex).min(info.clip_rect.x + info.clip_rect.w);
let by = (cy + ey).min(info.clip_rect.y + info.clip_rect.h);
if !(rx > lx && by > ty) {
return None;
}
let sx = lx.ceil().max(0.0) as u32;
let sy = ty.ceil().max(0.0) as u32;
let ex2 = rx.floor().max(0.0) as u32;
let ey2 = by.floor().max(0.0) as u32;
if ex2 <= sx || ey2 <= sy {
return None;
}
Some((sx, sy, ex2 - sx, ey2 - sy))
}
pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
return (1.0, 0.0, 0.0);
}
let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
if !s.is_finite() || s <= 1e-6 {
return (1.0, 0.0, 0.0);
}
(
s,
(canvas_dp[0] - world[0] * s) * 0.5,
(canvas_dp[1] - world[1] * s) * 0.5,
)
}
pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
let (base, _, _) = contain_fit(canvas_dp, world);
let zoom = sane_positive(cam.zoom);
let upp = sane_positive(cam.units_per_pixel);
let s = base * zoom / upp;
if !s.is_finite() || s <= 1e-6 {
return (1.0, 0.0, 0.0);
}
let (ox, oy) = match cam.fit {
FitMode::Keep => (
(canvas_dp[0] - world[0] * s) * 0.5,
(canvas_dp[1] - world[1] * s) * 0.5,
),
FitMode::Expand => (0.0, 0.0),
};
(s, ox, oy)
}
pub fn fit_view_proj(
canvas_dp: [f32; 2],
world_size: [f32; 2],
cam_center: [f32; 2],
fit: (f32, f32, f32),
) -> Mat4 {
fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
}
pub fn fit_view_proj_with_roll(
canvas_dp: [f32; 2],
world_size: [f32; 2],
cam_center: [f32; 2],
fit: (f32, f32, f32),
roll: f32,
) -> Mat4 {
let w = canvas_dp[0].max(1.0);
let h = canvas_dp[1].max(1.0);
let (s, ox, oy) = fit;
let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
let lx = cam_center[0] - world_size[0] * 0.5;
let ly = cam_center[1] - world_size[1] * 0.5;
let sx = 2.0 * s / w;
let sy = -2.0 * s / h;
let tx = 2.0 * (ox - lx * s) / w - 1.0;
let ty = 1.0 - 2.0 * (oy - ly * s) / h;
let base = Mat4::from_cols(
glam::Vec4::new(sx, 0.0, 0.0, 0.0),
glam::Vec4::new(0.0, sy, 0.0, 0.0),
glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
glam::Vec4::new(tx, ty, 0.0, 1.0),
);
if !roll.is_finite() || roll.abs() < 1e-7 {
return base;
}
let (c, r) = (roll.cos(), roll.sin());
let rot = Mat4::from_cols(
glam::Vec4::new(c, r, 0.0, 0.0),
glam::Vec4::new(-r, c, 0.0, 0.0),
glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
glam::Vec4::new(
cam_center[0] - c * cam_center[0] + r * cam_center[1],
cam_center[1] - r * cam_center[0] - c * cam_center[1],
0.0,
1.0,
),
);
base * rot
}
pub fn world_to_dp(
world: [f32; 2],
world_size: [f32; 2],
cam_center: [f32; 2],
fit: (f32, f32, f32),
) -> [f32; 2] {
world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
}
pub fn world_to_dp_with_roll(
world: [f32; 2],
world_size: [f32; 2],
cam_center: [f32; 2],
fit: (f32, f32, f32),
roll: f32,
) -> [f32; 2] {
let (s, ox, oy) = fit;
let [wx, wy] = rotate_about(world, cam_center, roll);
[
ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
]
}
pub fn dp_to_world(
dp: [f32; 2],
world_size: [f32; 2],
cam_center: [f32; 2],
fit: (f32, f32, f32),
) -> [f32; 2] {
dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
}
pub fn dp_to_world_with_roll(
dp: [f32; 2],
world_size: [f32; 2],
cam_center: [f32; 2],
fit: (f32, f32, f32),
roll: f32,
) -> [f32; 2] {
let (s, ox, oy) = fit;
if !s.is_finite() || s.abs() < 1e-6 {
return cam_center;
}
let base = [
(dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
(dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
];
rotate_about(base, cam_center, -roll)
}
fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
if !roll.is_finite() || roll.abs() < 1e-7 {
return p;
}
let (c, r) = (roll.cos(), roll.sin());
let dx = p[0] - center[0];
let dy = p[1] - center[1];
[center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
}
pub fn pick_world(local_px: [f32; 2], geom: FrameGeom, world_size: [f32; 2]) -> [f32; 2] {
let d = if geom.density.is_finite() && geom.density > 1e-6 {
geom.density
} else {
1.0
};
dp_to_world_with_roll(
[local_px[0] / d, local_px[1] / d],
world_size,
geom.pivot,
geom.fit,
geom.roll,
)
}
pub fn aim_world(
local_px: [f32; 2],
look_unshaken: [f32; 2],
roll_unshaken: f32,
fit: (f32, f32, f32),
density: f32,
world_size: [f32; 2],
) -> [f32; 2] {
let d = if density.is_finite() && density > 1e-6 {
density
} else {
1.0
};
dp_to_world_with_roll(
[local_px[0] / d, local_px[1] / d],
world_size,
look_unshaken,
fit,
roll_unshaken,
)
}
pub fn aim_world_unshaken(
local_px: [f32; 2],
cam: &Camera2d,
canvas_dp: [f32; 2],
world_size: [f32; 2],
density: f32,
) -> [f32; 2] {
let fit = effective_fit(canvas_dp, world_size, cam);
aim_world(
local_px,
[cam.center.x, cam.center.y],
0.0,
fit,
density,
world_size,
)
}
#[derive(Clone, Copy, Debug)]
pub struct FrameGeom {
pub fit: (f32, f32, f32),
pub look: [f32; 2],
pub density: f32,
pub viewport_px: [f32; 2],
pub roll: f32,
pub pivot: [f32; 2],
}
impl Default for FrameGeom {
fn default() -> Self {
Self {
fit: (1.0, 0.0, 0.0),
look: [0.0, 0.0],
density: 1.0,
viewport_px: [1.0, 1.0],
roll: 0.0,
pivot: [0.0, 0.0],
}
}
}
#[derive(Clone, Default)]
pub struct GeomHandle(Arc<Mutex<FrameGeom>>);
impl GeomHandle {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(FrameGeom::default())))
}
pub fn get(&self) -> FrameGeom {
self.0.lock().map(|g| *g).unwrap_or_default()
}
pub fn set(&self, g: FrameGeom) {
if let Ok(mut slot) = self.0.lock() {
*slot = g;
}
}
pub fn arc(&self) -> Arc<Mutex<FrameGeom>> {
self.0.clone()
}
}
impl From<FrameGeom> for GeomHandle {
fn from(g: FrameGeom) -> Self {
Self(Arc::new(Mutex::new(g)))
}
}
impl From<std::sync::Arc<std::sync::Mutex<FrameGeom>>> for GeomHandle {
fn from(arc: std::sync::Arc<std::sync::Mutex<FrameGeom>>) -> Self {
Self(arc)
}
}
impl From<std::rc::Rc<std::cell::Cell<FrameGeom>>> for GeomHandle {
fn from(rc: std::rc::Rc<std::cell::Cell<FrameGeom>>) -> Self {
Self(Arc::new(Mutex::new(rc.get())))
}
}
pub const CLICK_SLOP_DP: f32 = 12.0;
pub const CLICK_SLOP_PX: f32 = CLICK_SLOP_DP;
#[allow(dead_code)]
fn click_within_slop(a: [f32; 2], b: [f32; 2]) -> bool {
click_within_slop_dp(a, b, 1.0)
}
pub fn click_within_slop_dp(a: [f32; 2], b: [f32; 2], density: f32) -> bool {
let d = if density.is_finite() && density > 1e-6 {
density
} else {
1.0
};
let dx = (a[0] - b[0]) / d;
let dy = (a[1] - b[1]) / d;
dx * dx + dy * dy <= CLICK_SLOP_DP * CLICK_SLOP_DP
}
pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
let (s, ox, oy) = geom.fit;
let r = rotate_about(center, geom.pivot, geom.roll);
(
[
ox + (r[0] - size[0] * 0.5 - geom.look[0]) * s,
oy + (r[1] - size[1] * 0.5 - geom.look[1]) * s,
],
[size[0] * s, size[1] * s],
)
}
fn rgba8(c: [f32; 4]) -> Color {
Color::from_rgba(
(c[0].clamp(0.0, 1.0) * 255.0) as u8,
(c[1].clamp(0.0, 1.0) * 255.0) as u8,
(c[2].clamp(0.0, 1.0) * 255.0) as u8,
(c[3].clamp(0.0, 1.0) * 255.0) as u8,
)
}
#[allow(non_snake_case)] pub fn Viewport2d(
input: FrameInput,
geom_out: GeomHandle,
on_event: impl Fn(PickEvent) + 'static,
) -> View {
Viewport2dShared(Arc::new(input), geom_out, on_event)
}
#[allow(non_snake_case)]
pub fn Viewport2dShared(
input: Arc<FrameInput>,
geom_out: GeomHandle,
on_event: impl Fn(PickEvent) + 'static,
) -> View {
let world_size = input.world_size;
let pick_geom = geom_out.clone();
let move_geom = geom_out.clone();
let release_geom = geom_out.clone();
let draw_input = input.clone();
let draw_geom = geom_out.clone();
let on_event = Rc::new(on_event);
let on_down = on_event.clone();
let on_touch_down = on_event.clone();
let on_move = on_event.clone();
let on_touch_move = on_event.clone();
let on_up = on_event.clone();
let on_up_click = on_event.clone();
let on_leave = on_event;
let press: Rc<RefCell<HashMap<u64, [f32; 2]>>> = Rc::new(RefCell::new(HashMap::new()));
let press_down = press.clone();
let press_up = press.clone();
let press_leave = press.clone();
let modifier = Modifier::new()
.fill_max_size()
.on_pointer_down(move |ev: repose_core::input::PointerEvent| {
let p = ev.position;
let g = pick_geom.get();
let world = pick_world([p.x, p.y], g, world_size);
press_down.borrow_mut().insert(ev.id.0, [p.x, p.y]);
let w = ev.position_in_window();
let button = button_of(&ev);
on_down(PickEvent::Press {
world: Vec2::new(world[0], world[1]),
screen: [w.x, w.y],
button,
});
if is_touch(&ev) {
on_touch_down(PickEvent::TouchDown {
id: ev.id.0,
screen: screen_of(&ev),
});
}
})
.on_pointer_move(move |ev: repose_core::input::PointerEvent| {
let p = ev.position;
let g = move_geom.get();
let world = pick_world([p.x, p.y], g, world_size);
let w = ev.position_in_window();
on_move(PickEvent::Hover {
world: Vec2::new(world[0], world[1]),
screen: [w.x, w.y],
});
if is_touch(&ev) {
on_touch_move(PickEvent::TouchMove {
id: ev.id.0,
screen: screen_of(&ev),
});
}
})
.on_pointer_up(move |ev: repose_core::input::PointerEvent| {
let p = ev.position;
let start = press_up.borrow_mut().remove(&ev.id.0);
if let Some(start) = start
&& click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
{
let g = release_geom.get();
let world = pick_world([p.x, p.y], g, world_size);
let w = ev.position_in_window();
let button = button_of(&ev);
on_up_click(PickEvent::Click {
world: Vec2::new(world[0], world[1]),
screen: [w.x, w.y],
button,
});
}
if is_touch(&ev) {
on_up(PickEvent::TouchUp { id: ev.id.0 });
}
})
.on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
press_leave.borrow_mut().remove(&ev.id.0);
if is_touch(&ev) {
on_leave(PickEvent::TouchUp { id: ev.id.0 });
}
});
Canvas(modifier, move |scope: &mut DrawScope| {
let d = effective_density_scale();
let cam = draw_input.cam;
let cam_center = cam.effective_center();
let fit = effective_fit(
[scope.size.width / d, scope.size.height / d],
draw_input.world_size,
&cam,
);
draw_geom.set(FrameGeom {
fit,
look: [
cam_center[0] - draw_input.world_size[0] * 0.5,
cam_center[1] - draw_input.world_size[1] * 0.5,
],
density: d,
viewport_px: [scope.size.width, scope.size.height],
roll: cam.roll,
pivot: cam_center,
});
let project = |wx: f32, wy: f32| -> [f32; 2] {
let [dx, dy] = world_to_dp_with_roll(
[wx, wy],
draw_input.world_size,
cam_center,
(fit.0, fit.1, fit.2),
cam.roll,
);
[dx * d, dy * d]
};
if let Some(bg) = draw_input.background {
scope.draw_rect(
Rect {
x: 0.0,
y: 0.0,
w: scope.size.width,
h: scope.size.height,
},
rgba8(bg),
Px(0.0),
);
}
for spr in draw_input.sprites.iter() {
let px_per_world = fit.0 * d;
let [px, py] = project(spr.center.x, spr.center.y);
let ax = if spr.flip_x {
1.0 - spr.anchor.x
} else {
spr.anchor.x
};
let ay = if spr.flip_y {
1.0 - spr.anchor.y
} else {
spr.anchor.y
};
scope.draw_rect_rotated(
Rect {
x: px - ax * spr.size.x * px_per_world,
y: py - ay * spr.size.y * px_per_world,
w: (spr.size.x * px_per_world).max(0.0),
h: (spr.size.y * px_per_world).max(0.0),
},
rgba8(spr.color),
Px(0.0),
cam.roll + spr.rotation,
repose_core::Vec2 { x: px, y: py },
);
}
for t in draw_input.texts.iter() {
let [tx, ty] = project(t.pos.x, t.pos.y);
scope.draw_text(
t.text.clone(),
repose_core::Vec2 { x: tx, y: ty },
rgba8(t.color),
Px(t.size * fit.0 * d),
);
}
if let Some(tint) = draw_input.overlay_color {
scope.draw_rect(
Rect {
x: 0.0,
y: 0.0,
w: scope.size.width,
h: scope.size.height,
},
rgba8(tint),
Px(0.0),
);
}
})
}
#[allow(non_snake_case)]
pub fn Viewport2dGpu(
input: FrameInput,
geom_out: GeomHandle,
uploads: Arc<[AtlasUpload]>,
desc: BatchDesc,
on_event: impl Fn(PickEvent) + 'static,
) -> View {
Viewport2dGpuWithIdShared(
Arc::new(input),
geom_out,
uploads,
desc,
"viewport2d.main",
on_event,
)
}
#[allow(non_snake_case)]
pub fn Viewport2dGpuWithId(
input: FrameInput,
geom_out: GeomHandle,
uploads: Arc<[AtlasUpload]>,
desc: BatchDesc,
batch_id: impl Into<String>,
on_event: impl Fn(PickEvent) + 'static,
) -> View {
Viewport2dGpuWithIdShared(Arc::new(input), geom_out, uploads, desc, batch_id, on_event)
}
#[allow(non_snake_case)]
pub fn Viewport2dGpuWithIdShared(
input: Arc<FrameInput>,
geom_out: GeomHandle,
uploads: Arc<[AtlasUpload]>,
desc: BatchDesc,
batch_id: impl Into<String>,
on_event: impl Fn(PickEvent) + 'static,
) -> View {
viewport2d_gpu_with_id_shared(input, geom_out, uploads, desc, batch_id, on_event, false)
}
fn viewport2d_gpu_with_id_shared(
input: Arc<FrameInput>,
geom_out: GeomHandle,
uploads: Arc<[AtlasUpload]>,
desc: BatchDesc,
batch_id: impl Into<String>,
on_event: impl Fn(PickEvent) + 'static,
overlay_separate: bool,
) -> View {
let batch_id: String = batch_id.into();
let bg_id = format!("{batch_id}.background");
let overlay_id = format!("{batch_id}.overlay");
let world_size = input.world_size;
let pick_geom = geom_out.clone();
let move_geom = geom_out.clone();
let release_geom = geom_out.clone();
let size_geom = geom_out.clone();
let size_input = input.clone();
let geom_for_payload = geom_out.clone();
let on_event = Arc::new(on_event);
let on_down = on_event.clone();
let on_touch_down = on_event.clone();
let on_move = on_event.clone();
let on_touch_move = on_event.clone();
let on_up = on_event.clone();
let on_up_click = on_event.clone();
let on_leave = on_event;
let press: Arc<Mutex<HashMap<u64, [f32; 2]>>> = Arc::new(Mutex::new(HashMap::new()));
let press_down = press.clone();
let press_up = press.clone();
let press_leave = press.clone();
let payload = GpuViewport {
input: input.clone(),
geom: geom_for_payload.arc(),
batch_id,
uploads,
desc,
overlay_separate,
bg: FullscreenPass::new(
bg_id,
fullscreen::SOLID_WGSL,
FullscreenDesc {
texture_slots: 0,
filter: TextureFilter::Nearest,
},
),
overlay: FullscreenPass::new(
overlay_id,
fullscreen::SOLID_WGSL,
FullscreenDesc {
texture_slots: 0,
filter: TextureFilter::Nearest,
},
),
};
let modifier = Modifier::new()
.fill_max_size()
.on_size_changed(move |dp: repose_core::Vec2| {
let d = effective_density_scale();
let d = if d.is_finite() && d > 1e-6 { d } else { 1.0 };
let fit = effective_fit([dp.x, dp.y], size_input.world_size, &size_input.cam);
let center = size_input.cam.effective_center();
size_geom.set(FrameGeom {
viewport_px: [dp.x * d, dp.y * d],
density: d,
fit,
look: [
center[0] - size_input.world_size[0] * 0.5,
center[1] - size_input.world_size[1] * 0.5,
],
roll: size_input.cam.roll,
pivot: center,
});
})
.on_pointer_down(move |ev: repose_core::input::PointerEvent| {
let p = ev.position;
let g = pick_geom.get();
let world = pick_world([p.x, p.y], g, world_size);
if let Ok(mut slots) = press_down.lock() {
slots.insert(ev.id.0, [p.x, p.y]);
}
let w = ev.position_in_window();
let button = button_of(&ev);
on_down(PickEvent::Press {
world: Vec2::new(world[0], world[1]),
screen: [w.x, w.y],
button,
});
if is_touch(&ev) {
on_touch_down(PickEvent::TouchDown {
id: ev.id.0,
screen: screen_of(&ev),
});
}
})
.on_pointer_move(move |ev: repose_core::input::PointerEvent| {
let p = ev.position;
let g = move_geom.get();
let world = pick_world([p.x, p.y], g, world_size);
let w = ev.position_in_window();
on_move(PickEvent::Hover {
world: Vec2::new(world[0], world[1]),
screen: [w.x, w.y],
});
if is_touch(&ev) {
on_touch_move(PickEvent::TouchMove {
id: ev.id.0,
screen: screen_of(&ev),
});
}
})
.on_pointer_up(move |ev: repose_core::input::PointerEvent| {
let p = ev.position;
let start = press_up.lock().ok().and_then(|mut s| s.remove(&ev.id.0));
if let Some(start) = start
&& click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
{
let g = release_geom.get();
let world = pick_world([p.x, p.y], g, world_size);
let w = ev.position_in_window();
let button = button_of(&ev);
on_up_click(PickEvent::Click {
world: Vec2::new(world[0], world[1]),
screen: [w.x, w.y],
button,
});
}
if is_touch(&ev) {
on_up(PickEvent::TouchUp { id: ev.id.0 });
}
})
.on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
if let Ok(mut s) = press_leave.lock() {
s.remove(&ev.id.0);
}
if is_touch(&ev) {
on_leave(PickEvent::TouchUp { id: ev.id.0 });
}
});
Embedded(modifier, Callback::new(payload))
}
struct GpuViewport {
input: Arc<FrameInput>,
geom: Arc<Mutex<FrameGeom>>,
batch_id: String,
uploads: Arc<[AtlasUpload]>,
desc: BatchDesc,
overlay_separate: bool,
bg: FullscreenPass,
overlay: FullscreenPass,
}
impl WgpuCallback for GpuViewport {
fn resource_key(&self) -> Option<&str> {
Some("repame.viewport2d.resources")
}
fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &ScreenDescriptor,
resources: &mut CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let (vp_phys, density) = self
.geom
.lock()
.map(|g| {
let d = if g.density.is_finite() && g.density > 1e-6 {
g.density
} else {
1.0
};
(g.viewport_px, d)
})
.unwrap_or(([1.0, 1.0], 1.0));
let dp = if vp_phys[0] > 1.0 && vp_phys[1] > 1.0 {
[vp_phys[0] / density, vp_phys[1] / density]
} else {
self.input.viewport_dp
};
let mut batch = SpriteBatch::with_id(self.batch_id.clone(), self.desc);
batch.set_camera(self.input.cam.fit_matrix(dp, self.input.world_size));
for s in &self.input.sprites {
batch.push_sprite(s);
}
batch.prepare_with_uploads(
device,
queue,
encoder,
screen,
resources,
self.uploads.as_ref(),
);
let composite = post::use_composite(self.input.chroma);
if let Some(bg) = self.input.background
&& !composite
{
let words = [bg[0], bg[1], bg[2], bg[3]];
self.bg.prepare_with(
device,
queue,
screen,
resources,
bytemuck::cast_slice(&words),
&[] as &[FullscreenTexture],
);
}
if !self.overlay_separate
&& let Some(ov) = self.input.overlay_color
{
let words = [ov[0], ov[1], ov[2], ov[3]];
self.overlay.prepare_with(
device,
queue,
screen,
resources,
bytemuck::cast_slice(&words),
&[] as &[FullscreenTexture],
);
}
if post::use_composite(self.input.chroma) {
let w = vp_phys[0].max(1.0) as u32;
let h = vp_phys[1].max(1.0) as u32;
post::prepare_composite(
device,
queue,
encoder,
screen,
resources,
self.batch_id.as_str(),
w,
h,
self.input.chroma,
self.input.background,
);
}
Vec::new()
}
fn paint(
&self,
info: repose_core::PaintCallbackInfo,
rpass: &mut CallbackRenderPass<'_, '_>,
resources: &CallbackResources,
) {
let d = if info.pixels_per_point.is_finite() && info.pixels_per_point > 1e-6 {
info.pixels_per_point
} else {
1.0
};
let vp = [info.viewport.w, info.viewport.h];
let fit = effective_fit(
[vp[0] / d, vp[1] / d],
self.input.world_size,
&self.input.cam,
);
let cam = self.input.cam.effective_center();
let roll = self.input.cam.roll;
if let Ok(mut g) = self.geom.lock() {
*g = FrameGeom {
fit,
look: [
cam[0] - self.input.world_size[0] * 0.5,
cam[1] - self.input.world_size[1] * 0.5,
],
density: d,
viewport_px: vp,
roll,
pivot: cam,
};
}
if post::use_composite(self.input.chroma) {
post::paint_composite(self.batch_id.as_str(), rpass, resources);
} else {
if self.input.background.is_some() {
self.bg.paint(info, rpass, resources);
}
let restore = (
info.clip_rect.x.ceil().max(0.0) as u32,
info.clip_rect.y.ceil().max(0.0) as u32,
((info.clip_rect.x + info.clip_rect.w).floor().max(0.0) as u32)
.saturating_sub(info.clip_rect.x.ceil().max(0.0) as u32),
((info.clip_rect.y + info.clip_rect.h).floor().max(0.0) as u32)
.saturating_sub(info.clip_rect.y.ceil().max(0.0) as u32),
);
match fitted_box_scissor(&info, self.input.world_size, fit, roll) {
Some(sc) => {
rpass.set_scissor_rect(sc.0, sc.1, sc.2, sc.3);
batch::draw_batch_with_id_callback(self.batch_id.as_str(), rpass, resources);
rpass.set_scissor_rect(restore.0, restore.1, restore.2, restore.3);
}
None => {
batch::draw_batch_with_id_callback(self.batch_id.as_str(), rpass, resources);
}
}
}
if !self.overlay_separate && self.input.overlay_color.is_some() {
self.overlay.paint(info, rpass, resources);
}
}
}
#[allow(non_snake_case)]
pub fn Viewport2dGpuWithHud(
input: FrameInput,
geom: GeomHandle,
uploads: Arc<[AtlasUpload]>,
desc: BatchDesc,
on_event: impl Fn(PickEvent) + 'static,
) -> View {
Viewport2dGpuWithHudShared(Arc::new(input), geom, uploads, desc, on_event)
}
#[allow(non_snake_case)]
pub fn Viewport2dGpuWithHudShared(
input: Arc<FrameInput>,
geom: GeomHandle,
uploads: Arc<[AtlasUpload]>,
desc: BatchDesc,
on_event: impl Fn(PickEvent) + 'static,
) -> View {
use repose_ui::ViewExt as _;
let hud_input = input.clone();
let hud_geom = geom.clone();
let hud = Canvas(
Modifier::new().fill_max_size().hit_passthrough(),
move |scope: &mut DrawScope| {
let input = hud_input.as_ref();
let cam = input.cam;
let world_size = input.world_size;
let d = effective_density_scale();
let cam_center = cam.effective_center();
let fit = effective_fit(
[scope.size.width / d, scope.size.height / d],
world_size,
&cam,
);
hud_geom.set(FrameGeom {
fit,
look: [
cam_center[0] - world_size[0] * 0.5,
cam_center[1] - world_size[1] * 0.5,
],
density: d,
viewport_px: [scope.size.width, scope.size.height],
roll: cam.roll,
pivot: cam_center,
});
let project = |wx: f32, wy: f32| -> [f32; 2] {
let [dx, dy] = world_to_dp_with_roll(
[wx, wy],
world_size,
cam_center,
(fit.0, fit.1, fit.2),
cam.roll,
);
[dx * d, dy * d]
};
for t in input.texts.iter() {
let [tx, ty] = project(t.pos.x, t.pos.y);
scope.draw_text(
t.text.clone(),
repose_core::Vec2 { x: tx, y: ty },
rgba8(t.color),
Px(t.size * fit.0 * d),
);
}
if let Some(tint) = input.overlay_color {
scope.draw_rect(
Rect {
x: 0.0,
y: 0.0,
w: scope.size.width,
h: scope.size.height,
},
rgba8(tint),
Px(0.0),
);
}
},
);
repose_ui::ZStack(Modifier::new().fill_max_size())
.child(viewport2d_gpu_with_id_shared(
input,
geom,
uploads,
desc,
"viewport2d.main",
on_event,
true,
))
.child(hud)
}
#[allow(non_snake_case)]
pub fn ActorFrame(
center: [f32; 2],
size: [f32; 2],
geom: GeomHandle,
mirror_x: bool,
child: View,
) -> View {
ActorFrameWithOrigin(center, size, geom, mirror_x, [0.5, 0.5], child)
}
#[allow(non_snake_case)]
pub fn ActorFrameWithOrigin(
center: [f32; 2],
size: [f32; 2],
geom: GeomHandle,
mirror_x: bool,
origin: [f32; 2],
child: View,
) -> View {
let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
let mut modifier = Modifier::new().size(Dp(w), Dp(h)).absolute().offset(
Some(Dp(ox)),
Some(Dp(oy)),
None,
None,
);
if mirror_x {
modifier = modifier
.transform_origin(origin[0].clamp(0.0, 1.0), origin[1].clamp(0.0, 1.0))
.scale2(-1.0, 1.0);
}
UiBox(modifier).child(child)
}
#[cfg(test)]
mod tests {
use super::*;
use repose_core::locals::{Density, set_density_default};
#[test]
fn contain_fit_roundtrip_at_density_125() {
set_density_default(Density { scale: 1.25 });
let d = effective_density_scale();
let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
let cam = [400.0, 300.0];
let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
set_density_default(Density { scale: 1.0 });
assert!(
(fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
"dp fit, got {fit:?}"
);
assert!(
(dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
"draw forward, got ({dx},{dy})"
);
assert!(
(wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
"pick inverse, got ({wx},{wy})"
);
let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
assert!(
(back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
"shifted round trip, got {back:?}"
);
}
#[test]
fn gpu_viewport_honors_cam_and_background() {
use repose_core::{Color, Rect, Scene, SceneNode};
use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
Ok(r) => r,
Err(e) => {
eprintln!("SKIP gpu viewport test (no GPU): {e}");
return;
}
};
let page = [255, 0, 255, 255].repeat(4);
let uploads = vec![AtlasUpload {
page: 0,
x: 0,
y: 0,
w: 2,
h: 2,
rgba: page,
}];
let cam = Camera2d {
center: Vec2::new(64.0, 64.0),
offset: Vec2::ZERO,
units_per_pixel: 0.5,
zoom: 1.0,
roll: 0.0,
fit: FitMode::Keep,
};
let input = FrameInput {
cam,
world_size: [128.0, 128.0],
viewport_dp: [256.0, 256.0],
sprites: vec![SpriteInstance {
center: Vec2::new(64.0, 64.0),
size: Vec2::new(32.0, 32.0),
uv_min: Vec2::new(0.0, 0.0),
uv_max: Vec2::new(1.0, 1.0),
color: [1.0, 1.0, 1.0, 1.0],
..Default::default()
}],
texts: Vec::new(),
background: Some([0.0, 0.0, 1.0, 1.0]),
overlay_color: None,
chroma: 0.0,
};
let geom = Arc::new(Mutex::new(FrameGeom {
fit: (1.0, 0.0, 0.0),
look: [0.0, 0.0],
density: 1.0,
viewport_px: [256.0, 256.0],
roll: 0.0,
pivot: [64.0, 64.0],
}));
let payload = GpuViewport {
input: Arc::new(input.clone()),
geom,
batch_id: "test.viewport2d.main".to_string(),
uploads: Arc::from(uploads),
desc: BatchDesc {
layer_size: 2,
layers: 1,
filter: TextureFilter::Nearest,
..Default::default()
},
overlay_separate: false,
bg: FullscreenPass::new(
"test.viewport2d.background",
fullscreen::SOLID_WGSL,
FullscreenDesc {
texture_slots: 0,
filter: TextureFilter::Nearest,
},
),
overlay: FullscreenPass::new(
"test.viewport2d.overlay",
fullscreen::SOLID_WGSL,
FullscreenDesc {
texture_slots: 0,
filter: TextureFilter::Nearest,
},
),
};
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 255),
nodes: vec![SceneNode::Callback {
rect: Rect {
x: 0.0,
y: 0.0,
w: 256.0,
h: 256.0,
},
payload: Callback::new(payload),
}],
};
let px = renderer
.render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
.expect("offscreen render");
let at = |x: u32, y: u32| -> [u8; 4] {
let i = ((y * 256 + x) * 4) as usize;
[px[i], px[i + 1], px[i + 2], px[i + 3]]
};
assert_eq!(at(8, 8), [0, 0, 255, 255], "background fill");
assert_eq!(at(247, 247), [0, 0, 255, 255], "background fill");
assert_eq!(at(128, 128), [255, 0, 255, 255], "cam-centered quad");
let overlay_input = FrameInput {
overlay_color: Some([1.0, 0.0, 0.0, 1.0]),
..input
};
let overlay_payload = GpuViewport {
input: Arc::new(overlay_input),
geom: Arc::new(Mutex::new(FrameGeom {
fit: (1.0, 0.0, 0.0),
look: [0.0, 0.0],
density: 1.0,
viewport_px: [256.0, 256.0],
roll: 0.0,
pivot: [64.0, 64.0],
})),
batch_id: "test.viewport2d.overlay".to_string(),
uploads: Arc::from(vec![AtlasUpload {
page: 0,
x: 0,
y: 0,
w: 2,
h: 2,
rgba: [255, 0, 255, 255].repeat(4),
}]),
desc: BatchDesc {
layer_size: 2,
layers: 1,
filter: TextureFilter::Nearest,
..Default::default()
},
overlay_separate: false,
bg: FullscreenPass::new(
"test.viewport2d.background",
fullscreen::SOLID_WGSL,
FullscreenDesc {
texture_slots: 0,
filter: TextureFilter::Nearest,
},
),
overlay: FullscreenPass::new(
"test.viewport2d.overlay",
fullscreen::SOLID_WGSL,
FullscreenDesc {
texture_slots: 0,
filter: TextureFilter::Nearest,
},
),
};
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 255),
nodes: vec![SceneNode::Callback {
rect: Rect {
x: 0.0,
y: 0.0,
w: 256.0,
h: 256.0,
},
payload: Callback::new(overlay_payload),
}],
};
let px = renderer
.render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
.expect("offscreen render");
let at = |x: u32, y: u32| -> [u8; 4] {
let i = ((y * 256 + x) * 4) as usize;
[px[i], px[i + 1], px[i + 2], px[i + 3]]
};
assert_eq!(at(8, 8), [255, 0, 0, 255], "overlay covers background");
assert_eq!(at(128, 128), [255, 0, 0, 255], "overlay covers sprite");
}
#[test]
fn surface_dp_matches_board_point_under_shake() {
let geom = FrameGeom {
fit: (1.0, 100.0, 0.0),
look: [0.0, 0.0],
density: 1.25,
viewport_px: [1250.0, 750.0],
roll: 0.0,
pivot: [400.0, 300.0],
};
let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
assert!(
(ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
"offset, got ({ox},{oy})"
);
assert!(
(w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
"size, got ({w},{h})"
);
let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
assert!(
(ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
"surface glued to board point"
);
let shaken = FrameGeom {
look: [10.0, -10.0],
..geom
};
let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
assert!(
(sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
"surface glued under shake"
);
}
#[test]
fn contain_fit_has_no_silent_clamp() {
for (canvas, world) in [
([8000.0, 6000.0], [80.0, 60.0]),
([80.0, 60.0], [8000.0, 6000.0]),
([1000.0, 750.0], [800.0, 600.0]),
] {
let fit = contain_fit(canvas, world);
let cam = [world[0] * 0.5, world[1] * 0.5];
let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
let back = dp_to_world(dp, world, cam, fit);
assert!(
(back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
"round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
);
}
assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
assert_eq!(
dp_to_world(
[10.0, 10.0],
[800.0, 600.0],
[400.0, 300.0],
(0.0, 0.0, 0.0)
),
[400.0, 300.0]
);
}
#[test]
fn fit_mode_keep_is_contain_fit() {
for (canvas, world) in [
([1600.0, 900.0], [800.0, 800.0]),
([900.0, 1600.0], [800.0, 800.0]),
([1000.0, 1000.0], [800.0, 600.0]),
] {
let cam = Camera2d {
center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
fit: FitMode::Keep,
..Default::default()
};
let fit = effective_fit(canvas, world, &cam);
assert_eq!(fit, contain_fit(canvas, world), "{canvas:?}/{world:?}");
let (s, ox, oy) = fit;
assert!((ox - (canvas[0] - world[0] * s) * 0.5).abs() < 1e-4);
assert!((oy - (canvas[1] - world[1] * s) * 0.5).abs() < 1e-4);
assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
}
}
#[test]
fn fit_mode_expand_reveals_more_and_never_crops() {
let canvas = [1600.0, 900.0];
let world = [800.0, 800.0];
let cam = Camera2d {
center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
fit: FitMode::Expand,
..Default::default()
};
let (s, ox, oy) = effective_fit(canvas, world, &cam);
let keep = effective_fit(
canvas,
world,
&Camera2d {
fit: FitMode::Keep,
..cam
},
);
assert_eq!(s, keep.0, "expand must not change the uniform scale");
assert_eq!((ox, oy), (0.0, 0.0), "expand anchors at the origin");
assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
let visible = [canvas[0] / s, canvas[1] / s];
let exact = [
(visible[0] - world[0]).abs() < 1e-3,
(visible[1] - world[1]).abs() < 1e-3,
];
assert!(exact[0] ^ exact[1], "one axis exact: {visible:?}");
if exact[0] {
assert!(visible[1] > world[1], "y reveals: {visible:?}");
} else {
assert!(visible[0] > world[0], "x reveals: {visible:?}");
}
let dp = world_to_dp([100.0, 700.0], world, cam.effective_center(), (s, ox, oy));
let back = dp_to_world(dp, world, cam.effective_center(), (s, ox, oy));
assert!((back[0] - 100.0).abs() < 1e-3 && (back[1] - 700.0).abs() < 1e-3);
}
#[test]
fn canvas_rotated_rect_matches_instance_rows() {
let cam = Camera2d {
center: Vec2::new(400.0, 300.0),
offset: Vec2::ZERO,
units_per_pixel: 1.0,
zoom: 1.0,
roll: 0.3,
fit: FitMode::Keep,
};
let world_size = [800.0, 600.0];
let canvas_dp = [800.0, 600.0];
let fit = effective_fit(canvas_dp, world_size, &cam);
let center = cam.effective_center();
let project = |wx: f32, wy: f32| -> [f32; 2] {
world_to_dp_with_roll([wx, wy], world_size, center, fit, cam.roll)
};
let k = fit.0;
for (rotation, flip_x, flip_y, anchor) in [
(0.7f32, false, false, [0.5, 0.5]),
(0.7, true, false, [0.5, 0.5]),
(0.7, false, true, [0.5, 0.5]),
(0.7, true, true, [0.5, 0.5]),
(-1.2, true, false, [0.0, 0.0]),
(2.1, false, true, [1.0, 0.25]),
(0.0, true, false, [0.5, 0.5]),
] {
let size = [64.0f32, 40.0];
let ctr = [400.0f32, 300.0];
let (row0, row1) = batch::instance_rows(ctr, size, rotation, anchor, flip_x, flip_y);
let mut expect = [[0.0f32; 2]; 4];
for (i, corner) in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]
.iter()
.enumerate()
{
let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
expect[i] = project(wx, wy);
}
let ax = if flip_x { 1.0 - anchor[0] } else { anchor[0] };
let ay = if flip_y { 1.0 - anchor[1] } else { anchor[1] };
let [px, py] = project(ctr[0], ctr[1]);
let angle = cam.roll + rotation;
let (c, s) = (angle.cos(), angle.sin());
let (ox, oy) = (px - ax * size[0] * k, py - ay * size[1] * k);
let w = size[0] * k;
let h = size[1] * k;
for (i, corner) in [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].iter().enumerate() {
let rx = px + (ox + corner[0] - px) * c - (oy + corner[1] - py) * s;
let ry = py + (ox + corner[0] - px) * s + (oy + corner[1] - py) * c;
let mut best = f32::INFINITY;
for e in expect {
best = best.min((rx - e[0]).hypot(ry - e[1]));
}
assert!(
best < 1e-2,
"rot={rotation} flip=({flip_x},{flip_y}) anchor={anchor:?}: corner {i} ({rx},{ry}) matches none of {expect:?}"
);
}
}
}
#[test]
#[allow(deprecated)]
fn camera_guards_div_by_zero() {
let bad = Camera2d {
center: Vec2::ZERO,
offset: Vec2::ZERO,
units_per_pixel: 0.0,
zoom: 0.0,
roll: 0.0,
fit: FitMode::Keep,
};
let m = bad.view_proj([0.0, 0.0]);
assert!(m.is_finite(), "view_proj stays finite, got {m:?}");
assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
}
#[test]
fn fit_matrix_canvas_gpu_picks_agree() {
let cam = Camera2d {
center: Vec2::new(410.0, 290.0),
offset: Vec2::ZERO,
units_per_pixel: 1.0,
zoom: 2.0,
roll: 0.0,
fit: FitMode::Keep,
};
let world_size = [800.0, 600.0];
let density = 1.25;
let vp_phys = [1250.0, 750.0];
let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
let cam_center = cam.effective_center();
let fit = effective_fit(canvas_dp, world_size, &cam);
assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
let vp = cam.fit_matrix(canvas_dp, world_size);
assert!(vp.is_finite());
for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
let px = [dx * density, dy * density];
let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
let gpu_px = [
(ndc.x + 1.0) * 0.5 * vp_phys[0],
(1.0 - ndc.y) * 0.5 * vp_phys[1],
];
assert!(
(px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
"canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
);
let back =
cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
assert!(
(back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
"pick round trip for {world:?} gave {back:?}"
);
}
}
#[test]
fn letterbox_pick_matches_draw() {
let cam = Camera2d {
center: Vec2::new(400.0, 400.0),
..Default::default()
};
let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
let plain = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
let dm = m - plain;
let max = dm
.to_cols_array()
.iter()
.fold(0.0f32, |a, b| a.max(b.abs()));
assert!(max < 1e-5);
}
#[test]
fn picks_ignore_viewport_origin() {
let geom = FrameGeom {
fit: (1.0, 100.0, 0.0),
look: [0.0, 0.0],
density: 1.25,
viewport_px: [1250.0, 750.0],
roll: 0.0,
pivot: [400.0, 300.0],
};
let world_size = [800.0, 600.0];
let a = pick_world([125.0, 75.0], geom, world_size);
let b = pick_world([125.0, 75.0], geom, world_size);
assert_eq!(a, b);
let [wx, wy] = pick_world([125.0, 75.0], geom, world_size);
assert!(
(wx - 0.0).abs() < 1e-4 && (wy - 60.0).abs() < 1e-4,
"local pick, got ({wx},{wy})"
);
}
#[test]
fn offset_shifts_look_and_draw_together() {
let world_size = [800.0, 600.0];
let canvas_dp = [1000.0, 600.0];
let mut cam = Camera2d {
center: Vec2::new(400.0, 300.0),
offset: Vec2::new(10.0, -10.0),
units_per_pixel: 1.0,
zoom: 1.0,
roll: 0.0,
fit: FitMode::Keep,
};
assert_eq!(cam.effective_center(), [410.0, 290.0]);
let fit = effective_fit(canvas_dp, world_size, &cam);
let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
cam.offset = Vec2::ZERO;
let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
}
#[test]
fn touch_gate_and_screen_space() {
use repose_core::Vec2 as RVec2;
use repose_core::input::{
Modifiers, PointerButton, PointerEvent, PointerEventKind, PointerId, PointerKind,
};
let ev_of = |kind: PointerKind| {
let mut ev = PointerEvent::new(
PointerId(3),
kind,
PointerEventKind::Down(PointerButton::Primary),
RVec2 { x: 15.0, y: 27.0 },
1.0,
Modifiers::default(),
);
ev.origin = RVec2 { x: 5.0, y: 7.0 };
ev.position = RVec2 { x: 10.0, y: 20.0 };
ev
};
assert!(is_touch(&ev_of(PointerKind::Touch)));
assert!(is_touch(&ev_of(PointerKind::Pen)));
assert!(!is_touch(&ev_of(PointerKind::Mouse)));
let ev = ev_of(PointerKind::Touch);
assert_eq!(ev.origin + ev.position, ev.position_in_window());
assert_eq!(screen_of(&ev), [15.0, 27.0]);
}
#[test]
fn presses_track_per_pointer_id() {
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
let presses: Rc<RefCell<HashMap<u64, [f32; 2]>>> = Rc::new(RefCell::new(HashMap::new()));
let down = presses.clone();
let up = presses.clone();
let leave = presses.clone();
let press_down = move |id: u64, at: [f32; 2]| {
down.borrow_mut().insert(id, at);
};
let press_up = move |id: u64| up.borrow_mut().remove(&id);
let press_leave = move |id: u64| {
leave.borrow_mut().remove(&id);
};
press_down(1, [10.0, 10.0]);
press_down(2, [200.0, 200.0]);
assert_eq!(press_up(1), Some([10.0, 10.0]));
assert_eq!(
presses.borrow().get(&2),
Some(&[200.0, 200.0]),
"second finger press survives first finger lift"
);
press_leave(2);
assert!(presses.borrow().is_empty());
}
#[test]
fn click_slop_cancels_drags() {
assert!(click_within_slop([100.0, 100.0], [105.0, 105.0]));
assert!(click_within_slop_dp([100.0, 100.0], [105.0, 105.0], 1.0));
assert!(!click_within_slop_dp(
[100.0, 100.0],
[100.0 + CLICK_SLOP_DP + 1.0, 100.0],
1.0
));
}
#[test]
fn click_slop_is_density_independent() {
assert!(click_within_slop_dp([0.0, 0.0], [36.0, 0.0], 3.0));
assert!(!click_within_slop_dp([0.0, 0.0], [37.0, 0.0], 3.0));
assert!(click_within_slop_dp([0.0, 0.0], [12.0, 0.0], 1.0));
assert!(!click_within_slop_dp([0.0, 0.0], [13.0, 0.0], 1.0));
}
#[test]
fn aim_ignores_shake_while_pick_follows_it() {
use super::{aim_world_unshaken, pick_world};
let world_size = [800.0, 600.0];
let canvas_dp = [800.0, 600.0];
let mut cam = Camera2d {
center: Vec2::new(400.0, 300.0),
offset: Vec2::new(10.0, -5.0),
units_per_pixel: 1.0,
zoom: 1.0,
roll: 0.0,
fit: FitMode::Keep,
};
let fit = effective_fit(canvas_dp, world_size, &cam);
let shaken = FrameGeom {
fit,
look: [
cam.effective_center()[0] - world_size[0] * 0.5,
cam.effective_center()[1] - world_size[1] * 0.5,
],
density: 1.0,
viewport_px: [800.0, 600.0],
roll: 0.0,
pivot: cam.effective_center(),
};
let px = [400.0, 300.0];
let pick = pick_world(px, shaken, world_size);
let aim = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
assert!(
(pick[0] - aim[0] - 10.0).abs() < 1e-3 && (pick[1] - aim[1] + 5.0).abs() < 1e-3,
"pick follows shake, aim does not: pick {pick:?} aim {aim:?}"
);
cam.offset = Vec2::ZERO;
let aim_still = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
assert!(
(aim[0] - aim_still[0]).abs() < 1e-3 && (aim[1] - aim_still[1]).abs() < 1e-3,
"aim independent of shake: {aim:?} vs {aim_still:?}"
);
}
#[test]
fn roll_roundtrips_and_moves_pixels() {
let cam = Camera2d {
center: Vec2::new(400.0, 300.0),
offset: Vec2::ZERO,
units_per_pixel: 1.0,
zoom: 1.0,
roll: std::f32::consts::FRAC_PI_2,
fit: FitMode::Keep,
};
let world = [800.0, 600.0];
let dp = [1000.0, 600.0];
let fit = effective_fit(dp, world, &cam);
for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
assert!(
(back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
"roll roundtrip for {p:?} gave {back:?}"
);
}
let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
let rolled =
world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
assert!(
(plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
"roll must move pixels, plain {plain:?} rolled {rolled:?}"
);
let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
assert_eq!(m0, legacy);
}
#[test]
fn surface_tracks_roll_about_pivot() {
let geom = FrameGeom {
fit: (1.0, 0.0, 0.0),
look: [0.0, 0.0],
density: 1.0,
viewport_px: [800.0, 600.0],
roll: std::f32::consts::FRAC_PI_2,
pivot: [400.0, 300.0],
};
let ([ox, oy], _) = surface_dp([500.0, 300.0], [10.0, 10.0], geom);
assert!(
(ox - 395.0).abs() < 1e-3 && (oy - 395.0).abs() < 1e-3,
"rolled surface, got ({ox},{oy})"
);
}
#[test]
fn geom_handle_compat_wraps_legacy_handles() {
use std::cell::Cell;
use std::rc::Rc;
let rc = Rc::new(Cell::new(FrameGeom {
fit: (2.0, 1.0, 2.0),
..Default::default()
}));
let h: GeomHandle = rc.into();
assert_eq!(h.get().fit, (2.0, 1.0, 2.0));
let arc = Arc::new(Mutex::new(FrameGeom::default()));
let h2: GeomHandle = arc.into();
assert_eq!(h2.get().density, 1.0);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CursorFrame {
pub rgba: Vec<u8>,
pub size: [u16; 2],
pub hotspot: [u16; 2],
pub key: (i32, [u32; 3], u64, u32),
}
pub fn cursor_frame(
strip: &[u8],
strip_w: u32,
strip_h: u32,
frames: u32,
frame: i32,
cell: (u32, u32),
origin: (f32, f32),
tint: [f32; 4],
mag: u32,
strip_hash: u64,
) -> Option<CursorFrame> {
let frames = frames.max(1) as i32;
let frame = frame.clamp(0, frames - 1);
let (cw, ch) = (cell.0.max(1), cell.1.max(1));
if strip_w < cw * (frame as u32 + 1) || strip_h < ch {
return None;
}
let mag = mag.clamp(1, 8);
let tint_u8 = [
(tint[0].clamp(0.0, 1.0) * 255.0) as u32,
(tint[1].clamp(0.0, 1.0) * 255.0) as u32,
(tint[2].clamp(0.0, 1.0) * 255.0) as u32,
];
let key = (frame, tint_u8, strip_hash, mag);
let fx = (frame as u32 * cw) as usize;
let (dw, dh) = (cw * mag, ch * mag);
if dw > 2048 || dh > 2048 {
return None;
}
let mut px = Vec::with_capacity((dw * dh * 4) as usize);
for row in 0..ch {
let start = ((row * strip_w) as usize + fx) * 4;
let end = start + (cw as usize) * 4;
let cell = strip.get(start..end)?;
let mut mag_row = Vec::with_capacity((cw * mag * 4) as usize);
for pix in cell.chunks_exact(4) {
let t = [
(pix[0] as u32 * tint_u8[0] / 255) as u8,
(pix[1] as u32 * tint_u8[1] / 255) as u8,
(pix[2] as u32 * tint_u8[2] / 255) as u8,
pix[3],
];
for _ in 0..mag {
mag_row.extend_from_slice(&t);
}
}
for _ in 0..mag {
px.extend_from_slice(&mag_row);
}
}
let (w16, h16) = (dw.min(2048) as u16, dh.min(2048) as u16);
Some(CursorFrame {
rgba: px,
size: [w16, h16],
hotspot: [
(origin.0 * mag as f32)
.round()
.clamp(0.0, w16.saturating_sub(1) as f32) as u16,
(origin.1 * mag as f32)
.round()
.clamp(0.0, h16.saturating_sub(1) as f32) as u16,
],
key,
})
}
pub fn unproject_px(
px: Vec2,
viewport_dp: [f32; 2],
world_size: [f32; 2],
density: f32,
cam: &Camera2d,
) -> Vec2 {
let d = if density.is_finite() && density > 1e-6 {
density
} else {
1.0
};
let dp = [px.x / d, px.y / d];
cam.dp_to_world_pt(viewport_dp, world_size, dp)
}
#[cfg(test)]
mod unproject_tests {
use super::*;
#[test]
fn px_round_trips_through_fit() {
let cam = Camera2d {
center: Vec2::new(213.0, 120.0),
offset: Vec2::ZERO,
units_per_pixel: 1.0 / 3.0,
zoom: 1.0,
roll: 0.0,
fit: FitMode::Keep,
};
let world = Vec2::new(100.0, 60.0);
let viewport_dp = [1024.0, 600.0];
let world_size = [1024.0, 600.0];
let (s, ox, oy) = effective_fit(viewport_dp, world_size, &cam);
let [dx, dy] = world_to_dp(
[world.x, world.y],
world_size,
cam.effective_center(),
(s, ox, oy),
);
let px = Vec2::new(dx * 1.25, dy * 1.25);
let back = unproject_px(px, viewport_dp, world_size, 1.25, &cam);
assert!(
(back - world).length() < 0.01,
"round trip, got {back:?} for {world:?}"
);
}
}