kitgit

tirbofish/dropbear · commit

5c67beb3f55ff4b1a0af1b0895e63d332dc1266f

feature: hot reloading shaders

Unverified

Thribhu K <4tkbytes@pm.me> · 2026-04-03 04:45

view full diff

diff --git a/Cargo.toml b/Cargo.toml
index c9774c7..1995a02 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -96,6 +96,9 @@ naga = { version = "29", features = ["wgsl-in"] }
 uuid = { version = "1.22", features = ["v4", "serde"] }
 splines = "5.0"
 
+notify = "9.0.0-rc.2"
+arc-swap = "1.9"
+
 [patch.crates-io]
 egui = { git = "https://github.com/emilk/egui", branch = "main" }
 emath = { git = "https://github.com/emilk/egui", branch = "main" }
diff --git a/crates/dropbear-engine/Cargo.toml b/crates/dropbear-engine/Cargo.toml
index 8c74ddc..bcda34c 100644
--- a/crates/dropbear-engine/Cargo.toml
+++ b/crates/dropbear-engine/Cargo.toml
@@ -53,6 +53,8 @@ rkyv.workspace = true
 bevy_mikktspace.workspace = true
 wesl.workspace = true
 egui_extras.workspace = true
+arc-swap.workspace = true
+notify.workspace = true
 
 [target.'cfg(not(target_os = "android"))'.dependencies]
 rfd.workspace = true
diff --git a/crates/dropbear-engine/src/graphics.rs b/crates/dropbear-engine/src/graphics.rs
index e001ab9..0fbba25 100644
--- a/crates/dropbear-engine/src/graphics.rs
+++ b/crates/dropbear-engine/src/graphics.rs
@@ -3,7 +3,7 @@ use crate::model::Vertex;
 use crate::{BindGroupLayouts, texture};
 use crate::{State, egui_renderer::EguiRenderer};
 use dropbear_future_queue::FutureQueue;
-use egui::{Context, TextureId};
+use egui::TextureId;
 use glam::{DMat4, DQuat, DVec3, Mat3};
 use parking_lot::{Mutex, RwLock};
 use std::ops::{Deref, DerefMut};
diff --git a/crates/dropbear-engine/src/model.rs b/crates/dropbear-engine/src/model.rs
index 3cfca43..a5408d5 100644
--- a/crates/dropbear-engine/src/model.rs
+++ b/crates/dropbear-engine/src/model.rs
@@ -1,5 +1,5 @@
 use crate::asset::{AssetRegistry, Handle};
