A crates/client/Cargo.toml => crates/client/Cargo.toml +35 -0
@@ 0,0 1,35 @@
+[package]
+name = "starkingdoms-client"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+# Everywhere dependencies
+[dependencies]
+tracing = "0.1" # Log system
+tracing-subscriber = "0.3" # Log layers
+bevy_ecs = "0.15"
+egui = "0.30"
+wgpu = { version = "23", features = ["webgl"] }
+winit = "0.30"
+thiserror = "2"
+image = "0.25"
+egui-winit = { version = "0.30", default-features = false, features = ["links", "wayland", "x11"] }
+egui-wgpu = "0.30"
+web-time = "1"
+futures = "0.3"
+nalgebra = "0.33"
+
+# WASM dependencies
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+tracing-web = "0.1" # Log output
+console_error_panic_hook = "0.1" # Give useful information in the panic response, other than the useless "entered unreachable code"
+wasm-bindgen = "0.2"
+web-sys = "0.3"
+wasm-bindgen-futures = "0.4"
+
+# Native dependencies
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+pollster = "0.4"<
\ No newline at end of file
A crates/client/index.html => crates/client/index.html +39 -0
@@ 0,0 1,39 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>StarKingdoms.TK</title>
+
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
+
+ <style>
+ html, body {
+ overflow: hidden;
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ width: 100%;
+ }
+
+ canvas {
+ margin-right: auto;
+ margin-left: auto;
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+ </style>
+ </head>
+ <body>
+ <script type="module">
+ import init from './pkg/starkingdoms_client.js';
+ async function run() {
+ await init();
+ }
+ window.addEventListener('load', run);
+ </script>
+ </body>
+</html><
\ No newline at end of file
A crates/client/src/ecs.rs => crates/client/src/ecs.rs +87 -0
@@ 0,0 1,87 @@
+use bevy_ecs::bundle::Bundle;
+use bevy_ecs::component::Component;
+use bevy_ecs::system::Resource;
+use nalgebra::Matrix3;
+
+#[derive(Component, Debug, Clone, Copy)]
+pub struct Translation {
+ pub x: f32,
+ pub y: f32,
+}
+impl Translation {
+ pub fn as_matrix(&self) -> Matrix3<f32> {
+ Matrix3::from_iterator([
+ 1.0, 0.0, self.x,
+ 0.0, 1.0, self.y,
+ 0.0, 0.0, 1.0,
+ ])
+ }
+}
+
+#[derive(Component, Debug, Clone, Copy)]
+pub struct Shear {
+ pub x: f32,
+ pub y: f32,
+}
+impl Shear {
+ pub fn as_matrix(&self) -> Matrix3<f32> {
+ Matrix3::from_iterator([
+ 1.0, self.x, 0.0,
+ self.y, 1.0, 0.0,
+ 0.0, 0.0, 1.0,
+ ])
+ }
+}
+
+#[derive(Component, Debug, Clone, Copy)]
+pub struct Scale {
+ pub width: f32,
+ pub height: f32,
+}
+impl Scale {
+ pub fn as_matrix(&self) -> Matrix3<f32> {
+ Matrix3::from_iterator([
+ self.width, 0.0, 0.0,
+ 0.0, self.height, 0.0,
+ 0.0, 0.0, 1.0
+ ])
+ }
+}
+#[derive(Component, Debug, Clone, Copy)]
+pub struct Rotation {
+ pub radians: f32
+}
+impl Rotation {
+ pub fn as_matrix(&self) -> Matrix3<f32> {
+ let x = self.radians.cos();
+ let y = self.radians.sin();
+ Matrix3::from_iterator([
+ x, y, 0.0,
+ -y, x, 0.0,
+ 0.0, 0.0, 1.0
+ ])
+ }
+}
+
+#[derive(Component, Debug, Clone)]
+pub struct SpriteTexture {
+ pub texture: String,
+}
+
+#[derive(Bundle)]
+pub struct SpriteBundle {
+ pub position: Translation,
+ pub shear: Shear,
+ pub scale: Scale,
+ pub texture: SpriteTexture,
+ pub rotation: Rotation
+}
+
+#[derive(Resource, Debug)]
+pub struct Camera {
+ pub x: f32,
+ pub y: f32,
+ pub shear_x: f32,
+ pub shear_y: f32,
+ pub zoom: f32,
+}
A crates/client/src/input.rs => crates/client/src/input.rs +7 -0
@@ 0,0 1,7 @@
+use bevy_ecs::event::Event;
+
+#[derive(Event, Debug, Copy, Clone)]
+pub enum MouseWheelEvent {
+ Line { x: f64, y: f64 },
+ Pixel { x: f64, y: f64 },
+}
A crates/client/src/lib.rs => crates/client/src/lib.rs +182 -0
@@ 0,0 1,182 @@
+use crate::ecs::{Camera, Translation, Rotation, Scale, SpriteBundle, SpriteTexture};
+use crate::input::MouseWheelEvent;
+use crate::rendering::ui::UiRenderable;
+use crate::rendering::App;
+use bevy_ecs::event::{EventReader, Events};
+use bevy_ecs::schedule::Schedule;
+use bevy_ecs::system::ResMut;
+use bevy_ecs::world::World;
+use ecs::Shear;
+use egui::{Context, DragValue};
+use tracing::info;
+use winit::event_loop::{ControlFlow, EventLoop};
+
+#[cfg(target_arch = "wasm32")]
+#[path = "wasm/mod.rs"]
+pub mod platform;
+#[cfg(not(target_arch = "wasm32"))]
+#[path = "native/mod.rs"]
+pub mod platform;
+
+pub mod ecs;
+pub mod input;
+pub mod rendering;
+
+// Hi, you've found the real main function! This is called AFTER platform-specific initialization code.
+pub fn start() {
+ info!(
+ "Hello, world! StarKingdoms.TK v{} says hello, running on {}",
+ env!("CARGO_PKG_VERSION"),
+ if cfg!(target_arch = "wasm32") {
+ "wasm"
+ } else {
+ "native"
+ }
+ );
+
+ info!("Creating the ECS world...");
+ let mut world = World::new();
+
+ world.insert_resource::<Events<MouseWheelEvent>>(Events::default());
+ world.insert_resource(Camera {
+ x: 0.0,
+ y: 0.0,
+ shear_x: 0.0,
+ shear_y: 0.0,
+ zoom: 1.0,
+ });
+
+ let mut start_schedule = Schedule::default();
+ // Add startup things here
+ // Caution: This will run before there are things on-screen
+ start_schedule.run(&mut world);
+
+ let mut update_schedule = Schedule::default();
+ // Add things to run every frame here
+ // Caution: This will run once before there are things on screen
+ update_schedule.add_systems(zoom_camera_on_mouse_events);
+
+ world.spawn(SpriteBundle {
+ position: Translation {
+ x: 100.0,
+ y: 100.0
+ },
+ shear: Shear {
+ x: 0.0,
+ y: 0.0,
+ },
+ scale: Scale {
+ width: 100.0,
+ height: 100.0,
+ },
+ rotation: Rotation {
+ radians: 45.0_f32.to_radians()
+ },
+ texture: SpriteTexture {
+ texture: "happy-tree".to_string(),
+ },
+ });
+
+ let event_loop = EventLoop::new().unwrap();
+ event_loop.set_control_flow(ControlFlow::Poll);
+ event_loop
+ .run_app(&mut App::new(world, update_schedule, Gui {}))
+ .unwrap();
+}
+
+fn zoom_camera_on_mouse_events(mut events: EventReader<MouseWheelEvent>, mut camera: ResMut<Camera>) {
+ for event in events.read() {
+ let raw_delta = match event {
+ MouseWheelEvent::Line { y, .. } => *y,
+ MouseWheelEvent::Pixel { y, ..} => *y,
+ } as f32;
+
+ let delta = if raw_delta < 0.0 {
+ raw_delta * -0.9
+ } else {
+ raw_delta * 1.1
+ };
+
+ if delta < 0.0 {
+ camera.zoom *= 1.0 / delta;
+ } else {
+ camera.zoom *= delta;
+ }
+ }
+}
+
+pub struct Gui {}
+impl UiRenderable for Gui {
+ fn render(&mut self, ctx: &Context, world: &mut World) {
+ egui::Window::new("Main Menu")
+ .resizable(false)
+ .show(ctx, |ui| {
+ ui.heading("StarKingdoms.TK");
+ ui.label("A game about floating through space");
+ ui.separator();
+
+ let mut sprites = world.query::<(&mut Translation, &mut Shear, &mut Scale, &SpriteTexture, &mut Rotation)>();
+ for (mut pos, mut shear, mut scale, tex, mut rot) in sprites.iter_mut(world) {
+ ui.heading(&tex.texture);
+
+ egui::Grid::new("sprite_grid")
+ .num_columns(2)
+ .spacing([40.0, 4.0])
+ .striped(true)
+ .show(ui, |ui| {
+ ui.label("X");
+ ui.add(DragValue::new(&mut pos.x).speed(0.1));
+ ui.end_row();
+
+ ui.label("Y");
+ ui.add(DragValue::new(&mut pos.y).speed(0.1));
+ ui.end_row();
+
+ ui.label("Shear X");
+ ui.add(DragValue::new(&mut shear.x).speed(0.1));
+ ui.end_row();
+
+ ui.label("Shear Y");
+ ui.add(DragValue::new(&mut shear.y).speed(0.1));
+ ui.end_row();
+
+ ui.label("Width");
+ ui.add(DragValue::new(&mut scale.width).speed(0.1));
+ ui.end_row();
+
+ ui.label("Height");
+ ui.add(DragValue::new(&mut scale.height).speed(0.1));
+ ui.end_row();
+
+ ui.label("Rotation");
+ ui.add(DragValue::new(&mut rot.radians).speed(0.1));
+ ui.end_row();
+ });
+ }
+ let mut camera = world.resource_mut::<Camera>();
+ egui::Grid::new("camera_grid")
+ .num_columns(2)
+ .spacing([40.0, 4.0])
+ .show(ui, |ui| {
+ ui.label("Camera X");
+ ui.add(DragValue::new(&mut camera.x).speed(0.1));
+ ui.end_row();
+
+ ui.label("Camera Y");
+ ui.add(DragValue::new(&mut camera.y).speed(0.1));
+ ui.end_row();
+
+ ui.label("Shear X");
+ ui.add(DragValue::new(&mut camera.shear_x).speed(0.1));
+ ui.end_row();
+ ui.label("Shear Y");
+ ui.add(DragValue::new(&mut camera.shear_y).speed(0.1));
+ ui.end_row();
+
+ ui.label("Camera Zoom");
+ ui.add(DragValue::new(&mut camera.zoom).speed(0.1));
+ ui.end_row();
+ });
+ });
+ }
+}
A crates/client/src/main.rs => crates/client/src/main.rs +5 -0
@@ 0,0 1,5 @@
+/// --- IMPORTANT: DO NOT EDIT - EDIT THE APPROPRIATE PLATFORM SPECIFIC ENTRYPOINT ---
+/// `platform::start` IS WHAT YOU WANT
+fn main() {
+ starkingdoms_client::platform::entrypoint();
+}
A crates/client/src/native/mod.rs => crates/client/src/native/mod.rs +11 -0
@@ 0,0 1,11 @@
+/// --- IMPORTANT: THIS IS A DUAL TARGET CRATE ---
+/// THIS WILL ONLY EXECUTE ON NATIVE
+/// DO ONLY PLATFORM SPECIFIC INITIALIZATION HERE
+/// FOR ACTUAL PROGRAM LOGIC, EDIT `crate::start`
+/// DO NOT RENAME
+pub fn entrypoint() {
+ tracing_subscriber::fmt::init();
+
+ // All done with platform-specific initialization, call back into the common code path
+ crate::start();
+}
A crates/client/src/rendering/mipmap.rs => crates/client/src/rendering/mipmap.rs +134 -0
@@ 0,0 1,134 @@
+use crate::rendering::texture::Texture;
+use std::collections::HashMap;
+use tracing::debug;
+use wgpu::{
+ include_wgsl, BindGroupDescriptor, BindGroupEntry, BindingResource, Color, ColorTargetState,
+ ColorWrites, CommandEncoderDescriptor, Device, FilterMode, FragmentState, LoadOp, Operations,
+ Queue, RenderPassColorAttachment, RenderPassDescriptor, RenderPipeline,
+ RenderPipelineDescriptor, Sampler, SamplerDescriptor, ShaderModule, StoreOp, TextureFormat,
+ TextureViewDescriptor, VertexState,
+};
+
+pub struct MipGenerator {
+ shader: ShaderModule,
+ sampler: Sampler,
+ pipelines: HashMap<TextureFormat, RenderPipeline>,
+}
+impl MipGenerator {
+ pub fn new(device: &Device) -> Self {
+ debug!("initializing MipGenerator, compiling shader module");
+ let shader = device.create_shader_module(include_wgsl!("../shaders/text_quad_mips.wgsl"));
+ Self {
+ shader,
+ sampler: device.create_sampler(&SamplerDescriptor {
+ min_filter: FilterMode::Linear,
+ label: Some("MipGenerator sampler"),
+ ..Default::default()
+ }),
+ pipelines: HashMap::new(),
+ }
+ }
+
+ pub fn generate_mips(&mut self, texture: &Texture, device: &Device, queue: &Queue) {
+ let pipeline = self
+ .pipelines
+ .entry(texture.texture.format())
+ .or_insert_with(|| {
+ device.create_render_pipeline(&RenderPipelineDescriptor {
+ label: Some("MipGenerator format pipeline"),
+ layout: None,
+ vertex: VertexState {
+ module: &self.shader,
+ entry_point: Some("vs"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ fragment: Some(FragmentState {
+ module: &self.shader,
+ entry_point: Some("fs"),
+ compilation_options: Default::default(),
+ targets: &[Some(ColorTargetState {
+ format: texture.texture.format(),
+ blend: None,
+ write_mask: ColorWrites::default(),
+ })],
+ }),
+ multiview: None,
+ cache: None,
+ })
+ });
+
+ let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
+ label: Some("MipGenerator command encoder"),
+ });
+
+ let mut width = texture.texture.width();
+ let mut height = texture.texture.height();
+ let mut base_mip_level = 0;
+
+ while width > 1 || height > 1 {
+ width = 1.max(width / 2);
+ height = 1.max(height / 2);
+
+ let bind_group = device.create_bind_group(&BindGroupDescriptor {
+ label: Some("MipGenerator bind group"),
+ layout: &pipeline.get_bind_group_layout(0),
+ entries: &[
+ BindGroupEntry {
+ binding: 0,
+ resource: BindingResource::Sampler(&self.sampler),
+ },
+ BindGroupEntry {
+ binding: 1,
+ resource: BindingResource::TextureView(&texture.texture.create_view(
+ &TextureViewDescriptor {
+ base_mip_level,
+ mip_level_count: Some(1),
+ ..Default::default()
+ },
+ )),
+ },
+ ],
+ });
+
+ base_mip_level += 1;
+
+ let texture_view = texture.texture.create_view(&TextureViewDescriptor {
+ base_mip_level,
+ mip_level_count: Some(1),
+ ..Default::default()
+ });
+
+ let render_pass_descriptor = RenderPassDescriptor {
+ label: Some("MipGenerator render pass"),
+ color_attachments: &[Some(RenderPassColorAttachment {
+ view: &texture_view,
+ resolve_target: None,
+ ops: Operations {
+ load: LoadOp::Clear(Color {
+ r: 0.0,
+ g: 0.0,
+ b: 0.0,
+ a: 1.0,
+ }),
+ store: StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ };
+
+ let mut pass = encoder.begin_render_pass(&render_pass_descriptor);
+ pass.set_pipeline(pipeline);
+ pass.set_bind_group(0, Some(&bind_group), &[]);
+ pass.draw(0..6, 0..1);
+ }
+
+ let command_buffer = encoder.finish();
+ queue.submit(std::iter::once(command_buffer));
+ }
+}
A crates/client/src/rendering/mod.rs => crates/client/src/rendering/mod.rs +230 -0
@@ 0,0 1,230 @@
+mod mipmap;
+mod renderer;
+mod texture;
+pub mod ui;
+
+use crate::input::MouseWheelEvent;
+use crate::rendering::renderer::RenderInitRes::{Initialized, NotReadyYet};
+#[allow(unused_imports)]
+use crate::rendering::renderer::{Renderer, RenderInitRes};
+use crate::rendering::ui::UiRenderable;
+use bevy_ecs::schedule::Schedule;
+use bevy_ecs::world::World;
+use std::ops::Add;
+use std::process::exit;
+use std::sync::Arc;
+use std::time::Duration;
+use tracing::info;
+use web_time::Instant;
+use winit::application::ApplicationHandler;
+use winit::dpi::LogicalSize;
+use winit::event::{MouseScrollDelta, WindowEvent};
+use winit::event_loop::{ActiveEventLoop, ControlFlow};
+use winit::window::{Window, WindowId};
+
+pub struct App<T: UiRenderable> {
+ window: Option<Arc<Window>>,
+ renderer: Option<Renderer<T>>,
+
+ #[cfg(target_arch = "wasm32")]
+ renderer_rx: Option<futures::channel::oneshot::Receiver<RenderInitRes<T>>>,
+
+ world: Option<World>,
+ update_schedule: Option<Schedule>,
+ ui_renderable: Option<T>,
+}
+
+impl<T: UiRenderable> App<T> {
+ pub fn new(world: World, update_schedule: Schedule, ui_renderable: T) -> Self {
+ Self {
+ window: None,
+ renderer: None,
+ #[cfg(target_arch = "wasm32")]
+ renderer_rx: None,
+ world: Some(world),
+ update_schedule: Some(update_schedule),
+ ui_renderable: Some(ui_renderable),
+ }
+ }
+}
+impl<T: UiRenderable + 'static> ApplicationHandler for App<T> {
+ fn resumed(&mut self, event_loop: &ActiveEventLoop) {
+ if self.window.is_none() {
+ let attributes = Window::default_attributes().with_title("StarKingdoms.TK");
+
+ let window = Arc::new(event_loop.create_window(attributes).unwrap());
+ self.window = Some(window.clone());
+
+ let world = self.world.take().unwrap();
+ let update_schedule = self.update_schedule.take().unwrap();
+ let ui_renderable = self.ui_renderable.take().unwrap();
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let renderer = pollster::block_on(async move {
+ Renderer::try_init(window.clone(), world, update_schedule, ui_renderable).await
+ });
+ match renderer {
+ Initialized(r) => {
+ self.renderer = Some(r);
+ }
+ NotReadyYet(w, u, t) => {
+ self.world = Some(w);
+ self.update_schedule = Some(u);
+ self.ui_renderable = Some(t);
+ }
+ }
+ }
+ #[cfg(target_arch = "wasm32")]
+ {
+ use winit::platform::web::WindowExtWebSys;
+ // Add it to the DOM
+ web_sys::window()
+ .unwrap()
+ .document()
+ .unwrap()
+ .body()
+ .unwrap()
+ .append_child(&window.canvas().unwrap())
+ .unwrap();
+
+ let (tx, rx) = futures::channel::oneshot::channel();
+ self.renderer_rx = Some(rx);
+ wasm_bindgen_futures::spawn_local(async move {
+ let renderer =
+ Renderer::try_init(window.clone(), world, update_schedule, ui_renderable).await;
+ tx.send(renderer).unwrap();
+ });
+ }
+ }
+ }
+
+ fn window_event(
+ &mut self,
+ event_loop: &ActiveEventLoop,
+ _window_id: WindowId,
+ event: WindowEvent,
+ ) {
+ if event == WindowEvent::CloseRequested {
+ info!("Close requested by underlying event system");
+ event_loop.exit();
+ exit(1);
+ }
+ if let Some(renderer) = &mut self.renderer {
+ let egui_response = renderer.gui_winit.on_window_event(&renderer.window, &event);
+ if egui_response.consumed {
+ return;
+ }
+ }
+
+ match event {
+ WindowEvent::Resized(new) => {
+ if let Some(renderer) = &mut self.renderer {
+ if new.width > 0 && new.height > 0 {
+ renderer.size = new;
+ renderer.scale_factor = renderer.window.scale_factor();
+ renderer.logical_size =
+ LogicalSize::from_physical(renderer.size, renderer.scale_factor);
+
+ renderer.surface_configuration.width = new.width;
+ renderer.surface_configuration.height = new.height;
+ renderer
+ .surface
+ .configure(&renderer.device, &renderer.surface_configuration);
+ }
+ }
+ }
+ WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
+ if let Some(renderer) = &mut self.renderer {
+ renderer.scale_factor = scale_factor;
+ renderer.logical_size =
+ LogicalSize::from_physical(renderer.size, renderer.scale_factor);
+ }
+ }
+ WindowEvent::MouseWheel { delta, .. } => {
+ if let Some(renderer) = &mut self.renderer {
+ renderer.world.send_event(match delta {
+ MouseScrollDelta::PixelDelta(pos) => {
+ MouseWheelEvent::Pixel { x: pos.x, y: pos.y }
+ }
+ MouseScrollDelta::LineDelta(x, y) => MouseWheelEvent::Line {
+ x: x as f64,
+ y: y as f64,
+ },
+ });
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
+ #[allow(unused_variables)]
+ if let Some(window) = self.window.clone() {
+ #[cfg(target_arch = "wasm32")]
+ {
+ let mut renderer_rxd = false;
+ if let Some(rx) = self.renderer_rx.as_mut() {
+ if let Ok(Some(renderer)) = rx.try_recv() {
+ match renderer {
+ Initialized(r) => {
+ self.renderer = Some(r);
+ renderer_rxd = true;
+ }
+ NotReadyYet(w, u, t) => {
+ let (tx, rx) = futures::channel::oneshot::channel();
+ self.renderer_rx = Some(rx);
+ wasm_bindgen_futures::spawn_local(async move {
+ let renderer = Renderer::try_init(window.clone(), w, u, t).await;
+ tx.send(renderer).unwrap();
+ });
+ }
+ }
+ }
+ }
+ if renderer_rxd {
+ self.renderer_rx = None;
+ }
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ if self.renderer.is_none() {
+ if let Some(window) = self.window.clone() {
+ let world = self.world.take().unwrap();
+ let update_schedule = self.update_schedule.take().unwrap();
+ let ui_renderable = self.ui_renderable.take().unwrap();
+
+ let renderer = pollster::block_on(async move {
+ Renderer::try_init(window.clone(), world, update_schedule, ui_renderable)
+ .await
+ });
+
+ match renderer {
+ Initialized(r) => {
+ self.renderer = Some(r);
+ }
+ NotReadyYet(w, u, t) => {
+ self.world = Some(w);
+ self.update_schedule = Some(u);
+ self.ui_renderable = Some(t);
+ }
+ }
+
+ return;
+ }
+ }
+ }
+
+ let Some(renderer) = &mut self.renderer else {
+ return;
+ };
+
+ renderer.render();
+
+ event_loop.set_control_flow(ControlFlow::WaitUntil(
+ Instant::now().add(Duration::from_secs_f64(1.0 / 60.0)),
+ ));
+ }
+ }
+}
A crates/client/src/rendering/renderer.rs => crates/client/src/rendering/renderer.rs +389 -0
@@ 0,0 1,389 @@
+use crate::ecs::{Camera, Rotation, Scale, Shear, SpriteTexture, Translation};
+use crate::rendering::mipmap::MipGenerator;
+use crate::rendering::renderer::RenderInitRes::{Initialized, NotReadyYet};
+use crate::rendering::texture;
+use crate::rendering::ui::UiRenderable;
+use bevy_ecs::schedule::Schedule;
+use bevy_ecs::world::World;
+use egui::ViewportId;
+use std::collections::HashMap;
+use std::fmt::{Debug, Formatter};
+use std::sync::Arc;
+use tracing::info;
+use web_time::Instant;
+use wgpu::SurfaceConfiguration;
+use wgpu::{
+ include_wgsl, Adapter, Backends, BindGroupDescriptor, BindGroupEntry, BindingResource, Buffer,
+ BufferDescriptor, BufferUsages, Color, ColorTargetState, CommandEncoderDescriptor, Device,
+ DeviceDescriptor, Features, FragmentState, Instance, InstanceDescriptor, Limits, LoadOp,
+ Operations, Queue, RenderPassColorAttachment, RenderPassDescriptor, RenderPipeline,
+ RenderPipelineDescriptor, RequestAdapterOptions, ShaderModule, StoreOp, Surface,
+ TextureViewDescriptor, VertexState,
+};
+use winit::dpi::{LogicalSize, PhysicalSize};
+use winit::window::Window;
+
+#[allow(unused_attributes, dead_code)]
+pub struct Renderer<T: UiRenderable> {
+ pub last_frame_time: Instant,
+
+ pub surface: Surface<'static>,
+ pub surface_configuration: SurfaceConfiguration,
+ pub device: Device,
+ pub queue: Queue,
+ pub adapter: Adapter,
+
+ pub sprite_shader_module: ShaderModule,
+ pub sprite_pipeline: RenderPipeline,
+
+ pub world: World,
+ pub update_schedule: Schedule,
+
+ pub gui_ctx: egui::Context,
+ pub gui_winit: egui_winit::State,
+ pub gui_renderer: egui_wgpu::Renderer,
+ pub gui_renderable: T,
+
+ pub textures: HashMap<String, texture::Texture>,
+ pub mip_generator: MipGenerator,
+
+ pub frame_uniform: Buffer,
+ pub local_uniform: Buffer,
+ pub scale_factor: f64,
+
+ pub window: Arc<Window>,
+
+ pub size: PhysicalSize<u32>,
+ pub logical_size: LogicalSize<u32>,
+}
+
+#[allow(clippy::large_enum_variant)]
+pub enum RenderInitRes<T: UiRenderable> {
+ Initialized(Renderer<T>),
+ NotReadyYet(World, Schedule, T),
+}
+impl<T: UiRenderable> Debug for RenderInitRes<T> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Initialized(..) => write!(f, "[initialized renderer]"),
+ NotReadyYet(..) => write!(f, "[pending initialization]"),
+ }
+ }
+}
+
+impl<T: UiRenderable> Renderer<T> {
+ pub async fn try_init(
+ window: Arc<Window>,
+ world: World,
+ update_schedule: Schedule,
+ gui_renderable: T,
+ ) -> RenderInitRes<T> {
+ let size = window.inner_size();
+ if size.width == 0 || size.height == 0 {
+ return NotReadyYet(world, update_schedule, gui_renderable);
+ }
+
+ // First, create an instance. This is our handle to wgpu, and is the equivalent of navigator.gpu in WebGPU
+ let instance = Instance::new(InstanceDescriptor {
+ backends: Backends::all(), // Select the appropriate backend for the HAL to use. Defined in the platform module, as it's platform-specific
+ ..Default::default() // Other fields aren't relevant here
+ });
+
+ // Next, get our render surface
+ let surface = instance.create_surface(window.clone()).unwrap();
+
+ // Next, request out adapter
+ let adapter = instance
+ .request_adapter(&RequestAdapterOptions {
+ power_preference: Default::default(), // Don't care
+ force_fallback_adapter: false, // We want a real GPU
+ compatible_surface: Some(&surface), // Find an adapter that is able to render to our window
+ })
+ .await
+ .unwrap();
+
+ let mut limits = Limits::downlevel_webgl2_defaults();
+ limits.max_texture_dimension_1d = 8192;
+ limits.max_texture_dimension_2d = 8192;
+ limits.max_texture_dimension_3d = 2048;
+ let (device, queue) = adapter
+ .request_device(
+ &DeviceDescriptor {
+ label: Some("Basic render device"),
+ required_features: Features::default(),
+ required_limits: limits,
+ memory_hints: Default::default(),
+ },
+ None,
+ )
+ .await
+ .unwrap();
+
+ let format = surface
+ .get_default_config(&adapter, size.width, size.height)
+ .unwrap();
+
+ surface.configure(&device, &format);
+
+ let sprite_shader_module =
+ device.create_shader_module(include_wgsl!("../shaders/sprite.wgsl"));
+ let sprite_pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
+ label: Some("Sprite pipeline"),
+ layout: None,
+ vertex: VertexState {
+ module: &sprite_shader_module,
+ entry_point: Some("vs"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ fragment: Some(FragmentState {
+ module: &sprite_shader_module,
+ entry_point: Some("fs"),
+ compilation_options: Default::default(),
+ targets: &[Some(ColorTargetState {
+ format: format.format,
+ blend: None,
+ write_mask: Default::default(),
+ })],
+ }),
+ multiview: None,
+ cache: None,
+ });
+
+ let gui_context = egui::Context::default();
+ let gui_winit = egui_winit::State::new(
+ gui_context.clone(),
+ ViewportId::ROOT,
+ &window,
+ Some(window.scale_factor() as f32),
+ None,
+ Some(device.limits().max_texture_dimension_2d as usize),
+ );
+ let gui_renderer = egui_wgpu::Renderer::new(&device, format.format, None, 1, false);
+
+ let frame_uniform = device.create_buffer(&BufferDescriptor {
+ label: Some("frame uniforms"),
+ size: 48 + 16, // mat3x3f, vec2f
+ usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ let local_uniform = device.create_buffer(&BufferDescriptor {
+ label: Some("local uniforms"),
+ size: 48, // mat3x3f
+ usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+
+ Initialized(Self {
+ mip_generator: MipGenerator::new(&device),
+ textures: HashMap::new(),
+ last_frame_time: Instant::now(),
+ surface,
+ device,
+ queue,
+ adapter,
+ sprite_shader_module,
+ sprite_pipeline,
+ world,
+ update_schedule,
+ gui_ctx: gui_context,
+ gui_winit,
+ gui_renderer,
+ gui_renderable,
+ logical_size: LogicalSize::from_physical(size, window.scale_factor()),
+ scale_factor: window.scale_factor(),
+ window,
+ frame_uniform,
+ local_uniform,
+ size,
+ surface_configuration: format,
+ })
+ }
+
+ pub fn render(&mut self) {
+ // update the world
+ self.update_schedule.run(&mut self.world);
+ // update the UI
+ let egui_output = self
+ .gui_ctx
+ .run(self.gui_winit.take_egui_input(&self.window), |ctx| {
+ self.gui_renderable.render(ctx, &mut self.world)
+ });
+ self.gui_winit
+ .handle_platform_output(&self.window, egui_output.platform_output);
+
+ let output = self.surface.get_current_texture().unwrap();
+ let view = output
+ .texture
+ .create_view(&TextureViewDescriptor::default());
+
+ let render_pass_descriptor = RenderPassDescriptor {
+ label: Some("basic render pass"),
+ color_attachments: &[Some(RenderPassColorAttachment {
+ view: &view,
+ resolve_target: None,
+ ops: Operations {
+ load: LoadOp::Clear(Color {
+ r: 0.3,
+ g: 0.3,
+ b: 0.3,
+ a: 1.0,
+ }),
+ store: StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ };
+ let mut encoder = self
+ .device
+ .create_command_encoder(&CommandEncoderDescriptor {
+ label: Some("command encoder"),
+ });
+
+ let mut sprites_to_render: Vec<(Translation, Shear, Scale, SpriteTexture, Rotation)> = vec![];
+
+ let mut things_to_render = self.world.query::<(&Translation, &Shear, &Scale, &SpriteTexture, &Rotation)>();
+ for thing in things_to_render.iter_mut(&mut self.world) {
+ sprites_to_render.push((*thing.0, *thing.1, *thing.2, thing.3.clone(), *thing.4));
+ }
+
+ let cam = self.world.resource::<Camera>();
+
+ let mut frame_uniform = vec![];
+ let frame_uniform_values = [
+ cam.zoom, cam.shear_y,0.0, 0.0,
+ cam.shear_x,cam.zoom, 0.0, 0.0,
+ cam.x, cam.y, 1.0, 0.0,
+ self.logical_size.width as f32, self.logical_size.height as f32, 0.0, 0.0];
+ for i in frame_uniform_values {
+ let mut bytes = i.to_ne_bytes().to_vec();
+ frame_uniform.append(&mut bytes);
+ }
+
+ self.queue.write_buffer(&self.frame_uniform, 0, &frame_uniform);
+
+ for (pos, shear, scale, tex, rot) in sprites_to_render {
+ let tex = self.textures.entry(tex.texture.clone()).or_insert_with(|| {
+ info!("loading texture {}", &tex.texture);
+ let b: &[u8] = match tex.texture.as_str() {
+ "f" => include_bytes!("../textures/f.png"),
+ "happy-tree" => include_bytes!("../textures/happy-tree.png"),
+ "uv" => include_bytes!("../textures/uv.png"),
+ u => panic!("unknown texture {u}, has it been added in rendering::renderer::<impl Renderer>::render()?")
+ };
+ texture::Texture::new(
+ b,
+ &tex.texture,
+ &self.device,
+ &self.queue,
+ &mut self.mip_generator,
+ )
+ });
+
+ let xy_matrix = pos.as_matrix();
+ let shear_matrix = shear.as_matrix();
+ let rot_matrix = rot.as_matrix();
+ let scale_matrix = scale.as_matrix();
+
+ let transform_matrix = scale_matrix * shear_matrix * rot_matrix * xy_matrix;
+
+
+ let mut local_buffer = vec![];
+ let local_buffer_data = [
+ transform_matrix.m11, transform_matrix.m12, transform_matrix.m13, 0.0,
+ transform_matrix.m21, transform_matrix.m22, transform_matrix.m23, 0.0,
+ transform_matrix.m31, transform_matrix.m32, transform_matrix.m33, 0.0,
+ ];
+ for i in local_buffer_data {
+ let mut bytes = i.to_ne_bytes().to_vec();
+ local_buffer.append(&mut bytes);
+ }
+
+ self.queue
+ .write_buffer(&self.local_uniform, 0, &local_buffer);
+
+ let bind_group = self.device.create_bind_group(&BindGroupDescriptor {
+ label: Some("test_bind_group"),
+ layout: &self.sprite_pipeline.get_bind_group_layout(0),
+ entries: &[
+ BindGroupEntry {
+ binding: 0,
+ resource: BindingResource::Sampler(&tex.sampler),
+ },
+ BindGroupEntry {
+ binding: 1,
+ resource: BindingResource::TextureView(
+ &tex.texture.create_view(&TextureViewDescriptor::default()),
+ ),
+ },
+ BindGroupEntry {
+ binding: 2,
+ resource: BindingResource::Buffer(
+ self.frame_uniform.as_entire_buffer_binding(),
+ ),
+ },
+ BindGroupEntry {
+ binding: 3,
+ resource: BindingResource::Buffer(
+ self.local_uniform.as_entire_buffer_binding(),
+ ),
+ },
+ ],
+ });
+ let mut pass = encoder.begin_render_pass(&render_pass_descriptor);
+ pass.set_pipeline(&self.sprite_pipeline);
+ pass.set_bind_group(0, Some(&bind_group), &[]);
+ pass.draw(0..6, 0..1);
+ }
+ // main game rendering done
+ // next up: egui UI rendering
+ for (id, image_delta) in &egui_output.textures_delta.set {
+ self.gui_renderer
+ .update_texture(&self.device, &self.queue, *id, image_delta);
+ }
+ {
+ let paint_jobs = self
+ .gui_ctx
+ .tessellate(egui_output.shapes, self.scale_factor as f32);
+ let screen_descriptor = egui_wgpu::ScreenDescriptor {
+ size_in_pixels: [self.size.width, self.size.height],
+ pixels_per_point: self.scale_factor as f32,
+ };
+ self.gui_renderer.update_buffers(
+ &self.device,
+ &self.queue,
+ &mut encoder,
+ &paint_jobs,
+ &screen_descriptor,
+ );
+ let render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
+ label: Some("ui render pass"),
+ color_attachments: &[Some(RenderPassColorAttachment {
+ view: &view,
+ resolve_target: None,
+ ops: Operations {
+ load: LoadOp::Load,
+ store: StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ let mut forgotten_render_pass = render_pass.forget_lifetime();
+ self.gui_renderer
+ .render(&mut forgotten_render_pass, &paint_jobs, &screen_descriptor);
+ for id in egui_output.textures_delta.free {
+ self.gui_renderer.free_texture(&id);
+ }
+ }
+
+ let buffer = encoder.finish();
+ self.queue.submit(std::iter::once(buffer));
+
+ output.present();
+ }
+}
A crates/client/src/rendering/texture.rs => crates/client/src/rendering/texture.rs +77 -0
@@ 0,0 1,77 @@
+use crate::rendering::mipmap::MipGenerator;
+use image::EncodableLayout;
+use wgpu::{
+ Device, Extent3d, FilterMode, ImageCopyTexture, ImageDataLayout, Origin3d, Queue,
+ SamplerDescriptor, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages,
+};
+
+#[derive(Debug)]
+pub struct Texture {
+ pub texture: wgpu::Texture,
+ pub sampler: wgpu::Sampler,
+}
+impl Texture {
+ pub fn new(
+ bytes: &[u8],
+ label: &str,
+ device: &Device,
+ queue: &Queue,
+ mip_generator: &mut MipGenerator,
+ ) -> Self {
+ let img = image::load_from_memory(bytes).unwrap();
+ let rgba = img.to_rgba8();
+
+ let max_size = rgba.width().max(rgba.height());
+ let log_factor = if max_size == 0 { 0 } else { max_size.ilog2() };
+ let optimal_mip_levels = 1 + log_factor;
+
+ let texture = device.create_texture(&TextureDescriptor {
+ label: Some(label),
+ size: Extent3d {
+ width: rgba.width(),
+ height: rgba.height(),
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: optimal_mip_levels,
+ sample_count: 1,
+ dimension: TextureDimension::D2,
+ format: TextureFormat::Rgba8UnormSrgb,
+ usage: TextureUsages::TEXTURE_BINDING
+ | TextureUsages::COPY_DST
+ | TextureUsages::RENDER_ATTACHMENT,
+ view_formats: &[TextureFormat::Rgba8UnormSrgb],
+ });
+ queue.write_texture(
+ ImageCopyTexture {
+ texture: &texture,
+ mip_level: 0,
+ origin: Origin3d::ZERO,
+ aspect: Default::default(),
+ },
+ rgba.as_bytes(),
+ ImageDataLayout {
+ offset: 0,
+ bytes_per_row: Some(rgba.width() * 4),
+ rows_per_image: Some(rgba.height()),
+ },
+ Extent3d {
+ width: rgba.width(),
+ height: rgba.height(),
+ depth_or_array_layers: 1,
+ },
+ );
+
+ let sampler = device.create_sampler(&SamplerDescriptor {
+ label: Some("test_sampler"),
+ mag_filter: FilterMode::Linear,
+ min_filter: FilterMode::Linear,
+ ..Default::default()
+ });
+
+ let tex = Self { texture, sampler };
+
+ mip_generator.generate_mips(&tex, device, queue);
+
+ tex
+ }
+}
A crates/client/src/rendering/ui.rs => crates/client/src/rendering/ui.rs +5 -0
@@ 0,0 1,5 @@
+use bevy_ecs::world::World;
+
+pub trait UiRenderable {
+ fn render(&mut self, ctx: &egui::Context, world: &mut World);
+}
A crates/client/src/shaders/sprite.wgsl => crates/client/src/shaders/sprite.wgsl +52 -0
@@ 0,0 1,52 @@
+struct VertexShaderOut {
+ @builtin(position) position: vec4<f32>,
+ @location(0) texcoord: vec2<f32>
+}
+
+struct FrameUniforms {
+ camera_transform: mat3x3f,
+ viewport_size: vec2f,
+}
+struct LocalUniforms {
+ transform: mat3x3f,
+}
+@group(0) @binding(2) var<uniform> frame_uni: FrameUniforms;
+@group(0) @binding(3) var<uniform> local_uni: LocalUniforms;
+
+@vertex fn vs(
+ @builtin(vertex_index) vertexIndex : u32
+) -> VertexShaderOut {
+ let pos = array(
+ vec2<f32>(-0.5, -0.5),
+ vec2<f32>(0.5, -0.5),
+ vec2<f32>(-0.5, 0.5),
+ vec2<f32>(-0.5, 0.5),
+ vec2<f32>(0.5, -0.5),
+ vec2<f32>(0.5, 0.5)
+ );
+
+ var vsOutput: VertexShaderOut;
+
+ let homogeneous_position = frame_uni.camera_transform * local_uni.transform * vec3f(pos[vertexIndex], 1);
+ let position = homogeneous_position.xy / homogeneous_position.z;
+ // convert from pixels to 0.0 to 1.0
+ let zeroToOne = position / frame_uni.viewport_size;
+ // convert from 0 - 1 to 0 - 2
+ let zeroToTwo = zeroToOne * 2.0;
+ // convert from 0 - 2 to -1 - +1 (clip space)
+ let flippedClipSpace = zeroToTwo - 1.0;
+ // flip Y
+ let clipSpace = flippedClipSpace * vec2f(1, -1);
+
+ vsOutput.position = vec4f(clipSpace, 0.0, 1.0);
+ vsOutput.texcoord = pos[vertexIndex] + vec2f(0.5, 0.5);
+
+ return vsOutput;
+}
+
+@group(0) @binding(0) var tex_sampler: sampler;
+@group(0) @binding(1) var tex: texture_2d<f32>;
+
+@fragment fn fs(fsInput: VertexShaderOut) -> @location(0) vec4<f32> {
+ return textureSample(tex, tex_sampler, fsInput.texcoord);
+}
A crates/client/src/shaders/text_quad_mips.wgsl => crates/client/src/shaders/text_quad_mips.wgsl +26 -0
@@ 0,0 1,26 @@
+struct VertexOutput {
+ @builtin(position) position: vec4<f32>,
+ @location(0) texcoord: vec2<f32>
+}
+@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
+ let pos = array(
+ vec2<f32>(0.0, 0.0),
+ vec2<f32>(1.0, 0.0),
+ vec2<f32>(0.0, 1.0),
+ vec2<f32>(0.0, 1.0),
+ vec2<f32>(1.0, 0.0),
+ vec2<f32>(1.0, 1.0)
+ );
+ var out: VertexOutput;
+ let xy = pos[vertexIndex];
+ out.position = vec4<f32>(xy * 2.0 - 1.0, 0.0, 1.0);
+ out.texcoord = vec2<f32>(xy.x, 1.0 - xy.y);
+ return out;
+}
+
+@group(0) @binding(0) var texSampler: sampler;
+@group(0) @binding(1) var tex: texture_2d<f32>;
+
+@fragment fn fs(inp: VertexOutput) -> @location(0) vec4<f32> {
+ return textureSample(tex, texSampler, inp.texcoord);
+}<
\ No newline at end of file
A crates/client/src/textures/f.png => crates/client/src/textures/f.png +0 -0
A crates/client/src/textures/happy-tree.png => crates/client/src/textures/happy-tree.png +0 -0
A crates/client/src/textures/uv.png => crates/client/src/textures/uv.png +0 -0
A crates/client/src/wasm/mod.rs => crates/client/src/wasm/mod.rs +33 -0
@@ 0,0 1,33 @@
+use tracing::Level;
+use tracing_subscriber::fmt::format::Pretty;
+use tracing_subscriber::prelude::*;
+use tracing_web::{performance_layer, MakeWebConsoleWriter};
+use wasm_bindgen::prelude::wasm_bindgen;
+
+/// --- IMPORTANT: THIS IS A DUAL TARGET CRATE ---
+/// THIS WILL ONLY EXECUTE ON WEBASSEMBLY
+/// DO ONLY PLATFORM SPECIFIC INITIALIZATION HERE
+/// FOR ACTUAL PROGRAM LOGIC, EDIT `crate::start`
+/// DO NOT RENAME
+#[wasm_bindgen(start)]
+pub fn entrypoint() {
+ // Panic handler
+ console_error_panic_hook::set_once();
+
+ /* ----- Logging setup ----- */
+ let fmt_layer = tracing_subscriber::fmt::layer()
+ .with_ansi(false) // not supported in browsers
+ .without_time() // std::time doesn't exist in wasm
+ .with_writer(MakeWebConsoleWriter::new().with_max_level(Level::DEBUG)); // wgpu spams the console, and this is slow as hell
+
+ let perf_layer = performance_layer() // enable performance tracing
+ .with_details_from_fields(Pretty::default()); // ... with pretty fields
+
+ tracing_subscriber::registry()
+ .with(fmt_layer)
+ .with(perf_layer)
+ .init(); // register the logger
+
+ // All done with platform-specific initialization, call back into the common code path
+ crate::start();
+}