spottedcat 2.0.0

Rusty SpottedCat simple game engine
Documentation
use crate::graphics::core::Graphics;
use crate::window::{SceneHost, make_screen_target};
use crate::{Context, Spot};
use std::pin::Pin;
use std::time::Duration;

#[doc(hidden)]
#[derive(Clone, Copy, Debug)]
pub struct SpotRendererOptions {
    pub target_format: wgpu::TextureFormat,
    pub initial_size: [u32; 2],
    pub scale_factor: f64,
    pub transparent: bool,
}

impl Default for SpotRendererOptions {
    fn default() -> Self {
        Self {
            target_format: wgpu::TextureFormat::Rgba8Unorm,
            initial_size: [1, 1],
            scale_factor: 1.0,
            transparent: false,
        }
    }
}

impl SpotRendererOptions {
    fn validate(self) -> anyhow::Result<Self> {
        if self.initial_size[0] == 0 || self.initial_size[1] == 0 {
            anyhow::bail!(
                "initial render target dimensions must be non-zero, got {}x{}",
                self.initial_size[0],
                self.initial_size[1]
            );
        }
        if !self.scale_factor.is_finite() || self.scale_factor <= 0.0 {
            anyhow::bail!(
                "scale factor must be finite and positive, got {}",
                self.scale_factor
            );
        }
        Ok(self)
    }
}

#[doc(hidden)]
pub struct SpotRenderer {
    ctx: Pin<Box<Context>>,
    scene: SceneHost,
    scale_factor: f64,
    removed: bool,
}

impl SpotRenderer {
    pub fn from_wgpu<T: Spot + 'static>(
        adapter: wgpu::Adapter,
        device: wgpu::Device,
        queue: wgpu::Queue,
        options: SpotRendererOptions,
    ) -> anyhow::Result<Self> {
        let options = options.validate()?;
        let mut ctx = Box::pin(Context::new());
        ctx.update_window_metrics_physical(
            options.initial_size[0],
            options.initial_size[1],
            options.scale_factor,
        );
        ctx.attach_graphics(Graphics::from_wgpu(
            adapter,
            device,
            queue,
            options.target_format,
            options.initial_size[0],
            options.initial_size[1],
            options.transparent,
        ));

        let mut scene = SceneHost::new::<T>();
        scene.initialize_if_missing(&mut ctx);

        Ok(Self {
            ctx,
            scene,
            scale_factor: options.scale_factor,
            removed: false,
        })
    }

    pub fn update(&mut self, dt: Duration) -> anyhow::Result<()> {
        self.ensure_active()?;
        self.ctx.set_delta_time(dt);
        if let Some(spot) = self.scene.spot_mut() {
            spot.update(&mut self.ctx, dt);
        }
        self.ctx.input_mut().end_frame();
        Ok(())
    }

    pub fn render_to_view(
        &mut self,
        target: &wgpu::TextureView,
        size: [u32; 2],
    ) -> anyhow::Result<()> {
        self.ensure_active()?;
        if size[0] == 0 || size[1] == 0 {
            anyhow::bail!(
                "render target dimensions must be non-zero, got {}x{}",
                size[0],
                size[1]
            );
        }

        self.ctx
            .update_window_metrics_physical(size[0], size[1], self.scale_factor);
        self.ctx.begin_frame();
        let screen = make_screen_target(&self.ctx);
        if let Some(spot) = self.scene.spot_mut() {
            spot.draw(&mut self.ctx, screen);
        }
        self.scene.apply_pending_switch(&mut self.ctx);

        let mut graphics = self
            .ctx
            .detach_graphics()
            .ok_or_else(|| anyhow::anyhow!("Spot renderer GPU state is not attached"))?;
        let result = graphics.render_to_view(&mut self.ctx, target, size);
        self.ctx.attach_graphics(graphics);
        result
    }

    pub fn set_scale_factor(&mut self, scale_factor: f64) -> anyhow::Result<()> {
        if !scale_factor.is_finite() || scale_factor <= 0.0 {
            anyhow::bail!(
                "scale factor must be finite and positive, got {}",
                scale_factor
            );
        }
        self.scale_factor = scale_factor;
        Ok(())
    }

    pub fn set_draw_interpolation(&mut self, alpha: f32) -> anyhow::Result<()> {
        self.ensure_active()?;
        if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) {
            anyhow::bail!("draw interpolation must be between 0 and 1, got {alpha}");
        }
        self.ctx.set_draw_alpha(alpha);
        Ok(())
    }

    pub fn set_input_focus(&mut self, focused: bool) -> anyhow::Result<()> {
        self.ensure_active()?;
        self.ctx.input_mut().handle_focus(focused);
        Ok(())
    }

    pub fn set_cursor_position(&mut self, position: [f32; 2]) -> anyhow::Result<()> {
        self.ensure_active()?;
        if !position[0].is_finite() || !position[1].is_finite() {
            anyhow::bail!(
                "cursor position must be finite, got [{}, {}]",
                position[0],
                position[1]
            );
        }
        self.ctx
            .input_mut()
            .handle_cursor_moved(crate::Pt::from(position[0]), crate::Pt::from(position[1]));
        Ok(())
    }

    #[cfg(not(target_os = "android"))]
    pub fn handle_mouse_input(
        &mut self,
        state: winit::event::ElementState,
        button: winit::event::MouseButton,
    ) -> anyhow::Result<()> {
        self.ensure_active()?;
        self.ctx.input_mut().handle_mouse_input(state, button);
        Ok(())
    }

    #[cfg(not(target_os = "android"))]
    pub fn handle_mouse_wheel(
        &mut self,
        delta: winit::event::MouseScrollDelta,
    ) -> anyhow::Result<()> {
        self.ensure_active()?;
        self.ctx.input_mut().handle_mouse_wheel(delta);
        Ok(())
    }

    #[cfg(not(target_os = "android"))]
    pub fn handle_keyboard_input(
        &mut self,
        state: winit::event::ElementState,
        physical_key: winit::keyboard::PhysicalKey,
    ) -> anyhow::Result<()> {
        self.ensure_active()?;
        self.ctx
            .input_mut()
            .handle_keyboard_input(state, physical_key);
        Ok(())
    }

    pub fn end_input_frame(&mut self) -> anyhow::Result<()> {
        self.ensure_active()?;
        self.ctx.input_mut().end_frame();
        Ok(())
    }

    pub fn resumed(&mut self) -> anyhow::Result<()> {
        self.ensure_active()?;
        if let Some(spot) = self.scene.spot_mut() {
            spot.resumed(&mut self.ctx);
        }
        Ok(())
    }

    pub fn suspended(&mut self) -> anyhow::Result<()> {
        self.ensure_active()?;
        if let Some(spot) = self.scene.spot_mut() {
            spot.suspended(&mut self.ctx);
        }
        self.ctx.clear_transient_input();
        self.ctx.clear_transient_state();
        Ok(())
    }

    pub fn remove(&mut self) {
        if self.removed {
            return;
        }
        self.scene.remove_current(&mut self.ctx);
        if let Some(graphics) = self.ctx.graphics_mut() {
            graphics.finish_profiling();
        }
        self.removed = true;
    }

    fn ensure_active(&self) -> anyhow::Result<()> {
        if self.removed {
            anyhow::bail!("Spot renderer has been removed");
        }
        Ok(())
    }
}