-use crate::buffer::{MutableDataBuffer, UniformBuffer};
+use crate::buffer::UniformBuffer;
 use crate::texture::TextureBuilder;
 use crate::{
     graphics::SharedGraphicsContext,
@@ -21,7 +21,6 @@ use wgpu::{
     BufferAddress, FilterMode, MipmapFilterMode, VertexAttribute, VertexBufferLayout,
     util::DeviceExt,
 };
-use dropbear_utils::Dirty;
 
 // note to self: do not derive clone otherwise it wil take too much memory
 // #[derive(Clone)]
diff --git a/crates/dropbear-engine/src/pipelines/mod.rs b/crates/dropbear-engine/src/pipelines/mod.rs
index ec90a9a..cb1cd2e 100644
--- a/crates/dropbear-engine/src/pipelines/mod.rs
+++ b/crates/dropbear-engine/src/pipelines/mod.rs
@@ -1,6 +1,9 @@
+use std::path::PathBuf;
 use crate::graphics::SharedGraphicsContext;
 use crate::shader::Shader;
 use std::sync::Arc;
+use arc_swap::ArcSwap;
+use notify::{RecommendedWatcher, RecursiveMode, Watcher};
 
 pub mod globals;
 pub mod hdr;
@@ -11,6 +14,80 @@ pub mod builder;
 
 pub use globals::{Globals, GlobalsUniform};
 
+/// A render pipeline that hot-swaps itself when shader source files change.
+pub struct HotPipeline {
+    pipeline: Arc<ArcSwap<wgpu::RenderPipeline>>,
+}
+
+impl HotPipeline {
+    pub fn new<F>(
+        device: Arc<wgpu::Device>,
+        watch_dir: PathBuf,
+        factory: F,
+    ) -> anyhow::Result<Self>
+    where
+        F: Fn(&wgpu::Device) -> anyhow::Result<wgpu::RenderPipeline> + Send + Sync + 'static,
+    {
+        let factory = Arc::new(factory);
+
+        let initial = factory(&device)?;
+        let pipeline = Arc::new(ArcSwap::from_pointee(initial));
+
+        if !watch_dir.exists() {
+            log::warn!("HotPipeline: watch directory does not exist: {watch_dir:?}");
+            return Ok(Self { pipeline });
+        }
+
+        let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
+        let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())?;
+        watcher.watch(&watch_dir, RecursiveMode::Recursive)?;
+
+        let pipeline_ref = pipeline.clone();
+        std::thread::spawn(move || {
+            let _watcher = watcher; // keep the watcher alive inside the thread
+            for event in rx {
+                match event {
+                    Ok(ev) => {
+                        use notify::EventKind::*;
+                        match ev.kind {
+                            Modify(_) | Create(_) => {
+                                log::info!("Shader change detected: {:?}", ev.paths);
+                                let result = std::panic::catch_unwind(
+                                    std::panic::AssertUnwindSafe(|| factory(&device)),
+                                );
+                                match result {
+                                    Ok(Ok(new_pipeline)) => {
+                                        pipeline_ref.store(Arc::new(new_pipeline));
+                                        log::info!("Pipeline hot-reloaded successfully");
+                                    }
+                                    Ok(Err(e)) => {
+                                        log::error!(
+                                            "Shader compile error, keeping old pipeline: {e}"
+                                        );
+                                    }
+                                    Err(_) => {
+                                        log::error!(
+                                            "Shader factory panicked during hot reload, keeping old pipeline"
+                                        );
+                                    }
+                                }
+                            }
+                            _ => {}
+                        }
+                    }
+                    Err(e) => log::error!("Shader watcher error: {e}"),
+                }
+            }
+        });
+
+        Ok(Self { pipeline })
+    }
+
+    pub fn get(&self) -> arc_swap::Guard<Arc<wgpu::RenderPipeline>> {
+        self.pipeline.load()
+    }
+}
+
 /// A helper in defining a pipelines required information, as well as getters.
 ///
 /// This contains the bare minimum for any pipeline.
diff --git a/crates/dropbear-engine/src/pipelines/shader.rs b/crates/dropbear-engine/src/pipelines/shader.rs
index c51557e..fbc0d42 100644
--- a/crates/dropbear-engine/src/pipelines/shader.rs
+++ b/crates/dropbear-engine/src/pipelines/shader.rs
@@ -1,17 +1,15 @@
 use crate::graphics::{InstanceRaw, SharedGraphicsContext};
 use crate::model;
 use crate::model::Vertex;
-use crate::pipelines::DropbearShaderPipeline;
-use crate::shader::Shader;
+use crate::pipelines::HotPipeline;
 use crate::texture::Texture;
 use std::sync::Arc;
