use core::ops::DerefMut;
use unlit_ecs::{Entity, World};
use unlit_wgpu::globals::{Globals, View};
use unlit_wgpu::pipeline::{CAMERA_BINDING, FRAME_BINDING, SpecializedUnlitPipeline};
use unlit_wgpu::resources::{ResHandle, ResourceGraph};
use unlit_wgpu::scene::Scene;
use unlit_wgpu::specialize::SpecializedPipeline;
use unlit_wgpu::specialize::SurfaceKey;
use unlit_wgpu::ui::{EguiIntegration, ScreenDescriptor, screen_view, ui_variant};
use web_time::Instant;
use zerocopy::IntoBytes;
use crate::input::InputHandle;
pub use crate::source::InputCapture;
use crate::source::{FrameSource, FrameSourceOrder, RenderContext, frame_target};
pub use egui;
pub mod convert;
type PanelCallback = Box<dyn FnMut(&World, Entity, &mut egui::Ui)>;
pub struct UiPanel(pub PanelCallback);
impl UiPanel {
pub fn new(f: impl FnMut(&World, Entity, &mut egui::Ui) + 'static) -> Self {
Self(Box::new(f))
}
pub fn run(&mut self, world: &World, entity: Entity, ui: &mut egui::Ui) {
(self.0)(world, entity, ui)
}
}
struct Gpu {
camera: wgpu::Buffer,
globals: wgpu::Buffer,
global_group: ResHandle<wgpu::BindGroup>,
integration: EguiIntegration,
}
pub struct UiSource {
ctx: egui::Context,
start: Instant,
scene: Scene,
gpu: Option<Gpu>,
surface: Option<SurfaceKey>,
input: Option<Entity>,
}
impl UiSource {
pub fn new() -> Self {
Self {
ctx: egui::Context::default(),
start: Instant::now(),
scene: Scene::new(),
gpu: None,
surface: None,
input: None,
}
}
pub fn context(&self) -> &egui::Context {
&self.ctx
}
pub fn context_mut(&mut self) -> &mut egui::Context {
&mut self.ctx
}
fn graph<'w>(
world: &'w World,
ctx: RenderContext,
) -> impl DerefMut<Target = ResourceGraph> + 'w {
world
.get_mut::<ResourceGraph>(ctx.graph)
.expect("the context's resource graph exists")
}
fn run_panels(&self, world: &World, input: egui::RawInput) -> egui::FullOutput {
self.ctx.run_ui(input, |ui| {
for (entity, mut panel) in world.query::<&mut UiPanel>() {
panel.run(world, entity, ui);
}
})
}
fn build_gpu(&mut self, device: &wgpu::Device, graph: &mut ResourceGraph, surface: SurfaceKey) {
let variant = ui_variant(device, surface.color_format.is_srgb(), surface);
let pipeline = SpecializedPipeline::create(device, variant);
match &mut self.gpu {
Some(gpu) => {
let group = global_group(device, &pipeline, &gpu.camera, &gpu.globals);
graph
.replace(&gpu.global_group, group)
.expect("the global group is registered in the graph");
gpu.integration.retarget(pipeline);
}
None => {
let camera = uniform_buffer(
device,
"unlit3d::ui::camera",
<View as const_shader_layout::ShaderLayout>::SIZE.get(),
);
let globals = uniform_buffer(
device,
"unlit3d::ui::globals",
<Globals as const_shader_layout::ShaderLayout>::SIZE.get(),
);
let camera_id = graph.insert(camera.clone(), None);
let globals_id = graph.insert(globals.clone(), None);
let group = global_group(device, &pipeline, &camera, &globals);
let group_id = graph.insert(group, None);
graph.add_dependency(&group_id, &camera_id);
graph.add_dependency(&group_id, &globals_id);
self.gpu = Some(Gpu {
camera,
globals,
global_group: group_id.clone(),
integration: EguiIntegration::new(device, group_id, pipeline),
});
}
}
self.surface = Some(surface);
}
}
impl Default for UiSource {
fn default() -> Self {
Self::new()
}
}
impl FrameSource for UiSource {
fn build_scene(
&mut self,
world: &World,
ctx: RenderContext,
encoder: &mut wgpu::CommandEncoder,
) {
let maintain = ctx.maintain_scope(world);
self.scene.clear();
let Some(target) = frame_target(world) else {
log::warn!(
"UiSource::build_scene: no FrameTarget in the world, so the \
frame's render target is unknown and no UI is drawn; write \
one by binding a render target before rendering"
);
return;
};
let device = world
.get::<wgpu::Device>(ctx.device)
.expect("the context's device resource exists")
.clone();
let queue = world
.get::<wgpu::Queue>(ctx.queue)
.expect("the context's queue resource exists")
.clone();
let input_state = world
.query::<&InputHandle>()
.next()
.map(|(entity, handle)| {
let state = handle.read();
let pixels_per_point = if state.scale_factor > 0.0 {
state.scale_factor
} else {
1.0
};
(
entity,
state.scale_factor,
state.size_px,
state.focused,
convert::to_egui_events(state.events(), pixels_per_point),
)
});
self.input = input_state.as_ref().map(|(entity, ..)| *entity);
let (scale_factor, size_px, focused, events) = match input_state {
Some((_, scale_factor, size_px, focused, events)) => {
(scale_factor, size_px, focused, events)
}
None => (1.0, (target.width, target.height), true, Vec::new()),
};
let size_px = if size_px == (0, 0) {
(target.width, target.height)
} else {
size_px
};
let pixels_per_point = if scale_factor > 0.0 {
scale_factor
} else {
1.0
};
let screen = ScreenDescriptor {
size_in_pixels: [size_px.0, size_px.1],
pixels_per_point,
};
let mut graph = Self::graph(world, ctx);
if self.gpu.is_none() || self.surface != Some(target.surface) {
self.build_gpu(&device, &mut graph, target.surface);
}
let points = screen.size_in_points();
let mut input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::Vec2::new(points[0], points[1]),
)),
time: Some(self.start.elapsed().as_secs_f64()),
focused,
events,
max_texture_side: Some(device.limits().max_texture_dimension_2d as usize),
..Default::default()
};
input
.viewports
.get_mut(&input.viewport_id)
.expect("the root viewport is always present")
.native_pixels_per_point = Some(pixels_per_point);
let output = self.run_panels(world, input);
let gpu = self.gpu.as_mut().expect("built above");
gpu.integration
.update(&mut graph, &queue, encoder, &self.ctx, output, screen);
queue.write_buffer(
&gpu.camera,
0,
screen_view(screen.size_in_points()).as_bytes(),
);
queue.write_buffer(&gpu.globals, 0, Globals::default().as_bytes());
drop(graph);
drop(maintain);
let graph = Self::graph(world, ctx);
let mut ui_scene = gpu.integration.scene(&graph);
self.scene.extend(&mut ui_scene);
self.surface = Some(target.surface);
publish_capture(world, &self.ctx);
}
fn scene(&self) -> &Scene {
&self.scene
}
fn order(&self) -> FrameSourceOrder {
FrameSourceOrder::UI
}
}
fn publish_capture(world: &World, ctx: &egui::Context) {
let capture = InputCapture {
pointer: ctx.egui_wants_pointer_input(),
keyboard: ctx.egui_wants_keyboard_input(),
};
let Some(entity) = world.query::<&InputCapture>().next().map(|(e, _)| e) else {
return;
};
let _ = world.with_mut::<InputCapture, _>(entity, |slot| *slot = capture);
}
fn uniform_buffer(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn global_group(
device: &wgpu::Device,
pipeline: &SpecializedUnlitPipeline,
camera: &wgpu::Buffer,
globals: &wgpu::Buffer,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("unlit3d::ui::globals"),
layout: &pipeline.descriptor().bind_group_layouts(device).global,
entries: &[
wgpu::BindGroupEntry {
binding: CAMERA_BINDING,
resource: camera.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: FRAME_BINDING,
resource: globals.as_entire_binding(),
},
],
})
}
#[cfg(test)]
mod tests {
use super::*;
use core::cell::{Cell, RefCell};
use std::rc::Rc;
use unlit_wgpu::capabilities::DeviceCapabilities;
use crate::source::{FrameTarget, set_frame_target, spawn_context};
struct Runs(u32);
fn test_target() -> FrameTarget {
FrameTarget {
surface: SurfaceKey {
color_format: wgpu::TextureFormat::Rgba8UnormSrgb,
depth_stencil_format: None,
sample_count: 1,
},
width: 128,
height: 96,
}
}
fn test_world() -> (World, RenderContext) {
let mut world = World::new();
let (device, queue) = wgpu::Device::noop(&wgpu::DeviceDescriptor::default());
let ctx = spawn_context(
&mut world,
device,
queue,
ResourceGraph::new(),
DeviceCapabilities::default(),
);
(world, ctx)
}
fn encoder(world: &World, ctx: RenderContext) -> wgpu::CommandEncoder {
world
.get::<wgpu::Device>(ctx.device)
.expect("the context's device")
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("test::encoder"),
})
}
#[test]
fn each_panel_runs_once_and_may_write_a_sibling() {
let mut world = World::new();
let runs = Rc::new(Cell::new(0u32));
for _ in 0..2 {
let runs = Rc::clone(&runs);
world.spawn((
Runs(0),
UiPanel::new(move |world, entity, ui| {
runs.set(runs.get() + 1);
let seen = world.get::<Runs>(entity).expect("the sibling exists").0;
let _ = world.with_mut::<Runs, _>(entity, |runs| runs.0 = seen + 1);
ui.label("panel");
}),
));
}
let source = UiSource::new();
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::Vec2::new(128.0, 96.0),
)),
..Default::default()
};
let mut output = source.run_panels(&world, input);
output.textures_delta.clear();
let panels: Vec<Entity> = world
.query::<&UiPanel>()
.map(|(entity, _)| entity)
.collect();
assert_eq!(panels.len(), 2, "both panels are driven by one query");
for entity in panels {
assert_eq!(
world.get::<Runs>(entity).expect("the sibling").0,
1,
"the panel ran once and wrote its sibling"
);
}
assert_eq!(runs.get(), 2, "one closure call per panel");
}
#[test]
fn a_panel_is_driven_by_the_source() {
let (mut world, ctx) = test_world();
assert!(set_frame_target(&world, test_target()));
world.spawn((UiPanel::new(|_world, _entity, ui| {
ui.painter().rect_filled(
egui::Rect::from_min_size(egui::Pos2::new(8.0, 8.0), egui::Vec2::new(64.0, 32.0)),
0.0,
egui::Color32::from_rgb(0, 255, 0),
);
}),));
let mut source = UiSource::new();
let mut encoder = encoder(&world, ctx);
source.build_scene(&world, ctx, &mut encoder);
assert!(
!source.scene().is_empty(),
"the panel's rectangle became a draw"
);
}
#[test]
fn a_missing_frame_target_draws_nothing() {
let (mut world, ctx) = test_world();
world.spawn((UiPanel::new(|_world, _entity, ui| {
ui.label("not drawn");
}),));
let mut source = UiSource::new();
let mut encoder = encoder(&world, ctx);
source.build_scene(&world, ctx, &mut encoder);
assert!(source.scene().is_empty());
assert!(source.gpu.is_none(), "no target was specialized for");
}
#[test]
fn a_fresh_source_declares_the_overlay_order() {
let source = UiSource::new();
assert_eq!(source.order(), FrameSourceOrder::UI);
}
#[test]
fn a_panel_may_queue_a_spawn() {
struct Spawned;
let world = World::new();
let source = UiSource::new();
let world = Rc::new(RefCell::new(world));
{
let world = Rc::clone(&world);
world
.borrow_mut()
.spawn((UiPanel::new(move |world, _entity, ui| {
if world.query::<&Spawned>().next().is_none() {
world.queue().spawn((Spawned,));
}
ui.label("once");
}),));
}
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::Vec2::new(128.0, 96.0),
)),
..Default::default()
};
let mut output = {
let world = world.borrow();
source.run_panels(&world, input.clone())
};
output.textures_delta.clear();
assert_eq!(
world.borrow().query::<&Spawned>().count(),
0,
"a queued spawn has not happened yet"
);
world.borrow_mut().apply();
assert_eq!(
world.borrow().query::<&Spawned>().count(),
1,
"the queue landed on apply"
);
let mut output = source.run_panels(&world.borrow(), input);
output.textures_delta.clear();
world.borrow_mut().apply();
assert_eq!(
world.borrow().query::<&Spawned>().count(),
1,
"the panel re-ran and found what it asked for"
);
}
#[test]
fn every_pass_drives_every_panel() {
let mut world = World::new();
let runs = Rc::new(Cell::new(0u32));
for _ in 0..2 {
let runs = Rc::clone(&runs);
world.spawn((UiPanel::new(move |_world, _entity, _ui| {
runs.set(runs.get() + 1);
}),));
}
let source = UiSource::new();
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::Vec2::new(128.0, 96.0),
)),
..Default::default()
};
let mut seen = Vec::new();
for _ in 0..2 {
let mut output = source.run_panels(&world, input.clone());
output.textures_delta.clear();
seen.push(runs.get());
}
assert_eq!(
seen,
vec![2, 4],
"each pass drove both panels again, so the query was rebuilt"
);
}
#[test]
fn a_filter_selects_which_panels_a_caller_collects() {
use unlit_ecs::With;
struct Foreground;
let mut world = World::new();
world.spawn((Foreground, UiPanel::new(|_world, _entity, _ui| {})));
world.spawn((UiPanel::new(|_world, _entity, _ui| {}),));
let foreground: Vec<Entity> = world
.query_filtered::<Entity, With<Foreground>>()
.map(|(entity, _)| entity)
.collect();
assert_eq!(foreground.len(), 1, "the filter picked one of two panels");
let all: Vec<Entity> = world.query::<&UiPanel>().map(|(e, _)| e).collect();
assert_eq!(all.len(), 2, "the unfiltered query sees both");
assert!(
all.contains(&foreground[0]),
"the filtered panel is one of them"
);
}
#[test]
fn the_frame_events_reach_the_panels() {
let (mut world, ctx) = test_world();
assert!(set_frame_target(&world, test_target()));
let seen: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
let recorded = Rc::clone(&seen);
world.spawn((UiPanel::new(move |_world, _entity, ui| {
ui.text_edit_singleline(&mut String::new());
let pressed = ui.input(|input| {
input
.events
.iter()
.filter_map(|event| match event {
egui::Event::Text(text) => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
});
recorded.borrow_mut().extend(pressed);
}),));
let input = InputHandle::new();
world.spawn((input.clone(),));
{
let mut state = input.write();
state.set_size_px(128, 96);
state.push(crate::input::InputEvent::Text(crate::input::TextEvent(
"hi".to_string(),
)));
}
let mut source = UiSource::new();
let mut encoder = encoder(&world, ctx);
source.build_scene(&world, ctx, &mut encoder);
input.write().clear_events();
source.build_scene(&world, ctx, &mut encoder);
assert_eq!(
&*seen.borrow(),
&["hi".to_string()],
"the frame's text event reached the panel's egui input, once"
);
}
#[test]
fn a_mouse_click_reaches_a_widget() {
fn button() -> egui::Rect {
egui::Rect::from_min_size(egui::Pos2::new(8.0, 8.0), egui::Vec2::new(48.0, 24.0))
}
let clicked = Rc::new(Cell::new(false));
let (mut world, ctx) = test_world();
assert!(set_frame_target(&world, test_target()));
let flag = Rc::clone(&clicked);
world.spawn((UiPanel::new(move |_world, _entity, ui| {
let response = ui.allocate_rect(button(), egui::Sense::click());
if response.clicked() {
flag.set(true);
}
}),));
let input = InputHandle::new();
world.spawn((input.clone(),));
input.write().set_size_px(128, 96);
let mut source = UiSource::new();
let mut encoder = encoder(&world, ctx);
source.build_scene(&world, ctx, &mut encoder);
assert!(!clicked.get(), "nothing is clicked before any input");
{
let mut state = input.write();
let centre = [button().center().x, button().center().y];
for event in [
crate::input::MouseEvent::Moved { position: centre },
crate::input::MouseEvent::Button {
position: centre,
button: crate::input::MouseButton::Primary,
pressed: true,
modifiers: crate::input::Modifiers::default(),
},
crate::input::MouseEvent::Button {
position: centre,
button: crate::input::MouseButton::Primary,
pressed: false,
modifiers: crate::input::Modifiers::default(),
},
] {
state.push(crate::input::InputEvent::Mouse(event));
}
}
source.build_scene(&world, ctx, &mut encoder);
assert!(
clicked.get(),
"a press and release inside a widget must click it"
);
}
#[test]
fn the_ui_publishes_what_it_claimed() {
let (mut world, ctx) = test_world();
assert!(set_frame_target(&world, test_target()));
world.spawn((UiPanel::new(|_world, _entity, ui| {
let _ = ui.allocate_rect(
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::Vec2::splat(64.0)),
egui::Sense::click(),
);
}),));
let input = InputHandle::new();
world.spawn((input.clone(),));
{
let mut state = input.write();
state.set_size_px(128, 96);
state.push(crate::input::InputEvent::Mouse(
crate::input::MouseEvent::Moved {
position: [16.0, 16.0],
},
));
}
let mut source = UiSource::new();
let mut encoder = encoder(&world, ctx);
source.build_scene(&world, ctx, &mut encoder);
source.build_scene(&world, ctx, &mut encoder);
let capture = world
.query::<&InputCapture>()
.next()
.map(|(_, capture)| *capture)
.expect("the frame context spawns a capture resource");
assert!(
capture.pointer,
"a widget under the pointer claims it: {capture:?}"
);
assert!(capture.any());
}
#[test]
fn a_world_without_input_state_draws_an_idle_frame() {
let (mut world, ctx) = test_world();
assert!(set_frame_target(&world, test_target()));
world.spawn((UiPanel::new(|_world, _entity, ui| {
ui.painter().rect_filled(
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::Vec2::splat(16.0)),
0.0,
egui::Color32::RED,
);
}),));
let mut source = UiSource::new();
let mut encoder = encoder(&world, ctx);
source.build_scene(&world, ctx, &mut encoder);
assert!(
!source.scene().is_empty(),
"a panel draws with no input in the world"
);
}
}