kitgit

tirbofish/dropbear · diff

67ad3f2 · Thribhu K

feature: using WESL shaders for eay modularisation. wip: remake shaders to better suit PBR and hopefully not AI generated.

Unverified

diff --git a/Cargo.toml b/Cargo.toml
index d44e929..c9774c7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -37,7 +37,7 @@ env_logger = "0.11"
 futures = "0.3"
 gilrs = "0.11"
 git2 = { version = "0.20", features = ["vendored-openssl"] }
-glam = { version = "0.30", features = ["serde", "mint", "bytemuck", "rkyv", "bytechec"] } # required to be at 0.30 because of rapier3d not being updated yet
+glam = { version = "0.30", features = ["serde", "mint", "bytemuck", "rkyv", "bytecheck"] } # required to be at 0.30 because of rapier3d not being updated yet
 hecs = { version = "0.11", features = ["serde"] }
 log = "0.4"
 log-once = "0.4"
@@ -64,7 +64,7 @@ tree-sitter-kotlin = "0.3"
 libloading = "0.9"
 indexmap = "2.11"
 sha2 = "0.11"
-wesl = "0.3"
+wesl = { version = "0.3", features = ["package"] }
 dashmap = "6.1"
 open = "5.3"
 memory-stats = "1.2"
@@ -96,6 +96,12 @@ naga = { version = "29", features = ["wgsl-in"] }
 uuid = { version = "1.22", features = ["v4", "serde"] }
 splines = "5.0"
 
+[patch.crates-io]
+egui = { git = "https://github.com/emilk/egui", branch = "main" }
+emath = { git = "https://github.com/emilk/egui", branch = "main" }
+epaint = { git = "https://github.com/emilk/egui", branch = "main" }
+ecolor = { git = "https://github.com/emilk/egui", branch = "main" }
+
 [workspace.dependencies.image]
 version = "0.25"
 default-features = false
diff --git a/crates/dropbear-engine/Cargo.toml b/crates/dropbear-engine/Cargo.toml
index f76d848..95b17dc 100644
--- a/crates/dropbear-engine/Cargo.toml
+++ b/crates/dropbear-engine/Cargo.toml
@@ -1,6 +1,5 @@
 [package]
 name = "dropbear-engine"
-description = "A game engine made by tirbofish. Thats really it..."
 
 version.workspace = true
 edition.workspace = true
@@ -52,6 +51,7 @@ uuid.workspace = true
 float-derive.workspace = true
 rkyv.workspace = true
 bevy_mikktspace.workspace = true
+wesl.workspace = true
 
 [target.'cfg(not(target_os = "android"))'.dependencies]
 rfd.workspace = true
@@ -60,3 +60,4 @@ rfd.workspace = true
 slank = { path = "../slank", features = ["download-slang"] }
 naga.workspace = true
 anyhow.workspace = true
+wesl.workspace = true
diff --git a/crates/dropbear-engine/build.rs b/crates/dropbear-engine/build.rs
index 0c9ae06..efec77c 100644
--- a/crates/dropbear-engine/build.rs
+++ b/crates/dropbear-engine/build.rs
@@ -1,7 +1,17 @@
 use slank::{SlangShaderBuilder, SlangTarget};
-use std::path::Path;
 
 fn main() -> anyhow::Result<()> {
+
+    println!("cargo:rerun-if-changed=src/shaders");
+
+    compile_slang_shaders()?;
+
+    compile_wesl_shaders()?;
+
+    Ok(())
+}
+
+fn compile_slang_shaders() -> anyhow::Result<()> {
     // to copy paste:
     // let shader = Shader::from_slang(graphics.clone(), &slank::compiled::CompiledSlangShader::from_bytes("light cube", include_slang!("light_cube")));
 
@@ -13,43 +23,20 @@ fn main() -> anyhow::Result<()> {
         .add_source_path("src/shaders/blit.slang")?
         .compile_to_out_dir(SlangTarget::SpirV)?;
 
-    println!("cargo:rerun-if-changed=src/shaders");
-
-    validate_wgsl()?;
-
     Ok(())
 }
 
-fn validate_wgsl() -> anyhow::Result<()> {
-    let shader_dir = Path::new("src/shaders");
-
-    for entry in std::fs::read_dir(shader_dir)? {
-        let path = entry?.path();
-        if path.extension().and_then(|e| e.to_str()) != Some("wgsl") {
-            continue;
-        }
-
-        println!("cargo:rerun-if-changed={}", path.display());
-
-        let src = std::fs::read_to_string(&path)?;
-
-        let mut frontend = naga::front::wgsl::Frontend::new();
-        let module = frontend.parse(&src).unwrap_or_else(|e| {
-            panic!(
-                "WGSL parse error in {}:\n{}",
-                path.display(),
-                e.emit_to_string(&src)
-            );
-        });
-
-        let mut validator = naga::valid::Validator::new(
-            naga::valid::ValidationFlags::all(),
-            naga::valid::Capabilities::all(),
-        );
-        validator.validate(&module).unwrap_or_else(|e| {
-            panic!("WGSL validation error in {}:\n{:?}", path.display(), e);
-        });
-    }
+fn compile_wesl_shaders() -> anyhow::Result<()> {
+    wesl::PkgBuilder::new("dropbear_shaders")
+        .scan_root("src/shaders")
+        .expect("failed to scan WESL files")
+        .validate()
+        .inspect_err(|e| {
+            eprintln!("{e}");
+            panic!("{e}");
+        })?
+        .build_artifact()
+        .expect("failed to build artifact");
 
     Ok(())
-}
+}
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/pipelines/hdr.rs b/crates/dropbear-engine/src/pipelines/hdr.rs
index 74ed195..4bb7a7e 100644
--- a/crates/dropbear-engine/src/pipelines/hdr.rs
+++ b/crates/dropbear-engine/src/pipelines/hdr.rs
@@ -1,6 +1,6 @@
 use crate::pipelines::create_render_pipeline;
 use crate::texture::{Texture, TextureBuilder};
-use wgpu::Operations;
+use wgpu::{Operations, ShaderModuleDescriptor};
 use wgpu::util::DeviceExt;
 
 #[repr(C)]
@@ -122,7 +122,21 @@ impl HdrPipeline {
         });
 
         // We'll cover the shader next
