diff --git a/templates/apps/rust-gpu-wgpu/.gitignore.hbs b/templates/apps/rust-gpu-wgpu/.gitignore.hbs new file mode 100644 index 00000000..461a5a91 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/.gitignore.hbs @@ -0,0 +1,10 @@ +# Rust +target/ +**/*.rs.bk + +# cargo-mobile2 +.cargo/ +/gen + +# macOS +.DS_Store \ No newline at end of file diff --git a/templates/apps/rust-gpu-wgpu/Cargo.toml.hbs b/templates/apps/rust-gpu-wgpu/Cargo.toml.hbs new file mode 100644 index 00000000..17d46216 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/Cargo.toml.hbs @@ -0,0 +1,42 @@ +[package] +name = "{{app.name}}" +version = "0.1.0" +authors = ["{{author}}"] +edition = "2024" + +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name = "{{app.name}}-desktop" +path = "gen/bin/desktop.rs" + +[dependencies] +# shader crate +shaders = { path = "shaders" } + +# API +wgpu = { version = "29.0.1", features = ["spirv"] } +pollster = "0.4.0" +log = "0.4.29" + +# other +bytemuck = { version = "1.24.0", features = ["derive"] } +raw-window-handle = "0.6.2" +anyhow = "1.0.98" + +# cargo-mobile2 +[target.'cfg(target_os = "android")'.dependencies] +winit = { version = "0.30.13", features = ["android-native-activity"] } +android_logger = "0.15.1" + +[target.'cfg(not(target_os = "android"))'.dependencies] +winit = "0.30.13" +env_logger = "0.11.8" + +[build-dependencies] +# rust-gpu +spirv-builder = { version = "0.10.0-alpha.1" } + +# other +anyhow = "1.0.98" diff --git a/templates/apps/rust-gpu-wgpu/build.rs b/templates/apps/rust-gpu-wgpu/build.rs new file mode 100644 index 00000000..fafbde44 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/build.rs @@ -0,0 +1,20 @@ +use spirv_builder::{ShaderPanicStrategy, SpirvBuilder, SpirvMetadata}; +use std::path::PathBuf; + +pub fn main() -> anyhow::Result<()> { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let crate_path = [manifest_dir, "shaders"] + .iter() + .copied() + .collect::(); + + let mut builder = SpirvBuilder::new(crate_path, "spirv-unknown-vulkan1.3"); + builder.build_script.defaults = true; + builder.shader_panic_strategy = ShaderPanicStrategy::SilentExit; + builder.spirv_metadata = SpirvMetadata::Full; + + let compile_result = builder.build()?; + let spv_path = compile_result.module.unwrap_single(); + println!("cargo::rustc-env=SHADER_SPV_PATH={}", spv_path.display()); + Ok(()) +} diff --git a/templates/apps/rust-gpu-wgpu/gen/bin/desktop.rs.hbs b/templates/apps/rust-gpu-wgpu/gen/bin/desktop.rs.hbs new file mode 100644 index 00000000..df976373 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/gen/bin/desktop.rs.hbs @@ -0,0 +1,4 @@ +fn main() { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + {{snake-case app.name}}::start_app(); +} diff --git a/templates/apps/rust-gpu-wgpu/rust-toolchain.toml b/templates/apps/rust-gpu-wgpu/rust-toolchain.toml new file mode 100644 index 00000000..907dd5e3 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2026-04-11" +components = ["rust-src", "rustc-dev", "llvm-tools"] diff --git a/templates/apps/rust-gpu-wgpu/shaders/Cargo.toml b/templates/apps/rust-gpu-wgpu/shaders/Cargo.toml new file mode 100644 index 00000000..7ccf3162 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/shaders/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "shaders" +version = "0.1.0" +edition = "2024" + +[lints.rust] +unexpected_cfgs = { level = "allow", check-cfg = ['cfg(target_arch, values("spirv"))'] } + +[dependencies] +spirv-std = { version = "0.10.0-alpha.1" } +glam = { version = "0.32.0", default-features = false } +bytemuck = { version = "1.24.0", features = ["derive"] } diff --git a/templates/apps/rust-gpu-wgpu/shaders/rust-toolchain.toml b/templates/apps/rust-gpu-wgpu/shaders/rust-toolchain.toml new file mode 100644 index 00000000..907dd5e3 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/shaders/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2026-04-11" +components = ["rust-src", "rustc-dev", "llvm-tools"] diff --git a/templates/apps/rust-gpu-wgpu/shaders/src/lib.rs b/templates/apps/rust-gpu-wgpu/shaders/src/lib.rs new file mode 100644 index 00000000..92bd1015 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/shaders/src/lib.rs @@ -0,0 +1,36 @@ +#![no_std] + +use bytemuck::{Pod, Zeroable}; +use core::f32::consts::PI; +use glam::{vec2, vec3, Vec3, Vec4}; +#[cfg(target_arch = "spirv")] +use spirv_std::num_traits::Float; +use spirv_std::spirv; + +#[derive(Copy, Clone, Pod, Zeroable)] +#[repr(C)] +pub struct ShaderConstants { + pub width: u32, + pub height: u32, + pub time: f32, +} + +#[spirv(fragment)] +pub fn main_fs(vtx_color: Vec3, output: &mut Vec4) { + *output = Vec4::from((vtx_color, 1.)); +} + +#[spirv(vertex)] +pub fn main_vs( + #[spirv(vertex_index)] vert_id: i32, + #[spirv(descriptor_set = 0, binding = 0, storage_buffer)] constants: &ShaderConstants, + #[spirv(position)] vtx_pos: &mut Vec4, + vtx_color: &mut Vec3, +) { + let speed = 0.4; + let time = constants.time * speed + vert_id as f32 * (2. * PI * 120. / 360.); + let position = vec2(f32::sin(time), f32::cos(time)); + *vtx_pos = Vec4::from((position, 0.0, 1.0)); + + *vtx_color = [vec3(1., 0., 0.), vec3(0., 1., 0.), vec3(0., 0., 1.)][vert_id as usize % 3]; +} diff --git a/templates/apps/rust-gpu-wgpu/src/lib.rs.hbs b/templates/apps/rust-gpu-wgpu/src/lib.rs.hbs new file mode 100644 index 00000000..327c4cf7 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/src/lib.rs.hbs @@ -0,0 +1,30 @@ +pub mod util; +pub mod wgpu_renderer; + +use wgpu_renderer::App; +use winit::event_loop::EventLoop; + +#[cfg(not(target_os = "android"))] +#[unsafe(no_mangle)] +#[inline(never)] +pub extern "C" fn start_app() { + env_logger::init(); + + let event_loop = EventLoop::new().unwrap(); + event_loop.run_app(&mut App::default()).unwrap(); +} + +#[cfg(target_os = "android")] +#[unsafe(no_mangle)] +fn android_main(app: winit::platform::android::activity::AndroidApp) { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Trace) + .with_tag("{{app.name}}"), + ); + + use winit::platform::android::EventLoopBuilderExtAndroid; + + let event_loop = EventLoop::builder().with_android_app(app).build().unwrap(); + event_loop.run_app(&mut App::default()).unwrap(); +} diff --git a/templates/apps/rust-gpu-wgpu/src/util.rs b/templates/apps/rust-gpu-wgpu/src/util.rs new file mode 100644 index 00000000..deac2044 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/src/util.rs @@ -0,0 +1,3 @@ +pub fn enable_debug_layer() -> bool { + std::env::var("DEBUG_LAYER").is_ok_and(|e| !(e == "0" || e == "false")) +} diff --git a/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/mod.rs.hbs b/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/mod.rs.hbs new file mode 100644 index 00000000..38542f82 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/mod.rs.hbs @@ -0,0 +1,160 @@ +use crate::wgpu_renderer::renderer::MyRenderer; +use crate::wgpu_renderer::swapchain::MySwapchainManager; +use anyhow::Context; +use pollster::block_on; +use shaders::ShaderConstants; +use std::sync::Arc; +use std::time::Instant; +use winit::{ + application::ApplicationHandler, + event::{ElementState, KeyEvent, WindowEvent}, + event_loop::ActiveEventLoop, + keyboard::{Key, NamedKey}, + window::{Window, WindowId}, +}; + +mod render_pipeline; +mod renderer; +mod swapchain; + +#[derive(Default)] +pub struct App(Option); + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.0.is_none() { + self.0 = Some(block_on(State::new(event_loop)).unwrap()); + } + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) { + let state = self.0.as_mut().unwrap(); + state.window_event(event_loop, id, event).unwrap(); + } + + // FIX(iOS): https://github.com/rust-windowing/winit/issues/3406 + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait); + + #[cfg(target_os = "ios")] + { + if let Some(state) = self.0.as_mut() { + if state.ios_request_redraw { + state.window.request_redraw(); + } + + state.ios_request_redraw = false; + } + } + } +} + +struct State { + start: Instant, + window: Arc, + renderer: MyRenderer, + swapchain: MySwapchainManager<'static>, + // FIX(iOS): https://github.com/rust-windowing/winit/issues/3406 + #[cfg(target_os = "ios")] + ios_request_redraw: bool, +} + +impl State { + async fn new(event_loop: &ActiveEventLoop) -> anyhow::Result { + let attributes = Window::default_attributes().with_title("{{app.stylized-name}}"); + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + let attributes = attributes.with_inner_size(winit::dpi::LogicalSize::new(1280, 720)); + let window = Arc::new(event_loop.create_window(attributes).unwrap()); + + let instance = + wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle_from_env( + Box::new(event_loop.owned_display_handle()), + )); + let surface = instance.create_surface(window.clone())?; + let adapter = + wgpu::util::initialize_adapter_from_env_or_default(&instance, Some(&surface)).await?; + + let required_features = wgpu::Features::IMMEDIATES; + let required_limits = wgpu::Limits { + max_immediate_size: 128, + max_inter_stage_shader_variables: 15, + ..Default::default() + }; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: None, + required_features, + required_limits, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + memory_hints: wgpu::MemoryHints::Performance, + trace: Default::default(), + }) + .await + .context("Failed to create device")?; + + let swapchain = MySwapchainManager::new( + instance.clone(), + adapter.clone(), + device.clone(), + window.clone(), + surface, + ); + let renderer = MyRenderer::new(device, queue, swapchain.format())?; + Ok(Self { + start: Instant::now(), + window, + swapchain, + renderer, + // FIX(iOS): https://github.com/rust-windowing/winit/issues/3406 + #[cfg(target_os = "ios")] + ios_request_redraw: false, + }) + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) -> anyhow::Result<()> { + match event { + WindowEvent::RedrawRequested => { + let time = self.start.elapsed().as_secs_f32(); + let renderer = &self.renderer; + self.swapchain.render(|render_target| { + renderer.render( + &ShaderConstants { + time, + width: render_target.texture().width(), + height: render_target.texture().height(), + }, + render_target, + ) + })?; + + // FIX(iOS): https://github.com/rust-windowing/winit/issues/3406 + #[cfg(not(target_os = "ios"))] + self.window.request_redraw(); + + #[cfg(target_os = "ios")] + { + self.ios_request_redraw = true; + } + } + WindowEvent::KeyboardInput { + event: + KeyEvent { + logical_key: Key::Named(NamedKey::Escape), + state: ElementState::Pressed, + .. + }, + .. + } + | WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(_) => self.swapchain.should_recreate(), + _ => (), + } + Ok(()) + } +} diff --git a/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/render_pipeline.rs b/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/render_pipeline.rs new file mode 100644 index 00000000..944d8941 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/render_pipeline.rs @@ -0,0 +1,70 @@ +use crate::wgpu_renderer::renderer::{GlobalBindGroup, GlobalBindGroupLayout}; +use shaders::ShaderConstants; +use wgpu::{ + include_spirv, ColorTargetState, ColorWrites, Device, FragmentState, FrontFace, + MultisampleState, PipelineLayoutDescriptor, PolygonMode, PrimitiveState, PrimitiveTopology, + RenderPass, RenderPipeline, RenderPipelineDescriptor, TextureFormat, VertexState, +}; + +#[derive(Debug, Clone)] +pub struct MyRenderPipeline { + pipeline: RenderPipeline, +} + +impl MyRenderPipeline { + pub fn new( + device: &Device, + global_bind_group_layout: &GlobalBindGroupLayout, + out_format: TextureFormat, + ) -> anyhow::Result { + let module = device.create_shader_module(include_spirv!(env!("SHADER_SPV_PATH"))); + + let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { + label: Some("MyRenderPipeline layout"), + bind_group_layouts: &[Some(&global_bind_group_layout.0)], + immediate_size: size_of::() as u32, + }); + + Ok(Self { + pipeline: device.create_render_pipeline(&RenderPipelineDescriptor { + label: Some("MyRenderPipeline"), + layout: Some(&layout), + vertex: VertexState { + module: &module, + entry_point: Some("main_vs"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: PrimitiveState { + topology: PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: MultisampleState::default(), + fragment: Some(FragmentState { + module: &module, + entry_point: Some("main_fs"), + compilation_options: Default::default(), + targets: &[Some(ColorTargetState { + format: out_format, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }), + }) + } + + pub fn draw(&self, rpass: &mut RenderPass<'_>, global_bind_group: &GlobalBindGroup) { + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, &global_bind_group.0, &[]); + rpass.draw(0..3, 0..1); + } +} diff --git a/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/renderer.rs b/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/renderer.rs new file mode 100644 index 00000000..c6eab3f5 --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/renderer.rs @@ -0,0 +1,120 @@ +use crate::wgpu_renderer::render_pipeline::MyRenderPipeline; +use shaders::ShaderConstants; +use wgpu::util::{BufferInitDescriptor, DeviceExt}; +use wgpu::wgt::CommandEncoderDescriptor; +use wgpu::{ + BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayout, BindGroupLayoutDescriptor, + BindGroupLayoutEntry, BindingResource, BindingType, Buffer, BufferBinding, BufferBindingType, + BufferUsages, Color, Device, LoadOp, Operations, Queue, RenderPassColorAttachment, + RenderPassDescriptor, ShaderStages, StoreOp, TextureFormat, TextureView, +}; + +pub struct MyRenderer { + pub device: Device, + pub queue: Queue, + global_bind_group_layout: GlobalBindGroupLayout, + pipeline: MyRenderPipeline, +} + +impl MyRenderer { + pub fn new(device: Device, queue: Queue, out_format: TextureFormat) -> anyhow::Result { + let global_bind_group_layout = GlobalBindGroupLayout::new(&device); + let pipeline = MyRenderPipeline::new(&device, &global_bind_group_layout, out_format)?; + Ok(Self { + global_bind_group_layout, + pipeline, + device, + queue, + }) + } + + pub fn render( + &self, + shader_constants: &ShaderConstants, + output: TextureView, + ) -> anyhow::Result<()> { + let global_bind_group = self + .global_bind_group_layout + .create(&self.device, shader_constants); + + let mut cmd = self + .device + .create_command_encoder(&CommandEncoderDescriptor { + label: Some("main draw"), + }); + + let mut rpass = cmd.begin_render_pass(&RenderPassDescriptor { + label: Some("main renderpass"), + color_attachments: &[Some(RenderPassColorAttachment { + view: &output, + depth_slice: None, + resolve_target: None, + ops: Operations { + load: LoadOp::Clear(Color::BLACK), + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + self.pipeline.draw(&mut rpass, &global_bind_group); + drop(rpass); + + self.queue.submit(std::iter::once(cmd.finish())); + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct GlobalBindGroupLayout(pub BindGroupLayout); + +impl GlobalBindGroupLayout { + pub fn new(device: &Device) -> Self { + Self(device.create_bind_group_layout(&BindGroupLayoutDescriptor { + label: Some("GlobalBindGroupLayout"), + entries: &[BindGroupLayoutEntry { + binding: 0, + visibility: ShaderStages::VERTEX_FRAGMENT, + ty: BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + })) + } + + pub fn create(&self, device: &Device, shader_constants: &ShaderConstants) -> GlobalBindGroup { + let shader_constants = device.create_buffer_init(&BufferInitDescriptor { + label: Some("ShaderConstants"), + contents: bytemuck::bytes_of(shader_constants), + usage: BufferUsages::STORAGE, + }); + self.create_from_buffer(device, &shader_constants) + } + + pub fn create_from_buffer( + &self, + device: &Device, + shader_constants: &Buffer, + ) -> GlobalBindGroup { + GlobalBindGroup(device.create_bind_group(&BindGroupDescriptor { + label: Some("GlobalBindGroup"), + layout: &self.0, + entries: &[BindGroupEntry { + binding: 0, + resource: BindingResource::Buffer(BufferBinding { + buffer: shader_constants, + offset: 0, + size: None, + }), + }], + })) + } +} + +#[derive(Debug, Clone)] +pub struct GlobalBindGroup(pub BindGroup); diff --git a/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/swapchain.rs b/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/swapchain.rs new file mode 100644 index 00000000..97aac5dc --- /dev/null +++ b/templates/apps/rust-gpu-wgpu/src/wgpu_renderer/swapchain.rs @@ -0,0 +1,119 @@ +use anyhow::Context; +use std::sync::Arc; +use wgpu::{Adapter, CurrentSurfaceTexture, Device, Instance, Surface, TextureFormat, TextureView}; +use winit::dpi::PhysicalSize; +use winit::window::Window; + +pub struct MySwapchainManager<'a> { + instance: Instance, + adapter: Adapter, + device: Device, + window: Arc, + surface: Surface<'a>, + format: TextureFormat, + + // state below + active: Option, + should_recreate: bool, +} + +pub struct ActiveConfiguration { + size: PhysicalSize, +} + +impl<'a> MySwapchainManager<'a> { + pub fn new( + instance: Instance, + adapter: Adapter, + device: Device, + window: Arc, + surface: Surface<'a>, + ) -> Self { + let caps = surface.get_capabilities(&adapter); + Self { + instance, + adapter, + device, + window, + surface, + format: caps.formats[0], + active: None, + should_recreate: true, + } + } + + #[inline] + pub fn should_recreate(&mut self) { + self.should_recreate = true; + } + + pub fn format(&self) -> TextureFormat { + self.format + } + + pub fn render( + &mut self, + f: impl FnOnce(TextureView) -> anyhow::Result<()>, + ) -> anyhow::Result<()> { + let size = self.window.inner_size(); + if let Some(active) = &self.active { + if active.size != size { + self.should_recreate(); + } + } else { + self.should_recreate(); + } + + if self.should_recreate { + self.should_recreate = false; + self.configure_surface(size)?; + } + + match self.surface.get_current_texture() { + CurrentSurfaceTexture::Success(surface_texture) => { + let output_view = + surface_texture + .texture + .create_view(&wgpu::TextureViewDescriptor { + format: Some(self.format), + ..wgpu::TextureViewDescriptor::default() + }); + f(output_view)?; + surface_texture.present(); + } + CurrentSurfaceTexture::Occluded | CurrentSurfaceTexture::Timeout => (), + CurrentSurfaceTexture::Suboptimal(_) | CurrentSurfaceTexture::Outdated => { + self.should_recreate(); + } + CurrentSurfaceTexture::Validation => { + anyhow::bail!("Validation error during surface texture acquisition") + } + CurrentSurfaceTexture::Lost => { + self.surface = self.instance.create_surface(self.window.clone())?; + self.should_recreate(); + } + }; + Ok(()) + } + + fn configure_surface(&mut self, size: PhysicalSize) -> anyhow::Result<()> { + let mut surface_config = self + .surface + .get_default_config(&self.adapter, size.width, size.height) + .with_context(|| { + format!( + "Incompatible adapter for surface, returned capabilities: {:?}", + self.surface.get_capabilities(&self.adapter) + ) + })?; + + // force srgb surface format + surface_config.view_formats.push(self.format); + // limit framerate to vsync + surface_config.present_mode = wgpu::PresentMode::AutoVsync; + self.surface.configure(&self.device, &surface_config); + + self.active = Some(ActiveConfiguration { size }); + Ok(()) + } +}