diff --git a/crates/graphics/src/lib.rs b/crates/graphics/src/lib.rs
index a64a756a..f1d513d2 100644
--- a/crates/graphics/src/lib.rs
+++ b/crates/graphics/src/lib.rs
@@ -28,11 +28,14 @@ pub mod plane;
pub mod atlas_cache;
pub mod image_cache;
+pub mod texture_loader;
pub use event::Event;
pub use map::Map;
pub use plane::Plane;
+pub use texture_loader::TextureLoader;
+
pub struct GraphicsState {
pub image_cache: image_cache::Cache,
pub atlas_cache: atlas_cache::Cache,
diff --git a/crates/graphics/src/texture_loader.rs b/crates/graphics/src/texture_loader.rs
new file mode 100644
index 00000000..b2b2966e
--- /dev/null
+++ b/crates/graphics/src/texture_loader.rs
@@ -0,0 +1,246 @@
+// Copyright (C) 2023 Lily Lyons
+//
+// This file is part of Luminol.
+//
+// Luminol is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Luminol is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Luminol. If not, see .
+//
+// Additional permission under GNU GPL version 3 section 7
+//
+// If you modify this Program, or any covered work, by linking or combining
+// it with Steamworks API by Valve Corporation, containing parts covered by
+// terms of the Steamworks API by Valve Corporation, the licensors of this
+// Program grant you additional permission to convey the resulting work.
+
+use dashmap::{DashMap, DashSet};
+
+use egui::load::{LoadError, SizedTexture, TextureLoadResult, TexturePoll};
+
+use std::sync::{
+ atomic::{AtomicUsize, Ordering},
+ Arc,
+};
+
+use wgpu::util::DeviceExt;
+
+pub struct TextureLoader {
+ loaded_textures: DashMap>,
+ load_errors: DashMap,
+ unloaded_textures: DashSet,
+
+ loaded_bytes: AtomicUsize,
+
+ render_state: egui_wgpu::RenderState,
+}
+
+pub struct Texture {
+ wgpu: wgpu::Texture,
+ egui: egui::TextureId,
+}
+
+pub const LOADER_ID: &str = egui::load::generate_loader_id!(TextureLoader);
+
+// NOTE blindly assumes texture components are 1 byte
+fn texture_size_bytes(texture: &wgpu::Texture) -> u32 {
+ texture.width()
+ * texture.height()
+ * texture.depth_or_array_layers()
+ * texture.format().components() as u32
+}
+
+fn load_wgpu_texture_from_path(
+ filesystem: &impl luminol_filesystem::FileSystem,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ path: &str,
+) -> anyhow::Result {
+ let file = filesystem.read(path)?;
+ let texture_data = image::load_from_memory(&file)?.to_rgba8();
+
+ Ok(device.create_texture_with_data(
+ queue,
+ &wgpu::TextureDescriptor {
+ label: Some(path),
+ size: wgpu::Extent3d {
+ width: texture_data.width(),
+ height: texture_data.height(),
+ depth_or_array_layers: 1,
+ },
+ dimension: wgpu::TextureDimension::D2,
+ mip_level_count: 1,
+ sample_count: 1,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::COPY_SRC
+ | wgpu::TextureUsages::COPY_DST
+ | wgpu::TextureUsages::TEXTURE_BINDING,
+ view_formats: &[],
+ },
+ &texture_data,
+ ))
+}
+
+impl TextureLoader {
+ pub fn new(render_state: egui_wgpu::RenderState) -> Self {
+ Self {
+ loaded_textures: DashMap::with_capacity(64),
+ load_errors: DashMap::new(),
+ unloaded_textures: DashSet::with_capacity(64),
+
+ loaded_bytes: AtomicUsize::new(0),
+
+ render_state,
+ }
+ }
+
+ pub fn load_unloaded_textures(&self, filesystem: &impl luminol_filesystem::FileSystem) {
+ // dashmap has no drain method so this is the best we can do
+ let mut renderer = self.render_state.renderer.write();
+ for path in self.unloaded_textures.iter() {
+ let wgpu_texture = match load_wgpu_texture_from_path(
+ filesystem,
+ &self.render_state.device,
+ &self.render_state.queue,
+ path.as_str(),
+ ) {
+ Ok(t) => t,
+ Err(error) => {
+ self.load_errors.insert(path.to_string(), error);
+
+ continue;
+ }
+ };
+
+ self.loaded_bytes.fetch_add(
+ texture_size_bytes(&wgpu_texture) as usize,
+ Ordering::Relaxed,
+ );
+
+ let texture_id = renderer.register_native_texture(
+ &self.render_state.device,
+ &wgpu_texture.create_view(&wgpu::TextureViewDescriptor {
+ label: Some(&path),
+ ..Default::default()
+ }),
+ wgpu::FilterMode::Nearest,
+ );
+
+ self.loaded_textures.insert(
+ path.to_string(),
+ Arc::new(Texture {
+ wgpu: wgpu_texture,
+ egui: texture_id,
+ }),
+ );
+ }
+ self.unloaded_textures.clear();
+ }
+
+ pub fn load_now(
+ &self,
+ filesystem: &impl luminol_filesystem::FileSystem,
+ path: impl AsRef,
+ ) -> anyhow::Result> {
+ let path = path.as_ref().as_str();
+
+ let wgpu_texture = load_wgpu_texture_from_path(
+ filesystem,
+ &self.render_state.device,
+ &self.render_state.queue,
+ path,
+ )?;
+
+ Ok(self.register_texture(path.to_string(), wgpu_texture))
+ }
+
+ pub fn register_texture(&self, uri: String, wgpu_texture: wgpu::Texture) -> Arc {
+ self.loaded_bytes.fetch_add(
+ texture_size_bytes(&wgpu_texture) as usize,
+ Ordering::Relaxed,
+ );
+
+ // todo maybe use custom sampler descriptor?
+ // would allow for better texture names in debuggers
+ let texture_id = self.render_state.renderer.write().register_native_texture(
+ &self.render_state.device,
+ &wgpu_texture.create_view(&wgpu::TextureViewDescriptor {
+ label: Some(&uri),
+ ..Default::default()
+ }),
+ wgpu::FilterMode::Nearest,
+ );
+
+ let texture = Arc::new(Texture {
+ wgpu: wgpu_texture,
+ egui: texture_id,
+ });
+ self.loaded_textures.insert(uri, texture.clone());
+ texture
+ }
+
+ pub fn get(&self, path: impl AsRef) -> Option> {
+ self.loaded_textures
+ .get(path.as_ref().as_str())
+ .as_deref()
+ .cloned()
+ }
+}
+
+impl egui::load::TextureLoader for TextureLoader {
+ fn id(&self) -> &str {
+ LOADER_ID
+ }
+
+ fn load(
+ &self,
+ _: &egui::Context,
+ uri: &str,
+ _: egui::TextureOptions,
+ _: egui::SizeHint,
+ ) -> TextureLoadResult {
+ if let Some(texture) = self.loaded_textures.get(uri).as_deref() {
+ let extents = texture.wgpu.size();
+ return Ok(TexturePoll::Ready {
+ texture: SizedTexture::new(
+ texture.egui,
+ egui::vec2(extents.width as f32, extents.height as f32),
+ ),
+ });
+ }
+
+ if let Some(error) = self.load_errors.get(uri) {
+ return Err(LoadError::Loading(error.to_string()));
+ }
+
+ self.unloaded_textures.insert(uri.to_string());
+
+ Ok(TexturePoll::Pending { size: None })
+ }
+
+ fn forget(&self, uri: &str) {
+ if let Some((_, texture)) = self.loaded_textures.remove(uri) {
+ self.loaded_bytes.fetch_sub(
+ texture_size_bytes(&texture.wgpu) as usize,
+ Ordering::Relaxed,
+ );
+ }
+ }
+
+ fn forget_all(&self) {
+ self.loaded_textures.clear();
+ self.loaded_bytes.store(0, Ordering::Relaxed);
+ }
+
+ fn byte_size(&self) -> usize {
+ self.loaded_bytes.load(Ordering::Relaxed)
+ }
+}