+use wesl::ModulePath;
 use wgpu::{CompareFunction, DepthBiasState, StencilState};
 
 /// As defined in `shaders/shader.wesl`
 pub struct MainRenderPipeline {
-    shader: Shader,
-    pipeline_layout: wgpu::PipelineLayout,
-    pipeline: wgpu::RenderPipeline,
+    pipeline: HotPipeline,
 
     pub per_frame: Option<wgpu::BindGroup>,
     pub per_material: Option<wgpu::BindGroup>,
@@ -19,92 +17,113 @@ pub struct MainRenderPipeline {
     pub environment: Option<wgpu::BindGroup>,
 }
 
-impl DropbearShaderPipeline for MainRenderPipeline {
-    fn new(graphics: Arc<SharedGraphicsContext>) -> Self {
-        let source = wesl::Wesl::new("src/shaders")
-            .add_package(&crate::shader::code::PACKAGE)
-            .compile(&"dropbear_shaders::shader".parse().unwrap())
-            .inspect_err(|e| {
-                panic!("{e}");
-            })
-            .unwrap()
-            .to_string();
-
-        let shader = Shader::new(
-            graphics.clone(),
-            &source,
-            Some("viewport shaders"),
-        );
-
-        let bind_group_layouts = vec![
-            Some(&graphics.layouts.per_frame_layout),
-            Some(&graphics.layouts.material_bind_layout),
-            Some(&graphics.layouts.animation_layout),
-            Some(&graphics.layouts.environment_layout),
-        ];
-
-        let pipeline_layout =
+impl MainRenderPipeline {
+    pub fn new(graphics: Arc<SharedGraphicsContext>) -> Self {
+        let pipeline_layout = Arc::new(
             graphics
                 .device
                 .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
                     label: Some("main render pipeline layout"),
-                    bind_group_layouts: bind_group_layouts.as_slice(),
+                    bind_group_layouts: &[
+                        Some(&graphics.layouts.per_frame_layout),
+                        Some(&graphics.layouts.material_bind_layout),
+                        Some(&graphics.layouts.animation_layout),
+                        Some(&graphics.layouts.environment_layout),
+                    ],
                     immediate_size: 0,
-                });
+                }),
+        );
 
         let hdr_format = graphics.hdr.read().format();
         let sample_count: u32 = (*graphics.antialiasing.read()).into();
-        let pipeline = graphics
-            .device
-            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
-                label: Some("main render pipeline"),
-                layout: Some(&pipeline_layout),
-                vertex: wgpu::VertexState {
-                    module: &shader.module,
-                    entry_point: Some("vs_main"),
-                    buffers: &[model::ModelVertex::desc(), InstanceRaw::desc()],
-                    compilation_options: wgpu::PipelineCompilationOptions::default(),
-                },
-                fragment: Some(wgpu::FragmentState {
-                    module: &shader.module,
-                    entry_point: Some("s_fs_main"),
-                    targets: &[Some(wgpu::ColorTargetState {
-                        format: hdr_format,
-                        blend: Some(wgpu::BlendState::REPLACE),
-                        write_mask: wgpu::ColorWrites::ALL,
-                    })],
-                    compilation_options: wgpu::PipelineCompilationOptions::default(),
-                }),
-                primitive: wgpu::PrimitiveState {
-                    topology: wgpu::PrimitiveTopology::TriangleList,
-                    strip_index_format: None,
-                    front_face: wgpu::FrontFace::Cw,
-                    cull_mode: Some(wgpu::Face::Back),
-                    polygon_mode: wgpu::PolygonMode::Fill,
-                    unclipped_depth: false,
-                    conservative: false,
-                },
-                depth_stencil: Some(wgpu::DepthStencilState {
-                    format: Texture::DEPTH_FORMAT,
-                    depth_write_enabled: Some(true),
-                    depth_compare: Some(CompareFunction::Greater),
-                    stencil: StencilState::default(),
-                    bias: DepthBiasState::default(),
-                }),
-                multisample: wgpu::MultisampleState {
-                    count: sample_count,
-                    mask: !0,
-                    alpha_to_coverage_enabled: false,
-                },
-                cache: None,
-                multiview_mask: None,
-            });
+        let device = graphics.device.clone();
+
+        let shader_dir = std::path::PathBuf::from(concat!(
+            env!("CARGO_MANIFEST_DIR"),
+            "/src/shaders"
+        ));
+
+        let pipeline = HotPipeline::new(
+            device,
+            shader_dir.clone(),
+            move |device| {
+                let source = wesl::Wesl::new(&shader_dir)
+                    .compile(&ModulePath::from_path("/shader.wesl"))
+                    .map_err(|e| anyhow::anyhow!("{e}"))?
+                    .to_string();
+
+                // fixes early read
+                for ep in ["vs_main", "s_fs_main"] {
+                    if !source.contains(&format!("fn {ep}(")) {
+                        return Err(anyhow::anyhow!(
+                            "compiled shader is missing entry point '{ep}' \
+                            (file may have been read mid-write)"
+                        ));
+                    }
+                }
+
+                log::debug!("Compiled WGSL: {} bytes", source.len());
+
+                wgpu::naga::front::wgsl::parse_str(&source)
+                    .map_err(|e| anyhow::anyhow!("WGSL parse error: {}", e.emit_to_string(&source)))?;
+
+                let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+                    label: Some("viewport shaders"),
+                    source: wgpu::ShaderSource::Wgsl(source.into()),
+                });
 