impl Drop for SpotRenderer {
    fn drop(&mut self) {
        self.remove();
    }
}

#[cfg(test)]
mod tests {
    use super::{SpotRenderer, SpotRendererOptions};
    use crate::{Context, Image, Spot};
    use std::time::Duration;

    struct EmptyScene;

    impl Spot for EmptyScene {
        fn initialize(_ctx: &mut Context) -> Self {
            Self
        }

        fn draw(&mut self, _ctx: &mut Context, _screen: Image) {}
    }

    #[test]
    fn default_options_match_editor_viewport_format() {
        let options = SpotRendererOptions::default();
        assert_eq!(options.target_format, wgpu::TextureFormat::Rgba8Unorm);
        assert_eq!(options.initial_size, [1, 1]);
        assert_eq!(options.scale_factor, 1.0);
        assert!(!options.transparent);
    }

    #[test]
    fn options_reject_invalid_dimensions_and_scale() {
        assert!(
            SpotRendererOptions {
                initial_size: [0, 1],
                ..Default::default()
            }
            .validate()
            .is_err()
        );
        assert!(
            SpotRendererOptions {
                scale_factor: 0.0,
                ..Default::default()
            }
            .validate()
            .is_err()
        );
    }

    #[test]
    #[cfg(not(target_arch = "wasm32"))]
    fn shared_gpu_renderer_renders_to_external_view() {
        let instance = wgpu::Instance::default();
        let Ok(adapter) =
            crate::platform::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
                power_preference: wgpu::PowerPreference::LowPower,
                compatible_surface: None,
                force_fallback_adapter: false,
            }))
        else {
            eprintln!("skipping shared GPU test because no adapter is available");
            return;
        };
        let (device, queue) =
            crate::platform::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
                label: Some("spot_shared_gpu_test_device"),
                required_features: wgpu::Features::empty(),
                required_limits: adapter.limits(),
                experimental_features: wgpu::ExperimentalFeatures::default(),
                memory_hints: wgpu::MemoryHints::default(),
                trace: wgpu::Trace::Off,
            }))
            .expect("request test device");
        let poll_device = device.clone();
        let target = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("spot_shared_gpu_test_target"),
            size: wgpu::Extent3d {
                width: 8,
                height: 8,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8Unorm,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            view_formats: &[],
        });
        let view = target.create_view(&wgpu::TextureViewDescriptor::default());
        let mut renderer = SpotRenderer::from_wgpu::<EmptyScene>(
            adapter,
            device,
            queue,
            SpotRendererOptions {
                initial_size: [8, 8],
                ..Default::default()
            },
        )
        .expect("construct shared GPU renderer");

        renderer
            .render_to_view(&view, [8, 8])
            .expect("render to external view");
        poll_device
            .poll(wgpu::PollType::Wait {
                submission_index: None,
                timeout: Some(Duration::from_secs(5)),
            })
            .expect("wait for shared GPU render");
    }
}