Skip to main content

lunar_render/
lib.rs

1//! rendering subsystem via wgpu
2#![allow(
3	clippy::cast_precision_loss,
4	clippy::cast_possible_truncation,
5	clippy::cast_sign_loss
6)]
7//!
8//! decoupled from game logic. handles 2D sprite and text rendering.
9//!
10//! # rendering model
11//!
12//! game code does not touch the GPU directly. two paths feed the renderer:
13//!
14//! 1. **components** (preferred) — spawn entities with [`Sprite`] or [`Text`]
15//!    alongside a [`Transform`]. built-in systems
16//!    enqueue them automatically every frame.
17//! 2. **immediate mode** (HUD / debug / one-shots) — call `draw_sprite`,
18//!    `draw_rect`, `draw_line`, `draw_text` on [`RenderQueue`] from inside a
19//!    system. useful when the thing you're drawing isn't a persistent entity.
20//!
21//! [`DrawCommand`] / [`DrawKind`] / [`RenderQueue::push`] are internal plumbing
22//! and not part of the public contract — they're hidden from rustdoc.
23//!
24//! # example: component-driven
25//!
26//! ```ignore
27//! use lunar::prelude::*;
28//!
29//! fn spawn_player(mut commands: Commands, mut assets: ResMut<AssetServer>) {
30//!     commands.spawn((
31//!         Transform::from_xy(100.0, 100.0),
32//!         Sprite::new(assets.load_texture("player.png")),
33//!     ));
34//! }
35//! ```
36//!
37//! # example: immediate mode
38//!
39//! ```ignore
40//! fn draw_hud(mut queue: ResMut<RenderQueue>) {
41//!     queue.draw_rect(Vec2::ZERO, Vec2::new(200.0, 40.0), Color::rgba(0.0, 0.0, 0.0, 0.6));
42//! }
43//! ```
44
45pub mod atlas;
46mod camera_follow;
47mod screen_shake;
48mod text;
49pub mod textbox;
50
51pub use camera_follow::CameraFollow2d;
52pub use screen_shake::ScreenShake;
53
54use rustc_hash::FxHashMap as HashMap;
55use std::sync::Arc;
56
57use bevy_ecs::prelude::*;
58use bevy_ecs::schedule::IntoScheduleConfigs;
59use lunar_assets::{AssetServer, Font, Handle, Texture};
60use lunar_core::{App, GamePlugin, Time};
61use lunar_math::{Color, Transform, Vec2};
62
63/// internal parameters for writing sprite vertices.
64#[allow(dead_code)]
65#[derive(Clone, Copy)]
66struct SpriteDrawParams {
67	position: Vec2,
68	rotation: f32,
69	scale: Vec2,
70	tint: Color,
71	uv_rect: Option<(Vec2, Vec2)>,
72	origin: Vec2,
73}
74
75/// parameters for drawing a transformed sprite.
76/// used with [`RenderQueue::draw_sprite_transformed_on_layer`] to avoid
77/// too many function arguments.
78#[derive(Debug, Clone, Copy)]
79pub struct SpriteParams {
80	/// position in world space
81	pub position: Vec2,
82	/// size (width, height)
83	pub scale: Vec2,
84	/// rotation in radians
85	pub rotation: f32,
86	/// origin point for rotation and scaling
87	pub origin: Vec2,
88	/// color tint (RGBA)
89	pub tint: Color,
90}
91
92/// opaque identifier for an offscreen render target created by [`RenderEngine::create_render_target`].
93///
94/// store this alongside the returned [`Handle<Texture>`] — the handle is used
95/// to draw the target as a sprite; this id is used to direct a camera at the target.
96#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Resource)]
97pub struct RenderTargetId(pub u32);
98
99/// maps [`RenderTargetId`] → [`Handle<Texture>`] so game code can retrieve the
100/// drawable texture handle for a render target without keeping it separately.
101#[derive(Resource, Default)]
102pub struct RenderTargetStore {
103	entries: rustc_hash::FxHashMap<RenderTargetId, lunar_assets::Handle<lunar_assets::Texture>>,
104}
105
106impl RenderTargetStore {
107	/// get the texture handle for a render target, for use with draw_sprite.
108	#[must_use]
109	pub fn get_texture(
110		&self,
111		id: RenderTargetId,
112	) -> Option<lunar_assets::Handle<lunar_assets::Texture>> {
113		self.entries.get(&id).copied()
114	}
115}
116
117/// camera resource, affects how the render queue is projected.
118///
119/// when no camera resource exists, rendering uses world-space anchored at origin.
120/// when present, the orthographic projection is offset and scaled accordingly.
121///
122/// # example
123///
124/// ```ignore
125/// use lunar_render::Camera;
126/// use lunar_math::Vec2;
127///
128/// // camera centered at (400, 300), letterboxed to an 800x600 viewport
129/// let cam = Camera {
130///     position: Vec2::new(400.0, 300.0),
131///     zoom: 1.0,
132///     rotation: 0.0,
133///     viewport: Some((800, 600)),
134///     layer_parallax: Default::default(),
135///     target: None,
136/// };
137///
138/// // use cam.projection_matrix(window_w, window_h) for the render projection
139/// ```
140#[derive(Resource, Clone)]
141pub struct Camera {
142	/// camera position in world space
143	pub position: Vec2,
144	/// zoom level (1.0 = 1:1, 2.0 = 2x zoom)
145	pub zoom: f32,
146	/// rotation in radians
147	pub rotation: f32,
148	/// viewport size in pixels (None = full window)
149	pub viewport: Option<(u32, u32)>,
150	/// per-layer offset for parallax scrolling (layer id → world offset)
151	pub layer_parallax: HashMap<i32, Vec2>,
152	/// render target to draw into (None = swapchain / window)
153	pub target: Option<RenderTargetId>,
154}
155
156impl Camera {
157	/// create a new camera at the origin with default settings
158	#[must_use]
159	pub fn new() -> Self {
160		Self {
161			position: Vec2::ZERO,
162			zoom: 1.0,
163			rotation: 0.0,
164			viewport: None,
165			layer_parallax: HashMap::default(),
166			target: None,
167		}
168	}
169
170	/// create a camera at the given position
171	#[must_use]
172	pub fn at_position(x: f32, y: f32) -> Self {
173		Self {
174			position: Vec2::new(x, y),
175			zoom: 1.0,
176			rotation: 0.0,
177			viewport: None,
178			layer_parallax: HashMap::default(),
179			target: None,
180		}
181	}
182
183	/// compute the orthographic projection matrix incorporating camera transforms.
184	/// returns a 4x4 column-major matrix as a flat array of 16 f32s.
185	/// for per-layer parallax, use [`Camera::projection_matrix_for_layer`] instead.
186	///
187	/// # example
188	///
189	/// ```ignore
190	/// use lunar_render::Camera;
191	///
192	/// let cam = Camera::default();
193	/// let proj = cam.projection_matrix(800, 600);
194	/// // proj is a [f32; 16] suitable for wgpu uniform upload
195	/// ```
196	#[must_use]
197	pub fn projection_matrix(&self, window_width: u32, window_height: u32) -> [f32; 16] {
198		self.projection_matrix_for_layer(0, window_width, window_height)
199	}
200
201	/// compute the orthographic projection matrix with a per-layer parallax offset.
202	/// the layer's parallax offset is subtracted from the camera position before
203	/// computing the transform, so layers can scroll at different speeds.
204	/// set per-layer offsets via [`Camera::set_layer_parallax`].
205	#[must_use]
206	pub fn projection_matrix_for_layer(
207		&self,
208		layer: i32,
209		window_width: u32,
210		window_height: u32,
211	) -> [f32; 16] {
212		let parallax_offset = self
213			.layer_parallax
214			.get(&layer)
215			.copied()
216			.unwrap_or(Vec2::ZERO);
217		let effective_pos = self.position - parallax_offset;
218		self.projection_matrix_at(effective_pos, window_width, window_height)
219	}
220
221	/// internal: compute projection at a specific camera position.
222	fn projection_matrix_at(&self, pos: Vec2, window_width: u32, window_height: u32) -> [f32; 16] {
223		#[allow(clippy::cast_precision_loss)]
224		let w = window_width as f32;
225		#[allow(clippy::cast_precision_loss)]
226		let h = window_height as f32;
227		let zoom = self.zoom.max(0.001);
228		let cos = self.rotation.cos();
229		let sin = self.rotation.sin();
230
231		// base orthographic scale
232		let sx = 2.0 / w * zoom;
233		let sy = -2.0 / h * zoom;
234
235		// camera translation (accounting for rotation)
236		let tx = -pos.y.mul_add(-sin, pos.x * cos);
237		let ty = -pos.y.mul_add(cos, pos.x * sin);
238
239		// combined matrix: scale then translate, y-down
240		[
241			sx,
242			0.0,
243			0.0,
244			0.0,
245			0.0,
246			sy,
247			0.0,
248			0.0,
249			0.0,
250			0.0,
251			1.0,
252			0.0,
253			sx * tx,
254			sy * ty,
255			0.0,
256			1.0,
257		]
258	}
259
260	/// set a parallax offset for a specific layer.
261	/// the offset is a world-space Vec2 subtracted from the camera position
262	/// when rendering that layer. to scroll a background at half speed,
263	/// pass `camera.position * 0.5` as the offset each frame.
264	pub fn set_layer_parallax(&mut self, layer: i32, offset: Vec2) {
265		self.layer_parallax.insert(layer, offset);
266	}
267
268	/// remove the parallax offset for a layer, reverting to normal camera tracking.
269	pub fn clear_layer_parallax(&mut self, layer: i32) {
270		self.layer_parallax.remove(&layer);
271	}
272
273	/// convert a screen-space pixel position to world-space coordinates.
274	///
275	/// accounts for camera position, zoom, rotation, and viewport letterboxing.
276	/// screen origin is top-left, y-down. world is top-left, y-down.
277	///
278	/// # example
279	///
280	/// ```ignore
281	/// fn my_system(camera: Res<Camera>, input: Res<InputState>) {
282	///     let (mx, my) = input.mouse_position();
283	///     let world = camera.screen_to_world(Vec2::new(mx, my), 800, 600);
284	///     // spawn something at the mouse position in world space
285	/// }
286	/// ```
287	#[must_use]
288	pub fn screen_to_world(&self, screen: Vec2, window_width: u32, window_height: u32) -> Vec2 {
289		let (vw, vh) = self.viewport.unwrap_or((window_width, window_height));
290		#[allow(clippy::cast_precision_loss)]
291		let (vw_f, vh_f) = (vw as f32, vh as f32);
292
293		let zoom = self.zoom.max(0.001);
294		let cos = self.rotation.cos();
295		let sin = self.rotation.sin();
296
297		// unapply projection transform — input is viewport-space (0..vw, 0..vh)
298		let nx = screen.x / vw_f - 0.5;
299		let ny = screen.y / vh_f - 0.5;
300		let world_dx = nx * vw_f / zoom;
301		let world_dy = ny * vh_f / zoom;
302
303		// unrotate
304		let unrot_x = world_dx * cos + world_dy * sin;
305		let unrot_y = -world_dx * sin + world_dy * cos;
306
307		Vec2::new(self.position.x + unrot_x, self.position.y + unrot_y)
308	}
309
310	/// convert a world-space position to screen-space pixel coordinates.
311	///
312	/// inverse of [`screen_to_world`](Self::screen_to_world).
313	/// the result is in screen pixel coordinates (top-left origin, y-down).
314	#[must_use]
315	pub fn world_to_screen(&self, world: Vec2, window_width: u32, window_height: u32) -> Vec2 {
316		let (vw, vh) = self.viewport.unwrap_or((window_width, window_height));
317		#[allow(clippy::cast_precision_loss)]
318		let (vw_f, vh_f) = (vw as f32, vh as f32);
319
320		let zoom = self.zoom.max(0.001);
321		let cos = self.rotation.cos();
322		let sin = self.rotation.sin();
323
324		// rotate world delta and apply zoom/scale
325		let dx = world.x - self.position.x;
326		let dy = world.y - self.position.y;
327		let rx = dx * cos - dy * sin;
328		let ry = dx * sin + dy * cos;
329
330		// apply ortho projection — output is viewport-space (0..vw, 0..vh)
331		let sx = rx * zoom / vw_f;
332		let sy = -ry * zoom / vh_f;
333
334		Vec2::new((sx + 0.5) * vw_f, (0.5 - sy) * vh_f)
335	}
336
337	/// enable or disable viewport letterboxing.
338	///
339	/// when enabled, the projection auto-computes black-bar offsets when the
340	/// window aspect ratio doesn't match the viewport aspect ratio.
341	/// this is called internally by [`set_target_aspect`](Self::set_target_aspect).
342	///
343	/// returns `&mut Self` for chaining.
344	pub fn set_target_aspect(&mut self, width: u32, height: u32) -> &mut Self {
345		self.viewport = Some((width, height));
346		self
347	}
348}
349
350impl Default for Camera {
351	fn default() -> Self {
352		Self::new()
353	}
354}
355
356/// rendering configuration.
357///
358/// controls window size, vsync, and frame rate limiting.
359/// used when initializing the [`RenderEngine`] and [`lunar_core::WindowSettings`].
360#[derive(Debug, Clone)]
361pub struct RenderConfig {
362	/// window width
363	pub width: u32,
364	/// window height
365	pub height: u32,
366	/// vsync enabled
367	pub vsync: bool,
368	/// target frame cap (0 = uncapped/vsync-limited)
369	pub frame_cap: u32,
370	/// fixed logic tick rate — independent of render frame rate.
371	/// `time.delta_seconds()` in game systems always equals `1 / tick_hz`.
372	pub tick_rate: lunar_core::TickRate,
373	/// window title bar text.
374	pub title: String,
375	/// fixed aspect ratio. when set, the window snaps on resize to maintain this ratio.
376	/// expressed as width/height (e.g. `16.0/9.0`). `None` = free aspect ratio.
377	pub target_aspect: Option<f32>,
378	/// whether the window is resizable. true by default.
379	pub allow_resize: bool,
380}
381
382impl Default for RenderConfig {
383	fn default() -> Self {
384		Self {
385			width: 1280,
386			height: 720,
387			vsync: true,
388			frame_cap: 0,
389			tick_rate: lunar_core::TickRate::Hz60,
390			title: "Lunar".to_string(),
391			target_aspect: None,
392			allow_resize: true,
393		}
394	}
395}
396
397impl RenderConfig {
398	/// the loop-timing parameters ([`frame_cap`](Self::frame_cap) +
399	/// [`tick_rate`](Self::tick_rate)) as a [`LoopConfig`](lunar_core::LoopConfig)
400	/// for [`App::run`](lunar_core::App::run).
401	#[must_use]
402	pub fn loop_config(&self) -> lunar_core::LoopConfig {
403		lunar_core::LoopConfig {
404			frame_cap: self.frame_cap,
405			tick_rate: self.tick_rate,
406		}
407	}
408}
409
410/// initial vertex capacity per frame (64k vertices = ~10k sprites with packed color).
411/// the buffer doubles automatically the frame after an overflow is detected,
412/// so this is a tunable starting point — never a ceiling.
413/// vertex format: [pos.x, pos.y, u, v] (16 bytes) + [`color_u32`] (4 bytes) = 20 bytes per vertex
414const INITIAL_VERTEX_CAPACITY: usize = 65536;
415
416/// bind group key reserved for the glyph atlas texture used by text draws.
417/// regular sprite textures use their asset ID; the white placeholder uses `u32::MAX`.
418const GLYPH_ATLAS_BIND_ID: u32 = u32::MAX - 1;
419
420/// number of vertex buffers for double-buffering (prevents GPU read/write conflicts)
421const VERTEX_BUFFER_COUNT: usize = 2;
422
423/// bytes per vertex: 2 floats for position + 2 floats for uv + 1 u32 for packed rgba color
424const VERTEX_STRIDE: usize = 20;
425
426/// render engine resource, owns all GPU rendering state.
427///
428/// managed by the engine — game code reads it via `Res<RenderEngine>` when it
429/// needs direct GPU access for custom draw calls or render target operations.
430#[cfg_attr(not(target_arch = "wasm32"), derive(Resource))]
431pub struct RenderEngine {
432	surface: wgpu::Surface<'static>,
433	device: wgpu::Device,
434	queue: wgpu::Queue,
435	config: wgpu::SurfaceConfiguration,
436	render_config: RenderConfig,
437	sprite_pipeline: wgpu::RenderPipeline,
438	uniform_buf: wgpu::Buffer,
439	// group 0: view-global (projection uniform) — set once per layer change
440	globals_bg: wgpu::BindGroup,
441	// group 1: material (texture + sampler) — set per texture batch
442	material_bgl: wgpu::BindGroupLayout,
443	sampler: wgpu::Sampler,
444	textures: HashMap<u32, GpuTexture>,
445	// keyed by texture id; contains only texture + sampler bindings (no uniform)
446	material_bgs: HashMap<u32, wgpu::BindGroup>,
447	/// persistent vertex buffers — double-buffered to prevent GPU read/write conflicts
448	vertex_bufs: [wgpu::Buffer; VERTEX_BUFFER_COUNT],
449	/// current vertex capacity (number of vertices, not bytes). doubles when
450	/// a frame overflows. starts at [`INITIAL_VERTEX_CAPACITY`].
451	vertex_capacity: usize,
452	/// set during render when a draw was dropped due to capacity. on the next
453	/// frame, [`Self::grow_vertex_buffers`] doubles capacity before drawing.
454	overflow_flag: bool,
455	/// current frame index for buffer selection
456	frame_index: usize,
457	/// current write offset into the active vertex buffer
458	vertex_offset: usize,
459	glyph_atlas: text::GlyphAtlas,
460	#[allow(dead_code)]
461	glyph_atlas_texture: Option<GpuTexture>,
462	render_passes: Vec<Box<dyn RenderPass>>,
463	/// vulkan pipeline cache for faster startup on subsequent launches
464	#[cfg(not(target_arch = "wasm32"))]
465	pipeline_cache: Option<wgpu::PipelineCache>,
466	/// persistent scratch — reused each frame to sort draw commands by (layer, texture).
467	/// avoids a per-frame Vec allocation proportional to the draw list size.
468	sorted_indices: Vec<usize>,
469	/// persistent scratch — reused each frame for text glyph quad layout results.
470	/// keyed by command index. avoids a per-frame HashMap + inner Vec allocation.
471	text_quads: HashMap<usize, Vec<text::TextGlyphQuad>>,
472	/// LRU layout cache — avoids re-shaping text whose content hasn't changed.
473	/// capped at 256 entries; evicts least-recently-used on overflow.
474	text_layout_cache: text::TextLayoutCache,
475	/// texture views for render target output — keyed by RenderTargetId.0.
476	/// stored separately from `textures` to avoid borrow conflicts in render().
477	render_target_views: HashMap<u32, wgpu::TextureView>,
478	/// counter for generating unique render target IDs (high range, no collision with asset IDs).
479	render_target_counter: u32,
480}
481
482/// gpu-ready texture: texture + view + sampler
483#[allow(dead_code)]
484struct GpuTexture {
485	texture: wgpu::Texture,
486	view: wgpu::TextureView,
487}
488
489impl RenderEngine {
490	/// create render engine from a surface (native, blocking)
491	///
492	/// # Panics
493	///
494	/// panics if no adapter or device can be created.
495	#[cfg(not(target_arch = "wasm32"))]
496	pub fn from_surface(
497		instance: &wgpu::Instance,
498		surface: wgpu::Surface<'static>,
499		config: RenderConfig,
500	) -> Self {
501		let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
502			power_preference: wgpu::PowerPreference::HighPerformance,
503			force_fallback_adapter: false,
504			compatible_surface: Some(&surface),
505		}))
506		.expect("failed to request adapter");
507
508		let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
509			label: Some("lunar render device"),
510			required_features: wgpu::Features::empty(),
511			required_limits: wgpu::Limits::default(),
512			memory_hints: wgpu::MemoryHints::Performance,
513			trace: wgpu::Trace::default(),
514			experimental_features: wgpu::ExperimentalFeatures::disabled(),
515		}))
516		.expect("failed to request device");
517
518		Self::init_inner(&adapter, &device, queue, surface, config)
519	}
520
521	/// create render engine from a surface (WASM, async)
522	#[cfg(target_arch = "wasm32")]
523	pub async fn from_surface(
524		instance: &wgpu::Instance,
525		surface: wgpu::Surface<'static>,
526		config: RenderConfig,
527	) -> Self {
528		let adapter = instance
529            .request_adapter(&wgpu::RequestAdapterOptions {
530                power_preference: wgpu::PowerPreference::HighPerformance,
531                force_fallback_adapter: false,
532                compatible_surface: Some(&surface),
533            })
534            .await
535            .expect("no WebGPU adapter found — in Firefox enable dom.webgpu.enabled in about:config, Chrome 113+ required");
536
537		let (device, queue) = adapter
538			.request_device(&wgpu::DeviceDescriptor {
539				label: Some("lunar render device"),
540				required_features: wgpu::Features::empty(),
541				required_limits: wgpu::Limits::default(),
542				memory_hints: wgpu::MemoryHints::Performance,
543				trace: wgpu::Trace::default(),
544				experimental_features: wgpu::ExperimentalFeatures::disabled(),
545			})
546			.await
547			.expect("failed to request device");
548
549		Self::init_inner(&adapter, &device, queue, surface, config)
550	}
551
552	/// create a WebGPU surface from a canvas element (WASM only).
553	///
554	/// # Errors
555	///
556	/// returns an error if the canvas element is not compatible with the GPU
557	/// or if the browser denies GPU access.
558	#[cfg(target_arch = "wasm32")]
559	pub fn create_canvas_surface(
560		instance: &wgpu::Instance,
561		canvas: &web_sys::HtmlCanvasElement,
562	) -> Result<wgpu::Surface<'static>, String> {
563		let surface = instance
564			.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
565			.map_err(|e| format!("failed to create surface: {e:?}"))?;
566		Ok(surface)
567	}
568
569	/// find a canvas element by id and return it.
570	///
571	/// # Errors
572	///
573	/// returns an error if no window, no document, no element with the given id,
574	/// or if the element is not an html canvas element.
575	#[cfg(target_arch = "wasm32")]
576	pub fn find_canvas(id: &str) -> Result<web_sys::HtmlCanvasElement, String> {
577		use wasm_bindgen::JsCast;
578		let window = web_sys::window().ok_or("no window")?;
579		let document = window.document().ok_or("no document")?;
580		let element = document
581			.get_element_by_id(id)
582			.ok_or_else(|| format!("no element with id '{id}'"))?;
583		element
584			.dyn_into::<web_sys::HtmlCanvasElement>()
585			.map_err(|_| format!("element '{id}' is not a canvas"))
586	}
587
588	#[allow(clippy::too_many_lines)]
589	fn init_inner(
590		adapter: &wgpu::Adapter,
591		device: &wgpu::Device,
592		queue: wgpu::Queue,
593		surface: wgpu::Surface<'static>,
594		config: RenderConfig,
595	) -> Self {
596		let caps = surface.get_capabilities(adapter);
597		let format = caps
598			.formats
599			.first()
600			.copied()
601			.unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb);
602
603		let surface_config = wgpu::SurfaceConfiguration {
604			usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
605			format,
606			width: config.width,
607			height: config.height,
608			present_mode: if config.vsync {
609				wgpu::PresentMode::AutoVsync
610			} else {
611				wgpu::PresentMode::AutoNoVsync
612			},
613			alpha_mode: caps.alpha_modes.first().copied().unwrap_or_default(),
614			view_formats: vec![],
615			desired_maximum_frame_latency: 2,
616		};
617
618		surface.configure(device, &surface_config);
619
620		// projection matrix uniform (4x4 f32 = 64 bytes)
621		let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
622			label: Some("uniform buffer"),
623			size: 64,
624			usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
625			mapped_at_creation: false,
626		});
627
628		let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
629			label: Some("sprite sampler"),
630			address_mode_u: wgpu::AddressMode::ClampToEdge,
631			address_mode_v: wgpu::AddressMode::ClampToEdge,
632			mag_filter: wgpu::FilterMode::Nearest,
633			min_filter: wgpu::FilterMode::Nearest,
634			mipmap_filter: wgpu::MipmapFilterMode::Nearest,
635			..Default::default()
636		});
637
638		// group 0: view-global (projection uniform only) — set once per layer
639		let globals_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
640			label: Some("[globals] bgl"),
641			entries: &[wgpu::BindGroupLayoutEntry {
642				binding: 0,
643				visibility: wgpu::ShaderStages::VERTEX,
644				ty: wgpu::BindingType::Buffer {
645					ty: wgpu::BufferBindingType::Uniform,
646					has_dynamic_offset: false,
647					min_binding_size: None,
648				},
649				count: None,
650			}],
651		});
652
653		// group 1: material (texture + sampler) — switched per texture batch
654		let material_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
655			label: Some("[material] bgl"),
656			entries: &[
657				wgpu::BindGroupLayoutEntry {
658					binding: 0,
659					visibility: wgpu::ShaderStages::FRAGMENT,
660					ty: wgpu::BindingType::Texture {
661						sample_type: wgpu::TextureSampleType::Float { filterable: true },
662						view_dimension: wgpu::TextureViewDimension::D2,
663						multisampled: false,
664					},
665					count: None,
666				},
667				wgpu::BindGroupLayoutEntry {
668					binding: 1,
669					visibility: wgpu::ShaderStages::FRAGMENT,
670					ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
671					count: None,
672				},
673			],
674		});
675
676		let globals_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
677			label: Some("[globals] bg"),
678			layout: &globals_bgl,
679			entries: &[wgpu::BindGroupEntry {
680				binding: 0,
681				resource: uniform_buf.as_entire_binding(),
682			}],
683		});
684
685		// 1x1 white texture used for untextured draws (rects, lines, text)
686		let placeholder_texture = device.create_texture(&wgpu::TextureDescriptor {
687			label: Some("white 1x1"),
688			size: wgpu::Extent3d {
689				width: 1,
690				height: 1,
691				depth_or_array_layers: 1,
692			},
693			mip_level_count: 1,
694			sample_count: 1,
695			dimension: wgpu::TextureDimension::D2,
696			format: wgpu::TextureFormat::Rgba8UnormSrgb,
697			usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
698			view_formats: &[],
699		});
700		queue.write_texture(
701			wgpu::TexelCopyTextureInfo {
702				texture: &placeholder_texture,
703				mip_level: 0,
704				origin: wgpu::Origin3d::ZERO,
705				aspect: wgpu::TextureAspect::All,
706			},
707			&[255u8, 255, 255, 255],
708			wgpu::TexelCopyBufferLayout {
709				offset: 0,
710				bytes_per_row: Some(4),
711				rows_per_image: Some(1),
712			},
713			wgpu::Extent3d {
714				width: 1,
715				height: 1,
716				depth_or_array_layers: 1,
717			},
718		);
719		let placeholder_view =
720			placeholder_texture.create_view(&wgpu::TextureViewDescriptor::default());
721
722		let placeholder_material_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
723			label: Some("[material] placeholder bg"),
724			layout: &material_bgl,
725			entries: &[
726				wgpu::BindGroupEntry {
727					binding: 0,
728					resource: wgpu::BindingResource::TextureView(&placeholder_view),
729				},
730				wgpu::BindGroupEntry {
731					binding: 1,
732					resource: wgpu::BindingResource::Sampler(&sampler),
733				},
734			],
735		});
736
737		let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
738			label: Some("sprite shader"),
739			source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(SHADER_SOURCE)),
740		});
741
742		let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
743			label: Some("sprite pipeline layout"),
744			bind_group_layouts: &[Some(&globals_bgl), Some(&material_bgl)],
745			immediate_size: 0,
746		});
747
748		// vertex layout: [pos.x, pos.y, u, v] (16 bytes) + [packed rgba u32] (4 bytes) = 20 bytes
749		let sprite_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
750			label: Some("sprite pipeline"),
751			layout: Some(&pipeline_layout),
752			vertex: wgpu::VertexState {
753				module: &shader,
754				entry_point: Some("vs_main"),
755				buffers: &[wgpu::VertexBufferLayout {
756					array_stride: VERTEX_STRIDE as u64,
757					step_mode: wgpu::VertexStepMode::Vertex,
758					attributes: &[
759						wgpu::VertexAttribute {
760							format: wgpu::VertexFormat::Float32x2,
761							offset: 0,
762							shader_location: 0,
763						},
764						wgpu::VertexAttribute {
765							format: wgpu::VertexFormat::Float32x2,
766							offset: 8,
767							shader_location: 1,
768						},
769						wgpu::VertexAttribute {
770							format: wgpu::VertexFormat::Unorm8x4,
771							offset: 16,
772							shader_location: 2,
773						},
774					],
775				}],
776				compilation_options: wgpu::PipelineCompilationOptions::default(),
777			},
778			fragment: Some(wgpu::FragmentState {
779				module: &shader,
780				entry_point: Some("fs_main"),
781				targets: &[Some(wgpu::ColorTargetState {
782					format: surface_config.format,
783					blend: Some(wgpu::BlendState {
784						color: wgpu::BlendComponent {
785							src_factor: wgpu::BlendFactor::SrcAlpha,
786							dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
787							operation: wgpu::BlendOperation::Add,
788						},
789						alpha: wgpu::BlendComponent {
790							src_factor: wgpu::BlendFactor::One,
791							dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
792							operation: wgpu::BlendOperation::Add,
793						},
794					}),
795					write_mask: wgpu::ColorWrites::ALL,
796				})],
797				compilation_options: wgpu::PipelineCompilationOptions::default(),
798			}),
799			primitive: wgpu::PrimitiveState {
800				topology: wgpu::PrimitiveTopology::TriangleList,
801				strip_index_format: None,
802				front_face: wgpu::FrontFace::Ccw,
803				cull_mode: None,
804				polygon_mode: wgpu::PolygonMode::Fill,
805				unclipped_depth: false,
806				conservative: false,
807			},
808			depth_stencil: None,
809			multisample: wgpu::MultisampleState::default(),
810			cache: None, // populated below after cache is loaded
811			multiview_mask: None,
812		});
813
814		let frame_cap_str = if config.frame_cap == 0 {
815			"uncapped".to_string()
816		} else {
817			config.frame_cap.to_string()
818		};
819		log::info!(
820			"render engine initialized: {}x{}, frame_cap={}",
821			config.width,
822			config.height,
823			frame_cap_str
824		);
825
826		// persistent vertex buffers — double-buffered to prevent GPU read/write conflicts
827		// uses COPY_DST for queue.write_buffer (no MAP_WRITE needed)
828		let vertex_bufs: [wgpu::Buffer; VERTEX_BUFFER_COUNT] = std::array::from_fn(|i| {
829			device.create_buffer(&wgpu::BufferDescriptor {
830				label: Some(&format!("persistent vertex buffer {i}")),
831				size: (INITIAL_VERTEX_CAPACITY * VERTEX_STRIDE) as u64,
832				usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
833				mapped_at_creation: false,
834			})
835		});
836
837		Self {
838			surface,
839			device: device.clone(),
840			queue,
841			config: surface_config,
842			render_config: config,
843			sprite_pipeline,
844			uniform_buf,
845			globals_bg,
846			material_bgl,
847			sampler,
848			textures: HashMap::default(),
849			material_bgs: {
850				let mut map = HashMap::default();
851				map.insert(u32::MAX, placeholder_material_bg);
852				map
853			},
854			vertex_bufs,
855			vertex_capacity: INITIAL_VERTEX_CAPACITY,
856			overflow_flag: false,
857			frame_index: 0,
858			vertex_offset: 0,
859			glyph_atlas: text::GlyphAtlas::new(2048, 1024),
860			glyph_atlas_texture: None,
861			render_passes: Vec::new(),
862			#[cfg(not(target_arch = "wasm32"))]
863			pipeline_cache: Self::load_pipeline_cache(device),
864			sorted_indices: Vec::new(),
865			text_quads: HashMap::default(),
866			text_layout_cache: text::TextLayoutCache::new(256),
867			render_target_views: HashMap::default(),
868			render_target_counter: 0,
869		}
870	}
871
872	/// update the uniform buffer with the projection matrix for a specific layer.
873	/// applies per-layer parallax offset from the camera if present.
874	fn update_projection_for_layer(&mut self, layer: i32, camera: Option<&Camera>) {
875		let (surface_w, surface_h) = (self.config.width as f32, self.config.height as f32);
876
877		// POST_PROCESS layer always uses screen-space projection (ignores camera)
878		if layer >= layers::POST_PROCESS {
879			let projection: [f32; 16] = [
880				2.0 / surface_w,
881				0.0,
882				0.0,
883				0.0,
884				0.0,
885				-2.0 / surface_h,
886				0.0,
887				0.0,
888				0.0,
889				0.0,
890				1.0,
891				0.0,
892				-1.0,
893				1.0,
894				0.0,
895				1.0,
896			];
897			self.queue
898				.write_buffer(&self.uniform_buf, 0, bytemuck::cast_slice(&projection));
899			return;
900		}
901
902		// if camera has a viewport, compute a letterboxed projection that fits
903		// the viewport into the surface while preserving aspect ratio
904		let projection = if let Some(cam) = camera
905			&& let Some((vp_w, vp_h)) = cam.viewport
906		{
907			let (vp_w, vp_h) = (vp_w as f32, vp_h as f32);
908			// scale viewport to fit surface, maintaining aspect ratio
909			let scale = (surface_w / vp_w).min(surface_h / vp_h);
910			let vp_w_scaled = vp_w * scale;
911			let vp_h_scaled = vp_h * scale;
912
913			// clip-space offset to center the viewport
914			let offset_x = (surface_w - vp_w_scaled) / surface_w;
915			let offset_y = (surface_h - vp_h_scaled) / surface_h;
916
917			// build a custom orthographic projection:
918			// maps world (cam_x - vp_w/2, cam_y - vp_h/2) .. (cam_x + vp_w/2, cam_y + vp_h/2)
919			// to a centered letterboxed region of clip space
920			let _sx = (vp_w_scaled / surface_w) * 2.0 / vp_w;
921			let _sy = -(vp_h_scaled / surface_h) * 2.0 / vp_h;
922			let pos = cam.position;
923			let tx = pos.x;
924			let ty = pos.y;
925
926			// clip_x = sx * (world_x - tx) + (sx * tx - 1 + offset_x)
927			//        = sx * world_x + (sx * tx - 1 + offset_x - sx * tx)
928			//        = sx * world_x - 1 + offset_x
929
930			// Actually simpler: compute clip directly
931			let left = -1.0 + offset_x;
932			let right = 1.0 - offset_x;
933			let bottom = -1.0 + offset_y;
934			let top = 1.0 - offset_y;
935
936			// scale from world to clip:
937			// world_x = tx → clip_x = 0 → (left+right)/2
938			// world_x = tx - vp_w/2 → clip_x = left
939			// world_x = tx + vp_w/2 → clip_x = right
940
941			let sx2 = (right - left) / vp_w;
942			let sy2 = (bottom - top) / vp_h;
943			let tx2 = (right + left) / 2.0;
944			let ty2 = (top + bottom) / 2.0;
945
946			[
947				sx2,
948				0.0,
949				0.0,
950				0.0,
951				0.0,
952				sy2,
953				0.0,
954				0.0,
955				0.0,
956				0.0,
957				1.0,
958				0.0,
959				sx2 * (-tx) + tx2,
960				sy2 * (-ty) + ty2,
961				0.0,
962				1.0,
963			]
964		} else if let Some(cam) = camera {
965			cam.projection_matrix_for_layer(layer, self.config.width, self.config.height)
966		} else {
967			[
968				2.0 / surface_w,
969				0.0,
970				0.0,
971				0.0,
972				0.0,
973				-2.0 / surface_h,
974				0.0,
975				0.0,
976				0.0,
977				0.0,
978				1.0,
979				0.0,
980				-1.0,
981				1.0,
982				0.0,
983				1.0,
984			]
985		};
986		self.queue
987			.write_buffer(&self.uniform_buf, 0, bytemuck::cast_slice(&projection));
988	}
989
990	/// load the vulkan pipeline cache from disk if it exists.
991	#[cfg(not(target_arch = "wasm32"))]
992	fn load_pipeline_cache(device: &wgpu::Device) -> Option<wgpu::PipelineCache> {
993		let cache_path = std::path::Path::new(".pipeline_cache.bin");
994		if cache_path.exists() {
995			match std::fs::read(cache_path) {
996				Ok(data) => {
997					log::info!("loaded pipeline cache ({} bytes)", data.len());
998					// SAFETY: `create_pipeline_cache` is unsafe because malformed
999					// cache data could cause undefined behavior in some drivers.
1000					// We pass `fallback: true` so wgpu silently rebuilds a fresh
1001					// cache if validation fails; the data on disk was written by
1002					// a prior run of this same binary, so format mismatch is the
1003					// only realistic risk and is handled by the fallback.
1004					Some(unsafe {
1005						device.create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
1006							label: Some("loaded pipeline cache"),
1007							data: Some(&data),
1008							fallback: true,
1009						})
1010					})
1011				}
1012				Err(e) => {
1013					log::warn!("failed to load pipeline cache: {e}");
1014					None
1015				}
1016			}
1017		} else {
1018			None
1019		}
1020	}
1021
1022	/// save the vulkan pipeline cache to disk.
1023	/// call this before shutting down to speed up future launches.
1024	#[cfg(not(target_arch = "wasm32"))]
1025	pub fn save_pipeline_cache(&self) {
1026		if let Some(ref cache) = self.pipeline_cache
1027			&& let Some(data) = cache.get_data()
1028		{
1029			let cache_path = std::path::Path::new(".pipeline_cache.bin");
1030			if let Err(e) = std::fs::write(cache_path, &data) {
1031				log::warn!("failed to save pipeline cache: {e}");
1032			} else {
1033				log::info!("saved pipeline cache ({} bytes)", data.len());
1034			}
1035		}
1036	}
1037
1038	/// register a custom render pass.
1039	/// passes are executed in registration order after the default 2D pass.
1040	pub fn add_render_pass<P: RenderPass>(&mut self, pass: P) {
1041		self.render_passes.push(Box::new(pass));
1042	}
1043
1044	/// get the current render config
1045	pub const fn config(&self) -> &RenderConfig {
1046		&self.render_config
1047	}
1048
1049	/// get the wgpu device
1050	pub const fn device(&self) -> &wgpu::Device {
1051		&self.device
1052	}
1053
1054	/// get the wgpu queue
1055	pub const fn queue(&self) -> &wgpu::Queue {
1056		&self.queue
1057	}
1058
1059	/// resize the render surface
1060	pub fn resize(&mut self, width: u32, height: u32) {
1061		self.config.width = width;
1062		self.config.height = height;
1063		self.surface.configure(&self.device, &self.config);
1064		self.render_config.width = width;
1065		self.render_config.height = height;
1066	}
1067
1068	/// remove a texture and its cached bind group.
1069	/// call this when a texture is no longer needed to free GPU memory.
1070	pub fn remove_texture(&mut self, tex_id: u32) {
1071		self.textures.remove(&tex_id);
1072		self.material_bgs.remove(&tex_id);
1073		self.render_target_views.remove(&tex_id);
1074	}
1075
1076	/// create an offscreen render target of the given pixel dimensions.
1077	///
1078	/// returns a `(RenderTargetId, Handle<Texture>)` pair:
1079	/// - use the id in `Camera.target` to direct a camera at this target.
1080	/// - use the texture handle with `draw_sprite` to display the target's contents.
1081	///
1082	/// also registers the entry in the supplied [`RenderTargetStore`] so it can be
1083	/// looked up later via [`RenderTargetStore::get_texture`].
1084	pub fn create_render_target(
1085		&mut self,
1086		store: &mut RenderTargetStore,
1087		width: u32,
1088		height: u32,
1089	) -> (RenderTargetId, Handle<Texture>) {
1090		let id = self.render_target_counter;
1091		self.render_target_counter += 1;
1092
1093		// use a high-range ID to avoid collisions with asset-server texture IDs
1094		let tex_id = u32::MAX / 2 + id;
1095
1096		let gpu_texture = self.device.create_texture(&wgpu::TextureDescriptor {
1097			label: Some(&format!("[rt:{tex_id}]")),
1098			size: wgpu::Extent3d {
1099				width,
1100				height,
1101				depth_or_array_layers: 1,
1102			},
1103			mip_level_count: 1,
1104			sample_count: 1,
1105			dimension: wgpu::TextureDimension::D2,
1106			format: self.config.format,
1107			usage: wgpu::TextureUsages::RENDER_ATTACHMENT
1108				| wgpu::TextureUsages::TEXTURE_BINDING
1109				| wgpu::TextureUsages::COPY_SRC,
1110			view_formats: &[],
1111		});
1112		// create two views from the same texture:
1113		// - render_view: used as the render pass color attachment (stored separately to avoid borrow conflicts)
1114		// - sample_view: used for sprite sampling (stored in self.textures)
1115		let render_view = gpu_texture.create_view(&wgpu::TextureViewDescriptor::default());
1116		let sample_view = gpu_texture.create_view(&wgpu::TextureViewDescriptor::default());
1117		self.render_target_views.insert(tex_id, render_view);
1118		self.textures.insert(
1119			tex_id,
1120			GpuTexture {
1121				texture: gpu_texture,
1122				view: sample_view,
1123			},
1124		);
1125
1126		let rt_id = RenderTargetId(tex_id);
1127		let handle = Handle::<Texture>::new(tex_id, 0);
1128		store.entries.insert(rt_id, handle);
1129		(rt_id, handle)
1130	}
1131
1132	/// register font bytes with the glyph atlas. glyphs are rasterized on demand
1133	/// during render() and the atlas is uploaded then.
1134	pub fn upload_font(&mut self, font_id: u32, data: &[u8]) {
1135		self.glyph_atlas.register_font(font_id, data);
1136	}
1137
1138	/// upload a texture to the GPU, returns its handle id.
1139	/// if the texture is already uploaded, this is a no-op.
1140	pub fn upload_texture(&mut self, handle: &Handle<Texture>, texture: &Texture) {
1141		if self.textures.contains_key(&handle.id()) {
1142			return;
1143		}
1144
1145		let mip_count = texture.mip_level_count();
1146		let gpu_texture = self.device.create_texture(&wgpu::TextureDescriptor {
1147			label: Some("sprite texture"),
1148			size: wgpu::Extent3d {
1149				width: texture.width,
1150				height: texture.height,
1151				depth_or_array_layers: 1,
1152			},
1153			mip_level_count: mip_count,
1154			sample_count: 1,
1155			dimension: wgpu::TextureDimension::D2,
1156			format: wgpu::TextureFormat::Rgba8UnormSrgb,
1157			usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1158			view_formats: &[],
1159		});
1160
1161		// upload base mip
1162		self.queue.write_texture(
1163			wgpu::TexelCopyTextureInfo {
1164				texture: &gpu_texture,
1165				mip_level: 0,
1166				origin: wgpu::Origin3d::ZERO,
1167				aspect: wgpu::TextureAspect::All,
1168			},
1169			&texture.pixels,
1170			wgpu::TexelCopyBufferLayout {
1171				offset: 0,
1172				bytes_per_row: Some(4 * texture.width),
1173				rows_per_image: Some(texture.height),
1174			},
1175			wgpu::Extent3d {
1176				width: texture.width,
1177				height: texture.height,
1178				depth_or_array_layers: 1,
1179			},
1180		);
1181		// upload additional mip levels when present
1182		let mut mip_w = texture.width;
1183		let mut mip_h = texture.height;
1184		for (i, mip_data) in texture.mips.iter().enumerate() {
1185			mip_w = (mip_w / 2).max(1);
1186			mip_h = (mip_h / 2).max(1);
1187			self.queue.write_texture(
1188				wgpu::TexelCopyTextureInfo {
1189					texture: &gpu_texture,
1190					mip_level: i as u32 + 1,
1191					origin: wgpu::Origin3d::ZERO,
1192					aspect: wgpu::TextureAspect::All,
1193				},
1194				mip_data,
1195				wgpu::TexelCopyBufferLayout {
1196					offset: 0,
1197					bytes_per_row: Some(4 * mip_w),
1198					rows_per_image: Some(mip_h),
1199				},
1200				wgpu::Extent3d {
1201					width: mip_w,
1202					height: mip_h,
1203					depth_or_array_layers: 1,
1204				},
1205			);
1206		}
1207
1208		let view = gpu_texture.create_view(&wgpu::TextureViewDescriptor::default());
1209		let tex_id = handle.id();
1210
1211		// create and cache material bind group for this texture (texture + sampler only)
1212		let material_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1213			label: Some("[material] texture bg"),
1214			layout: &self.material_bgl,
1215			entries: &[
1216				wgpu::BindGroupEntry {
1217					binding: 0,
1218					resource: wgpu::BindingResource::TextureView(&view),
1219				},
1220				wgpu::BindGroupEntry {
1221					binding: 1,
1222					resource: wgpu::BindingResource::Sampler(&self.sampler),
1223				},
1224			],
1225		});
1226		self.material_bgs.insert(tex_id, material_bg);
1227
1228		self.textures.insert(
1229			tex_id,
1230			GpuTexture {
1231				texture: gpu_texture,
1232				view,
1233			},
1234		);
1235	}
1236
1237	/// upload the glyph atlas to the GPU as a texture.
1238	#[allow(dead_code)]
1239	fn upload_glyph_atlas(&mut self) {
1240		let atlas = &self.glyph_atlas;
1241		let gpu_texture = self.device.create_texture(&wgpu::TextureDescriptor {
1242			label: Some("glyph atlas texture"),
1243			size: wgpu::Extent3d {
1244				width: atlas.width,
1245				height: atlas.height,
1246				depth_or_array_layers: 1,
1247			},
1248			mip_level_count: 1,
1249			sample_count: 1,
1250			dimension: wgpu::TextureDimension::D2,
1251			format: wgpu::TextureFormat::Rgba8UnormSrgb,
1252			usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1253			view_formats: &[],
1254		});
1255
1256		self.queue.write_texture(
1257			wgpu::TexelCopyTextureInfo {
1258				texture: &gpu_texture,
1259				mip_level: 0,
1260				origin: wgpu::Origin3d::ZERO,
1261				aspect: wgpu::TextureAspect::All,
1262			},
1263			atlas.pixels(),
1264			wgpu::TexelCopyBufferLayout {
1265				offset: 0,
1266				bytes_per_row: Some(4 * atlas.width),
1267				rows_per_image: Some(atlas.height),
1268			},
1269			wgpu::Extent3d {
1270				width: atlas.width,
1271				height: atlas.height,
1272				depth_or_array_layers: 1,
1273			},
1274		);
1275
1276		let view = gpu_texture.create_view(&wgpu::TextureViewDescriptor::default());
1277		self.glyph_atlas_texture = Some(GpuTexture {
1278			texture: gpu_texture,
1279			view,
1280		});
1281	}
1282
1283	/// current surface size
1284	pub fn surface_size(&self) -> (u32, u32) {
1285		(self.config.width, self.config.height)
1286	}
1287
1288	/// recreate the persistent vertex buffers at the current `vertex_capacity`.
1289	/// called when the previous frame overflowed; doubles the capacity first.
1290	fn grow_vertex_buffers(&mut self) {
1291		let new_capacity = self.vertex_capacity.saturating_mul(2);
1292		log::warn!(
1293			"render: vertex buffer overflow detected; growing capacity {} → {} vertices",
1294			self.vertex_capacity,
1295			new_capacity
1296		);
1297		self.vertex_capacity = new_capacity;
1298		self.vertex_bufs = std::array::from_fn(|i| {
1299			self.device.create_buffer(&wgpu::BufferDescriptor {
1300				label: Some(&format!("persistent vertex buffer {i}")),
1301				size: (self.vertex_capacity * VERTEX_STRIDE) as u64,
1302				usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1303				mapped_at_creation: false,
1304			})
1305		});
1306	}
1307
1308	/// render all draw commands for this frame.
1309	/// sprites are batched by texture — one draw call per unique texture.
1310	/// rects (no texture) are drawn in a single additional draw call.
1311	#[allow(clippy::too_many_lines)]
1312	pub fn render(
1313		&mut self,
1314		commands: &[DrawCommand],
1315		camera: Option<&Camera>,
1316		render_info: &mut RenderInfo,
1317	) {
1318		// if last frame overflowed, double the buffers before rendering this one.
1319		if self.overflow_flag {
1320			self.grow_vertex_buffers();
1321			self.overflow_flag = false;
1322		}
1323
1324		// per-frame stats — written to RenderInfo before returning so the
1325		// debug overlay (and any game HUD that reads RenderInfo) shows real
1326		// values instead of the zero-defaults.
1327		let mut sprite_count: u32 = 0;
1328		let mut draw_calls: u32 = 0;
1329
1330		// check if camera is targeting an offscreen render target
1331		let rt_tex_id = camera.and_then(|c| c.target).map(|rt| rt.0);
1332
1333		// acquire swapchain frame only when rendering to window (not offscreen)
1334		let surface_frame = if rt_tex_id.is_none() {
1335			match self.surface.get_current_texture() {
1336				wgpu::CurrentSurfaceTexture::Success(f)
1337				| wgpu::CurrentSurfaceTexture::Suboptimal(f) => Some(f),
1338				_ => return,
1339			}
1340		} else {
1341			None
1342		};
1343
1344		// get the output view — owned. wgpu::TextureView is a cheap Arc handle, so
1345		// cloning the cached render-target view sidesteps the borrow checker without
1346		// any unsafe pointer juggling.
1347		let view: wgpu::TextureView = if let Some(id) = rt_tex_id {
1348			let Some(rt_view) = self.render_target_views.get(&id) else {
1349				return;
1350			};
1351			rt_view.clone()
1352		} else {
1353			surface_frame
1354				.as_ref()
1355				.unwrap()
1356				.texture
1357				.create_view(&wgpu::TextureViewDescriptor::default())
1358		};
1359
1360		// update atlas rasterization scale from the viewport so glyphs are sharp
1361		// even when the game viewport is letterboxed into a larger window
1362		let text_scale = if let Some(cam) = camera
1363			&& let Some((vp_w, vp_h)) = cam.viewport
1364		{
1365			let sx = self.config.width as f32 / vp_w as f32;
1366			let sy = self.config.height as f32 / vp_h as f32;
1367			sx.min(sy)
1368		} else {
1369			1.0
1370		};
1371		if self.glyph_atlas.set_scale(text_scale) {
1372			// UV coords changed — cached quads reference stale positions in the atlas
1373			self.text_layout_cache.clear();
1374		}
1375
1376		// pre-compute text layouts (fills atlas as a side effect).
1377		// cache checks: if content+style unchanged since last shape, reuse origin-relative quads
1378		// and apply position, skipping the cosmic-text shaping pipeline entirely.
1379		// entry API reuses Vec allocations across frames; retain removes stale keys.
1380		let cmd_count = commands.len();
1381		for (i, cmd) in commands.iter().enumerate() {
1382			let DrawKind::Text {
1383				font,
1384				content,
1385				position,
1386				font_size,
1387				wrap_width,
1388				line_height,
1389				..
1390			} = &cmd.kind
1391			else {
1392				continue;
1393			};
1394			let font_id = u32::try_from(font.unwrap_or(0)).unwrap_or(u32::MAX);
1395			let slot = self.text_quads.entry(i).or_default();
1396
1397			if let Some(cached) =
1398				self.text_layout_cache
1399					.get(font_id, content, *font_size, *wrap_width)
1400			{
1401				// cache hit: apply world position to origin-relative quads
1402				slot.clear();
1403				slot.extend(cached.iter().map(|q| text::TextGlyphQuad {
1404					position: Vec2::new(q.position.x + position.x, q.position.y + position.y),
1405					size: q.size,
1406					uv_min: q.uv_min,
1407					uv_max: q.uv_max,
1408				}));
1409			} else {
1410				// cache miss: shape at origin (Vec2::ZERO), cache the result, apply position
1411				let mut origin_quads: Vec<text::TextGlyphQuad> = Vec::new();
1412				if let Some(max_w) = wrap_width {
1413					text::layout_text_wrapped_into(
1414						&mut self.glyph_atlas,
1415						font_id,
1416						content,
1417						*font_size,
1418						Vec2::ZERO,
1419						*max_w,
1420						*line_height,
1421						&mut origin_quads,
1422					);
1423				} else {
1424					text::layout_text_into(
1425						&mut self.glyph_atlas,
1426						font_id,
1427						content,
1428						*font_size,
1429						Vec2::ZERO,
1430						&mut origin_quads,
1431					);
1432				}
1433				self.text_layout_cache.insert(
1434					font_id,
1435					content,
1436					*font_size,
1437					*wrap_width,
1438					origin_quads.clone(),
1439				);
1440				slot.clear();
1441				slot.extend(origin_quads.into_iter().map(|q| text::TextGlyphQuad {
1442					position: Vec2::new(q.position.x + position.x, q.position.y + position.y),
1443					size: q.size,
1444					uv_min: q.uv_min,
1445					uv_max: q.uv_max,
1446				}));
1447			}
1448		}
1449		self.text_quads.retain(|k, _| *k < cmd_count);
1450		if std::mem::take(&mut self.glyph_atlas.dirty) {
1451			self.upload_glyph_atlas();
1452			if let Some(atlas) = &self.glyph_atlas_texture {
1453				let material_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1454					label: Some("[material] glyph atlas bg"),
1455					layout: &self.material_bgl,
1456					entries: &[
1457						wgpu::BindGroupEntry {
1458							binding: 0,
1459							resource: wgpu::BindingResource::TextureView(&atlas.view),
1460						},
1461						wgpu::BindGroupEntry {
1462							binding: 1,
1463							resource: wgpu::BindingResource::Sampler(&self.sampler),
1464						},
1465					],
1466				});
1467				self.material_bgs.insert(GLYPH_ATLAS_BIND_ID, material_bg);
1468			}
1469		}
1470
1471		// track current layer for parallax — updated as we iterate sorted commands
1472		let mut current_layer: Option<i32> = None;
1473
1474		// sort by (layer, texture_id) — same-texture commands are contiguous, no HashMap needed.
1475		// reuse the persistent Vec — clear() retains capacity from previous frames.
1476		self.sorted_indices.clear();
1477		self.sorted_indices.extend(0..commands.len());
1478		self.sorted_indices.sort_unstable_by_key(|&i| {
1479			let cmd = &commands[i];
1480			let layer = match &cmd.kind {
1481				DrawKind::Sprite { layer, .. }
1482				| DrawKind::Rect { layer, .. }
1483				| DrawKind::Line { layer, .. }
1484				| DrawKind::Text { layer, .. } => *layer,
1485			};
1486			let secondary: i64 = match &cmd.kind {
1487				DrawKind::Sprite {
1488					sort_key: Some(k), ..
1489				} => i64::from(*k),
1490				DrawKind::Sprite {
1491					texture: Some(id), ..
1492				} => i64::try_from(*id).unwrap_or(i64::MAX),
1493				DrawKind::Text { .. } => i64::from(GLYPH_ATLAS_BIND_ID),
1494				_ => i64::MAX,
1495			};
1496			(layer, secondary)
1497		});
1498
1499		let mut encoder = self
1500			.device
1501			.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1502				label: Some("render encoder"),
1503			});
1504
1505		{
1506			let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1507				label: Some("render pass"),
1508				color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1509					view: &view,
1510					resolve_target: None,
1511					ops: wgpu::Operations {
1512						load: wgpu::LoadOp::Clear(wgpu::Color {
1513							r: 0.07,
1514							g: 0.07,
1515							b: 0.07,
1516							a: 1.0,
1517						}),
1518						store: wgpu::StoreOp::Store,
1519					},
1520					depth_slice: None,
1521				})],
1522				depth_stencil_attachment: None,
1523				timestamp_writes: None,
1524				occlusion_query_set: None,
1525				multiview_mask: None,
1526			});
1527
1528			pass.set_pipeline(&self.sprite_pipeline);
1529
1530			// advance frame index for double-buffering
1531			self.frame_index = (self.frame_index + 1) % VERTEX_BUFFER_COUNT;
1532			// reset persistent vertex buffer offset for this frame
1533			self.vertex_offset = 0;
1534
1535			// single pass in sorted order — sprites and rects interleave correctly by layer.
1536			// sort gives (layer, tex_id) where rects use u32::MAX, so within a layer sprites
1537			// always precede rects, and lower layers are fully drawn before higher ones.
1538			let mut current_tex: Option<u32> = None;
1539			let mut batch_start = 0;
1540
1541			for i in 0..self.sorted_indices.len() {
1542				let orig_idx = self.sorted_indices[i];
1543				let command = &commands[orig_idx];
1544				let layer = match &command.kind {
1545					DrawKind::Sprite { layer, .. }
1546					| DrawKind::Rect { layer, .. }
1547					| DrawKind::Line { layer, .. }
1548					| DrawKind::Text { layer, .. } => *layer,
1549				};
1550
1551				let tex_id = match &command.kind {
1552					DrawKind::Sprite {
1553						texture: Some(id), ..
1554					} => u32::try_from(*id).unwrap_or(u32::MAX),
1555					DrawKind::Text { .. } => GLYPH_ATLAS_BIND_ID,
1556					_ => u32::MAX,
1557				};
1558
1559				// update projection if layer changed (parallax)
1560				if current_layer != Some(layer) {
1561					if self.vertex_offset > batch_start
1562						&& let Some(prev_tex) = current_tex
1563					{
1564						let vertex_count = (self.vertex_offset - batch_start) / VERTEX_STRIDE;
1565						self.draw_vertex_batch(&mut pass, prev_tex, batch_start, vertex_count);
1566						draw_calls += 1;
1567					}
1568					batch_start = self.vertex_offset;
1569					self.update_projection_for_layer(layer, camera);
1570					current_layer = Some(layer);
1571				}
1572
1573				// flush and switch bind group when tex changes
1574				if current_tex != Some(tex_id) {
1575					if self.vertex_offset > batch_start
1576						&& let Some(prev_tex) = current_tex
1577					{
1578						let vertex_count = (self.vertex_offset - batch_start) / VERTEX_STRIDE;
1579						self.draw_vertex_batch(&mut pass, prev_tex, batch_start, vertex_count);
1580						draw_calls += 1;
1581					}
1582					batch_start = self.vertex_offset;
1583					current_tex = Some(tex_id);
1584				}
1585
1586				if self.vertex_offset + 6 * VERTEX_STRIDE > self.vertex_capacity * VERTEX_STRIDE {
1587					self.overflow_flag = true;
1588					continue;
1589				}
1590
1591				match &command.kind {
1592					DrawKind::Sprite {
1593						texture: Some(_),
1594						position,
1595						rotation,
1596						scale,
1597						tint,
1598						uv_rect,
1599						origin,
1600						..
1601					} => {
1602						self.write_sprite_vertices(&SpriteDrawParams {
1603							position: *position,
1604							rotation: *rotation,
1605							scale: *scale,
1606							tint: *tint,
1607							uv_rect: *uv_rect,
1608							origin: *origin,
1609						});
1610						sprite_count += 1;
1611					}
1612					DrawKind::Sprite { texture: None, .. } => {}
1613					DrawKind::Rect {
1614						position,
1615						size,
1616						color,
1617						..
1618					} => {
1619						self.write_rect_vertices(*position, *size, *color);
1620					}
1621					DrawKind::Line {
1622						start,
1623						end,
1624						color,
1625						thickness,
1626						..
1627					} => {
1628						self.write_line_vertices(*start, *end, *color, *thickness);
1629					}
1630					DrawKind::Text { color, .. } => {
1631						let color = *color;
1632						let count = self.text_quads.get(&orig_idx).map(|q| q.len()).unwrap_or(0);
1633						for qi in 0..count {
1634							let quad = self.text_quads[&orig_idx][qi]; // Copy
1635							self.write_text_quad(&quad, color);
1636						}
1637					}
1638				}
1639			}
1640
1641			// flush final batch
1642			if self.vertex_offset > batch_start
1643				&& let Some(tex_id) = current_tex
1644			{
1645				let vertex_count = (self.vertex_offset - batch_start) / VERTEX_STRIDE;
1646				self.draw_vertex_batch(&mut pass, tex_id, batch_start, vertex_count);
1647				draw_calls += 1;
1648			}
1649		}
1650
1651		// execute custom render passes
1652		for pass in &self.render_passes {
1653			let mut custom_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1654				label: Some(pass.name()),
1655				color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1656					view: &view,
1657					resolve_target: None,
1658					ops: wgpu::Operations {
1659						load: wgpu::LoadOp::Load,
1660						store: wgpu::StoreOp::Store,
1661					},
1662					depth_slice: None,
1663				})],
1664				depth_stencil_attachment: None,
1665				timestamp_writes: None,
1666				occlusion_query_set: None,
1667				multiview_mask: None,
1668			});
1669			pass.execute(&self.device, &self.queue, &mut custom_pass);
1670		}
1671
1672		self.queue.submit(Some(encoder.finish()));
1673		if let Some(frame) = surface_frame {
1674			frame.present();
1675		}
1676
1677		render_info.window_width = self.config.width;
1678		render_info.window_height = self.config.height;
1679		render_info.sprite_count = sprite_count;
1680		render_info.draw_calls = draw_calls;
1681	}
1682
1683	/// draw a batch of vertices from the persistent vertex buffer.
1684	fn draw_vertex_batch(
1685		&self,
1686		pass: &mut wgpu::RenderPass<'_>,
1687		tex_id: u32,
1688		offset: usize,
1689		vertex_count: usize,
1690	) {
1691		let Some(material_bg) = self.material_bgs.get(&tex_id) else {
1692			return;
1693		};
1694		pass.set_bind_group(0, &self.globals_bg, &[]);
1695		pass.set_bind_group(1, material_bg, &[]);
1696		let buf = &self.vertex_bufs[self.frame_index];
1697		pass.set_vertex_buffer(
1698			0,
1699			buf.slice(offset as u64..(offset + vertex_count * VERTEX_STRIDE) as u64),
1700		);
1701		pass.draw(0..u32::try_from(vertex_count).unwrap_or(0), 0..1);
1702	}
1703
1704	/// write a sprite's 6 vertices into the persistent vertex buffer.
1705	/// vertex format: [pos.x, pos.y, u, v] (f32) + [packed rgba] (u32) = 20 bytes
1706	/// origin is the pivot point for rotation/scaling, relative to the sprite's top-left.
1707	fn write_sprite_vertices(&mut self, params: &SpriteDrawParams) {
1708		let &SpriteDrawParams {
1709			position,
1710			rotation,
1711			scale,
1712			tint,
1713			uv_rect,
1714			origin,
1715		} = params;
1716		let cos = rotation.cos();
1717		let sin = rotation.sin();
1718
1719		// corners relative to origin (not center)
1720		let corners = [
1721			[-origin.x, -origin.y],
1722			[scale.x - origin.x, -origin.y],
1723			[-origin.x, scale.y - origin.y],
1724			[-origin.x, scale.y - origin.y],
1725			[scale.x - origin.x, -origin.y],
1726			[scale.x - origin.x, scale.y - origin.y],
1727		];
1728
1729		let (uv_min, uv_max) = uv_rect.unwrap_or((Vec2::ZERO, Vec2::new(1.0, 1.0)));
1730		let uvs = [
1731			[uv_min.x, uv_min.y],
1732			[uv_max.x, uv_min.y],
1733			[uv_min.x, uv_max.y],
1734			[uv_min.x, uv_max.y],
1735			[uv_max.x, uv_min.y],
1736			[uv_max.x, uv_max.y],
1737		];
1738
1739		let packed_color = pack_color(tint);
1740		// 6 vertices * 5 components (4 f32 + 1 u32) = 30 elements
1741		let mut verts: [u32; 30] = [0; 30];
1742		for (i, [lx, ly]) in corners.iter().enumerate() {
1743			let rx = lx * cos - ly * sin;
1744			let ry = lx * sin + ly * cos;
1745			let px = position.x + rx;
1746			let py = position.y + ry;
1747			let [u, v] = uvs[i];
1748			let base = i * 5;
1749			verts[base] = f32_to_u32(px);
1750			verts[base + 1] = f32_to_u32(py);
1751			verts[base + 2] = f32_to_u32(u);
1752			verts[base + 3] = f32_to_u32(v);
1753			verts[base + 4] = packed_color;
1754		}
1755
1756		let bytes = bytemuck::cast_slice(&verts);
1757		let buf = &self.vertex_bufs[self.frame_index];
1758		self.queue
1759			.write_buffer(buf, self.vertex_offset as u64, bytes);
1760		self.vertex_offset += 6 * VERTEX_STRIDE;
1761	}
1762
1763	/// write a rect's 6 vertices into the persistent vertex buffer.
1764	/// vertex format: [pos.x, pos.y, u, v] (f32) + [packed rgba] (u32) = 20 bytes
1765	fn write_rect_vertices(&mut self, position: Vec2, size: Vec2, color: Color) {
1766		let (x, y, w, h) = (position.x, position.y, size.x, size.y);
1767		let packed_color = pack_color(color);
1768		// 6 vertices * 5 components = 30 u32s
1769		let mut verts: [u32; 30] = [0; 30];
1770		let positions = [
1771			(x, y),
1772			(x + w, y),
1773			(x, y + h),
1774			(x, y + h),
1775			(x + w, y),
1776			(x + w, y + h),
1777		];
1778		for (i, (px, py)) in positions.iter().enumerate() {
1779			let base = i * 5;
1780			verts[base] = f32_to_u32(*px);
1781			verts[base + 1] = f32_to_u32(*py);
1782			verts[base + 2] = 0; // u
1783			verts[base + 3] = 0; // v
1784			verts[base + 4] = packed_color;
1785		}
1786		let bytes = bytemuck::cast_slice(&verts);
1787		let buf = &self.vertex_bufs[self.frame_index];
1788		self.queue
1789			.write_buffer(buf, self.vertex_offset as u64, bytes);
1790		self.vertex_offset += 6 * VERTEX_STRIDE;
1791	}
1792
1793	/// write a line's 6 vertices into the persistent vertex buffer.
1794	/// renders a rotated rectangle along the line segment.
1795	fn write_line_vertices(&mut self, start: Vec2, end: Vec2, color: Color, thickness: f32) {
1796		let dx = end.x - start.x;
1797		let dy = end.y - start.y;
1798		let len = dx.hypot(dy);
1799		if len < 0.001 {
1800			return;
1801		}
1802		// unit direction and perpendicular
1803		let nx = -dy / len;
1804		let ny = dx / len;
1805		let half_t = thickness * 0.5;
1806		// 4 corners of the line rectangle
1807		let corners = [
1808			(nx.mul_add(half_t, start.x), ny.mul_add(half_t, start.y)),
1809			(nx.mul_add(-half_t, start.x), ny.mul_add(-half_t, start.y)),
1810			(nx.mul_add(half_t, end.x), ny.mul_add(half_t, end.y)),
1811			(nx.mul_add(-half_t, end.x), ny.mul_add(-half_t, end.y)),
1812		];
1813		let packed_color = pack_color(color);
1814		// 6 vertices (2 triangles) * 5 components = 30 u32s
1815		let mut verts: [u32; 30] = [0; 30];
1816		let indices = [0, 1, 2, 2, 1, 3];
1817		for (i, &idx) in indices.iter().enumerate() {
1818			let base = i * 5;
1819			let (px, py) = corners[idx];
1820			verts[base] = f32_to_u32(px);
1821			verts[base + 1] = f32_to_u32(py);
1822			verts[base + 2] = 0; // u
1823			verts[base + 3] = 0; // v
1824			verts[base + 4] = packed_color;
1825		}
1826		let bytes = bytemuck::cast_slice(&verts);
1827		let buf = &self.vertex_bufs[self.frame_index];
1828		self.queue
1829			.write_buffer(buf, self.vertex_offset as u64, bytes);
1830		self.vertex_offset += 6 * VERTEX_STRIDE;
1831	}
1832
1833	/// write a text quad's 6 vertices into the persistent vertex buffer.
1834	/// vertex format: [pos.x, pos.y, u, v] (f32) + [packed rgba] (u32) = 20 bytes
1835	fn write_text_quad(&mut self, quad: &text::TextGlyphQuad, color: Color) {
1836		let x = quad.position.x;
1837		let y = quad.position.y;
1838		let w = quad.size.x;
1839		let h = quad.size.y;
1840		let u0 = quad.uv_min.x;
1841		let v0 = quad.uv_min.y;
1842		let u1 = quad.uv_max.x;
1843		let v1 = quad.uv_max.y;
1844		let packed_color = pack_color(color);
1845		// 6 vertices * 5 components = 30 u32s
1846		let mut verts: [u32; 30] = [0; 30];
1847		let positions_uvs = [
1848			(x, y, u0, v0),
1849			(x + w, y, u1, v0),
1850			(x, y + h, u0, v1),
1851			(x, y + h, u0, v1),
1852			(x + w, y, u1, v0),
1853			(x + w, y + h, u1, v1),
1854		];
1855		for (i, (px, py, u, v)) in positions_uvs.iter().enumerate() {
1856			let base = i * 5;
1857			verts[base] = f32_to_u32(*px);
1858			verts[base + 1] = f32_to_u32(*py);
1859			verts[base + 2] = f32_to_u32(*u);
1860			verts[base + 3] = f32_to_u32(*v);
1861			verts[base + 4] = packed_color;
1862		}
1863		let bytes = bytemuck::cast_slice(&verts);
1864		let buf = &self.vertex_bufs[self.frame_index];
1865		self.queue
1866			.write_buffer(buf, self.vertex_offset as u64, bytes);
1867		self.vertex_offset += 6 * VERTEX_STRIDE;
1868	}
1869}
1870
1871/// save the pipeline cache on shutdown so the next launch benefits from it.
1872#[cfg(not(target_arch = "wasm32"))]
1873impl Drop for RenderEngine {
1874	fn drop(&mut self) {
1875		self.save_pipeline_cache();
1876	}
1877}
1878
1879/// pack an rgba color into a single u32 (r in lowest byte, a in highest).
1880fn pack_color(color: Color) -> u32 {
1881	#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1882	let r = (color.r * 255.0).clamp(0.0, 255.0) as u32;
1883	#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1884	let g = (color.g * 255.0).clamp(0.0, 255.0) as u32;
1885	#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1886	let b = (color.b * 255.0).clamp(0.0, 255.0) as u32;
1887	#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1888	let a = (color.a * 255.0).clamp(0.0, 255.0) as u32;
1889	(a << 24) | (b << 16) | (g << 8) | r
1890}
1891
1892/// reinterpret an f32 as a u32 without conversion (for vertex buffer packing).
1893fn f32_to_u32(value: f32) -> u32 {
1894	bytemuck::cast(value)
1895}
1896
1897const SHADER_SOURCE: &str = r"
1898struct Uniforms { projection: mat4x4<f32> }
1899
1900struct VertexOut {
1901    @builtin(position) clip_position: vec4<f32>,
1902    @location(0) uv: vec2<f32>,
1903    @location(1) color: vec4<f32>,
1904}
1905
1906@group(0) @binding(0) var<uniform> uniforms: Uniforms;
1907@group(1) @binding(0) var sprite_texture: texture_2d<f32>;
1908@group(1) @binding(1) var sprite_sampler: sampler;
1909
1910@vertex
1911fn vs_main(
1912    @location(0) pos: vec2<f32>,
1913    @location(1) uv: vec2<f32>,
1914    @location(2) color: vec4<f32>,
1915) -> VertexOut {
1916    var out: VertexOut;
1917    out.clip_position = uniforms.projection * vec4<f32>(pos, 0.0, 1.0);
1918    out.uv = uv;
1919    out.color = color;
1920    return out;
1921}
1922
1923@fragment
1924fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
1925    let tex_color = textureSample(sprite_texture, sprite_sampler, in.uv);
1926    return tex_color * in.color;
1927}
1928";
1929
1930/// render queue resource, collects draw commands each frame.
1931///
1932/// game logic pushes draw commands into the queue during the update phase.
1933/// the render engine consumes the queue during the render phase.
1934///
1935/// # lifecycle
1936///
1937/// call [`RenderQueue::clear()`] at the start of each frame to remove
1938/// last frame's commands before adding new ones.
1939#[derive(Resource)]
1940pub struct RenderQueue {
1941	commands: Vec<DrawCommand>,
1942	/// optional render target (texture handle id) for off-screen rendering
1943	target: Option<u32>,
1944}
1945
1946/// internal — a single draw command produced by the engine's enqueue helpers.
1947///
1948/// hidden from the public API: game code uses the [`Sprite`] / [`Text`]
1949/// components or the immediate-mode helpers on [`RenderQueue`]
1950/// (`draw_sprite`, `draw_text`, `draw_rect`, `draw_line`). this type and its
1951/// `kind` field exist only so the engine can pass commands to the renderer.
1952#[doc(hidden)]
1953#[derive(Debug, Clone)]
1954pub struct DrawCommand {
1955	/// draw type
1956	pub kind: DrawKind,
1957}
1958
1959/// internal — primitive variant for a [`DrawCommand`].
1960///
1961/// hidden from the public API; see [`DrawCommand`].
1962#[doc(hidden)]
1963#[derive(Debug, Clone)]
1964pub enum DrawKind {
1965	/// draw a 2D sprite
1966	/// `uv_rect` overrides the default UV range \[0..1, 0..1\] (used for texture atlases).
1967	/// origin is the pivot point for rotation and scaling, relative to the sprite's top-left.
1968	Sprite {
1969		texture: Option<u64>,
1970		position: Vec2,
1971		rotation: f32,
1972		scale: Vec2,
1973		tint: Color,
1974		layer: i32,
1975		uv_rect: Option<(Vec2, Vec2)>,
1976		origin: Vec2,
1977		/// when set, overrides texture-based secondary sort with this key (y-sort uses world_y * 100)
1978		sort_key: Option<i32>,
1979	},
1980	/// draw a 2D rectangle
1981	Rect {
1982		position: Vec2,
1983		size: Vec2,
1984		color: Color,
1985		layer: i32,
1986	},
1987	/// draw a line between two points
1988	Line {
1989		start: Vec2,
1990		end: Vec2,
1991		color: Color,
1992		thickness: f32,
1993		layer: i32,
1994	},
1995	/// draw text
1996	Text {
1997		font: Option<u64>,
1998		content: Arc<str>,
1999		position: Vec2,
2000		font_size: f32,
2001		color: Color,
2002		layer: i32,
2003		/// if set, text wraps at this pixel width using word boundaries
2004		wrap_width: Option<f32>,
2005		/// line spacing when wrap_width is set; 0.0 = font_size * 1.25
2006		line_height: f32,
2007	},
2008}
2009
2010/// built-in layer constants for common rendering needs.
2011/// lower values are drawn first (behind), higher values are drawn last (in front).
2012pub mod layers {
2013	/// background layer — static backgrounds, parallax layers
2014	pub const BACKGROUND: i32 = 0;
2015	/// game layer — game objects, characters, projectiles
2016	pub const GAME: i32 = 100;
2017	/// foreground layer — effects, overlays, weather
2018	pub const FOREGROUND: i32 = 200;
2019	/// UI layer — HUD, menus, dialogue boxes
2020	pub const UI: i32 = 300;
2021	/// post-process layer — screen-space fullscreen overlays (flash, tint, fade).
2022	/// drawn after UI in screen space; ignores camera position and zoom.
2023	pub const POST_PROCESS: i32 = 1000;
2024}
2025
2026/// renderable 2D sprite component.
2027///
2028/// any entity carrying a [`Transform`] and a `Sprite`
2029/// is drawn automatically each frame. game code spawns the entity and the
2030/// engine's render system enqueues the draw — no manual `RenderQueue` calls.
2031///
2032/// # example
2033///
2034/// ```ignore
2035/// use lunar::prelude::*;
2036///
2037/// fn spawn_player(mut commands: Commands, mut assets: ResMut<AssetServer>) {
2038///     let texture = assets.load_texture("player.png");
2039///     commands.spawn((
2040///         Transform::from_xy(100.0, 100.0),
2041///         Sprite::new(texture).with_size(Vec2::new(32.0, 32.0)),
2042///     ));
2043/// }
2044/// ```
2045///
2046/// fields can be set directly or via the builder methods. when `size` is
2047/// `None`, the sprite renders at the texture's native pixel size if the
2048/// texture is loaded; otherwise a 32×32 placeholder is used.
2049#[derive(Debug, Clone, Component)]
2050pub struct Sprite {
2051	/// texture to draw
2052	pub texture: Handle<Texture>,
2053	/// rendered size in pixels. `None` = use the texture's native size.
2054	/// the entity's `Transform::scale` is applied on top of this.
2055	pub size: Option<Vec2>,
2056	/// color tint multiplied with the texture (RGBA). default white = no tint.
2057	pub color: Color,
2058	/// optional UV sub-rect for atlas sampling: `(uv_min, uv_max)` in 0..1 space.
2059	pub source_rect: Option<(Vec2, Vec2)>,
2060	/// pivot for rotation/scale, in pixels relative to the sprite's top-left.
2061	/// `None` = sprite center (size / 2).
2062	pub origin: Option<Vec2>,
2063	/// render layer (lower = behind, higher = in front). see [`layers`].
2064	pub layer: i32,
2065}
2066
2067impl Sprite {
2068	/// create a sprite with default settings (white tint, native size, centered, GAME layer)
2069	#[must_use]
2070	pub const fn new(texture: Handle<Texture>) -> Self {
2071		Self {
2072			texture,
2073			size: None,
2074			color: Color::WHITE,
2075			source_rect: None,
2076			origin: None,
2077			layer: layers::GAME,
2078		}
2079	}
2080
2081	/// set explicit pixel size (overrides texture's native size)
2082	#[must_use]
2083	pub const fn with_size(mut self, size: Vec2) -> Self {
2084		self.size = Some(size);
2085		self
2086	}
2087
2088	/// set color tint
2089	#[must_use]
2090	pub const fn with_color(mut self, color: Color) -> Self {
2091		self.color = color;
2092		self
2093	}
2094
2095	/// set the render layer
2096	#[must_use]
2097	pub const fn with_layer(mut self, layer: i32) -> Self {
2098		self.layer = layer;
2099		self
2100	}
2101
2102	/// set the UV sub-rect for atlas sampling
2103	#[must_use]
2104	pub const fn with_source_rect(mut self, uv_min: Vec2, uv_max: Vec2) -> Self {
2105		self.source_rect = Some((uv_min, uv_max));
2106		self
2107	}
2108
2109	/// set the origin (pivot point) in pixels relative to top-left
2110	#[must_use]
2111	pub const fn with_origin(mut self, origin: Vec2) -> Self {
2112		self.origin = Some(origin);
2113		self
2114	}
2115}
2116
2117/// renderable text component.
2118///
2119/// any entity carrying a [`Transform`] and a `Text`
2120/// is drawn automatically each frame. position comes from `Transform.translation`.
2121///
2122/// # example
2123///
2124/// ```ignore
2125/// use lunar::prelude::*;
2126///
2127/// fn spawn_label(mut commands: Commands, mut assets: ResMut<AssetServer>) {
2128///     let font = assets.load_font("ui.ttf");
2129///     commands.spawn((
2130///         Transform::from_xy(10.0, 10.0),
2131///         Text::new("Score: 0", font).with_size(20.0),
2132///     ));
2133/// }
2134/// ```
2135#[derive(Debug, Clone, Component)]
2136pub struct Text {
2137	/// text content. use `Arc::from("hello")` or `Arc::from(string)` to set.
2138	pub content: Arc<str>,
2139	/// font to render with
2140	pub font: Handle<Font>,
2141	/// font size in pixels
2142	pub font_size: f32,
2143	/// text color (RGBA)
2144	pub color: Color,
2145	/// render layer
2146	pub layer: i32,
2147}
2148
2149impl Text {
2150	/// create a text component with default settings (16px white, UI layer)
2151	#[must_use]
2152	pub fn new(content: impl Into<Arc<str>>, font: Handle<Font>) -> Self {
2153		Self {
2154			content: content.into(),
2155			font,
2156			font_size: 16.0,
2157			color: Color::WHITE,
2158			layer: layers::UI,
2159		}
2160	}
2161
2162	/// set font size in pixels
2163	#[must_use]
2164	pub const fn with_size(mut self, font_size: f32) -> Self {
2165		self.font_size = font_size;
2166		self
2167	}
2168
2169	/// set text color
2170	#[must_use]
2171	pub const fn with_color(mut self, color: Color) -> Self {
2172		self.color = color;
2173		self
2174	}
2175
2176	/// set the render layer
2177	#[must_use]
2178	pub const fn with_layer(mut self, layer: i32) -> Self {
2179		self.layer = layer;
2180		self
2181	}
2182}
2183
2184impl RenderQueue {
2185	/// create a new empty render queue
2186	#[must_use]
2187	pub fn new() -> Self {
2188		Self {
2189			commands: Vec::with_capacity(1024),
2190			target: None,
2191		}
2192	}
2193
2194	/// clear all pending draw commands
2195	pub fn clear(&mut self) {
2196		self.commands.clear();
2197		self.target = None;
2198	}
2199
2200	/// set the render target for subsequent draw commands.
2201	/// pass None to render to the main surface.
2202	pub const fn set_target(&mut self, target: Option<u32>) {
2203		self.target = target;
2204	}
2205
2206	/// get the current render target
2207	#[must_use]
2208	pub const fn target(&self) -> Option<u32> {
2209		self.target
2210	}
2211
2212	/// internal — enqueue a raw draw command. game code should prefer the
2213	/// [`Sprite`] / [`Text`] components or the `draw_*` helpers below.
2214	#[doc(hidden)]
2215	pub fn push(&mut self, command: DrawCommand) {
2216		self.commands.push(command);
2217	}
2218
2219	/// internal — drain target for the renderer.
2220	#[doc(hidden)]
2221	#[must_use]
2222	pub fn commands(&self) -> &[DrawCommand] {
2223		&self.commands
2224	}
2225
2226	/// draw a sprite at the given position and size using a texture handle
2227	pub fn draw_sprite(&mut self, texture: &Handle<Texture>, position: Vec2, size: Vec2) {
2228		self.draw_sprite_on_layer(texture, position, size, layers::GAME);
2229	}
2230
2231	/// draw a sprite on a specific layer
2232	pub fn draw_sprite_on_layer(
2233		&mut self,
2234		texture: &Handle<Texture>,
2235		position: Vec2,
2236		size: Vec2,
2237		layer: i32,
2238	) {
2239		self.push(DrawCommand {
2240			kind: DrawKind::Sprite {
2241				texture: Some(u64::from(texture.id())),
2242				position,
2243				rotation: 0.0,
2244				scale: size,
2245				tint: Color::WHITE,
2246				layer,
2247				uv_rect: None,
2248				origin: Vec2::new(size.x * 0.5, size.y * 0.5),
2249				sort_key: None,
2250			},
2251		});
2252	}
2253
2254	/// draw a sprite from a texture atlas by region name.
2255	/// the `uv_rect` is automatically set from the atlas region's UV coordinates.
2256	pub fn draw_sprite_atlas(
2257		&mut self,
2258		texture: &Handle<Texture>,
2259		position: Vec2,
2260		size: Vec2,
2261		region: (Vec2, Vec2),
2262	) {
2263		self.draw_sprite_atlas_on_layer(texture, position, size, region, layers::GAME);
2264	}
2265
2266	/// draw a sprite from a texture atlas on a specific layer.
2267	pub fn draw_sprite_atlas_on_layer(
2268		&mut self,
2269		texture: &Handle<Texture>,
2270		position: Vec2,
2271		size: Vec2,
2272		region: (Vec2, Vec2),
2273		layer: i32,
2274	) {
2275		self.push(DrawCommand {
2276			kind: DrawKind::Sprite {
2277				texture: Some(u64::from(texture.id())),
2278				position,
2279				rotation: 0.0,
2280				scale: size,
2281				tint: Color::WHITE,
2282				layer,
2283				uv_rect: Some(region),
2284				origin: Vec2::new(size.x * 0.5, size.y * 0.5),
2285				sort_key: None,
2286			},
2287		});
2288	}
2289
2290	/// draw a sprite with full transform control using a texture handle
2291	pub fn draw_sprite_transformed(&mut self, texture: &Handle<Texture>, params: SpriteParams) {
2292		self.draw_sprite_transformed_on_layer(texture, params, layers::GAME);
2293	}
2294
2295	/// draw a sprite with full transform control on a specific layer
2296	pub fn draw_sprite_transformed_on_layer(
2297		&mut self,
2298		texture: &Handle<Texture>,
2299		params: SpriteParams,
2300		layer: i32,
2301	) {
2302		self.push(DrawCommand {
2303			kind: DrawKind::Sprite {
2304				texture: Some(u64::from(texture.id())),
2305				position: params.position,
2306				rotation: params.rotation,
2307				scale: params.scale,
2308				tint: params.tint,
2309				layer,
2310				uv_rect: None,
2311				origin: params.origin,
2312				sort_key: None,
2313			},
2314		});
2315	}
2316
2317	/// draw a colored rectangle
2318	pub fn draw_rect(&mut self, position: Vec2, size: Vec2, color: Color) {
2319		self.draw_rect_on_layer(position, size, color, layers::GAME);
2320	}
2321
2322	/// draw a colored rectangle on a specific layer
2323	pub fn draw_rect_on_layer(&mut self, position: Vec2, size: Vec2, color: Color, layer: i32) {
2324		self.push(DrawCommand {
2325			kind: DrawKind::Rect {
2326				position,
2327				size,
2328				color,
2329				layer,
2330			},
2331		});
2332	}
2333
2334	/// draw a colored rectangle in screen space (post-process layer).
2335	/// coordinates are in pixels: top-left is (0, 0), bottom-right is (window_w, window_h).
2336	/// use this from [`PostEffect::apply`] implementations.
2337	pub fn draw_screen_rect(&mut self, position: Vec2, size: Vec2, color: Color) {
2338		self.draw_rect_on_layer(position, size, color, layers::POST_PROCESS);
2339	}
2340
2341	/// draw a line between two points with the given thickness.
2342	/// uses a proper rotated rectangle, not an AABB approximation.
2343	pub fn draw_line(&mut self, start: Vec2, end: Vec2, color: Color, thickness: f32) {
2344		self.draw_line_on_layer(start, end, color, thickness, layers::GAME);
2345	}
2346
2347	/// draw a line on a specific layer
2348	pub fn draw_line_on_layer(
2349		&mut self,
2350		start: Vec2,
2351		end: Vec2,
2352		color: Color,
2353		thickness: f32,
2354		layer: i32,
2355	) {
2356		self.push(DrawCommand {
2357			kind: DrawKind::Line {
2358				start,
2359				end,
2360				color,
2361				thickness,
2362				layer,
2363			},
2364		});
2365	}
2366
2367	/// clear the screen with the given color.
2368	/// this is a convenience that draws a full-screen rect — the render engine's
2369	/// default clear color is not affected.
2370	pub fn clear_color(&mut self, color: Color) {
2371		self.push(DrawCommand {
2372			kind: DrawKind::Rect {
2373				position: Vec2::ZERO,
2374				size: Vec2::new(10000.0, 10000.0),
2375				color,
2376				layer: layers::BACKGROUND,
2377			},
2378		});
2379	}
2380
2381	/// draw text at the given position using the specified font handle
2382	pub fn draw_text(
2383		&mut self,
2384		font: &Handle<Font>,
2385		content: &str,
2386		position: Vec2,
2387		font_size: f32,
2388		color: Color,
2389	) {
2390		self.draw_text_on_layer(font, content, position, font_size, color, layers::GAME);
2391	}
2392
2393	/// draw text on a specific layer
2394	pub fn draw_text_on_layer(
2395		&mut self,
2396		font: &Handle<Font>,
2397		content: &str,
2398		position: Vec2,
2399		font_size: f32,
2400		color: Color,
2401		layer: i32,
2402	) {
2403		self.push(DrawCommand {
2404			kind: DrawKind::Text {
2405				font: Some(u64::from(font.id())),
2406				content: Arc::from(content),
2407				position,
2408				font_size,
2409				color,
2410				layer,
2411				wrap_width: None,
2412				line_height: 0.0,
2413			},
2414		});
2415	}
2416
2417	/// draw text in screen-space coordinates (for UI).
2418	/// internally converts through the camera to world-space.
2419	/// the position is relative to the viewport top-left, y-down.
2420	#[allow(clippy::too_many_arguments)]
2421	pub fn draw_ui_text(
2422		&mut self,
2423		font: &Handle<Font>,
2424		text: &str,
2425		screen_pos: Vec2,
2426		font_size: f32,
2427		color: Color,
2428		camera: &Camera,
2429		window_width: u32,
2430		window_height: u32,
2431	) {
2432		let world = camera.screen_to_world(screen_pos, window_width, window_height);
2433		self.draw_text_on_layer(font, text, world, font_size, color, layers::UI);
2434	}
2435
2436	/// draw a colored rectangle in screen-space coordinates (for UI).
2437	/// internally converts through the camera to world-space.
2438	pub fn draw_ui_rect(
2439		&mut self,
2440		screen_pos: Vec2,
2441		size: Vec2,
2442		color: Color,
2443		camera: &Camera,
2444		window_width: u32,
2445		window_height: u32,
2446	) {
2447		let world = camera.screen_to_world(screen_pos, window_width, window_height);
2448		self.draw_rect_on_layer(world, size, color, layers::UI);
2449	}
2450
2451	/// draw word-wrapped text on the given layer.
2452	/// `max_width` is the pixel width at which lines break.
2453	/// `line_height` is the vertical spacing per line; 0.0 = font_size * 1.25.
2454	#[allow(clippy::too_many_arguments)]
2455	pub fn draw_text_wrapped(
2456		&mut self,
2457		font: &Handle<Font>,
2458		content: &str,
2459		position: Vec2,
2460		font_size: f32,
2461		color: Color,
2462		max_width: f32,
2463		line_height: f32,
2464		layer: i32,
2465	) {
2466		self.push(DrawCommand {
2467			kind: DrawKind::Text {
2468				font: Some(u64::from(font.id())),
2469				content: Arc::from(content),
2470				position,
2471				font_size,
2472				color,
2473				layer,
2474				wrap_width: Some(max_width),
2475				line_height,
2476			},
2477		});
2478	}
2479
2480	/// draw a sprite in screen-space coordinates (for UI).
2481	/// the position is the top-left corner in screen pixels, y-down.
2482	#[allow(clippy::too_many_arguments)]
2483	pub fn draw_ui_sprite(
2484		&mut self,
2485		texture: &Handle<Texture>,
2486		screen_pos: Vec2,
2487		size: Vec2,
2488		camera: &Camera,
2489		window_width: u32,
2490		window_height: u32,
2491	) {
2492		let world = camera.screen_to_world(screen_pos, window_width, window_height);
2493		self.draw_sprite_on_layer(texture, world, size, layers::UI);
2494	}
2495
2496	/// immediate mode drawing API for debug visualization and quick prototyping.
2497	///
2498	/// the closure receives a [`DrawContext`] with convenience methods for
2499	/// drawing lines, circles, rects, and text without managing draw commands manually.
2500	///
2501	/// # example
2502	///
2503	/// ```ignore
2504	/// queue.draw_immediate(|draw| {
2505	///     draw.line(Vec2::new(0.0, 0.0), Vec2::new(100.0, 100.0), Color::RED, 2.0);
2506	///     draw.circle(Vec2::new(50.0, 50.0), 20.0, Color::GREEN, 2.0);
2507	///     draw.rect(Vec2::new(10.0, 10.0), Vec2::new(40.0, 40.0), Color::BLUE);
2508	///     draw.text("debug info", Vec2::new(0.0, 0.0), 16.0, Color::WHITE);
2509	/// });
2510	/// ```
2511	pub fn draw_immediate(&mut self, f: impl FnOnce(&mut DrawContext<'_>)) {
2512		let mut ctx = DrawContext { queue: self };
2513		f(&mut ctx);
2514	}
2515}
2516
2517/// drawing context for immediate mode rendering.
2518///
2519/// provides convenience methods for debug drawing without managing
2520/// draw commands manually. obtained via [`RenderQueue::draw_immediate`].
2521pub struct DrawContext<'a> {
2522	queue: &'a mut RenderQueue,
2523}
2524
2525impl DrawContext<'_> {
2526	/// draw a line between two points.
2527	pub fn line(&mut self, start: Vec2, end: Vec2, color: Color, thickness: f32) {
2528		self.queue.draw_line(start, end, color, thickness);
2529	}
2530
2531	/// draw a filled rectangle.
2532	pub fn rect(&mut self, position: Vec2, size: Vec2, color: Color) {
2533		self.queue.draw_rect(position, size, color);
2534	}
2535
2536	/// draw a stroked rectangle (outline only).
2537	pub fn rect_stroke(&mut self, position: Vec2, size: Vec2, color: Color, thickness: f32) {
2538		let Vec2 { x, y } = position;
2539		let Vec2 { x: w, y: h } = size;
2540		// top
2541		self.line(Vec2::new(x, y), Vec2::new(x + w, y), color, thickness);
2542		// bottom
2543		self.line(
2544			Vec2::new(x, y + h),
2545			Vec2::new(x + w, y + h),
2546			color,
2547			thickness,
2548		);
2549		// left
2550		self.line(Vec2::new(x, y), Vec2::new(x, y + h), color, thickness);
2551		// right
2552		self.line(
2553			Vec2::new(x + w, y),
2554			Vec2::new(x + w, y + h),
2555			color,
2556			thickness,
2557		);
2558	}
2559
2560	/// draw a stroked circle (outline only, approximated with line segments).
2561	pub fn circle(&mut self, center: Vec2, radius: f32, color: Color, thickness: f32) {
2562		let segments = 32;
2563		#[allow(clippy::cast_precision_loss)]
2564		for i in 0..segments {
2565			let a1 = (i as f32 / segments as f32) * 2.0 * std::f32::consts::PI;
2566			let a2 = ((i + 1) as f32 / segments as f32) * 2.0 * std::f32::consts::PI;
2567			let x1 = center.x + a1.cos() * radius;
2568			let y1 = center.y + a1.sin() * radius;
2569			let x2 = center.x + a2.cos() * radius;
2570			let y2 = center.y + a2.sin() * radius;
2571			self.line(Vec2::new(x1, y1), Vec2::new(x2, y2), color, thickness);
2572		}
2573	}
2574
2575	/// draw a filled circle (scanline rects, one pixel tall per row).
2576	pub fn circle_filled(&mut self, center: Vec2, radius: f32, color: Color) {
2577		let r = radius.ceil() as i32;
2578		#[allow(clippy::cast_precision_loss)]
2579		for dy in -r..=r {
2580			let dy_f = dy as f32 + 0.5; // sample at center of the scanline row
2581			let half_w = (radius * radius - dy_f * dy_f).sqrt();
2582			if half_w <= 0.0 {
2583				continue;
2584			}
2585			self.queue.push(DrawCommand {
2586				kind: DrawKind::Rect {
2587					position: Vec2::new(center.x - half_w, center.y + dy as f32),
2588					size: Vec2::new(half_w * 2.0, 1.0),
2589					color,
2590					layer: layers::FOREGROUND,
2591				},
2592			});
2593		}
2594	}
2595
2596	/// draw text.
2597	pub fn text(&mut self, content: &str, position: Vec2, font_size: f32, color: Color) {
2598		// use a placeholder font id of 0 for immediate mode text
2599		self.queue.push(DrawCommand {
2600			kind: DrawKind::Text {
2601				font: Some(0),
2602				content: Arc::from(content),
2603				position,
2604				font_size,
2605				color,
2606				layer: layers::FOREGROUND,
2607				wrap_width: None,
2608				line_height: 0.0,
2609			},
2610		});
2611	}
2612
2613	/// draw a point as a small filled circle.
2614	pub fn point(&mut self, position: Vec2, color: Color) {
2615		self.circle(position, 3.0, color, 1.0);
2616	}
2617
2618	/// draw an AABB collision box.
2619	pub fn aabb(&mut self, min: Vec2, max: Vec2, color: Color, thickness: f32) {
2620		self.rect_stroke(min, max - min, color, thickness);
2621	}
2622}
2623
2624/// trait for custom render passes that can be executed by the render engine.
2625///
2626/// implement this trait to add custom rendering (e.g. post-processing, 3D passes).
2627/// passes are executed in registration order after the default 2D pass.
2628pub trait RenderPass: Send + Sync + 'static {
2629	/// unique name for this pass.
2630	fn name(&self) -> &str;
2631
2632	/// execute this render pass.
2633	/// the pass receives a reference to the render engine and the current
2634	/// render pass encoder. use these to issue draw commands.
2635	fn execute(
2636		&self,
2637		_device: &wgpu::Device,
2638		_queue: &wgpu::Queue,
2639		_pass: &mut wgpu::RenderPass<'_>,
2640	) {
2641	}
2642}
2643
2644impl Default for RenderQueue {
2645	fn default() -> Self {
2646		Self::new()
2647	}
2648}
2649
2650// ── post-processing ────────────────────────────────────────────────────────
2651
2652/// trait for a custom post-processing effect applied each frame after the main render.
2653///
2654/// implement this and push instances onto [`PostProcessStack`] to draw
2655/// screen-space overlays. uses [`layers::POST_PROCESS`] coordinates: top-left is
2656/// (0, 0), bottom-right is (window_w, window_h).
2657///
2658/// # example
2659///
2660/// ```ignore
2661/// use lunar::prelude::*;
2662///
2663/// struct Vignette;
2664/// impl PostEffect for Vignette {
2665///     fn apply(&self, queue: &mut RenderQueue, w: f32, h: f32) {
2666///         let border = 40.0;
2667///         let color = Color::rgba(0.0, 0.0, 0.0, 0.35);
2668///         queue.draw_screen_rect(Vec2::ZERO, Vec2::new(w, border), color);
2669///         queue.draw_screen_rect(Vec2::new(0.0, h - border), Vec2::new(w, border), color);
2670///     }
2671/// }
2672/// ```
2673pub trait PostEffect: Send + Sync + 'static {
2674	/// draw the effect's commands into the queue.
2675	/// `window_w` and `window_h` are the current surface dimensions in pixels.
2676	fn apply(&self, queue: &mut RenderQueue, window_w: f32, window_h: f32);
2677}
2678
2679/// ordered stack of post-processing effects applied each frame after the main render.
2680///
2681/// insert as a resource, then push effects onto it. effects are drawn in push order
2682/// (first pushed = drawn first = underneath later effects).
2683///
2684/// # example
2685///
2686/// ```ignore
2687/// fn setup(mut stack: ResMut<PostProcessStack>) {
2688///     stack.push(ScreenFlash { color: Color::RED, intensity: 0.4, decay: 2.0 });
2689/// }
2690/// ```
2691#[derive(Resource, Default)]
2692pub struct PostProcessStack {
2693	effects: Vec<Box<dyn PostEffect>>,
2694}
2695
2696impl PostProcessStack {
2697	/// add an effect to the top of the stack.
2698	pub fn push(&mut self, effect: impl PostEffect + 'static) {
2699		self.effects.push(Box::new(effect));
2700	}
2701
2702	/// remove all effects from the stack.
2703	pub fn clear(&mut self) {
2704		self.effects.clear();
2705	}
2706
2707	/// true when the stack has no effects.
2708	#[must_use]
2709	pub fn is_empty(&self) -> bool {
2710		self.effects.is_empty()
2711	}
2712}
2713
2714/// draws a fullscreen colored overlay each frame.
2715///
2716/// use for damage flashes, screen transitions, fades. set `decay > 0.0` and the
2717/// engine automatically reduces `intensity` over time via `decay_screen_flash_system`.
2718///
2719/// insert this as a resource; remove it when intensity reaches 0 if you don't want
2720/// it to linger.
2721///
2722/// # example
2723///
2724/// ```ignore
2725/// commands.insert_resource(ScreenFlash {
2726///     color: Color::RED,
2727///     intensity: 0.5,
2728///     decay: 3.0,  // fades out in ~0.17 seconds
2729/// });
2730/// ```
2731#[derive(Resource, Clone, Copy)]
2732pub struct ScreenFlash {
2733	pub color: Color,
2734	/// opacity: 0.0 = invisible, 1.0 = fully opaque.
2735	pub intensity: f32,
2736	/// intensity units lost per second. 0.0 = persistent.
2737	pub decay: f32,
2738}
2739
2740/// draws a semi-transparent colored overlay each frame.
2741///
2742/// lower intensity than a full [`ScreenFlash`]. good for long-lasting status
2743/// effects like poison (green tint) or low health (red vignette).
2744///
2745/// # example
2746///
2747/// ```ignore
2748/// commands.insert_resource(ColorTint { color: Color::GREEN, intensity: 0.15 });
2749/// ```
2750#[derive(Resource, Clone, Copy)]
2751pub struct ColorTint {
2752	pub color: Color,
2753	/// blend strength: 0.0 = no effect, 1.0 = full tint.
2754	pub intensity: f32,
2755}
2756
2757fn apply_post_process_system(
2758	mut queue: ResMut<RenderQueue>,
2759	flash: Option<Res<ScreenFlash>>,
2760	tint: Option<Res<ColorTint>>,
2761	stack: Res<PostProcessStack>,
2762	info: Res<RenderInfo>,
2763) {
2764	let w = info.window_width as f32;
2765	let h = info.window_height as f32;
2766	if w == 0.0 || h == 0.0 {
2767		return;
2768	}
2769	let size = Vec2::new(w, h);
2770	for effect in &stack.effects {
2771		effect.apply(&mut queue, w, h);
2772	}
2773	if let Some(tint) = tint
2774		&& tint.intensity > 0.0
2775	{
2776		queue.draw_screen_rect(
2777			Vec2::ZERO,
2778			size,
2779			Color::rgba(tint.color.r, tint.color.g, tint.color.b, tint.intensity),
2780		);
2781	}
2782	if let Some(flash) = flash
2783		&& flash.intensity > 0.0
2784	{
2785		queue.draw_screen_rect(
2786			Vec2::ZERO,
2787			size,
2788			Color::rgba(flash.color.r, flash.color.g, flash.color.b, flash.intensity),
2789		);
2790	}
2791}
2792
2793fn decay_screen_flash_system(
2794	mut commands: Commands,
2795	flash: Option<ResMut<ScreenFlash>>,
2796	time: Res<lunar_core::Time>,
2797) {
2798	if let Some(mut flash) = flash
2799		&& flash.decay > 0.0
2800	{
2801		flash.intensity -= flash.decay * time.delta_seconds();
2802		if flash.intensity <= 0.0 {
2803			commands.remove_resource::<ScreenFlash>();
2804		}
2805	}
2806}
2807
2808/// render info resource, tracks rendering statistics.
2809///
2810/// updated each frame by the render system. game code can read
2811/// this to display debug info or make performance decisions.
2812#[derive(Resource)]
2813pub struct RenderInfo {
2814	/// window width in pixels
2815	pub window_width: u32,
2816	/// window height in pixels
2817	pub window_height: u32,
2818	/// current frames per second
2819	pub fps: f32,
2820	/// time to render last frame in milliseconds
2821	pub frame_time_ms: f32,
2822	/// number of draw calls issued last frame
2823	pub draw_calls: u32,
2824	/// number of sprites rendered last frame
2825	pub sprite_count: u32,
2826}
2827
2828impl RenderInfo {
2829	/// create a new render info with default values
2830	#[must_use]
2831	pub const fn new() -> Self {
2832		Self {
2833			window_width: 0,
2834			window_height: 0,
2835			fps: 0.0,
2836			frame_time_ms: 0.0,
2837			draw_calls: 0,
2838			sprite_count: 0,
2839		}
2840	}
2841}
2842
2843impl Default for RenderInfo {
2844	fn default() -> Self {
2845		Self::new()
2846	}
2847}
2848
2849/// debug overlay for displaying runtime stats.
2850///
2851/// when enabled, draws FPS, frame time, sprite count, and entity count
2852/// in the top-left corner using immediate mode rendering.
2853#[derive(Resource)]
2854pub struct DebugOverlay {
2855	/// whether the overlay is currently visible
2856	pub enabled: bool,
2857	/// position in screen space (top-left corner)
2858	pub position: Vec2,
2859	/// font size for text
2860	pub font_size: f32,
2861	/// text color
2862	pub color: Color,
2863	// scratch buffer reused each frame to avoid per-line format! allocations
2864	scratch: String,
2865}
2866
2867impl DebugOverlay {
2868	/// create a new debug overlay with default settings.
2869	#[must_use]
2870	pub fn new() -> Self {
2871		Self {
2872			enabled: false,
2873			position: Vec2::new(10.0, 10.0),
2874			font_size: 14.0,
2875			color: Color::WHITE,
2876			scratch: String::new(),
2877		}
2878	}
2879
2880	/// draw debug info to the render queue.
2881	/// call this each frame with current stats.
2882	pub fn draw(
2883		&mut self,
2884		queue: &mut RenderQueue,
2885		fps: f32,
2886		frame_time_ms: f32,
2887		sprite_count: u32,
2888		entity_count: u32,
2889	) {
2890		if !self.enabled {
2891			return;
2892		}
2893		use std::fmt::Write;
2894		let x = self.position.x;
2895		let y = self.position.y;
2896		let fs = self.font_size;
2897		let color = self.color;
2898		let spacing = fs + 2.0;
2899
2900		// write! reuses scratch's allocation; Arc::from copies into the draw command.
2901		// saves one format! String alloc per line at steady-state.
2902		self.scratch.clear();
2903		let _ = write!(self.scratch, "FPS: {fps:.1}");
2904		push_debug_text(queue, &self.scratch, Vec2::new(x, y), fs, color);
2905
2906		self.scratch.clear();
2907		let _ = write!(self.scratch, "Frame: {frame_time_ms:.1}ms");
2908		push_debug_text(queue, &self.scratch, Vec2::new(x, y + spacing), fs, color);
2909
2910		self.scratch.clear();
2911		let _ = write!(self.scratch, "Sprites: {sprite_count}");
2912		push_debug_text(
2913			queue,
2914			&self.scratch,
2915			Vec2::new(x, y + spacing * 2.0),
2916			fs,
2917			color,
2918		);
2919
2920		self.scratch.clear();
2921		let _ = write!(self.scratch, "Entities: {entity_count}");
2922		push_debug_text(
2923			queue,
2924			&self.scratch,
2925			Vec2::new(x, y + spacing * 3.0),
2926			fs,
2927			color,
2928		);
2929	}
2930}
2931
2932fn push_debug_text(
2933	queue: &mut RenderQueue,
2934	content: &str,
2935	position: Vec2,
2936	font_size: f32,
2937	color: Color,
2938) {
2939	queue.push(DrawCommand {
2940		kind: DrawKind::Text {
2941			font: Some(0),
2942			content: Arc::from(content),
2943			position,
2944			font_size,
2945			color,
2946			layer: layers::FOREGROUND,
2947			wrap_width: None,
2948			line_height: 0.0,
2949		},
2950	});
2951}
2952
2953impl Default for DebugOverlay {
2954	fn default() -> Self {
2955		Self::new()
2956	}
2957}
2958
2959/// render plugin, registers render systems and resources.
2960///
2961/// add this plugin to your [`App`] to enable rendering.
2962/// it registers the [`RenderQueue`] and [`RenderInfo`] as ECS resources.
2963pub struct RenderPlugin;
2964
2965impl Default for RenderPlugin {
2966	fn default() -> Self {
2967		Self
2968	}
2969}
2970
2971impl GamePlugin for RenderPlugin {
2972	fn name(&self) -> &'static str {
2973		"RenderPlugin"
2974	}
2975
2976	fn build(&mut self, app: &mut App) {
2977		app.insert_resource(RenderQueue::new());
2978		app.insert_resource(RenderInfo::new());
2979		app.insert_resource(DebugOverlay::new());
2980		app.insert_resource(PostProcessStack::default());
2981		app.add_system_to_stage(
2982			lunar_core::UpdateStage::PostUpdate,
2983			decay_screen_flash_system,
2984		);
2985		app.add_system_to_stage(
2986			lunar_core::UpdateStage::PostUpdate,
2987			apply_post_process_system,
2988		);
2989		app.add_system_to_stage(
2990			lunar_core::UpdateStage::PostUpdate,
2991			(
2992				camera_follow::camera_follow_system,
2993				screen_shake::screen_shake_system,
2994			)
2995				.chain(),
2996		);
2997		// upload_new_textures_system runs first to ensure any texture that became
2998		// ready this frame is on the GPU before auto_sprite_system enqueues draws.
2999		#[cfg(not(target_arch = "wasm32"))]
3000		app.add_system_to_stage(
3001			lunar_core::UpdateStage::Render,
3002			(
3003				upload_new_textures_system,
3004				upload_new_fonts_system,
3005				evict_textures_system,
3006				frame_stats_system,
3007				auto_sprite_system,
3008				auto_text_system,
3009				debug_overlay_system,
3010				render_system,
3011			)
3012				.chain(),
3013		);
3014		#[cfg(target_arch = "wasm32")]
3015		app.add_system_to_stage(
3016			lunar_core::UpdateStage::Render,
3017			(
3018				wasm_upload_new_textures_system,
3019				wasm_upload_new_fonts_system,
3020				wasm_evict_textures_system,
3021				frame_stats_system,
3022				auto_sprite_system,
3023				auto_text_system,
3024				debug_overlay_system,
3025				wasm_render_system,
3026			)
3027				.chain(),
3028		);
3029	}
3030}
3031
3032// thread-local storage for the WASM render engine.
3033// wgpu WebGPU types are !Send, so we cannot store RenderEngine as an ECS Resource
3034// on WASM. instead, bootstrap stores it here and the render system borrows it.
3035#[cfg(target_arch = "wasm32")]
3036thread_local! {
3037	static WASM_RENDER_ENGINE: std::cell::RefCell<Option<RenderEngine>> =
3038		std::cell::RefCell::new(None);
3039}
3040
3041/// store the render engine for WASM rendering.
3042/// call this once from bootstrap after async GPU init, before starting the game loop.
3043#[cfg(target_arch = "wasm32")]
3044pub fn wasm_set_render_engine(engine: RenderEngine) {
3045	WASM_RENDER_ENGINE.with(|cell| {
3046		*cell.borrow_mut() = Some(engine);
3047	});
3048}
3049
3050/// wasm render system: borrows the engine from thread-local storage,
3051/// renders all queued commands, then clears the queue.
3052#[cfg(target_arch = "wasm32")]
3053#[allow(clippy::needless_pass_by_value)]
3054fn wasm_render_system(
3055	mut queue: ResMut<RenderQueue>,
3056	mut render_info: ResMut<RenderInfo>,
3057	camera: Option<Res<Camera>>,
3058) {
3059	WASM_RENDER_ENGINE.with(|cell| {
3060		if let Some(engine) = cell.borrow_mut().as_mut() {
3061			engine.render(queue.commands(), camera.as_deref(), &mut render_info);
3062		}
3063	});
3064	queue.clear();
3065}
3066
3067/// uploads textures that became ready in the asset server to the GPU.
3068///
3069/// drains the pending list from [`AssetServer`] and calls [`RenderEngine::upload_texture`]
3070/// for each one. runs before the render chain so draws on the same frame a texture
3071/// loads will succeed.
3072#[cfg(not(target_arch = "wasm32"))]
3073#[allow(clippy::needless_pass_by_value)]
3074fn upload_new_textures_system(mut assets: ResMut<AssetServer>, mut render: ResMut<RenderEngine>) {
3075	for id in assets.drain_new_texture_ids() {
3076		if let Some(texture) = assets.get_texture_by_id(id) {
3077			let handle = Handle::<Texture>::new(id, 0);
3078			render.upload_texture(&handle, texture);
3079		}
3080	}
3081}
3082
3083/// WASM version — accesses the render engine from thread-local storage.
3084#[cfg(target_arch = "wasm32")]
3085#[allow(clippy::needless_pass_by_value)]
3086fn wasm_upload_new_textures_system(mut assets: ResMut<AssetServer>) {
3087	let ids = assets.drain_new_texture_ids();
3088	WASM_RENDER_ENGINE.with(|cell| {
3089		if let Some(engine) = cell.borrow_mut().as_mut() {
3090			for id in ids {
3091				if let Some(texture) = assets.get_texture_by_id(id) {
3092					let handle = Handle::<Texture>::new(id, 0);
3093					engine.upload_texture(&handle, texture);
3094				}
3095			}
3096		}
3097	});
3098}
3099
3100#[cfg(not(target_arch = "wasm32"))]
3101#[allow(clippy::needless_pass_by_value)]
3102fn upload_new_fonts_system(mut assets: ResMut<AssetServer>, mut render: ResMut<RenderEngine>) {
3103	for id in assets.drain_new_font_ids() {
3104		if let Some(font) = assets.get_font_by_id(id) {
3105			render.upload_font(id, &font.data);
3106		}
3107	}
3108}
3109
3110#[cfg(target_arch = "wasm32")]
3111#[allow(clippy::needless_pass_by_value)]
3112fn wasm_upload_new_fonts_system(mut assets: ResMut<AssetServer>) {
3113	let ids = assets.drain_new_font_ids();
3114	WASM_RENDER_ENGINE.with(|cell| {
3115		if let Some(engine) = cell.borrow_mut().as_mut() {
3116			for id in &ids {
3117				if let Some(font) = assets.get_font_by_id(*id) {
3118					engine.upload_font(*id, &font.data);
3119				}
3120			}
3121		}
3122	});
3123}
3124
3125/// free GPU resources for textures released via `AssetServer::release_texture`.
3126#[cfg(not(target_arch = "wasm32"))]
3127#[allow(clippy::needless_pass_by_value)]
3128fn evict_textures_system(mut assets: ResMut<AssetServer>, mut render: ResMut<RenderEngine>) {
3129	for id in assets.drain_evicted_texture_ids() {
3130		render.remove_texture(id);
3131	}
3132}
3133
3134/// WASM version of texture eviction.
3135#[cfg(target_arch = "wasm32")]
3136#[allow(clippy::needless_pass_by_value)]
3137fn wasm_evict_textures_system(mut assets: ResMut<AssetServer>) {
3138	let ids = assets.drain_evicted_texture_ids();
3139	WASM_RENDER_ENGINE.with(|cell| {
3140		if let Some(engine) = cell.borrow_mut().as_mut() {
3141			for id in ids {
3142				engine.remove_texture(id);
3143			}
3144		}
3145	});
3146}
3147
3148/// populate the [`RenderInfo`] resource each frame from [`Time`] so the debug
3149/// overlay (and any game-side HUD that reads it) shows real values. without
3150/// this, `info.fps` and `info.frame_time_ms` stayed at their `0.0` default.
3151#[allow(clippy::needless_pass_by_value)]
3152fn frame_stats_system(time: Res<Time>, mut info: ResMut<RenderInfo>) {
3153	let raw_delta = time.raw_delta_seconds();
3154	info.frame_time_ms = raw_delta * 1000.0;
3155	info.fps = if raw_delta > 0.0 {
3156		1.0 / raw_delta
3157	} else {
3158		0.0
3159	};
3160}
3161
3162/// marker component that opts a sprite into y-sort ordering.
3163///
3164/// sprites with `YSort` sort by world Y within their layer instead of texture id.
3165/// lower Y (further up the screen) is drawn first. useful for top-down RPGs and
3166/// brawlers where vertical position determines draw order.
3167#[derive(Component)]
3168pub struct YSort;
3169
3170/// auto-render system: enqueues a sprite draw for every entity with both
3171/// `Transform` and `Sprite`. resolves native texture size from `AssetServer`
3172/// when `Sprite::size` is `None`.
3173#[allow(clippy::needless_pass_by_value)]
3174fn auto_sprite_system(
3175	assets: Option<Res<AssetServer>>,
3176	mut queue: ResMut<RenderQueue>,
3177	query: Query<(&Transform, &Sprite, Option<&YSort>)>,
3178) {
3179	for (transform, sprite, y_sort) in &query {
3180		let resolved_size = sprite.size.unwrap_or_else(|| {
3181			assets
3182				.as_deref()
3183				.and_then(|server| server.get_texture(&sprite.texture))
3184				.map_or(Vec2::splat(32.0), |texture| {
3185					Vec2::new(texture.width as f32, texture.height as f32)
3186				})
3187		});
3188		let final_size = resolved_size * transform.scale;
3189		let origin = sprite
3190			.origin
3191			.map_or_else(|| final_size * 0.5, |o| o * transform.scale);
3192		// y-sort encodes world Y * 100 as i32 — sub-pixel precision, ~21M world units range
3193		let sort_key = y_sort.map(|_| (transform.translation.y * 100.0) as i32);
3194		queue.push(DrawCommand {
3195			kind: DrawKind::Sprite {
3196				texture: Some(u64::from(sprite.texture.id())),
3197				position: transform.translation,
3198				rotation: transform.rotation,
3199				scale: final_size,
3200				tint: sprite.color,
3201				layer: sprite.layer,
3202				uv_rect: sprite.source_rect,
3203				origin,
3204				sort_key,
3205			},
3206		});
3207	}
3208}
3209
3210/// auto-render system: enqueues a text draw for every entity with both
3211/// `Transform` and `Text`.
3212#[allow(clippy::needless_pass_by_value)]
3213fn auto_text_system(mut queue: ResMut<RenderQueue>, query: Query<(&Transform, &Text)>) {
3214	for (transform, text) in &query {
3215		queue.push(DrawCommand {
3216			kind: DrawKind::Text {
3217				font: Some(u64::from(text.font.id())),
3218				content: Arc::clone(&text.content),
3219				position: transform.translation,
3220				font_size: text.font_size,
3221				color: text.color,
3222				layer: text.layer,
3223				wrap_width: None,
3224				line_height: 0.0,
3225			},
3226		});
3227	}
3228}
3229
3230/// render system that processes the render queue.
3231/// clears the queue at the start of each frame, then renders all commands.
3232/// native-only: takes `ResMut<RenderEngine>` and `RenderEngine` only implements
3233/// `Resource` on native (WebGPU types are `!Send` on wasm).
3234#[cfg(not(target_arch = "wasm32"))]
3235fn render_system(
3236	mut render_engine: ResMut<RenderEngine>,
3237	mut queue: ResMut<RenderQueue>,
3238	mut render_info: ResMut<RenderInfo>,
3239	camera: Option<Res<Camera>>,
3240) {
3241	render_engine.render(queue.commands(), camera.as_deref(), &mut render_info);
3242	queue.clear();
3243}
3244
3245/// debug overlay system — draws FPS, frame time, sprite count, and entity count.
3246#[allow(clippy::needless_pass_by_value)]
3247fn debug_overlay_system(
3248	mut overlay: ResMut<DebugOverlay>,
3249	info: Res<RenderInfo>,
3250	mut queue: ResMut<RenderQueue>,
3251	entities: Query<Entity>,
3252) {
3253	#[allow(clippy::cast_possible_truncation)]
3254	let entity_count = entities.iter().count() as u32;
3255	overlay.draw(
3256		&mut queue,
3257		info.fps,
3258		info.frame_time_ms,
3259		info.sprite_count,
3260		entity_count,
3261	);
3262}