use std::cell::Cell;
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, Modifier, Rect, View};
use repose_render_wgpu::{Callback, CallbackResources, ScreenDescriptor, WgpuCallback};
use repose_ui::Box as UiBox;
use repose_ui::ViewExt;
pub mod batch;
use batch::draw_batch;
pub use batch::{AtlasUpload, BatchDesc, SpriteBatch, TextureFilter, instance_rows, screen_camera};
#[derive(Clone, Copy, Debug)]
pub struct Camera2d {
pub center: Vec2,
pub units_per_pixel: f32,
pub zoom: f32,
}
impl Default for Camera2d {
fn default() -> Self {
Self {
center: Vec2::ZERO,
units_per_pixel: 1.0,
zoom: 1.0,
}
}
}
impl Camera2d {
pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
let w = viewport_px[0] * self.units_per_pixel / self.zoom;
let h = viewport_px[1] * self.units_per_pixel / self.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.center.y, 0.0));
proj * view
}
pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
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)]
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,
}
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,
}
}
}
#[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_px: [f32; 2],
pub sprites: Vec<SpriteInstance>,
pub texts: Vec<WorldText>,
pub background: Option<[f32; 4]>,
pub overlay_color: Option<[f32; 4]>,
}
#[derive(Clone, Debug)]
pub enum PickEvent {
Click { world: Vec2, screen: [f32; 2] },
Hover { world: Vec2 },
}
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]);
let s = s.clamp(0.1, 8.0);
(
s,
(canvas_dp[0] - world[0] * s) * 0.5,
(canvas_dp[1] - world[1] * s) * 0.5,
)
}
pub fn world_to_dp(
world: [f32; 2],
world_size: [f32; 2],
cam_center: [f32; 2],
fit: (f32, f32, f32),
) -> [f32; 2] {
let (s, ox, oy) = fit;
[
ox + (world[0] - (cam_center[0] - world_size[0] * 0.5)) * s,
oy + (world[1] - (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] {
let (s, ox, oy) = fit;
[
(dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
(dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
]
}
#[derive(Clone, Copy, Debug)]
pub struct FrameGeom {
pub fit: (f32, f32, f32),
pub look: [f32; 2],
pub density: f32,
pub viewport_px: [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],
}
}
}
pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
let (s, ox, oy) = geom.fit;
(
[
ox + (center[0] - size[0] * 0.5 - geom.look[0]) * s,
oy + (center[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: Rc<Cell<FrameGeom>>,
on_event: impl Fn(PickEvent) + 'static,
) -> View {
let input = Rc::new(input);
let world_size = input.world_size;
let cam_center = [input.cam.center.x, input.cam.center.y];
let pick_geom = geom_out.clone();
let draw_input = input.clone();
let draw_geom = geom_out.clone();
let modifier = Modifier::new().fill_max_size().on_pointer_down(
move |ev: repose_core::input::PointerEvent| {
let w = ev.position_in_window();
let g = pick_geom.get();
let world = dp_to_world(
[w.x / g.density, w.y / g.density],
world_size,
[
g.look[0] + world_size[0] * 0.5,
g.look[1] + world_size[1] * 0.5,
],
g.fit,
);
on_event(PickEvent::Click {
world: Vec2::new(world[0], world[1]),
screen: [w.x, w.y],
});
},
);
Canvas(modifier, move |scope: &mut DrawScope| {
let d = effective_density_scale();
let fit = contain_fit(
[scope.size.width / d, scope.size.height / d],
draw_input.world_size,
);
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],
});
let s = fit.0 * d;
let project = |wx: f32, wy: f32| -> [f32; 2] {
let [dx, dy] = world_to_dp(
[wx, wy],
draw_input.world_size,
cam_center,
(fit.0, fit.1, fit.2),
);
[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),
0.0,
);
}
for spr in draw_input.sprites.iter() {
let [tx, ty] = project(
spr.center.x - spr.anchor.x * spr.size.x,
spr.center.y - spr.anchor.y * spr.size.y,
);
scope.draw_rect(
Rect {
x: tx,
y: ty,
w: spr.size.x * s,
h: spr.size.y * s,
},
rgba8(spr.color),
0.0,
);
}
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),
t.size * s,
);
}
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),
0.0,
);
}
})
}
#[allow(non_snake_case)]
pub fn Viewport2dGpu(
input: FrameInput,
geom_out: Arc<Mutex<FrameGeom>>,
uploads: Vec<AtlasUpload>,
desc: BatchDesc,
on_event: impl Fn(PickEvent) + 'static,
) -> View {
let input = Arc::new(input);
let world_size = input.world_size;
let pick_geom = geom_out.clone();
let payload = GpuViewport {
input: input.clone(),
geom: geom_out,
uploads,
desc,
};
let modifier = Modifier::new().fill_max_size().on_pointer_down(
move |ev: repose_core::input::PointerEvent| {
let w = ev.position_in_window();
let Ok(g) = pick_geom.lock() else {
return;
};
let world = dp_to_world(
[w.x / g.density, w.y / g.density],
world_size,
[
g.look[0] + world_size[0] * 0.5,
g.look[1] + world_size[1] * 0.5,
],
g.fit,
);
on_event(PickEvent::Click {
world: Vec2::new(world[0], world[1]),
screen: [w.x, w.y],
});
},
);
Embedded(modifier, Callback::new(payload))
}
struct GpuViewport {
input: Arc<FrameInput>,
geom: Arc<Mutex<FrameGeom>>,
uploads: Vec<AtlasUpload>,
desc: BatchDesc,
}
impl WgpuCallback for GpuViewport {
fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &ScreenDescriptor,
resources: &mut CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let vp = self
.geom
.lock()
.map(|g| g.viewport_px)
.unwrap_or([1.0, 1.0]);
let vp = if vp[0] > 1.0 && vp[1] > 1.0 {
vp
} else {
self.input.viewport_px
};
let mut batch = SpriteBatch::new(self.desc);
batch.set_camera(screen_camera(vp));
for s in &self.input.sprites {
batch.push_sprite(s);
}
batch.extend_uploads(self.uploads.clone());
batch.prepare(device, queue, encoder, screen, resources)
}
fn paint(
&self,
info: repose_core::PaintCallbackInfo,
rpass: &mut wgpu::RenderPass<'static>,
resources: &CallbackResources,
) {
let d = info.pixels_per_point.max(0.0001);
let vp = [info.viewport.w, info.viewport.h];
let fit = contain_fit([vp[0] / d, vp[1] / d], self.input.world_size);
let cam = [self.input.cam.center.x, self.input.cam.center.y];
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,
};
}
draw_batch(rpass, resources);
}
}
#[allow(non_snake_case)]
pub fn ActorFrame(
center: [f32; 2],
size: [f32; 2],
geom: Rc<Cell<FrameGeom>>,
mirror_x: bool,
child: View,
) -> View {
let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
let mut modifier = Modifier::new()
.size(w, h)
.absolute()
.offset(Some(ox), Some(oy), None, None);
if mirror_x {
modifier = modifier.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 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],
};
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"
);
}
}