diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92fc49bd..2bf7f594 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,10 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + # sdl2-sys bundled build ships an older SDL2 CMakeLists.txt that uses + # cmake_minimum_required(VERSION 3.1.3). Newer CMake versions (4.0+) + # removed compatibility with policies < 3.5; this env var restores it. + CMAKE_POLICY_VERSION_MINIMUM: "3.5" jobs: # Fast sanity checks to fail quickly before heavy matrix jobs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 38b83a07..f0b03a43 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -117,6 +117,8 @@ The `RenderBackend` trait abstracts the GPU backend. Both 2D (SpriteBatch) and 3 | `rapier3d` | `rapier3d` | | `headless` | (empty) | | `xbox-gdk` | `wgpu-backend` | +| `sdl-window` | `wgpu-backend`, `sdl2` | +| `switch-vulkan` | `wgpu-backend` | ### SpriteBatch (2D) diff --git a/Cargo.lock b/Cargo.lock index ff1b233c..660204fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1837,6 +1837,7 @@ dependencies = [ "rodio", "rustls", "rustybuzz", + "sdl2", "serde", "serde_json", "smallvec", @@ -4333,6 +4334,31 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "sdl2" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "raw-window-handle", + "sdl2-sys", +] + +[[package]] +name = "sdl2-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" +dependencies = [ + "cfg-if", + "cmake", + "libc", + "version-compare", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5272,6 +5298,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + [[package]] name = "version_check" version = "0.9.5" diff --git a/docs/findings/switch-vulkan-poc.md b/docs/findings/switch-vulkan-poc.md new file mode 100644 index 00000000..e65d4f71 --- /dev/null +++ b/docs/findings/switch-vulkan-poc.md @@ -0,0 +1,129 @@ +# Nintendo Switch Vulkan Feasibility PoC -- Findings Report + +**Issue**: #341 (parent: #135 Console Strategy) +**Date**: 2026-04-02 +**Status**: Architecture validated, Nintendo SDK access required for runtime testing + +## Executive Summary + +GoudEngine's wgpu-based rendering architecture is structurally compatible with +Nintendo Switch's Vulkan 1.1 support. The engine's trait abstraction and runtime +backend selection support plugging in a Switch platform without modifying higher +layers. A `switch-vulkan` feature flag enables Switch-specific paths. + +**Verdict**: Conditionally feasible. wgpu's Vulkan backend aligns with Switch's +Vulkan 1.1 support, but significant blockers exist around the proprietary SDK, +custom Rust target, and NVN-to-Vulkan translation layer performance. + +## Architecture Integration + +The following components were added under the `switch-vulkan` feature flag: + +| Component | File | Purpose | +|-----------|------|---------| +| `SwitchVulkanPlatform` | `libs/platform/switch_vulkan_platform.rs` | `PlatformBackend` impl for Switch `nn::vi` windowing | +| `SwitchWindowHandle` | `libs/graphics/backend/wgpu_backend/switch_surface.rs` | `raw-window-handle` wrapper for wgpu surface creation | +| `switch_init` | `libs/graphics/backend/wgpu_backend/switch_init.rs` | wgpu Vulkan initialization from Switch handle | +| `WindowBackendKind::SwitchVulkan` | `libs/platform/mod.rs` | Enum variant for runtime backend selection | +| native_runtime arms | `libs/platform/native_runtime.rs` | Detection, validation, construction for Switch pair | + +All components compile and type-check on macOS (x86_64 and aarch64). The +constructors return `BackendNotSupported` on non-aarch64 targets, allowing +cross-platform CI to verify the code without a Switch devkit. + +## wgpu Vulkan on Nintendo Switch + +### Compatibility + +Nintendo Switch supports Vulkan 1.1 via a translation layer over NVN (Nintendo's +proprietary low-level API). wgpu's Vulkan backend targets Vulkan 1.0+ and should +work, but: +- The Switch's Vulkan driver has known conformance gaps +- Some Vulkan extensions used by wgpu may not be available +- Performance overhead from NVN translation is unknown + +### Surface Creation + +Switch uses `nn::vi` for display/layer management. The Vulkan surface is created +from an `nn::vi::Layer` handle, not from a standard window handle. This requires +a custom `raw-window-handle` implementation. + +Risk: Medium. No standard windowing model -- requires manual `RawWindowHandle` +construction from `nn::vi` native window pointers. + +## Shader Pipeline + +GoudEngine uses naga for shader translation (GLSL -> WGSL -> SPIR-V). The Switch +Vulkan driver accepts SPIR-V, so the existing pipeline should work without +modification. However: +- naga's SPIR-V output should be tested against Switch's Vulkan conformance +- Some SPIR-V features may not be supported by the Switch driver +- Shader compilation performance on Switch hardware is unknown + +## Blocking Issues + +| # | Issue | Severity | Mitigation | +|---|-------|----------|------------| +| 1 | No official Rust target for Switch (`aarch64-nintendo-switch-none`) | High | Custom target JSON + tier 3 LLVM support | +| 2 | NintendoSDK is NDA-protected, not publicly available | High | Requires Nintendo developer program membership | +| 3 | Vulkan loader: Switch uses `nn::gfx` loader, not standard `libvulkan.so` | High | Custom Vulkan loader integration in wgpu | +| 4 | Audio: `nn::audio` API, not ALSA/PulseAudio | High | Custom audio backend via FFI | +| 5 | Input: `nn::hid` for Joy-Con/Pro Controller | Medium | Extend InputManager with Switch mappings | +| 6 | Performance: NVN-to-Vulkan translation overhead unknown | Medium | Benchmark once SDK available | +| 7 | File I/O: `nn::fs` for ROM/save data access | Medium | Custom asset loader path | + +## Audio/Input/Filesystem Gaps + +### Audio +The Switch uses `nn::audio` for sound output. GoudEngine's current audio backend +(rodio on desktop) would need a Switch-specific implementation wrapping `nn::audio` +via FFI. The `AudioManager` trait abstraction exists but has no Switch backend. + +### Input +Switch input uses `nn::hid` for Joy-Con and Pro Controller support. The engine's +`InputManager` would need: +- Joy-Con button mapping (including gyro/accelerometer) +- Handheld vs. docked controller detection +- Touch screen support in handheld mode + +### Filesystem +Switch uses `nn::fs` for ROM access and save data. The engine's `AssetServer` +would need a custom loader path that reads from the ROM filesystem via `nn::fs` +instead of standard `std::fs` operations. + +## Build Toolchain + +- Target: `aarch64-nintendo-switch-none` (custom target JSON) +- SDK: NintendoSDK (requires Nintendo developer program membership) +- Linker: Nintendo toolchain (part of SDK) +- Sysroot: SDK sysroot for aarch64 +- Rust: Nightly required for custom target support + +## Certification Requirements + +Nintendo Lot Check requirements affecting engine: +- Must handle HOME button suspend/resume correctly +- Must support handheld/docked mode switching (720p to 1080p resolution change) +- Must meet performance requirements (30fps minimum) +- Must handle controller disconnect/reconnect gracefully +- Must support save data management via `nn::account` + `nn::fs` + +## Estimated Development Effort + +| Phase | Work | Weeks | +|-------|------|-------| +| SDK setup + custom Rust target | | 2-3 | +| Vulkan surface + basic rendering | | 3-4 | +| Audio + input integration | | 2-3 | +| File I/O + asset loading | | 1-2 | +| Performance optimization | | 2-3 | +| Lot Check compliance | | 2-3 | +| **Total** | | **12-18** | + +## Recommended Next Steps + +1. Join Nintendo developer program and obtain NintendoSDK +2. Create custom Rust target JSON for `aarch64-nintendo-switch` +3. Test wgpu Vulkan backend on Switch devkit +4. If Vulkan works: plan audio, input, filesystem as separate issues +5. If Vulkan blocked: evaluate NVN backend as alternative (requires writing custom wgpu backend) diff --git a/goud_engine/Cargo.toml b/goud_engine/Cargo.toml index b00e365d..91b52607 100644 --- a/goud_engine/Cargo.toml +++ b/goud_engine/Cargo.toml @@ -27,6 +27,8 @@ rapier2d = ["dep:rapier2d"] rapier3d = ["dep:rapier3d"] headless = [] xbox-gdk = ["wgpu-backend"] +sdl-window = ["wgpu-backend", "dep:sdl2"] +switch-vulkan = ["wgpu-backend"] # TODO: https://github.com/aram-devdocs/GoudEngine/issues/8 [dependencies] @@ -83,6 +85,7 @@ web-sys = { version = "0.3", optional = true, features = [ "AudioDestinationNode", "BaseAudioContext", "Blob", "Url", ] } +sdl2 = { version = "0.38", optional = true, features = ["bundled", "raw-window-handle"] } bumpalo = "3.20.2" smallvec = "1.15.1" diff --git a/goud_engine/src/libs/graphics/backend/mod.rs b/goud_engine/src/libs/graphics/backend/mod.rs index b2b7d68f..c6bfbce4 100644 --- a/goud_engine/src/libs/graphics/backend/mod.rs +++ b/goud_engine/src/libs/graphics/backend/mod.rs @@ -23,7 +23,12 @@ pub mod blend; pub mod capabilities; -#[cfg(any(feature = "native", feature = "xbox-gdk"))] +#[cfg(any( + feature = "native", + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" +))] pub mod native_backend; pub mod null; #[cfg(feature = "legacy-glfw-opengl")] @@ -32,7 +37,9 @@ pub mod render_backend; pub mod types; #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] pub mod wgpu_backend; diff --git a/goud_engine/src/libs/graphics/backend/native_backend/mod.rs b/goud_engine/src/libs/graphics/backend/native_backend/mod.rs index 5a8ad282..36705343 100644 --- a/goud_engine/src/libs/graphics/backend/native_backend/mod.rs +++ b/goud_engine/src/libs/graphics/backend/native_backend/mod.rs @@ -19,7 +19,9 @@ use super::render_backend::RenderBackend; use super::render_backend::StateOps; #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] use super::wgpu_backend::WgpuBackend; @@ -30,7 +32,9 @@ pub enum NativeRenderBackend { OpenGlLegacy(Box), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] /// Default wgpu backend used by the native runtime. Wgpu(Box), @@ -43,7 +47,9 @@ impl NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.info(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.info(), } @@ -59,7 +65,9 @@ impl NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.bind_texture_by_index(index, unit), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.bind_texture_by_index(index, unit), } @@ -71,7 +79,9 @@ impl NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_viewport(0, 0, width, height), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.resize(width, height), } @@ -84,7 +94,9 @@ impl NativeRenderBackend { Self::OpenGlLegacy(_) => {} #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.drop_surface(), } @@ -97,7 +109,9 @@ impl NativeRenderBackend { Self::OpenGlLegacy(_) => Ok(()), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.recreate_surface(), } diff --git a/goud_engine/src/libs/graphics/backend/native_backend/native_draw.rs b/goud_engine/src/libs/graphics/backend/native_backend/native_draw.rs index 150fbdc5..933efd07 100644 --- a/goud_engine/src/libs/graphics/backend/native_backend/native_draw.rs +++ b/goud_engine/src/libs/graphics/backend/native_backend/native_draw.rs @@ -11,7 +11,9 @@ impl DrawOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_vertex_attributes(layout), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_vertex_attributes(layout), } @@ -23,7 +25,9 @@ impl DrawOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_vertex_bindings(bindings), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_vertex_bindings(bindings), } @@ -40,7 +44,9 @@ impl DrawOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.draw_arrays(topology, first, count), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.draw_arrays(topology, first, count), } @@ -57,7 +63,9 @@ impl DrawOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.draw_indexed(topology, count, offset), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.draw_indexed(topology, count, offset), } @@ -74,7 +82,9 @@ impl DrawOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.draw_indexed_u16(topology, count, offset), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.draw_indexed_u16(topology, count, offset), } @@ -94,7 +104,9 @@ impl DrawOps for NativeRenderBackend { } #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => { backend.draw_arrays_instanced(topology, first, count, instance_count) @@ -116,7 +128,9 @@ impl DrawOps for NativeRenderBackend { } #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => { backend.draw_indexed_instanced(topology, count, offset, instance_count) diff --git a/goud_engine/src/libs/graphics/backend/native_backend/native_impls.rs b/goud_engine/src/libs/graphics/backend/native_backend/native_impls.rs index 28b1598b..65a9dc51 100644 --- a/goud_engine/src/libs/graphics/backend/native_backend/native_impls.rs +++ b/goud_engine/src/libs/graphics/backend/native_backend/native_impls.rs @@ -14,7 +14,9 @@ impl RenderBackend for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.bind_default_vertex_array(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.bind_default_vertex_array(), } @@ -26,7 +28,9 @@ impl RenderBackend for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.validate_text_draw_state(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.validate_text_draw_state(), } @@ -42,7 +46,9 @@ impl RenderBackend for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.read_default_framebuffer_rgba8(width, height), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.read_default_framebuffer_rgba8(width, height), } @@ -56,7 +62,9 @@ impl FrameOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.begin_frame(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.begin_frame(), } @@ -68,7 +76,9 @@ impl FrameOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.end_frame(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.end_frame(), } @@ -82,7 +92,9 @@ impl ClearOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_clear_color(r, g, b, a), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_clear_color(r, g, b, a), } @@ -94,7 +106,9 @@ impl ClearOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.clear_color(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.clear_color(), } @@ -106,7 +120,9 @@ impl ClearOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.clear_depth(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.clear_depth(), } @@ -120,7 +136,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_viewport(x, y, width, height), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_viewport(x, y, width, height), } @@ -132,7 +150,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.enable_depth_test(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.enable_depth_test(), } @@ -144,7 +164,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.disable_depth_test(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.disable_depth_test(), } @@ -156,7 +178,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.enable_blending(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.enable_blending(), } @@ -168,7 +192,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.disable_blending(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.disable_blending(), } @@ -184,7 +210,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_blend_func(src, dst), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_blend_func(src, dst), } @@ -196,7 +224,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.enable_culling(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.enable_culling(), } @@ -208,7 +238,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.disable_culling(), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.disable_culling(), } @@ -220,7 +252,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_cull_face(face), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_cull_face(face), } @@ -232,7 +266,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_depth_func(func), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_depth_func(func), } @@ -244,7 +280,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_front_face(face), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_front_face(face), } @@ -256,7 +294,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_depth_mask(enabled), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_depth_mask(enabled), } @@ -268,7 +308,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_multisampling_enabled(enabled), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_multisampling_enabled(enabled), } @@ -280,7 +322,9 @@ impl StateOps for NativeRenderBackend { Self::OpenGlLegacy(backend) => backend.set_line_width(width), #[cfg(any( all(feature = "native", feature = "wgpu-backend"), - feature = "xbox-gdk" + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" ))] Self::Wgpu(backend) => backend.set_line_width(width), } diff --git a/goud_engine/src/libs/graphics/backend/native_backend/native_resources.rs b/goud_engine/src/libs/graphics/backend/native_backend/native_resources.rs index c72379fc..54bcf9e7 100644 --- a/goud_engine/src/libs/graphics/backend/native_backend/native_resources.rs +++ b/goud_engine/src/libs/graphics/backend/native_backend/native_resources.rs @@ -15,7 +15,7 @@ macro_rules! dispatch { match $self { #[cfg(feature = "legacy-glfw-opengl")] Self::OpenGlLegacy(b) => b.$method($($arg),*), - #[cfg(any(all(feature = "native", feature = "wgpu-backend"), feature = "xbox-gdk"))] + #[cfg(any(all(feature = "native", feature = "wgpu-backend"), feature = "xbox-gdk", feature = "sdl-window", feature = "switch-vulkan"))] Self::Wgpu(b) => b.$method($($arg),*), } }; diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs index f989a06f..8f566d3c 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs @@ -31,7 +31,15 @@ mod init; mod pipeline; mod readback; mod resources; +#[cfg(feature = "sdl-window")] +mod sdl_init; +#[cfg(feature = "sdl-window")] +pub(crate) mod sdl_surface; mod shader; +#[cfg(feature = "switch-vulkan")] +mod switch_init; +#[cfg(feature = "switch-vulkan")] +pub(crate) mod switch_surface; mod texture; mod uniforms; #[cfg(feature = "xbox-gdk")] diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs new file mode 100644 index 00000000..c4d2557e --- /dev/null +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs @@ -0,0 +1,295 @@ +//! SDL2 wgpu initialization path. +//! +//! Separated from `init.rs` to stay within the 500-line file limit. +//! TODO(sdl-window): Extract shared init logic (bind group layouts, fallback +//! textures, surface config) into a common helper before promoting this PoC +//! to production. Currently duplicates `new_async` to avoid refactoring the +//! existing init path during the feasibility study. + +use super::{ + BackendCapabilities, BackendInfo, BlendFactor, CullFace, DepthFunc, FrontFace, HashMap, + PrimitiveTopology, ShaderLanguage, WgpuBackend, +}; +use crate::core::{ + error::{GoudError, GoudResult}, + handle::HandleAllocator, +}; +use std::sync::Arc; + +use super::init::{MAX_TEXTURE_UNITS, UNIFORM_BUFFER_SIZE}; + +impl WgpuBackend { + /// Creates a new wgpu backend from an SDL2 window handle. + /// + /// Forces the Vulkan backend since SDL2 on Linux uses Vulkan. + /// Blocks on async wgpu initialization via pollster. + pub fn new_from_sdl_handle( + handle: Arc, + width: u32, + height: u32, + ) -> GoudResult { + pollster::block_on(Self::new_sdl_async(handle, width, height)) + } + + async fn new_sdl_async( + handle: Arc, + width: u32, + height: u32, + ) -> GoudResult { + let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle(); + instance_desc.backends = wgpu::Backends::VULKAN; + let instance = wgpu::Instance::new(instance_desc); + + let surface = instance + .create_surface(wgpu::SurfaceTarget::from(handle)) + .map_err(|e| GoudError::BackendNotSupported(format!("wgpu SDL surface: {e}")))?; + + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + .map_err(|e| { + GoudError::BackendNotSupported(format!("No suitable Vulkan adapter: {e}")) + })?; + + let (device, queue): (wgpu::Device, wgpu::Queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("GoudEngine-SDL"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + ..Default::default() + }) + .await + .map_err(|e| GoudError::BackendNotSupported(format!("wgpu device: {e}")))?; + + let caps = surface.get_capabilities(&adapter); + let surface_format = caps + .formats + .iter() + .find(|f| !f.is_srgb()) + .copied() + .unwrap_or(caps.formats[0]); + let surface_supports_copy_src = caps.usages.contains(wgpu::TextureUsages::COPY_SRC); + + let w = width.max(1); + let h = height.max(1); + let surface_config = wgpu::SurfaceConfiguration { + usage: if surface_supports_copy_src { + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC + } else { + wgpu::TextureUsages::RENDER_ATTACHMENT + }, + format: surface_format, + width: w, + height: h, + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&device, &surface_config); + + let (depth_texture, depth_view) = Self::create_depth_texture(&device, w, h); + + let adapter_info = adapter.get_info(); + let limits = device.limits(); + + let info = BackendInfo { + name: "wgpu-sdl", + version: format!("{:?}", adapter_info.backend), + vendor: adapter_info.vendor.to_string(), + renderer: adapter_info.name.clone(), + capabilities: BackendCapabilities { + max_texture_units: limits.max_sampled_textures_per_shader_stage.min(16), + max_texture_size: limits.max_texture_dimension_2d, + max_vertex_attributes: limits.max_vertex_attributes, + max_uniform_buffer_size: limits.max_uniform_buffer_binding_size as u32, + supports_instancing: true, + supports_compute_shaders: true, + supports_geometry_shaders: false, + supports_tessellation: false, + supports_multisampling: true, + supports_anisotropic_filtering: true, + supports_bc_compression: false, + }, + shader_language: ShaderLanguage::Wgsl, + }; + + let uniform_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("uniform_bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: None, + }, + count: None, + }], + }); + + let texture_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("texture_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let fallback_tex_bind_group = { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("fallback-white-1x1"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &[255u8, 255, 255, 255], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default()); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("fallback-texture-bg"), + layout: &texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }) + }; + + let storage_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("storage_bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let fallback_storage_bind_group = { + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("fallback-storage"), + size: 64, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("fallback-storage-bg"), + layout: &storage_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buf.as_entire_binding(), + }], + }) + }; + + Ok(Self { + info, + device, + queue, + surface: Some(surface), + surface_config, + surface_format, + surface_supports_copy_src, + wgpu_instance: instance, + wgpu_adapter: adapter, + window: None, + depth_texture, + depth_view, + last_frame_readback: None, + clear_color: wgpu::Color::BLACK, + needs_clear: false, + current_frame: None, + draw_commands: Vec::new(), + depth_test_enabled: false, + depth_write_enabled: true, + depth_func: DepthFunc::Less, + blend_enabled: false, + blend_src: BlendFactor::One, + blend_dst: BlendFactor::Zero, + cull_enabled: false, + cull_face: CullFace::Back, + front_face_state: FrontFace::Ccw, + buffer_allocator: HandleAllocator::new(), + buffers: HashMap::new(), + pending_destroy_buffers: Vec::new(), + texture_allocator: HandleAllocator::new(), + textures: HashMap::new(), + shader_allocator: HandleAllocator::new(), + shaders: HashMap::new(), + bound_vertex_buffer: None, + bound_index_buffer: None, + bound_shader: None, + bound_textures: vec![None; MAX_TEXTURE_UNITS], + current_layout: None, + current_vertex_bindings: Vec::new(), + current_topology: PrimitiveTopology::Triangles, + pipeline_cache: HashMap::new(), + uniform_bind_group_layout, + texture_bind_group_layout, + storage_bind_group_layout, + fallback_tex_bind_group, + fallback_storage_bind_group, + bound_storage_buffer: None, + storage_bind_group_cache: HashMap::new(), + uniform_ring: Vec::with_capacity(UNIFORM_BUFFER_SIZE * 64), + }) + } +} diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_surface.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_surface.rs new file mode 100644 index 00000000..aece37a0 --- /dev/null +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_surface.rs @@ -0,0 +1,89 @@ +//! SDL2 window handle wrapper for wgpu surface creation. +//! +//! Provides [`SdlWindowHandle`] which implements the `raw-window-handle` +//! traits required by wgpu to create a Vulkan surface from an SDL2 window. +//! Uses SDL2's built-in `raw-window-handle` 0.6 support to extract handles. + +use crate::core::error::{GoudError, GoudResult}; + +/// Wrapper providing `raw-window-handle` traits for an SDL2 window. +/// +/// Captures the raw window and display handles from an SDL2 window at +/// construction time and presents them via the `raw-window-handle` traits +/// that wgpu requires for surface creation. +pub struct SdlWindowHandle { + raw_window: wgpu::rwh::RawWindowHandle, + raw_display: wgpu::rwh::RawDisplayHandle, +} + +impl SdlWindowHandle { + /// Extracts raw window handles from an SDL2 window. + /// + /// Uses SDL2's `raw-window-handle` 0.6 support to obtain platform-native + /// handles (X11, Wayland, Cocoa, Win32) for wgpu surface creation. + pub fn from_sdl_window(window: &sdl2::video::Window) -> GoudResult { + use wgpu::rwh::{HasDisplayHandle, HasWindowHandle}; + + let window_handle = window.window_handle().map_err(|e| { + GoudError::InitializationFailed(format!("Failed to get SDL2 window handle: {e}")) + })?; + let display_handle = window.display_handle().map_err(|e| { + GoudError::InitializationFailed(format!("Failed to get SDL2 display handle: {e}")) + })?; + + Ok(Self { + raw_window: window_handle.as_raw(), + raw_display: display_handle.as_raw(), + }) + } + + /// Creates a dummy handle for type-checking on non-desktop platforms. + /// + /// The returned handle must never be used to create an actual surface. + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + pub fn dummy() -> Self { + Self { + raw_window: wgpu::rwh::RawWindowHandle::Web(wgpu::rwh::WebWindowHandle::new(0)), + raw_display: wgpu::rwh::RawDisplayHandle::Web(wgpu::rwh::WebDisplayHandle::new()), + } + } +} + +// SAFETY: The raw window and display handles are owned by the SDL2 window +// which is kept alive for the duration of the game in SdlPlatform. The handles +// are only used to create a wgpu surface during initialization, after which +// wgpu owns the surface independently. +unsafe impl Send for SdlWindowHandle {} +// SAFETY: Same reasoning as Send -- the raw handles are opaque values and wgpu +// only reads them during surface creation on the main thread. +unsafe impl Sync for SdlWindowHandle {} + +impl wgpu::rwh::HasWindowHandle for SdlWindowHandle { + fn window_handle(&self) -> Result, wgpu::rwh::HandleError> { + // SAFETY: The raw handle was obtained from a valid SDL2 window and + // outlives the returned `WindowHandle` via the borrow of `self`. + Ok(unsafe { wgpu::rwh::WindowHandle::borrow_raw(self.raw_window) }) + } +} + +impl wgpu::rwh::HasDisplayHandle for SdlWindowHandle { + fn display_handle(&self) -> Result, wgpu::rwh::HandleError> { + // SAFETY: The raw handle was obtained from a valid SDL2 display and + // outlives the returned `DisplayHandle` via the borrow of `self`. + Ok(unsafe { wgpu::rwh::DisplayHandle::borrow_raw(self.raw_display) }) + } +} + +#[cfg(test)] +mod tests { + /// SDL2 requires a display server; skip in headless CI. + #[ignore] + #[test] + fn sdl_handle_from_window_succeeds() { + let sdl = sdl2::init().expect("SDL2 init"); + let video = sdl.video().expect("SDL2 video"); + let window = video.window("test", 64, 64).build().expect("window"); + let handle = super::SdlWindowHandle::from_sdl_window(&window); + assert!(handle.is_ok()); + } +} diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs new file mode 100644 index 00000000..5a895317 --- /dev/null +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs @@ -0,0 +1,295 @@ +//! Nintendo Switch wgpu initialization path. +//! +//! Separated from `init.rs` to stay within the 500-line file limit. +//! TODO(switch-vulkan): Extract shared init logic (bind group layouts, fallback +//! textures, surface config) into a common helper before promoting this PoC +//! to production. Currently duplicates `new_async` to avoid refactoring the +//! existing init path during the feasibility study. + +use super::{ + BackendCapabilities, BackendInfo, BlendFactor, CullFace, DepthFunc, FrontFace, HashMap, + PrimitiveTopology, ShaderLanguage, WgpuBackend, +}; +use crate::core::{ + error::{GoudError, GoudResult}, + handle::HandleAllocator, +}; +use std::sync::Arc; + +use super::init::{MAX_TEXTURE_UNITS, UNIFORM_BUFFER_SIZE}; + +impl WgpuBackend { + /// Creates a new wgpu backend from a Nintendo Switch window handle. + /// + /// Forces the Vulkan backend since Switch uses Vulkan 1.1 (via NVN + /// translation layer). Blocks on async wgpu initialization via pollster. + pub fn new_from_switch_handle( + handle: Arc, + width: u32, + height: u32, + ) -> GoudResult { + pollster::block_on(Self::new_switch_async(handle, width, height)) + } + + async fn new_switch_async( + handle: Arc, + width: u32, + height: u32, + ) -> GoudResult { + let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle(); + instance_desc.backends = wgpu::Backends::VULKAN; + let instance = wgpu::Instance::new(instance_desc); + + let surface = instance + .create_surface(wgpu::SurfaceTarget::from(handle)) + .map_err(|e| GoudError::BackendNotSupported(format!("wgpu Switch surface: {e}")))?; + + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + .map_err(|e| { + GoudError::BackendNotSupported(format!("No suitable Vulkan adapter: {e}")) + })?; + + let (device, queue): (wgpu::Device, wgpu::Queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("GoudEngine-Switch"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + ..Default::default() + }) + .await + .map_err(|e| GoudError::BackendNotSupported(format!("wgpu device: {e}")))?; + + let caps = surface.get_capabilities(&adapter); + let surface_format = caps + .formats + .iter() + .find(|f| !f.is_srgb()) + .copied() + .unwrap_or(caps.formats[0]); + let surface_supports_copy_src = caps.usages.contains(wgpu::TextureUsages::COPY_SRC); + + let w = width.max(1); + let h = height.max(1); + let surface_config = wgpu::SurfaceConfiguration { + usage: if surface_supports_copy_src { + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC + } else { + wgpu::TextureUsages::RENDER_ATTACHMENT + }, + format: surface_format, + width: w, + height: h, + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&device, &surface_config); + + let (depth_texture, depth_view) = Self::create_depth_texture(&device, w, h); + + let adapter_info = adapter.get_info(); + let limits = device.limits(); + + let info = BackendInfo { + name: "wgpu-switch", + version: format!("{:?}", adapter_info.backend), + vendor: adapter_info.vendor.to_string(), + renderer: adapter_info.name.clone(), + capabilities: BackendCapabilities { + max_texture_units: limits.max_sampled_textures_per_shader_stage.min(16), + max_texture_size: limits.max_texture_dimension_2d, + max_vertex_attributes: limits.max_vertex_attributes, + max_uniform_buffer_size: limits.max_uniform_buffer_binding_size as u32, + supports_instancing: true, + supports_compute_shaders: true, + supports_geometry_shaders: false, + supports_tessellation: false, + supports_multisampling: true, + supports_anisotropic_filtering: true, + supports_bc_compression: false, + }, + shader_language: ShaderLanguage::Wgsl, + }; + + let uniform_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("uniform_bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: None, + }, + count: None, + }], + }); + + let texture_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("texture_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let fallback_tex_bind_group = { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("fallback-white-1x1"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &[255u8, 255, 255, 255], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default()); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("fallback-texture-bg"), + layout: &texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }) + }; + + let storage_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("storage_bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let fallback_storage_bind_group = { + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("fallback-storage"), + size: 64, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("fallback-storage-bg"), + layout: &storage_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buf.as_entire_binding(), + }], + }) + }; + + Ok(Self { + info, + device, + queue, + surface: Some(surface), + surface_config, + surface_format, + surface_supports_copy_src, + wgpu_instance: instance, + wgpu_adapter: adapter, + window: None, + depth_texture, + depth_view, + last_frame_readback: None, + clear_color: wgpu::Color::BLACK, + needs_clear: false, + current_frame: None, + draw_commands: Vec::new(), + depth_test_enabled: false, + depth_write_enabled: true, + depth_func: DepthFunc::Less, + blend_enabled: false, + blend_src: BlendFactor::One, + blend_dst: BlendFactor::Zero, + cull_enabled: false, + cull_face: CullFace::Back, + front_face_state: FrontFace::Ccw, + buffer_allocator: HandleAllocator::new(), + buffers: HashMap::new(), + pending_destroy_buffers: Vec::new(), + texture_allocator: HandleAllocator::new(), + textures: HashMap::new(), + shader_allocator: HandleAllocator::new(), + shaders: HashMap::new(), + bound_vertex_buffer: None, + bound_index_buffer: None, + bound_shader: None, + bound_textures: vec![None; MAX_TEXTURE_UNITS], + current_layout: None, + current_vertex_bindings: Vec::new(), + current_topology: PrimitiveTopology::Triangles, + pipeline_cache: HashMap::new(), + uniform_bind_group_layout, + texture_bind_group_layout, + storage_bind_group_layout, + fallback_tex_bind_group, + fallback_storage_bind_group, + bound_storage_buffer: None, + storage_bind_group_cache: HashMap::new(), + uniform_ring: Vec::with_capacity(UNIFORM_BUFFER_SIZE * 64), + }) + } +} diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_surface.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_surface.rs new file mode 100644 index 00000000..0819d030 --- /dev/null +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_surface.rs @@ -0,0 +1,60 @@ +//! Nintendo Switch window handle wrapper for wgpu surface creation. +//! +//! Provides [`SwitchWindowHandle`] which implements the `raw-window-handle` +//! traits required by wgpu. This is a pure PoC stub -- no real Switch handles +//! are available without the NintendoSDK, so all trait methods return +//! `HandleError::Unavailable`. + +/// Wrapper providing `raw-window-handle` traits for a Nintendo Switch window. +/// +/// On the actual Switch hardware, this would wrap an `nn::vi::Layer` native +/// window handle. For the PoC, all methods return `HandleError::Unavailable`. +pub struct SwitchWindowHandle { + #[allow(dead_code)] // Will hold nn::vi native window once SDK is available. + native_window: *mut std::ffi::c_void, +} + +impl SwitchWindowHandle { + /// Creates a dummy handle for type-checking on all platforms. + /// + /// The returned handle must never be used to create an actual surface. + pub fn dummy() -> Self { + Self { + native_window: std::ptr::null_mut(), + } + } +} + +// SAFETY: The native window handle would be owned by the nn::vi runtime +// which lives for the duration of the game. The handle is only used to +// create a wgpu surface during initialization. +unsafe impl Send for SwitchWindowHandle {} +// SAFETY: Same reasoning as Send -- the native window handle is an opaque +// pointer and wgpu only reads it during surface creation. +unsafe impl Sync for SwitchWindowHandle {} + +impl wgpu::rwh::HasWindowHandle for SwitchWindowHandle { + fn window_handle(&self) -> Result, wgpu::rwh::HandleError> { + // PoC stub: no real Switch handle format available without NintendoSDK. + Err(wgpu::rwh::HandleError::Unavailable) + } +} + +impl wgpu::rwh::HasDisplayHandle for SwitchWindowHandle { + fn display_handle(&self) -> Result, wgpu::rwh::HandleError> { + // PoC stub: no real Switch display handle available without NintendoSDK. + Err(wgpu::rwh::HandleError::Unavailable) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dummy_handle_returns_unavailable() { + use wgpu::rwh::HasWindowHandle; + let handle = SwitchWindowHandle::dummy(); + assert!(handle.window_handle().is_err()); + } +} diff --git a/goud_engine/src/libs/platform/mod.rs b/goud_engine/src/libs/platform/mod.rs index c9e73dc7..d775ee04 100644 --- a/goud_engine/src/libs/platform/mod.rs +++ b/goud_engine/src/libs/platform/mod.rs @@ -32,8 +32,17 @@ #[cfg(feature = "legacy-glfw-opengl")] pub mod glfw_platform; -#[cfg(any(feature = "native", feature = "xbox-gdk"))] +#[cfg(any( + feature = "native", + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" +))] pub mod native_runtime; +#[cfg(feature = "sdl-window")] +pub mod sdl_platform; +#[cfg(feature = "switch-vulkan")] +pub mod switch_vulkan_platform; #[cfg(all( feature = "wgpu-backend", feature = "native", @@ -49,7 +58,12 @@ pub mod winit_platform; #[cfg(feature = "xbox-gdk")] pub mod xbox_gdk_platform; -#[cfg(any(feature = "native", feature = "xbox-gdk"))] +#[cfg(any( + feature = "native", + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" +))] use crate::core::input_manager::InputManager; /// Fullscreen mode for the native window. @@ -114,6 +128,12 @@ pub enum WindowBackendKind { /// Xbox GDK windowing path (PoC). #[cfg(feature = "xbox-gdk")] XboxGdk = 2, + /// SDL2 windowing path (Linux). + #[cfg(feature = "sdl-window")] + SdlWindow = 3, + /// Nintendo Switch Vulkan path (PoC). + #[cfg(feature = "switch-vulkan")] + SwitchVulkan = 4, } impl WindowBackendKind { @@ -124,6 +144,10 @@ impl WindowBackendKind { 1 => Some(Self::GlfwLegacy), #[cfg(feature = "xbox-gdk")] 2 => Some(Self::XboxGdk), + #[cfg(feature = "sdl-window")] + 3 => Some(Self::SdlWindow), + #[cfg(feature = "switch-vulkan")] + 4 => Some(Self::SwitchVulkan), _ => None, } } @@ -184,7 +208,12 @@ impl Default for WindowConfig { /// /// Most windowing APIs require main-thread access. Implementations are NOT /// required to be `Send` or `Sync`. -#[cfg(any(feature = "native", feature = "xbox-gdk"))] +#[cfg(any( + feature = "native", + feature = "xbox-gdk", + feature = "sdl-window", + feature = "switch-vulkan" +))] pub trait PlatformBackend { /// Returns `true` if the window has been requested to close. fn should_close(&self) -> bool; @@ -315,4 +344,22 @@ mod tests { Some(WindowBackendKind::XboxGdk) ); } + + #[cfg(feature = "sdl-window")] + #[test] + fn sdl_window_backend_round_trip() { + assert_eq!( + WindowBackendKind::from_u32(3), + Some(WindowBackendKind::SdlWindow) + ); + } + + #[cfg(feature = "switch-vulkan")] + #[test] + fn switch_vulkan_window_backend_round_trip() { + assert_eq!( + WindowBackendKind::from_u32(4), + Some(WindowBackendKind::SwitchVulkan) + ); + } } diff --git a/goud_engine/src/libs/platform/native_runtime.rs b/goud_engine/src/libs/platform/native_runtime.rs index 563b905f..e2701029 100644 --- a/goud_engine/src/libs/platform/native_runtime.rs +++ b/goud_engine/src/libs/platform/native_runtime.rs @@ -26,7 +26,7 @@ fn invalid_pair_error( render_backend: RenderBackendKind, ) -> GoudError { GoudError::InitializationFailed(format!( - "invalid native backend pair: window={window_backend:?} render={render_backend:?}; supported pairs are Winit+Wgpu, GlfwLegacy+OpenGlLegacy, and XboxGdk+Wgpu" + "invalid native backend pair: window={window_backend:?} render={render_backend:?}; supported pairs are Winit+Wgpu, GlfwLegacy+OpenGlLegacy, XboxGdk+Wgpu, SdlWindow+Wgpu, and SwitchVulkan+Wgpu" )) } @@ -39,6 +39,16 @@ pub fn detect_best_backend() -> (WindowBackendKind, RenderBackendKind) { { return (WindowBackendKind::XboxGdk, RenderBackendKind::Wgpu); } + #[cfg(all(feature = "switch-vulkan", target_arch = "aarch64"))] + { + return (WindowBackendKind::SwitchVulkan, RenderBackendKind::Wgpu); + } + // SDL works on all desktops but auto-detect prefers it only on Linux + // where it replaces GLFW as the portkit-friendly windowing layer. + #[cfg(all(feature = "sdl-window", target_os = "linux"))] + { + return (WindowBackendKind::SdlWindow, RenderBackendKind::Wgpu); + } #[cfg(all( feature = "native", feature = "wgpu-backend", @@ -79,6 +89,10 @@ pub fn validate_native_backend_pair( (WindowBackendKind::GlfwLegacy, RenderBackendKind::OpenGlLegacy) => Ok(()), #[cfg(feature = "xbox-gdk")] (WindowBackendKind::XboxGdk, RenderBackendKind::Wgpu) => Ok(()), + #[cfg(feature = "sdl-window")] + (WindowBackendKind::SdlWindow, RenderBackendKind::Wgpu) => Ok(()), + #[cfg(feature = "switch-vulkan")] + (WindowBackendKind::SwitchVulkan, RenderBackendKind::Wgpu) => Ok(()), (_, RenderBackendKind::Auto) => Ok(()), // Auto is resolved before validation _ => Err(invalid_pair_error(window_backend, render_backend)), } @@ -177,6 +191,40 @@ pub fn create_native_runtime( )), }) } + #[cfg(feature = "sdl-window")] + (WindowBackendKind::SdlWindow, RenderBackendKind::Wgpu) => { + let platform = super::sdl_platform::SdlPlatform::new(window_config)?; + let handle = platform.window_handle(); + let (w, h) = platform.get_framebuffer_size(); + let mut backend = + crate::libs::graphics::backend::wgpu_backend::WgpuBackend::new_from_sdl_handle( + handle, w, h, + )?; + backend.resize(w, h); + Ok(NativeRuntime { + platform: Box::new(platform), + render_backend: SharedNativeRenderBackend::new(NativeRenderBackend::Wgpu( + Box::new(backend), + )), + }) + } + #[cfg(feature = "switch-vulkan")] + (WindowBackendKind::SwitchVulkan, RenderBackendKind::Wgpu) => { + let platform = super::switch_vulkan_platform::SwitchVulkanPlatform::new(window_config)?; + let handle = platform.window_handle(); + let (w, h) = platform.get_framebuffer_size(); + let mut backend = + crate::libs::graphics::backend::wgpu_backend::WgpuBackend::new_from_switch_handle( + handle, w, h, + )?; + backend.resize(w, h); + Ok(NativeRuntime { + platform: Box::new(platform), + render_backend: SharedNativeRenderBackend::new(NativeRenderBackend::Wgpu( + Box::new(backend), + )), + }) + } _ => Err(invalid_pair_error(window_backend, render_backend)), } } @@ -235,4 +283,24 @@ mod tests { .is_ok() ); } + + #[cfg(feature = "sdl-window")] + #[test] + fn accepts_sdl_window_pair() { + assert!(validate_native_backend_pair( + WindowBackendKind::SdlWindow, + RenderBackendKind::Wgpu + ) + .is_ok()); + } + + #[cfg(feature = "switch-vulkan")] + #[test] + fn accepts_switch_vulkan_pair() { + assert!(validate_native_backend_pair( + WindowBackendKind::SwitchVulkan, + RenderBackendKind::Wgpu + ) + .is_ok()); + } } diff --git a/goud_engine/src/libs/platform/sdl_platform.rs b/goud_engine/src/libs/platform/sdl_platform.rs new file mode 100644 index 00000000..ef008277 --- /dev/null +++ b/goud_engine/src/libs/platform/sdl_platform.rs @@ -0,0 +1,328 @@ +//! SDL2 platform backend. +//! +//! Provides a [`PlatformBackend`] implementation using SDL2 for windowing and +//! input. On non-desktop targets the constructor returns +//! [`GoudError::BackendNotSupported`] so the module can still be type-checked. + +use std::sync::Arc; +use std::time::Instant; + +use crate::core::error::{GoudError, GoudResult}; +use crate::core::input_manager::InputManager; +use crate::core::math::Vec2; +use crate::core::providers::input_types::{KeyCode, MouseButton as EngineMouseButton}; + +use super::{FullscreenMode, PlatformBackend, WindowConfig}; +use crate::libs::graphics::backend::wgpu_backend::sdl_surface::SdlWindowHandle; + +/// SDL2 platform backend. +/// +/// Wraps an SDL2 window and implements the engine's platform abstraction. +/// Uses SDL2's `raw-window-handle` support to provide wgpu-compatible +/// window handles for Vulkan surface creation. +pub struct SdlPlatform { + should_close: bool, + width: u32, + height: u32, + last_frame: Instant, + handle: Arc, + /// Retained to keep the SDL2 context alive; dropped on shutdown. + #[allow(dead_code)] + sdl_context: sdl2::Sdl, + /// Retained to keep the SDL2 video subsystem alive. + #[allow(dead_code)] + video: sdl2::VideoSubsystem, + /// The SDL2 window driving the event pump. + sdl_window: sdl2::video::Window, + /// SDL2 event pump for polling OS events. + event_pump: sdl2::EventPump, +} + +impl SdlPlatform { + /// Creates the SDL2 platform. + /// + /// Initialises SDL2, creates a window with the requested configuration, + /// and extracts raw window handles for wgpu surface creation. + pub fn new(config: &WindowConfig) -> GoudResult { + Self::new_inner(config) + } + + /// Returns the window handle wrapper for wgpu surface creation. + pub fn window_handle(&self) -> Arc { + Arc::clone(&self.handle) + } + + // ------------------------------------------------------------------ + // Desktop implementation (actual SDL2 path) + // ------------------------------------------------------------------ + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + fn new_inner(config: &WindowConfig) -> GoudResult { + let sdl_context = sdl2::init() + .map_err(|e| GoudError::InitializationFailed(format!("SDL2 init failed: {e}")))?; + let video = sdl_context + .video() + .map_err(|e| GoudError::InitializationFailed(format!("SDL2 video init failed: {e}")))?; + + let sdl_window = video + .window(&config.title, config.width, config.height) + .position_centered() + .resizable() + .build() + .map_err(|e| { + GoudError::InitializationFailed(format!("SDL2 window creation failed: {e}")) + })?; + + let event_pump = sdl_context + .event_pump() + .map_err(|e| GoudError::InitializationFailed(format!("SDL2 event pump failed: {e}")))?; + + // Extract raw window handle via SDL2's raw-window-handle 0.6 support. + let handle = SdlWindowHandle::from_sdl_window(&sdl_window)?; + + let (w, h) = sdl_window.size(); + + Ok(Self { + should_close: false, + width: w, + height: h, + last_frame: Instant::now(), + handle: Arc::new(handle), + sdl_context, + video, + sdl_window, + event_pump, + }) + } + + // ------------------------------------------------------------------ + // Non-desktop stub (allows type-checking on other targets) + // ------------------------------------------------------------------ + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + fn new_inner(_config: &WindowConfig) -> GoudResult { + Err(GoudError::BackendNotSupported( + "SDL2 windowing is only available on desktop targets".to_string(), + )) + } +} + +impl PlatformBackend for SdlPlatform { + fn should_close(&self) -> bool { + self.should_close + } + + fn set_should_close(&mut self, should_close: bool) { + self.should_close = should_close; + } + + fn poll_events(&mut self, input: &mut InputManager) -> f32 { + input.update(); + + let now = Instant::now(); + let dt = now.duration_since(self.last_frame).as_secs_f32(); + self.last_frame = now; + + for event in self.event_pump.poll_iter() { + match event { + sdl2::event::Event::Quit { .. } => { + self.should_close = true; + } + sdl2::event::Event::Window { + win_event: sdl2::event::WindowEvent::Resized(w, h), + .. + } => { + self.width = w as u32; + self.height = h as u32; + } + sdl2::event::Event::KeyDown { + scancode: Some(sc), + repeat: false, + .. + } => { + if let Some(key) = map_sdl_scancode(sc) { + input.press_key(key); + } + } + sdl2::event::Event::KeyUp { + scancode: Some(sc), .. + } => { + if let Some(key) = map_sdl_scancode(sc) { + input.release_key(key); + } + } + sdl2::event::Event::MouseMotion { x, y, .. } => { + input.set_mouse_position(Vec2::new(x as f32, y as f32)); + } + sdl2::event::Event::MouseButtonDown { mouse_btn, .. } => { + if let Some(btn) = map_sdl_mouse_button(mouse_btn) { + input.press_mouse_button(btn); + } + } + sdl2::event::Event::MouseButtonUp { mouse_btn, .. } => { + if let Some(btn) = map_sdl_mouse_button(mouse_btn) { + input.release_mouse_button(btn); + } + } + sdl2::event::Event::MouseWheel { x, y, .. } => { + input.add_scroll_delta(Vec2::new(x as f32, y as f32)); + } + _ => {} + } + } + + dt + } + + fn swap_buffers(&mut self) { + // No-op: wgpu handles presentation via surface.present(). + } + + fn get_size(&self) -> (u32, u32) { + (self.width, self.height) + } + + fn request_size(&mut self, width: u32, height: u32) -> bool { + self.sdl_window + .set_size(width, height) + .map(|()| { + self.width = width; + self.height = height; + }) + .is_ok() + } + + fn get_framebuffer_size(&self) -> (u32, u32) { + self.sdl_window.drawable_size() + } + + fn set_fullscreen(&mut self, mode: FullscreenMode) -> bool { + let sdl_mode = match mode { + FullscreenMode::Windowed => sdl2::video::FullscreenType::Off, + FullscreenMode::Borderless => sdl2::video::FullscreenType::Desktop, + FullscreenMode::Exclusive => sdl2::video::FullscreenType::True, + }; + self.sdl_window.set_fullscreen(sdl_mode).is_ok() + } + + fn get_fullscreen(&self) -> FullscreenMode { + match self.sdl_window.fullscreen_state() { + sdl2::video::FullscreenType::Off => FullscreenMode::Windowed, + sdl2::video::FullscreenType::Desktop => FullscreenMode::Borderless, + sdl2::video::FullscreenType::True => FullscreenMode::Exclusive, + } + } +} + +/// Maps an SDL2 scancode to the engine's platform-neutral [`KeyCode`]. +/// +/// The engine's `KeyCode` values follow the GLFW convention. SDL2 scancodes +/// differ, so we map the most common keys. Returns `None` for unmapped codes. +fn map_sdl_scancode(sc: sdl2::keyboard::Scancode) -> Option { + use sdl2::keyboard::Scancode; + match sc { + Scancode::Space => Some(KeyCode::Space), + Scancode::A => Some(KeyCode::A), + Scancode::B => Some(KeyCode::B), + Scancode::C => Some(KeyCode::C), + Scancode::D => Some(KeyCode::D), + Scancode::E => Some(KeyCode::E), + Scancode::F => Some(KeyCode::F), + Scancode::G => Some(KeyCode::G), + Scancode::H => Some(KeyCode::H), + Scancode::I => Some(KeyCode::I), + Scancode::J => Some(KeyCode::J), + Scancode::K => Some(KeyCode::K), + Scancode::L => Some(KeyCode::L), + Scancode::M => Some(KeyCode::M), + Scancode::N => Some(KeyCode::N), + Scancode::O => Some(KeyCode::O), + Scancode::P => Some(KeyCode::P), + Scancode::Q => Some(KeyCode::Q), + Scancode::R => Some(KeyCode::R), + Scancode::S => Some(KeyCode::S), + Scancode::T => Some(KeyCode::T), + Scancode::U => Some(KeyCode::U), + Scancode::V => Some(KeyCode::V), + Scancode::W => Some(KeyCode::W), + Scancode::X => Some(KeyCode::X), + Scancode::Y => Some(KeyCode::Y), + Scancode::Z => Some(KeyCode::Z), + Scancode::Num0 => Some(KeyCode::Num0), + Scancode::Num1 => Some(KeyCode::Num1), + Scancode::Num2 => Some(KeyCode::Num2), + Scancode::Num3 => Some(KeyCode::Num3), + Scancode::Num4 => Some(KeyCode::Num4), + Scancode::Num5 => Some(KeyCode::Num5), + Scancode::Num6 => Some(KeyCode::Num6), + Scancode::Num7 => Some(KeyCode::Num7), + Scancode::Num8 => Some(KeyCode::Num8), + Scancode::Num9 => Some(KeyCode::Num9), + Scancode::Escape => Some(KeyCode::Escape), + Scancode::Return => Some(KeyCode::Enter), + Scancode::Tab => Some(KeyCode::Tab), + Scancode::Backspace => Some(KeyCode::Backspace), + Scancode::Right => Some(KeyCode::Right), + Scancode::Left => Some(KeyCode::Left), + Scancode::Down => Some(KeyCode::Down), + Scancode::Up => Some(KeyCode::Up), + Scancode::LShift => Some(KeyCode::LeftShift), + Scancode::RShift => Some(KeyCode::RightShift), + Scancode::LCtrl => Some(KeyCode::LeftControl), + Scancode::RCtrl => Some(KeyCode::RightControl), + Scancode::LAlt => Some(KeyCode::LeftAlt), + Scancode::RAlt => Some(KeyCode::RightAlt), + _ => None, + } +} + +/// Maps an SDL2 mouse button to the engine's platform-neutral [`EngineMouseButton`]. +fn map_sdl_mouse_button(btn: sdl2::mouse::MouseButton) -> Option { + match btn { + sdl2::mouse::MouseButton::Left => Some(EngineMouseButton::Left), + sdl2::mouse::MouseButton::Right => Some(EngineMouseButton::Right), + sdl2::mouse::MouseButton::Middle => Some(EngineMouseButton::Middle), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_sdl_scancode_covers_wasd() { + use sdl2::keyboard::Scancode; + assert_eq!(map_sdl_scancode(Scancode::W), Some(KeyCode::W)); + assert_eq!(map_sdl_scancode(Scancode::A), Some(KeyCode::A)); + assert_eq!(map_sdl_scancode(Scancode::S), Some(KeyCode::S)); + assert_eq!(map_sdl_scancode(Scancode::D), Some(KeyCode::D)); + } + + #[test] + fn map_sdl_mouse_button_covers_basics() { + assert_eq!( + map_sdl_mouse_button(sdl2::mouse::MouseButton::Left), + Some(EngineMouseButton::Left) + ); + assert_eq!( + map_sdl_mouse_button(sdl2::mouse::MouseButton::Right), + Some(EngineMouseButton::Right) + ); + } + + /// SDL2 requires a display server; skip in headless CI. + #[ignore] + #[test] + fn sdl_platform_creates_and_reports_size() { + let config = WindowConfig { + width: 320, + height: 240, + title: "sdl-test".to_string(), + ..WindowConfig::default() + }; + let platform = SdlPlatform::new(&config).expect("SDL2 should init"); + let (w, h) = platform.get_size(); + assert_eq!((w, h), (320, 240)); + } +} diff --git a/goud_engine/src/libs/platform/switch_vulkan_platform.rs b/goud_engine/src/libs/platform/switch_vulkan_platform.rs new file mode 100644 index 00000000..6cbb8813 --- /dev/null +++ b/goud_engine/src/libs/platform/switch_vulkan_platform.rs @@ -0,0 +1,127 @@ +//! Nintendo Switch Vulkan platform backend (PoC). +//! +//! Provides a [`PlatformBackend`] implementation for the Nintendo Switch using +//! Vulkan. On non-aarch64 targets the constructor returns +//! [`GoudError::BackendNotSupported`] so the module can still be type-checked +//! during cross-platform development. + +use std::sync::Arc; +use std::time::Instant; + +use crate::core::error::{GoudError, GoudResult}; +use crate::core::input_manager::InputManager; + +use super::{FullscreenMode, PlatformBackend, WindowConfig}; +use crate::libs::graphics::backend::wgpu_backend::switch_surface::SwitchWindowHandle; + +/// Nintendo Switch Vulkan platform backend. +/// +/// Wraps a Switch `nn::vi::Layer` handle and implements the engine's platform +/// abstraction. On non-aarch64 hosts the constructor always fails with a +/// descriptive error. +#[allow(dead_code)] // PoC stub: struct fields used once NintendoSDK is linked. +pub struct SwitchVulkanPlatform { + should_close: bool, + width: u32, + height: u32, + last_frame: Instant, + handle: Arc, +} + +impl SwitchVulkanPlatform { + /// Creates the Nintendo Switch Vulkan platform. + /// + /// On aarch64 targets this would initialize the `nn::vi` display layer + /// and extract the native window handle for Vulkan surface creation. On + /// other targets this returns `BackendNotSupported`. + pub fn new(config: &WindowConfig) -> GoudResult { + Self::new_inner(config) + } + + /// Returns the window handle wrapper for wgpu surface creation. + #[allow(dead_code)] // Called from native_runtime once NintendoSDK is linked. + pub fn window_handle(&self) -> Arc { + Arc::clone(&self.handle) + } + + // ------------------------------------------------------------------ + // aarch64 implementation (actual Switch path) + // ------------------------------------------------------------------ + + #[cfg(target_arch = "aarch64")] + fn new_inner(_config: &WindowConfig) -> GoudResult { + // TODO(switch-vulkan): Initialize nn::vi display, create layer, + // extract native window handle for Vulkan surface creation. + Err(GoudError::BackendNotSupported( + "Nintendo Switch platform requires NintendoSDK (not yet linked)".to_string(), + )) + } + + // ------------------------------------------------------------------ + // Non-aarch64 stub (allows type-checking on x86_64 hosts) + // ------------------------------------------------------------------ + + #[cfg(not(target_arch = "aarch64"))] + fn new_inner(_config: &WindowConfig) -> GoudResult { + Err(GoudError::BackendNotSupported( + "Nintendo Switch platform is only available on aarch64 targets".to_string(), + )) + } +} + +impl PlatformBackend for SwitchVulkanPlatform { + fn should_close(&self) -> bool { + self.should_close + } + + fn set_should_close(&mut self, should_close: bool) { + self.should_close = should_close; + } + + fn poll_events(&mut self, _input: &mut InputManager) -> f32 { + let now = Instant::now(); + let dt = now.duration_since(self.last_frame).as_secs_f32(); + self.last_frame = now; + dt + } + + fn swap_buffers(&mut self) { + // No-op: wgpu handles presentation via surface.present(). + } + + fn get_size(&self) -> (u32, u32) { + (self.width, self.height) + } + + fn request_size(&mut self, _width: u32, _height: u32) -> bool { + // Switch is always 1920x1080 docked; resize is a no-op. + false + } + + fn get_framebuffer_size(&self) -> (u32, u32) { + // Switch has no HiDPI scaling; logical == physical. + (self.width, self.height) + } + + fn set_fullscreen(&mut self, mode: FullscreenMode) -> bool { + // Switch is always fullscreen; accept borderless/exclusive, reject windowed. + mode != FullscreenMode::Windowed + } + + fn get_fullscreen(&self) -> FullscreenMode { + FullscreenMode::Exclusive + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(not(target_arch = "aarch64"))] + #[test] + fn new_returns_backend_not_supported_on_non_aarch64() { + let result = SwitchVulkanPlatform::new(&WindowConfig::default()); + let err = result.err().expect("should fail on non-aarch64"); + assert!(err.to_string().contains("aarch64")); + } +}