-        let shader = wgpu::include_wgsl!("../shaders/hdr.wgsl");
+        let source = wesl::Wesl::new("src/shaders")
+            .add_package(&crate::shader::code::PACKAGE)
+            .compile(&"dropbear_shaders::hdr".parse().unwrap())
+            .inspect_err(|e| {
+                panic!("{e}");
+            })
+            .unwrap()
+            .to_string();
+
+
+        let shader = ShaderModuleDescriptor {
+            label: Some("hdr shader"),
+            source: wgpu::ShaderSource::Wgsl(source.into()),
+        };
+
         let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
             label: None,
             bind_group_layouts: &[Some(&layout)],
diff --git a/crates/dropbear-engine/src/pipelines/shader.rs b/crates/dropbear-engine/src/pipelines/shader.rs
index cecfed1..c51557e 100644
--- a/crates/dropbear-engine/src/pipelines/shader.rs
+++ b/crates/dropbear-engine/src/pipelines/shader.rs
@@ -7,7 +7,7 @@ use crate::texture::Texture;
 use std::sync::Arc;
 use wgpu::{CompareFunction, DepthBiasState, StencilState};
 
-/// As defined in `shaders/shader.wgsl`
+/// As defined in `shaders/shader.wesl`
 pub struct MainRenderPipeline {
     shader: Shader,
     pipeline_layout: wgpu::PipelineLayout,
@@ -21,9 +21,18 @@ pub struct MainRenderPipeline {
 
 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(),
-            include_str!("../shaders/shader.wgsl"),
+            &source,
             Some("viewport shaders"),
         );
 
@@ -104,6 +113,10 @@ impl DropbearShaderPipeline for MainRenderPipeline {
         }
     }
 
+    fn shader(&self) -> &Shader {
+        &self.shader
+    }
+
     fn pipeline_layout(&self) -> &wgpu::PipelineLayout {
         &self.pipeline_layout
     }
@@ -111,10 +124,6 @@ impl DropbearShaderPipeline for MainRenderPipeline {
     fn pipeline(&self) -> &wgpu::RenderPipeline {
         &self.pipeline
     }
-
-    fn shader(&self) -> &Shader {
-        &self.shader
-    }
 }
 
 impl MainRenderPipeline {
diff --git a/crates/dropbear-engine/src/shader.rs b/crates/dropbear-engine/src/shader.rs
index ce4185f..cf3b660 100644
--- a/crates/dropbear-engine/src/shader.rs
+++ b/crates/dropbear-engine/src/shader.rs
@@ -6,6 +6,11 @@ use std::ops::Deref;
 use std::sync::Arc;
 use wgpu::ShaderModule;
 
+pub mod code {
+    wesl::wesl_pkg!(dropbear_shaders);
+    pub use dropbear_shaders::*;
+}
+
 /// A nice little struct that stored basic information about a WGPU shaders.
 pub struct Shader {
     /// The label of the shader.
@@ -21,7 +26,7 @@ pub struct Shader {
 
     /// The content of the shader as a readable string content, in the case you need to look
     /// at the original source.
-    pub content: String,
+    pub content: Option<String>,
 }
 
 impl Deref for Shader {
@@ -57,7 +62,7 @@ impl Shader {
                 None => "shader".into(),
             },
             module,
-            content: shader_file_contents.to_string(),
+            content: Some(shader_file_contents.to_string()),
         }
     }
 
@@ -75,7 +80,30 @@ impl Shader {
         Self {
             label: shader.label().clone(),
             module,
-            content: String::from_utf8(shader.source.clone()).unwrap_or_default(),
+            content: None,
+        }
+    }
+    
+    /// At this point, just create the shader manually lol
+    pub fn from_descriptor(
+        graphics: Arc<SharedGraphicsContext>, 
+        desc: wgpu::ShaderModuleDescriptor,
+        label: Option<&str>
+    ) -> Self {
+        let module = graphics.device.create_shader_module(desc);
+        puffin::profile_function!();
+
+        log::debug!("Created new shaders under the label: {:?}", label);
+
+        CompiledSlangShader::from_bytes("light cube", slank::include_slang!("light_cube"));
+
+        Self {
+            label: match label {
+                Some(label) => label.into(),
+                None => "shader".into(),
+            },
+            module,
+            content: None,
         }
     }
 }