-        log::debug!("Created main render pipeline");
+                let render_pipeline =
+                    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+                        label: Some("main render pipeline"),
+                        layout: Some(&pipeline_layout),
+                        vertex: wgpu::VertexState {
+                            module: &shader,
+                            entry_point: Some("vs_main"),
+                            buffers: &[model::ModelVertex::desc(), InstanceRaw::desc()],
+                            compilation_options: wgpu::PipelineCompilationOptions::default(),
+                        },
+                        fragment: Some(wgpu::FragmentState {
+                            module: &shader,
+                            entry_point: Some("s_fs_main"),
+                            targets: &[Some(wgpu::ColorTargetState {
+                                format: hdr_format,
+                                blend: Some(wgpu::BlendState::REPLACE),
+                                write_mask: wgpu::ColorWrites::ALL,
+                            })],
+                            compilation_options: wgpu::PipelineCompilationOptions::default(),
+                        }),
+                        primitive: wgpu::PrimitiveState {
+                            topology: wgpu::PrimitiveTopology::TriangleList,
+                            strip_index_format: None,
+                            front_face: wgpu::FrontFace::Cw,
+                            cull_mode: Some(wgpu::Face::Back),
+                            polygon_mode: wgpu::PolygonMode::Fill,
+                            unclipped_depth: false,
+                            conservative: false,
+                        },
+                        depth_stencil: Some(wgpu::DepthStencilState {
+                            format: Texture::DEPTH_FORMAT,
+                            depth_write_enabled: Some(true),
+                            depth_compare: Some(CompareFunction::Greater),
+                            stencil: StencilState::default(),
+                            bias: DepthBiasState::default(),
+                        }),
+                        multisample: wgpu::MultisampleState {
+                            count: sample_count,
+                            mask: !0,
+                            alpha_to_coverage_enabled: false,
+                        },
+                        cache: None,
+                        multiview_mask: None,
+                    });
+
+                log::debug!("Created main render pipeline");
+                Ok(render_pipeline)
+            },
+        )
+        .expect("failed to build initial main render pipeline");
 
         Self {
-            shader,
-            pipeline_layout,
             pipeline,
             per_frame: None,
             per_material: None,
@@ -113,16 +132,8 @@ impl DropbearShaderPipeline for MainRenderPipeline {
         }
     }
 
-    fn shader(&self) -> &Shader {
-        &self.shader
-    }
-
-    fn pipeline_layout(&self) -> &wgpu::PipelineLayout {
-        &self.pipeline_layout
-    }
-
-    fn pipeline(&self) -> &wgpu::RenderPipeline {
-        &self.pipeline
+    pub fn pipeline(&self) -> arc_swap::Guard<Arc<wgpu::RenderPipeline>> {
+        self.pipeline.get()
     }
 }
 
diff --git a/crates/eucalyptus-core/src/rendering.rs b/crates/eucalyptus-core/src/rendering.rs
index 71cfdc0..16558b0 100644
--- a/crates/eucalyptus-core/src/rendering.rs
+++ b/crates/eucalyptus-core/src/rendering.rs
@@ -343,7 +343,7 @@ impl RendererCommon {
                     timestamp_writes: None,
                     multiview_mask: None,
                 });
-                pass.set_pipeline(pipeline.pipeline());
+                pass.set_pipeline(&pipeline.pipeline());
                 pass.set_vertex_buffer(1, instance_buffer.slice(static_count as usize));
 
                 for mesh in &model.meshes {
@@ -445,7 +445,7 @@ impl RendererCommon {
                     timestamp_writes: None,
                     multiview_mask: None,
                 });
-                pass.set_pipeline(pipeline.pipeline());
+                pass.set_pipeline(&pipeline.pipeline());
                 pass.set_vertex_buffer(1, instance_buffer.slice(1));
 
                 for mesh in &model.meshes {
diff --git a/crates/eucalyptus-editor/Cargo.toml b/crates/eucalyptus-editor/Cargo.toml
index c2c070f..72b5226 100644
--- a/crates/eucalyptus-editor/Cargo.toml
+++ b/crates/eucalyptus-editor/Cargo.toml
@@ -60,6 +60,8 @@ puffin.workspace = true
 image.workspace = true
 rkyv.workspace = true
 wesl.workspace = true
+notify.workspace = true
+arc-swap.workspace = true
 
 [target.'cfg(unix)'.dependencies]
 daemonize = "0.5.0"
diff --git a/crates/eucalyptus-editor/src/editor/mod.rs b/crates/eucalyptus-editor/src/editor/mod.rs
index 55ee0ae..7ad2d8a 100644
--- a/crates/eucalyptus-editor/src/editor/mod.rs
+++ b/crates/eucalyptus-editor/src/editor/mod.rs
@@ -66,6 +66,7 @@ use std::cmp::PartialEq;
 use std::collections::{HashSet, VecDeque};
 use std::rc::Rc;
 use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Instant};
