#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub mod atlas;
mod camera_follow;
mod screen_shake;
mod text;
pub mod textbox;
pub use camera_follow::CameraFollow2d;
pub use screen_shake::ScreenShake;
use rustc_hash::FxHashMap as HashMap;
use std::sync::Arc;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use lunar_assets::{AssetServer, Font, Handle, Texture};
use lunar_core::{App, GamePlugin, Time};
use lunar_math::{Color, Transform, Vec2};
#[allow(dead_code)]
#[derive(Clone, Copy)]
struct SpriteDrawParams {
position: Vec2,
rotation: f32,
scale: Vec2,
tint: Color,
uv_rect: Option<(Vec2, Vec2)>,
origin: Vec2,
}
#[derive(Debug, Clone, Copy)]
pub struct SpriteParams {
pub position: Vec2,
pub scale: Vec2,
pub rotation: f32,
pub origin: Vec2,
pub tint: Color,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Resource)]
pub struct RenderTargetId(pub u32);
#[derive(Resource, Default)]
pub struct RenderTargetStore {
entries: rustc_hash::FxHashMap<RenderTargetId, lunar_assets::Handle<lunar_assets::Texture>>,
}
impl RenderTargetStore {
#[must_use]
pub fn get_texture(
&self,
id: RenderTargetId,
) -> Option<lunar_assets::Handle<lunar_assets::Texture>> {
self.entries.get(&id).copied()
}
}
#[derive(Resource, Clone)]
pub struct Camera {
pub position: Vec2,
pub zoom: f32,
pub rotation: f32,
pub viewport: Option<(u32, u32)>,
pub layer_parallax: HashMap<i32, Vec2>,
pub target: Option<RenderTargetId>,
}
impl Camera {
#[must_use]
pub fn new() -> Self {
Self {
position: Vec2::ZERO,
zoom: 1.0,
rotation: 0.0,
viewport: None,
layer_parallax: HashMap::default(),
target: None,
}
}
#[must_use]
pub fn at_position(x: f32, y: f32) -> Self {
Self {
position: Vec2::new(x, y),
zoom: 1.0,
rotation: 0.0,
viewport: None,
layer_parallax: HashMap::default(),
target: None,
}
}
#[must_use]
pub fn projection_matrix(&self, window_width: u32, window_height: u32) -> [f32; 16] {
self.projection_matrix_for_layer(0, window_width, window_height)
}
#[must_use]
pub fn projection_matrix_for_layer(
&self,
layer: i32,
window_width: u32,
window_height: u32,
) -> [f32; 16] {
let parallax_offset = self
.layer_parallax
.get(&layer)
.copied()
.unwrap_or(Vec2::ZERO);
let effective_pos = self.position - parallax_offset;
self.projection_matrix_at(effective_pos, window_width, window_height)
}
fn projection_matrix_at(&self, pos: Vec2, window_width: u32, window_height: u32) -> [f32; 16] {
#[allow(clippy::cast_precision_loss)]
let w = window_width as f32;
#[allow(clippy::cast_precision_loss)]
let h = window_height as f32;
let zoom = self.zoom.max(0.001);
let cos = self.rotation.cos();
let sin = self.rotation.sin();
let sx = 2.0 / w * zoom;
let sy = -2.0 / h * zoom;
let tx = -pos.y.mul_add(-sin, pos.x * cos);
let ty = -pos.y.mul_add(cos, pos.x * sin);
[
sx,
0.0,
0.0,
0.0,
0.0,
sy,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
sx * tx,
sy * ty,
0.0,
1.0,
]
}
pub fn set_layer_parallax(&mut self, layer: i32, offset: Vec2) {
self.layer_parallax.insert(layer, offset);
}
pub fn clear_layer_parallax(&mut self, layer: i32) {
self.layer_parallax.remove(&layer);
}
#[must_use]
pub fn screen_to_world(&self, screen: Vec2, window_width: u32, window_height: u32) -> Vec2 {
let (vw, vh) = self.viewport.unwrap_or((window_width, window_height));
#[allow(clippy::cast_precision_loss)]
let (vw_f, vh_f) = (vw as f32, vh as f32);
let zoom = self.zoom.max(0.001);
let cos = self.rotation.cos();
let sin = self.rotation.sin();
let nx = screen.x / vw_f - 0.5;
let ny = screen.y / vh_f - 0.5;
let world_dx = nx * vw_f / zoom;
let world_dy = ny * vh_f / zoom;
let unrot_x = world_dx * cos + world_dy * sin;
let unrot_y = -world_dx * sin + world_dy * cos;
Vec2::new(self.position.x + unrot_x, self.position.y + unrot_y)
}
#[must_use]
pub fn world_to_screen(&self, world: Vec2, window_width: u32, window_height: u32) -> Vec2 {
let (vw, vh) = self.viewport.unwrap_or((window_width, window_height));
#[allow(clippy::cast_precision_loss)]
let (vw_f, vh_f) = (vw as f32, vh as f32);
let zoom = self.zoom.max(0.001);
let cos = self.rotation.cos();
let sin = self.rotation.sin();
let dx = world.x - self.position.x;
let dy = world.y - self.position.y;
let rx = dx * cos - dy * sin;
let ry = dx * sin + dy * cos;
let sx = rx * zoom / vw_f;
let sy = -ry * zoom / vh_f;
Vec2::new((sx + 0.5) * vw_f, (0.5 - sy) * vh_f)
}
pub fn set_target_aspect(&mut self, width: u32, height: u32) -> &mut Self {
self.viewport = Some((width, height));
self
}
}
impl Default for Camera {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct RenderConfig {
pub width: u32,
pub height: u32,
pub vsync: bool,
pub frame_cap: u32,
pub tick_rate: lunar_core::TickRate,
pub title: String,
pub target_aspect: Option<f32>,
pub allow_resize: bool,
}
impl Default for RenderConfig {
fn default() -> Self {
Self {
width: 1280,
height: 720,
vsync: true,
frame_cap: 0,
tick_rate: lunar_core::TickRate::Hz60,
title: "Lunar".to_string(),
target_aspect: None,
allow_resize: true,
}
}
}
impl RenderConfig {
#[must_use]
pub fn loop_config(&self) -> lunar_core::LoopConfig {
lunar_core::LoopConfig {
frame_cap: self.frame_cap,
tick_rate: self.tick_rate,
}
}
}
const INITIAL_VERTEX_CAPACITY: usize = 65536;
const GLYPH_ATLAS_BIND_ID: u32 = u32::MAX - 1;
const VERTEX_BUFFER_COUNT: usize = 2;
const VERTEX_STRIDE: usize = 20;
#[cfg_attr(not(target_arch = "wasm32"), derive(Resource))]
pub struct RenderEngine {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
render_config: RenderConfig,
sprite_pipeline: wgpu::RenderPipeline,
uniform_buf: wgpu::Buffer,
globals_bg: wgpu::BindGroup,
material_bgl: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
textures: HashMap<u32, GpuTexture>,
material_bgs: HashMap<u32, wgpu::BindGroup>,
vertex_bufs: [wgpu::Buffer; VERTEX_BUFFER_COUNT],
vertex_capacity: usize,
overflow_flag: bool,
frame_index: usize,
vertex_offset: usize,
glyph_atlas: text::GlyphAtlas,
#[allow(dead_code)]
glyph_atlas_texture: Option<GpuTexture>,
render_passes: Vec<Box<dyn RenderPass>>,
#[cfg(not(target_arch = "wasm32"))]
pipeline_cache: Option<wgpu::PipelineCache>,
sorted_indices: Vec<usize>,
text_quads: HashMap<usize, Vec<text::TextGlyphQuad>>,
text_layout_cache: text::TextLayoutCache,
render_target_views: HashMap<u32, wgpu::TextureView>,
render_target_counter: u32,
}
#[allow(dead_code)]
struct GpuTexture {
texture: wgpu::Texture,
view: wgpu::TextureView,
}
impl RenderEngine {
#[cfg(not(target_arch = "wasm32"))]
pub fn from_surface(
instance: &wgpu::Instance,
surface: wgpu::Surface<'static>,
config: RenderConfig,
) -> Self {
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))
.expect("failed to request adapter");
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("lunar render device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::Performance,
trace: wgpu::Trace::default(),
experimental_features: wgpu::ExperimentalFeatures::disabled(),
}))
.expect("failed to request device");
Self::init_inner(&adapter, &device, queue, surface, config)
}
#[cfg(target_arch = "wasm32")]
pub async fn from_surface(
instance: &wgpu::Instance,
surface: wgpu::Surface<'static>,
config: RenderConfig,
) -> Self {
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
})
.await
.expect("no WebGPU adapter found — in Firefox enable dom.webgpu.enabled in about:config, Chrome 113+ required");
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("lunar render device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::Performance,
trace: wgpu::Trace::default(),
experimental_features: wgpu::ExperimentalFeatures::disabled(),
})
.await
.expect("failed to request device");
Self::init_inner(&adapter, &device, queue, surface, config)
}
#[cfg(target_arch = "wasm32")]
pub fn create_canvas_surface(
instance: &wgpu::Instance,
canvas: &web_sys::HtmlCanvasElement,
) -> Result<wgpu::Surface<'static>, String> {
let surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
.map_err(|e| format!("failed to create surface: {e:?}"))?;
Ok(surface)
}
#[cfg(target_arch = "wasm32")]
pub fn find_canvas(id: &str) -> Result<web_sys::HtmlCanvasElement, String> {
use wasm_bindgen::JsCast;
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let element = document
.get_element_by_id(id)
.ok_or_else(|| format!("no element with id '{id}'"))?;
element
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| format!("element '{id}' is not a canvas"))
}
#[allow(clippy::too_many_lines)]
fn init_inner(
adapter: &wgpu::Adapter,
device: &wgpu::Device,
queue: wgpu::Queue,
surface: wgpu::Surface<'static>,
config: RenderConfig,
) -> Self {
let caps = surface.get_capabilities(adapter);
let format = caps
.formats
.first()
.copied()
.unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb);
let surface_config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: config.width,
height: config.height,
present_mode: if config.vsync {
wgpu::PresentMode::AutoVsync
} else {
wgpu::PresentMode::AutoNoVsync
},
alpha_mode: caps.alpha_modes.first().copied().unwrap_or_default(),
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(device, &surface_config);
let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("uniform buffer"),
size: 64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("sprite sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
let globals_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("[globals] bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let material_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("[material] 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 globals_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("[globals] bg"),
layout: &globals_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buf.as_entire_binding(),
}],
});
let placeholder_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("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: &placeholder_texture,
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 placeholder_view =
placeholder_texture.create_view(&wgpu::TextureViewDescriptor::default());
let placeholder_material_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("[material] placeholder bg"),
layout: &material_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&placeholder_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("sprite shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(SHADER_SOURCE)),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("sprite pipeline layout"),
bind_group_layouts: &[Some(&globals_bgl), Some(&material_bgl)],
immediate_size: 0,
});
let sprite_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sprite pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[wgpu::VertexBufferLayout {
array_stride: VERTEX_STRIDE as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 8,
shader_location: 1,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Unorm8x4,
offset: 16,
shader_location: 2,
},
],
}],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: surface_config.format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
cache: None, multiview_mask: None,
});
let frame_cap_str = if config.frame_cap == 0 {
"uncapped".to_string()
} else {
config.frame_cap.to_string()
};
log::info!(
"render engine initialized: {}x{}, frame_cap={}",
config.width,
config.height,
frame_cap_str
);
let vertex_bufs: [wgpu::Buffer; VERTEX_BUFFER_COUNT] = std::array::from_fn(|i| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&format!("persistent vertex buffer {i}")),
size: (INITIAL_VERTEX_CAPACITY * VERTEX_STRIDE) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
});
Self {
surface,
device: device.clone(),
queue,
config: surface_config,
render_config: config,
sprite_pipeline,
uniform_buf,
globals_bg,
material_bgl,
sampler,
textures: HashMap::default(),
material_bgs: {
let mut map = HashMap::default();
map.insert(u32::MAX, placeholder_material_bg);
map
},
vertex_bufs,
vertex_capacity: INITIAL_VERTEX_CAPACITY,
overflow_flag: false,
frame_index: 0,
vertex_offset: 0,
glyph_atlas: text::GlyphAtlas::new(2048, 1024),
glyph_atlas_texture: None,
render_passes: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
pipeline_cache: Self::load_pipeline_cache(device),
sorted_indices: Vec::new(),
text_quads: HashMap::default(),
text_layout_cache: text::TextLayoutCache::new(256),
render_target_views: HashMap::default(),
render_target_counter: 0,
}
}
fn update_projection_for_layer(&mut self, layer: i32, camera: Option<&Camera>) {
let (surface_w, surface_h) = (self.config.width as f32, self.config.height as f32);
if layer >= layers::POST_PROCESS {
let projection: [f32; 16] = [
2.0 / surface_w,
0.0,
0.0,
0.0,
0.0,
-2.0 / surface_h,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
-1.0,
1.0,
0.0,
1.0,
];
self.queue
.write_buffer(&self.uniform_buf, 0, bytemuck::cast_slice(&projection));
return;
}
let projection = if let Some(cam) = camera
&& let Some((vp_w, vp_h)) = cam.viewport
{
let (vp_w, vp_h) = (vp_w as f32, vp_h as f32);
let scale = (surface_w / vp_w).min(surface_h / vp_h);
let vp_w_scaled = vp_w * scale;
let vp_h_scaled = vp_h * scale;
let offset_x = (surface_w - vp_w_scaled) / surface_w;
let offset_y = (surface_h - vp_h_scaled) / surface_h;
let _sx = (vp_w_scaled / surface_w) * 2.0 / vp_w;
let _sy = -(vp_h_scaled / surface_h) * 2.0 / vp_h;
let pos = cam.position;
let tx = pos.x;
let ty = pos.y;
let left = -1.0 + offset_x;
let right = 1.0 - offset_x;
let bottom = -1.0 + offset_y;
let top = 1.0 - offset_y;
let sx2 = (right - left) / vp_w;
let sy2 = (bottom - top) / vp_h;
let tx2 = (right + left) / 2.0;
let ty2 = (top + bottom) / 2.0;
[
sx2,
0.0,
0.0,
0.0,
0.0,
sy2,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
sx2 * (-tx) + tx2,
sy2 * (-ty) + ty2,
0.0,
1.0,
]
} else if let Some(cam) = camera {
cam.projection_matrix_for_layer(layer, self.config.width, self.config.height)
} else {
[
2.0 / surface_w,
0.0,
0.0,
0.0,
0.0,
-2.0 / surface_h,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
-1.0,
1.0,
0.0,
1.0,
]
};
self.queue
.write_buffer(&self.uniform_buf, 0, bytemuck::cast_slice(&projection));
}
#[cfg(not(target_arch = "wasm32"))]
fn load_pipeline_cache(device: &wgpu::Device) -> Option<wgpu::PipelineCache> {
let cache_path = std::path::Path::new(".pipeline_cache.bin");
if cache_path.exists() {
match std::fs::read(cache_path) {
Ok(data) => {
log::info!("loaded pipeline cache ({} bytes)", data.len());
Some(unsafe {
device.create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
label: Some("loaded pipeline cache"),
data: Some(&data),
fallback: true,
})
})
}
Err(e) => {
log::warn!("failed to load pipeline cache: {e}");
None
}
}
} else {
None
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn save_pipeline_cache(&self) {
if let Some(ref cache) = self.pipeline_cache
&& let Some(data) = cache.get_data()
{
let cache_path = std::path::Path::new(".pipeline_cache.bin");
if let Err(e) = std::fs::write(cache_path, &data) {
log::warn!("failed to save pipeline cache: {e}");
} else {
log::info!("saved pipeline cache ({} bytes)", data.len());
}
}
}
pub fn add_render_pass<P: RenderPass>(&mut self, pass: P) {
self.render_passes.push(Box::new(pass));
}
pub const fn config(&self) -> &RenderConfig {
&self.render_config
}
pub const fn device(&self) -> &wgpu::Device {
&self.device
}
pub const fn queue(&self) -> &wgpu::Queue {
&self.queue
}
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
self.render_config.width = width;
self.render_config.height = height;
}
pub fn remove_texture(&mut self, tex_id: u32) {
self.textures.remove(&tex_id);
self.material_bgs.remove(&tex_id);
self.render_target_views.remove(&tex_id);
}
pub fn create_render_target(
&mut self,
store: &mut RenderTargetStore,
width: u32,
height: u32,
) -> (RenderTargetId, Handle<Texture>) {
let id = self.render_target_counter;
self.render_target_counter += 1;
let tex_id = u32::MAX / 2 + id;
let gpu_texture = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some(&format!("[rt:{tex_id}]")),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.config.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let render_view = gpu_texture.create_view(&wgpu::TextureViewDescriptor::default());
let sample_view = gpu_texture.create_view(&wgpu::TextureViewDescriptor::default());
self.render_target_views.insert(tex_id, render_view);
self.textures.insert(
tex_id,
GpuTexture {
texture: gpu_texture,
view: sample_view,
},
);
let rt_id = RenderTargetId(tex_id);
let handle = Handle::<Texture>::new(tex_id, 0);
store.entries.insert(rt_id, handle);
(rt_id, handle)
}
pub fn upload_font(&mut self, font_id: u32, data: &[u8]) {
self.glyph_atlas.register_font(font_id, data);
}
pub fn upload_texture(&mut self, handle: &Handle<Texture>, texture: &Texture) {
if self.textures.contains_key(&handle.id()) {
return;
}
let mip_count = texture.mip_level_count();
let gpu_texture = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("sprite texture"),
size: wgpu::Extent3d {
width: texture.width,
height: texture.height,
depth_or_array_layers: 1,
},
mip_level_count: mip_count,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
self.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &gpu_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&texture.pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * texture.width),
rows_per_image: Some(texture.height),
},
wgpu::Extent3d {
width: texture.width,
height: texture.height,
depth_or_array_layers: 1,
},
);
let mut mip_w = texture.width;
let mut mip_h = texture.height;
for (i, mip_data) in texture.mips.iter().enumerate() {
mip_w = (mip_w / 2).max(1);
mip_h = (mip_h / 2).max(1);
self.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &gpu_texture,
mip_level: i as u32 + 1,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
mip_data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * mip_w),
rows_per_image: Some(mip_h),
},
wgpu::Extent3d {
width: mip_w,
height: mip_h,
depth_or_array_layers: 1,
},
);
}
let view = gpu_texture.create_view(&wgpu::TextureViewDescriptor::default());
let tex_id = handle.id();
let material_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("[material] texture bg"),
layout: &self.material_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.material_bgs.insert(tex_id, material_bg);
self.textures.insert(
tex_id,
GpuTexture {
texture: gpu_texture,
view,
},
);
}
#[allow(dead_code)]
fn upload_glyph_atlas(&mut self) {
let atlas = &self.glyph_atlas;
let gpu_texture = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("glyph atlas texture"),
size: wgpu::Extent3d {
width: atlas.width,
height: atlas.height,
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: &[],
});
self.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &gpu_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
atlas.pixels(),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * atlas.width),
rows_per_image: Some(atlas.height),
},
wgpu::Extent3d {
width: atlas.width,
height: atlas.height,
depth_or_array_layers: 1,
},
);
let view = gpu_texture.create_view(&wgpu::TextureViewDescriptor::default());
self.glyph_atlas_texture = Some(GpuTexture {
texture: gpu_texture,
view,
});
}
pub fn surface_size(&self) -> (u32, u32) {
(self.config.width, self.config.height)
}
fn grow_vertex_buffers(&mut self) {
let new_capacity = self.vertex_capacity.saturating_mul(2);
log::warn!(
"render: vertex buffer overflow detected; growing capacity {} → {} vertices",
self.vertex_capacity,
new_capacity
);
self.vertex_capacity = new_capacity;
self.vertex_bufs = std::array::from_fn(|i| {
self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&format!("persistent vertex buffer {i}")),
size: (self.vertex_capacity * VERTEX_STRIDE) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
});
}
#[allow(clippy::too_many_lines)]
pub fn render(
&mut self,
commands: &[DrawCommand],
camera: Option<&Camera>,
render_info: &mut RenderInfo,
) {
if self.overflow_flag {
self.grow_vertex_buffers();
self.overflow_flag = false;
}
let mut sprite_count: u32 = 0;
let mut draw_calls: u32 = 0;
let rt_tex_id = camera.and_then(|c| c.target).map(|rt| rt.0);
let surface_frame = if rt_tex_id.is_none() {
match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(f)
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => Some(f),
_ => return,
}
} else {
None
};
let view: wgpu::TextureView = if let Some(id) = rt_tex_id {
let Some(rt_view) = self.render_target_views.get(&id) else {
return;
};
rt_view.clone()
} else {
surface_frame
.as_ref()
.unwrap()
.texture
.create_view(&wgpu::TextureViewDescriptor::default())
};
let text_scale = if let Some(cam) = camera
&& let Some((vp_w, vp_h)) = cam.viewport
{
let sx = self.config.width as f32 / vp_w as f32;
let sy = self.config.height as f32 / vp_h as f32;
sx.min(sy)
} else {
1.0
};
if self.glyph_atlas.set_scale(text_scale) {
self.text_layout_cache.clear();
}
let cmd_count = commands.len();
for (i, cmd) in commands.iter().enumerate() {
let DrawKind::Text {
font,
content,
position,
font_size,
wrap_width,
line_height,
..
} = &cmd.kind
else {
continue;
};
let font_id = u32::try_from(font.unwrap_or(0)).unwrap_or(u32::MAX);
let slot = self.text_quads.entry(i).or_default();
if let Some(cached) =
self.text_layout_cache
.get(font_id, content, *font_size, *wrap_width)
{
slot.clear();
slot.extend(cached.iter().map(|q| text::TextGlyphQuad {
position: Vec2::new(q.position.x + position.x, q.position.y + position.y),
size: q.size,
uv_min: q.uv_min,
uv_max: q.uv_max,
}));
} else {
let mut origin_quads: Vec<text::TextGlyphQuad> = Vec::new();
if let Some(max_w) = wrap_width {
text::layout_text_wrapped_into(
&mut self.glyph_atlas,
font_id,
content,
*font_size,
Vec2::ZERO,
*max_w,
*line_height,
&mut origin_quads,
);
} else {
text::layout_text_into(
&mut self.glyph_atlas,
font_id,
content,
*font_size,
Vec2::ZERO,
&mut origin_quads,
);
}
self.text_layout_cache.insert(
font_id,
content,
*font_size,
*wrap_width,
origin_quads.clone(),
);
slot.clear();
slot.extend(origin_quads.into_iter().map(|q| text::TextGlyphQuad {
position: Vec2::new(q.position.x + position.x, q.position.y + position.y),
size: q.size,
uv_min: q.uv_min,
uv_max: q.uv_max,
}));
}
}
self.text_quads.retain(|k, _| *k < cmd_count);
if std::mem::take(&mut self.glyph_atlas.dirty) {
self.upload_glyph_atlas();
if let Some(atlas) = &self.glyph_atlas_texture {
let material_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("[material] glyph atlas bg"),
layout: &self.material_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&atlas.view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.material_bgs.insert(GLYPH_ATLAS_BIND_ID, material_bg);
}
}
let mut current_layer: Option<i32> = None;
self.sorted_indices.clear();
self.sorted_indices.extend(0..commands.len());
self.sorted_indices.sort_unstable_by_key(|&i| {
let cmd = &commands[i];
let layer = match &cmd.kind {
DrawKind::Sprite { layer, .. }
| DrawKind::Rect { layer, .. }
| DrawKind::Line { layer, .. }
| DrawKind::Text { layer, .. } => *layer,
};
let secondary: i64 = match &cmd.kind {
DrawKind::Sprite {
sort_key: Some(k), ..
} => i64::from(*k),
DrawKind::Sprite {
texture: Some(id), ..
} => i64::try_from(*id).unwrap_or(i64::MAX),
DrawKind::Text { .. } => i64::from(GLYPH_ATLAS_BIND_ID),
_ => i64::MAX,
};
(layer, secondary)
});
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("render encoder"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("render pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.07,
g: 0.07,
b: 0.07,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.sprite_pipeline);
self.frame_index = (self.frame_index + 1) % VERTEX_BUFFER_COUNT;
self.vertex_offset = 0;
let mut current_tex: Option<u32> = None;
let mut batch_start = 0;
for i in 0..self.sorted_indices.len() {
let orig_idx = self.sorted_indices[i];
let command = &commands[orig_idx];
let layer = match &command.kind {
DrawKind::Sprite { layer, .. }
| DrawKind::Rect { layer, .. }
| DrawKind::Line { layer, .. }
| DrawKind::Text { layer, .. } => *layer,
};
let tex_id = match &command.kind {
DrawKind::Sprite {
texture: Some(id), ..
} => u32::try_from(*id).unwrap_or(u32::MAX),
DrawKind::Text { .. } => GLYPH_ATLAS_BIND_ID,
_ => u32::MAX,
};
if current_layer != Some(layer) {
if self.vertex_offset > batch_start
&& let Some(prev_tex) = current_tex
{
let vertex_count = (self.vertex_offset - batch_start) / VERTEX_STRIDE;
self.draw_vertex_batch(&mut pass, prev_tex, batch_start, vertex_count);
draw_calls += 1;
}
batch_start = self.vertex_offset;
self.update_projection_for_layer(layer, camera);
current_layer = Some(layer);
}
if current_tex != Some(tex_id) {
if self.vertex_offset > batch_start
&& let Some(prev_tex) = current_tex
{
let vertex_count = (self.vertex_offset - batch_start) / VERTEX_STRIDE;
self.draw_vertex_batch(&mut pass, prev_tex, batch_start, vertex_count);
draw_calls += 1;
}
batch_start = self.vertex_offset;
current_tex = Some(tex_id);
}
if self.vertex_offset + 6 * VERTEX_STRIDE > self.vertex_capacity * VERTEX_STRIDE {
self.overflow_flag = true;
continue;
}
match &command.kind {
DrawKind::Sprite {
texture: Some(_),
position,
rotation,
scale,
tint,
uv_rect,
origin,
..
} => {
self.write_sprite_vertices(&SpriteDrawParams {
position: *position,
rotation: *rotation,
scale: *scale,
tint: *tint,
uv_rect: *uv_rect,
origin: *origin,
});
sprite_count += 1;
}
DrawKind::Sprite { texture: None, .. } => {}
DrawKind::Rect {
position,
size,
color,
..
} => {
self.write_rect_vertices(*position, *size, *color);
}
DrawKind::Line {
start,
end,
color,
thickness,
..
} => {
self.write_line_vertices(*start, *end, *color, *thickness);
}
DrawKind::Text { color, .. } => {
let color = *color;
let count = self.text_quads.get(&orig_idx).map(|q| q.len()).unwrap_or(0);
for qi in 0..count {
let quad = self.text_quads[&orig_idx][qi]; self.write_text_quad(&quad, color);
}
}
}
}
if self.vertex_offset > batch_start
&& let Some(tex_id) = current_tex
{
let vertex_count = (self.vertex_offset - batch_start) / VERTEX_STRIDE;
self.draw_vertex_batch(&mut pass, tex_id, batch_start, vertex_count);
draw_calls += 1;
}
}
for pass in &self.render_passes {
let mut custom_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(pass.name()),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.execute(&self.device, &self.queue, &mut custom_pass);
}
self.queue.submit(Some(encoder.finish()));
if let Some(frame) = surface_frame {
frame.present();
}
render_info.window_width = self.config.width;
render_info.window_height = self.config.height;
render_info.sprite_count = sprite_count;
render_info.draw_calls = draw_calls;
}
fn draw_vertex_batch(
&self,
pass: &mut wgpu::RenderPass<'_>,
tex_id: u32,
offset: usize,
vertex_count: usize,
) {
let Some(material_bg) = self.material_bgs.get(&tex_id) else {
return;
};
pass.set_bind_group(0, &self.globals_bg, &[]);
pass.set_bind_group(1, material_bg, &[]);
let buf = &self.vertex_bufs[self.frame_index];
pass.set_vertex_buffer(
0,
buf.slice(offset as u64..(offset + vertex_count * VERTEX_STRIDE) as u64),
);
pass.draw(0..u32::try_from(vertex_count).unwrap_or(0), 0..1);
}
fn write_sprite_vertices(&mut self, params: &SpriteDrawParams) {
let &SpriteDrawParams {
position,
rotation,
scale,
tint,
uv_rect,
origin,
} = params;
let cos = rotation.cos();
let sin = rotation.sin();
let corners = [
[-origin.x, -origin.y],
[scale.x - origin.x, -origin.y],
[-origin.x, scale.y - origin.y],
[-origin.x, scale.y - origin.y],
[scale.x - origin.x, -origin.y],
[scale.x - origin.x, scale.y - origin.y],
];
let (uv_min, uv_max) = uv_rect.unwrap_or((Vec2::ZERO, Vec2::new(1.0, 1.0)));
let uvs = [
[uv_min.x, uv_min.y],
[uv_max.x, uv_min.y],
[uv_min.x, uv_max.y],
[uv_min.x, uv_max.y],
[uv_max.x, uv_min.y],
[uv_max.x, uv_max.y],
];
let packed_color = pack_color(tint);
let mut verts: [u32; 30] = [0; 30];
for (i, [lx, ly]) in corners.iter().enumerate() {
let rx = lx * cos - ly * sin;
let ry = lx * sin + ly * cos;
let px = position.x + rx;
let py = position.y + ry;
let [u, v] = uvs[i];
let base = i * 5;
verts[base] = f32_to_u32(px);
verts[base + 1] = f32_to_u32(py);
verts[base + 2] = f32_to_u32(u);
verts[base + 3] = f32_to_u32(v);
verts[base + 4] = packed_color;
}
let bytes = bytemuck::cast_slice(&verts);
let buf = &self.vertex_bufs[self.frame_index];
self.queue
.write_buffer(buf, self.vertex_offset as u64, bytes);
self.vertex_offset += 6 * VERTEX_STRIDE;
}
fn write_rect_vertices(&mut self, position: Vec2, size: Vec2, color: Color) {
let (x, y, w, h) = (position.x, position.y, size.x, size.y);
let packed_color = pack_color(color);
let mut verts: [u32; 30] = [0; 30];
let positions = [
(x, y),
(x + w, y),
(x, y + h),
(x, y + h),
(x + w, y),
(x + w, y + h),
];
for (i, (px, py)) in positions.iter().enumerate() {
let base = i * 5;
verts[base] = f32_to_u32(*px);
verts[base + 1] = f32_to_u32(*py);
verts[base + 2] = 0; verts[base + 3] = 0; verts[base + 4] = packed_color;
}
let bytes = bytemuck::cast_slice(&verts);
let buf = &self.vertex_bufs[self.frame_index];
self.queue
.write_buffer(buf, self.vertex_offset as u64, bytes);
self.vertex_offset += 6 * VERTEX_STRIDE;
}
fn write_line_vertices(&mut self, start: Vec2, end: Vec2, color: Color, thickness: f32) {
let dx = end.x - start.x;
let dy = end.y - start.y;
let len = dx.hypot(dy);
if len < 0.001 {
return;
}
let nx = -dy / len;
let ny = dx / len;
let half_t = thickness * 0.5;
let corners = [
(nx.mul_add(half_t, start.x), ny.mul_add(half_t, start.y)),
(nx.mul_add(-half_t, start.x), ny.mul_add(-half_t, start.y)),
(nx.mul_add(half_t, end.x), ny.mul_add(half_t, end.y)),
(nx.mul_add(-half_t, end.x), ny.mul_add(-half_t, end.y)),
];
let packed_color = pack_color(color);
let mut verts: [u32; 30] = [0; 30];
let indices = [0, 1, 2, 2, 1, 3];
for (i, &idx) in indices.iter().enumerate() {
let base = i * 5;
let (px, py) = corners[idx];
verts[base] = f32_to_u32(px);
verts[base + 1] = f32_to_u32(py);
verts[base + 2] = 0; verts[base + 3] = 0; verts[base + 4] = packed_color;
}
let bytes = bytemuck::cast_slice(&verts);
let buf = &self.vertex_bufs[self.frame_index];
self.queue
.write_buffer(buf, self.vertex_offset as u64, bytes);
self.vertex_offset += 6 * VERTEX_STRIDE;
}
fn write_text_quad(&mut self, quad: &text::TextGlyphQuad, color: Color) {
let x = quad.position.x;
let y = quad.position.y;
let w = quad.size.x;
let h = quad.size.y;
let u0 = quad.uv_min.x;
let v0 = quad.uv_min.y;
let u1 = quad.uv_max.x;
let v1 = quad.uv_max.y;
let packed_color = pack_color(color);
let mut verts: [u32; 30] = [0; 30];
let positions_uvs = [
(x, y, u0, v0),
(x + w, y, u1, v0),
(x, y + h, u0, v1),
(x, y + h, u0, v1),
(x + w, y, u1, v0),
(x + w, y + h, u1, v1),
];
for (i, (px, py, u, v)) in positions_uvs.iter().enumerate() {
let base = i * 5;
verts[base] = f32_to_u32(*px);
verts[base + 1] = f32_to_u32(*py);
verts[base + 2] = f32_to_u32(*u);
verts[base + 3] = f32_to_u32(*v);
verts[base + 4] = packed_color;
}
let bytes = bytemuck::cast_slice(&verts);
let buf = &self.vertex_bufs[self.frame_index];
self.queue
.write_buffer(buf, self.vertex_offset as u64, bytes);
self.vertex_offset += 6 * VERTEX_STRIDE;
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for RenderEngine {
fn drop(&mut self) {
self.save_pipeline_cache();
}
}
fn pack_color(color: Color) -> u32 {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let r = (color.r * 255.0).clamp(0.0, 255.0) as u32;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let g = (color.g * 255.0).clamp(0.0, 255.0) as u32;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let b = (color.b * 255.0).clamp(0.0, 255.0) as u32;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let a = (color.a * 255.0).clamp(0.0, 255.0) as u32;
(a << 24) | (b << 16) | (g << 8) | r
}
fn f32_to_u32(value: f32) -> u32 {
bytemuck::cast(value)
}
const SHADER_SOURCE: &str = r"
struct Uniforms { projection: mat4x4<f32> }
struct VertexOut {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(1) @binding(0) var sprite_texture: texture_2d<f32>;
@group(1) @binding(1) var sprite_sampler: sampler;
@vertex
fn vs_main(
@location(0) pos: vec2<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec4<f32>,
) -> VertexOut {
var out: VertexOut;
out.clip_position = uniforms.projection * vec4<f32>(pos, 0.0, 1.0);
out.uv = uv;
out.color = color;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
let tex_color = textureSample(sprite_texture, sprite_sampler, in.uv);
return tex_color * in.color;
}
";
#[derive(Resource)]
pub struct RenderQueue {
commands: Vec<DrawCommand>,
target: Option<u32>,
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct DrawCommand {
pub kind: DrawKind,
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub enum DrawKind {
Sprite {
texture: Option<u64>,
position: Vec2,
rotation: f32,
scale: Vec2,
tint: Color,
layer: i32,
uv_rect: Option<(Vec2, Vec2)>,
origin: Vec2,
sort_key: Option<i32>,
},
Rect {
position: Vec2,
size: Vec2,
color: Color,
layer: i32,
},
Line {
start: Vec2,
end: Vec2,
color: Color,
thickness: f32,
layer: i32,
},
Text {
font: Option<u64>,
content: Arc<str>,
position: Vec2,
font_size: f32,
color: Color,
layer: i32,
wrap_width: Option<f32>,
line_height: f32,
},
}
pub mod layers {
pub const BACKGROUND: i32 = 0;
pub const GAME: i32 = 100;
pub const FOREGROUND: i32 = 200;
pub const UI: i32 = 300;
pub const POST_PROCESS: i32 = 1000;
}
#[derive(Debug, Clone, Component)]
pub struct Sprite {
pub texture: Handle<Texture>,
pub size: Option<Vec2>,
pub color: Color,
pub source_rect: Option<(Vec2, Vec2)>,
pub origin: Option<Vec2>,
pub layer: i32,
}
impl Sprite {
#[must_use]
pub const fn new(texture: Handle<Texture>) -> Self {
Self {
texture,
size: None,
color: Color::WHITE,
source_rect: None,
origin: None,
layer: layers::GAME,
}
}
#[must_use]
pub const fn with_size(mut self, size: Vec2) -> Self {
self.size = Some(size);
self
}
#[must_use]
pub const fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
#[must_use]
pub const fn with_layer(mut self, layer: i32) -> Self {
self.layer = layer;
self
}
#[must_use]
pub const fn with_source_rect(mut self, uv_min: Vec2, uv_max: Vec2) -> Self {
self.source_rect = Some((uv_min, uv_max));
self
}
#[must_use]
pub const fn with_origin(mut self, origin: Vec2) -> Self {
self.origin = Some(origin);
self
}
}
#[derive(Debug, Clone, Component)]
pub struct Text {
pub content: Arc<str>,
pub font: Handle<Font>,
pub font_size: f32,
pub color: Color,
pub layer: i32,
}
impl Text {
#[must_use]
pub fn new(content: impl Into<Arc<str>>, font: Handle<Font>) -> Self {
Self {
content: content.into(),
font,
font_size: 16.0,
color: Color::WHITE,
layer: layers::UI,
}
}
#[must_use]
pub const fn with_size(mut self, font_size: f32) -> Self {
self.font_size = font_size;
self
}
#[must_use]
pub const fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
#[must_use]
pub const fn with_layer(mut self, layer: i32) -> Self {
self.layer = layer;
self
}
}
impl RenderQueue {
#[must_use]
pub fn new() -> Self {
Self {
commands: Vec::with_capacity(1024),
target: None,
}
}
pub fn clear(&mut self) {
self.commands.clear();
self.target = None;
}
pub const fn set_target(&mut self, target: Option<u32>) {
self.target = target;
}
#[must_use]
pub const fn target(&self) -> Option<u32> {
self.target
}
#[doc(hidden)]
pub fn push(&mut self, command: DrawCommand) {
self.commands.push(command);
}
#[doc(hidden)]
#[must_use]
pub fn commands(&self) -> &[DrawCommand] {
&self.commands
}
pub fn draw_sprite(&mut self, texture: &Handle<Texture>, position: Vec2, size: Vec2) {
self.draw_sprite_on_layer(texture, position, size, layers::GAME);
}
pub fn draw_sprite_on_layer(
&mut self,
texture: &Handle<Texture>,
position: Vec2,
size: Vec2,
layer: i32,
) {
self.push(DrawCommand {
kind: DrawKind::Sprite {
texture: Some(u64::from(texture.id())),
position,
rotation: 0.0,
scale: size,
tint: Color::WHITE,
layer,
uv_rect: None,
origin: Vec2::new(size.x * 0.5, size.y * 0.5),
sort_key: None,
},
});
}
pub fn draw_sprite_atlas(
&mut self,
texture: &Handle<Texture>,
position: Vec2,
size: Vec2,
region: (Vec2, Vec2),
) {
self.draw_sprite_atlas_on_layer(texture, position, size, region, layers::GAME);
}
pub fn draw_sprite_atlas_on_layer(
&mut self,
texture: &Handle<Texture>,
position: Vec2,
size: Vec2,
region: (Vec2, Vec2),
layer: i32,
) {
self.push(DrawCommand {
kind: DrawKind::Sprite {
texture: Some(u64::from(texture.id())),
position,
rotation: 0.0,
scale: size,
tint: Color::WHITE,
layer,
uv_rect: Some(region),
origin: Vec2::new(size.x * 0.5, size.y * 0.5),
sort_key: None,
},
});
}
pub fn draw_sprite_transformed(&mut self, texture: &Handle<Texture>, params: SpriteParams) {
self.draw_sprite_transformed_on_layer(texture, params, layers::GAME);
}
pub fn draw_sprite_transformed_on_layer(
&mut self,
texture: &Handle<Texture>,
params: SpriteParams,
layer: i32,
) {
self.push(DrawCommand {
kind: DrawKind::Sprite {
texture: Some(u64::from(texture.id())),
position: params.position,
rotation: params.rotation,
scale: params.scale,
tint: params.tint,
layer,
uv_rect: None,
origin: params.origin,
sort_key: None,
},
});
}
pub fn draw_rect(&mut self, position: Vec2, size: Vec2, color: Color) {
self.draw_rect_on_layer(position, size, color, layers::GAME);
}
pub fn draw_rect_on_layer(&mut self, position: Vec2, size: Vec2, color: Color, layer: i32) {
self.push(DrawCommand {
kind: DrawKind::Rect {
position,
size,
color,
layer,
},
});
}
pub fn draw_screen_rect(&mut self, position: Vec2, size: Vec2, color: Color) {
self.draw_rect_on_layer(position, size, color, layers::POST_PROCESS);
}
pub fn draw_line(&mut self, start: Vec2, end: Vec2, color: Color, thickness: f32) {
self.draw_line_on_layer(start, end, color, thickness, layers::GAME);
}
pub fn draw_line_on_layer(
&mut self,
start: Vec2,
end: Vec2,
color: Color,
thickness: f32,
layer: i32,
) {
self.push(DrawCommand {
kind: DrawKind::Line {
start,
end,
color,
thickness,
layer,
},
});
}
pub fn clear_color(&mut self, color: Color) {
self.push(DrawCommand {
kind: DrawKind::Rect {
position: Vec2::ZERO,
size: Vec2::new(10000.0, 10000.0),
color,
layer: layers::BACKGROUND,
},
});
}
pub fn draw_text(
&mut self,
font: &Handle<Font>,
content: &str,
position: Vec2,
font_size: f32,
color: Color,
) {
self.draw_text_on_layer(font, content, position, font_size, color, layers::GAME);
}
pub fn draw_text_on_layer(
&mut self,
font: &Handle<Font>,
content: &str,
position: Vec2,
font_size: f32,
color: Color,
layer: i32,
) {
self.push(DrawCommand {
kind: DrawKind::Text {
font: Some(u64::from(font.id())),
content: Arc::from(content),
position,
font_size,
color,
layer,
wrap_width: None,
line_height: 0.0,
},
});
}
#[allow(clippy::too_many_arguments)]
pub fn draw_ui_text(
&mut self,
font: &Handle<Font>,
text: &str,
screen_pos: Vec2,
font_size: f32,
color: Color,
camera: &Camera,
window_width: u32,
window_height: u32,
) {
let world = camera.screen_to_world(screen_pos, window_width, window_height);
self.draw_text_on_layer(font, text, world, font_size, color, layers::UI);
}
pub fn draw_ui_rect(
&mut self,
screen_pos: Vec2,
size: Vec2,
color: Color,
camera: &Camera,
window_width: u32,
window_height: u32,
) {
let world = camera.screen_to_world(screen_pos, window_width, window_height);
self.draw_rect_on_layer(world, size, color, layers::UI);
}
#[allow(clippy::too_many_arguments)]
pub fn draw_text_wrapped(
&mut self,
font: &Handle<Font>,
content: &str,
position: Vec2,
font_size: f32,
color: Color,
max_width: f32,
line_height: f32,
layer: i32,
) {
self.push(DrawCommand {
kind: DrawKind::Text {
font: Some(u64::from(font.id())),
content: Arc::from(content),
position,
font_size,
color,
layer,
wrap_width: Some(max_width),
line_height,
},
});
}
#[allow(clippy::too_many_arguments)]
pub fn draw_ui_sprite(
&mut self,
texture: &Handle<Texture>,
screen_pos: Vec2,
size: Vec2,
camera: &Camera,
window_width: u32,
window_height: u32,
) {
let world = camera.screen_to_world(screen_pos, window_width, window_height);
self.draw_sprite_on_layer(texture, world, size, layers::UI);
}
pub fn draw_immediate(&mut self, f: impl FnOnce(&mut DrawContext<'_>)) {
let mut ctx = DrawContext { queue: self };
f(&mut ctx);
}
}
pub struct DrawContext<'a> {
queue: &'a mut RenderQueue,
}
impl DrawContext<'_> {
pub fn line(&mut self, start: Vec2, end: Vec2, color: Color, thickness: f32) {
self.queue.draw_line(start, end, color, thickness);
}
pub fn rect(&mut self, position: Vec2, size: Vec2, color: Color) {
self.queue.draw_rect(position, size, color);
}
pub fn rect_stroke(&mut self, position: Vec2, size: Vec2, color: Color, thickness: f32) {
let Vec2 { x, y } = position;
let Vec2 { x: w, y: h } = size;
self.line(Vec2::new(x, y), Vec2::new(x + w, y), color, thickness);
self.line(
Vec2::new(x, y + h),
Vec2::new(x + w, y + h),
color,
thickness,
);
self.line(Vec2::new(x, y), Vec2::new(x, y + h), color, thickness);
self.line(
Vec2::new(x + w, y),
Vec2::new(x + w, y + h),
color,
thickness,
);
}
pub fn circle(&mut self, center: Vec2, radius: f32, color: Color, thickness: f32) {
let segments = 32;
#[allow(clippy::cast_precision_loss)]
for i in 0..segments {
let a1 = (i as f32 / segments as f32) * 2.0 * std::f32::consts::PI;
let a2 = ((i + 1) as f32 / segments as f32) * 2.0 * std::f32::consts::PI;
let x1 = center.x + a1.cos() * radius;
let y1 = center.y + a1.sin() * radius;
let x2 = center.x + a2.cos() * radius;
let y2 = center.y + a2.sin() * radius;
self.line(Vec2::new(x1, y1), Vec2::new(x2, y2), color, thickness);
}
}
pub fn circle_filled(&mut self, center: Vec2, radius: f32, color: Color) {
let r = radius.ceil() as i32;
#[allow(clippy::cast_precision_loss)]
for dy in -r..=r {
let dy_f = dy as f32 + 0.5; let half_w = (radius * radius - dy_f * dy_f).sqrt();
if half_w <= 0.0 {
continue;
}
self.queue.push(DrawCommand {
kind: DrawKind::Rect {
position: Vec2::new(center.x - half_w, center.y + dy as f32),
size: Vec2::new(half_w * 2.0, 1.0),
color,
layer: layers::FOREGROUND,
},
});
}
}
pub fn text(&mut self, content: &str, position: Vec2, font_size: f32, color: Color) {
self.queue.push(DrawCommand {
kind: DrawKind::Text {
font: Some(0),
content: Arc::from(content),
position,
font_size,
color,
layer: layers::FOREGROUND,
wrap_width: None,
line_height: 0.0,
},
});
}
pub fn point(&mut self, position: Vec2, color: Color) {
self.circle(position, 3.0, color, 1.0);
}
pub fn aabb(&mut self, min: Vec2, max: Vec2, color: Color, thickness: f32) {
self.rect_stroke(min, max - min, color, thickness);
}
}
pub trait RenderPass: Send + Sync + 'static {
fn name(&self) -> &str;
fn execute(
&self,
_device: &wgpu::Device,
_queue: &wgpu::Queue,
_pass: &mut wgpu::RenderPass<'_>,
) {
}
}
impl Default for RenderQueue {
fn default() -> Self {
Self::new()
}
}
pub trait PostEffect: Send + Sync + 'static {
fn apply(&self, queue: &mut RenderQueue, window_w: f32, window_h: f32);
}
#[derive(Resource, Default)]
pub struct PostProcessStack {
effects: Vec<Box<dyn PostEffect>>,
}
impl PostProcessStack {
pub fn push(&mut self, effect: impl PostEffect + 'static) {
self.effects.push(Box::new(effect));
}
pub fn clear(&mut self) {
self.effects.clear();
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.effects.is_empty()
}
}
#[derive(Resource, Clone, Copy)]
pub struct ScreenFlash {
pub color: Color,
pub intensity: f32,
pub decay: f32,
}
#[derive(Resource, Clone, Copy)]
pub struct ColorTint {
pub color: Color,
pub intensity: f32,
}
fn apply_post_process_system(
mut queue: ResMut<RenderQueue>,
flash: Option<Res<ScreenFlash>>,
tint: Option<Res<ColorTint>>,
stack: Res<PostProcessStack>,
info: Res<RenderInfo>,
) {
let w = info.window_width as f32;
let h = info.window_height as f32;
if w == 0.0 || h == 0.0 {
return;
}
let size = Vec2::new(w, h);
for effect in &stack.effects {
effect.apply(&mut queue, w, h);
}
if let Some(tint) = tint
&& tint.intensity > 0.0
{
queue.draw_screen_rect(
Vec2::ZERO,
size,
Color::rgba(tint.color.r, tint.color.g, tint.color.b, tint.intensity),
);
}
if let Some(flash) = flash
&& flash.intensity > 0.0
{
queue.draw_screen_rect(
Vec2::ZERO,
size,
Color::rgba(flash.color.r, flash.color.g, flash.color.b, flash.intensity),
);
}
}
fn decay_screen_flash_system(
mut commands: Commands,
flash: Option<ResMut<ScreenFlash>>,
time: Res<lunar_core::Time>,
) {
if let Some(mut flash) = flash
&& flash.decay > 0.0
{
flash.intensity -= flash.decay * time.delta_seconds();
if flash.intensity <= 0.0 {
commands.remove_resource::<ScreenFlash>();
}
}
}
#[derive(Resource)]
pub struct RenderInfo {
pub window_width: u32,
pub window_height: u32,
pub fps: f32,
pub frame_time_ms: f32,
pub draw_calls: u32,
pub sprite_count: u32,
}
impl RenderInfo {
#[must_use]
pub const fn new() -> Self {
Self {
window_width: 0,
window_height: 0,
fps: 0.0,
frame_time_ms: 0.0,
draw_calls: 0,
sprite_count: 0,
}
}
}
impl Default for RenderInfo {
fn default() -> Self {
Self::new()
}
}
#[derive(Resource)]
pub struct DebugOverlay {
pub enabled: bool,
pub position: Vec2,
pub font_size: f32,
pub color: Color,
scratch: String,
}
impl DebugOverlay {
#[must_use]
pub fn new() -> Self {
Self {
enabled: false,
position: Vec2::new(10.0, 10.0),
font_size: 14.0,
color: Color::WHITE,
scratch: String::new(),
}
}
pub fn draw(
&mut self,
queue: &mut RenderQueue,
fps: f32,
frame_time_ms: f32,
sprite_count: u32,
entity_count: u32,
) {
if !self.enabled {
return;
}
use std::fmt::Write;
let x = self.position.x;
let y = self.position.y;
let fs = self.font_size;
let color = self.color;
let spacing = fs + 2.0;
self.scratch.clear();
let _ = write!(self.scratch, "FPS: {fps:.1}");
push_debug_text(queue, &self.scratch, Vec2::new(x, y), fs, color);
self.scratch.clear();
let _ = write!(self.scratch, "Frame: {frame_time_ms:.1}ms");
push_debug_text(queue, &self.scratch, Vec2::new(x, y + spacing), fs, color);
self.scratch.clear();
let _ = write!(self.scratch, "Sprites: {sprite_count}");
push_debug_text(
queue,
&self.scratch,
Vec2::new(x, y + spacing * 2.0),
fs,
color,
);
self.scratch.clear();
let _ = write!(self.scratch, "Entities: {entity_count}");
push_debug_text(
queue,
&self.scratch,
Vec2::new(x, y + spacing * 3.0),
fs,
color,
);
}
}
fn push_debug_text(
queue: &mut RenderQueue,
content: &str,
position: Vec2,
font_size: f32,
color: Color,
) {
queue.push(DrawCommand {
kind: DrawKind::Text {
font: Some(0),
content: Arc::from(content),
position,
font_size,
color,
layer: layers::FOREGROUND,
wrap_width: None,
line_height: 0.0,
},
});
}
impl Default for DebugOverlay {
fn default() -> Self {
Self::new()
}
}
pub struct RenderPlugin;
impl Default for RenderPlugin {
fn default() -> Self {
Self
}
}
impl GamePlugin for RenderPlugin {
fn name(&self) -> &'static str {
"RenderPlugin"
}
fn build(&mut self, app: &mut App) {
app.insert_resource(RenderQueue::new());
app.insert_resource(RenderInfo::new());
app.insert_resource(DebugOverlay::new());
app.insert_resource(PostProcessStack::default());
app.add_system_to_stage(
lunar_core::UpdateStage::PostUpdate,
decay_screen_flash_system,
);
app.add_system_to_stage(
lunar_core::UpdateStage::PostUpdate,
apply_post_process_system,
);
app.add_system_to_stage(
lunar_core::UpdateStage::PostUpdate,
(
camera_follow::camera_follow_system,
screen_shake::screen_shake_system,
)
.chain(),
);
#[cfg(not(target_arch = "wasm32"))]
app.add_system_to_stage(
lunar_core::UpdateStage::Render,
(
upload_new_textures_system,
upload_new_fonts_system,
evict_textures_system,
frame_stats_system,
auto_sprite_system,
auto_text_system,
debug_overlay_system,
render_system,
)
.chain(),
);
#[cfg(target_arch = "wasm32")]
app.add_system_to_stage(
lunar_core::UpdateStage::Render,
(
wasm_upload_new_textures_system,
wasm_upload_new_fonts_system,
wasm_evict_textures_system,
frame_stats_system,
auto_sprite_system,
auto_text_system,
debug_overlay_system,
wasm_render_system,
)
.chain(),
);
}
}
#[cfg(target_arch = "wasm32")]
thread_local! {
static WASM_RENDER_ENGINE: std::cell::RefCell<Option<RenderEngine>> =
std::cell::RefCell::new(None);
}
#[cfg(target_arch = "wasm32")]
pub fn wasm_set_render_engine(engine: RenderEngine) {
WASM_RENDER_ENGINE.with(|cell| {
*cell.borrow_mut() = Some(engine);
});
}
#[cfg(target_arch = "wasm32")]
#[allow(clippy::needless_pass_by_value)]
fn wasm_render_system(
mut queue: ResMut<RenderQueue>,
mut render_info: ResMut<RenderInfo>,
camera: Option<Res<Camera>>,
) {
WASM_RENDER_ENGINE.with(|cell| {
if let Some(engine) = cell.borrow_mut().as_mut() {
engine.render(queue.commands(), camera.as_deref(), &mut render_info);
}
});
queue.clear();
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::needless_pass_by_value)]
fn upload_new_textures_system(mut assets: ResMut<AssetServer>, mut render: ResMut<RenderEngine>) {
for id in assets.drain_new_texture_ids() {
if let Some(texture) = assets.get_texture_by_id(id) {
let handle = Handle::<Texture>::new(id, 0);
render.upload_texture(&handle, texture);
}
}
}
#[cfg(target_arch = "wasm32")]
#[allow(clippy::needless_pass_by_value)]
fn wasm_upload_new_textures_system(mut assets: ResMut<AssetServer>) {
let ids = assets.drain_new_texture_ids();
WASM_RENDER_ENGINE.with(|cell| {
if let Some(engine) = cell.borrow_mut().as_mut() {
for id in ids {
if let Some(texture) = assets.get_texture_by_id(id) {
let handle = Handle::<Texture>::new(id, 0);
engine.upload_texture(&handle, texture);
}
}
}
});
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::needless_pass_by_value)]
fn upload_new_fonts_system(mut assets: ResMut<AssetServer>, mut render: ResMut<RenderEngine>) {
for id in assets.drain_new_font_ids() {
if let Some(font) = assets.get_font_by_id(id) {
render.upload_font(id, &font.data);
}
}
}
#[cfg(target_arch = "wasm32")]
#[allow(clippy::needless_pass_by_value)]
fn wasm_upload_new_fonts_system(mut assets: ResMut<AssetServer>) {
let ids = assets.drain_new_font_ids();
WASM_RENDER_ENGINE.with(|cell| {
if let Some(engine) = cell.borrow_mut().as_mut() {
for id in &ids {
if let Some(font) = assets.get_font_by_id(*id) {
engine.upload_font(*id, &font.data);
}
}
}
});
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::needless_pass_by_value)]
fn evict_textures_system(mut assets: ResMut<AssetServer>, mut render: ResMut<RenderEngine>) {
for id in assets.drain_evicted_texture_ids() {
render.remove_texture(id);
}
}
#[cfg(target_arch = "wasm32")]
#[allow(clippy::needless_pass_by_value)]
fn wasm_evict_textures_system(mut assets: ResMut<AssetServer>) {
let ids = assets.drain_evicted_texture_ids();
WASM_RENDER_ENGINE.with(|cell| {
if let Some(engine) = cell.borrow_mut().as_mut() {
for id in ids {
engine.remove_texture(id);
}
}
});
}
#[allow(clippy::needless_pass_by_value)]
fn frame_stats_system(time: Res<Time>, mut info: ResMut<RenderInfo>) {
let raw_delta = time.raw_delta_seconds();
info.frame_time_ms = raw_delta * 1000.0;
info.fps = if raw_delta > 0.0 {
1.0 / raw_delta
} else {
0.0
};
}
#[derive(Component)]
pub struct YSort;
#[allow(clippy::needless_pass_by_value)]
fn auto_sprite_system(
assets: Option<Res<AssetServer>>,
mut queue: ResMut<RenderQueue>,
query: Query<(&Transform, &Sprite, Option<&YSort>)>,
) {
for (transform, sprite, y_sort) in &query {
let resolved_size = sprite.size.unwrap_or_else(|| {
assets
.as_deref()
.and_then(|server| server.get_texture(&sprite.texture))
.map_or(Vec2::splat(32.0), |texture| {
Vec2::new(texture.width as f32, texture.height as f32)
})
});
let final_size = resolved_size * transform.scale;
let origin = sprite
.origin
.map_or_else(|| final_size * 0.5, |o| o * transform.scale);
let sort_key = y_sort.map(|_| (transform.translation.y * 100.0) as i32);
queue.push(DrawCommand {
kind: DrawKind::Sprite {
texture: Some(u64::from(sprite.texture.id())),
position: transform.translation,
rotation: transform.rotation,
scale: final_size,
tint: sprite.color,
layer: sprite.layer,
uv_rect: sprite.source_rect,
origin,
sort_key,
},
});
}
}
#[allow(clippy::needless_pass_by_value)]
fn auto_text_system(mut queue: ResMut<RenderQueue>, query: Query<(&Transform, &Text)>) {
for (transform, text) in &query {
queue.push(DrawCommand {
kind: DrawKind::Text {
font: Some(u64::from(text.font.id())),
content: Arc::clone(&text.content),
position: transform.translation,
font_size: text.font_size,
color: text.color,
layer: text.layer,
wrap_width: None,
line_height: 0.0,
},
});
}
}
#[cfg(not(target_arch = "wasm32"))]
fn render_system(
mut render_engine: ResMut<RenderEngine>,
mut queue: ResMut<RenderQueue>,
mut render_info: ResMut<RenderInfo>,
camera: Option<Res<Camera>>,
) {
render_engine.render(queue.commands(), camera.as_deref(), &mut render_info);
queue.clear();
}
#[allow(clippy::needless_pass_by_value)]
fn debug_overlay_system(
mut overlay: ResMut<DebugOverlay>,
info: Res<RenderInfo>,
mut queue: ResMut<RenderQueue>,
entities: Query<Entity>,
) {
#[allow(clippy::cast_possible_truncation)]
let entity_count = entities.iter().count() as u32;
overlay.draw(
&mut queue,
info.fps,
info.frame_time_ms,
info.sprite_count,
entity_count,
);
}