diff --git a/crates/dropbear-engine/src/shaders/common/animation.wesl b/crates/dropbear-engine/src/shaders/common/animation.wesl
new file mode 100644
index 0000000..4e50372
--- /dev/null
+++ b/crates/dropbear-engine/src/shaders/common/animation.wesl
@@ -0,0 +1,57 @@
+struct MorphTargetInfo {
+    num_vertices:  u32,
+    num_targets:   u32,
+    base_offset:   u32,
+    weight_offset: u32,
+    uses_morph:    u32,
+}
+
+@group(2) @binding(0) var<storage, read> s_skinning:     array<mat4x4<f32>>;
+@group(2) @binding(1) var<storage, read> s_morph_deltas: array<f32>;
+@group(2) @binding(2) var<storage, read> s_morph_weights: array<f32>;
+@group(2) @binding(3) var<uniform>       u_morph_info:   MorphTargetInfo;
+
+fn apply_morph(base_pos: vec3<f32>, vertex_id: u32) -> vec3<f32> {
+    var result = base_pos;
+    for (var t = 0u; t < u_morph_info.num_targets; t++) {
+        let weight = s_morph_weights[u_morph_info.weight_offset + t];
+        let idx    = u_morph_info.base_offset + (t * u_morph_info.num_vertices + vertex_id) * 3u;
+        let delta  = vec3<f32>(
+            s_morph_deltas[idx],
+            s_morph_deltas[idx + 1u],
+            s_morph_deltas[idx + 2u],
+        );
+        result += delta * weight;
+    }
+    return result;
+}
+
+struct AnimatedVertex {
+    position: vec3<f32>,
+    normal:   vec3<f32>,
+    tangent:  vec3<f32>,
+}
+
+fn apply_animation(
+    position:  vec3<f32>,
+    normal:    vec3<f32>,
+    tangent:   vec3<f32>,
+    joints:    vec4<u32>,
+    weights:   vec4<f32>,
+    vertex_id: u32,
+) -> AnimatedVertex {
+    // Morph targets are applied first (in bind-pose space, per glTF spec)
+    let morphed_pos = apply_morph(position, vertex_id);
+
+    let skin_matrix =
+        weights.x * s_skinning[joints.x] +
+        weights.y * s_skinning[joints.y] +
+        weights.z * s_skinning[joints.z] +
+        weights.w * s_skinning[joints.w];
+
+    let skinned_pos     = (skin_matrix * vec4<f32>(morphed_pos, 1.0)).xyz;
+    let skinned_normal  = normalize((skin_matrix * vec4<f32>(normal,  0.0)).xyz);
+    let skinned_tangent = normalize((skin_matrix * vec4<f32>(tangent, 0.0)).xyz);
+
+    return AnimatedVertex(skinned_pos, skinned_normal, skinned_tangent);
+}
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/shaders/common/constants.wesl b/crates/dropbear-engine/src/shaders/common/constants.wesl
new file mode 100644
index 0000000..4d472c0
--- /dev/null
+++ b/crates/dropbear-engine/src/shaders/common/constants.wesl
@@ -0,0 +1 @@
+const PI: f32 = 3.14159265358979;
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/shaders/common/environment.wesl b/crates/dropbear-engine/src/shaders/common/environment.wesl
new file mode 100644
index 0000000..478d2f1
--- /dev/null
+++ b/crates/dropbear-engine/src/shaders/common/environment.wesl
@@ -0,0 +1,2 @@
+@group(3) @binding(0) var env_map:     texture_cube<f32>;
+@group(3) @binding(1) var env_sampler: sampler;
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/shaders/common/light.wesl b/crates/dropbear-engine/src/shaders/common/light.wesl
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crates/dropbear-engine/src/shaders/common/light.wesl
diff --git a/crates/dropbear-engine/src/shaders/common/material.wesl b/crates/dropbear-engine/src/shaders/common/material.wesl
new file mode 100644
index 0000000..f4b61f2
--- /dev/null
+++ b/crates/dropbear-engine/src/shaders/common/material.wesl
@@ -0,0 +1,28 @@
+struct MaterialUniform {
+    base_colour:       vec4<f32>,
+    emissive:          vec3<f32>,
+    emissive_strength: f32,
+    metallic:          f32,
+    roughness:         f32,
+    normal_scale:      f32,
+    occlusion_strength: f32,
+    alpha_cutoff:      f32,
+    uv_tiling:         vec2<f32>,
+
+    has_normal_texture:   u32,
+    has_emissive_texture: u32,
+    has_metallic_texture: u32,
+    has_occlusion_texture: u32,
+}
+
+@group(1) @binding(0)  var<uniform>  u_material:  MaterialUniform;
+@group(1) @binding(1)  var           t_diffuse:   texture_2d<f32>;
+@group(1) @binding(2)  var           s_diffuse:   sampler;
+@group(1) @binding(3)  var           t_normal:    texture_2d<f32>;
+@group(1) @binding(4)  var           s_normal:    sampler;
+@group(1) @binding(5)  var           t_emissive:  texture_2d<f32>;
+@group(1) @binding(6)  var           s_emissive:  sampler;
+@group(1) @binding(7)  var           t_metallic:  texture_2d<f32>;
+@group(1) @binding(8)  var           s_metallic:  sampler;
+@group(1) @binding(9)  var           t_occlusion: texture_2d<f32>;
+@group(1) @binding(10) var           s_occlusion: sampler;
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/shaders/hdr.wesl b/crates/dropbear-engine/src/shaders/hdr.wesl
new file mode 100644
index 0000000..db32074
--- /dev/null
+++ b/crates/dropbear-engine/src/shaders/hdr.wesl
@@ -0,0 +1,67 @@
+// Maps HDR values to linear values
+// Based on http://www.oscars.org/science-technology/sci-tech-projects/aces
+fn aces_tone_map(hdr: vec3<f32>) -> vec3<f32> {
+    let m1 = mat3x3(
+        0.59719, 0.07600, 0.02840,
+        0.35458, 0.90834, 0.13383,
+        0.04823, 0.01566, 0.83777,
+    );
+    let m2 = mat3x3(
+        1.60475, -0.10208, -0.00327,
+        -0.53108,  1.10813, -0.07276,
+        -0.07367, -0.00605,  1.07602,
+    );
+    let v = m1 * hdr;
+    let a = v * (v + 0.0245786) - 0.000090537;
+    let b = v * (0.983729 * v + 0.4329510) + 0.238081;
+    return clamp(m2 * (a / b), vec3(0.0), vec3(1.0));
+}
+
+struct VertexOutput {
+    @location(0) uv: vec2<f32>,
+    @builtin(position) clip_position: vec4<f32>,
+};
+
+@vertex
+fn vs_main(
+    @builtin(vertex_index) vi: u32,
+) -> VertexOutput {
+    var out: VertexOutput;
+    // Generate a triangle that covers the whole screen
+    out.uv = vec2<f32>(
+        f32((vi << 1u) & 2u),
+        f32(vi & 2u),
+    );
+    out.clip_position = vec4<f32>(out.uv * 2.0 - 1.0, 0.0, 1.0);
+    // We need to invert the y coordinate so the image
+    // is not upside down
+    out.uv.y = 1.0 - out.uv.y;
+    return out;
+}
+
+@group(0)
+@binding(0)
+var hdr_image: texture_2d<f32>;
+
+@group(0)
+@binding(1)
+var hdr_sampler: sampler;
+
+struct PostProcessSettings {
+    gamma: f32,
+    _pad0: f32,
+    _pad1: f32,
+    _pad2: f32,
+}
+
+@group(0)
+@binding(2)
+var<uniform> post_process: PostProcessSettings;
+
+@fragment
+fn fs_main(vs: VertexOutput) -> @location(0) vec4<f32> {
+    let hdr = textureSample(hdr_image, hdr_sampler, vs.uv);
+    let sdr = aces_tone_map(hdr.rgb);
+    let gamma_corrected = pow(sdr, vec3<f32>(1.0 / post_process.gamma));
+    return vec4(gamma_corrected, hdr.a);
+}
diff --git a/crates/dropbear-engine/src/shaders/hdr.wgsl b/crates/dropbear-engine/src/shaders/hdr.wgsl
deleted file mode 100644
index db32074..0000000
--- a/crates/dropbear-engine/src/shaders/hdr.wgsl
+++ /dev/null
@@ -1,67 +0,0 @@
-// Maps HDR values to linear values
-// Based on http://www.oscars.org/science-technology/sci-tech-projects/aces
-fn aces_tone_map(hdr: vec3<f32>) -> vec3<f32> {
-    let m1 = mat3x3(
-        0.59719, 0.07600, 0.02840,
-        0.35458, 0.90834, 0.13383,
-        0.04823, 0.01566, 0.83777,
-    );
-    let m2 = mat3x3(
-        1.60475, -0.10208, -0.00327,
-        -0.53108,  1.10813, -0.07276,
-        -0.07367, -0.00605,  1.07602,
-    );
-    let v = m1 * hdr;
-    let a = v * (v + 0.0245786) - 0.000090537;
-    let b = v * (0.983729 * v + 0.4329510) + 0.238081;
-    return clamp(m2 * (a / b), vec3(0.0), vec3(1.0));
-}
-
-struct VertexOutput {
-    @location(0) uv: vec2<f32>,
-    @builtin(position) clip_position: vec4<f32>,
-};
-
-@vertex
-fn vs_main(
-    @builtin(vertex_index) vi: u32,
-) -> VertexOutput {
-    var out: VertexOutput;
-    // Generate a triangle that covers the whole screen
-    out.uv = vec2<f32>(
-        f32((vi << 1u) & 2u),
-        f32(vi & 2u),
-    );
-    out.clip_position = vec4<f32>(out.uv * 2.0 - 1.0, 0.0, 1.0);
-    // We need to invert the y coordinate so the image
-    // is not upside down
-    out.uv.y = 1.0 - out.uv.y;
-    return out;
-}
-
-@group(0)
-@binding(0)
-var hdr_image: texture_2d<f32>;
-
-@group(0)
-@binding(1)
-var hdr_sampler: sampler;
-
-struct PostProcessSettings {
-    gamma: f32,
-    _pad0: f32,
-    _pad1: f32,
-    _pad2: f32,
-}
-
-@group(0)
-@binding(2)
-var<uniform> post_process: PostProcessSettings;
-
-@fragment
-fn fs_main(vs: VertexOutput) -> @location(0) vec4<f32> {
-    let hdr = textureSample(hdr_image, hdr_sampler, vs.uv);
-    let sdr = aces_tone_map(hdr.rgb);
-    let gamma_corrected = pow(sdr, vec3<f32>(1.0 / post_process.gamma));
-    return vec4(gamma_corrected, hdr.a);
-}
diff --git a/crates/dropbear-engine/src/shaders/shader.wesl b/crates/dropbear-engine/src/shaders/shader.wesl
new file mode 100644
index 0000000..867b04e
--- /dev/null
+++ b/crates/dropbear-engine/src/shaders/shader.wesl
@@ -0,0 +1,112 @@
+import super::common::animation;
+import super::common::material;
+import super::common::environment;
+
+struct Globals {
+    num_lights:       u32,
+    ambient_strength: f32,
+}
+
+struct CameraUniform {
+    view_pos:  vec4<f32>,
+    view:      mat4x4<f32>,
+    view_proj: mat4x4<f32>,
+    inv_proj:  mat4x4<f32>,
+    inv_view:  mat4x4<f32>,
+}
+
+struct Light {
+    position:  vec4<f32>,
+    direction: vec4<f32>, // x, y, z, outer_cutoff_angle
+    color:     vec4<f32>, // r, g, b, light_type (0=directional, 1=point, 2=spot)
+    constant:  f32,
+    lin:       f32,
+    quadratic: f32,
+    cutoff:    f32,
+}
+
+// per-frame
+@group(0) @binding(0) var<uniform>       u_globals:     Globals;
+@group(0) @binding(1) var<uniform>       u_camera:      CameraUniform;
+@group(0) @binding(2) var<storage, read> s_light_array: array<Light>;
+
+struct InstanceInput {
+    @location(8)  model_matrix_0: vec4<f32>,
+    @location(9)  model_matrix_1: vec4<f32>,
+    @location(10) model_matrix_2: vec4<f32>,
+    @location(11) model_matrix_3: vec4<f32>,
+
+    @location(12) normal_matrix_0: vec3<f32>,
+    @location(13) normal_matrix_1: vec3<f32>,
+    @location(14) normal_matrix_2: vec3<f32>,
+};
+
+struct VertexInput {
+    @builtin(vertex_index) vertex_id:  u32,
+    @location(0) position:   vec3<f32>,
+    @location(1) normal:     vec3<f32>,
+    @location(2) tangent:    vec4<f32>,
+    @location(3) tex_coords0: vec2<f32>,
+    @location(4) tex_coords1: vec2<f32>,
+    @location(5) colour0:    vec4<f32>,
+    @location(6) joints:     vec4<u32>,
+    @location(7) weights:    vec4<f32>,
+};
+
+struct VertexOutput {
+    @builtin(position) clip_position:     vec4<f32>,
+    @location(0) tex_coords:              vec2<f32>,
+    @location(1) world_normal:            vec3<f32>,
+    @location(2) world_position:          vec3<f32>,
+    @location(3) world_tangent:           vec3<f32>,
+    @location(4) world_bitangent:         vec3<f32>,
+    @location(5) world_view_position:     vec3<f32>,
+};
+
+
+
+@vertex
+fn vs_main(model: VertexInput, instance: InstanceInput) -> VertexOutput {
+    let model_matrix = mat4x4<f32>(
+        instance.model_matrix_0,
+        instance.model_matrix_1,
+        instance.model_matrix_2,
+        instance.model_matrix_3,
+    );
+    let normal_matrix = mat3x3<f32>(
+        instance.normal_matrix_0,
+        instance.normal_matrix_1,
+        instance.normal_matrix_2,
+    );
+
+    let anim = animation::apply_animation(
+        model.position,
+        model.normal,
+        model.tangent.xyz,
+        model.joints,
+        model.weights,
+        model.vertex_id,
+    );
+
+    let world_position  = model_matrix * vec4<f32>(anim.position, 1.0);
+    let world_normal    = normalize(normal_matrix * anim.normal);
+    let world_tangent   = normalize(normal_matrix * anim.tangent);
+    let world_bitangent = normalize(cross(world_normal, world_tangent) * model.tangent.w);
+
+    var out: VertexOutput;
+    out.clip_position       = u_camera.view_proj * world_position;
+    out.tex_coords          = model.tex_coords0;
+    out.world_normal        = world_normal;
+    out.world_position      = world_position.xyz;
+    out.world_tangent       = world_tangent;
+    out.world_bitangent     = world_bitangent;
+    out.world_view_position = u_camera.view_pos.xyz;
+    return out;
+}
+
+@fragment
+fn s_fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
+    let uv = in.tex_coords * material::u_material.uv_tiling;
+
+    return textureSample(material::t_diffuse, material::s_diffuse, uv);
+}
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/shaders/shader.wgsl b/crates/dropbear-engine/src/shaders/shader.wgsl
deleted file mode 100644
index 84f7b3f..0000000
--- a/crates/dropbear-engine/src/shaders/shader.wgsl
+++ /dev/null
@@ -1,409 +0,0 @@
-const PI: f32 = 3.14159265358979;
-
-struct Globals {
-    num_lights:       u32,
-    ambient_strength: f32,
-}
-
-struct CameraUniform {
-    view_pos:  vec4<f32>,
-    view:      mat4x4<f32>,
-    view_proj: mat4x4<f32>,
-    inv_proj:  mat4x4<f32>,
-    inv_view:  mat4x4<f32>,
-}
-
-struct Light {
-    position:  vec4<f32>,
-    direction: vec4<f32>, // x, y, z, outer_cutoff_angle
-    color:     vec4<f32>, // r, g, b, light_type (0=directional, 1=point, 2=spot)
-    constant:  f32,
-    lin:       f32,
-    quadratic: f32,
-    cutoff:    f32,
-}
-
-struct MaterialUniform {
-    base_colour:       vec4<f32>,
-    emissive:          vec3<f32>,
-    emissive_strength: f32,
-    metallic:          f32,
-    roughness:         f32,
-    normal_scale:      f32,
-    occlusion_strength: f32,
-    alpha_cutoff:      f32,
-    uv_tiling:         vec2<f32>,
-
-    has_normal_texture:   u32,
-    has_emissive_texture: u32,
-    has_metallic_texture: u32,
-    has_occlusion_texture: u32,
-}
-
-struct MorphTargetInfo {
-    num_vertices:  u32,
-    num_targets:   u32,
-    base_offset:   u32,
-    weight_offset: u32,
-    uses_morph:    u32,
-}
-
-// per-frame
-@group(0) @binding(0) var<uniform>       u_globals:     Globals;
-@group(0) @binding(1) var<uniform>       u_camera:      CameraUniform;
-@group(0) @binding(2) var<storage, read> s_light_array: array<Light>;
-
-// per-material
-@group(1) @binding(0)  var<uniform>  u_material:  MaterialUniform;
-@group(1) @binding(1)  var           t_diffuse:   texture_2d<f32>;
-@group(1) @binding(2)  var           s_diffuse:   sampler;
-@group(1) @binding(3)  var           t_normal:    texture_2d<f32>;
-@group(1) @binding(4)  var           s_normal:    sampler;
-@group(1) @binding(5)  var           t_emissive:  texture_2d<f32>;
-@group(1) @binding(6)  var           s_emissive:  sampler;
-@group(1) @binding(7)  var           t_metallic:  texture_2d<f32>;
-@group(1) @binding(8)  var           s_metallic:  sampler;
-@group(1) @binding(9)  var           t_occlusion: texture_2d<f32>;
-@group(1) @binding(10) var           s_occlusion: sampler;
-
-// animation
-@group(2) @binding(0) var<storage, read> s_skinning:     array<mat4x4<f32>>;
-@group(2) @binding(1) var<storage, read> s_morph_deltas: array<f32>;
-@group(2) @binding(2) var<storage, read> s_morph_weights: array<f32>;
-@group(2) @binding(3) var<uniform>       u_morph_info:   MorphTargetInfo;
-
-// environment
-@group(3) @binding(0) var env_map:     texture_cube<f32>;
-@group(3) @binding(1) var env_sampler: sampler;
-
-struct InstanceInput {
-    @location(8)  model_matrix_0: vec4<f32>,
-    @location(9)  model_matrix_1: vec4<f32>,
-    @location(10) model_matrix_2: vec4<f32>,
-    @location(11) model_matrix_3: vec4<f32>,
-
-    @location(12) normal_matrix_0: vec3<f32>,
-    @location(13) normal_matrix_1: vec3<f32>,
-    @location(14) normal_matrix_2: vec3<f32>,
-};
-
-struct VertexInput {
-    @builtin(vertex_index) vertex_id:  u32,
-    @location(0) position:   vec3<f32>,
-    @location(1) normal:     vec3<f32>,
-    @location(2) tangent:    vec4<f32>,
-    @location(3) tex_coords0: vec2<f32>,
-    @location(4) tex_coords1: vec2<f32>,
-    @location(5) colour0:    vec4<f32>,
-    @location(6) joints:     vec4<u32>,
-    @location(7) weights:    vec4<f32>,
-};
-
-struct VertexOutput {
-    @builtin(position) clip_position:     vec4<f32>,
-    @location(0) tex_coords:              vec2<f32>,
-    @location(1) world_normal:            vec3<f32>,
-    @location(2) world_position:          vec3<f32>,
-    @location(3) world_tangent:           vec3<f32>,
-    @location(4) world_bitangent:         vec3<f32>,
-    @location(5) world_view_position:     vec3<f32>,
-};
-
-fn apply_morph(base_pos: vec3<f32>, vertex_id: u32) -> vec3<f32> {
-    var result = base_pos;
-    for (var t = 0u; t < u_morph_info.num_targets; t++) {
-        let weight = s_morph_weights[u_morph_info.weight_offset + t];
-        let idx    = u_morph_info.base_offset + (t * u_morph_info.num_vertices + vertex_id) * 3u;
-        let delta  = vec3<f32>(
-            s_morph_deltas[idx],
-            s_morph_deltas[idx + 1u],
-            s_morph_deltas[idx + 2u],
-        );
-        result += delta * weight;
-    }
-    return result;
-}
-
-@vertex
-fn vs_main(model: VertexInput, instance: InstanceInput) -> VertexOutput {
-    let model_matrix = mat4x4<f32>(
-        instance.model_matrix_0,
-        instance.model_matrix_1,
-        instance.model_matrix_2,
-        instance.model_matrix_3,
-    );
-    let normal_matrix = mat3x3<f32>(
-        instance.normal_matrix_0,
-        instance.normal_matrix_1,
-        instance.normal_matrix_2,
-    );
-
-    var skin_matrix = mat4x4<f32>(
-        vec4<f32>(1.0, 0.0, 0.0, 0.0),
-        vec4<f32>(0.0, 1.0, 0.0, 0.0),
-        vec4<f32>(0.0, 0.0, 1.0, 0.0),
-        vec4<f32>(0.0, 0.0, 0.0, 1.0),
-    );
-    if (dot(model.weights, vec4<f32>(1.0)) > 0.0) {
-        let j = model.joints;
-        let w = model.weights;
-        skin_matrix =
-            s_skinning[j.x] * w.x +
-            s_skinning[j.y] * w.y +
-            s_skinning[j.z] * w.z +
-            s_skinning[j.w] * w.w;
-    }
-
-    let morphed_pos  = select(
-        model.position,
-        apply_morph(model.position, model.vertex_id),
-        u_morph_info.uses_morph != 0u,
-    );
-    let world_position = model_matrix * skin_matrix * vec4<f32>(morphed_pos, 1.0);
-
-    let skin_normal  = (skin_matrix * vec4<f32>(model.normal,      0.0)).xyz;
-    let skin_tangent = (skin_matrix * vec4<f32>(model.tangent.xyz, 0.0)).xyz;
-
-    let world_normal    = normalize(normal_matrix * skin_normal);
-    let world_tangent   = normalize(normal_matrix * skin_tangent);
-    let world_bitangent = normalize(cross(world_normal, world_tangent) * model.tangent.w);
-
-    var out: VertexOutput;
-    out.clip_position      = u_camera.view_proj * world_position;
-    out.tex_coords         = model.tex_coords0;
-    out.world_normal       = world_normal;
-    out.world_position     = world_position.xyz;
-    out.world_tangent      = world_tangent;
-    out.world_bitangent    = world_bitangent;
-    out.world_view_position = u_camera.view_pos.xyz;
-    return out;
-}
-
-fn distribution_ggx(n_dot_h: f32, roughness: f32) -> f32 {
-    let a  = roughness * roughness;
-    let a2 = a * a;
-    let d  = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0;
-    return a2 / (PI * d * d);
-}
-
-fn geometry_schlick_ggx(n_dot_x: f32, roughness: f32) -> f32 {
-    let r = roughness + 1.0;
-    let k = (r * r) / 8.0;
-    return n_dot_x / (n_dot_x * (1.0 - k) + k);
-}
-
-fn geometry_smith(n_dot_v: f32, n_dot_l: f32, roughness: f32) -> f32 {
-    return geometry_schlick_ggx(n_dot_v, roughness)
-         * geometry_schlick_ggx(n_dot_l, roughness);
-}
-
-fn fresnel_schlick(cos_theta: f32, f0: vec3<f32>) -> vec3<f32> {
-    return f0 + (1.0 - f0) * pow(clamp(1.0 - cos_theta, 0.0, 1.0), 5.0);
-}
-
-fn fresnel_schlick_roughness(cos_theta: f32, f0: vec3<f32>, roughness: f32) -> vec3<f32> {
-    let r1 = vec3<f32>(1.0 - roughness);
-    return f0 + (max(r1, f0) - f0) * pow(clamp(1.0 - cos_theta, 0.0, 1.0), 5.0);
-}
-
-fn pbr_direct(
-    n:         vec3<f32>,
-    v:         vec3<f32>,
-    l:         vec3<f32>,
-    albedo:    vec3<f32>,
-    f0:        vec3<f32>,
-    roughness: f32,
-    metallic:  f32,
-) -> vec3<f32> {
-    let h = normalize(v + l);
-
-    let n_dot_v = max(dot(n, v), 0.0001);
-    let n_dot_l = max(dot(n, l), 0.0);
-    let n_dot_h = max(dot(n, h), 0.0);
-    let h_dot_v = max(dot(h, v), 0.0);
-
-    let D = distribution_ggx(n_dot_h, roughness);
-    let G = geometry_smith(n_dot_v, n_dot_l, roughness);
-    let F = fresnel_schlick(h_dot_v, f0);
-
-    let numerator   = D * G * F;
-    let denominator = 4.0 * n_dot_v * n_dot_l + 0.0001;
-    let specular    = numerator / denominator;
-
-    let k_s = F;
-    let k_d = (1.0 - k_s) * (1.0 - metallic);
-
-    return (k_d * albedo / PI + specular) * n_dot_l;
-}
-
-fn directional_light_pbr(
-    light:     Light,
-    n:         vec3<f32>,
-    v:         vec3<f32>,
-    albedo:    vec3<f32>,
-    f0:        vec3<f32>,
-    roughness: f32,
-    metallic:  f32,
-) -> vec3<f32> {
-    let l = normalize(-light.direction.xyz);
-    return pbr_direct(n, v, l, albedo, f0, roughness, metallic) * light.color.rgb;
-}
-
-fn point_light_pbr(
-    light:     Light,
-    n:         vec3<f32>,
-    v:         vec3<f32>,
-    albedo:    vec3<f32>,
-    f0:        vec3<f32>,
-    roughness: f32,
-    metallic:  f32,
-    world_pos: vec3<f32>,
-) -> vec3<f32> {
-    let to_light = light.position.xyz - world_pos;
-    let dist     = length(to_light);
-    let l        = to_light / dist;
-    let atten    = 1.0 / (light.constant + light.lin * dist + light.quadratic * dist * dist);
-    return pbr_direct(n, v, l, albedo, f0, roughness, metallic) * light.color.rgb * atten;
-}
-
-fn spot_light_pbr(
-    light:     Light,
-    n:         vec3<f32>,
-    v:         vec3<f32>,
-    albedo:    vec3<f32>,
-    f0:        vec3<f32>,
-    roughness: f32,
-    metallic:  f32,
-    world_pos: vec3<f32>,
-) -> vec3<f32> {
-    let to_light     = light.position.xyz - world_pos;
-    let dist         = length(to_light);
-    let l            = to_light / dist;
-    let atten        = 1.0 / (light.constant + light.lin * dist + light.quadratic * dist * dist);
-
-    let spot_dir     = normalize(light.direction.xyz);
-    let theta        = dot(-l, spot_dir);
-    let outer_cutoff = light.direction.w;
-    let epsilon      = light.cutoff - outer_cutoff;
-    let cone_factor  = clamp((theta - outer_cutoff) / epsilon, 0.0, 1.0);
-
-    return pbr_direct(n, v, l, albedo, f0, roughness, metallic)
-         * light.color.rgb * atten * cone_factor;
-}
-
-fn ibl(
-    n:         vec3<f32>,
-    v:         vec3<f32>,
-    albedo:    vec3<f32>,
-    f0:        vec3<f32>,
-    roughness: f32,
-    metallic:  f32,
-) -> vec3<f32> {
-    let n_dot_v  = max(dot(n, v), 0.0001);
-    let num_mips = f32(textureNumLevels(env_map));
-
-    let F   = fresnel_schlick_roughness(n_dot_v, f0, roughness);
-    let k_s = F;
-    let k_d = (1.0 - k_s) * (1.0 - metallic);
-
-    // diffuse irradiance: most-blurred mip approximates hemisphere integral
-    let irradiance  = textureSampleLevel(env_map, env_sampler, n, num_mips - 1.0).rgb;
-    let diffuse_ibl = k_d * albedo * irradiance;
-
-    // specular: rougher surfaces sample blurrier mips
-    let r            = reflect(-v, n);
-    let specular_mip = roughness * roughness * (num_mips - 1.0);
-    let prefiltered  = textureSampleLevel(env_map, env_sampler, r, specular_mip).rgb;
-
-    // analytic BRDF integration approximation (no LUT required)
-    let env_brdf_x   = exp(-6.9 * roughness * roughness * n_dot_v);
-    let env_brdf     = F * (1.0 - env_brdf_x) + f0 * env_brdf_x;
-    let specular_ibl = prefiltered * env_brdf;
-
-    return diffuse_ibl + specular_ibl;
-}
-
-fn get_normal(in: VertexOutput, uv: vec2<f32>) -> vec3<f32> {
-    if u_material.has_normal_texture == 0u {
-        return normalize(in.world_normal);
-    }
-
-    let raw     = textureSample(t_normal, s_normal, uv).rgb;
-    let unpacked = normalize((raw * 2.0 - 1.0) * vec3<f32>(u_material.normal_scale, u_material.normal_scale, 1.0));
-
-    let n = normalize(in.world_normal);
-    // Gram-Schmidt re-orthogonalise tangent against normal
-    var t = normalize(in.world_tangent);
-    t = normalize(t - n * dot(n, t));
-
-    let b_in       = normalize(in.world_bitangent);
-    let handedness = select(-1.0, 1.0, dot(cross(n, t), b_in) >= 0.0);
-    let b          = cross(n, t) * handedness;
-
-    let tbn = mat3x3<f32>(t, b, n);
-    return normalize(tbn * unpacked);
-}
-
-@fragment
-fn s_fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
-    let uv = in.tex_coords * u_material.uv_tiling;
-
-    // albedo + alpha
-    let albedo_sample = textureSample(t_diffuse, s_diffuse, uv);
-    let base_colour   = albedo_sample * u_material.base_colour;
-    if base_colour.a < u_material.alpha_cutoff {
-        discard;
-    }
-    let albedo = base_colour.rgb;
-
-    // metallic / roughness — glTF: B = metallic, G = roughness
-    var metallic  = u_material.metallic;
-    var roughness = u_material.roughness;
-    if u_material.has_metallic_texture != 0u {
-        let mr    = textureSample(t_metallic, s_metallic, uv);
-        metallic  *= mr.b;
-        roughness *= mr.g;
-    }
-    metallic  = clamp(metallic,  0.0,  1.0);
-    roughness = clamp(roughness, 0.04, 1.0);
-
-    // occlusion
-    var occlusion = 1.0;
-    if u_material.has_occlusion_texture != 0u {
-        let occ   = textureSample(t_occlusion, s_occlusion, uv).r;
-        occlusion = 1.0 + u_material.occlusion_strength * (occ - 1.0);
-    }
-
-    // emissive
-    var emissive = u_material.emissive * u_material.emissive_strength;
-    if u_material.has_emissive_texture != 0u {
-        emissive *= textureSample(t_emissive, s_emissive, uv).rgb;
-    }
-
-    let n   = get_normal(in, uv);
-    let v   = normalize(u_camera.view_pos.xyz - in.world_position);
-    let f0  = mix(vec3<f32>(0.04), albedo, metallic);
-
-    // direct lighting (Cook-Torrance GGX)
-    var lo = vec3<f32>(0.0);
-    for (var i = 0u; i < u_globals.num_lights; i++) {
-        let light      = s_light_array[i];
-        let light_type = i32(light.color.w + 0.1);
-
-        if light_type == 0 {
-            lo += directional_light_pbr(light, n, v, albedo, f0, roughness, metallic);
-        } else if light_type == 1 {
-            lo += point_light_pbr(light, n, v, albedo, f0, roughness, metallic, in.world_position);
-        } else if light_type == 2 {
-            lo += spot_light_pbr(light, n, v, albedo, f0, roughness, metallic, in.world_position);
-        }
-    }
-
-    // image-based lighting
-    let ambient_ibl = ibl(n, v, albedo, f0, roughness, metallic)
-                    * occlusion
-                    * u_globals.ambient_strength;
-
-    let colour = lo + ambient_ibl + emissive;
-    return vec4<f32>(colour, base_colour.a);
-}
\ No newline at end of file