+use arc_swap::ArcSwap;
 use tokio::sync::oneshot;
 use transform_gizmo_egui::{EnumSet, Gizmo, GizmoMode, GizmoOrientation};
 use wgpu::{Color, Extent3d};
@@ -1623,8 +1624,8 @@ impl Editor {
         graphics: std::sync::Arc<dropbear_engine::graphics::SharedGraphicsContext>,
         skybox_texture: Option<&Vec<u8>>,
     ) {
-        self.main_render_pipeline = Some(MainRenderPipeline::new(graphics.clone()));
-        self.light_cube_pipeline = Some(LightCubePipeline::new(graphics.clone()));
+        let mut main_render_pipeline = MainRenderPipeline::new(graphics.clone());
+        let light_cube_pipeline = LightCubePipeline::new(graphics.clone());
         self.shader_globals = Some(GlobalsUniform::new(
             graphics.clone(),
             Some("editor shader globals"),
@@ -1659,18 +1660,13 @@ impl Editor {
             if let Ok(camera) = self.world.query_one::<&Camera>(camera_entity).get() {
                 self.animation_pipeline = Some(AnimationDefaults::new(graphics.clone()));
 
-                if let Some(main_pipeline) = self.main_render_pipeline.as_mut() {
-                    if let (Some(globals), Some(light_pipeline)) = (
-                        self.shader_globals.as_ref(),
-                        self.light_cube_pipeline.as_ref(),
-                    ) {
-                        let _ = main_pipeline.per_frame_bind_group(
-                            graphics.clone(),
-                            globals.buffer.buffer(),
-                            camera.buffer(),
-                            light_pipeline.light_buffer(),
-                        );
-                    }
+                if let Some(globals) = self.shader_globals.as_ref() {
+                    let _ = main_render_pipeline.per_frame_bind_group(
+                        graphics.clone(),
+                        globals.buffer.buffer(),
+                        camera.buffer(),
+                        light_cube_pipeline.light_buffer(),
+                    );
                 }
 
                 let sky_texture_result = HdrLoader::from_equirectangular_bytes(
@@ -1703,6 +1699,10 @@ impl Editor {
         if let Some(sky_pipeline) = pending_sky_pipeline {
             self.sky_pipeline = Some(sky_pipeline);
         }
+
+        // leave to last
+        self.main_render_pipeline = Some(main_render_pipeline);
+        self.light_cube_pipeline = Some(light_cube_pipeline);
     }
 
     /// Initialises another eucalyptus-editor play mode app as a separate process and monitors it in a separate thread.
diff --git a/crates/eucalyptus-editor/src/editor/scene.rs b/crates/eucalyptus-editor/src/editor/scene.rs
index 43d9d66..b296992 100644
--- a/crates/eucalyptus-editor/src/editor/scene.rs
+++ b/crates/eucalyptus-editor/src/editor/scene.rs
@@ -8,7 +8,6 @@ use dropbear_engine::{
     lighting::Light,
     scene::{Scene, SceneCommand},
 };
-use egui::UiBuilder;
 use eucalyptus_core::billboard::BillboardComponent;
 use eucalyptus_core::component::KotlinComponentDecl;
 use eucalyptus_core::properties::CustomProperties;
@@ -514,6 +513,7 @@ impl Scene for Editor {
             log_once::warn_once!("Animation pipeline not ready");
             return;
         };
+
         let per_frame_bind_group = pipeline.per_frame.as_ref()
             .expect("Per-frame bind group not initialised")
             .clone();
@@ -524,7 +524,7 @@ impl Scene for Editor {
             &graphics, &mut encoder, &hdr,
             &self.world, &batches, &model_cache,
             &per_frame_bind_group, environment_bind_group,
-            pipeline, animation_defaults,
+            &pipeline, animation_defaults,
             &self.instance_buffer_cache,
             &mut self.animated_instance_buffers,
             &mut self.animated_bind_group_cache,
diff --git a/crates/eucalyptus-editor/src/editor/ui/mod.rs b/crates/eucalyptus-editor/src/editor/ui/mod.rs
index 0516901..a0ed917 100644
--- a/crates/eucalyptus-editor/src/editor/ui/mod.rs
+++ b/crates/eucalyptus-editor/src/editor/ui/mod.rs
@@ -5,6 +5,7 @@ use glam::{Mat4, Vec2, Vec4};
 use kino_ui::camera::Camera2D;
 use std::sync::Arc;
 use wgpu::TextureFormat;
+use dropbear_engine::pipelines::HotPipeline;
 
 pub mod inspector;
 pub mod viewport;
@@ -117,15 +118,11 @@ pub struct UIGridPipeline {
     camera_buffer: wgpu::Buffer,
     camera_bind_group: wgpu::BindGroup,
 
-    pipeline: wgpu::RenderPipeline,
+    pipeline: HotPipeline,
 }
 
 impl UIGridPipeline {
     pub fn new(graphics: Arc<SharedGraphicsContext>) -> Self {
-        let shader = graphics
-            .device
-            .create_shader_module(wgpu::include_wgsl!("shader/grid.wgsl"));
-
         let camera_bind_group_layout =
             graphics
                 .device
@@ -161,56 +158,77 @@ impl UIGridPipeline {
                 }],
             });
 
-        let layout = graphics
-            .device
-            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
-                label: Some("ui grid pipeline layout"),
-                bind_group_layouts: &[Some(&camera_bind_group_layout)],
-                immediate_size: 0,
-            });
+        let layout = std::sync::Arc::new(
+            graphics
+                .device
+                .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+                    label: Some("ui grid pipeline layout"),
+                    bind_group_layouts: &[Some(&camera_bind_group_layout)],
+                    immediate_size: 0,
+                }),
+        );
 
         let sample_count: u32 = (*graphics.antialiasing.read()).into();
         let format = graphics.surface_format.add_srgb_suffix();
 
-        let pipeline = graphics
-            .device
-            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
-                label: Some("ui grid pipeline"),
-                layout: Some(&layout),
-                vertex: wgpu::VertexState {
-                    module: &shader,
-                    entry_point: Some("vs_main"),
-                    compilation_options: Default::default(),
-                    buffers: &[],
-                },
-                primitive: wgpu::PrimitiveState {
-                    topology: wgpu::PrimitiveTopology::TriangleList,
-                    strip_index_format: None,
-                    front_face: wgpu::FrontFace::Ccw,
-                    cull_mode: None,
-                    polygon_mode: wgpu::PolygonMode::Fill,
-                    unclipped_depth: false,
-                    conservative: false,
-                },
-                depth_stencil: None,
-                multisample: wgpu::MultisampleState {
-                    count: sample_count,
-                    mask: !0,
-                    alpha_to_coverage_enabled: false,
-                },
-                fragment: Some(wgpu::FragmentState {
-                    module: &shader,
-                    entry_point: Some("fs_main"),
-                    compilation_options: Default::default(),
-                    targets: &[Some(wgpu::ColorTargetState {
-                        format,
-                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
-                        write_mask: wgpu::ColorWrites::ALL,
-                    })],
-                }),
-                cache: None,
-                multiview_mask: None,
-            });
+        let device = graphics.device.clone();
+        let shader_dir = std::path::PathBuf::from(concat!(
+            env!("CARGO_MANIFEST_DIR"),
+            "/src/editor/ui/shader"
+        ));
+        let shader_file = shader_dir.join("grid.wgsl");
+
+        let pipeline = HotPipeline::new(
+            device,
+            shader_dir,
+            move |device| {
+                let source = std::fs::read_to_string(&shader_file)?;
+                let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+                    label: Some("ui grid shader"),
+                    source: wgpu::ShaderSource::Wgsl(source.into()),
+                });
+                let pipeline =
+                    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+                        label: Some("ui grid pipeline"),
+                        layout: Some(&layout),
+                        vertex: wgpu::VertexState {
+                            module: &shader,
+                            entry_point: Some("vs_main"),
+                            compilation_options: Default::default(),
+                            buffers: &[],
+                        },
+                        primitive: wgpu::PrimitiveState {
+                            topology: wgpu::PrimitiveTopology::TriangleList,
+                            strip_index_format: None,
+                            front_face: wgpu::FrontFace::Ccw,
+                            cull_mode: None,
+                            polygon_mode: wgpu::PolygonMode::Fill,
+                            unclipped_depth: false,
+                            conservative: false,
+                        },
+                        depth_stencil: None,
+                        multisample: wgpu::MultisampleState {
+                            count: sample_count,
+                            mask: !0,
+                            alpha_to_coverage_enabled: false,
+                        },
+                        fragment: Some(wgpu::FragmentState {
+                            module: &shader,
+                            entry_point: Some("fs_main"),
+                            compilation_options: Default::default(),
+                            targets: &[Some(wgpu::ColorTargetState {
+                                format,
+                                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
+                                write_mask: wgpu::ColorWrites::ALL,
+                            })],
+                        }),
+                        cache: None,
+                        multiview_mask: None,
+                    });
+                Ok(pipeline)
+            },
+        )
+        .expect("failed to build initial grid pipeline");
 
         Self {
             size: wgpu::Extent3d {
@@ -377,6 +395,8 @@ impl UIGridPipeline {
             (resolve_view, None)
         };
 
+        let pipeline = self.pipeline.get();
+
         {
             let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                 label: Some("ui viewport grid render pass"),
@@ -395,17 +415,13 @@ impl UIGridPipeline {
                 multiview_mask: None,
             });
 
-            self.render(&mut render_pass);
+            render_pass.set_pipeline(&pipeline);
+            render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
+            render_pass.draw(0..3, 0..1);
         }
 
         graphics.queue.submit(std::iter::once(encoder.finish()));
     }
-
-    pub fn render<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) {
-        render_pass.set_pipeline(&self.pipeline);
-        render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
-        render_pass.draw(0..3, 0..1);
-    }
 }
 
 #[repr(C)]
diff --git a/crates/eucalyptus-editor/src/lib.rs b/crates/eucalyptus-editor/src/lib.rs
index ccf1075..0418fb7 100644
--- a/crates/eucalyptus-editor/src/lib.rs
+++ b/crates/eucalyptus-editor/src/lib.rs
@@ -10,6 +10,7 @@ pub mod signal;
 pub mod spawn;
 pub mod stats;
 pub mod utils;
+pub mod shader;
 // pub mod outline;
 
 use editor::docks::asset_viewer::AssetViewerDock;
diff --git a/crates/eucalyptus-editor/src/menu.rs b/crates/eucalyptus-editor/src/menu.rs
index 16eb418..3203416 100644
--- a/crates/eucalyptus-editor/src/menu.rs
+++ b/crates/eucalyptus-editor/src/menu.rs
@@ -6,7 +6,7 @@ use dropbear_engine::{
     input::{Controller, Keyboard, Mouse},
     scene::{Scene, SceneCommand},
 };
-use egui::{self, FontId, Frame, RichText, Ui, UiBuilder};
+use egui::{self, FontId, Frame, RichText, Ui};
 use egui_toast::{ToastOptions, Toasts};
 use eucalyptus_core::config::ProjectConfig;
 use eucalyptus_core::states::PROJECT;
diff --git a/crates/eucalyptus-editor/src/shader.rs b/crates/eucalyptus-editor/src/shader.rs
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crates/eucalyptus-editor/src/shader.rs
diff --git a/crates/eucalyptus-editor/src/stats.rs b/crates/eucalyptus-editor/src/stats.rs
index 45b752f..3a5cfd9 100644
--- a/crates/eucalyptus-editor/src/stats.rs
+++ b/crates/eucalyptus-editor/src/stats.rs
@@ -3,7 +3,7 @@ use std::{collections::VecDeque, time::Instant};
 use dropbear_engine::WGPU_BACKEND;
 use dropbear_engine::input::{Controller, Keyboard, Mouse};
 use dropbear_engine::scene::Scene;
-use egui::{Color32, Context, RichText, Ui};
+use egui::{Color32, RichText, Ui};
 use egui_plot::{Legend, Line, Plot, PlotPoints};
 
 use dropbear_engine::gilrs;