diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a53f0a43..4d954aed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,9 @@ jobs: example: - examples/cube - examples/pill_tunel + - examples/pbr_helmet + - examples/pbr_balls + - examples/sponza steps: - uses: actions/checkout@v4 @@ -176,6 +179,8 @@ jobs: workspaces: | examples/cube/build/wasm/.build/pill_web -> target examples/pill_tunel/build/wasm/.build/pill_web -> target + examples/pbr_helmet/build/wasm/.build/pill_web -> target + examples/pbr_balls/build/wasm/.build/pill_web -> target - uses: jetli/wasm-pack-action@v0.4.0 with: diff --git a/.gitignore b/.gitignore index 08957af5..2f78f2f6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target/ build/ .vscode/ *.lock +.claude docs/* engine/pill_engine/docs/uml_out/* @@ -10,3 +11,12 @@ engine/target_* *.log *.wgsl + +*.cooked_mesh +*.cooked_tex +DamagedHelmet.glb +MetalRoughSpheres.glb +studio_small_08.hdr + +# Workspace placeholder member (see engine/Cargo.toml); resolves cargo at rest +/engine/NO_PATH/ diff --git a/engine/.cargo/config.toml b/engine/.cargo/config.toml new file mode 100644 index 00000000..067b86ce --- /dev/null +++ b/engine/.cargo/config.toml @@ -0,0 +1,18 @@ +# Enable native CPU SIMD (AVX2/AVX512 on x86_64, NEON on ARM) for non-WASM builds. +# Without this the compiler defaults to SSE2 baseline on x86_64. +# wasm32 intentionally absent — target-cpu=native breaks WASM builds. + +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=native"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "target-cpu=native"] + +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-cpu=native"] + +[target.aarch64-apple-darwin] +rustflags = ["-C", "target-cpu=native"] + +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=native"] diff --git a/engine/Cargo.toml b/engine/Cargo.toml index adcc520c..31944895 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -5,7 +5,6 @@ members = [ "pill_assets", "pill_core", "pill_engine", - "pill_renderer", "pill_native", "pill_web", "pill_runtime", diff --git a/engine/pill_assets/Cargo.toml b/engine/pill_assets/Cargo.toml index 6ab39832..ef3a0914 100644 --- a/engine/pill_assets/Cargo.toml +++ b/engine/pill_assets/Cargo.toml @@ -8,7 +8,18 @@ path = "src/lib.rs" [dependencies] anyhow = "1.0" -glob = "0.3" -png = "0.17" -tobj = "3.0" bytemuck = { version = "1.4", features = ["derive"] } +# Lean Radiance HDR decoder for `ibl::equirect_from_hdr` (runtime + build). Wasm-safe; far smaller +# than pulling the full `image` crate just for HDR. +zune-hdr = "0.4" + +# Build-time-only (filesystem Rules); not compiled into wasm runtime builds. +glob = { version = "0.3", optional = true } +png = { version = "0.17", optional = true } +tobj = { version = "3.0", optional = true } +gltf = { version = "1", default-features = false, features = ["utils", "names"], optional = true } + +[features] +default = ["build"] +# Filesystem asset pipeline (HLSL→WGSL, glb/obj/png/equirect cooking). Used by build.rs / launcher. +build = ["dep:glob", "dep:png", "dep:tobj", "dep:gltf", "gltf/import"] diff --git a/engine/pill_assets/src/convert.rs b/engine/pill_assets/src/convert.rs new file mode 100644 index 00000000..8f54eb32 --- /dev/null +++ b/engine/pill_assets/src/convert.rs @@ -0,0 +1,195 @@ +//! Shared mesh/texture byte-format conversion — the single source of the cooked formats. +//! +//! Pure (no filesystem, no glTF/image deps): the vertex assembly, tangent generation, matrix math +//! and `RMSH`/`RTEX` writers used by both the build-time cooker (`GlbToCookedMesh`) and the +//! engine's runtime glTF loader. + +use bytemuck::{Pod, Zeroable}; + +/// Cooked vertex layout. Must stay bit-for-bit identical to `pill_engine::resources::MeshVertex` +/// (the engine casts `RMSH` bytes straight to that type). +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +pub struct CookedVertex { + pub position: [f32; 3], + pub texture_coordinates: [f32; 2], + pub normal: [f32; 3], + pub tangent: [f32; 3], + pub bitangent: [f32; 3], +} + +pub type Mat4 = [[f32; 4]; 4]; + +pub const IDENTITY: Mat4 = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], +]; + +/// Builds cooked vertices for one primitive, baking `world` into positions/normals/tangents. +/// If `tangents` is empty, tangents/bitangents are derived from positions+UVs. +pub fn build_vertices( + positions: &[[f32; 3]], + normals: &[[f32; 3]], + uvs: &[[f32; 2]], + tangents: &[[f32; 4]], + indices: &[u32], + world: Mat4, +) -> Vec { + let mut vertices: Vec = (0..positions.len()) + .map(|i| { + let n = transform_normal(world, normals[i]); + let (tx, ty, tz, sign) = tangents + .get(i) + .map(|t| (t[0], t[1], t[2], t[3])) + .unwrap_or((1.0, 0.0, 0.0, 1.0)); + let t_world = transform_normal(world, [tx, ty, tz]); + let bx = (n[1] * t_world[2] - n[2] * t_world[1]) * sign; + let by = (n[2] * t_world[0] - n[0] * t_world[2]) * sign; + let bz = (n[0] * t_world[1] - n[1] * t_world[0]) * sign; + CookedVertex { + position: transform_point(world, positions[i]), + texture_coordinates: uvs.get(i).copied().unwrap_or([0.0, 0.0]), + normal: n, + tangent: t_world, + bitangent: [bx, by, bz], + } + }) + .collect(); + + if tangents.is_empty() { + compute_tangents(&mut vertices, indices); + } + vertices +} + +/// RMSH v1: `b"RMSH"` + u32 version=1 + u32 vertex_count + u32 index_count + vertices + indices. +pub fn rmsh_bytes(vertices: &[CookedVertex], indices: &[u32]) -> Vec { + let vertex_bytes: &[u8] = bytemuck::cast_slice(vertices); + let index_bytes: &[u8] = bytemuck::cast_slice(indices); + let mut out = Vec::with_capacity(16 + vertex_bytes.len() + index_bytes.len()); + out.extend_from_slice(b"RMSH"); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&(vertices.len() as u32).to_le_bytes()); + out.extend_from_slice(&(indices.len() as u32).to_le_bytes()); + out.extend_from_slice(vertex_bytes); + out.extend_from_slice(index_bytes); + out +} + +/// RTEX v1: `b"RTEX"` + u32 version=1 + u32 width + u32 height + raw RGBA8. +pub fn rtex_bytes(w: u32, h: u32, rgba: &[u8]) -> Vec { + let mut out = Vec::with_capacity(16 + rgba.len()); + out.extend_from_slice(b"RTEX"); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + out.extend_from_slice(rgba); + out +} + +// --- Tangent generation (Lengyel) --- + +pub fn compute_tangents(vertices: &mut [CookedVertex], indices: &[u32]) { + let mut triangle_counts = vec![0usize; vertices.len()]; + + for tri in indices.chunks(3) { + let (i0, i1, i2) = (tri[0] as usize, tri[1] as usize, tri[2] as usize); + let p0 = vertices[i0].position; + let p1 = vertices[i1].position; + let p2 = vertices[i2].position; + let uv0 = vertices[i0].texture_coordinates; + let uv1 = vertices[i1].texture_coordinates; + let uv2 = vertices[i2].texture_coordinates; + + let dp1 = sub3(p1, p0); + let dp2 = sub3(p2, p0); + let duv1 = sub2(uv1, uv0); + let duv2 = sub2(uv2, uv0); + + let det = duv1[0] * duv2[1] - duv1[1] * duv2[0]; + if det.abs() < 1e-8 { + continue; + } + let inv = 1.0 / det; + let tangent = scale3(sub3(scale3(dp1, duv2[1]), scale3(dp2, duv1[1])), inv); + let bitangent = scale3(sub3(scale3(dp2, duv1[0]), scale3(dp1, duv2[0])), inv); + + for &i in &[i0, i1, i2] { + vertices[i].tangent = add3(vertices[i].tangent, tangent); + vertices[i].bitangent = add3(vertices[i].bitangent, bitangent); + triangle_counts[i] += 1; + } + } + + for (i, &count) in triangle_counts.iter().enumerate() { + if count > 0 { + let inv = 1.0 / count as f32; + vertices[i].tangent = normalize3(scale3(vertices[i].tangent, inv)); + vertices[i].bitangent = normalize3(scale3(vertices[i].bitangent, inv)); + } + } +} + +// --- Vector / matrix math --- + +fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn sub2(a: [f32; 2], b: [f32; 2]) -> [f32; 2] { + [a[0] - b[0], a[1] - b[1]] +} +fn scale3(a: [f32; 3], s: f32) -> [f32; 3] { + [a[0] * s, a[1] * s, a[2] * s] +} +fn add3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn normalize3(a: [f32; 3]) -> [f32; 3] { + let len = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt(); + if len < 1e-10 { + a + } else { + [a[0] / len, a[1] / len, a[2] / len] + } +} + +pub fn mat4_mul(a: Mat4, b: Mat4) -> Mat4 { + let mut c = [[0.0f32; 4]; 4]; + for i in 0..4 { + for j in 0..4 { + c[i][j] = (0..4).map(|k| a[i][k] * b[k][j]).sum(); + } + } + c +} + +/// Builds a row-major Mat4 from glTF's column-major 4×4 (as returned by `Transform::matrix()`). +pub fn mat4_from_cols(m: [[f32; 4]; 4]) -> Mat4 { + [ + [m[0][0], m[1][0], m[2][0], m[3][0]], + [m[0][1], m[1][1], m[2][1], m[3][1]], + [m[0][2], m[1][2], m[2][2], m[3][2]], + [m[0][3], m[1][3], m[2][3], m[3][3]], + ] +} + +pub fn transform_point(m: Mat4, p: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * p[0] + m[0][1] * p[1] + m[0][2] * p[2] + m[0][3], + m[1][0] * p[0] + m[1][1] * p[1] + m[1][2] * p[2] + m[1][3], + m[2][0] * p[0] + m[2][1] * p[1] + m[2][2] * p[2] + m[2][3], + ] +} + +pub fn transform_normal(m: Mat4, n: [f32; 3]) -> [f32; 3] { + // Normals transform by the inverse-transpose of the upper-left 3x3. + // For uniform or orthogonal scaling this equals the 3x3 itself (re-normalized). + let r = [ + m[0][0] * n[0] + m[0][1] * n[1] + m[0][2] * n[2], + m[1][0] * n[0] + m[1][1] * n[1] + m[1][2] * n[2], + m[2][0] * n[0] + m[2][1] * n[1] + m[2][2] * n[2], + ]; + normalize3(r) +} diff --git a/engine/pill_assets/src/ibl.rs b/engine/pill_assets/src/ibl.rs new file mode 100644 index 00000000..43d520ff --- /dev/null +++ b/engine/pill_assets/src/ibl.rs @@ -0,0 +1,377 @@ +//! Image-based lighting bake — the single source of the IBL math. +//! +//! Pure CPU, no filesystem: usable both at build time (the `EquirectToIBL` rule) and at runtime +//! (games baking from a fetched/embedded HDR, including on WASM under the `runtime` feature). +//! Inputs/outputs are plain f32 RGBA buffers; callers handle I/O and GPU upload. + +use std::f32::consts::PI; + +/// Decodes a Radiance `.hdr` equirectangular panorama and resizes it to 512×256 RGBA f32. +/// (Runtime equivalent of the build-time HDR decode step.) Bilinear downsample is plenty for a +/// low-frequency environment map. +pub fn equirect_from_hdr(bytes: &[u8]) -> anyhow::Result<(Vec, u32, u32)> { + let mut decoder = zune_hdr::HdrDecoder::new(bytes); + let rgb: Vec = decoder + .decode() + .map_err(|e| anyhow::anyhow!("decode HDR: {e:?}"))?; + let (src_w, src_h) = decoder + .get_dimensions() + .ok_or_else(|| anyhow::anyhow!("HDR has no dimensions"))?; + + let (out_w, out_h) = (512usize, 256usize); + let mut pixels = Vec::with_capacity(out_w * out_h * 4); + let sample = |x: usize, y: usize, c: usize| rgb[(y * src_w + x) * 3 + c]; + for oy in 0..out_h { + let fy = ((oy as f32 + 0.5) / out_h as f32 * src_h as f32 - 0.5).max(0.0); + let y0 = (fy as usize).min(src_h - 1); + let y1 = (y0 + 1).min(src_h - 1); + let wy = fy - y0 as f32; + for ox in 0..out_w { + let fx = ((ox as f32 + 0.5) / out_w as f32 * src_w as f32 - 0.5).max(0.0); + let x0 = (fx as usize).min(src_w - 1); + let x1 = (x0 + 1).min(src_w - 1); + let wx = fx - x0 as f32; + let mut rgba = [0.0f32, 0.0, 0.0, 1.0]; + for (c, out) in rgba.iter_mut().take(3).enumerate() { + let top = sample(x0, y0, c) + (sample(x1, y0, c) - sample(x0, y0, c)) * wx; + let bot = sample(x0, y1, c) + (sample(x1, y1, c) - sample(x0, y1, c)) * wx; + *out = top + (bot - top) * wy; + } + pixels.extend_from_slice(&rgba); + } + } + Ok((pixels, out_w as u32, out_h as u32)) +} + +/// Bakes IBL maps from an Rgba32Float equirect panorama. +/// Returns (diffuse 32×16, specular mips [128×64 → 8×4], brdf_lut 256×256), all Rgba32Float. +pub fn bake_all(equirect: &[f32], width: u32, height: u32) -> (Vec, Vec>, Vec) { + let eq = Equirect { + pixels: equirect.to_vec(), + w: width, + h: height, + }; + + // diffuse: irradiance as linear f32 (sRGB→linear round-trip for u8 intermediate) + let diff_u8 = compute_irradiance(&eq, 32, 16); + let diffuse: Vec = diff_u8 + .chunks(4) + .flat_map(|rgba| { + [ + srgb_to_linear(rgba[0] as f32 / 255.0), + srgb_to_linear(rgba[1] as f32 / 255.0), + srgb_to_linear(rgba[2] as f32 / 255.0), + 1.0f32, + ] + }) + .collect(); + + let specular_mips = bake_specular_mips(&eq); + + // brdf_lut: linear u8 → f32 + let lut_u8 = compute_brdf_lut(256, 256); + let brdf_lut: Vec = lut_u8.iter().map(|&b| b as f32 / 255.0).collect(); + + (diffuse, specular_mips, brdf_lut) +} + +/// Specular prefilter mip chain (base 128×64, 5 levels), Rgba32Float HDR. Mip i uses roughness +/// i/4 to match the shader's `roughness * MAX_REFLECTION_LOD(4)`. +pub fn bake_specular_mips(eq: &Equirect) -> Vec> { + const MIP_ROUGHNESS: [f32; 5] = [0.04, 0.25, 0.5, 0.75, 1.0]; + let mut mips: Vec> = Vec::with_capacity(MIP_ROUGHNESS.len()); + for (mip, &r) in MIP_ROUGHNESS.iter().enumerate() { + let w = (128u32 >> mip).max(1); + let h = (64u32 >> mip).max(1); + mips.push(compute_specular_prefilter(eq, w, h, r)); + } + mips +} + +// --- Equirect --- + +/// Equirect panorama as flat RGBA f32 (linear). Build-time callers that read from an RTEX file +/// construct this directly; runtime callers get it from `equirect_from_hdr` / `bake_all` input. +pub struct Equirect { + pub pixels: Vec, + pub w: u32, + pub h: u32, +} + +impl Equirect { + // Sample at a world-space direction; nearest-neighbor, returns linear HDR values. + pub fn sample(&self, dir: [f32; 3]) -> [f32; 3] { + let (u, v) = dir_to_equirect_uv(dir); + let x = ((u * self.w as f32) as u32).min(self.w - 1); + let y = ((v * self.h as f32) as u32).min(self.h - 1); + let i = ((y * self.w + x) * 4) as usize; + [self.pixels[i], self.pixels[i + 1], self.pixels[i + 2]] + } +} + +// --- Coordinate helpers --- + +fn equirect_uv_to_dir(u: f32, v: f32) -> [f32; 3] { + let az = (u - 0.5) * 2.0 * PI; // consistent with dir_to_equirect_uv: u = 0.5 + az/(2π) + let elev = (0.5 - v) * PI; + let cos_e = elev.cos(); + [cos_e * az.cos(), elev.sin(), cos_e * az.sin()] +} + +fn dir_to_equirect_uv(dir: [f32; 3]) -> (f32, f32) { + let [x, y, z] = normalize(dir); + let az = z.atan2(x); + let elev = y.clamp(-1.0, 1.0).asin(); + let u = (0.5 + az / (2.0 * PI)).rem_euclid(1.0); + let v = (0.5 - elev / PI).clamp(0.0, 1.0); + (u, v) +} + +// --- Vector math --- + +fn normalize(v: [f32; 3]) -> [f32; 3] { + let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-8); + [v[0] / len, v[1] / len, v[2] / len] +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +// Build an orthonormal tangent frame (t, b) from normal n. +fn tangent_frame(n: [f32; 3]) -> ([f32; 3], [f32; 3]) { + let up = if n[1].abs() < 0.9 { + [0.0f32, 1.0, 0.0] + } else { + [1.0f32, 0.0, 0.0] + }; + let t = normalize(cross(up, n)); + let b = cross(n, t); + (t, b) +} + +// Transform v from tangent space (where n=[0,0,1]) to world space. +fn tangent_to_world(v: [f32; 3], n: [f32; 3]) -> [f32; 3] { + let (t, b) = tangent_frame(n); + normalize([ + t[0] * v[0] + b[0] * v[1] + n[0] * v[2], + t[1] * v[0] + b[1] * v[1] + n[1] * v[2], + t[2] * v[0] + b[2] * v[1] + n[2] * v[2], + ]) +} + +// --- Hammersley quasi-random sequence --- + +fn radical_inverse_vdc(mut bits: u32) -> f32 { + bits = bits.rotate_right(16); + bits = ((bits & 0x5555_5555) << 1) | ((bits & 0xAAAA_AAAA) >> 1); + bits = ((bits & 0x3333_3333) << 2) | ((bits & 0xCCCC_CCCC) >> 2); + bits = ((bits & 0x0F0F_0F0F) << 4) | ((bits & 0xF0F0_F0F0) >> 4); + bits = ((bits & 0x00FF_00FF) << 8) | ((bits & 0xFF00_FF00) >> 8); + bits as f32 * 2.328_306_4e-10 // / 2^32 +} + +fn hammersley(i: u32, n: u32) -> (f32, f32) { + (i as f32 / n as f32, radical_inverse_vdc(i)) +} + +// GGX importance sampling: returns half-vector in world space around n. +fn importance_sample_ggx(xi: (f32, f32), roughness: f32, n: [f32; 3]) -> [f32; 3] { + let a = roughness * roughness; + let phi = 2.0 * PI * xi.0; + let cos_t = ((1.0 - xi.1) / (1.0 + (a * a - 1.0) * xi.1)) + .max(0.0) + .sqrt(); + let sin_t = (1.0 - cos_t * cos_t).max(0.0).sqrt(); + let h_local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; + tangent_to_world(h_local, n) +} + +// --- Diffuse irradiance: cosine-weighted Riemann sum --- + +/// Diffuse irradiance map (sRGB-encoded RGBA8). Public so the build rule can write it as RTEX v1. +pub fn compute_irradiance(eq: &Equirect, out_w: u32, out_h: u32) -> Vec { + const N_PHI: u32 = 64; + const N_THETA: u32 = 32; + let dphi = 2.0 * PI / N_PHI as f32; + let dtheta = 0.5 * PI / N_THETA as f32; + + let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); + for y in 0..out_h { + for x in 0..out_w { + let u = (x as f32 + 0.5) / out_w as f32; + let v = (y as f32 + 0.5) / out_h as f32; + let n = equirect_uv_to_dir(u, v); + + let mut irr = [0.0f32; 3]; + let mut wt = 0.0f32; + + for j in 0..N_THETA { + for i in 0..N_PHI { + let phi = (i as f32 + 0.5) * dphi; + let theta = (j as f32 + 0.5) * dtheta; + let sin_t = theta.sin(); + let cos_t = theta.cos(); + let local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; + let world = tangent_to_world(local, n); + let sample = eq.sample(world); + let w = cos_t * sin_t; + irr[0] += sample[0] * w; + irr[1] += sample[1] * w; + irr[2] += sample[2] * w; + wt += w; + } + } + + let s = PI / wt; + out.push(linear_to_srgb(irr[0] * s)); + out.push(linear_to_srgb(irr[1] * s)); + out.push(linear_to_srgb(irr[2] * s)); + out.push(255u8); + } + } + out +} + +// --- Specular prefilter: GGX importance sampling, HDR linear output --- + +/// One specular-prefilter mip (Rgba32Float). Public so the build rule can assemble the mip chain. +pub fn compute_specular_prefilter( + eq: &Equirect, + out_w: u32, + out_h: u32, + roughness: f32, +) -> Vec { + const N_SAMPLES: u32 = 256; + + let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); + for y in 0..out_h { + for x in 0..out_w { + let u = (x as f32 + 0.5) / out_w as f32; + let v = (y as f32 + 0.5) / out_h as f32; + let n = equirect_uv_to_dir(u, v); + + let mut col = [0.0f32; 3]; + let mut wt = 0.0f32; + + for i in 0..N_SAMPLES { + let xi = hammersley(i, N_SAMPLES); + let h = importance_sample_ggx(xi, roughness, n); + let vdoth = dot(n, h).max(0.0); + let l = normalize([ + 2.0 * vdoth * h[0] - n[0], + 2.0 * vdoth * h[1] - n[1], + 2.0 * vdoth * h[2] - n[2], + ]); + let ndotl = dot(n, l).max(0.0); + if ndotl > 0.0 { + let s = eq.sample(l); + col[0] += s[0] * ndotl; + col[1] += s[1] * ndotl; + col[2] += s[2] * ndotl; + wt += ndotl; + } + } + + let s = 1.0 / wt.max(1e-6); + out.push(col[0] * s); + out.push(col[1] * s); + out.push(col[2] * s); + out.push(1.0f32); + } + } + out +} + +// --- BRDF LUT: GGX split-sum preintegration (Karis / UE4) --- +// Stored linear (not sRGB): R = F0 scale, G = F0 bias. + +fn geometry_schlick_ggx_ibl(ndotv: f32, roughness: f32) -> f32 { + let k = roughness * roughness / 2.0; + ndotv / (ndotv * (1.0 - k) + k) +} + +fn geometry_smith_ibl(ndotv: f32, ndotl: f32, roughness: f32) -> f32 { + geometry_schlick_ggx_ibl(ndotv, roughness) * geometry_schlick_ggx_ibl(ndotl, roughness) +} + +/// Environment-independent split-sum BRDF LUT (linear RGBA8). Public for the build rule. +pub fn compute_brdf_lut(w: u32, h: u32) -> Vec { + const N: u32 = 1024; + + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + // x = NdotV (0=grazing, 1=normal incidence) + // y = roughness (0=smooth, 1=rough) — y=0 is top of texture + let ndotv = (x as f32 + 0.5) / w as f32; + let roughness = (y as f32 + 0.5) / h as f32; + let roughness = roughness.max(0.04); + + let v = [(1.0 - ndotv * ndotv).max(0.0).sqrt(), 0.0f32, ndotv]; + let n = [0.0f32, 0.0, 1.0]; + + let mut scale = 0.0f32; + let mut bias = 0.0f32; + + for i in 0..N { + let xi = hammersley(i, N); + let h_v = importance_sample_ggx(xi, roughness, n); + let vdoth = dot(v, h_v).max(0.0); + let l = normalize([ + 2.0 * vdoth * h_v[0] - v[0], + 2.0 * vdoth * h_v[1] - v[1], + 2.0 * vdoth * h_v[2] - v[2], + ]); + let ndotl = l[2].max(0.0); // n = [0,0,1] + let ndoth = h_v[2].max(0.0); + + if ndotl > 0.0 { + let g_vis = geometry_smith_ibl(ndotv, ndotl, roughness) * vdoth + / (ndoth * ndotv.max(0.001)); + let fc = (1.0 - vdoth).powi(5); + scale += (1.0 - fc) * g_vis; + bias += fc * g_vis; + } + } + + scale = (scale / N as f32).clamp(0.0, 1.0); + bias = (bias / N as f32).clamp(0.0, 1.0); + + // Linear bytes (not sRGB — BRDF coefficients are in linear space) + out.push((scale * 255.0 + 0.5) as u8); + out.push((bias * 255.0 + 0.5) as u8); + out.push(0u8); + out.push(255u8); + } + } + out +} + +// --- Color utilities (shared with studio_equirect) --- + +pub fn linear_to_srgb(x: f32) -> u8 { + let c = x.clamp(0.0, 1.0); + let s = if c <= 0.003_130_8 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + }; + (s * 255.0 + 0.5) as u8 +} + +pub fn srgb_to_linear(x: f32) -> f32 { + if x <= 0.040_45 { + x / 12.92 + } else { + ((x + 0.055) / 1.055).powf(2.4) + } +} diff --git a/engine/pill_assets/src/lib.rs b/engine/pill_assets/src/lib.rs index b8ab120b..f8db5683 100644 --- a/engine/pill_assets/src/lib.rs +++ b/engine/pill_assets/src/lib.rs @@ -1,106 +1,125 @@ -//! Minimal declarative asset pipeline. +//! Asset conversion + declarative build pipeline. //! -//! A `Pipeline` is a list of `Rule`s applied to a `root` directory. Each rule -//! declares an input glob, a per-input output path, and an action that turns -//! one input into one output. The runner globs, mtime-skips, and invokes the -//! action — nothing more. +//! Two layers: +//! - Pure conversion/bake ([`convert`], [`ibl`], and the `runtime` [`gltf_runtime`]) — no +//! filesystem, wasm-safe. Shared by the build-time cooker and by games loading assets at +//! runtime. +//! - A build-time `Pipeline` of `Rule`s (feature `build`, the default) applied to a `root` +//! directory: each rule declares an input glob, a per-input output path, and an action that +//! turns one input into one output. The runner globs, mtime-skips, and invokes the action. +//! Callers: `pill_engine/build.rs` and `PillLauncher -a assets`. //! -//! Today this is a single rule (HLSL → WGSL via slangc). The interface admits -//! chained rules (output of one rule is input to another) — when that lands, -//! the runner will topo-sort the rule list. Single-rule case needs no sort. -//! -//! Two callers: `pill_engine/build.rs` (cargo build-script side effect) and -//! `PillLauncher -a assets` (explicit). Both share this single runner. +//! The split lets games enable only `runtime` (no `build`) so the filesystem pipeline and its +//! deps are never compiled into a wasm binary. -use std::path::{Path, PathBuf}; -use std::time::SystemTime; +// Pure conversion/bake — shared by the build cooker (this crate's `Rule`s) and the engine's +// runtime glTF loader. No filesystem, no glTF dep; wasm-safe so `pill_engine`'s `gltf_loading` +// feature can compile these into a game binary. +pub mod convert; +pub mod ibl; -use anyhow::{Context, Result}; +// --- Build-time filesystem pipeline (feature `build`, the default) --- -pub mod rules; +#[cfg(feature = "build")] +mod pipeline { + use std::path::{Path, PathBuf}; + use std::time::SystemTime; -pub use rules::{default_rules, HlslToWgsl, ObjToCookedMesh, PngToCookedTex}; + use anyhow::{Context, Result}; -pub trait Rule { - /// Glob (relative to the pipeline root) selecting source inputs. - fn input_glob(&self) -> &'static str; + pub trait Rule { + /// Glob (relative to the pipeline root) selecting source inputs. + fn input_glob(&self) -> &'static str; - /// Map an input path to the output path it produces. - fn output_for(&self, input: &Path) -> PathBuf; + /// Map an input path to the output path it produces. + fn output_for(&self, input: &Path) -> PathBuf; - /// Run the tool that turns `input` → `output`. Called only when stale. - fn build(&self, input: &Path, output: &Path) -> Result<()>; + /// Run the tool that turns `input` → `output`. Called only when stale. + fn build(&self, input: &Path, output: &Path) -> Result<()>; - /// Human-readable rule name for logs/errors. - fn name(&self) -> &'static str; -} + /// Human-readable rule name for logs/errors. + fn name(&self) -> &'static str; + } -pub struct Pipeline { - pub root: PathBuf, - pub rules: Vec>, -} + pub struct Pipeline { + pub root: PathBuf, + pub rules: Vec>, + } -#[derive(Default, Debug)] -pub struct Stats { - /// Every input matched by any rule. Build scripts emit cargo:rerun-if-changed for these. - pub discovered: Vec, - /// Outputs we actually wrote this run. - pub rebuilt: Vec, - /// Outputs that were already up-to-date (skipped). - pub skipped: Vec, -} + #[derive(Default, Debug)] + pub struct Stats { + /// Every input matched by any rule. Build scripts emit cargo:rerun-if-changed for these. + pub discovered: Vec, + /// Outputs we actually wrote this run. + pub rebuilt: Vec, + /// Outputs that were already up-to-date (skipped). + pub skipped: Vec, + } -impl Pipeline { - pub fn run(&self) -> Result { - let mut stats = Stats::default(); + impl Pipeline { + pub fn run(&self) -> Result { + let mut stats = Stats::default(); - for rule in &self.rules { - let pattern = self.root.join(rule.input_glob()); - let pattern_str = pattern - .to_str() - .with_context(|| format!("non-UTF8 path in pipeline root: {pattern:?}"))?; + for rule in &self.rules { + let pattern = self.root.join(rule.input_glob()); + let pattern_str = pattern + .to_str() + .with_context(|| format!("non-UTF8 path in pipeline root: {pattern:?}"))?; - let matches = glob::glob(pattern_str).with_context(|| { - format!("invalid glob {pattern_str:?} for rule {}", rule.name()) - })?; + let matches = glob::glob(pattern_str).with_context(|| { + format!("invalid glob {pattern_str:?} for rule {}", rule.name()) + })?; - for entry in matches { - let input = - entry.with_context(|| format!("glob entry error for rule {}", rule.name()))?; - stats.discovered.push(input.clone()); + for entry in matches { + let input = entry + .with_context(|| format!("glob entry error for rule {}", rule.name()))?; + stats.discovered.push(input.clone()); - let output = rule.output_for(&input); + let output = rule.output_for(&input); - if is_up_to_date(&input, &output) { - stats.skipped.push(output); - continue; - } + if is_up_to_date(&input, &output) { + stats.skipped.push(output); + continue; + } - if let Some(parent) = output.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create output dir {parent:?}"))?; - } + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create output dir {parent:?}"))?; + } - rule.build(&input, &output).with_context(|| { - format!("rule {} failed: {input:?} -> {output:?}", rule.name()) - })?; - stats.rebuilt.push(output); + rule.build(&input, &output).with_context(|| { + format!("rule {} failed: {input:?} -> {output:?}", rule.name()) + })?; + stats.rebuilt.push(output); + } } + + Ok(stats) } + } - Ok(stats) + fn is_up_to_date(input: &Path, output: &Path) -> bool { + let in_mtime = mtime(input); + let out_mtime = mtime(output); + match (in_mtime, out_mtime) { + (Some(i), Some(o)) => o >= i, + _ => false, + } } -} -fn is_up_to_date(input: &Path, output: &Path) -> bool { - let in_mtime = mtime(input); - let out_mtime = mtime(output); - match (in_mtime, out_mtime) { - (Some(i), Some(o)) => o >= i, - _ => false, + fn mtime(p: &Path) -> Option { + p.metadata().ok()?.modified().ok() } } -fn mtime(p: &Path) -> Option { - p.metadata().ok()?.modified().ok() -} +#[cfg(feature = "build")] +pub mod rules; + +#[cfg(feature = "build")] +pub use pipeline::{Pipeline, Rule, Stats}; + +#[cfg(feature = "build")] +pub use rules::{ + default_rules, EquirectToIBL, GlbToCookedMesh, HlslToWgsl, ObjToCookedMesh, PngToCookedTex, + StudioEquirect, +}; diff --git a/engine/pill_assets/src/rules/equirect_to_ibl.rs b/engine/pill_assets/src/rules/equirect_to_ibl.rs new file mode 100644 index 00000000..2a4d30f0 --- /dev/null +++ b/engine/pill_assets/src/rules/equirect_to_ibl.rs @@ -0,0 +1,75 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use super::studio_equirect::{write_rtex, write_rtex_hdr_mip}; +use crate::ibl::{self, Equirect}; +use crate::Rule; + +pub struct EquirectToIBL; + +impl Rule for EquirectToIBL { + fn name(&self) -> &'static str { + "equirect_to_ibl" + } + + fn input_glob(&self) -> &'static str { + "**/*_equirect.cooked_tex" + } + + fn output_for(&self, input: &Path) -> PathBuf { + // Anchor output is the diffuse IBL; build() also writes specular IBL + BRDF LUT. + let stem = input.file_stem().unwrap().to_str().unwrap(); + let base = stem.strip_suffix("_equirect").unwrap_or(stem); + input.with_file_name(format!("{base}_diffuse_ibl.cooked_tex")) + } + + fn build(&self, input: &Path, output: &Path) -> Result<()> { + let equirect = load_equirect(input)?; + let dir = output.parent().unwrap(); + let stem = input.file_stem().unwrap().to_str().unwrap(); + let base = stem.strip_suffix("_equirect").unwrap_or(stem); + + // 1. Diffuse irradiance (32×16 sRGB) — anchor output + let irr = ibl::compute_irradiance(&equirect, 32, 16); + write_rtex(output, 32, 16, &irr)?; + + // 2. Specular prefilter HDR mip chain (RTEX v4, Rgba32Float). + let spec_path = dir.join(format!("{base}_specular_ibl.cooked_tex")); + let spec_mips = ibl::bake_specular_mips(&equirect); + write_rtex_hdr_mip(&spec_path, 128, 64, &spec_mips)?; + + // 3. BRDF LUT (256×256 linear) — split-sum preintegration, environment-independent + let lut = ibl::compute_brdf_lut(256, 256); + let lut_path = dir.join("brdf_lut.cooked_tex"); + write_rtex(&lut_path, 256, 256, &lut)?; + + Ok(()) + } +} + +/// Loads a cooked equirect (RTEX v1 sRGB or v2 Rgba32Float) into the linear-f32 `Equirect` the +/// bake math operates on. +fn load_equirect(path: &Path) -> Result { + let data = std::fs::read(path).with_context(|| format!("read {path:?}"))?; + if data.len() < 16 || &data[0..4] != b"RTEX" { + bail!("invalid RTEX in {path:?}"); + } + let version = u32::from_le_bytes(data[4..8].try_into().unwrap()); + let w = u32::from_le_bytes(data[8..12].try_into().unwrap()); + let h = u32::from_le_bytes(data[12..16].try_into().unwrap()); + let pixels = if version == 2 { + // v2: Rgba32Float (4 bytes per channel, f32 LE) + data[16..] + .chunks_exact(4) + .map(|b| f32::from_le_bytes(b.try_into().unwrap())) + .collect() + } else { + // v1: Rgba8UnormSrgb — decode to linear f32 at load time + data[16..] + .iter() + .map(|&b| ibl::srgb_to_linear(b as f32 / 255.0)) + .collect() + }; + Ok(Equirect { pixels, w, h }) +} diff --git a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs new file mode 100644 index 00000000..f4c5f4b5 --- /dev/null +++ b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs @@ -0,0 +1,210 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use crate::convert::{self, CookedVertex}; +use crate::Rule; + +/// GLB → cooked_mesh + sidecar textures. +/// +/// Primary output: `{stem}.cooked_mesh` (RMSH format, same as ObjToCookedMesh). +/// Side outputs written in the same directory when textures are present: +/// `{stem}_albedo.cooked_tex` — base color (RTEX RGBA8) +/// `{stem}_normal.cooked_tex` — normal map (RTEX RGBA8) +/// `{stem}_metallic_roughness.cooked_tex` — G=roughness, B=metallic (RTEX RGBA8) +/// `{stem}_emissive.cooked_tex` — emissive color (RTEX RGBA8) +/// +/// Merges all primitives into one mesh — note the runtime loader keeps them per-primitive for +/// multi-material scenes. Vertex/tangent/RMSH conversion is shared via [`crate::convert`]. +pub struct GlbToCookedMesh; + +impl Rule for GlbToCookedMesh { + fn name(&self) -> &'static str { + "glb_to_cooked_mesh" + } + + fn input_glob(&self) -> &'static str { + "**/*.glb" + } + + fn output_for(&self, input: &Path) -> PathBuf { + input.with_extension("cooked_mesh") + } + + fn build(&self, input: &Path, output: &Path) -> Result<()> { + let bytes = std::fs::read(input).with_context(|| format!("read {input:?}"))?; + let (doc, buffers, images) = + gltf::import_slice(&bytes).with_context(|| format!("parse GLB {input:?}"))?; + + // --- Mesh --- + // Walk the scene node hierarchy. For each node that references a mesh, apply the + // node's world transform to vertex positions and normals before cooked output. + // This bakes the root-node transform (scale, rotation, etc.) into the geometry so + // game code needs no compensating transform on the mesh entity. + + let mut all_vertices: Vec = Vec::new(); + let mut all_indices: Vec = Vec::new(); + + // Collect (mesh_index, world_transform_4x4) pairs by traversing the scene graph. + let mut mesh_instances: Vec<(usize, convert::Mat4)> = Vec::new(); + let default_scene = doc.default_scene().or_else(|| doc.scenes().next()); + if let Some(scene) = default_scene { + let mut stack: Vec<(gltf::Node, convert::Mat4)> = + scene.nodes().map(|n| (n, convert::IDENTITY)).collect(); + while let Some((node, parent_transform)) = stack.pop() { + let local = convert::mat4_from_cols(node.transform().matrix()); + let world = convert::mat4_mul(parent_transform, local); + if let Some(mesh) = node.mesh() { + mesh_instances.push((mesh.index(), world)); + } + for child in node.children() { + stack.push((child, world)); + } + } + } else { + // No scene: fall back to processing every mesh with identity transform. + for (i, _) in doc.meshes().enumerate() { + mesh_instances.push((i, convert::IDENTITY)); + } + } + + if mesh_instances.is_empty() { + anyhow::bail!("{input:?}: no meshes in GLB"); + } + + let meshes: Vec = doc.meshes().collect(); + for (mesh_idx, world) in mesh_instances { + let mesh = &meshes[mesh_idx]; + for prim in mesh.primitives() { + let reader = prim.reader(|buffer| Some(&*buffers[buffer.index()])); + let vertex_offset = all_vertices.len() as u32; + + let positions: Vec<[f32; 3]> = reader + .read_positions() + .with_context(|| format!("{input:?}: missing positions"))? + .collect(); + let normals: Vec<[f32; 3]> = reader + .read_normals() + .with_context(|| format!("{input:?}: missing normals"))? + .collect(); + let uvs: Vec<[f32; 2]> = reader + .read_tex_coords(0) + .with_context(|| format!("{input:?}: missing UV set 0"))? + .into_f32() + .collect(); + let tangents: Vec<[f32; 4]> = reader + .read_tangents() + .map(|tangents_iter| tangents_iter.collect()) + .unwrap_or_default(); + let prim_indices: Vec = reader + .read_indices() + .with_context(|| format!("{input:?}: missing indices"))? + .into_u32() + .collect(); + + let vertices = convert::build_vertices( + &positions, + &normals, + &uvs, + &tangents, + &prim_indices, + world, + ); + + all_indices.extend(prim_indices.iter().map(|&i| i + vertex_offset)); + all_vertices.extend(vertices); + } + } + + std::fs::write(output, convert::rmsh_bytes(&all_vertices, &all_indices)) + .with_context(|| format!("write {output:?}"))?; + + // --- Textures (side outputs) --- + + let stem = input + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let dir = output + .parent() + .with_context(|| "output has no parent directory")?; + + for mat in doc.materials() { + let mat_idx = mat.index(); + let suffix = |base: &str| -> String { + match mat_idx { + None | Some(0) => format!("{stem}_{base}.cooked_tex"), + Some(i) => format!("{stem}_mat{i}_{base}.cooked_tex"), + } + }; + let pbr = mat.pbr_metallic_roughness(); + + if let Some(info) = pbr.base_color_texture() { + write_cooked_tex( + &images, + info.texture().source().index(), + dir.join(suffix("albedo")), + "base color texture", + input, + )?; + } + if let Some(info) = mat.normal_texture() { + write_cooked_tex( + &images, + info.texture().source().index(), + dir.join(suffix("normal")), + "normal texture", + input, + )?; + } + if let Some(info) = pbr.metallic_roughness_texture() { + write_cooked_tex( + &images, + info.texture().source().index(), + dir.join(suffix("metallic_roughness")), + "metallic_roughness texture", + input, + )?; + } + if let Some(info) = mat.emissive_texture() { + write_cooked_tex( + &images, + info.texture().source().index(), + dir.join(suffix("emissive")), + "emissive texture", + input, + )?; + } + } + + Ok(()) + } +} + +fn write_cooked_tex( + images: &[gltf::image::Data], + img_idx: usize, + path: std::path::PathBuf, + label: &str, + input: &Path, +) -> Result<()> { + use gltf::image::Format; + use std::borrow::Cow; + let Some(img) = images.get(img_idx) else { + return Ok(()); + }; + let rgba: Cow<[u8]> = match img.format { + Format::R8G8B8A8 => Cow::Borrowed(&img.pixels), + Format::R8G8B8 => Cow::Owned( + img.pixels + .chunks_exact(3) + .flat_map(|pixel| [pixel[0], pixel[1], pixel[2], 255u8]) + .collect(), + ), + format => bail!("{input:?}: {label}: unsupported GLB image format: {format:?}"), + }; + // glTF UV origin (0,0) is top-left, matching wgpu/Vulkan — no row flip needed. + super::studio_equirect::write_rtex(&path, img.width, img.height, &rgba) + .with_context(|| format!("{input:?}: {label}")) +} diff --git a/engine/pill_assets/src/rules/mod.rs b/engine/pill_assets/src/rules/mod.rs index 854f42f7..a02a3c4f 100644 --- a/engine/pill_assets/src/rules/mod.rs +++ b/engine/pill_assets/src/rules/mod.rs @@ -1,18 +1,27 @@ use crate::Rule; +pub mod equirect_to_ibl; +pub mod glb_to_cooked_mesh; pub mod hlsl_to_wgsl; pub mod obj_to_cooked_mesh; pub mod png_to_cooked_tex; +pub mod studio_equirect; +pub use equirect_to_ibl::EquirectToIBL; +pub use glb_to_cooked_mesh::GlbToCookedMesh; pub use hlsl_to_wgsl::HlslToWgsl; pub use obj_to_cooked_mesh::ObjToCookedMesh; pub use png_to_cooked_tex::PngToCookedTex; +pub use studio_equirect::StudioEquirect; /// Built-in rule set used by both the cargo build-script and the launcher. pub fn default_rules() -> Vec> { vec![ Box::new(HlslToWgsl), Box::new(PngToCookedTex), + Box::new(StudioEquirect), // generates *_equirect.cooked_tex from *.studio + Box::new(EquirectToIBL), // generates IBL maps from *_equirect.cooked_tex Box::new(ObjToCookedMesh), + Box::new(GlbToCookedMesh), ] } diff --git a/engine/pill_assets/src/rules/studio_equirect.rs b/engine/pill_assets/src/rules/studio_equirect.rs new file mode 100644 index 00000000..6fe38e29 --- /dev/null +++ b/engine/pill_assets/src/rules/studio_equirect.rs @@ -0,0 +1,132 @@ +use std::f32::consts::PI; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::Rule; + +// Gradient (linear, inverse-Reinhard compensated: c/(1+c) → target on screen). +const TOP: [f32; 3] = [0.90, 0.90, 1.20]; // Reinhard → [0.47, 0.47, 0.55] cool blue ceiling +const HORIZON: [f32; 3] = [0.43, 0.43, 0.46]; // Reinhard → [0.30, 0.30, 0.32] neutral mid +const BOTTOM: [f32; 3] = [0.040, 0.040, 0.050]; // Reinhard → [0.04, 0.04, 0.05] dark floor + +// Key light: warm tungsten, upper-left, ~40° above horizon. +const KEY_U: f32 = 0.10; +const KEY_V: f32 = 0.28; +const KEY_INTENSITY: [f32; 3] = [6.0, 4.8, 2.5]; // Reinhard → [0.86, 0.83, 0.71] warm gold +const KEY_SIGMA: f32 = 0.0875; + +// Rim light: cool blue, upper-right, ~45° above horizon. +const RIM_U: f32 = 0.60; +const RIM_V: f32 = 0.25; +const RIM_INTENSITY: [f32; 3] = [1.5, 2.5, 5.0]; // Reinhard → [0.60, 0.71, 0.83] cool blue +const RIM_SIGMA: f32 = 0.100; + +pub struct StudioEquirect; + +impl Rule for StudioEquirect { + fn name(&self) -> &'static str { + "studio_equirect" + } + + fn input_glob(&self) -> &'static str { + "**/*.studio" + } + + fn output_for(&self, input: &Path) -> PathBuf { + let stem = input.file_stem().unwrap().to_str().unwrap(); + input.with_file_name(format!("{stem}_equirect.cooked_tex")) + } + + fn build(&self, _input: &Path, output: &Path) -> Result<()> { + let (w, h) = (512u32, 256u32); + let pixels = generate_equirect(w, h); + write_rtex_hdr(output, w, h, &pixels) + } +} + +fn generate_equirect(w: u32, h: u32) -> Vec { + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let v = (y as f32 + 0.5) / h as f32; + let elev = (0.5 - v) * PI; + let sin_e = elev.sin(); + let t = (sin_e + 1.0) * 0.5; + let mut col = if t < 0.5 { + lerp3(BOTTOM, HORIZON, t * 2.0) + } else { + lerp3(HORIZON, TOP, (t - 0.5) * 2.0) + }; + let dku = u - KEY_U; + let dku = (dku - dku.round()).abs(); // wrap U to [-0.5, 0.5] + let dkv = v - KEY_V; + let w_key = (-(dku * dku + dkv * dkv) / (2.0 * KEY_SIGMA * KEY_SIGMA)).exp(); + col[0] = col[0].max(KEY_INTENSITY[0] * w_key); + col[1] = col[1].max(KEY_INTENSITY[1] * w_key); + col[2] = col[2].max(KEY_INTENSITY[2] * w_key); + let dru = u - RIM_U; + let dru = (dru - dru.round()).abs(); + let drv = v - RIM_V; + let w_rim = (-(dru * dru + drv * drv) / (2.0 * RIM_SIGMA * RIM_SIGMA)).exp(); + col[0] = col[0].max(RIM_INTENSITY[0] * w_rim); + col[1] = col[1].max(RIM_INTENSITY[1] * w_rim); + col[2] = col[2].max(RIM_INTENSITY[2] * w_rim); + out.push(col[0]); + out.push(col[1]); + out.push(col[2]); + out.push(1.0); + } + } + out +} + +// --- shared utilities --- + +// RTEX v2: Rgba32Float (4 bytes per channel, f32 LE), linear HDR. +pub(crate) fn write_rtex_hdr(path: &Path, w: u32, h: u32, rgba_f32: &[f32]) -> Result<()> { + let mut out = Vec::with_capacity(16 + rgba_f32.len() * 4); + out.extend_from_slice(b"RTEX"); + out.extend_from_slice(&2u32.to_le_bytes()); // version 2 = Rgba32Float HDR + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + for &v in rgba_f32 { + out.extend_from_slice(&v.to_le_bytes()); + } + std::fs::write(path, &out).with_context(|| format!("write_rtex_hdr {path:?}"))?; + Ok(()) +} + +// RTEX v4: Rgba32Float mip chain for HDR specular prefilter. +// Header: RTEX | 4 | w | h | mip_count | mip0_f32_pixels | mip1_f32_pixels | ... +pub(crate) fn write_rtex_hdr_mip(path: &Path, w: u32, h: u32, mips: &[Vec]) -> Result<()> { + let mut out = Vec::new(); + out.extend_from_slice(b"RTEX"); + out.extend_from_slice(&4u32.to_le_bytes()); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + out.extend_from_slice(&(mips.len() as u32).to_le_bytes()); + for mip in mips { + for &v in mip { + out.extend_from_slice(&v.to_le_bytes()); + } + } + std::fs::write(path, &out).with_context(|| format!("write_rtex_hdr_mip {path:?}"))?; + Ok(()) +} + +// RTEX v1: Rgba8UnormSrgb (legacy LDR IBL outputs). Bytes built by the shared writer. +pub(crate) fn write_rtex(path: &Path, w: u32, h: u32, rgba: &[u8]) -> Result<()> { + std::fs::write(path, crate::convert::rtex_bytes(w, h, rgba)) + .with_context(|| format!("write_rtex {path:?}"))?; + Ok(()) +} + +pub(crate) fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} diff --git a/engine/pill_core/Cargo.toml b/engine/pill_core/Cargo.toml index 184154e8..dba87686 100644 --- a/engine/pill_core/Cargo.toml +++ b/engine/pill_core/Cargo.toml @@ -9,7 +9,7 @@ authors = [ edition = "2021" [dependencies] -glam = { version = "0.30.8", features = ["serde"] } +glam = { version = "0.30.8", features = ["serde", "bytemuck"] } thiserror = "1.0.30" boolinator = "2.4.0" log = { version = "0.4", features = ["release_max_level_off"] } diff --git a/engine/pill_core/src/handle.rs b/engine/pill_core/src/handle.rs new file mode 100644 index 00000000..2b8167f7 --- /dev/null +++ b/engine/pill_core/src/handle.rs @@ -0,0 +1,159 @@ +// WHY over PillSlotMapKey: the renderer has 7 resource types — Handle gives all 7 for free; PillSlotMapKey needs a macro block + unsafe impl written out per type. + +use core::fmt; +use core::hash::{Hash, Hasher}; +use core::marker::PhantomData; + +#[repr(transparent)] +pub struct Handle { + raw: u64, + _marker: PhantomData, +} + +impl Handle { + /// Constructs a handle from a raw slot index and generation counter. + #[inline] + pub const fn from_parts(index: u32, generation: u32) -> Self { + Self { + raw: ((generation as u64) << 32) | (index as u64), + _marker: PhantomData, + } + } + + /// Returns the slot index encoded in this handle. + #[inline] + pub const fn index(&self) -> u32 { + self.raw as u32 + } + + /// Returns the generation counter encoded in this handle. + #[inline] + pub const fn generation(&self) -> u32 { + (self.raw >> 32) as u32 + } + + pub const INVALID: Self = Self { + raw: u64::MAX, + _marker: PhantomData, + }; + + /// Returns `true` if this handle has not been set to the sentinel invalid value. + #[inline] + pub const fn is_valid(&self) -> bool { + self.raw != u64::MAX + } +} + +impl Copy for Handle {} +impl Clone for Handle { + fn clone(&self) -> Self { + *self + } +} +impl PartialEq for Handle { + fn eq(&self, other: &Self) -> bool { + self.raw == other.raw + } +} +impl Eq for Handle {} +impl Hash for Handle { + fn hash(&self, state: &mut H) { + self.raw.hash(state) + } +} +impl fmt::Debug for Handle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Handle({:#x})", self.raw) + } +} + +struct Entry { + generation: u32, + resource: Option, +} + +pub struct ResourcePool { + entries: Vec>, + free_list: Vec, + _marker: PhantomData, +} + +impl Default for ResourcePool { + fn default() -> Self { + Self::new() + } +} + +impl ResourcePool { + /// Creates an empty pool with no allocated slots. + pub fn new() -> Self { + Self { + entries: Vec::new(), + free_list: Vec::new(), + _marker: PhantomData, + } + } + + /// Inserts a resource and returns a handle that uniquely identifies this slot generation. + #[inline] + pub fn insert(&mut self, resource: V) -> Handle { + if let Some(idx) = self.free_list.pop() { + let entry = &mut self.entries[idx as usize]; + entry.generation = entry.generation.wrapping_add(1); + entry.resource = Some(resource); + Handle::from_parts(idx, entry.generation) + } else { + let idx = self.entries.len() as u32; + self.entries.push(Entry { + generation: 0, + resource: Some(resource), + }); + Handle::from_parts(idx, 0) + } + } + + /// Removes the resource at the given handle; returns `None` if the handle is stale or out of bounds. + #[inline] + pub fn remove(&mut self, h: Handle) -> Option { + let idx = h.index() as usize; + if idx >= self.entries.len() { + return None; + } + let entry = &mut self.entries[idx]; + if entry.generation != h.generation() { + return None; + } + let res = entry.resource.take(); + entry.generation = entry.generation.wrapping_add(1); + self.free_list.push(h.index()); + res + } + + /// Returns a shared reference to the resource at the given handle, or `None` if stale or absent. + #[inline] + pub fn get(&self, h: Handle) -> Option<&V> { + let idx = h.index() as usize; + if idx >= self.entries.len() { + return None; + } + let entry = &self.entries[idx]; + if entry.generation != h.generation() { + return None; + } + entry.resource.as_ref() + } + + /// Returns an exclusive reference to the resource at the given handle, or `None` if stale or absent. + #[inline] + pub fn get_mut(&mut self, h: Handle) -> Option<&mut V> { + let idx = h.index() as usize; + if idx >= self.entries.len() { + return None; + } + let entry = &mut self.entries[idx]; + if entry.generation != h.generation() { + return None; + } + entry.resource.as_mut() + } +} diff --git a/engine/pill_core/src/lib.rs b/engine/pill_core/src/lib.rs index be203ebb..126effde 100644 --- a/engine/pill_core/src/lib.rs +++ b/engine/pill_core/src/lib.rs @@ -4,6 +4,7 @@ mod bitmask_utils; mod color; mod error; +mod handle; mod log; mod math; #[cfg(not(target_arch = "wasm32"))] @@ -12,6 +13,7 @@ mod pill_slotmap; mod pill_twinmap; mod pill_typemap; mod style; +mod tags; mod timer; mod utils; @@ -63,6 +65,13 @@ pub use utils::{ pub use color::{generate_color_palette, hsl_to_rgb, DISTINCT_COLOR_PALETTE}; +pub use handle::{Handle, ResourcePool}; + +pub use tags::{ + RendererBufferTag, RendererCameraTag, RendererMaterialTag, RendererMeshTag, + RendererPipelineTag, RendererPipelineV2Tag, RendererTextureTag, +}; + pub use timer::{Timer, TimerRecord}; #[cfg(not(target_arch = "wasm32"))] diff --git a/engine/pill_core/src/pill_typemap.rs b/engine/pill_core/src/pill_typemap.rs index deba4aba..ff2eb976 100644 --- a/engine/pill_core/src/pill_typemap.rs +++ b/engine/pill_core/src/pill_typemap.rs @@ -17,7 +17,7 @@ use std::marker::PhantomData; /// [`PillTypeMap`]: struct.PillTypeMap.html pub trait PillTypeMapKey: Any { /// Defines the value type that corresponds to this `PillTypeMapKey`. - type Storage: Send; //+ Sync; + type Storage; //: Send + Sync; } /// PillTypeMap is a simple abstraction around the standard library's [`HashMap`] @@ -27,7 +27,7 @@ pub trait PillTypeMapKey: Any { /// [`HashMap`]: std::collections::HashMap //pub struct PillTypeMap(HashMap>); #[derive(Default)] -pub struct PillTypeMap(HashMap>); +pub struct PillTypeMap(HashMap>); impl PillTypeMap { /// Creates a new instance of `PillTypeMap`. @@ -268,7 +268,7 @@ where K: PillTypeMapKey, { //entry: HashMapOccupiedEntry<'a, TypeId, Box<(dyn Any + Send + Sync)>>, - entry: HashMapOccupiedEntry<'a, TypeId, Box>, + entry: HashMapOccupiedEntry<'a, TypeId, Box>, _marker: PhantomData<&'a K::Storage>, } @@ -307,7 +307,7 @@ where K: PillTypeMapKey, { //entry: HashMapVacantEntry<'a, TypeId, Box<(dyn Any + Send + Sync)>>, - entry: HashMapVacantEntry<'a, TypeId, Box>, + entry: HashMapVacantEntry<'a, TypeId, Box>, _marker: PhantomData<&'a K::Storage>, } diff --git a/engine/pill_core/src/tags.rs b/engine/pill_core/src/tags.rs new file mode 100644 index 00000000..87ed54ff --- /dev/null +++ b/engine/pill_core/src/tags.rs @@ -0,0 +1,9 @@ +// WHY: zero-size marker types so Handle and Handle are different types — passing the wrong one is a compile error, not a runtime bug. + +pub struct RendererBufferTag; +pub struct RendererCameraTag; +pub struct RendererMaterialTag; +pub struct RendererMeshTag; +pub struct RendererPipelineTag; +pub struct RendererPipelineV2Tag; +pub struct RendererTextureTag; diff --git a/engine/pill_core/src/timer.rs b/engine/pill_core/src/timer.rs index 18f3ac7b..fa9dd853 100644 --- a/engine/pill_core/src/timer.rs +++ b/engine/pill_core/src/timer.rs @@ -22,6 +22,7 @@ pub struct Timer { pub records: Vec, current_label: Option, current_label_start: Option, + counters: Vec<(String, u64)>, } impl Default for Timer { @@ -31,15 +32,18 @@ impl Default for Timer { } impl Timer { + /// Creates an empty timer with no records, contexts, or counters. pub fn new() -> Self { Self { stack: Vec::new(), records: Vec::new(), current_label: None, current_label_start: None, + counters: Vec::new(), } } + /// Opens a new named timing context; any previously open context becomes a parent. pub fn begin_context(&mut self, label: impl Into) { // Close current segment if needed self.flush_record(); @@ -54,6 +58,7 @@ impl Timer { }); } + /// Closes the innermost context and records its elapsed duration. pub fn end_context(&mut self) -> Result<()> { self.flush_record(); // End any remaining record @@ -74,6 +79,7 @@ impl Timer { Ok(()) } + /// Records a named checkpoint; its duration is measured until the next `record` or `end_context` call. pub fn record(&mut self, label: impl Into) { self.flush_record(); // End any previous one self.current_label = Some(label.into()); @@ -98,10 +104,35 @@ impl Timer { self.current_label_start = None; } + /// Returns the sum of all top-level record durations in milliseconds. pub fn total_duration(&self) -> f32 { - self.records.iter().map(|r| r.duration).sum() + self.records.iter().map(|record| record.duration).sum() } + /// Sets the named counter to `value`, inserting it if it does not exist yet. + pub fn set_counter(&mut self, label: impl Into, value: u64) { + let label = label.into(); + if let Some(entry) = self.counters.iter_mut().find(|(key, _)| key == &label) { + entry.1 = value; + } else { + self.counters.push((label, value)); + } + } + + /// Returns the current value of the named counter, or `None` if it has not been set. + pub fn get_counter(&self, label: &str) -> Option { + self.counters + .iter() + .find(|(key, _)| key == label) + .map(|(_, value)| *value) + } + + /// Returns all counters as a slice of (label, value) pairs. + pub fn counters(&self) -> &[(String, u64)] { + &self.counters + } + + /// Prints all records to stdout at the given indentation level. pub fn print(&self, indent: usize) { for record in &self.records { Self::print_record(record, indent); diff --git a/engine/pill_core/src/utils.rs b/engine/pill_core/src/utils.rs index 057988ac..09f2eea6 100644 --- a/engine/pill_core/src/utils.rs +++ b/engine/pill_core/src/utils.rs @@ -88,5 +88,9 @@ macro_rules! create_game { let game: Box = Box::new($game_contructor); Box::into_raw(Box::new(game)) as *mut std::ffi::c_void } + + pub fn create_pill_game() -> Box { + Box::new($game_contructor) + } }; } diff --git a/engine/pill_engine/Cargo.toml b/engine/pill_engine/Cargo.toml index be7dd641..141fa9e7 100644 --- a/engine/pill_engine/Cargo.toml +++ b/engine/pill_engine/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] pill_core = {path = "../pill_core"} +glam = { version = "0.30.8", features = ["serde", "bytemuck"] } # Errors thiserror = "1.0.30" @@ -14,15 +15,23 @@ log = { version = "0.4", features = ["release_max_level_off"] } # Loaders tobj = { version = "3.0", optional = true } +# Runtime glTF loading (feature `gltf_loading`): parse glTF + decode embedded/fetched textures. +# The shared vertex/format/bake math comes from pill_assets (default-features off → its pure, +# wasm-safe `convert`/`ibl` modules only, no filesystem pipeline). Parsing uses goth-gltf +# (nanoserde-based) not the gltf crate (serde_json) — that swap saved ~150 KB of wasm. Textures +# decode with the lean `zune-*` codecs (vs the full `image` crate, another ~110 KB). +goth-gltf = { version = "0.3", optional = true } +zune-jpeg = { version = "0.4", default-features = false, optional = true } +zune-png = { version = "0.4", default-features = false, optional = true } +pill_assets = { path = "../pill_assets", default-features = false, optional = true } # Other readonly = "0.2" winit = "0.30.12" -boolinator = "2.4.0" bytemuck = { version = "1.4", features = [ "derive" ] } lazy_static = "1.4.0" dyn-clone = "1.0.4" -egui = { version = "0.32.1", optional = true } +wgpu = "25.0.0" serde = { version = "1.0", features = ["derive"] } bincode = "1.0" once_cell = "1.21.3" @@ -30,11 +39,20 @@ bitvec = "1.0.1" rand = "0.9.2" web-time = "1.1" +# Optional deps activated by feature flags +egui = { version = "0.32.1", optional = true } +egui-wgpu = { version = "0.32.1", features = ["winit"], optional = true } +egui-winit = { version = "0.32.1", default-features = false, optional = true } + # Native-only dependencies (not available on WASM) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] png = "0.17" rodio = { version = "0.20", features = ["wav", "mp3"] } gilrs = "0.11.0" +pollster = "0.2" +# clipboard pulls arboard, which has no wasm32 backend — desktop only (powers the egui [C] copy button) +egui-winit = { version = "0.32.1", default-features = false, features = ["clipboard"], optional = true } + [build-dependencies] fs_extra = "1.2" @@ -45,6 +63,10 @@ game = [] internal = [] headless = [] networking = [] -debug_ui = ["dep:egui"] +ui = ["dep:egui", "dep:egui-wgpu", "dep:egui-winit"] obj_loading = ["dep:tobj"] +# Runtime multi-file/embedded glTF loading into engine byte formats (RMSH meshes, RTEX textures). +gltf_loading = ["dep:goth-gltf", "dep:zune-jpeg", "dep:zune-png", "dep:pill_assets"] +# Runtime IBL bake (equirect → diffuse/specular/brdf), shared with the build-time cooker. +ibl_bake = ["dep:pill_assets"] all = ["game", "internal", "networking"] diff --git a/engine/pill_engine/res/shaders/background_fragment.hlsl b/engine/pill_engine/res/shaders/background_fragment.hlsl new file mode 100644 index 00000000..395a8ef4 --- /dev/null +++ b/engine/pill_engine/res/shaders/background_fragment.hlsl @@ -0,0 +1,23 @@ +struct SkyCam { + float3 right; float tan_half_fov; + float3 up; float aspect; + float3 fwd; float _pad; + float3 bg_color; float _pad2; +}; +[[vk::binding(0, 0)]] ConstantBuffer UCam; +[[vk::binding(1, 0)]] Texture2D texEquirect; +[[vk::binding(2, 0)]] SamplerState smpEquirect; + +#include "include/equirect.hlsl" + +[shader("fragment")] +float4 fs_main(float2 ndc : TEXCOORD0) : SV_Target { + float3 dir = normalize( + UCam.right * (ndc.x * UCam.tan_half_fov * UCam.aspect) + + UCam.up * (ndc.y * UCam.tan_half_fov) + + UCam.fwd + ); + float2 uv = dir_to_equirect_uv(dir); + float3 sky = texEquirect.Sample(smpEquirect, uv).rgb; + return float4(sky * UCam.bg_color, 1.0); +} diff --git a/engine/pill_engine/res/shaders/background_vertex.hlsl b/engine/pill_engine/res/shaders/background_vertex.hlsl new file mode 100644 index 00000000..f9b8c7b9 --- /dev/null +++ b/engine/pill_engine/res/shaders/background_vertex.hlsl @@ -0,0 +1,17 @@ +struct VOut { + float4 pos : SV_Position; + float2 ndc : TEXCOORD0; // NDC XY passed to fragment for ray reconstruction +}; + +// Full-screen triangle: 3 vertices cover NDC [-1,1]² without a vertex buffer. +VOut vs_main(uint vi : SV_VertexID) { + float2 p[3] = { + float2(-1.0, -3.0), + float2( 3.0, 1.0), + float2(-1.0, 1.0) + }; + VOut o; + o.pos = float4(p[vi], 1.0, 1.0); // NDC z/w = 1.0 = far plane (LessEqual depth test) + o.ndc = p[vi]; + return o; +} diff --git a/engine/pill_engine/res/shaders/include/common.hlsl b/engine/pill_engine/res/shaders/include/common.hlsl index 9bc9ca59..c8bf6aac 100644 --- a/engine/pill_engine/res/shaders/include/common.hlsl +++ b/engine/pill_engine/res/shaders/include/common.hlsl @@ -5,8 +5,9 @@ // any field change must be mirrored on both sides. struct EngineParams { - float3 fog_color; + float delta_time; float fog_density; + float3 fog_color; }; [[vk::binding(0, 0)]] ConstantBuffer engine; diff --git a/engine/pill_engine/res/shaders/include/equirect.hlsl b/engine/pill_engine/res/shaders/include/equirect.hlsl new file mode 100644 index 00000000..15f6f5d3 --- /dev/null +++ b/engine/pill_engine/res/shaders/include/equirect.hlsl @@ -0,0 +1,9 @@ +// Equirectangular UV mapping: atan2(z,x) convention matches the bake in studio_equirect.rs. +static const float EQUIRECT_PI = 3.14159265359; + +float2 dir_to_equirect_uv(float3 dir) { + float3 d = normalize(dir); + float u = 0.5 + atan2(d.z, d.x) / (2.0 * EQUIRECT_PI); + float v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / EQUIRECT_PI; + return float2(frac(u), clamp(v, 0.0, 1.0)); +} diff --git a/engine/pill_engine/res/shaders/pbr_opaque_fragment.hlsl b/engine/pill_engine/res/shaders/pbr_opaque_fragment.hlsl new file mode 100644 index 00000000..f1c69cbe --- /dev/null +++ b/engine/pill_engine/res/shaders/pbr_opaque_fragment.hlsl @@ -0,0 +1,141 @@ +// PBR static fragment shader. Edit here — pill_assets regenerates the .wgsl. +// Full GGX microfacet BRDF with 3 directional lights and optional IBL fallback. + +struct Camera { + float4 position; + column_major float4x4 viewProjection; + float3 fog_color; + float fog_density; +}; +[[vk::binding(0, 0)]] ConstantBuffer UCamera; + +// IBL resources in globals bind group (set 0, bindings 1-6). +[[vk::binding(1, 0)]] Texture2D texIrradiance; +[[vk::binding(2, 0)]] SamplerState smpIrradiance; +[[vk::binding(3, 0)]] Texture2D texPrefilter; +[[vk::binding(4, 0)]] SamplerState smpPrefilter; +[[vk::binding(5, 0)]] Texture2D texBrdfLut; +[[vk::binding(6, 0)]] SamplerState smpBrdfLut; + +// PBR material textures (set 1) — bindings match DEFAULT_LIT_SHADER layout (0-7). +[[vk::binding(0, 1)]] Texture2D texBaseColor; +[[vk::binding(1, 1)]] SamplerState smpBaseColor; +[[vk::binding(2, 1)]] Texture2D texNormal; +[[vk::binding(3, 1)]] SamplerState smpNormal; +[[vk::binding(4, 1)]] Texture2D texMetallicRoughness; +[[vk::binding(5, 1)]] SamplerState smpMetallicRoughness; +[[vk::binding(6, 1)]] Texture2D texEmissive; +[[vk::binding(7, 1)]] SamplerState smpEmissive; + +// PBR params UBO (set 2) — 48 bytes: 3 × 16-byte slots. +// Each scalar slot uses float+float+float2 padding to stay 16 bytes without float3 alignment gaps. +struct MaterialParams { + float3 baseColorFactor; + float _pad0; + float roughnessFactor; + float _pad1; + float2 _pad2; + float metallicFactor; + float _pad3; + float2 _pad4; +}; +[[vk::binding(0, 2)]] ConstantBuffer UMaterial; + +static const float PI = 3.14159265359; + +#include "include/equirect.hlsl" + +// Camera at +Z looking -Z (glTF default). Z components flipped vs. -Z camera setup. +static const float3 LIGHT_DIR0 = float3( 0.38, -0.38, -0.84); // key: behind-camera, upper-left +static const float3 LIGHT_DIR1 = float3(-0.50, 0.50, 0.71); // rim: front upper-right +static const float3 LIGHT_DIR2 = float3( 0.00, -1.00, 0.00); // bounce: from below +static const float4 LIGHT_COL0 = float4(1.0, 0.98, 0.95, 2.2); // near-neutral key +static const float4 LIGHT_COL1 = float4(0.6, 0.65, 1.00, 0.8); // cool rim +static const float4 LIGHT_COL2 = float4(0.8, 0.80, 0.80, 0.3); // neutral bounce + +float DistributionGGX(float3 N, float3 H, float roughness) { + // Add epsilon to avoid singularities at very low roughness. + float a = max(roughness * roughness, 0.0025); + float a2 = a * a; + float NdotH = max(dot(N, H), 0.0); + float denom = (NdotH * NdotH * (a2 - 1.0) + 1.0); + return a2 / (PI * denom * denom + 1e-7); +} + +float GeometrySchlickGGX(float NdotV, float roughness) { + // Heitz's k for direct lighting approximation. + float r = roughness + 1.0; + float k = (r * r) / 8.0; + return NdotV / (NdotV * (1.0 - k) + k); +} + +float GeometrySmith(float3 N, float3 V, float3 L, float roughness) { + float NdotV = max(dot(N, V), 0.0); + float NdotL = max(dot(N, L), 0.0); + return GeometrySchlickGGX(NdotL, roughness) * GeometrySchlickGGX(NdotV, roughness); +} + +float3 fresnelSchlick(float cosTheta, float3 F0) { + return F0 + (float3(1.0, 1.0, 1.0) - F0) * pow(1.0 - cosTheta, 5.0); +} + +float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness) { + return F0 + (max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0); +} + + +float3 accumulateDirLight( + float3 N, float3 V, float3 F0, + float3 albedo, float roughness, float metallic, + float3 lightDir, float4 lightColor +) { + // lightDir is direction from light to surface; incoming L is opposite. + float3 L = normalize(-lightDir); + float3 H = normalize(V + L); + float3 radiance = lightColor.w * lightColor.xyz; + float NDF = DistributionGGX(N, H, roughness); + float G = GeometrySmith(N, V, L, roughness); + float3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); + float3 kD = (float3(1.0, 1.0, 1.0) - F) * (1.0 - metallic); + float denom = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001; + float3 specular = (NDF * G * F) / float3(denom, denom, denom); + return (kD * (albedo / PI) + specular) * radiance * max(dot(N, L), 0.0); +} + +[shader("fragment")] +float4 fs_main( + [[vk::location(0)]] float2 uv : TEXCOORD0, + [[vk::location(1)]] float3 WorldPos: TEXCOORD1, + [[vk::location(2)]] float3 NormalIn: TEXCOORD2 +) : SV_TARGET { + float3 albedo = texBaseColor.Sample(smpBaseColor, uv).rgb * UMaterial.baseColorFactor; + float2 mr = texMetallicRoughness.Sample(smpMetallicRoughness, uv).gb; + // mr.x = G channel (roughness 0=smooth, 1=rough); mr.y = B channel (metallic 0=dielectric, 1=metal) + float roughness = clamp(mr.x * (1.0 - UMaterial.roughnessFactor), 0.045, 0.99); + float metallic = mr.y * UMaterial.metallicFactor; + // TODO: Support normal mapping (tangent space) and AO texture. + float3 N = normalize(NormalIn); + float3 V = normalize(UCamera.position.xyz - WorldPos); + float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, float3(metallic, metallic, metallic)); + float3 Lo = float3(0.0, 0.0, 0.0); + Lo += accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR0, LIGHT_COL0); + Lo += accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR1, LIGHT_COL1); + Lo += accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR2, LIGHT_COL2); + // Diffuse IBL ambient. + float3 kS = fresnelSchlick(max(dot(N, V), 0.0), F0); + float3 kD = (float3(1.0, 1.0, 1.0) - kS) * (1.0 - metallic); + float3 irradiance = texIrradiance.Sample(smpIrradiance, dir_to_equirect_uv(N)).rgb; + float3 ambientDiffuse = kD * irradiance * albedo; + // Specular IBL. + float3 R = reflect(-V, N); + float MAX_REFLECTION_LOD = 4.0; + float3 prefilteredColor = texPrefilter.SampleLevel(smpPrefilter, dir_to_equirect_uv(R), roughness * MAX_REFLECTION_LOD).rgb; + float2 envBRDF = texBrdfLut.Sample(smpBrdfLut, float2(max(dot(N, V), 0.0), roughness)).rg; + float3 F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); + float3 specularIBL = prefilteredColor * (F * envBRDF.x + envBRDF.y); + float3 emissive = texEmissive.Sample(smpEmissive, uv).rgb; + float3 color = Lo + ambientDiffuse + specularIBL + emissive; + float dist = length(UCamera.position.xyz - WorldPos); + float fog = 1.0 - exp(-UCamera.fog_density * UCamera.fog_density * dist * dist); + return float4(lerp(color, UCamera.fog_color, fog), 1.0); +} diff --git a/engine/pill_engine/res/shaders/pbr_opaque_vertex.hlsl b/engine/pill_engine/res/shaders/pbr_opaque_vertex.hlsl new file mode 100644 index 00000000..9632f8da --- /dev/null +++ b/engine/pill_engine/res/shaders/pbr_opaque_vertex.hlsl @@ -0,0 +1,56 @@ +// PBR static vertex shader. Edit here — pill_assets regenerates the .wgsl. +// Per-draw storage holds raw position/rotation(radians)/scale; the GPU builds the +// model matrix here (hardware sin/cos), so the CPU does ZERO per-entity trig and +// uploads 48 B instead of a 64 B matrix. MVP = viewProjection·model, also on GPU. +// [Aaltonen "HypeHype" GDC 2023; Lottes @NOTimothyLottes 2025-01-23] + +struct Camera { + float4 position; + column_major float4x4 viewProjection; + float3 fog_color; + float fog_density; +}; +[[vk::binding(0, 0)]] ConstantBuffer UCamera; + +struct PerDraw { + float4 position; // xyz + float4 rotation; // xyz, radians + float4 scale; // xyz +}; +[[vk::binding(0, 3)]] StructuredBuffer UPerDrawArray; + +struct VSOut { + [[vk::location(0)]] float2 uv : TEXCOORD0; + [[vk::location(1)]] float3 worldPos : TEXCOORD1; + [[vk::location(2)]] float3 worldNormal: TEXCOORD2; + float4 sv_position: SV_POSITION; +}; + +struct VSIn { + [[vk::location(0)]] float3 pos; + [[vk::location(4)]] float2 uv; + [[vk::location(5)]] float3 normal; +}; + +// Row-major float3x3 constructors; mul(R, v) applies standard right-handed rotation. +float3x3 rot_x(float a) { float c = cos(a), s = sin(a); return float3x3(1, 0, 0, 0, c, -s, 0, s, c); } +float3x3 rot_y(float a) { float c = cos(a), s = sin(a); return float3x3(c, 0, s, 0, 1, 0, -s, 0, c); } +float3x3 rot_z(float a) { float c = cos(a), s = sin(a); return float3x3(c, -s, 0, s, c, 0, 0, 0, 1); } + +[shader("vertex")] +VSOut vs_main(VSIn input, uint instance_id : SV_InstanceID) { + PerDraw per_draw = UPerDrawArray[instance_id]; + + // model = T * (Rx*Ry*Rz) * S — matches glam from_scale_rotation_translation(Quat::x*y*z). + float3x3 rotation = mul(rot_x(per_draw.rotation.x), mul(rot_y(per_draw.rotation.y), rot_z(per_draw.rotation.z))); + float3 scaled = input.pos * per_draw.scale.xyz; + float3 worldPos = per_draw.position.xyz + mul(rotation, scaled); + float4 worldPos4 = float4(worldPos, 1.0); + + VSOut output; + output.sv_position = mul(UCamera.viewProjection, worldPos4); + output.uv = input.uv; + output.worldPos = worldPos; + output.worldNormal = normalize(mul(rotation, input.normal)); + return output; +} diff --git a/engine/pill_engine/res/shaders/tonemap_fragment.hlsl b/engine/pill_engine/res/shaders/tonemap_fragment.hlsl new file mode 100644 index 00000000..3f68b9d5 --- /dev/null +++ b/engine/pill_engine/res/shaders/tonemap_fragment.hlsl @@ -0,0 +1,9 @@ +[[vk::binding(0, 0)]] Texture2D texHdr; +[[vk::binding(1, 0)]] SamplerState smpHdr; + +[shader("fragment")] +float4 fs_main(float2 uv : TEXCOORD0) : SV_Target { + float3 hdr = texHdr.Sample(smpHdr, uv).rgb; + float3 mapped = hdr / (hdr + 1.0); + return float4(mapped, 1.0); +} diff --git a/engine/pill_engine/res/shaders/tonemap_vertex.hlsl b/engine/pill_engine/res/shaders/tonemap_vertex.hlsl new file mode 100644 index 00000000..66a8a1a1 --- /dev/null +++ b/engine/pill_engine/res/shaders/tonemap_vertex.hlsl @@ -0,0 +1,16 @@ +struct VOut { + float4 pos : SV_Position; + float2 uv : TEXCOORD0; +}; + +VOut vs_main(uint vi : SV_VertexID) { + float2 p[3] = { + float2(-1.0, -3.0), + float2( 3.0, 1.0), + float2(-1.0, 1.0) + }; + VOut o; + o.pos = float4(p[vi], 0.0, 1.0); + o.uv = float2(p[vi].x * 0.5 + 0.5, -p[vi].y * 0.5 + 0.5); + return o; +} diff --git a/engine/pill_engine/res/textures/studio.studio b/engine/pill_engine/res/textures/studio.studio new file mode 100644 index 00000000..e69de29b diff --git a/engine/pill_engine/src/app_config.rs b/engine/pill_engine/src/app_config.rs index 9f077625..4e374b1e 100644 --- a/engine/pill_engine/src/app_config.rs +++ b/engine/pill_engine/src/app_config.rs @@ -20,7 +20,7 @@ impl EngineConfig { continue; } if let Some(eq) = line.find('=') { - let key = line[..eq].trim().to_uppercase(); + let key = line[..eq].trim().to_ascii_uppercase(); let value = line[eq + 1..].trim().to_string(); values.insert(key, value); } @@ -29,13 +29,14 @@ impl EngineConfig { } pub fn set(&mut self, key: &str, value: i64) { - self.values.insert(key.to_uppercase(), value.to_string()); + self.values + .insert(key.to_ascii_uppercase(), value.to_string()); } pub fn get_int(&self, key: &str) -> Result { use pill_core::PillError; self.values - .get(&key.to_uppercase()) + .get(&key.to_ascii_uppercase()) .ok_or_else(|| -> PillError { format!("{key} not found in config").into() })? .parse::() .map_err(|e| -> PillError { @@ -47,9 +48,9 @@ impl EngineConfig { use pill_core::PillError; let v = self .values - .get(&key.to_uppercase()) + .get(&key.to_ascii_uppercase()) .ok_or_else(|| -> PillError { format!("{key} not found in config").into() })?; - match v.to_lowercase().as_str() { + match v.to_ascii_lowercase().as_str() { "true" | "1" | "yes" => Ok(true), "false" | "0" | "no" => Ok(false), _ => Err(format!("Config key {key} is not a valid bool: {v}").into()), diff --git a/engine/pill_engine/src/config.rs b/engine/pill_engine/src/config.rs index b40602cc..1f6479d7 100644 --- a/engine/pill_engine/src/config.rs +++ b/engine/pill_engine/src/config.rs @@ -8,6 +8,9 @@ use crate::{ resources::{MaterialHandle, ShaderHandle, TextureHandle, TextureType}, }; +#[cfg(feature = "ui")] +use crate::ecs::egui_system; + #[cfg(not(target_arch = "wasm32"))] use crate::ecs::{audio_system, haptics_system, AudioManagerComponent}; @@ -72,6 +75,13 @@ pub const RENDERING_SYSTEM: SystemConfig = SystemConfig { update_phase: UpdatePhase::PostGame, }; +#[cfg(feature = "ui")] +pub const EGUI_SYSTEM: SystemConfig = SystemConfig { + name: "egui_system", + system_function: egui_system, + update_phase: UpdatePhase::PostGame, +}; + // --- Resources --- pub const RESOURCE_VERSION_LIMIT: usize = 255; @@ -86,6 +96,8 @@ pub const MAX_SOUNDS: usize = 10; pub const DEFAULT_RESOURCE_PREFIX: &str = "pill_engine"; pub const DEFAULT_COLOR_TEXTURE_NAME: &str = "pill_engine_default_color"; pub const DEFAULT_NORMAL_TEXTURE_NAME: &str = "pill_engine_default_normal"; +pub const DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME: &str = "pill_engine_default_metallic_roughness"; +pub const DEFAULT_EMISSIVE_TEXTURE_NAME: &str = "pill_engine_default_emissive"; // RTEX layout: b"RTEX" | u32LE version=1 | u32LE width | u32LE height | raw RGBA bytes pub const DEFAULT_COLOR_TEXTURE_BYTES: [u8; 20] = [ @@ -96,6 +108,24 @@ pub const DEFAULT_NORMAL_TEXTURE_BYTES: [u8; 20] = [ b'R', b'T', b'E', b'X', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 128, 128, 255, 255, // flat normal (0,0,1) ]; +// Neutral metallic_roughness: G=255 (max roughness → scalar passes through), B=0 (metallic=0) +pub const DEFAULT_METALLIC_ROUGHNESS_TEXTURE_BYTES: [u8; 20] = [ + b'R', b'T', b'E', b'X', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 255, 0, 255, +]; +// Black emissive: no emission by default +pub const DEFAULT_EMISSIVE_TEXTURE_BYTES: [u8; 20] = [ + b'R', b'T', b'E', b'X', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 255, +]; + +// 1×1 Rgba32Float white — PassBackground equirect fallback; bg_color tints it to the desired solid color +// 1.0f32 LE = 00 00 80 3F +pub const DEFAULT_EQUIRECT_FALLBACK_PIXEL: &[u8] = &[ + 0x00, 0x00, 0x80, 0x3F, 0x00, 0x00, 0x80, 0x3F, 0x00, 0x00, 0x80, 0x3F, 0x00, 0x00, 0x80, 0x3F, +]; +// 1×1 Rgba8Unorm neutral-gray — IBL diffuse/specular fallback: RGBA = [77, 77, 77, 255] ≈ 0.3 linear +pub const DEFAULT_IBL_FALLBACK_PIXEL: &[u8] = &[77, 77, 77, 255]; +// 1×1 Rgba8Unorm — BRDF LUT fallback: R=F0_scale=0.5, G=F0_bias=0.5 +pub const DEFAULT_BRDF_LUT_FALLBACK_PIXEL: &[u8] = &[128, 128, 0, 255]; // Default lit shader pub const DEFAULT_LIT_SHADER_NAME: &str = "pill_engine_default_lit_shader"; @@ -103,8 +133,13 @@ pub const DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME: &str = "color"; pub const DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_BINDINGS: (u32, u32) = (0, 1); pub const DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME: &str = "normal"; pub const DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_BINDINGS: (u32, u32) = (2, 3); +pub const DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME: &str = "metallic_roughness"; +pub const DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_BINDINGS: (u32, u32) = (4, 5); +pub const DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_NAME: &str = "emissive"; +pub const DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_BINDINGS: (u32, u32) = (6, 7); pub const DEFAULT_LIT_SHADER_TINT_PARAMETER_SLOT_NAME: &str = "tint"; pub const DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME: &str = "specularity"; +pub const DEFAULT_LIT_SHADER_METALLIC_FACTOR_PARAMETER_SLOT_NAME: &str = "metallic_factor"; pub const DEFAULT_LIT_MATERIAL_NAME: &str = "pill_engine_default_lit_material"; pub const DEFAULT_UNLIT_SHADER_NAME: &str = "pill_engine_default_unlit_shader"; @@ -114,32 +149,12 @@ pub const DEFAULT_UNLIT_SHADER_TINT_PARAMETER_SLOT_NAME: &str = "tint"; pub const DEFAULT_UNLIT_MATERIAL_NAME: &str = "pill_engine_default_unlit_material"; // Render queue key -pub type RenderQueueKeyType = u64; // Defines size of renderer queue key (Should be u8, u16, u32, or u64) - -pub const RENDER_QUEUE_KEY_ITEMS_LENGTH: [RenderQueueKeyType; 7] = [5, 8, 8, 8, 8, 8, 8]; // Defines size of next render queue key parts (bits from left to right) - -// 64-bit render sort key layout (u64) -// +---------+--------------+---------------------------------------------------------+ -// | Bits | Field | Description | -// +---------+--------------+---------------------------------------------------------+ -// | 0..4 | Order | 5 bits: render order (max 32) | -// | 5..12 | Shader Idx | 8 bits: shader index (max 256) | -// | 13..20 | Shader Ver | 8 bits: shader version (max 256) | -// | 21..28 | Material Idx | 8 bits: material index (max 256) | -// | 29..36 | Material Ver | 8 bits: material version (max 256) | -// | 37..44 | Mesh Idx | 8 bits: mesh index (max 256) | -// | 45..52 | Mesh Ver | 8 bits: mesh version (max 256) | -// | 53..63 | Free | 11 bits: free for future use | -// +---------+--------------+---------------------------------------------------------+ - -// Indices of render queue key parts (maps RENDER_QUEUE_KEY_ITEMS_LENGTH) -pub const RENDER_QUEUE_KEY_RENDERING_ORDER_IDX: u8 = 0; -pub const RENDER_QUEUE_KEY_SHADER_INDEX_IDX: u8 = 1; -pub const RENDER_QUEUE_KEY_SHADER_VERSION_IDX: u8 = 2; -pub const RENDER_QUEUE_KEY_MATERIAL_INDEX_IDX: u8 = 3; -pub const RENDER_QUEUE_KEY_MATERIAL_VERSION_IDX: u8 = 4; -pub const RENDER_QUEUE_KEY_MESH_INDEX_IDX: u8 = 5; -pub const RENDER_QUEUE_KEY_MESH_VERSION_IDX: u8 = 6; +pub type RenderQueueKeyType = u64; + +// 64-bit render sort key layout (MSB → LSB): +// bit: 63 59 58 51 50 43 42 35 34 27 26 19 18 11 10 0 +// [ order ] [shader_idx] [shader_ver] [ mat_idx ] [ mat_ver ] [mesh_idx ] [mesh_ver ] [ unused ] +// [ 5 bits ] [ 8 bits ] [ 8 bits ] [ 8 bits ] [ 8 bits ] [ 8 bits ] [ 8 bits ] [ 11 bits ] // Default resource handle - Color texture pub const DEFAULT_COLOR_TEXTURE_HANDLE: TextureHandle = TextureHandle(PillSlotMapKeyData { @@ -163,6 +178,29 @@ pub const DEFAULT_RENDERER_NORMAL_TEXTURE_HANDLE: RendererTextureHandle = version: std::num::NonZeroU32::new(1).unwrap(), }); +// Default resource handle - MetallicRoughness texture +pub const DEFAULT_METALLIC_ROUGHNESS_TEXTURE_HANDLE: TextureHandle = + TextureHandle(PillSlotMapKeyData { + index: 3, + version: std::num::NonZeroU32::new(1).unwrap(), + }); +pub const DEFAULT_RENDERER_METALLIC_ROUGHNESS_TEXTURE_HANDLE: RendererTextureHandle = + RendererTextureHandle(PillSlotMapKeyData { + index: 3, + version: std::num::NonZeroU32::new(1).unwrap(), + }); + +// Default resource handle - Emissive texture +pub const DEFAULT_EMISSIVE_TEXTURE_HANDLE: TextureHandle = TextureHandle(PillSlotMapKeyData { + index: 4, + version: std::num::NonZeroU32::new(1).unwrap(), +}); +pub const DEFAULT_RENDERER_EMISSIVE_TEXTURE_HANDLE: RendererTextureHandle = + RendererTextureHandle(PillSlotMapKeyData { + index: 4, + version: std::num::NonZeroU32::new(1).unwrap(), + }); + pub fn get_default_texture_handles( texture_type: TextureType, ) -> (TextureHandle, RendererTextureHandle) { @@ -175,6 +213,14 @@ pub fn get_default_texture_handles( DEFAULT_NORMAL_TEXTURE_HANDLE, DEFAULT_RENDERER_NORMAL_TEXTURE_HANDLE, ), + TextureType::MetallicRoughness => ( + DEFAULT_METALLIC_ROUGHNESS_TEXTURE_HANDLE, + DEFAULT_RENDERER_METALLIC_ROUGHNESS_TEXTURE_HANDLE, + ), + TextureType::Emissive => ( + DEFAULT_EMISSIVE_TEXTURE_HANDLE, + DEFAULT_RENDERER_EMISSIVE_TEXTURE_HANDLE, + ), } } @@ -222,8 +268,8 @@ lazy_static! { ]; #[cfg(not(target_arch = "wasm32"))] component_types.push(TypeId::of::()); - #[cfg(feature = "debug_ui")] - component_types.push(TypeId::of::()); + #[cfg(feature = "ui")] + component_types.push(TypeId::of::()); component_types }; } diff --git a/engine/pill_engine/src/ecs/components/camera_component.rs b/engine/pill_engine/src/ecs/components/camera_component.rs index 00fa3d6a..617fa8a8 100644 --- a/engine/pill_engine/src/ecs/components/camera_component.rs +++ b/engine/pill_engine/src/ecs/components/camera_component.rs @@ -72,6 +72,11 @@ impl CameraComponentBuilder { self } + pub fn look_at(mut self, target: Option) -> Self { + self.component.look_at = target; + self + } + pub fn build(self) -> CameraComponent { self.component } @@ -84,6 +89,7 @@ pub struct CameraComponent { pub fov: f32, pub range: Range, pub clear_color: Vector3f, + pub look_at: Option, // Exponential-squared fog: final_color is mixed toward fog_color by // 1 - exp(-density² · distance²). density = 0.0 disables fog (default). pub fog_density: f32, @@ -111,6 +117,7 @@ impl CameraComponent { clear_color: Vector3f::new(0.15, 0.15, 0.15), fog_density: 0.0, fog_color: Vector3f::new(0.0, 0.0, 0.0), + look_at: None, renderer_resource_handle: None, enabled: false, } diff --git a/engine/pill_engine/src/ecs/components/component_storage.rs b/engine/pill_engine/src/ecs/components/component_storage.rs index a8eb1f20..9c2e47e5 100644 --- a/engine/pill_engine/src/ecs/components/component_storage.rs +++ b/engine/pill_engine/src/ecs/components/component_storage.rs @@ -1,3 +1,6 @@ +use crate::ecs::GlobalComponent; +use pill_core::{get_type_name, EngineError, PillTypeMap, PillTypeMapKey, Result}; + // --- Component storage --- pub struct ComponentStorage { @@ -29,3 +32,18 @@ impl GlobalComponentStorage { Self { data: Some(data) } } } + +/// Borrows global component `T` out of a `PillTypeMap`, erroring if absent. +/// Single source of truth for `Engine::get_global_component` and `WorldQuery::get_global` — +/// lives here because it depends on `GlobalComponentStorage`'s `data` layout. +pub fn get_global_component_from(globals: &PillTypeMap) -> Result<&T> +where + T: GlobalComponent> + PillTypeMapKey, +{ + globals + .get::() + .and_then(|storage| storage.data.as_ref()) + .ok_or_else(|| -> pill_core::PillError { + EngineError::GlobalComponentNotFound(get_type_name::()).into() + }) +} diff --git a/engine/pill_engine/src/ecs/components/egui_manager_component.rs b/engine/pill_engine/src/ecs/components/egui_component.rs similarity index 70% rename from engine/pill_engine/src/ecs/components/egui_manager_component.rs rename to engine/pill_engine/src/ecs/components/egui_component.rs index 5030c3a2..3043b857 100644 --- a/engine/pill_engine/src/ecs/components/egui_manager_component.rs +++ b/engine/pill_engine/src/ecs/components/egui_component.rs @@ -1,11 +1,11 @@ -#![cfg(feature = "debug_ui")] +#![cfg(feature = "ui")] -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use crate::{ ecs::{ components::{GlobalComponent, GlobalComponentStorage}, - UpdatePhase, + EguiClient, UpdatePhase, }, engine::Engine, }; @@ -15,24 +15,64 @@ use pill_core::{PillTypeMapKey, Timer, TimerRecord}; use pill_core::{ErrorContext, Result}; -pub struct EguiManagerComponent { +fn format_record(buf: &mut String, record: &TimerRecord, depth: usize) { + buf.push_str(&format!( + "{}{:.3} ms {}\n", + " ".repeat(depth), + record.duration, + record.label + )); + for sub in &record.subrecords { + format_record(buf, sub, depth + 1); + } +} + +fn format_timers(system_timers: &[(UpdatePhase, Vec<(String, Timer)>)]) -> String { + let mut buf = String::new(); + for (phase, timers) in system_timers { + let total: f32 = timers.iter().map(|(_, t)| t.total_duration()).sum(); + buf.push_str(&format!("Phase: {} {:.3} ms\n", phase, total)); + for (name, timer) in timers { + buf.push_str(&format!(" {}\n", name)); + for record in &timer.records { + format_record(&mut buf, record, 2); + } + } + } + buf +} + +pub struct EguiComponent { + pub egui_client: Arc, collapsing_state: HashMap, + game_overlay: Option>, } -impl Default for EguiManagerComponent { +impl Default for EguiComponent { fn default() -> Self { Self::new() } } -impl EguiManagerComponent { +impl EguiComponent { pub fn new() -> Self { Self { + egui_client: EguiClient::new(), collapsing_state: HashMap::new(), + game_overlay: None, } } - pub fn get_ui(engine: &mut Engine) -> Box { + pub fn set_overlay(&mut self, f: impl Fn(&egui::Context) + Send + Sync + 'static) { + self.game_overlay = Some(Arc::new(f)); + } + + pub fn get_ui(engine: &mut Engine) -> Box { + let game_overlay = engine + .get_global_component::() + .ok() + .and_then(|c| c.game_overlay.clone()); + let entity_count = engine .scene_manager .get_active_scene() @@ -75,7 +115,10 @@ impl EguiManagerComponent { .sum::(); let frame_delta_time = engine.frame_delta_time; - let ui = Box::new(move |ui: &egui::Context| { + let ui: Box = Box::new(move |ui: &egui::Context| { + if let Some(ref overlay) = game_overlay { + overlay(ui); + } egui::Window::new("Pill Engine") .default_open(false) .resizable(true) @@ -97,10 +140,15 @@ impl EguiManagerComponent { ))); ui.add(egui::Label::new(format!("Entities: {}", entity_count))); ui.separator(); - ui.add(egui::Label::new(format!( - "Systems: {}, Total delta time: {:.3} ms", - system_count, total_systems_delta_time - ))); + ui.horizontal(|ui| { + ui.add(egui::Label::new(format!( + "Systems: {}, Total delta time: {:.3} ms", + system_count, total_systems_delta_time + ))); + if ui.small_button("[C]").clicked() { + ui.ctx().copy_text(format_timers(&system_timers)); + } + }); let mut phase_state = HashMap::new(); for (update_phase, system_timers) in system_timers.iter() { let phase_duration = system_timers @@ -174,8 +222,8 @@ impl EguiManagerComponent { } } -impl PillTypeMapKey for EguiManagerComponent { - type Storage = GlobalComponentStorage; +impl PillTypeMapKey for EguiComponent { + type Storage = GlobalComponentStorage; } -impl GlobalComponent for EguiManagerComponent {} +impl GlobalComponent for EguiComponent {} diff --git a/engine/pill_engine/src/ecs/components/mesh_component.rs b/engine/pill_engine/src/ecs/components/mesh_component.rs new file mode 100644 index 00000000..23d02660 --- /dev/null +++ b/engine/pill_engine/src/ecs/components/mesh_component.rs @@ -0,0 +1,83 @@ +use crate::{ + ecs::{Component, ComponentStorage}, + engine::Engine, + resources::{Mesh, MeshHandle}, +}; +use pill_core::{get_type_name, PillStyle, PillTypeMapKey}; +use pill_core::{ErrorContext, Result}; + +// --- Builder --- + +pub struct MeshComponentBuilder { + component: MeshComponent, +} + +impl MeshComponentBuilder { + pub fn default() -> Self { + Self { + component: MeshComponent::new(), + } + } + + pub fn mesh(mut self, mesh_handle: &MeshHandle) -> Self { + self.component.mesh_handle = Some(*mesh_handle); + self + } + + pub fn build(self) -> MeshComponent { + self.component + } +} + +// --- Mesh Component --- + +#[readonly::make] +pub struct MeshComponent { + #[readonly] + pub mesh_handle: Option, +} + +impl Default for MeshComponent { + fn default() -> Self { + Self::new() + } +} + +impl MeshComponent { + pub fn builder() -> MeshComponentBuilder { + MeshComponentBuilder::default() + } + + pub fn new() -> Self { + Self { mesh_handle: None } + } + + pub fn set_mesh(&mut self, mesh_handle: &MeshHandle) { + self.mesh_handle = Some(*mesh_handle); + } + + pub fn remove_mesh(&mut self) { + self.mesh_handle = None; + } + + pub(crate) fn set_mesh_handle(&mut self, mesh_handle: Option) { + self.mesh_handle = mesh_handle; + } +} + +impl PillTypeMapKey for MeshComponent { + type Storage = ComponentStorage; +} + +impl Component for MeshComponent { + fn initialize(&mut self, engine: &mut Engine) -> Result<()> { + if let Some(handle) = &self.mesh_handle { + engine.get_resource::(handle).context(format!( + "Creating {} {} failed", + "Component".general_object_style(), + get_type_name::().specific_object_style() + ))?; + } + Ok(()) + } +} diff --git a/engine/pill_engine/src/ecs/components/mod.rs b/engine/pill_engine/src/ecs/components/mod.rs index 210a45d3..468490bb 100644 --- a/engine/pill_engine/src/ecs/components/mod.rs +++ b/engine/pill_engine/src/ecs/components/mod.rs @@ -10,14 +10,16 @@ pub(crate) mod camera_component; mod component; mod component_storage; pub(crate) mod deferred_update_component; -#[cfg(feature = "debug_ui")] -pub(crate) mod egui_manager_component; +#[cfg(feature = "ui")] +pub(crate) mod egui_component; pub(crate) mod input_component; -pub(crate) mod mesh_rendering_component; +pub(crate) mod mesh_component; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod network_manager_component; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod network_state_component; +pub(crate) mod pbr_renderable_component; +pub(crate) mod render_state_component; pub(crate) mod time_component; pub(crate) mod transform_component; @@ -25,4 +27,4 @@ pub(crate) mod transform_component; pub use component::{Component, ComponentDestroyer, ConcreteComponentDestroyer, GlobalComponent}; -pub use component_storage::{ComponentStorage, GlobalComponentStorage}; +pub use component_storage::{get_global_component_from, ComponentStorage, GlobalComponentStorage}; diff --git a/engine/pill_engine/src/ecs/components/mesh_rendering_component.rs b/engine/pill_engine/src/ecs/components/pbr_renderable_component.rs similarity index 69% rename from engine/pill_engine/src/ecs/components/mesh_rendering_component.rs rename to engine/pill_engine/src/ecs/components/pbr_renderable_component.rs index eb2a167f..b45f7b15 100644 --- a/engine/pill_engine/src/ecs/components/mesh_rendering_component.rs +++ b/engine/pill_engine/src/ecs/components/pbr_renderable_component.rs @@ -5,8 +5,10 @@ use crate::{ DeferredUpdateManagerPointer, EntityHandle, SceneHandle, }, engine::Engine, - graphics::{compose_render_queue_key, RenderQueueKey}, - resources::{Material, MaterialHandle, Mesh, MeshHandle, ResourceManager}, + graphics::{compose_pbr_render_queue_key, compose_render_queue_key, RenderQueueKey}, + resources::{ + Material, MaterialHandle, Mesh, MeshHandle, PBRMaterial, PBRMaterialHandle, ResourceManager, + }, }; use pill_core::{get_type_name, PillStyle, PillTypeMapKey}; @@ -16,17 +18,18 @@ use pill_core::{ErrorContext, Result}; const DEFERRED_REQUEST_VARIANT_UPDATE_RENDER_QUEUE: usize = 0; const DEFERRED_REQUEST_VARIANT_SET_MATERIAL: usize = 1; const DEFERRED_REQUEST_VARIANT_SET_MESH: usize = 2; +const DEFERRED_REQUEST_VARIANT_SET_PBR_MATERIAL: usize = 3; // --- Builder --- -pub struct MeshRenderingComponentBuilder { - component: MeshRenderingComponent, +pub struct PbrRenderableComponentBuilder { + component: PbrRenderableComponent, } -impl MeshRenderingComponentBuilder { +impl PbrRenderableComponentBuilder { pub fn default() -> Self { Self { - component: MeshRenderingComponent::new(), + component: PbrRenderableComponent::new(), } } @@ -40,19 +43,26 @@ impl MeshRenderingComponentBuilder { self } - pub fn build(self) -> MeshRenderingComponent { + pub fn pbr_material(mut self, material_handle: &PBRMaterialHandle) -> Self { + self.component.pbr_material_handle = Some(*material_handle); + self + } + + pub fn build(self) -> PbrRenderableComponent { self.component } } -// --- Mesh Rendering Component --- +// --- PBR Renderable Component --- #[readonly::make] -pub struct MeshRenderingComponent { +pub struct PbrRenderableComponent { #[readonly] pub mesh_handle: Option, #[readonly] pub material_handle: Option, + #[readonly] + pub pbr_material_handle: Option, pub(crate) render_queue_key: Option, entity_handle: Option, @@ -60,21 +70,22 @@ pub struct MeshRenderingComponent { deferred_update_manager: Option, } -impl Default for MeshRenderingComponent { +impl Default for PbrRenderableComponent { fn default() -> Self { Self::new() } } -impl MeshRenderingComponent { - pub fn builder() -> MeshRenderingComponentBuilder { - MeshRenderingComponentBuilder::default() +impl PbrRenderableComponent { + pub fn builder() -> PbrRenderableComponentBuilder { + PbrRenderableComponentBuilder::default() } pub fn new() -> Self { Self { mesh_handle: None, material_handle: None, + pbr_material_handle: None, render_queue_key: None, entity_handle: None, scene_handle: None, @@ -87,6 +98,11 @@ impl MeshRenderingComponent { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_SET_MATERIAL); } + pub fn set_pbr_material(&mut self, material_handle: &PBRMaterialHandle) { + self.pbr_material_handle = Some(*material_handle); + self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_SET_PBR_MATERIAL); + } + pub fn set_mesh(&mut self, mesh_handle: &MeshHandle) { self.mesh_handle = Some(*mesh_handle); self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_SET_MESH); @@ -94,6 +110,7 @@ impl MeshRenderingComponent { pub fn remove_material(&mut self) { self.material_handle = None; + self.pbr_material_handle = None; self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_UPDATE_RENDER_QUEUE); } @@ -114,17 +131,17 @@ impl MeshRenderingComponent { &mut self, resource_manager: &ResourceManager, ) -> Result<()> { - if let Some(handle) = &self.mesh_handle { - // Use default material if no material is set - let material_handle = match self.material_handle { - Some(v) => v, - None => DEFAULT_MATERIAL_HANDLE, + if let Some(mesh_handle) = &self.mesh_handle { + // PBR path takes priority; legacy Material path is the fallback + let result = if let Some(pbr_handle) = self.pbr_material_handle { + compose_pbr_render_queue_key(resource_manager, pbr_handle, mesh_handle) + } else { + // Use default material if no material is set + let material_handle = self.material_handle.unwrap_or(DEFAULT_MATERIAL_HANDLE); + compose_render_queue_key(resource_manager, &material_handle, mesh_handle) }; - // Compose render queue key and set it - if let Ok(render_queue_key) = - compose_render_queue_key(resource_manager, &material_handle, handle) - { + if let Ok(render_queue_key) = result { self.render_queue_key = Some(render_queue_key); } else { self.render_queue_key = None; @@ -144,7 +161,7 @@ impl MeshRenderingComponent { let scene_handle = self.scene_handle.expect( "Critical: Cannot post deferred update request. No SceneHandle set in Component", ); - let request = DeferredUpdateComponentRequest::::new( + let request = DeferredUpdateComponentRequest::::new( entity_handle, scene_handle, request_variant, @@ -154,11 +171,11 @@ impl MeshRenderingComponent { } } -impl PillTypeMapKey for MeshRenderingComponent { - type Storage = ComponentStorage; +impl PillTypeMapKey for PbrRenderableComponent { + type Storage = ComponentStorage; } -impl Component for MeshRenderingComponent { +impl Component for PbrRenderableComponent { fn initialize(&mut self, engine: &mut Engine) -> Result<()> { // This component is using DeferredUpdateSystem so keep DeferredUpdateManager let deferred_update_component = engine @@ -176,6 +193,15 @@ impl Component for MeshRenderingComponent { ))?; } + // Check if PBR material handle is valid + if let Some(handle) = &self.pbr_material_handle { + engine.get_resource::(handle).context(format!( + "Creating {} {} failed", + "Component".general_object_style(), + get_type_name::().specific_object_style() + ))?; + } + // Check if mesh handle is valid if let Some(handle) = &self.mesh_handle { engine.get_resource::(handle).context(format!( @@ -199,7 +225,6 @@ impl Component for MeshRenderingComponent { fn deferred_update(&mut self, engine: &mut Engine, request: usize) -> Result<()> { match request { DEFERRED_REQUEST_VARIANT_SET_MATERIAL => { - // Check if material handle is valid engine .get_resource::(&self.material_handle.unwrap()) .context(format!( @@ -210,8 +235,18 @@ impl Component for MeshRenderingComponent { self.update_render_queue_key(&engine.resource_manager)?; } + DEFERRED_REQUEST_VARIANT_SET_PBR_MATERIAL => { + engine + .get_resource::(&self.pbr_material_handle.unwrap()) + .context(format!( + "Setting {} {} failed", + "Resource".general_object_style(), + "PBRMaterial".specific_object_style() + ))?; + + self.update_render_queue_key(&engine.resource_manager)?; + } DEFERRED_REQUEST_VARIANT_SET_MESH => { - // Check if mesh handle is valid engine .get_resource::(&self.mesh_handle.unwrap()) .context(format!( @@ -223,7 +258,6 @@ impl Component for MeshRenderingComponent { self.update_render_queue_key(&engine.resource_manager)?; } DEFERRED_REQUEST_VARIANT_UPDATE_RENDER_QUEUE => { - // Update mesh rendering queue self.update_render_queue_key(&engine.resource_manager)?; } _ => { diff --git a/engine/pill_engine/src/ecs/components/render_state_component.rs b/engine/pill_engine/src/ecs/components/render_state_component.rs new file mode 100644 index 00000000..9293a9ce --- /dev/null +++ b/engine/pill_engine/src/ecs/components/render_state_component.rs @@ -0,0 +1,43 @@ +use crate::{ + ecs::components::{GlobalComponent, GlobalComponentStorage}, + graphics::RendererTextureHandle, +}; + +use pill_core::PillTypeMapKey; + +pub struct RenderStateComponent { + pub boot_done: bool, + pub background: RendererTextureHandle, + pub ibl_diffuse: RendererTextureHandle, + pub ibl_specular: RendererTextureHandle, + pub ibl_brdf_lut: RendererTextureHandle, + pub bg_color: [f32; 3], // tint multiplied against equirect sample; [1,1,1] = identity + pub fog_density: f32, +} + +impl RenderStateComponent { + /// Creates the component in its pre-boot state; handles are replaced by `create_default_resources`. + pub fn new() -> Self { + Self { + boot_done: false, + background: RendererTextureHandle::default(), + ibl_diffuse: RendererTextureHandle::default(), + ibl_specular: RendererTextureHandle::default(), + ibl_brdf_lut: RendererTextureHandle::default(), + bg_color: [1.0, 1.0, 1.0], + fog_density: 0.0, + } + } +} + +impl Default for RenderStateComponent { + fn default() -> Self { + Self::new() + } +} + +impl PillTypeMapKey for RenderStateComponent { + type Storage = GlobalComponentStorage; +} + +impl GlobalComponent for RenderStateComponent {} diff --git a/engine/pill_engine/src/ecs/components/transform_component.rs b/engine/pill_engine/src/ecs/components/transform_component.rs index f93a6f1d..d2deb41e 100644 --- a/engine/pill_engine/src/ecs/components/transform_component.rs +++ b/engine/pill_engine/src/ecs/components/transform_component.rs @@ -48,7 +48,9 @@ impl TransformComponentBuilder { // --- Transform Component --- -// NOTE: Setting position/rotation/scale directly is not possible since we need to update matrices after each change +// 36 bytes hot (pos+rot+scale only); model_matrix/normal_matrix were dead weight — GPU +// computes matrices from pos/rot/scale sent as instance data. [Acton "Data-Oriented Design +// and C++" CppCon 2014 — hot/cold struct split reduces cache lines per entity 152→36 B] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[readonly::make] pub struct TransformComponent { @@ -58,14 +60,6 @@ pub struct TransformComponent { pub rotation: Vector3f, #[readonly] pub scale: Vector3f, - - model_matrix: Matrix4f, - normal_matrix: Matrix3fA, - - // There may me multiple updates of the position/rotation/scale in the single frame. - // Not to calculate matrices multiple times, we will update them only once per frame - // The update happens in the rendering system - pub matrix_update_required: bool, } impl TransformComponent { @@ -78,9 +72,6 @@ impl TransformComponent { position: Vector3f::ZERO, rotation: Vector3f::ZERO, scale: Vector3f::new(1.0, 1.0, 1.0), - model_matrix: Matrix4f::IDENTITY, - normal_matrix: Matrix3fA::IDENTITY, - matrix_update_required: true, } } @@ -88,7 +79,6 @@ impl TransformComponent { pub fn set_position(&mut self, position: Vector3f) { self.position = position; - self.matrix_update_required = true; } pub fn translate(&mut self, delta: f32, direction: Direction) { @@ -106,19 +96,16 @@ impl TransformComponent { Direction::WorldUp => self.position.y += delta, Direction::WorldDown => self.position.y -= delta, } - self.matrix_update_required = true; } pub fn translate_world(&mut self, delta: Vector3f) { self.position += delta; - self.matrix_update_required = true; } pub fn translate_local(&mut self, delta: Vector3f) { self.position += self.get_forward_direction() * delta.z + self.get_right_direction() * delta.x + self.get_up_direction() * delta.y; - self.matrix_update_required = true; } // --- Directions --- @@ -158,41 +145,30 @@ impl TransformComponent { pub fn set_rotation(&mut self, rotation: Vector3f) { self.rotation = rotation; - self.matrix_update_required = true; } // TODO: Implement quaternion rotation pub fn rotate_around_axis(&mut self, angle: f32, axis: Vector3f) { self.rotation += angle * axis; - self.matrix_update_required = true; } // --- Scale --- pub fn set_scale(&mut self, scale: Vector3f) { self.scale = scale; - self.matrix_update_required = true; } } -pub fn update_transform_matrices(transform_component: &mut TransformComponent) { - let model = Matrix4f::model( - transform_component.position, - transform_component.rotation, - transform_component.scale, - ); - let normal = Matrix3f::from_euler_angles(transform_component.rotation); +// Kept for backward compatibility — no longer needed since the renderer +// computes matrices on the GPU from pos/rot/scale instance data. +pub fn update_transform_matrices(_transform_component: &mut TransformComponent) {} - transform_component.model_matrix = model; - transform_component.normal_matrix = normal.into(); +pub fn get_model_matrix(_transform_component: &TransformComponent) -> Matrix4f { + Matrix4f::IDENTITY } -pub fn get_model_matrix(transform_component: &TransformComponent) -> Matrix4f { - transform_component.model_matrix -} - -pub fn get_normal_matrix(transform_component: &TransformComponent) -> Matrix3fA { - transform_component.normal_matrix +pub fn get_normal_matrix(_transform_component: &TransformComponent) -> Matrix3fA { + Matrix3fA::IDENTITY } impl PillTypeMapKey for TransformComponent { @@ -206,44 +182,3 @@ impl Default for TransformComponent { Self::new() } } - -pub trait Matrix3fAngleExt { - fn from_euler_angles(rotation_deg: Vector3f) -> Matrix3f; -} - -pub trait Matrix4fModelExt { - fn model(position: Vector3f, rotation_deg: Vector3f, scale: Vector3f) -> Matrix4f; - fn from_euler_angles(rotation_deg: Vector3f) -> Matrix4f; -} - -impl Matrix3fAngleExt for Matrix3f { - fn from_euler_angles(rotation_deg: Vector3f) -> Matrix3f { - let rz = Matrix3f::from_angle(rotation_deg.z.to_radians()); - let ry = Matrix3f::from_angle(rotation_deg.y.to_radians()); - let rx = Matrix3f::from_angle(rotation_deg.x.to_radians()); - rz * ry * rx - } -} - -impl Matrix4fModelExt for Matrix4f { - fn model(position: Vector3f, rotation_deg: Vector3f, scale: Vector3f) -> Matrix4f { - let rz = Matrix3f::from_angle(rotation_deg.z.to_radians()); - let ry = Matrix3f::from_angle(rotation_deg.y.to_radians()); - let rx = Matrix3f::from_angle(rotation_deg.x.to_radians()); - let rot3 = rz * ry * rx; - - let t = Matrix4f::from_translation(position); - let r = Matrix4f::from_mat3(rot3); - let s = Matrix4f::from_scale(scale); - - t * r * s - } - - fn from_euler_angles(rotation_deg: Vector3f) -> Matrix4f { - let rz = Matrix3f::from_angle(rotation_deg.z.to_radians()); - let ry = Matrix3f::from_angle(rotation_deg.y.to_radians()); - let rx = Matrix3f::from_angle(rotation_deg.x.to_radians()); - let rot3 = rz * ry * rx; - Matrix4f::from_mat3(rot3) - } -} diff --git a/engine/pill_engine/src/ecs/egui_client.rs b/engine/pill_engine/src/ecs/egui_client.rs new file mode 100644 index 00000000..eea24084 --- /dev/null +++ b/engine/pill_engine/src/ecs/egui_client.rs @@ -0,0 +1,40 @@ +#![cfg(feature = "ui")] + +use std::sync::{Arc, Mutex}; + +type UiFunction = Box; + +pub struct EguiClient { + events: Mutex>, + ui: Mutex>, +} + +impl EguiClient { + /// Creates a new shared egui client with empty event and UI queues. + pub fn new() -> Arc { + Arc::new(Self { + events: Mutex::new(Vec::new()), + ui: Mutex::new(None), + }) + } + + /// Enqueues a window event for delivery to the egui input handler on the next frame. + pub fn handle_input(&self, event: winit::event::WindowEvent) { + self.events.lock().unwrap().push(event); + } + + /// Drains and returns all queued window events, leaving the queue empty. + pub fn take_events(&self) -> Vec { + std::mem::take(&mut self.events.lock().unwrap()) + } + + /// Replaces the current UI function with `ui_fn`; called once per frame before rendering. + pub fn set_ui(&self, ui_fn: impl Fn(&egui::Context) + Send + 'static) { + *self.ui.lock().unwrap() = Some(Box::new(ui_fn)); + } + + /// Takes ownership of the pending UI function, leaving `None` in its place. + pub fn take_ui(&self) -> Option { + self.ui.lock().unwrap().take() + } +} diff --git a/engine/pill_engine/src/ecs/mod.rs b/engine/pill_engine/src/ecs/mod.rs index 248c7e8e..1def33f8 100644 --- a/engine/pill_engine/src/ecs/mod.rs +++ b/engine/pill_engine/src/ecs/mod.rs @@ -1,6 +1,7 @@ #![cfg_attr(debug_assertions, allow(dead_code, unused_variables))] mod components; +mod egui_client; mod entity; mod scene; mod scene_manager; @@ -11,8 +12,8 @@ mod systems; // - Components pub use components::{ - Component, ComponentDestroyer, ComponentStorage, ConcreteComponentDestroyer, GlobalComponent, - GlobalComponentStorage, + get_global_component_from, Component, ComponentDestroyer, ComponentStorage, + ConcreteComponentDestroyer, GlobalComponent, GlobalComponentStorage, }; pub use components::camera_component::{ @@ -28,8 +29,13 @@ pub use components::audio_listener_component::AudioListenerComponent; #[cfg(not(target_arch = "wasm32"))] pub use components::audio_source_component::AudioSourceComponent; -#[cfg(feature = "debug_ui")] -pub use components::egui_manager_component::EguiManagerComponent; +#[cfg(feature = "ui")] +pub use components::egui_component::EguiComponent; + +pub use components::render_state_component::RenderStateComponent; + +#[cfg(feature = "ui")] +pub use egui_client::EguiClient; pub use components::deferred_update_component::{ DeferredUpdateComponent, DeferredUpdateComponentRequest, DeferredUpdateManagerPointer, @@ -45,7 +51,8 @@ pub use components::transform_component::{ get_model_matrix, get_normal_matrix, update_transform_matrices, TransformComponent, }; -pub use components::mesh_rendering_component::MeshRenderingComponent; +pub use components::mesh_component::MeshComponent; +pub use components::pbr_renderable_component::PbrRenderableComponent; pub use components::time_component::TimeComponent; @@ -63,6 +70,9 @@ pub use systems::{SystemFunction, SystemManager, UpdatePhase}; pub use systems::rendering_system::rendering_system; +#[cfg(feature = "ui")] +pub use systems::egui_system::egui_system; + pub use systems::deferred_update_system::deferred_update_system; pub use systems::input_system::input_system; diff --git a/engine/pill_engine/src/ecs/systems/audio_system.rs b/engine/pill_engine/src/ecs/systems/audio_system.rs index 940db605..0f4f629a 100644 --- a/engine/pill_engine/src/ecs/systems/audio_system.rs +++ b/engine/pill_engine/src/ecs/systems/audio_system.rs @@ -11,42 +11,51 @@ use pill_core::Result; fn get_rotation_matrix(angles: Vector3f) -> Result { // Get the angles from the vector and convert them to radians - let alfa = angles.x.to_radians(); - let beta = angles.y.to_radians(); - let gamma = angles.z.to_radians(); + let angle_x = angles.x.to_radians(); + let angle_y = angles.y.to_radians(); + let angle_z = angles.z.to_radians(); // Prepare rotation matrices - let rot_z = Matrix3f::from_rotation_z(alfa); - let rot_y = Matrix3f::from_rotation_y(beta); - let rot_x = Matrix3f::from_rotation_x(gamma); + let rot_z = Matrix3f::from_rotation_z(angle_x); + let rot_y = Matrix3f::from_rotation_y(angle_y); + let rot_x = Matrix3f::from_rotation_x(angle_z); // Return rotation matrix Ok(rot_z * rot_y * rot_x) } pub fn audio_system(engine: &mut Engine) -> Result<()> { + // Skip if no audio manager (headless or no audio device). + if engine + .get_global_component::() + .is_err() + { + return Ok(()); + } + // --- Update ear positions let mut left_ear_position = Vector3f::new(-1.0, 0.0, 0.0); let mut right_ear_position = Vector3f::new(1.0, 0.0, 0.0); // Update ear positions - for (_entity_handle, audio_listener_component, transform_component) in - engine.iterate_two_components::()? + if let Ok(iter) = engine.iterate_two_components::() { - if audio_listener_component.enabled { - // Get the retotation matrix - let left_rotation_matrix = get_rotation_matrix(transform_component.rotation)?; - let right_rotation_matrix = get_rotation_matrix(-transform_component.rotation)?; + for (_entity_handle, audio_listener_component, transform_component) in iter { + if audio_listener_component.enabled { + // Get the retotation matrix + let left_rotation_matrix = get_rotation_matrix(transform_component.rotation)?; + let right_rotation_matrix = get_rotation_matrix(-transform_component.rotation)?; - // Get two points for left and right ear relative to the origin multiplied to rotation matrix - left_ear_position = left_rotation_matrix * left_ear_position; - right_ear_position = right_rotation_matrix * right_ear_position; + // Get two points for left and right ear relative to the origin multiplied to rotation matrix + left_ear_position = left_rotation_matrix * left_ear_position; + right_ear_position = right_rotation_matrix * right_ear_position; - // Add the original position - left_ear_position += transform_component.position; - right_ear_position += transform_component.position; + // Add the original position + left_ear_position += transform_component.position; + right_ear_position += transform_component.position; - break; + break; + } } } @@ -58,15 +67,18 @@ pub fn audio_system(engine: &mut Engine) -> Result<()> { } // Update emitter position in all sinks based on transform components of entities to which audio source components are added - let active_scene = engine.scene_manager.get_active_scene()?; - for (_entity_handle, audio_source_component, transform_component) in - active_scene.get_two_component_iterator::()? - { - let audio_manager = engine.get_global_component::()?; - if let Some(index) = audio_source_component.sink_handle { - audio_manager - .get_spatial_sink(index) - .set_emitter_position(transform_component.position.into()); + if let Ok(active_scene) = engine.scene_manager.get_active_scene() { + if let Ok(iter) = + active_scene.get_two_component_iterator::() + { + for (_entity_handle, audio_source_component, transform_component) in iter { + let audio_manager = engine.get_global_component::()?; + if let Some(index) = audio_source_component.sink_handle { + audio_manager + .get_spatial_sink(index) + .set_emitter_position(transform_component.position.into()); + } + } } } @@ -80,29 +92,29 @@ pub fn audio_system(engine: &mut Engine) -> Result<()> { .data .as_mut() .unwrap(); - let active_scene = engine.scene_manager.get_active_scene_mut()?; - for (_entity_handle, audio_source_component) in - active_scene.get_one_component_iterator_mut::()? - { - // Check if the audio source has sink handle assigned - if let Some(sink_handle) = audio_source_component.sink_handle { - // Check if is playing - let sound_type = audio_source_component.sound_type; - let playing = match sound_type { - SoundType::Sound2D => { - let sink = audio_manager.get_ambient_sink(sink_handle); - !sink.is_paused() - } - SoundType::Sound3D => { - let sink = audio_manager.get_spatial_sink(sink_handle); - !sink.is_paused() - } - }; + if let Ok(active_scene) = engine.scene_manager.get_active_scene_mut() { + if let Ok(iter) = active_scene.get_one_component_iterator_mut::() { + for (_entity_handle, audio_source_component) in iter { + // Check if the audio source has sink handle assigned + if let Some(sink_handle) = audio_source_component.sink_handle { + // Check if is playing + let playing = match audio_source_component.sound_type { + SoundType::Sound2D => { + let sink = audio_manager.get_ambient_sink(sink_handle); + !sink.is_paused() + } + SoundType::Sound3D => { + let sink = audio_manager.get_spatial_sink(sink_handle); + !sink.is_paused() + } + }; - // Return sink to pool if stopped playing - if !playing { - let sink_handle = audio_source_component.return_sink().unwrap(); - audio_manager.return_sink(sink_handle, &sound_type); + // Return sink to pool if stopped playing + if !playing { + let sink_handle = audio_source_component.return_sink().unwrap(); + audio_manager.return_sink(sink_handle, &audio_source_component.sound_type); + } + } } } } diff --git a/engine/pill_engine/src/ecs/systems/egui_system.rs b/engine/pill_engine/src/ecs/systems/egui_system.rs new file mode 100644 index 00000000..094cee3c --- /dev/null +++ b/engine/pill_engine/src/ecs/systems/egui_system.rs @@ -0,0 +1,14 @@ +#![cfg(feature = "ui")] + +use crate::{ecs::EguiComponent, engine::Engine}; +use pill_core::Result; + +pub fn egui_system(engine: &mut Engine) -> Result<()> { + let ui = EguiComponent::get_ui(engine); + let client = engine + .get_global_component::()? + .egui_client + .clone(); + client.set_ui(move |ctx| ui(ctx)); + Ok(()) +} diff --git a/engine/pill_engine/src/ecs/systems/mod.rs b/engine/pill_engine/src/ecs/systems/mod.rs index 6dee9b19..c9c9213b 100644 --- a/engine/pill_engine/src/ecs/systems/mod.rs +++ b/engine/pill_engine/src/ecs/systems/mod.rs @@ -3,6 +3,8 @@ #[cfg(not(target_arch = "wasm32"))] pub(crate) mod audio_system; pub(crate) mod deferred_update_system; +#[cfg(feature = "ui")] +pub(crate) mod egui_system; pub(crate) mod input_system; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod networking_system; diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index f55a9891..e5dc7d1a 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -1,37 +1,66 @@ use crate::{ config::RENDERING_SYSTEM, - ecs::{ - CameraAspectRatio, CameraComponent, EntityHandle, MeshRenderingComponent, - TransformComponent, - }, + ecs::{CameraAspectRatio, CameraComponent, EntityHandle, RenderStateComponent}, engine::Engine, - graphics::RenderQueueItem, + graphics::{PassPBROpaque, PassTonemap, RendererTargetDesc}, }; -use pill_core::{warn, EngineError, LogContext, PillSlotMapKey, PillStyle, RendererError, Timer}; +use pill_core::{EngineError, RendererError, Timer}; -use pill_core::{ErrorContext, Result}; -use web_time::Instant; +use pill_core::Result; pub fn rendering_system(engine: &mut Engine) -> Result<()> { let mut timer = Timer::new(); timer.begin_context("rendering_system update"); + + // First-frame bootstrap: install default pass chain + let boot_done = engine + .get_global_component::()? + .boot_done; + + if !boot_done { + // Bootstrap wires only GPU resources (render targets). Per-frame state (bg/IBL/fog) is read + // from RenderStateComponent each frame inside the passes via WorldQuery::get_global. + let (w, h) = engine.renderer.get_surface_size(); + let hdr = engine.renderer.create_render_target(RendererTargetDesc { + name: "hdr_target".to_string(), + format: wgpu::TextureFormat::Rgba16Float, + width: w, + height: h, + })?; + #[cfg_attr(not(feature = "ui"), allow(unused_mut))] + let mut passes: Vec> = vec![ + Box::new(PassPBROpaque::new(Some(hdr))), + Box::new(PassTonemap::new(hdr)), + ]; + #[cfg(feature = "ui")] + { + use crate::{ecs::EguiComponent, graphics::PassEgui}; + let client = engine + .get_global_component::()? + .egui_client + .clone(); + let window = engine.renderer.get_window(); + passes.push(Box::new(PassEgui::new(window, client))); + } + engine.renderer.set_passes(passes)?; + engine + .get_global_component_mut::()? + .boot_done = true; + return Ok(()); + } + timer.record("Get active camera"); - let active_scene_handle = engine.scene_manager.get_active_scene_handle()?; let mut active_camera_entity_handle_result: Option = None; { let active_scene = engine.scene_manager.get_active_scene_mut()?; - // - Find active camera and update its aspect ratio if needed - - // Find first enabled camera and use it as active for (entity_handle, camera_component) in active_scene.get_one_component_iterator_mut::()? { if camera_component.enabled { - // Update active camera aspect ratio if it is set to automatic if let CameraAspectRatio::Automatic(_) = camera_component.aspect { let aspect_ratio = engine.window_size.width as f32 / engine.window_size.height as f32; @@ -43,109 +72,32 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { } } - let active_camera_entity_handle = active_camera_entity_handle_result - .ok_or_else(|| -> pill_core::PillError { EngineError::NoActiveCamera.into() })?; - - // - Prepare rendering data - timer.record("Clear render queue"); - - // Clear the render queue - engine.render_queue.clear(); - engine.render_queue.reserve(200000); // Reserve space for 1000 items - - timer.record("Prepare render queue"); - - let mut _matrix_calculation_duration: f32 = 0.0; - let mut add_to_render_queue_duration: f32 = 0.0; - - // Iterate mesh rendering components - for (entity_handle, _transform_component, mesh_rendering_component) in engine - .scene_manager - .get_two_component_iterator_mut::( - active_scene_handle, - )? { - // Update transform matrices if required - - // Add valid mesh rendering components to render queue - let add_to_render_queue_start_time = Instant::now(); - if let Some(render_queue_key) = mesh_rendering_component.render_queue_key { - let render_queue_item = RenderQueueItem { - key: render_queue_key, - entity_index: entity_handle.data().index, - }; - engine.render_queue.push(render_queue_item); - } else { - warn!(LogContext::Rendering => "Invalid render queue key"); - continue; - } - add_to_render_queue_duration += - add_to_render_queue_start_time.elapsed().as_secs_f32() * 1000.0; - } - - timer.record(format!( - "Matrix calculation {} ms", - _matrix_calculation_duration - )); - timer.record(format!( - "Add to render queue {} ms", - add_to_render_queue_duration - )); - - timer.record("Sort render queue"); - - // Sort render queue - engine.render_queue.sort(); - - timer.record("Get component storages"); - - #[cfg(feature = "debug_ui")] - let egui_ui = crate::ecs::EguiManagerComponent::get_ui(engine); - - let active_scene = engine.scene_manager.get_active_scene_mut()?; - // Get storages - let camera_component_storage = active_scene - .get_component_storage::() - .context(format!( - "{}: Cannot get active {}", - "rendering_system".specific_object_style(), - "Camera".general_object_style() - ))?; - let transform_component_storage = active_scene - .get_component_storage::() - .context(format!( - "{}: Cannot get {}", - "rendering_system".specific_object_style(), - "TransformComponents".specific_object_style() - )) - .unwrap(); + let active_camera_entity_handle = + active_camera_entity_handle_result.ok_or_else(|| -> pill_core::PillError { + pill_core::PillError::from(EngineError::NoActiveCamera) + })?; timer.begin_context("Render"); - // Render + // Stateless: the engine hands the active scene + globals to the renderer; each pass queries the + // per-entity components (WorldQuery::query::()) and globals (WorldQuery::get_global::()) + // it needs, and builds its own draw list. + let active_scene = engine.scene_manager.get_active_scene()?; + let globals = &engine.global_components; let delta_time = engine.frame_delta_time; - #[cfg(feature = "debug_ui")] - let render_result = engine.renderer.render( - active_camera_entity_handle, - &engine.render_queue, - camera_component_storage, - transform_component_storage, - egui_ui, - delta_time, - &mut timer, - ); - #[cfg(not(feature = "debug_ui"))] let render_result = engine.renderer.render( active_camera_entity_handle, - &engine.render_queue, - camera_component_storage, - transform_component_storage, + active_scene, + globals, delta_time, &mut timer, + &engine.resource_manager, ); match render_result { Ok(_) => { - timer.end_context()?; // End "Render" context + timer.end_context()?; // end "Render" + timer.end_context()?; // end "rendering_system update" engine.system_manager.update_system_timer( RENDERING_SYSTEM.name, RENDERING_SYSTEM.update_phase, @@ -153,24 +105,22 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { )?; Ok(()) } - Err(e) => { - match e.downcast_ref::() { - Some(RendererError::SurfaceLost) => { - // Recreate lost surface - timer.end_context()?; // End "Render" context - engine.system_manager.update_system_timer( - RENDERING_SYSTEM.name, - RENDERING_SYSTEM.update_phase, - timer, - )?; - engine.renderer.resize(engine.window_size); - Ok(()) - } - Some(RendererError::SurfaceOutOfMemory) => { - panic!("Critical: Renderer error, system out of memory"); - } - _ => Err(e), + Err(error) => match error.downcast_ref::() { + Some(RendererError::SurfaceLost) => { + timer.end_context()?; // end "Render" + timer.end_context()?; // end "rendering_system update" + engine.system_manager.update_system_timer( + RENDERING_SYSTEM.name, + RENDERING_SYSTEM.update_phase, + timer, + )?; + engine.renderer.resize(engine.window_size); + Ok(()) } - } + Some(RendererError::SurfaceOutOfMemory) => { + panic!("Critical: Renderer error, system out of memory"); + } + _ => Err(error), + }, } } diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 427f3bef..bd042d9d 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -1,4 +1,11 @@ -use crate::{app_config::EngineConfig, config::*, ecs::*, graphics::*, resources::*}; +use crate::{ + app_config::EngineConfig, + config::*, + ecs::*, + graphics::*, + renderer::resources::{RendererMaterial, RendererMesh, RendererShader, RendererTexture}, + resources::*, +}; use pill_core::{ debug, error, get_game_error_message, get_type_name, info, EngineError, LogContext, @@ -32,7 +39,6 @@ pub struct Engine { pub(crate) resource_manager: ResourceManager, pub(crate) global_components: PillTypeMap, pub(crate) input_queue: VecDeque, - pub(crate) render_queue: Vec, pub(crate) window_size: winit::dpi::PhysicalSize, pub(crate) game_resources_directory_path: std::path::PathBuf, pub(crate) frame_delta_time: f32, // In milliseconds @@ -63,7 +69,6 @@ impl Engine { resource_manager: ResourceManager::new(), global_components: PillTypeMap::new(), input_queue: VecDeque::new(), - render_queue: Vec::::with_capacity(max_entity_count), window_size: winit::dpi::PhysicalSize::::default(), game_resources_directory_path, frame_delta_time: 0.0, @@ -86,7 +91,6 @@ impl Engine { resource_manager: ResourceManager::new(), global_components: PillTypeMap::new(), input_queue: VecDeque::new(), - render_queue: Vec::::with_capacity(max_entity_count), window_size: winit::dpi::PhysicalSize::::default(), game_resources_directory_path: std::path::PathBuf::new(), frame_delta_time: 0.0.into(), @@ -119,8 +123,52 @@ impl Engine { .get_int("MAX_SOUNDS") .unwrap_or(MAX_SOUNDS as i64) as usize; + #[cfg(not(feature = "headless"))] + { + self.register_resource_type::(max_shader_count)?; + self.register_resource_type::(max_material_count)?; + self.register_resource_type::(max_texture_count)?; + self.register_resource_type::(max_mesh_count)?; + + // Upload neutral 1×1 fallback textures for the env slots; game may override before first frame. + let bg_h = self.renderer.create_texture_from_pixels( + "pill_engine_default_equirect", + &[DEFAULT_EQUIRECT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba32Float, + ); + let diff_h = self.renderer.create_texture_from_pixels( + "pill_engine_default_ibl_diffuse", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let spec_h = self.renderer.create_texture_from_pixels( + "pill_engine_default_ibl_specular", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let lut_h = self.renderer.create_texture_from_pixels( + "pill_engine_default_brdf_lut", + &[DEFAULT_BRDF_LUT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let rs = self.get_global_component_mut::()?; + rs.background = bg_h; + rs.ibl_diffuse = diff_h; + rs.ibl_specular = spec_h; + rs.ibl_brdf_lut = lut_h; + } + self.register_resource_type::(max_shader_count)?; self.register_resource_type::(max_material_count)?; + self.register_resource_type::(max_material_count)?; self.register_resource_type::(max_texture_count)?; self.register_resource_type::(max_mesh_count)?; #[cfg(not(target_arch = "wasm32"))] @@ -149,6 +197,10 @@ impl Engine { DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME.to_string(), ShaderParameterSlot::new(ShaderParameterType::Scalar), ), + ( + DEFAULT_LIT_SHADER_METALLIC_FACTOR_PARAMETER_SLOT_NAME.to_string(), + ShaderParameterSlot::new(ShaderParameterType::Scalar), + ), ] .into_iter() .collect(), @@ -167,6 +219,20 @@ impl Engine { DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_BINDINGS, ), ), + ( + DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME.to_string(), + ShaderTextureSlot::new( + TextureType::MetallicRoughness, + DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_BINDINGS, + ), + ), + ( + DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_NAME.to_string(), + ShaderTextureSlot::new( + TextureType::Emissive, + DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_BINDINGS, + ), + ), ] .into_iter() .collect(), @@ -221,6 +287,24 @@ impl Engine { ))?; debug!(LogContext::Engine => "Default normal texture {} created", DEFAULT_NORMAL_TEXTURE_NAME.name_style()); + debug!(LogContext::Engine => "Creating default metallic_roughness texture {}...", DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME.name_style()); + + let default_mr_texture_handle = self.add_default_resource(Texture::new( + DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME, + TextureType::MetallicRoughness, + ResourceLoader::Bytes(Box::new(DEFAULT_METALLIC_ROUGHNESS_TEXTURE_BYTES)), + ))?; + + debug!(LogContext::Engine => "Default metallic_roughness texture {} created", DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME.name_style()); + debug!(LogContext::Engine => "Creating default emissive texture {}...", DEFAULT_EMISSIVE_TEXTURE_NAME.name_style()); + + let default_emissive_texture_handle = self.add_default_resource(Texture::new( + DEFAULT_EMISSIVE_TEXTURE_NAME, + TextureType::Emissive, + ResourceLoader::Bytes(Box::new(DEFAULT_EMISSIVE_TEXTURE_BYTES)), + ))?; + + debug!(LogContext::Engine => "Default emissive texture {} created", DEFAULT_EMISSIVE_TEXTURE_NAME.name_style()); debug!(LogContext::Engine => "Creating default material {}...", DEFAULT_LIT_MATERIAL_NAME.name_style()); // Create default lit material @@ -232,6 +316,7 @@ impl Engine { Color::new(1.0, 1.0, 1.0), )? .scalar_parameter(DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME, 0.5)? + .scalar_parameter(DEFAULT_LIT_SHADER_METALLIC_FACTOR_PARAMETER_SLOT_NAME, 0.0)? .texture( DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME, default_color_texture_handle, @@ -240,6 +325,14 @@ impl Engine { DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME, default_normal_texture_handle, )? + .texture( + DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME, + default_mr_texture_handle, + )? + .texture( + DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_NAME, + default_emissive_texture_handle, + )? .build(), )?; @@ -302,12 +395,13 @@ impl Engine { // Register global components self.add_global_component(TimeComponent::new())?; self.add_global_component(DeferredUpdateComponent::new())?; - #[cfg(feature = "debug_ui")] - self.add_global_component(EguiManagerComponent::new())?; + #[cfg(feature = "ui")] + self.add_global_component(EguiComponent::new())?; #[cfg(not(feature = "headless"))] { self.add_global_component(InputComponent::new())?; + self.add_global_component(RenderStateComponent::new())?; } #[cfg(all(not(feature = "headless"), not(target_arch = "wasm32")))] @@ -351,6 +445,12 @@ impl Engine { AUDIO_SYSTEM.system_function, AUDIO_SYSTEM.update_phase, )?; + #[cfg(feature = "ui")] + self.system_manager.add_system( + EGUI_SYSTEM.name, + EGUI_SYSTEM.system_function, + EGUI_SYSTEM.update_phase, + )?; self.system_manager.add_system( RENDERING_SYSTEM.name, RENDERING_SYSTEM.system_function, @@ -434,8 +534,8 @@ impl Engine { EngineError::NonReturnedSystemTimer(system_name.clone()) ); } - Err(e) => { - panic!("{}", EngineError::Other(e.to_string())); + Err(error) => { + panic!("{}", EngineError::Other(error.to_string())); } }; @@ -525,9 +625,11 @@ impl Engine { } pub fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) { - #[cfg(feature = "debug_ui")] - self.renderer.pass_input_to_egui(event).unwrap(); - #[cfg(not(feature = "debug_ui"))] + #[cfg(feature = "ui")] + if let Ok(mgr) = self.get_global_component::() { + mgr.egui_client.handle_input(event.clone()); + } + #[cfg(not(feature = "ui"))] let _ = event; } @@ -538,8 +640,148 @@ impl Engine { // --- API ------------------------------------------------------------------ +/// Parse an RTEX binary header. +/// Returns `(pixel_bytes, width, height, mip_count, version)`. +fn decode_rtex_header(bytes: &[u8]) -> (&[u8], u32, u32, u32, u32) { + let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); + let w = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); + let h = u32::from_le_bytes(bytes[12..16].try_into().unwrap()); + if version == 3 || version == 4 { + let mip_count = u32::from_le_bytes(bytes[16..20].try_into().unwrap()); + (&bytes[20..], w, h, mip_count, version) + } else { + (&bytes[16..], w, h, 1, version) + } +} + /// Pill Engine game API impl Engine { + // --- Renderer API --- + + /// Provides equirect background texture bytes; used by PassBackground on first-frame init. + /// Must be called before the first rendered frame (i.e. from `start()`). + pub fn set_background_texture(&mut self, bytes: Vec) -> Result<()> { + let (raw, w, h, _mips, version) = decode_rtex_header(&bytes); + let format = if version == 2 { + wgpu::TextureFormat::Rgba32Float + } else { + wgpu::TextureFormat::Rgba8UnormSrgb + }; + let handle = + self.renderer + .create_texture_from_pixels("studio_equirect", &[raw], w, h, format); + self.get_global_component_mut::()? + .background = handle; + Ok(()) + } + + /// Provides IBL map bytes (diffuse irradiance, specular prefilter, BRDF LUT); + /// used by PassPBROpaque on first-frame init. + /// Must be called before the first rendered frame (i.e. from `start()`). + pub fn set_ibl_textures( + &mut self, + diffuse: Vec, + specular: Vec, + brdf_lut: Vec, + ) -> Result<()> { + let (diff_raw, dw, dh, _, _) = decode_rtex_header(&diffuse); + let diff_h = self.renderer.create_texture_from_pixels( + "diffuse_ibl", + &[diff_raw], + dw, + dh, + wgpu::TextureFormat::Rgba8UnormSrgb, + ); + // specular: RTEX v4 — Rgba32Float mip chain + let (spec_raw, sw, sh, spec_mip_count, _) = decode_rtex_header(&specular); + const SPEC_BYTES_PER_TEXEL: usize = 16; // Rgba32Float = 4 channels × 4 bytes + let mut spec_slices: Vec<&[u8]> = Vec::with_capacity(spec_mip_count as usize); + let mut offset = 0usize; + for mip in 0..spec_mip_count { + let mip_bytes = + (sw >> mip).max(1) as usize * (sh >> mip).max(1) as usize * SPEC_BYTES_PER_TEXEL; + spec_slices.push(&spec_raw[offset..offset + mip_bytes]); + offset += mip_bytes; + } + let spec_h = self.renderer.create_texture_from_pixels( + "specular_ibl", + &spec_slices, + sw, + sh, + wgpu::TextureFormat::Rgba32Float, + ); + + let (lut_raw, lw, lh, _, _) = decode_rtex_header(&brdf_lut); + let lut_h = self.renderer.create_texture_from_pixels( + "brdf_lut", + &[lut_raw], + lw, + lh, + wgpu::TextureFormat::Rgba8Unorm, + ); + let rs = self.get_global_component_mut::()?; + rs.ibl_diffuse = diff_h; + rs.ibl_specular = spec_h; + rs.ibl_brdf_lut = lut_h; + Ok(()) + } + + /// Creates a single-mip Rgba32Float GPU texture from f32 pixels. + /// Returns a handle for assignment to `RenderStateComponent`. + /// Must be called before the first rendered frame (i.e. from `start()`). + pub fn create_gpu_texture_f32( + &mut self, + name: &str, + pixels: &[f32], + width: u32, + height: u32, + ) -> Result { + let bytes: &[u8] = bytemuck::cast_slice(pixels); + Ok(self.renderer.create_texture_from_pixels( + name, + &[bytes], + width, + height, + wgpu::TextureFormat::Rgba32Float, + )) + } + + /// Creates a mipped Rgba32Float GPU texture from f32 pixels; `mips[0]` is the base level. + /// Returns a handle for assignment to `RenderStateComponent`. + /// Must be called before the first rendered frame (i.e. from `start()`). + pub fn create_gpu_mipped_texture_f32( + &mut self, + name: &str, + mips: &[Vec], + base_width: u32, + base_height: u32, + ) -> Result { + let slices: Vec<&[u8]> = mips + .iter() + .map(|m| bytemuck::cast_slice(m.as_slice())) + .collect(); + Ok(self.renderer.create_texture_from_pixels( + name, + &slices, + base_width, + base_height, + wgpu::TextureFormat::Rgba32Float, + )) + } + + // --- UI API --- + + /// Sets a game-defined egui overlay; called every frame before the engine debug window. + #[cfg(feature = "ui")] + pub fn set_game_overlay( + &mut self, + f: impl Fn(&egui::Context) + Send + Sync + 'static, + ) -> Result<()> { + self.get_global_component_mut::()? + .set_overlay(f); + Ok(()) + } + // --- System API --- /// Adds game-defined system to the game update phase @@ -826,18 +1068,7 @@ impl Engine { where T: GlobalComponent>, { - // Get component - let component = self - .global_components - .get::() - .ok_or_else(|| -> pill_core::PillError { - EngineError::GlobalComponentNotFound(get_type_name::()).into() - })? - .data - .as_ref() - .unwrap(); - - Ok(component) + crate::ecs::get_global_component_from::(&self.global_components) } /// Returns global mutable component diff --git a/engine/pill_engine/src/graphics/dummy_renderer.rs b/engine/pill_engine/src/graphics/dummy_renderer.rs index 2298b531..99c85dae 100644 --- a/engine/pill_engine/src/graphics/dummy_renderer.rs +++ b/engine/pill_engine/src/graphics/dummy_renderer.rs @@ -1,18 +1,18 @@ use crate::{ app_config::EngineConfig, - ecs::{CameraComponent, ComponentStorage, EntityHandle, TransformComponent}, + ecs::{EntityHandle, Scene}, graphics::{ - PillRenderer, RenderQueueItem, RendererCameraHandle, RendererMaterialHandle, - RendererMeshHandle, RendererShaderHandle, RendererTextureHandle, + BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererCameraHandle, + RendererTargetDesc, RendererTextureHandle, WorldQuery, }, - internal::{MaterialParameter, MaterialTexture}, - resources::{MeshData, ShaderParameterSlot, ShaderTextureSlot, TextureType}, + resources::{ResourceManager, ShaderParameterSlot, ShaderTextureSlot}, }; use pill_core::Result; use pill_core::Timer; -use std::{collections::HashMap, sync::Arc}; -use winit::{dpi::PhysicalSize, event::WindowEvent, window::Window}; +use std::collections::HashMap; +use std::sync::Arc; +use winit::{dpi::PhysicalSize, window::Window}; pub struct DummyRenderer; @@ -23,7 +23,7 @@ impl PillRenderer for DummyRenderer { // --- Create --- - fn create_shader( + fn create_shader_struct( &mut self, _name: &str, _vertex_wgsl: &str, @@ -32,117 +32,102 @@ impl PillRenderer for DummyRenderer { _parameter_slots: &[(String, ShaderParameterSlot)], _pass_engine_parameters: bool, _pass_camera_parameters: bool, - ) -> Result { - Ok(RendererShaderHandle::default()) + ) -> Result { + unimplemented!("DummyRenderer has no GPU shader creation") } - fn create_material( - &mut self, - _name: &str, - _renderer_shader_handle: RendererShaderHandle, - _textures: &[(String, MaterialTexture)], - _parameters: &HashMap, - ) -> Result { - Ok(RendererMaterialHandle::default()) + fn create_camera(&mut self) -> Result { + Ok(RendererCameraHandle::default()) } - fn create_texture( - &mut self, - _name: &str, - _rgba: &[u8], - _width: u32, - _height: u32, - _texture_type: TextureType, - ) -> Result { - Ok(RendererTextureHandle::default()) - } + // --- Destroy --- - fn create_mesh(&mut self, _name: &str, _mesh_data: &MeshData) -> Result { - Ok(RendererMeshHandle::default()) + fn destroy_camera(&mut self, _renderer_camera_handle: RendererCameraHandle) -> Result<()> { + Ok(()) } - fn create_camera(&mut self) -> Result { - Ok(RendererCameraHandle::default()) - } + // --- Other --- - // --- Update --- + fn resize(&mut self, _new_window_size: PhysicalSize) {} - fn update_material_textures( - &mut self, - _renderer_material_handle: RendererMaterialHandle, - _textures: &[(String, MaterialTexture)], - ) -> Result<()> { - Ok(()) + fn get_window(&self) -> std::sync::Arc { + unimplemented!("DummyRenderer has no window") } - fn update_material_parameters( + fn render( &mut self, - _renderer_material_handle: RendererMaterialHandle, - _parameters: &HashMap, + _active_camera_entity_handle: EntityHandle, + _scene: &Scene, + _globals: &pill_core::PillTypeMap, + _delta_time: f32, + _timer: &mut Timer, + _resource_manager: &ResourceManager, ) -> Result<()> { Ok(()) } - // --- Destroy --- + // --- Pass API --- - fn destroy_shader(&mut self, _renderer_shader_handle: RendererShaderHandle) -> Result<()> { + fn set_passes(&mut self, _passes: Vec>) -> Result<()> { Ok(()) } - fn destroy_material( - &mut self, - _renderer_material_handle: RendererMaterialHandle, - ) -> Result<()> { - Ok(()) + fn get_surface_size(&self) -> (u32, u32) { + (0, 0) } - fn destroy_texture(&mut self, _renderer_texture_handle: RendererTextureHandle) -> Result<()> { - Ok(()) + fn get_device(&self) -> &wgpu::Device { + unimplemented!("DummyRenderer has no wgpu Device") } - fn destroy_mesh(&mut self, _renderer_mesh_handle: RendererMeshHandle) -> Result<()> { - Ok(()) + fn get_queue(&self) -> &wgpu::Queue { + unimplemented!("DummyRenderer has no wgpu Queue") } - fn destroy_camera(&mut self, _renderer_camera_handle: RendererCameraHandle) -> Result<()> { - Ok(()) + fn get_surface_format(&self) -> wgpu::TextureFormat { + wgpu::TextureFormat::Rgba8UnormSrgb } - // --- Other --- + fn get_engine_parameters(&self) -> &crate::renderer::resources::EngineParameters { + unimplemented!("DummyRenderer has no EngineParameters") + } - fn resize(&mut self, _new_window_size: PhysicalSize) { - // no-op for dummy + fn get_camera_bind_group_layout(&self) -> wgpu::BindGroupLayout { + unimplemented!("DummyRenderer has no wgpu Device") } - #[cfg(feature = "debug_ui")] - fn pass_input_to_egui(&mut self, _event: &WindowEvent) -> Result<()> { - Ok(()) + fn create_buffer(&mut self, _desc: BufferDesc) -> Result { + unimplemented!("DummyRenderer has no wgpu Device") } - #[cfg(feature = "debug_ui")] - fn render( - &mut self, - _active_camera_entity_handle: EntityHandle, - _render_queue: &[RenderQueueItem], - _camera_component_storage: &ComponentStorage, - _transform_component_storage: &ComponentStorage, - _egui_ui: Box, - _delta_time: f32, - _timer: &mut Timer, - ) -> Result<()> { - Ok(()) + fn create_pipeline_v2(&mut self, _desc: PipelineV2Desc) -> Result { + unimplemented!("DummyRenderer has no wgpu Device") } - #[cfg(not(feature = "debug_ui"))] - fn render( + fn create_render_target(&mut self, _desc: RendererTargetDesc) -> Result { + Ok(RendererTextureHandle::default()) + } + + fn create_depth_texture(&mut self, _label: &str) -> Result { + Ok(RendererTextureHandle::default()) + } + + fn get_render_target_view(&self, _handle: RendererTextureHandle) -> Option<&wgpu::TextureView> { + None + } + + fn create_texture_from_pixels( &mut self, - _active_camera_entity_handle: EntityHandle, - _render_queue: &[RenderQueueItem], - _camera_component_storage: &ComponentStorage, - _transform_component_storage: &ComponentStorage, - _delta_time: f32, - _timer: &mut Timer, - ) -> Result<()> { - Ok(()) + _name: &str, + _mip_pixels: &[&[u8]], + _base_width: u32, + _base_height: u32, + _format: wgpu::TextureFormat, + ) -> RendererTextureHandle { + RendererTextureHandle::default() + } + + fn get_texture_view(&self, _handle: RendererTextureHandle) -> Option { + None } } diff --git a/engine/pill_engine/src/graphics/egui.rs b/engine/pill_engine/src/graphics/egui.rs deleted file mode 100644 index 47bf9ec6..00000000 --- a/engine/pill_engine/src/graphics/egui.rs +++ /dev/null @@ -1,20 +0,0 @@ -#![cfg(feature = "debug_ui")] - -pub struct EguiUI<'a> { - frame_delta_time: &'a f32, // Use a reference to frame_delta_time -} - -impl<'a> EguiUI<'a> { - fn render(&self, ui: &egui::Context) { - egui::Window::new("PillEngine") - .default_open(true) - .resizable(true) - .anchor(egui::Align2::LEFT_TOP, [0.0, 0.0]) - .show(ui, |ui| { - if ui.add(egui::Button::new("Click me")).clicked() { - println!("PRESSED"); - println!("{}", self.frame_delta_time); - } - }); - } -} diff --git a/engine/pill_engine/src/graphics/mod.rs b/engine/pill_engine/src/graphics/mod.rs index 1e0ad585..be42a06e 100644 --- a/engine/pill_engine/src/graphics/mod.rs +++ b/engine/pill_engine/src/graphics/mod.rs @@ -2,24 +2,33 @@ #[cfg(feature = "headless")] mod dummy_renderer; -#[cfg(feature = "debug_ui")] -mod egui; +mod pass_background; +#[cfg(feature = "ui")] +mod pass_egui; +mod pass_mesh; +mod pass_pbr_opaque; +mod pass_tonemap; mod render_queue; mod renderer; // --- Use --- pub use renderer::{ - PillRenderer, Renderer, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, + BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, Renderer, RendererCameraHandle, + RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, RendererTargetDesc, + RendererTextureHandle, ShaderDesc, WorldQuery, }; #[cfg(feature = "headless")] pub use self::dummy_renderer::DummyRenderer; -#[cfg(feature = "debug_ui")] -pub use egui::EguiUI; +pub use pass_background::PassBackground; +#[cfg(feature = "ui")] +pub use pass_egui::PassEgui; +pub use pass_mesh::PassMesh; +pub use pass_pbr_opaque::PassPBROpaque; +pub use pass_tonemap::PassTonemap; pub use render_queue::{ - compose_render_queue_key, decompose_render_queue_key, RenderQueueItem, RenderQueueKey, - RenderQueueKeyFields, RENDER_QUEUE_KEY_ORDER, + compose_pbr_render_queue_key, compose_render_queue_key, decompose_render_queue_key, + RenderQueueItem, RenderQueueKey, RenderQueueKeyFields, RENDER_QUEUE_KEY_ORDER, }; diff --git a/engine/pill_engine/src/graphics/pass_background.rs b/engine/pill_engine/src/graphics/pass_background.rs new file mode 100644 index 00000000..67b2fa24 --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_background.rs @@ -0,0 +1,271 @@ +use crate::{ + ecs::{CameraComponent, ComponentStorage, TransformComponent}, + graphics::{ + Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererTextureHandle, ShaderDesc, + WorldQuery, + }, +}; +use glam::{Mat3, Vec3}; +use pill_core::{PillSlotMapKey, Result}; + +use crate::config::DEFAULT_EQUIRECT_FALLBACK_PIXEL; + +static VS: &str = include_str!("../../res/shaders/background_vertex.wgsl"); +static FS: &str = include_str!("../../res/shaders/background_fragment.wgsl"); + +pub struct PassBackground { + hdr_target: RendererTextureHandle, + equirect: Option, + bg_color: [f32; 3], + state: Option, +} + +struct BgState { + pipeline: PipelineV2, + bind_group: wgpu::BindGroup, + camera_buffer: wgpu::Buffer, + _sampler: wgpu::Sampler, +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct BgCameraUbo { + right: [f32; 3], + tan_half_fov: f32, + up: [f32; 3], + aspect: f32, + fwd: [f32; 3], + _pad: f32, + bg_color: [f32; 3], + _pad2: f32, +} + +impl PassBackground { + pub fn new(hdr_target: RendererTextureHandle) -> Self { + Self { + hdr_target, + equirect: None, + bg_color: [1.0, 1.0, 1.0], + state: None, + } + } + + pub fn with_equirect(mut self, handle: RendererTextureHandle) -> Self { + self.equirect = Some(handle); + self + } + + pub fn with_bg_color(mut self, color: [f32; 3]) -> Self { + self.bg_color = color; + self + } +} + +impl Pass for PassBackground { + fn get_label(&self) -> &str { + "pass_background" + } + + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + let bind_groups = vec![vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ]]; + + let pipeline = renderer.create_pipeline_v2(PipelineV2Desc { + label: Some("pass_background"), + vs: ShaderDesc { + source: VS, + entry_func: "vs_main", + }, + ps: ShaderDesc { + source: FS, + entry_func: "fs_main", + }, + vertex_buffers: &[], + bind_groups, + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + })?; + + // Create equirect view; register a 1×1 Rgba32Float black fallback when no handle is set. + let equirect_h = match self.equirect { + Some(h) => h, + None => renderer.create_texture_from_pixels( + "equirect_fallback", + &[DEFAULT_EQUIRECT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba32Float, + ), + }; + let view = renderer + .get_texture_view(equirect_h) + .expect("equirect handle invalid"); + + let sampler = renderer + .get_device() + .create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, // equirect wraps in U + address_mode_v: wgpu::AddressMode::ClampToEdge, // poles clamp in V + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + let camera_buffer = renderer + .get_device() + .create_buffer(&wgpu::BufferDescriptor { + label: Some("pass_background_camera"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group = { + let layout = &pipeline.bind_group_layouts[0]; + renderer + .get_device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_background_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }) + }; + + self.state = Some(BgState { + pipeline, + bind_group, + camera_buffer, + _sampler: sampler, + }); + Ok(()) + } + + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + _frame: &wgpu::SurfaceTexture, + _view: &wgpu::TextureView, + world: &WorldQuery<'_>, + ) -> Result<()> { + let state = self.state.as_ref().unwrap(); + + // Compute inv_view_proj from active camera — same matrices as PBR pass. + let camera_components = world.query::()?; + let transform_components = world.query::()?; + let active_idx = world.active_camera.data().index as usize; + let cam = camera_components + .data + .get(active_idx) + .unwrap() + .as_ref() + .unwrap(); + let tfm = transform_components + .data + .get(active_idx) + .unwrap() + .as_ref() + .unwrap(); + + let eye = Vec3::new(tfm.position.x, tfm.position.y, tfm.position.z); + let fwd = if let Some(t) = cam.look_at { + (Vec3::new(t.x, t.y, t.z) - eye).normalize() + } else { + let roll = Mat3::from_rotation_z(tfm.rotation.z.to_radians()); + let yaw = Mat3::from_rotation_y(tfm.rotation.y.to_radians()); + let pitch = Mat3::from_rotation_x(tfm.rotation.x.to_radians()); + (yaw * pitch * roll) * Vec3::Z + }; + let right = fwd.cross(Vec3::Y).normalize(); + let up = right.cross(fwd); + + let ubo = BgCameraUbo { + right: right.to_array(), + tan_half_fov: (cam.fov.to_radians() / 2.0).tan(), + up: up.to_array(), + aspect: cam.aspect.get_value(), + fwd: fwd.to_array(), + _pad: 0.0, + bg_color: self.bg_color, + _pad2: 0.0, + }; + renderer + .get_queue() + .write_buffer(&state.camera_buffer, 0, bytemuck::bytes_of(&ubo)); + + let hdr_view = renderer.get_render_target_view(self.hdr_target).unwrap(); + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pass_background_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: hdr_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rp.set_pipeline(&state.pipeline.pipeline); + rp.set_bind_group(0, &state.bind_group, &[]); + rp.draw(0..3, 0..1); + Ok(()) + } +} diff --git a/engine/pill_renderer/src/drawers/egui_drawer.rs b/engine/pill_engine/src/graphics/pass_egui.rs similarity index 55% rename from engine/pill_renderer/src/drawers/egui_drawer.rs rename to engine/pill_engine/src/graphics/pass_egui.rs index b45b5b3c..1841299d 100644 --- a/engine/pill_renderer/src/drawers/egui_drawer.rs +++ b/engine/pill_engine/src/graphics/pass_egui.rs @@ -1,20 +1,23 @@ -use pill_core::Result; -use pill_core::Timer; +use crate::{ + ecs::EguiClient, + graphics::{Pass, PillRenderer, WorldQuery}, +}; +use pill_core::{RendererError, Result, Timer}; use std::sync::Arc; use winit::event::WindowEvent; const BORDER_RADIUS: f32 = 2.0; -pub struct EguiDrawer { - pub context: egui::Context, +struct EguiDrawer { + context: egui::Context, state: egui_winit::State, renderer: egui_wgpu::Renderer, - pub window_scale_factor: f32, - pub window: Arc, + window_scale_factor: f32, + window: Arc, } impl EguiDrawer { - pub fn new( + fn new( device: &wgpu::Device, output_color_format: wgpu::TextureFormat, output_depth_format: Option, @@ -51,19 +54,18 @@ impl EguiDrawer { } } - pub fn handle_input(&mut self, event: &WindowEvent) { + fn handle_input(&mut self, event: &WindowEvent) { let _ = self.state.on_window_event(&self.window, event); } #[allow(clippy::too_many_arguments)] - pub fn record_draw_commands( + fn record_draw_commands( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, window_surface_view: &wgpu::TextureView, screen_descriptor: egui_wgpu::ScreenDescriptor, - // run_ui: impl FnOnce(&egui::Context), mut run_ui: Box, timer: &mut Timer, ) -> Result<()> { @@ -72,9 +74,6 @@ impl EguiDrawer { let window = &self.window; let raw_input = self.state.take_egui_input(window); - // let full_output = self.context.run(raw_input, |ctx| { - // (&mut run_ui)(ctx); - // }); let full_output = self.context.run(raw_input, |_| { run_ui(&self.context); }); @@ -99,7 +98,7 @@ impl EguiDrawer { self.renderer .update_buffers(device, queue, encoder, &tris, &screen_descriptor); - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: window_surface_view, resolve_target: None, @@ -107,7 +106,6 @@ impl EguiDrawer { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, - //depth_slice: None, })], depth_stencil_attachment: None, label: Some("egui main render pass"), @@ -117,13 +115,10 @@ impl EguiDrawer { timer.record("Render"); - let render_pass: &mut wgpu::RenderPass<'static> = - unsafe { std::mem::transmute(&mut render_pass) }; + let mut render_pass = render_pass.forget_lifetime(); self.renderer - .render(&mut *render_pass, &tris, &screen_descriptor); - - // let _ = drop(render_pass); + .render(&mut render_pass, &tris, &screen_descriptor); for texture_id in &full_output.textures_delta.free { self.renderer.free_texture(texture_id) @@ -132,3 +127,79 @@ impl EguiDrawer { Ok(()) } } + +pub struct PassEgui { + window: Arc, + client: Arc, + drawer: Option, +} + +impl PassEgui { + /// Creates the egui pass; `EguiDrawer` is initialised lazily in `Pass::init`. + pub fn new(window: Arc, client: Arc) -> Self { + Self { + window, + client, + drawer: None, + } + } +} + +impl Pass for PassEgui { + fn get_label(&self) -> &str { + "pass_egui" + } + + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + self.drawer = Some(EguiDrawer::new( + renderer.get_device(), + renderer.get_surface_format(), + None, + 1, + self.window.clone(), + )); + Ok(()) + } + + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + _frame: &wgpu::SurfaceTexture, + view: &wgpu::TextureView, + _world: &WorldQuery<'_>, + ) -> Result<()> { + let drawer = self + .drawer + .as_mut() + .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; + + for ev in self.client.take_events() { + drawer.handle_input(&ev); + } + + let size = self.window.inner_size(); + let screen_desc = egui_wgpu::ScreenDescriptor { + size_in_pixels: [size.width, size.height], + pixels_per_point: drawer.window_scale_factor, + }; + + let ui_fn = self.client.take_ui(); + let run_ui: Box = match ui_fn { + Some(ui_function) => { + Box::new(move |egui_context: &egui::Context| ui_function(egui_context)) + } + None => Box::new(|_egui_context: &egui::Context| {}), + }; + + drawer.record_draw_commands( + renderer.get_device(), + renderer.get_queue(), + encoder, + view, + screen_desc, + run_ui, + &mut Timer::new(), + ) + } +} diff --git a/engine/pill_engine/src/graphics/pass_mesh.rs b/engine/pill_engine/src/graphics/pass_mesh.rs new file mode 100644 index 00000000..408061bf --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_mesh.rs @@ -0,0 +1,400 @@ +use std::{num::NonZeroU32, ops::Range}; + +use crate::ecs::{CameraComponent, PbrRenderableComponent, TransformComponent}; +use crate::graphics::{RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle}; +use crate::internal::decompose_render_queue_key; +use crate::{ + graphics::{Pass, PillRenderer, RendererTextureHandle, WorldQuery}, + renderer::{ + config::{ + CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, + INITIAL_INSTANCE_VECTOR_CAPACITY, MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, + MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX, MAX_INSTANCE_PER_DRAWCALL_COUNT, + }, + instance::Instance, + resources::{ + EngineParameters, RendererCamera, RendererMaterial, RendererMesh, RendererShader, + }, + }, + resources::ResourceManager, +}; +use pill_core::{debug, LogContext, PillSlotMapKey, PillStyle, RendererError, Result}; + +// --- DrawingContext --- + +#[derive(Debug, Clone, Default)] +struct DrawingContext { + rendering_order: u8, + shader_handle: Option, + shader_name: String, + material_handle: Option, + material_name: String, + mesh_handle: Option, + mesh_name: String, + mesh_index_count: u32, + + accumulated_instance_range: Range, + + instance_batch_number: u32, + instance_batch_size: u32, +} + +impl DrawingContext { + fn log(&self) { + debug!( + LogContext::Frame => + "Draw {} instance(s) {}->{}/{} command recorded [Batch: {}, Rendering order: {}, Shader: {}, Material: {}, Mesh: {}]", + self.accumulated_instance_range.end - self.accumulated_instance_range.start, + self.accumulated_instance_range.start, + self.accumulated_instance_range.end - 1, + self.instance_batch_size, + self.instance_batch_number, + self.rendering_order, + self.shader_name.name_style(), + self.material_name.name_style(), + self.mesh_name.name_style() + ); + } + + fn record_draw_accumulated_instances(&mut self, render_pass: &mut wgpu::RenderPass) { + if self.accumulated_instance_range.end > self.accumulated_instance_range.start { + render_pass.draw_indexed( + 0..self.mesh_index_count, + 0, + self.accumulated_instance_range.clone(), + ); + self.log(); + self.accumulated_instance_range = + self.accumulated_instance_range.end..self.accumulated_instance_range.end; + } + } + + fn accumulate_instance(&mut self) { + self.accumulated_instance_range.end += 1; + } + + fn change_rendering_order(&mut self, new_order: u8) { + self.rendering_order = new_order; + debug!(LogContext::Frame => "Rendering order changed to: {}", self.rendering_order); + } + + fn change_shader( + &mut self, + resource_manager: &ResourceManager, + engine_parameters: &EngineParameters, + shader_handle: RendererShaderHandle, + render_pass: &mut wgpu::RenderPass, + camera: &RendererCamera, + ) { + self.shader_handle = Some(shader_handle); + let shader: &RendererShader = resource_manager + .get_resource::(&shader_handle) + .expect("RendererShader not found"); + self.shader_name = shader.name.clone(); + + debug!(LogContext::Frame => "Changing shader to: {}", self.shader_name.name_style()); + + render_pass.set_pipeline(&shader.render_pipeline); + + if shader.pass_engine_parameters { + render_pass.set_bind_group( + ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, + &engine_parameters.bind_group, + &[], + ); + } + + if shader.pass_camera_parameters { + render_pass.set_bind_group( + CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, + &camera.bind_group, + &[], + ); + } + } + + fn change_material( + &mut self, + resource_manager: &ResourceManager, + material_handle: RendererMaterialHandle, + render_pass: &mut wgpu::RenderPass, + ) { + self.material_handle = Some(material_handle); + let material = resource_manager + .get_resource::(&material_handle) + .expect("RendererMaterial not found"); + self.material_name = material.name.clone(); + + if let Some(ref parameters_bind_group) = material.parameters_bind_group { + render_pass.set_bind_group( + MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, + parameters_bind_group, + &[], + ); + } + + if let Some(ref texture_bind_group) = material.textures_bind_group { + render_pass.set_bind_group( + MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX, + texture_bind_group, + &[], + ); + } + } + + fn change_mesh( + &mut self, + resource_manager: &ResourceManager, + mesh_handle: RendererMeshHandle, + render_pass: &mut wgpu::RenderPass, + ) { + self.mesh_handle = Some(mesh_handle); + let mesh = resource_manager + .get_resource::(&mesh_handle) + .expect("RendererMesh not found"); + self.mesh_name = mesh.name.clone(); + + self.mesh_index_count = mesh.index_count; + render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + render_pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + } +} + +// --- PassMesh --- + +/// Instance-batched rendering pass using `RendererShader` pipelines. +/// Install via `renderer.set_passes(vec![Box::new(PassMesh::new())])`. +pub struct PassMesh { + depth_handle: Option, + camera: Option, + instance_buffer: Option, + instances: Vec, +} + +impl PassMesh { + /// Creates a `PassMesh`; GPU resources are allocated lazily in `Pass::init`. + pub fn new() -> Self { + Self { + depth_handle: None, + camera: None, + instance_buffer: None, + instances: Vec::with_capacity(INITIAL_INSTANCE_VECTOR_CAPACITY), + } + } +} + +impl Default for PassMesh { + fn default() -> Self { + Self::new() + } +} + +impl Pass for PassMesh { + fn get_label(&self) -> &str { + "pass_mesh" + } + + /// Allocates the depth texture, camera UBO, and instance buffer. + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + self.depth_handle = Some(renderer.create_depth_texture("pass_mesh_depth")?); + + let layout = renderer.get_camera_bind_group_layout(); + self.camera = Some(RendererCamera::new(renderer.get_device(), layout)?); + + let buffer_size = + (std::mem::size_of::() * MAX_INSTANCE_PER_DRAWCALL_COUNT) as u64; + self.instance_buffer = Some( + renderer + .get_device() + .create_buffer(&wgpu::BufferDescriptor { + label: Some("pass_mesh_instance_buffer"), + size: buffer_size, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + ); + + Ok(()) + } + + /// Records instance-batched draw commands for the current frame. + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + _frame: &wgpu::SurfaceTexture, + view: &wgpu::TextureView, + world: &WorldQuery<'_>, + ) -> Result<()> { + // Query the component storages this pass needs. + let camera_components = world.query::()?; + let transform_components = world.query::()?; + let pbr_renderable_components = world.query::()?; + + // Resolve active camera. + let active_camera_index = world.active_camera.data().index as usize; + let active_camera_component = camera_components + .data + .get(active_camera_index) + .unwrap() + .as_ref() + .unwrap(); + let active_camera_transform = transform_components + .data + .get(active_camera_index) + .unwrap() + .as_ref() + .unwrap(); + + // Update camera UBO. + self.camera.as_mut().unwrap().update( + renderer.get_queue(), + active_camera_component, + active_camera_transform, + ); + + let clear_color = active_camera_component.clear_color; + + let depth_view = renderer + .get_render_target_view(self.depth_handle.unwrap()) + .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; + + debug!(LogContext::Frame => "Recording mesh draw commands"); + + let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pass_mesh"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: clear_color.x as f64, + g: clear_color.y as f64, + b: clear_color.z as f64, + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + }); + + let mut current_drawing_context = DrawingContext::default(); + + let mut render_pass = render_pass.forget_lifetime(); + + // Build this pass's draw list by querying the world (data-driven; no engine-global queue): + // every entity with a PbrRenderableComponent is an instance. + let renderable_indices: Vec = pbr_renderable_components + .data + .iter() + .enumerate() + .filter_map(|(index, slot)| slot.as_ref().map(|_| index as u32)) + .collect(); + for (i, instance_batch) in renderable_indices + .chunks(MAX_INSTANCE_PER_DRAWCALL_COUNT) + .enumerate() + { + let batch_size = instance_batch.len(); + current_drawing_context.instance_batch_number = i as u32; + current_drawing_context.instance_batch_size = batch_size as u32; + + self.instances.clear(); + + for &entity_index in instance_batch { + let transform_slot = transform_components + .data + .get(entity_index as usize) + .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; + let transform_component = transform_slot + .as_ref() + .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; + self.instances.push(Instance::new(transform_component)); + } + + let instance_buffer = self.instance_buffer.as_ref().unwrap(); + renderer.get_queue().write_buffer( + instance_buffer, + 0, + bytemuck::cast_slice(&self.instances), + ); + render_pass.set_vertex_buffer(1, instance_buffer.slice(..)); + + current_drawing_context.accumulated_instance_range = 0..0; + + for (j, &entity_index) in instance_batch.iter().enumerate() { + let key = pbr_renderable_components + .data + .get(entity_index as usize) + .and_then(|slot| slot.as_ref()) + .and_then(|pbr| pbr.render_queue_key) + .unwrap_or(0); + let key_fields = decompose_render_queue_key(key); + + let renderer_shader_handle = RendererShaderHandle::new( + key_fields.shader_index.into(), + NonZeroU32::new(key_fields.shader_version.into()).unwrap(), + ); + let renderer_material_handle = RendererMaterialHandle::new( + key_fields.material_index.into(), + NonZeroU32::new(key_fields.material_version.into()).unwrap(), + ); + let renderer_mesh_handle = RendererMeshHandle::new( + key_fields.mesh_index.into(), + NonZeroU32::new(key_fields.mesh_version.into()).unwrap(), + ); + + if current_drawing_context.rendering_order > key_fields.order { + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); + current_drawing_context.change_rendering_order(key_fields.order); + } + + if current_drawing_context.shader_handle != Some(renderer_shader_handle) { + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); + current_drawing_context.change_shader( + world.resources, + renderer.get_engine_parameters(), + renderer_shader_handle, + &mut render_pass, + self.camera.as_ref().unwrap(), + ); + } + + if current_drawing_context.material_handle != Some(renderer_material_handle) { + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); + current_drawing_context.change_material( + world.resources, + renderer_material_handle, + &mut render_pass, + ); + } + + if current_drawing_context.mesh_handle != Some(renderer_mesh_handle) { + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); + current_drawing_context.change_mesh( + world.resources, + renderer_mesh_handle, + &mut render_pass, + ); + } + + current_drawing_context.accumulate_instance(); + + if j == batch_size - 1 { + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); + } + } + } + + Ok(()) + } +} diff --git a/engine/pill_engine/src/graphics/pass_pbr_opaque.rs b/engine/pill_engine/src/graphics/pass_pbr_opaque.rs new file mode 100644 index 00000000..11808e5a --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_pbr_opaque.rs @@ -0,0 +1,1030 @@ +use crate::config::{ + DEFAULT_BRDF_LUT_FALLBACK_PIXEL, DEFAULT_EQUIRECT_FALLBACK_PIXEL, DEFAULT_IBL_FALLBACK_PIXEL, +}; +use crate::{ + ecs::{ + CameraComponent, ComponentStorage, PbrRenderableComponent, RenderStateComponent, + TransformComponent, + }, + graphics::{ + decompose_render_queue_key, BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, + RendererMaterialHandle, RendererMeshHandle, RendererTextureHandle, ShaderDesc, WorldQuery, + }, + renderer::resources::{RendererMaterial, RendererMesh}, +}; +use glam::{Mat3, Mat4, Vec3}; +use pill_core::{PillSlotMapKey, Result}; +use std::num::NonZeroU32; + +/// Camera uniform layout: position (vec4) + view-projection matrix (mat4x4) + fog. +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct CameraUniform { + position: [f32; 4], + view_projection_matrix: [[f32; 4]; 4], + fog_color: [f32; 3], // packed with fog_density for 16-byte alignment + fog_density: f32, +} + +impl CameraUniform { + fn new() -> Self { + Self { + position: [0.0; 4], + view_projection_matrix: Mat4::IDENTITY.to_cols_array_2d(), + fog_color: [1.0; 3], + fog_density: 0.0, + } + } + + /// Recomputes view and projection from ECS components; called once per frame before GPU upload. + fn update_data( + &mut self, + camera_component: &CameraComponent, + transform_component: &TransformComponent, + ) { + self.position = [ + transform_component.position.x, + transform_component.position.y, + transform_component.position.z, + 0.0, + ]; + + let eye = Vec3::new( + transform_component.position.x, + transform_component.position.y, + transform_component.position.z, + ); + let view = if let Some(t) = camera_component.look_at { + Mat4::look_at_rh(eye, Vec3::new(t.x, t.y, t.z), Vec3::Y) + } else { + let roll_matrix = Mat3::from_rotation_z(transform_component.rotation.z.to_radians()); + let yaw_matrix = Mat3::from_rotation_y(transform_component.rotation.y.to_radians()); + let pitch_matrix = Mat3::from_rotation_x(transform_component.rotation.x.to_radians()); + let rotation_matrix = yaw_matrix * pitch_matrix * roll_matrix; + let direction = rotation_matrix * Vec3::Z; + Mat4::look_to_rh(eye, direction, Vec3::Y) + }; + + let proj = Mat4::perspective_rh( + camera_component.fov.to_radians(), + camera_component.aspect.get_value(), + camera_component.range.start, + camera_component.range.end, + ); + + self.view_projection_matrix = (proj * view).to_cols_array_2d(); + } +} + +/// Per-draw storage entry: raw pos/rot/scale (48 B). GPU builds model = T·R·S and MVP. +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub(crate) struct PerDrawStd140 { + position: [f32; 4], // xyz + rotation: [f32; 4], // xyz, radians + scale: [f32; 4], // xyz +} + +// Storage buffer (StructuredBuffer / var) indexed by SV_InstanceID replaces +// dynamic-offset UBO: eliminates Metal's 256-B alignment tax and 120k per-instance API calls. +// [Lottes @NOTimothyLottes 2025-01-23; Aaltonen "Modern Mobile Rendering at HypeHype" GDC 2023] +const MAX_EXPECTED_PER_DRAW_INSTANCES: usize = 100_000; +const PER_DRAW_STRIDE_BYTES: usize = std::mem::size_of::(); + +pub const MATERIAL_BIND_GROUP_GLOBALS: usize = 0; +pub const MATERIAL_BIND_GROUP_TEXTURES: usize = 1; +pub const MATERIAL_BIND_GROUP_PARAMS: usize = 2; +pub const MATERIAL_BIND_GROUP_PERDRAW: usize = 3; + +/// Mesh batch within a material group: same mesh, its per-instance transforms accumulated this +/// frame; `base_offset_u32` is its first index in the concatenated storage buffer. +pub(crate) struct MeshBatch { + pub(crate) mesh_handle: RendererMeshHandle, + pub(crate) instances: Vec, + pub(crate) base_offset_u32: u32, +} + +/// Draw group: one pipeline + one material, containing one or more mesh batches. +pub(crate) struct GroupCmd { + pub(crate) material_handle: RendererMaterialHandle, + pub(crate) batches: Vec, +} + +/// Camera uniform for the background sub-draw (view direction basis + fov). +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct BgCameraUbo { + right: [f32; 3], + tan_half_fov: f32, + up: [f32; 3], + aspect: f32, + fwd: [f32; 3], + _pad: f32, + bg_color: [f32; 3], + _pad2: f32, +} + +/// GPU-side state for the background sub-draw within the PBR render pass. +struct BgSubState { + pipeline: PipelineV2, + camera_buf: wgpu::Buffer, + bind_group: wgpu::BindGroup, + sampler: wgpu::Sampler, + // Equirect handle the current bind_group was built from; rebuild only when it changes. + cached_bg: RendererTextureHandle, +} + +/// GPU-side pass state initialized in `Pass::init`, read every `Pass::draw`. +struct PassPBROpaqueState { + camera_uniform: CameraUniform, + camera_buffer: wgpu::Buffer, + globals_bind_group: wgpu::BindGroup, + pipeline: PipelineV2, + ibl_sampler: wgpu::Sampler, + prefilter_sampler: wgpu::Sampler, + per_draw_buffer: wgpu::Buffer, + per_draw_bind_group: wgpu::BindGroup, + bg: BgSubState, + // IBL handles the current globals_bind_group was built from; rebuild only when these change. + cached_ibl: [RendererTextureHandle; 3], +} + +/// Builds the globals bind group (camera UBO + IBL irradiance/prefilter/BRDF-LUT views). +/// Shared by init() and the per-frame rebuild-on-change path in draw(). +#[allow(clippy::too_many_arguments)] +fn build_globals_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + camera_buffer: &wgpu::Buffer, + irradiance_view: &wgpu::TextureView, + prefilter_view: &wgpu::TextureView, + brdf_lut_view: &wgpu::TextureView, + ibl_sampler: &wgpu::Sampler, + prefilter_sampler: &wgpu::Sampler, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_pbr_opaque_globals_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(irradiance_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(ibl_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(prefilter_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(prefilter_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(brdf_lut_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(ibl_sampler), + }, + ], + }) +} + +/// Builds the background bind group (camera UBO + equirect view + sampler). +fn build_bg_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + camera_buffer: &wgpu::Buffer, + equirect_view: &wgpu::TextureView, + sampler: &wgpu::Sampler, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_pbr_opaque_bg_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(equirect_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }) +} + +/// Default PBR pass: GGX shading with IBL, one instanced draw per material/mesh batch. +/// Per-frame state (IBL handles, background equirect, bg_color, fog) is read from +/// RenderStateComponent each frame via WorldQuery::get_global — nothing is baked at construction. +pub struct PassPBROpaque { + color_target: Option, + depth_texture: Option, + groups_buffer: Vec, + staging_buffer: Vec, + state: Option, +} + +impl PassPBROpaque { + /// Creates the pass; `Pass::init` must run before the first frame. + pub fn new(color_target: Option) -> Self { + Self { + color_target, + depth_texture: None, + groups_buffer: Vec::with_capacity(2000), + staging_buffer: Vec::with_capacity( + MAX_EXPECTED_PER_DRAW_INSTANCES * PER_DRAW_STRIDE_BYTES, + ), + state: None, + } + } +} + +/// Returns the initialized pass state; panics in debug if `init` was not called. +fn get_state(pass: &mut PassPBROpaque) -> &mut PassPBROpaqueState { + debug_assert!(pass.state.is_some()); + pass.state + .as_mut() + .expect("PassPBROpaque: state not initialized — call init() before draw()") +} + +impl Pass for PassPBROpaque { + fn get_label(&self) -> &str { + "pass_pbr_opaque" + } + + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + let vertex_wgsl = include_str!("../../res/shaders/pbr_opaque_vertex.wgsl"); + + let fragment_wgsl = include_str!("../../res/shaders/pbr_opaque_fragment.wgsl"); + + let bind_groups: Vec> = vec![ + // 0: globals (camera + IBL irradiance + prefilter + BRDF LUT) + vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + // 1: material textures (base_color, normal, metallic_roughness, emissive) + vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + // 2: material params + vec![wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + // 3: per-draw storage array — no dynamic offset, no 256-B Metal alignment (Apple Metal Spec §4.1.1). + vec![wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + ]; + + let desc = PipelineV2Desc { + label: Some("pass_pbr_opaque_pipeline"), + vs: ShaderDesc { + source: vertex_wgsl, + entry_func: "vs_main", + }, + ps: ShaderDesc { + source: fragment_wgsl, + entry_func: "fs_main", + }, + vertex_buffers: &[ + ::data_layout_descriptor(), + ], + bind_groups, + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + }; + + let pipeline = renderer.create_pipeline_v2(desc)?; + + let camera_buffer = { + let device = renderer.get_device(); + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pass_pbr_opaque_camera_buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + }; + + let per_draw_buffer = renderer.create_buffer(BufferDesc { + label: Some("pass_pbr_opaque_per_draw"), + byte_size: (PER_DRAW_STRIDE_BYTES * MAX_EXPECTED_PER_DRAW_INSTANCES) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + })?; + let per_draw_bind_group = { + let layout = &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_PERDRAW]; + let device = renderer.get_device(); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_pbr_opaque_per_draw_bind_group"), + layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: per_draw_buffer.as_entire_binding(), + }], + }) + }; + + let ibl_sampler = { + let device = renderer.get_device(); + device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }) + }; + + // Register 1px neutral-gray fallbacks for any missing IBL handle, + // then get owned views (texture lifetime managed by renderer's pass_textures). + // Initial bind group is built from neutral fallbacks; draw() rebuilds it from + // RenderStateComponent's IBL handles the first frame (and whenever they change). + let irr_h = renderer.create_texture_from_pixels( + "diffuse_ibl_fallback", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let pre_h = renderer.create_texture_from_pixels( + "specular_ibl_fallback", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let lut_h = renderer.create_texture_from_pixels( + "brdf_lut_fallback", + &[DEFAULT_BRDF_LUT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let irradiance_view = renderer + .get_texture_view(irr_h) + .expect("ibl_diffuse fallback invalid"); + let prefilter_view = renderer + .get_texture_view(pre_h) + .expect("ibl_specular fallback invalid"); + let brdf_lut_view = renderer + .get_texture_view(lut_h) + .expect("ibl_brdf_lut fallback invalid"); + + let prefilter_sampler = { + let device = renderer.get_device(); + device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Linear, + lod_min_clamp: 0.0, + lod_max_clamp: 16.0, + ..Default::default() + }) + }; + + let globals_bind_group = build_globals_bind_group( + renderer.get_device(), + &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_GLOBALS], + &camera_buffer, + &irradiance_view, + &prefilter_view, + &brdf_lut_view, + &ibl_sampler, + &prefilter_sampler, + ); + let cached_ibl = [irr_h, pre_h, lut_h]; + + self.depth_texture = Some(renderer.create_depth_texture("pass_pbr_opaque_depth")?); + + // Background sub-draw is always created; its equirect comes from RenderStateComponent.background + // each frame (draw() rebuilds the bind group when the handle changes). Initial bind group uses + // a 1px fallback equirect. + let bg = { + let bg_vs = include_str!("../../res/shaders/background_vertex.wgsl"); + let bg_fs = include_str!("../../res/shaders/background_fragment.wgsl"); + let bg_bind_groups = vec![vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ]]; + let bg_pipeline = renderer.create_pipeline_v2(PipelineV2Desc { + label: Some("pass_pbr_opaque_bg_pipeline"), + vs: ShaderDesc { + source: bg_vs, + entry_func: "vs_main", + }, + ps: ShaderDesc { + source: bg_fs, + entry_func: "fs_main", + }, + vertex_buffers: &[], + bind_groups: bg_bind_groups, + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + // LessEqual + write disabled: fills only pixels where depth == 1.0 (far plane). + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + })?; + let equirect_fallback_h = renderer.create_texture_from_pixels( + "bg_equirect_fallback", + &[DEFAULT_EQUIRECT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba32Float, + ); + let equirect_view = renderer + .get_texture_view(equirect_fallback_h) + .expect("bg equirect fallback invalid"); + let bg_sampler = renderer + .get_device() + .create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, // equirect wraps in U + address_mode_v: wgpu::AddressMode::ClampToEdge, // poles clamp in V + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + let bg_camera_buf = renderer + .get_device() + .create_buffer(&wgpu::BufferDescriptor { + label: Some("pass_pbr_opaque_bg_camera"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bg_bind_group = build_bg_bind_group( + renderer.get_device(), + &bg_pipeline.bind_group_layouts[0], + &bg_camera_buf, + &equirect_view, + &bg_sampler, + ); + BgSubState { + pipeline: bg_pipeline, + camera_buf: bg_camera_buf, + bind_group: bg_bind_group, + sampler: bg_sampler, + cached_bg: equirect_fallback_h, + } + }; + + self.state = Some(PassPBROpaqueState { + camera_uniform: CameraUniform::new(), + camera_buffer, + globals_bind_group, + pipeline, + ibl_sampler, + prefilter_sampler, + per_draw_buffer, + per_draw_bind_group, + bg, + cached_ibl, + }); + + Ok(()) + } + + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + _frame: &wgpu::SurfaceTexture, + view: &wgpu::TextureView, + world: &WorldQuery<'_>, + ) -> Result<()> { + // Query per-entity storages + the global render state (bg/IBL/fog) this pass needs. + let camera_components = world.query::()?; + let transform_components = world.query::()?; + let pbr_renderable_components = world.query::()?; + let render_state = world.get_global::()?; + + // Rebuild the IBL globals bind group only when the component's handles change (steady state: 0). + { + let ibl = [ + render_state.ibl_diffuse, + render_state.ibl_specular, + render_state.ibl_brdf_lut, + ]; + if get_state(self).cached_ibl != ibl { + let irradiance_view = renderer + .get_texture_view(ibl[0]) + .expect("ibl_diffuse handle invalid"); + let prefilter_view = renderer + .get_texture_view(ibl[1]) + .expect("ibl_specular handle invalid"); + let brdf_lut_view = renderer + .get_texture_view(ibl[2]) + .expect("ibl_brdf_lut handle invalid"); + let device = renderer.get_device(); + let state = get_state(self); + state.globals_bind_group = build_globals_bind_group( + device, + &state.pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_GLOBALS], + &state.camera_buffer, + &irradiance_view, + &prefilter_view, + &brdf_lut_view, + &state.ibl_sampler, + &state.prefilter_sampler, + ); + state.cached_ibl = ibl; + } + } + + // Rebuild the background bind group only when the component's equirect handle changes. + { + let bg_h = render_state.background; + if get_state(self).bg.cached_bg != bg_h { + let equirect_view = renderer + .get_texture_view(bg_h) + .expect("background handle invalid"); + let device = renderer.get_device(); + let state = get_state(self); + state.bg.bind_group = build_bg_bind_group( + device, + &state.bg.pipeline.bind_group_layouts[0], + &state.bg.camera_buf, + &equirect_view, + &state.bg.sampler, + ); + state.bg.cached_bg = bg_h; + } + } + + // Read active camera and transform. + let active_camera_index = world.active_camera.data().index as usize; + let active_camera_component = camera_components + .data + .get(active_camera_index) + .unwrap() + .as_ref() + .unwrap(); + let active_camera_transform = transform_components + .data + .get(active_camera_index) + .unwrap() + .as_ref() + .unwrap(); + + // Update camera uniform and write to GPU buffer. MVP = viewProjection·model is computed + // in the vertex shader, so the CPU never multiplies per-entity matrices. + // Fog fades to the background color (no separate fog_color); read from the component. + let fog_color = render_state.bg_color; + let fog_density = render_state.fog_density; + { + let state = get_state(self); + state + .camera_uniform + .update_data(active_camera_component, active_camera_transform); + state.camera_uniform.fog_color = fog_color; + state.camera_uniform.fog_density = fog_density; + renderer.get_queue().write_buffer( + &state.camera_buffer, + 0, + bytemuck::bytes_of(&state.camera_uniform), + ); + } + + // Data-driven: this pass builds its OWN opaque draw list by querying the world. Iterate the + // contiguous transform + pbr_renderable arrays, group by material+mesh (find-or-create, since + // ECS order is unsorted) to minimize bind-group/vertex-buffer switches — the grouping exists + // to cut GPU state changes, not for visual order. Group order is irrelevant (opaque, depth- + // tested). [Aaltonen "HypeHype" GDC 2023] + for group in self.groups_buffer.iter_mut() { + for batch in group.batches.iter_mut() { + batch.instances.clear(); // reuse capacity across frames + } + } + let transforms = &transform_components.data; + let pbrs = &pbr_renderable_components.data; + for (slot_transform, slot_pbr) in transforms.iter().zip(pbrs.iter()) { + let Some(transform) = slot_transform else { + continue; + }; + let Some(pbr) = slot_pbr else { + continue; + }; + let Some(key) = pbr.render_queue_key else { + continue; + }; + let key_fields = decompose_render_queue_key(key); + let mesh_handle = RendererMeshHandle::new( + key_fields.mesh_index.into(), + NonZeroU32::new(key_fields.mesh_version.into()).unwrap(), + ); + let material_handle = RendererMaterialHandle::new( + key_fields.material_index.into(), + NonZeroU32::new(key_fields.material_version.into()).unwrap(), + ); + let per_draw = PerDrawStd140 { + position: [ + transform.position.x, + transform.position.y, + transform.position.z, + 0.0, + ], + rotation: [ + transform.rotation.x, + transform.rotation.y, + transform.rotation.z, + 0.0, + ], + scale: [transform.scale.x, transform.scale.y, transform.scale.z, 0.0], + }; + let group_index = match self + .groups_buffer + .iter() + .position(|group| group.material_handle == material_handle) + { + Some(index) => index, + None => { + self.groups_buffer.push(GroupCmd { + material_handle, + batches: Vec::new(), + }); + self.groups_buffer.len() - 1 + } + }; + let group = &mut self.groups_buffer[group_index]; + match group + .batches + .iter_mut() + .find(|batch| batch.mesh_handle == mesh_handle) + { + Some(batch) => batch.instances.push(per_draw), + None => group.batches.push(MeshBatch { + mesh_handle, + instances: vec![per_draw], + base_offset_u32: 0, + }), + } + } + + // Concatenate per-batch instances into the storage buffer (sequential — cache-friendly) and + // assign each batch its first-instance offset. Single upload per pass. [Aaltonen GDC 2023] + self.staging_buffer.clear(); + let mut next_instance: u32 = 0; + for group in self.groups_buffer.iter_mut() { + for batch in group.batches.iter_mut() { + batch.base_offset_u32 = next_instance; + for per_draw in &batch.instances { + self.staging_buffer + .extend_from_slice(bytemuck::bytes_of(per_draw)); + next_instance = next_instance.wrapping_add(1); + } + } + } + if next_instance as usize > MAX_EXPECTED_PER_DRAW_INSTANCES { + log::error!( + "PassPBROpaque: per-draw capacity exceeded (needed={}, capacity={})", + next_instance, + MAX_EXPECTED_PER_DRAW_INSTANCES + ); + } + { + let state_ref = self + .state + .as_ref() + .expect("PassPBROpaque: state not initialized — call init() before draw()"); + renderer + .get_queue() + .write_buffer(&state_ref.per_draw_buffer, 0, &self.staging_buffer); + } + + let depth_view = renderer + .get_render_target_view(self.depth_texture.unwrap()) + .unwrap(); + let color_view = self + .color_target + .and_then(|h| renderer.get_render_target_view(h)) + .unwrap_or(view); + + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pass_pbr_opaque_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: color_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + }); + + let state_ref = self + .state + .as_ref() + .expect("PassPBROpaque: state not initialized — call init() before draw()"); + render_pass.set_pipeline(&state_ref.pipeline.pipeline); + // Per-draw array bound once; instance_index selects per-entity data — 5 draws instead of 60k. + // [Lottes @NOTimothyLottes 2025-01-23; WebGPU W3C 2024 §drawIndexed firstInstance] + render_pass.set_bind_group( + MATERIAL_BIND_GROUP_PERDRAW as u32, + &state_ref.per_draw_bind_group, + &[], + ); + for group in &self.groups_buffer { + render_pass.set_bind_group( + MATERIAL_BIND_GROUP_GLOBALS as u32, + &state_ref.globals_bind_group, + &[], + ); + + let mat = world + .resources + .get_resource::(&group.material_handle) + .expect("PassPBROpaque: RendererMaterial missing for draw group"); + + // Skip materials that don't have PBR-compatible bind groups. + let (Some(textures_bg), Some(params_bg)) = ( + mat.textures_bind_group.as_ref(), + mat.parameters_bind_group.as_ref(), + ) else { + continue; + }; + render_pass.set_bind_group(MATERIAL_BIND_GROUP_TEXTURES as u32, textures_bg, &[]); + render_pass.set_bind_group(MATERIAL_BIND_GROUP_PARAMS as u32, params_bg, &[]); + + for batch in &group.batches { + let mesh = world + .resources + .get_resource::(&batch.mesh_handle) + .expect("PassPBROpaque: RendererMesh missing for batch"); + render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + render_pass + .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + let first = batch.base_offset_u32; + let count = batch.instances.len() as u32; + if count == 0 { + continue; // material/mesh with no instances this frame + } + // One instanced draw per batch: instance_index in [first, first+count) selects per-draw data. + render_pass.draw_indexed(0..mesh.index_count, 0, first..(first + count)); + } + } + + // Background sub-draw: within the same render pass, after opaque geometry. + // LessEqual depth test + write_enabled=false: fills only pixels where depth == 1.0 (far plane = no geometry). + { + let bg = &state_ref.bg; + let eye = Vec3::new( + active_camera_transform.position.x, + active_camera_transform.position.y, + active_camera_transform.position.z, + ); + let fwd = if let Some(t) = active_camera_component.look_at { + (Vec3::new(t.x, t.y, t.z) - eye).normalize() + } else { + let roll = Mat3::from_rotation_z(active_camera_transform.rotation.z.to_radians()); + let yaw = Mat3::from_rotation_y(active_camera_transform.rotation.y.to_radians()); + let pitch = Mat3::from_rotation_x(active_camera_transform.rotation.x.to_radians()); + (yaw * pitch * roll) * Vec3::Z + }; + let right = fwd.cross(Vec3::Y).normalize(); + let up = right.cross(fwd); + let ubo = BgCameraUbo { + right: right.to_array(), + tan_half_fov: (active_camera_component.fov.to_radians() / 2.0).tan(), + up: up.to_array(), + aspect: active_camera_component.aspect.get_value(), + fwd: fwd.to_array(), + _pad: 0.0, + bg_color: render_state.bg_color, + _pad2: 0.0, + }; + renderer + .get_queue() + .write_buffer(&bg.camera_buf, 0, bytemuck::bytes_of(&ubo)); + render_pass.set_pipeline(&bg.pipeline.pipeline); + render_pass.set_bind_group(0, &bg.bind_group, &[]); + render_pass.draw(0..3, 0..1); + } + + Ok(()) + } +} diff --git a/engine/pill_engine/src/graphics/pass_tonemap.rs b/engine/pill_engine/src/graphics/pass_tonemap.rs new file mode 100644 index 00000000..c3d9f9d0 --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_tonemap.rs @@ -0,0 +1,172 @@ +use crate::graphics::{ + Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererTextureHandle, ShaderDesc, WorldQuery, +}; + +use pill_core::Result; + +static VS: &str = include_str!("../../res/shaders/tonemap_vertex.wgsl"); +static FS: &str = include_str!("../../res/shaders/tonemap_fragment.wgsl"); + +pub struct PassTonemap { + hdr_source: RendererTextureHandle, + state: Option, +} + +struct TonemapState { + pipeline: PipelineV2, + sampler: wgpu::Sampler, + // sRGB view of the swapchain — GPU applies OETF on write even when surface format is linear (e.g. WebGPU/wasm32). + swapchain_view_format: wgpu::TextureFormat, +} + +impl PassTonemap { + pub fn new(hdr_source: RendererTextureHandle) -> Self { + Self { + hdr_source, + state: None, + } + } +} + +impl Pass for PassTonemap { + fn get_label(&self) -> &str { + "pass_tonemap" + } + + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + let surface_format = renderer.get_surface_format(); + let swapchain_view_format = if surface_format.is_srgb() { + surface_format + } else { + surface_format.add_srgb_suffix() + }; + + let bind_groups = vec![vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ]]; + + let pipeline = renderer.create_pipeline_v2(PipelineV2Desc { + label: Some("pass_tonemap"), + vs: ShaderDesc { + source: VS, + entry_func: "vs_main", + }, + ps: ShaderDesc { + source: FS, + entry_func: "fs_main", + }, + vertex_buffers: &[], + bind_groups, + targets: &[Some(wgpu::ColorTargetState { + format: swapchain_view_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + })?; + + let sampler = renderer + .get_device() + .create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + self.state = Some(TonemapState { + pipeline, + sampler, + swapchain_view_format, + }); + Ok(()) + } + + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + frame: &wgpu::SurfaceTexture, + _view: &wgpu::TextureView, + _world: &WorldQuery<'_>, + ) -> Result<()> { + let state = self + .state + .as_ref() + .expect("PassTonemap: state not initialized — call init() before draw()"); + let hdr_view = renderer + .get_render_target_view(self.hdr_source) + .expect("PassTonemap: HDR render target missing"); + let swapchain_view = frame.texture.create_view(&wgpu::TextureViewDescriptor { + format: Some(state.swapchain_view_format), + ..Default::default() + }); + + let bind_group = { + let layout = &state.pipeline.bind_group_layouts[0]; + renderer + .get_device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_tonemap_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(hdr_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&state.sampler), + }, + ], + }) + }; + + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pass_tonemap_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &swapchain_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rp.set_pipeline(&state.pipeline.pipeline); + rp.set_bind_group(0, &bind_group, &[]); + rp.draw(0..3, 0..1); + Ok(()) + } +} diff --git a/engine/pill_engine/src/graphics/pass_tonemap_README.md b/engine/pill_engine/src/graphics/pass_tonemap_README.md new file mode 100644 index 00000000..3c37d218 --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_tonemap_README.md @@ -0,0 +1,22 @@ +# PassTonemap — gamma + +No `pow(color, 1/2.2)` in the shader. The swapchain view is always an `*Srgb` format; +the GPU applies the sRGB OETF on fragment write automatically. + +## Why a separate `swapchain_view_format` + +Desktop: surface format is already `Bgra8UnormSrgb` / `Rgba8UnormSrgb` — view = surface format. + +WebGPU/wasm32: canvas API only exposes `bgra8unorm` / `rgba8unorm` (no sRGB surface variant). +Fix: register the sRGB variant in `SurfaceConfiguration::view_formats`, build the pipeline +against it, and create the swapchain `TextureView` with it. The GPU still applies the OETF. + +Same pattern used by Bevy (`bevy_render/src/view/window/mod.rs`): +```rust +let texture_view_format = if !format.is_srgb() { Some(format.add_srgb_suffix()) } else { None }; +``` + +## References + +- wgpu `TextureFormat` docs — `*Srgb` variants: *"Apply sRGB transfer function for color-space conversion"* on write. https://docs.rs/wgpu/latest/wgpu/enum.TextureFormat.html +- wgpu wiki — *"When image format names contain the suffix `*_SRGB`, the sRGB gamma transform is applied transparently on every texel write."* https://github.com/gfx-rs/wgpu/wiki/Texture-Color-Formats-and-Srgb-conversions diff --git a/engine/pill_engine/src/graphics/render_queue.rs b/engine/pill_engine/src/graphics/render_queue.rs index 41167a79..f7985a0e 100644 --- a/engine/pill_engine/src/graphics/render_queue.rs +++ b/engine/pill_engine/src/graphics/render_queue.rs @@ -1,31 +1,29 @@ use crate::{ config::*, - resources::{Material, MaterialHandle, Mesh, MeshHandle, ResourceManager, Shader}, + renderer::resources::{RendererMaterial, RendererMesh, RendererShader}, + resources::{ + Material, MaterialHandle, Mesh, MeshHandle, PBRMaterial, PBRMaterialHandle, + ResourceManager, Shader, + }, }; use pill_core::PillSlotMapKey; -use core::fmt::{self, Debug}; -use lazy_static::lazy_static; +use core::fmt; use pill_core::Result; -use std::{ - cmp::Ordering, - convert::TryInto, - fmt::{Binary, Display}, - ops::{Add, Not, Range, Shl, Sub}, -}; - -// --- Render queue +use std::cmp::Ordering; -pub struct RenderQueue { - pub items: Vec, - pub context_change_indices: Vec, // Indices of items after which context changes (e.g. material or mesh change) -} +// --- Render queue item --- -// --- Render queue item +#[derive(Clone, Copy)] pub struct RenderQueueItem { pub key: RenderQueueKey, pub entity_index: u32, + /// Raw transform copied (no trig) during the cache-warm sequential ECS scan; + /// the vertex shader builds the model matrix on the GPU. xyz used, w padding. [Aaltonen GDC 2023] + pub position: [f32; 4], + pub rotation: [f32; 4], + pub scale: [f32; 4], } impl Ord for RenderQueueItem { @@ -48,92 +46,133 @@ impl PartialEq for RenderQueueItem { impl Eq for RenderQueueItem {} -impl Display for RenderQueueItem { +impl fmt::Display for RenderQueueItem { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "({} {})", self.key, self.entity_index) } } -impl Debug for RenderQueueItem { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { +impl fmt::Debug for RenderQueueItem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "({} {})", self.key, self.entity_index) } } -// --- Render queue field --- +// --- Render queue key type --- -pub struct RenderQueueField { - pub mask_range: core::ops::Range, - pub mask_shift: T, - pub mask: T, - pub max: T, -} +pub type RenderQueueKey = crate::config::RenderQueueKeyType; -pub trait Pow { - fn pow(self, exp: Self) -> Self; +// --- Bit-field descriptors --- + +pub struct RenderQueueFieldDesc { + pub mask: RenderQueueKey, + pub mask_shift: u32, + pub max: RenderQueueKey, } -impl RenderQueueField -where - T: Copy - + Default - + Pow - + Binary - + Debug - + From - + From - + Ord - + Shl - + Sub - + Add - + Not, -{ - pub fn new(mask_range: core::ops::Range) -> Self { - // Compile-time evaluable function - let one: T = T::from(1_u8); - let two: T = T::from(2_u8); - let mask_range_length = mask_range.end - mask_range.start + one; //if mask_range.start == zero { mask_range.end + one } else { mask_range.end - mask_range.start }; - let mask_size: T = T::from(std::mem::size_of::() as u8 * 8); - let mask_shift: T = mask_size - mask_range.end - one; - let mask: T = pill_core::create_bitmask_from_range::(&mask_range); - let max: T = two.pow(mask_range_length) - one; - - RenderQueueField { - mask_range, - mask_shift, - mask, - max, - } +const fn field(mask: u64, shift: u32, max: u64) -> RenderQueueFieldDesc { + RenderQueueFieldDesc { + mask, + mask_shift: shift, + max, } } -// Creates pill engine render queue composed from order, material index, material version, mesh index, mesh version +pub const RENDER_QUEUE_KEY_ORDER: RenderQueueFieldDesc = field(0xF800_0000_0000_0000, 59, 31); +pub const RENDER_QUEUE_KEY_SHADER_INDEX: RenderQueueFieldDesc = + field(0x07F8_0000_0000_0000, 51, 255); +pub const RENDER_QUEUE_KEY_SHADER_VERSION: RenderQueueFieldDesc = + field(0x0007_F800_0000_0000, 43, 255); +pub const RENDER_QUEUE_KEY_MATERIAL_INDEX: RenderQueueFieldDesc = + field(0x0000_07F8_0000_0000, 35, 255); +pub const RENDER_QUEUE_KEY_MATERIAL_VERSION: RenderQueueFieldDesc = + field(0x0000_0007_F800_0000, 27, 255); +pub const RENDER_QUEUE_KEY_MESH_INDEX: RenderQueueFieldDesc = field(0x0000_0000_07F8_0000, 19, 255); +pub const RENDER_QUEUE_KEY_MESH_VERSION: RenderQueueFieldDesc = + field(0x0000_0000_0007_F800, 11, 255); + +// --- Compose --- + +#[cfg_attr(feature = "headless", allow(unused_variables))] pub fn compose_render_queue_key( resource_manager: &ResourceManager, material_handle: &MaterialHandle, mesh_handle: &MeshHandle, ) -> Result { - let material = resource_manager.get_resource::(material_handle)?; - let shader = resource_manager.get_resource::(&material.shader_handle)?; - let mesh = resource_manager.get_resource::(mesh_handle)?; - - let render_queue_key: RenderQueueKey = ((RENDER_QUEUE_KEY_ORDER.max - material.rendering_order as RenderQueueKey) << RENDER_QUEUE_KEY_ORDER.mask_shift) | // Order has to be inverted for proper sorting - - // 1. Shader - defining rendering pipeline - ((shader.renderer_resource_handle.unwrap().data().index as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) | - ((shader.renderer_resource_handle.unwrap().data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) | - - // 2. Material - defining material properties, same rendering pipeline - ((material.renderer_resource_handle.unwrap().data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) | - ((material.renderer_resource_handle.unwrap().data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) | - - // 3. Mesh - defining mesh properties, same material thus same rendering pipeline - ((mesh.renderer_resource_handle.unwrap().data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift ) | - ((mesh.renderer_resource_handle.unwrap().data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); + #[cfg(feature = "headless")] + return Ok(0); + + #[cfg(not(feature = "headless"))] + { + let material = resource_manager.get_resource::(material_handle)?; + let shader = resource_manager.get_resource::(&material.shader_handle)?; + let mesh = resource_manager.get_resource::(mesh_handle)?; + + let renderer_shader_handle = + resource_manager.get_resource_handle::(&shader.name)?; + let renderer_material_handle = + resource_manager.get_resource_handle::(&material.name)?; + let renderer_mesh_handle = + resource_manager.get_resource_handle::(&mesh.name)?; + + Ok( + ((RENDER_QUEUE_KEY_ORDER.max - material.rendering_order as RenderQueueKey) + << RENDER_QUEUE_KEY_ORDER.mask_shift) + | ((renderer_shader_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) + | ((renderer_shader_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) + | ((renderer_material_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) + | ((renderer_material_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) + | ((renderer_mesh_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) + | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift), + ) + } +} - Ok(render_queue_key) +/// PBR path: PBRMaterial always uses default lit shader +#[cfg_attr(feature = "headless", allow(unused_variables))] +pub fn compose_pbr_render_queue_key( + resource_manager: &ResourceManager, + material_handle: PBRMaterialHandle, + mesh_handle: &MeshHandle, +) -> Result { + #[cfg(feature = "headless")] + return Ok(0); + + #[cfg(not(feature = "headless"))] + { + let material = resource_manager.get_resource::(&material_handle)?; + let mesh = resource_manager.get_resource::(mesh_handle)?; + + let renderer_shader_handle = + resource_manager.get_resource_handle::(DEFAULT_LIT_SHADER_NAME)?; + let renderer_material_handle = + resource_manager.get_resource_handle::(&material.name)?; + let renderer_mesh_handle = + resource_manager.get_resource_handle::(&mesh.name)?; + + Ok(((renderer_shader_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) + | ((renderer_shader_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) + | ((renderer_material_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) + | ((renderer_material_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) + | ((renderer_mesh_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) + | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift)) + } } +// --- Decompose --- + pub struct RenderQueueKeyFields { pub order: u8, pub shader_index: u8, @@ -144,65 +183,20 @@ pub struct RenderQueueKeyFields { pub mesh_version: u8, } -// Decomposes pill engine render queue key into separate fields -pub fn decompose_render_queue_key(render_queue_key: RenderQueueKey) -> RenderQueueKeyFields { - // [TODO] What if render queue key is not valid - let order: u8 = ((render_queue_key & RENDER_QUEUE_KEY_ORDER.mask as RenderQueueKey) - >> RENDER_QUEUE_KEY_ORDER.mask_shift as RenderQueueKey) as u8; - let shader_index: u8 = ((render_queue_key & RENDER_QUEUE_KEY_SHADER_INDEX.mask) - >> RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) as u8; - let shader_version: u8 = ((render_queue_key & RENDER_QUEUE_KEY_SHADER_VERSION.mask) - >> RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) as u8; - let material_index: u8 = ((render_queue_key & RENDER_QUEUE_KEY_MATERIAL_INDEX.mask) - >> RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) as u8; - let material_version: u8 = ((render_queue_key & RENDER_QUEUE_KEY_MATERIAL_VERSION.mask) - >> RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) as u8; - let mesh_index: u8 = ((render_queue_key & RENDER_QUEUE_KEY_MESH_INDEX.mask) - >> RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) as u8; - let mesh_version: u8 = ((render_queue_key & RENDER_QUEUE_KEY_MESH_VERSION.mask) - >> RENDER_QUEUE_KEY_MESH_VERSION.mask_shift) as u8; - +pub fn decompose_render_queue_key(key: RenderQueueKey) -> RenderQueueKeyFields { RenderQueueKeyFields { - order, - shader_index, - shader_version, - material_index, - material_version, - mesh_index, - mesh_version, - } -} - -// --- Render queue fields config --- - -pub type RenderQueueKey = crate::config::RenderQueueKeyType; - -impl Pow for RenderQueueKey { - fn pow(self, exp: Self) -> Self { - RenderQueueKey::pow(self, exp.try_into().unwrap()) - } -} - -fn get_render_queue_key_item_range(render_queue_item_index: u8) -> Range { - let mut start: RenderQueueKey = 0; - let mut end: RenderQueueKey = 0; - for i in 0..render_queue_item_index + 1 { - start += if i.ne(&0) { - RENDER_QUEUE_KEY_ITEMS_LENGTH[i as usize - 1] - } else { - 0 - }; - end += RENDER_QUEUE_KEY_ITEMS_LENGTH[i as usize]; + order: ((key & RENDER_QUEUE_KEY_ORDER.mask) >> RENDER_QUEUE_KEY_ORDER.mask_shift) as u8, + shader_index: ((key & RENDER_QUEUE_KEY_SHADER_INDEX.mask) + >> RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) as u8, + shader_version: ((key & RENDER_QUEUE_KEY_SHADER_VERSION.mask) + >> RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) as u8, + material_index: ((key & RENDER_QUEUE_KEY_MATERIAL_INDEX.mask) + >> RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) as u8, + material_version: ((key & RENDER_QUEUE_KEY_MATERIAL_VERSION.mask) + >> RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) as u8, + mesh_index: ((key & RENDER_QUEUE_KEY_MESH_INDEX.mask) + >> RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) as u8, + mesh_version: ((key & RENDER_QUEUE_KEY_MESH_VERSION.mask) + >> RENDER_QUEUE_KEY_MESH_VERSION.mask_shift) as u8, } - start..(end - 1) -} - -lazy_static! { // This will be initialized in runtime instead of compile-time (this is the cost of not using const function, const functions do not allow for generic variables bound by traits different than Sized) - pub static ref RENDER_QUEUE_KEY_ORDER: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_RENDERING_ORDER_IDX)); - pub static ref RENDER_QUEUE_KEY_SHADER_INDEX: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_SHADER_INDEX_IDX)); - pub static ref RENDER_QUEUE_KEY_SHADER_VERSION: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_SHADER_VERSION_IDX)); - pub static ref RENDER_QUEUE_KEY_MATERIAL_INDEX: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_MATERIAL_INDEX_IDX)); - pub static ref RENDER_QUEUE_KEY_MATERIAL_VERSION: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_MATERIAL_VERSION_IDX)); - pub static ref RENDER_QUEUE_KEY_MESH_INDEX: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_MESH_INDEX_IDX)); - pub static ref RENDER_QUEUE_KEY_MESH_VERSION: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_MESH_VERSION_IDX)); } diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index cf754ec5..78ab7d87 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -1,13 +1,15 @@ #![allow(clippy::too_many_arguments)] +#[cfg(feature = "ui")] +use crate::ecs::EguiClient; use crate::{ app_config::EngineConfig, - ecs::{CameraComponent, ComponentStorage, EntityHandle, TransformComponent}, - graphics::RenderQueueItem, - internal::{MaterialParameter, MaterialTexture, MeshData}, - resources::{ShaderParameterSlot, ShaderTextureSlot, TextureType}, + ecs::{ + Component, ComponentStorage, EntityHandle, GlobalComponent, GlobalComponentStorage, Scene, + }, + resources::{ResourceManager, ShaderParameterSlot, ShaderTextureSlot, TextureType}, }; -use pill_core::Timer; +use pill_core::{PillTypeMap, Timer}; use pill_core::Result; use std::{collections::HashMap, sync::Arc}; @@ -34,16 +36,121 @@ pill_core::define_new_pill_slotmap_key! { pub struct RendererShaderHandle; } +// --- Pass API types --- + +/// Read-only view of the world handed to each pass. A pass calls `query::()` for whatever +/// component types it needs and builds its OWN draw list (opaque, translucent, shadow, …). +/// The renderer stays domain-agnostic — it never names concrete component types. +pub struct WorldQuery<'a> { + pub active_camera: EntityHandle, + pub delta_time: f32, + pub resources: &'a ResourceManager, + scene: &'a Scene, + globals: &'a PillTypeMap, +} + +impl<'a> WorldQuery<'a> { + pub fn new( + active_camera: EntityHandle, + scene: &'a Scene, + globals: &'a PillTypeMap, + delta_time: f32, + resources: &'a ResourceManager, + ) -> Self { + Self { + active_camera, + delta_time, + resources, + scene, + globals, + } + } + + /// Borrow the storage for per-entity component type `T` from the active scene. + pub fn query(&self) -> Result<&'a ComponentStorage> + where + T: Component>, + { + self.scene.get_component_storage::() + } + + /// Borrow a global component `T` (e.g. RenderStateComponent) from the world. + pub fn get_global(&self) -> Result<&'a T> + where + T: GlobalComponent>, + { + crate::ecs::get_global_component_from::(self.globals) + } +} + +#[derive(Clone, Copy)] +pub struct BufferDesc<'a> { + pub label: Option<&'a str>, + pub byte_size: u64, + pub usage: wgpu::BufferUsages, +} + +#[derive(Clone, Copy, Debug)] +pub struct ShaderDesc<'a> { + pub source: &'a str, + pub entry_func: &'a str, +} + +#[derive(Clone, Debug)] +pub struct PipelineV2Desc<'a> { + pub label: Option<&'a str>, + pub vs: ShaderDesc<'a>, + pub ps: ShaderDesc<'a>, + pub vertex_buffers: &'a [wgpu::VertexBufferLayout<'a>], + pub bind_groups: Vec>, + pub targets: &'a [Option], + pub depth_stencil: Option, + pub multisample: wgpu::MultisampleState, + pub primitive: wgpu::PrimitiveState, +} + +pub struct RendererTargetDesc { + pub name: String, + pub format: wgpu::TextureFormat, + pub width: u32, + pub height: u32, +} + +pub struct PipelineV2 { + pub pipeline: wgpu::RenderPipeline, + pub bind_group_layouts: Vec, +} + +// --- Pass trait --- + +pub trait Pass { + /// Returns a short human-readable identifier used in profiling labels. + fn get_label(&self) -> &str; + /// Allocates all GPU resources the pass needs; called once before the first frame. + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()>; + /// Records this pass's GPU commands into `encoder` for the current frame. + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + frame: &wgpu::SurfaceTexture, + view: &wgpu::TextureView, + world: &WorldQuery<'_>, + ) -> Result<()>; +} + // --- Renderer trait definition --- pub trait PillRenderer { + /// Creates the renderer synchronously; panics on WASM where async init is required. fn new(window: Arc, config: EngineConfig) -> Result where Self: Sized; // --- Create --- - fn create_shader( + /// Compiles vertex and fragment WGSL into a `RendererShader` with slot and bind-group metadata. + fn create_shader_struct( &mut self, name: &str, vertex_wgsl: &str, @@ -52,84 +159,87 @@ pub trait PillRenderer { parameter_slots: &[(String, ShaderParameterSlot)], pass_engine_parameters: bool, pass_camera_parameters: bool, - ) -> Result; + ) -> Result; - fn create_material( - &mut self, - name: &str, - renderer_shader_handle: RendererShaderHandle, - textures: &[(String, MaterialTexture)], - parameters: &HashMap, - ) -> Result; + /// Allocates a GPU camera uniform buffer and returns a handle to it. + fn create_camera(&mut self) -> Result; - fn create_texture( - &mut self, - name: &str, - rgba: &[u8], - width: u32, - height: u32, - texture_type: TextureType, - ) -> Result; + // --- Destroy --- - fn create_mesh(&mut self, name: &str, mesh_data: &MeshData) -> Result; + /// Releases the GPU resources associated with the given camera handle. + fn destroy_camera(&mut self, renderer_camera_handle: RendererCameraHandle) -> Result<()>; - fn create_camera(&mut self) -> Result; + // --- Other --- - // --- Update --- + /// Reconfigures the swap chain and depth texture after a window resize. + fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize); - fn update_material_textures( - &mut self, - renderer_material_handle: RendererMaterialHandle, - textures: &[(String, MaterialTexture)], - ) -> Result<()>; + /// Returns the OS window the renderer is bound to. + fn get_window(&self) -> std::sync::Arc; - fn update_material_parameters( + /// Drives the full frame: acquires the surface, runs all passes, and presents. + fn render( &mut self, - renderer_material_handle: RendererMaterialHandle, - parameters: &HashMap, + active_camera_entity_handle: EntityHandle, + scene: &Scene, + globals: &PillTypeMap, + delta_time: f32, + timer: &mut Timer, + resource_manager: &ResourceManager, ) -> Result<()>; - // --- Destroy --- + // --- Pass API --- - fn destroy_shader(&mut self, renderer_shader_handle: RendererShaderHandle) -> Result<()>; + /// Replaces the current pass chain; calls `Pass::init` on each new pass before storing it. + fn set_passes(&mut self, passes: Vec>) -> Result<()>; - fn destroy_material(&mut self, renderer_material_handle: RendererMaterialHandle) -> Result<()>; + /// Returns the current surface dimensions `(width, height)` in pixels. + fn get_surface_size(&self) -> (u32, u32); - fn destroy_texture(&mut self, renderer_texture_handle: RendererTextureHandle) -> Result<()>; + /// Returns the wgpu `Device`; required by passes that allocate their own GPU resources. + fn get_device(&self) -> &wgpu::Device; - fn destroy_mesh(&mut self, renderer_mesh_handle: RendererMeshHandle) -> Result<()>; + /// Returns the wgpu `Queue`; required by passes that upload data each frame. + fn get_queue(&self) -> &wgpu::Queue; - fn destroy_camera(&mut self, renderer_camera_handle: RendererCameraHandle) -> Result<()>; + /// Returns the surface texture format; used when creating pass render pipelines. + fn get_surface_format(&self) -> wgpu::TextureFormat; - // --- Other --- + /// Returns the shared engine-parameters UBO; required by passes that use legacy RendererShader pipelines. + fn get_engine_parameters(&self) -> &crate::renderer::resources::EngineParameters; - fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize); + /// Returns a clone of the camera bind-group layout; required to create a RendererCamera in pass init. + fn get_camera_bind_group_layout(&self) -> wgpu::BindGroupLayout; - #[cfg(feature = "debug_ui")] - fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) -> Result<()>; + /// Allocates a GPU buffer with the given descriptor. + fn create_buffer(&mut self, desc: BufferDesc) -> Result; - #[cfg(feature = "debug_ui")] - fn render( - &mut self, - active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, - egui_ui: Box, - delta_time: f32, - timer: &mut Timer, - ) -> Result<()>; + /// Compiles a render pipeline from WGSL sources and bind group layout descriptors. + fn create_pipeline_v2(&mut self, desc: PipelineV2Desc) -> Result; - #[cfg(not(feature = "debug_ui"))] - fn render( + /// Creates an off-screen render target texture and returns its handle. + fn create_render_target(&mut self, desc: RendererTargetDesc) -> Result; + + /// Creates a depth texture sized to the current surface and returns its handle. + fn create_depth_texture(&mut self, label: &str) -> Result; + + /// Returns the texture view for a previously created render target, or `None` if not found. + fn get_render_target_view(&self, handle: RendererTextureHandle) -> Option<&wgpu::TextureView>; + + /// Uploads pixel data as a GPU texture and returns a handle stored in the renderer. + /// `mip_pixels[i]` is the raw byte slice for mip level `i`. + fn create_texture_from_pixels( &mut self, - active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, - delta_time: f32, - timer: &mut Timer, - ) -> Result<()>; + name: &str, + mip_pixels: &[&[u8]], + base_width: u32, + base_height: u32, + format: wgpu::TextureFormat, + ) -> RendererTextureHandle; + + /// Creates a new `TextureView` for a previously registered texture handle. + /// Returns `None` if the handle is not found. + fn get_texture_view(&self, handle: RendererTextureHandle) -> Option; } pub type Renderer = Box; diff --git a/engine/pill_engine/src/lib.rs b/engine/pill_engine/src/lib.rs index 46f92b24..8b0611c9 100644 --- a/engine/pill_engine/src/lib.rs +++ b/engine/pill_engine/src/lib.rs @@ -7,6 +7,7 @@ mod config; mod ecs; mod engine; mod graphics; +pub mod renderer; mod resources; // --- Macros --- @@ -63,14 +64,15 @@ pub mod game { ecs::{ CameraAspectRatio, CameraComponent, Component, ComponentStorage, EntityHandle, GamepadAxis, GamepadButton, GlobalComponent, GlobalComponentStorage, InputComponent, - MeshRenderingComponent, PlayerId, SceneHandle, TimeComponent, TransformComponent, - UpdatePhase, + MeshComponent, PbrRenderableComponent, PlayerId, RenderStateComponent, SceneHandle, + TimeComponent, TransformComponent, UpdatePhase, }, engine::{Engine, KeyboardKey, MouseButton, PillGame}, + graphics::RendererTextureHandle, resources::{ - Material, MaterialHandle, Mesh, MeshHandle, Resource, ResourceLoader, ResourceStorage, - Shader, ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, - TextureHandle, TextureType, + Material, MaterialHandle, Mesh, MeshHandle, PBRMaterial, PBRMaterialHandle, Resource, + ResourceLoader, ResourceStorage, Shader, ShaderParameterSlot, ShaderParameterType, + ShaderTextureSlot, Texture, TextureHandle, TextureType, }, }; @@ -80,6 +82,15 @@ pub mod game { resources::Sound, }; + #[cfg(feature = "gltf_loading")] + pub use crate::resources::{ + gltf_resource_uris, parse_gltf_scene, GltfMaterialData, GltfMeshData, GltfResourceUris, + GltfSceneData, GltfTextureData, + }; + // IBL bake, shared with the build-time cooker (lives in pill_assets, surfaced here for games). + #[cfg(feature = "ibl_bake")] + pub use pill_assets::ibl::{bake_all, equirect_from_hdr}; + extern crate pill_core; pub use pill_core::{ create_game, define_new_pill_slotmap_key, Color, PillTypeMapKey, Vector2f, Vector2i, @@ -92,6 +103,10 @@ pub mod game { #[cfg(not(target_arch = "wasm32"))] mod internal_mod { pub use crate::app_config::EngineConfig; + #[cfg(feature = "ui")] + pub use crate::ecs::EguiClient; + #[cfg(feature = "ui")] + pub use crate::ecs::EguiComponent; pub use crate::{ config::*, ecs::{ @@ -99,21 +114,23 @@ mod internal_mod { get_renderer_resource_handle_from_camera_component, networking_system_client, networking_system_server, update_transform_matrices, AudioListenerComponent, AudioManagerComponent, AudioSourceComponent, CameraAspectRatio, CameraComponent, - ComponentStorage, EntityHandle, EntityUpdate, InputComponent, MeshRenderingComponent, + ComponentStorage, EntityHandle, EntityUpdate, InputComponent, MeshComponent, NetworkEntityAction, NetworkEntityState, NetworkManagerComponent, NetworkSide, - NetworkStateComponent, NetworkUpdatePayload, Scene, TimeComponent, TransformComponent, + NetworkStateComponent, NetworkUpdatePayload, PbrRenderableComponent, + RenderStateComponent, Scene, TimeComponent, TransformComponent, }, engine::{Engine, PillGame}, graphics::{ - decompose_render_queue_key, PillRenderer, RenderQueueItem, RenderQueueKey, - RenderQueueKeyFields, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, RENDER_QUEUE_KEY_ORDER, + decompose_render_queue_key, BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, + RenderQueueItem, RenderQueueKey, RenderQueueKeyFields, RendererCameraHandle, + RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, RendererTargetDesc, + RendererTextureHandle, ShaderDesc, WorldQuery, RENDER_QUEUE_KEY_ORDER, }, resources::{ - get_renderer_texture_handle_from_material_texture, Material, MaterialHandle, - MaterialParameter, MaterialTexture, Mesh, MeshData, MeshHandle, MeshVertex, - ResourceLoader, ResourceManager, ShaderParameterSlot, ShaderParameterType, - ShaderTextureSlot, Texture, TextureHandle, TextureType, + Material, MaterialHandle, MaterialParameter, MaterialTexture, Mesh, MeshData, + MeshHandle, MeshVertex, PBRMaterial, PBRMaterialHandle, ResourceLoader, + ResourceManager, ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, + TextureHandle, TextureType, }, }; } @@ -121,25 +138,31 @@ mod internal_mod { #[cfg(target_arch = "wasm32")] mod internal_mod { pub use crate::app_config::EngineConfig; + #[cfg(feature = "ui")] + pub use crate::ecs::EguiClient; + #[cfg(feature = "ui")] + pub use crate::ecs::EguiComponent; pub use crate::{ config::*, ecs::{ get_model_matrix, get_normal_matrix, get_renderer_resource_handle_from_camera_component, update_transform_matrices, CameraAspectRatio, CameraComponent, ComponentStorage, EntityHandle, InputComponent, - MeshRenderingComponent, Scene, TimeComponent, TransformComponent, + MeshComponent, PbrRenderableComponent, RenderStateComponent, Scene, TimeComponent, + TransformComponent, }, engine::{Engine, PillGame}, graphics::{ - decompose_render_queue_key, PillRenderer, RenderQueueItem, RenderQueueKey, - RenderQueueKeyFields, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, RENDER_QUEUE_KEY_ORDER, + decompose_render_queue_key, BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, + RenderQueueItem, RenderQueueKey, RenderQueueKeyFields, RendererCameraHandle, + RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, RendererTargetDesc, + RendererTextureHandle, ShaderDesc, WorldQuery, RENDER_QUEUE_KEY_ORDER, }, resources::{ - get_renderer_texture_handle_from_material_texture, Material, MaterialHandle, - MaterialParameter, MaterialTexture, Mesh, MeshData, MeshHandle, MeshVertex, - ResourceLoader, ResourceManager, ShaderParameterSlot, ShaderParameterType, - ShaderTextureSlot, Texture, TextureHandle, TextureType, + Material, MaterialHandle, MaterialParameter, MaterialTexture, Mesh, MeshData, + MeshHandle, MeshVertex, PBRMaterial, PBRMaterialHandle, ResourceLoader, + ResourceManager, ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, + TextureHandle, TextureType, }, }; } diff --git a/engine/pill_engine/src/renderer/config.rs b/engine/pill_engine/src/renderer/config.rs new file mode 100644 index 00000000..a490a60a --- /dev/null +++ b/engine/pill_engine/src/renderer/config.rs @@ -0,0 +1,7 @@ +pub const MAX_INSTANCE_PER_DRAWCALL_COUNT: usize = 10000; +pub const INITIAL_INSTANCE_VECTOR_CAPACITY: usize = 10000; + +pub const ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 0; +pub const CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 1; +pub const MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 2; +pub const MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX: u32 = 3; diff --git a/engine/pill_engine/src/renderer/instance.rs b/engine/pill_engine/src/renderer/instance.rs new file mode 100644 index 00000000..164a82c4 --- /dev/null +++ b/engine/pill_engine/src/renderer/instance.rs @@ -0,0 +1,50 @@ +use crate::renderer::resources::Vertex; + +use crate::ecs::TransformComponent; +use pill_core::Matrix3f; + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct Instance { + pub(crate) transform: Matrix3f, +} + +impl Instance { + /// Creates an instance from a transform component's position, rotation, and scale. + pub fn new(transform_component: &TransformComponent) -> Instance { + Instance { + transform: Matrix3f::from_cols( + transform_component.position, + transform_component.rotation, + transform_component.scale, + ), + } + } +} + +impl Vertex for Instance { + fn data_layout_descriptor<'a>() -> wgpu::VertexBufferLayout<'a> { + use std::mem; + wgpu::VertexBufferLayout { + array_stride: mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 1, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, + shader_location: 2, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: mem::size_of::<[f32; 6]>() as wgpu::BufferAddress, + shader_location: 3, + format: wgpu::VertexFormat::Float32x3, + }, + ], + } + } +} diff --git a/engine/pill_engine/src/renderer/mod.rs b/engine/pill_engine/src/renderer/mod.rs new file mode 100644 index 00000000..72fa7b05 --- /dev/null +++ b/engine/pill_engine/src/renderer/mod.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod instance; +pub mod resources; +pub mod state; + +pub use state::Renderer; diff --git a/engine/pill_renderer/src/resources/engine_parameters.rs b/engine/pill_engine/src/renderer/resources/engine_parameters.rs similarity index 58% rename from engine/pill_renderer/src/resources/engine_parameters.rs rename to engine/pill_engine/src/renderer/resources/engine_parameters.rs index a76b7f21..03343ea7 100644 --- a/engine/pill_renderer/src/resources/engine_parameters.rs +++ b/engine/pill_engine/src/renderer/resources/engine_parameters.rs @@ -1,15 +1,21 @@ -use pill_core::Result; +use pill_core::{Result, Vector3f}; use wgpu::util::DeviceExt; -// Layout must match the HLSL `EngineParams` in `include/common.hlsl` (std140): -// vec3 fog_color; // offset 0 (12 bytes) -// float fog_density; // offset 12 (4 bytes) -// // total: 16 bytes +// Layout must match the WGSL `EngineParams_std140_0` compiled from `include/common.hlsl`: +// float delta_time; // offset 0 (4 bytes, @align(16) so 12 bytes padding follow) +// float fog_density; // offset 4 (4 bytes, @align(4), 8 bytes padding to reach @align(16) vec3) +// [padding] // offset 8 (8 bytes) +// vec3 fog_color; // offset 16 (12 bytes, @align(16)) +// [padding] // offset 28 (4 bytes, struct size rounded to 16) +// // total: 32 bytes #[repr(C)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct EngineParametersData { - pub fog_color: [f32; 3], + pub delta_time: f32, pub fog_density: f32, + _pad0: [f32; 2], + pub fog_color: Vector3f, + _pad1: f32, } impl Default for EngineParametersData { @@ -19,21 +25,25 @@ impl Default for EngineParametersData { } impl EngineParametersData { + /// Returns a zeroed instance; all fields are written before the first GPU upload. pub fn new() -> Self { Self { - fog_color: [0.0; 3], + delta_time: 0.0, fog_density: 0.0, + _pad0: [0.0; 2], + fog_color: Vector3f::ZERO, + _pad1: 0.0, } } - pub fn update_data(&mut self, fog_density: f32, fog_color: [f32; 3]) { + /// Writes the latest per-frame values into the CPU-side struct before the GPU upload. + pub fn update_data(&mut self, delta_time: f32, fog_density: f32, fog_color: Vector3f) { + self.delta_time = delta_time; self.fog_density = fog_density; self.fog_color = fog_color; } } -// --- Camera --- - #[derive(Debug)] pub struct EngineParameters { pub parameters_data: EngineParametersData, @@ -43,6 +53,7 @@ pub struct EngineParameters { } impl EngineParameters { + /// Allocates the GPU uniform buffer and bind group; called once during renderer init. pub fn new(device: &wgpu::Device) -> Result { let parameters_data = EngineParametersData::new(); @@ -53,15 +64,14 @@ impl EngineParameters { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); - // Define engine bind group layout let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("engine_parameters_bind_group_layout"), entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, // (set = X, binding = 0) + binding: 0, visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, // Specifies if this buffer will be changing size or not + has_dynamic_offset: false, min_binding_size: None, }, count: None, @@ -71,24 +81,30 @@ impl EngineParameters { let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { layout: &bind_group_layout, entries: &[wgpu::BindGroupEntry { - binding: 0, // (set = X, binding = 0) + binding: 0, resource: parameters_uniform_buffer.as_entire_binding(), }], label: Some("engine_parameters_bind_group"), }); - let camera = Self { + Ok(Self { parameters_data, parameters_uniform_buffer, bind_group_layout, bind_group, - }; - - Ok(camera) + }) } - pub fn update(&mut self, queue: &wgpu::Queue, fog_density: f32, fog_color: [f32; 3]) { - self.parameters_data.update_data(fog_density, fog_color); + /// Updates the CPU-side data and writes it to the GPU buffer; called once per frame before draw. + pub fn update( + &mut self, + queue: &wgpu::Queue, + delta_time: f32, + fog_density: f32, + fog_color: Vector3f, + ) { + self.parameters_data + .update_data(delta_time, fog_density, fog_color); queue.write_buffer( &self.parameters_uniform_buffer, 0, diff --git a/engine/pill_renderer/src/resources/mod.rs b/engine/pill_engine/src/renderer/resources/mod.rs similarity index 77% rename from engine/pill_renderer/src/resources/mod.rs rename to engine/pill_engine/src/renderer/resources/mod.rs index 96db9239..e7c6ebbf 100644 --- a/engine/pill_renderer/src/resources/mod.rs +++ b/engine/pill_engine/src/renderer/resources/mod.rs @@ -2,22 +2,12 @@ mod engine_parameters; mod renderer_camera; mod renderer_material; mod renderer_mesh; -mod renderer_resource_storage; mod renderer_shader; mod renderer_texture; -// --- Use --- - -pub use renderer_shader::RendererShader; - +pub use engine_parameters::EngineParameters; +pub use renderer_camera::RendererCamera; pub use renderer_material::RendererMaterial; - -pub use renderer_texture::RendererTexture; - pub use renderer_mesh::{RendererMesh, Vertex}; - -pub use renderer_camera::RendererCamera; - -pub use engine_parameters::EngineParameters; - -pub use renderer_resource_storage::RendererResourceStorage; +pub use renderer_shader::RendererShader; +pub use renderer_texture::RendererTexture; diff --git a/engine/pill_renderer/src/resources/renderer_camera.rs b/engine/pill_engine/src/renderer/resources/renderer_camera.rs similarity index 86% rename from engine/pill_renderer/src/resources/renderer_camera.rs rename to engine/pill_engine/src/renderer/resources/renderer_camera.rs index 8d590854..02c8c2a2 100644 --- a/engine/pill_renderer/src/resources/renderer_camera.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_camera.rs @@ -1,4 +1,4 @@ -use pill_engine::internal::{CameraComponent, TransformComponent}; +use crate::ecs::{CameraComponent, TransformComponent}; use pill_core::Result; use pill_core::{Matrix3f, Matrix4f, Vector3f, Vector4f}; @@ -6,19 +6,17 @@ use wgpu::util::DeviceExt; #[rustfmt::skip] pub const OPENGL_TO_WGPU_MATRIX: Matrix4f = Matrix4f::from_cols_array(&[ - 1.0, 0.0, 0.0, 0.0, // column 1 - 0.0, 1.0, 0.0, 0.0, // column 2 - 0.0, 0.0, 0.5, 0.5, // column 3 - 0.0, 0.0, 0.0, 1.0, // column 4 + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 0.5, 0.5, + 0.0, 0.0, 0.0, 1.0, ]); -// --- Camera Uniform --- - #[repr(C)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct CameraParametersData { - pub position: Vector4f, // Camera position - pub view_projection_matrix: Matrix4f, // Perspective manipulation + pub position: Vector4f, + pub view_projection_matrix: Matrix4f, } impl Default for CameraParametersData { @@ -35,12 +33,12 @@ impl CameraParametersData { } } + /// Writes the latest camera position and view-projection matrix from ECS components into the CPU-side struct. pub fn update_data( &mut self, camera_component: &CameraComponent, transform_component: &TransformComponent, ) { - // Update position self.position = Vector4f::new( transform_component.position.x, transform_component.position.y, @@ -48,7 +46,6 @@ impl CameraParametersData { 0.0, ); - // Update view-projection self.view_projection_matrix = CameraParametersData::calculate_projection_matrix(camera_component) * CameraParametersData::calculate_view_matrix(transform_component); @@ -75,8 +72,6 @@ impl CameraParametersData { } } -// --- Camera --- - #[derive(Debug)] pub struct RendererCamera { pub parameters_data: CameraParametersData, @@ -86,6 +81,7 @@ pub struct RendererCamera { } impl RendererCamera { + /// Allocates the camera uniform buffer and bind group using the provided layout. pub fn new( device: &wgpu::Device, camera_bind_group_layout: wgpu::BindGroupLayout, @@ -102,22 +98,21 @@ impl RendererCamera { let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { layout: &camera_bind_group_layout, entries: &[wgpu::BindGroupEntry { - binding: 0, // (set = X, binding = 0) + binding: 0, resource: parameters_uniform_buffer.as_entire_binding(), }], label: Some("camera_parameters_bind_group"), }); - let camera = Self { + Ok(Self { parameters_data, parameters_uniform_buffer, bind_group_layout: camera_bind_group_layout, bind_group, - }; - - Ok(camera) + }) } + /// Recomputes view-projection from ECS components and uploads the result to the GPU buffer. pub fn update( &mut self, queue: &wgpu::Queue, diff --git a/engine/pill_engine/src/renderer/resources/renderer_material.rs b/engine/pill_engine/src/renderer/resources/renderer_material.rs new file mode 100644 index 00000000..b58f02de --- /dev/null +++ b/engine/pill_engine/src/renderer/resources/renderer_material.rs @@ -0,0 +1,227 @@ +#![cfg_attr(debug_assertions, allow(dead_code, unused_imports, unused_variables))] + +use crate::graphics::RendererShaderHandle; +use crate::graphics::RendererTextureHandle; +use crate::{ + config::{ + DEFAULT_COLOR_TEXTURE_NAME, DEFAULT_EMISSIVE_TEXTURE_NAME, + DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME, DEFAULT_NORMAL_TEXTURE_NAME, + }, + graphics::RendererMaterialHandle, + renderer::resources::{RendererShader, RendererTexture}, + resources::{ + MaterialParameter, Resource, ResourceManager, ResourceStorage, ShaderParameterSlot, + ShaderParameterType, ShaderTextureSlot, + }, +}; +use pill_core::{debug, LogContext, PillStyle, PillTypeMapKey, RendererError, Result}; + +use std::collections::HashMap; + +pub struct RendererMaterial { + pub name: String, + pub shader_handle: RendererShaderHandle, + pub parameters_bind_group: Option, + pub textures_bind_group: Option, + pub(crate) parameters_uniform_buffer: Option, +} + +impl RendererMaterial { + /// Creates GPU buffers and bind groups for a material's parameters and textures. + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + resource_manager: &ResourceManager, + name: &str, + shader_handle: RendererShaderHandle, + resolved_textures: &HashMap, + parameters: &HashMap, + ) -> Result { + debug!(LogContext::Rendering => "Creating material {}", name.name_style()); + + let shader = resource_manager + .get_resource::(&shader_handle) + .map_err(|_| -> pill_core::PillError { + pill_core::PillError::from(RendererError::RendererResourceNotFound) + })?; + + let parameter_slots = &shader.parameter_slots; + let texture_slots = &shader.texture_slots; + + let (parameters_bind_group, parameters_uniform_buffer) = { + if !parameter_slots.is_empty() { + let parameters_uniform_buffer_size = Self::calculate_uniform_size(parameter_slots); + + let parameters_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&format!("{}_material_parameters_buffer", name)), + size: parameters_uniform_buffer_size as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + Self::write_parameters_to_buffer( + queue, + ¶meters_uniform_buffer, + parameter_slots, + parameters, + )?; + + let parameters_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(&format!("{}_material_parameters_bind_group", name)), + layout: shader.parameters_bind_group_layout.as_ref().unwrap(), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: parameters_uniform_buffer.as_entire_binding(), + }], + }); + + (Some(parameters_bind_group), Some(parameters_uniform_buffer)) + } else { + (None, None) + } + }; + + let textures_bind_group = if !texture_slots.is_empty() { + Some(Self::create_textures_bind_group( + device, + resource_manager, + shader.textures_bind_group_layout.as_ref().unwrap(), + &format!("{}_textures", name), + texture_slots, + resolved_textures, + )?) + } else { + None + }; + + Ok(Self { + name: name.to_string(), + shader_handle, + parameters_bind_group, + textures_bind_group, + parameters_uniform_buffer, + }) + } + + fn calculate_uniform_size(parameter_slots: &[(String, ShaderParameterSlot)]) -> usize { + const STD140_SLOT_BYTES: usize = 16; // each slot is padded to 16 bytes per std140 alignment rules + parameter_slots.len() * STD140_SLOT_BYTES + } + + /// Packs parameter values into a std140-aligned byte buffer and uploads it to the GPU. + pub(crate) fn write_parameters_to_buffer( + queue: &wgpu::Queue, + buffer: &wgpu::Buffer, + parameter_slots: &[(String, ShaderParameterSlot)], + parameters: &HashMap, + ) -> Result<()> { + let mut data = Vec::new(); + + for (slot_name, slot) in parameter_slots { + match slot.parameter_type { + ShaderParameterType::Color => { + if let Some(MaterialParameter::Color(value)) = parameters.get(slot_name) { + data.extend_from_slice(&value.x.to_le_bytes()); + data.extend_from_slice(&value.y.to_le_bytes()); + data.extend_from_slice(&value.z.to_le_bytes()); + data.extend_from_slice(&0.0f32.to_le_bytes()); + } else { + data.extend_from_slice(&[0u8; 16]); + } + } + ShaderParameterType::Scalar => { + if let Some(MaterialParameter::Scalar(value)) = parameters.get(slot_name) { + data.extend_from_slice(&value.to_le_bytes()); + data.extend_from_slice(&[0u8; 12]); + } else { + data.extend_from_slice(&[0u8; 16]); + } + } + ShaderParameterType::Bool => { + if let Some(MaterialParameter::Bool(value)) = parameters.get(slot_name) { + let value: u32 = if *value { 1 } else { 0 }; + data.extend_from_slice(&value.to_le_bytes()); + data.extend_from_slice(&[0u8; 12]); + } else { + data.extend_from_slice(&[0u8; 16]); + } + } + } + } + + if !data.is_empty() { + queue.write_buffer(buffer, 0, &data); + } + + Ok(()) + } + + /// Builds a wgpu bind group mapping each texture slot to its resolved renderer texture handle. + pub(crate) fn create_textures_bind_group( + device: &wgpu::Device, + resource_manager: &ResourceManager, + texture_bind_group_layout: &wgpu::BindGroupLayout, + name: &str, + texture_slots: &HashMap, + resolved_textures: &HashMap, + ) -> Result { + // Collect handles as owned values first so their borrows outlive the entries vec. + let mut slot_handles: Vec<(u32, u32, RendererTextureHandle)> = Vec::new(); + for (slot_name, slot) in texture_slots { + let handle = match resolved_textures.get(slot_name) { + Some(h) => *h, + None => { + let default_name = match slot.texture_type { + crate::resources::TextureType::Color => DEFAULT_COLOR_TEXTURE_NAME, + crate::resources::TextureType::Normal => DEFAULT_NORMAL_TEXTURE_NAME, + crate::resources::TextureType::MetallicRoughness => { + DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME + } + crate::resources::TextureType::Emissive => DEFAULT_EMISSIVE_TEXTURE_NAME, + }; + resource_manager + .get_resource_handle::(default_name) + .map_err(|_| -> pill_core::PillError { + pill_core::PillError::from(RendererError::RendererResourceNotFound) + })? + } + }; + slot_handles.push((slot.texture_binding, slot.sampler_binding, handle)); + } + + let mut entries = Vec::new(); + for (texture_binding, sampler_binding, handle) in &slot_handles { + let texture = resource_manager + .get_resource::(handle) + .map_err(|_| -> pill_core::PillError { + pill_core::PillError::from(RendererError::RendererResourceNotFound) + })?; + entries.push(wgpu::BindGroupEntry { + binding: *texture_binding, + resource: wgpu::BindingResource::TextureView(&texture.texture_view), + }); + entries.push(wgpu::BindGroupEntry { + binding: *sampler_binding, + resource: wgpu::BindingResource::Sampler(&texture.sampler), + }); + } + + Ok(device.create_bind_group(&wgpu::BindGroupDescriptor { + layout: texture_bind_group_layout, + entries: &entries, + label: Some(name), + })) + } +} + +impl PillTypeMapKey for RendererMaterial { + type Storage = ResourceStorage; +} + +impl Resource for RendererMaterial { + type Handle = RendererMaterialHandle; + + fn get_name(&self) -> String { + self.name.clone() + } +} diff --git a/engine/pill_renderer/src/resources/renderer_mesh.rs b/engine/pill_engine/src/renderer/resources/renderer_mesh.rs similarity index 77% rename from engine/pill_renderer/src/resources/renderer_mesh.rs rename to engine/pill_engine/src/renderer/resources/renderer_mesh.rs index 2416687b..5c9481b1 100644 --- a/engine/pill_renderer/src/resources/renderer_mesh.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_mesh.rs @@ -1,17 +1,13 @@ -use pill_engine::internal::{MeshData, MeshVertex}; +use crate::graphics::RendererMeshHandle; +use crate::resources::{MeshData, MeshVertex, Resource, ResourceStorage}; -use pill_core::Result; +use pill_core::{PillTypeMapKey, Result}; use wgpu::util::DeviceExt; -// --- Vertex --- - pub trait Vertex { - // Defines how a data is layed out in memory (To specify how RenderPipeline needs to map the buffer in the shader) fn data_layout_descriptor<'a>() -> wgpu::VertexBufferLayout<'a>; } -// --- Mesh --- - pub struct RendererMesh { pub name: String, pub vertex_buffer: wgpu::Buffer, @@ -33,14 +29,24 @@ impl RendererMesh { usage: wgpu::BufferUsages::INDEX, }); - let renderer_mesh = Self { + Ok(Self { name: name.to_string(), vertex_buffer, index_buffer, index_count: mesh_data.indices.len() as u32, - }; + }) + } +} + +impl PillTypeMapKey for RendererMesh { + type Storage = ResourceStorage; +} + +impl Resource for RendererMesh { + type Handle = RendererMeshHandle; - Ok(renderer_mesh) + fn get_name(&self) -> String { + self.name.clone() } } @@ -52,35 +58,26 @@ impl Vertex for RendererMesh { step_mode: wgpu::VertexStepMode::Vertex, attributes: &[ wgpu::VertexAttribute { - // Vertex position offset: 0, shader_location: 0, format: wgpu::VertexFormat::Float32x3, }, wgpu::VertexAttribute { - // Vertex texture coordinates - // slangc maps TEXCOORD0 → @location(4), not 1 offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, shader_location: 4, format: wgpu::VertexFormat::Float32x2, }, wgpu::VertexAttribute { - // Vertex normal - // slangc maps NORMAL → @location(5) offset: mem::size_of::<[f32; 5]>() as wgpu::BufferAddress, shader_location: 5, format: wgpu::VertexFormat::Float32x3, }, wgpu::VertexAttribute { - // Vertex tangent - // slangc maps TANGENT → @location(6) offset: mem::size_of::<[f32; 8]>() as wgpu::BufferAddress, shader_location: 6, format: wgpu::VertexFormat::Float32x3, }, wgpu::VertexAttribute { - // Vertex bitangent - // slangc maps BINORMAL → @location(7) offset: mem::size_of::<[f32; 11]>() as wgpu::BufferAddress, shader_location: 7, format: wgpu::VertexFormat::Float32x3, diff --git a/engine/pill_renderer/src/resources/renderer_shader.rs b/engine/pill_engine/src/renderer/resources/renderer_shader.rs similarity index 56% rename from engine/pill_renderer/src/resources/renderer_shader.rs rename to engine/pill_engine/src/renderer/resources/renderer_shader.rs index 51610e98..11208683 100644 --- a/engine/pill_renderer/src/resources/renderer_shader.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_shader.rs @@ -1,6 +1,6 @@ -use pill_core::Result; -use pill_core::{debug, LogContext, PillStyle}; -use pill_engine::internal::{ShaderParameterSlot, ShaderTextureSlot}; +use crate::graphics::RendererShaderHandle; +use crate::resources::{Resource, ResourceStorage, ShaderParameterSlot, ShaderTextureSlot}; +use pill_core::{debug, LogContext, PillStyle, PillTypeMapKey, Result}; use std::collections::HashMap; pub struct RendererShader { @@ -34,7 +34,6 @@ impl RendererShader { pass_engine_parameters: bool, pass_camera_parameters: bool, ) -> Result { - // Print shader information { let mut shader_info = format!( "Creating shader {}:\n - Settings:\n - Pass engine parameters: {}\n - Pass camera parameters: {}", @@ -56,14 +55,9 @@ impl RendererShader { )); } - // Log with context debug!(LogContext::Rendering => "{}", shader_info); } - debug!(LogContext::Rendering => "Shader modules created"); - - // Create shader modules from WGSL strings — wgpu consumes WGSL natively; - // no compile step at runtime, no naga linked into the binary. let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("master_vertex_shader"), source: wgpu::ShaderSource::Wgsl(vertex_wgsl.into()), @@ -73,75 +67,60 @@ impl RendererShader { source: wgpu::ShaderSource::Wgsl(fragment_wgsl.into()), }); - let parameters_bind_group_layout = { - if !parameter_slots.is_empty() { - let bind_group_layout_entry = wgpu::BindGroupLayoutEntry { - binding: 0, // (set = 2, binding = 0) - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }; - - Some( - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some(&format!("{}_parameters_bind_group_layout", name)), - entries: &[bind_group_layout_entry], - }), - ) - } else { - None - } - }; - - debug!(LogContext::Rendering => "Parameters bind group layout created"); - - // Create bind group layout entries for textures - Bind group slot 1 - let textures_bind_group_layout = { - if !texture_slots.is_empty() { - let mut entries = Vec::new(); - - for texture_slot in texture_slots.values() { - // Texture binding - entries.push(wgpu::BindGroupLayoutEntry { - binding: texture_slot.texture_binding, + let parameters_bind_group_layout = if !parameter_slots.is_empty() { + Some( + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(&format!("{}_parameters_bind_group_layout", name)), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2, - sample_type: wgpu::TextureSampleType::Float { filterable: true }, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, }, count: None, - }); + }], + }), + ) + } else { + None + }; - // Sampler binding - entries.push(wgpu::BindGroupLayoutEntry { - binding: texture_slot.sampler_binding, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }); - } - - Some( - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some(&format!("{}_textures_bind_group_layout", name)), - entries: &entries, - }), - ) - } else { - None + let textures_bind_group_layout = if !texture_slots.is_empty() { + let mut entries = Vec::new(); + + for texture_slot in texture_slots.values() { + entries.push(wgpu::BindGroupLayoutEntry { + binding: texture_slot.texture_binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }); + + entries.push(wgpu::BindGroupLayoutEntry { + binding: texture_slot.sampler_binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }); } - }; - debug!(LogContext::Rendering => "Textures bind group layout created"); + Some( + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(&format!("{}_textures_bind_group_layout", name)), + entries: &entries, + }), + ) + } else { + None + }; - // Create pipeline layout let pipeline_layout = { - // Collect bind group layouts only if they exist let mut bind_group_layouts = Vec::new(); if pass_engine_parameters { @@ -164,7 +143,6 @@ impl RendererShader { }) }; - // Create color target states that specifies what what color outputs wgpu should set up let color_target_states = &[Some(wgpu::ColorTargetState { format: color_format, blend: Some(wgpu::BlendState { @@ -180,7 +158,7 @@ impl RendererShader { vertex: wgpu::VertexState { module: &vertex_shader, entry_point: Some("vs_main"), - buffers: vertex_layouts, // Specifies structure of vertices that will be passed to the vertex shader + buffers: vertex_layouts, compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { @@ -190,25 +168,24 @@ impl RendererShader { compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState { - // Specifies how to interpret vertices when converting them into triangles - topology: wgpu::PrimitiveTopology::TriangleList, // Each three vertices will correspond to one triangle + topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, // Specifies how to determine whether a given triangle is facing forward or not (FrontFace::Ccw means that a triangle is facing forward if the vertices are arranged in a counter clockwise direction) - cull_mode: Some(wgpu::Face::Back), // Triangles that are not considered facing forward are culled (not included in the render) as specified by CullMode::Back - polygon_mode: wgpu::PolygonMode::Fill, // Setting this to anything other than Fill requires Features::NON_FILL_POLYGON_MODE - conservative: false, // Requires Features::CONSERVATIVE_RASTERIZATION - unclipped_depth: true, // Requires Features::DEPTH_CLAMPING + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: true, }, depth_stencil: depth_format.map(|format| wgpu::DepthStencilState { format, depth_write_enabled: true, - depth_compare: wgpu::CompareFunction::Less, // Specifies when to discard a new pixel. Using LESS means pixels will be drawn front to back + depth_compare: wgpu::CompareFunction::Less, stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { - count: 1, // Determines how many samples pipeline will use (Multisampling) - mask: !0, // Specifies which samples should be active + count: 1, + mask: !0, alpha_to_coverage_enabled: false, }, multiview: None, @@ -217,7 +194,9 @@ impl RendererShader { let render_pipeline = device.create_render_pipeline(&render_pipeline_descriptor); - let pipeline = Self { + debug!(LogContext::Rendering => "Render pipeline created"); + + Ok(Self { name: name.to_string(), render_pipeline, parameter_slots: parameter_slots.to_vec(), @@ -226,10 +205,18 @@ impl RendererShader { parameters_bind_group_layout, pass_engine_parameters, pass_camera_parameters, - }; + }) + } +} - debug!(LogContext::Rendering => "Render pipeline created"); +impl PillTypeMapKey for RendererShader { + type Storage = ResourceStorage; +} + +impl Resource for RendererShader { + type Handle = RendererShaderHandle; - Ok(pipeline) + fn get_name(&self) -> String { + self.name.clone() } } diff --git a/engine/pill_engine/src/renderer/resources/renderer_texture.rs b/engine/pill_engine/src/renderer/resources/renderer_texture.rs new file mode 100644 index 00000000..2e171f3e --- /dev/null +++ b/engine/pill_engine/src/renderer/resources/renderer_texture.rs @@ -0,0 +1,268 @@ +use crate::graphics::RendererTextureHandle; +use crate::resources::{Resource, ResourceStorage, TextureType}; + +use pill_core::{PillTypeMapKey, Result}; + +pub struct RendererTexture { + pub name: String, + pub texture: wgpu::Texture, + pub texture_view: wgpu::TextureView, + pub sampler: wgpu::Sampler, +} + +impl RendererTexture { + pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; + + pub fn new_texture( + device: &wgpu::Device, + queue: &wgpu::Queue, + name: &str, + rgba: &[u8], + width: u32, + height: u32, + texture_type: TextureType, + ) -> Result { + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + + let format = match texture_type { + TextureType::Color => wgpu::TextureFormat::Rgba8UnormSrgb, + TextureType::Normal => wgpu::TextureFormat::Rgba8Unorm, + TextureType::MetallicRoughness => wgpu::TextureFormat::Rgba8Unorm, + TextureType::Emissive => wgpu::TextureFormat::Rgba8UnormSrgb, + }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(name), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + // Write data to texture + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * width), + rows_per_image: Some(height), + }, + size, + ); + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + address_mode_w: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Nearest, + mipmap_filter: wgpu::FilterMode::Nearest, + lod_min_clamp: 0.0, + lod_max_clamp: 100.0, + ..Default::default() + }); + + Ok(Self { + name: name.to_string(), + texture, + texture_view, + sampler, + }) + } + + pub fn new_depth_texture( + device: &wgpu::Device, + surface_configuration: &wgpu::SurfaceConfiguration, + label: &str, + ) -> Result { + let size = wgpu::Extent3d { + width: surface_configuration.width, + height: surface_configuration.height, + depth_or_array_layers: 1, + }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: Self::DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + address_mode_w: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + compare: Some(wgpu::CompareFunction::LessEqual), + lod_min_clamp: 0.0, + lod_max_clamp: 100.0, + ..Default::default() + }); + + Ok(Self { + name: label.to_string(), + texture, + texture_view, + sampler, + }) + } + + /// Upload pre-computed pixel data as a 2D texture with an optional mip chain. + /// `mip_pixels[i]` is the raw byte slice for mip level `i`; all slices must be + /// sized consistently with `(base_width >> i) * (base_height >> i) * bytes_per_texel(format)`. + pub fn new_from_pixels( + device: &wgpu::Device, + queue: &wgpu::Queue, + name: &str, + mip_pixels: &[&[u8]], + base_width: u32, + base_height: u32, + format: wgpu::TextureFormat, + ) -> Result { + let mip_count = mip_pixels.len() as u32; + let bytes_per_texel: u32 = match format { + wgpu::TextureFormat::Rgba32Float => 16, + _ => 4, + }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(name), + size: wgpu::Extent3d { + width: base_width, + height: base_height, + depth_or_array_layers: 1, + }, + mip_level_count: mip_count, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + for (mip, &mip_data) in mip_pixels.iter().enumerate() { + let mip = mip as u32; + let mip_w = (base_width >> mip).max(1); + let mip_h = (base_height >> mip).max(1); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: mip, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + mip_data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(mip_w * bytes_per_texel), + rows_per_image: Some(mip_h), + }, + wgpu::Extent3d { + width: mip_w, + height: mip_h, + depth_or_array_layers: 1, + }, + ); + } + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Linear, + lod_min_clamp: 0.0, + lod_max_clamp: 16.0, + ..Default::default() + }); + + Ok(Self { + name: name.to_string(), + texture, + texture_view, + sampler, + }) + } + + pub fn new_render_target( + device: &wgpu::Device, + label: &str, + width: u32, + height: u32, + format: wgpu::TextureFormat, + ) -> Result { + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + Ok(Self { + name: label.to_string(), + texture, + texture_view, + sampler, + }) + } +} + +impl PillTypeMapKey for RendererTexture { + type Storage = ResourceStorage; +} + +impl Resource for RendererTexture { + type Handle = RendererTextureHandle; + + fn get_name(&self) -> String { + self.name.clone() + } +} diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs new file mode 100644 index 00000000..cd363643 --- /dev/null +++ b/engine/pill_engine/src/renderer/state.rs @@ -0,0 +1,660 @@ +#![allow(clippy::too_many_arguments)] +use crate::{ + app_config::EngineConfig, + graphics::{ + BufferDesc, Pass, PassPBROpaque, PillRenderer, PipelineV2, PipelineV2Desc, + RendererCameraHandle, RendererTargetDesc, RendererTextureHandle, WorldQuery, + }, + internal::{get_renderer_resource_handle_from_camera_component, EntityHandle}, + renderer::{ + instance::Instance, + resources::{ + EngineParameters, RendererCamera, RendererMesh, RendererShader, RendererTexture, Vertex, + }, + }, + resources::ResourceManager, +}; + +use pill_core::Result; +use pill_core::{ + debug, info, LogContext, PillSlotMap, PillSlotMapKey, PillStyle, RendererError, Timer, +}; +use std::{collections::HashMap, sync::Arc}; + +pub struct Renderer { + pub state: State, +} + +impl Renderer { + /// Creates the renderer asynchronously; required on WASM where `block_on` is unavailable. + pub async fn new_async( + window: Arc, + config: EngineConfig, + ) -> Result { + info!(LogContext::Rendering => "Initializing {}", "Renderer".module_object_style()); + let state = State::new(window, config).await?; + Ok(Self { state }) + } +} + +impl PillRenderer for Renderer { + #[cfg(not(target_arch = "wasm32"))] + fn new(window: Arc, config: EngineConfig) -> Result { + info!(LogContext::Rendering => "Initializing {}", "Renderer".module_object_style()); + let state = pollster::block_on(State::new(window, config))?; + Ok(Self { state }) + } + + #[cfg(target_arch = "wasm32")] + fn new(_window: Arc, _config: EngineConfig) -> Result { + panic!("Use Renderer::new_async on WASM") + } + + // --- Create --- + + fn create_shader_struct( + &mut self, + name: &str, + vertex_wgsl: &str, + fragment_wgsl: &str, + texture_slots: &HashMap, + parameter_slots: &[(String, crate::resources::ShaderParameterSlot)], + pass_engine_parameters: bool, + pass_camera_parameters: bool, + ) -> Result { + RendererShader::new( + name, + &self.state.device, + self.state.color_format, + Some(self.state.depth_format), + &[ + RendererMesh::data_layout_descriptor(), + Instance::data_layout_descriptor(), + ], + vertex_wgsl, + fragment_wgsl, + parameter_slots, + texture_slots, + &self.state.engine_parameters.bind_group_layout, + &self.state.camera_bind_group_layout, + pass_engine_parameters, + pass_camera_parameters, + ) + } + + fn create_camera(&mut self) -> Result { + let camera = RendererCamera::new( + &self.state.device, + self.state.camera_bind_group_layout.clone(), + )?; + let handle = self.state.cameras.insert(camera); + Ok(handle) + } + + // --- Destroy --- + + fn destroy_camera(&mut self, renderer_camera_handle: RendererCameraHandle) -> Result<()> { + self.state.cameras.remove(renderer_camera_handle).unwrap(); + Ok(()) + } + + // --- Other --- + + fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize) { + info!(LogContext::Rendering => "Resizing {} resources", "Renderer".module_object_style()); + self.state.resize(new_window_size) + } + + fn get_window(&self) -> Arc { + self.state.window.clone() + } + + fn render( + &mut self, + active_camera_entity_handle: EntityHandle, + scene: &crate::ecs::Scene, + globals: &pill_core::PillTypeMap, + delta_time: f32, + timer: &mut Timer, + resource_manager: &ResourceManager, + ) -> Result<()> { + debug!(LogContext::Frame => "Starting frame render"); + + timer.record("Get frame"); + + let frame = self.state.surface.get_current_texture(); + let frame = match frame { + std::result::Result::Ok(frame) => frame, + std::result::Result::Err(error) => match error { + wgpu::SurfaceError::Lost => return Err(RendererError::SurfaceLost.into()), + wgpu::SurfaceError::OutOfMemory => { + return Err(RendererError::SurfaceOutOfMemory.into()) + } + _ => return Err(RendererError::SurfaceOther.into()), + }, + }; + + let swapchain_view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + let mut encoder = + self.state + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("render_encoder"), + }); + + let world = WorldQuery::new( + active_camera_entity_handle, + scene, + globals, + delta_time, + resource_manager, + ); + + timer.begin_context("Scene Passes"); + + let mut passes = std::mem::take(&mut self.state.passes); + for pass in &mut passes { + timer.record(pass.get_label()); + pass.draw(&mut encoder, self, &frame, &swapchain_view, &world)?; + } + self.state.passes = passes; + + timer.end_context()?; + + timer.record("Submit commands and present frame"); + + self.state.frame_counter += 1; + let capture = self.state.screenshot.as_ref().and_then(|(target, path)| { + if self.state.frame_counter == *target { + Some(path.clone()) + } else { + None + } + }); + + let screenshot_buf = if let Some(ref _path) = capture { + let w = self.state.surface_configuration.width; + let h = self.state.surface_configuration.height; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes_per_row = (4 * w).div_ceil(align) * align; + let buf = self.state.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("screenshot_readback"), + size: (bytes_per_row * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + frame.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + Some((buf, w, h, bytes_per_row)) + } else { + None + }; + + self.state.queue.submit(std::iter::once(encoder.finish())); + + if let (Some((buf, w, h, bytes_per_row)), Some(path)) = (screenshot_buf, capture) { + let slice = buf.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + let _ = self.state.device.poll(wgpu::MaintainBase::Wait); + let data = slice.get_mapped_range(); + #[cfg(not(target_arch = "wasm32"))] + save_screenshot(&path, &data, w, h, bytes_per_row, self.state.color_format); + drop(data); + buf.unmap(); + self.state.screenshot = None; + } + + frame.present(); + + Ok(()) + } + + // --- Pass API --- + + fn set_passes(&mut self, mut passes: Vec>) -> Result<()> { + for pass in &mut passes { + pass.init(self)?; + } + self.state.passes = passes; + Ok(()) + } + + fn get_surface_size(&self) -> (u32, u32) { + ( + self.state.surface_configuration.width, + self.state.surface_configuration.height, + ) + } + + fn get_device(&self) -> &wgpu::Device { + &self.state.device + } + + fn get_queue(&self) -> &wgpu::Queue { + &self.state.queue + } + + fn get_surface_format(&self) -> wgpu::TextureFormat { + self.state.color_format + } + + fn get_engine_parameters(&self) -> &crate::renderer::resources::EngineParameters { + &self.state.engine_parameters + } + + fn get_camera_bind_group_layout(&self) -> wgpu::BindGroupLayout { + self.state.camera_bind_group_layout.clone() + } + + fn create_buffer(&mut self, desc: BufferDesc) -> Result { + let buffer = self.state.device.create_buffer(&wgpu::BufferDescriptor { + label: desc.label, + size: desc.byte_size, + usage: desc.usage, + mapped_at_creation: false, + }); + Ok(buffer) + } + + fn create_pipeline_v2(&mut self, desc: PipelineV2Desc) -> Result { + let bind_group_layouts: Vec = desc + .bind_groups + .iter() + .map(|entries| { + self.state + .device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: None, + entries, + }) + }) + .collect(); + + let bind_group_layout_refs: Vec<&wgpu::BindGroupLayout> = + bind_group_layouts.iter().collect(); + + let pipeline_layout = + self.state + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: desc.label, + bind_group_layouts: &bind_group_layout_refs, + push_constant_ranges: &[], + }); + + let vs_module = self + .state + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(desc.vs.entry_func), + source: wgpu::ShaderSource::Wgsl(desc.vs.source.into()), + }); + let fs_module = self + .state + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(desc.ps.entry_func), + source: wgpu::ShaderSource::Wgsl(desc.ps.source.into()), + }); + + let pipeline = self + .state + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: desc.label, + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &vs_module, + entry_point: Some(desc.vs.entry_func), + buffers: desc.vertex_buffers, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &fs_module, + entry_point: Some(desc.ps.entry_func), + targets: desc.targets, + compilation_options: Default::default(), + }), + primitive: desc.primitive, + depth_stencil: desc.depth_stencil, + multisample: desc.multisample, + multiview: None, + cache: None, + }); + + Ok(PipelineV2 { + pipeline, + bind_group_layouts, + }) + } + + fn create_render_target(&mut self, desc: RendererTargetDesc) -> Result { + let texture = RendererTexture::new_render_target( + &self.state.device, + &desc.name, + desc.width, + desc.height, + desc.format, + )?; + let handle = self.state.pass_textures.insert(texture); + Ok(handle) + } + + fn create_depth_texture(&mut self, label: &str) -> Result { + let texture = RendererTexture::new_depth_texture( + &self.state.device, + &self.state.surface_configuration, + label, + )?; + let handle = self.state.pass_textures.insert(texture); + Ok(handle) + } + + fn get_render_target_view(&self, handle: RendererTextureHandle) -> Option<&wgpu::TextureView> { + self.state + .pass_textures + .get(handle) + .map(|t| &t.texture_view) + } + + fn create_texture_from_pixels( + &mut self, + name: &str, + mip_pixels: &[&[u8]], + base_width: u32, + base_height: u32, + format: wgpu::TextureFormat, + ) -> RendererTextureHandle { + let texture = RendererTexture::new_from_pixels( + &self.state.device, + &self.state.queue, + name, + mip_pixels, + base_width, + base_height, + format, + ) + .expect("create_texture_from_pixels failed"); + self.state.pass_textures.insert(texture) + } + + fn get_texture_view(&self, handle: RendererTextureHandle) -> Option { + self.state.pass_textures.get(handle).map(|t| { + t.texture + .create_view(&wgpu::TextureViewDescriptor::default()) + }) + } +} + +pub struct State { + pub(crate) cameras: PillSlotMap, + pub(crate) engine_parameters: EngineParameters, + pass_textures: PillSlotMap, + surface: wgpu::Surface<'static>, + device: wgpu::Device, + queue: wgpu::Queue, + surface_configuration: wgpu::SurfaceConfiguration, + color_format: wgpu::TextureFormat, + depth_format: wgpu::TextureFormat, + depth_texture: RendererTexture, + passes: Vec>, + camera_bind_group_layout: wgpu::BindGroupLayout, + window: Arc, + // Optional frame capture: (target_frame, output_path). Set via PILL_SCREENSHOT env var. + screenshot: Option<(u32, String)>, + frame_counter: u32, +} + +impl State { + async fn new(window: Arc, config: EngineConfig) -> Result { + let window_width = config.get_int("WINDOW_WIDTH")? as u32; + let window_height = config.get_int("WINDOW_HEIGHT")? as u32; + let window_size = winit::dpi::PhysicalSize::new(window_width, window_height); + + // Create wgpu instance and window surface + let (instance, surface) = { + let backends = match std::env::var("WGPU_BACKENDS").as_deref() { + std::result::Result::Ok("VULKAN") => wgpu::Backends::VULKAN, + std::result::Result::Ok("DX12") => wgpu::Backends::DX12, + std::result::Result::Ok("METAL") => wgpu::Backends::METAL, + std::result::Result::Ok("GL") => wgpu::Backends::GL, + std::result::Result::Ok("BROWSER_WEBGPU") => wgpu::Backends::BROWSER_WEBGPU, + _ => wgpu::Backends::all(), + }; + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends, + flags: wgpu::InstanceFlags::from_build_config().with_env(), + backend_options: wgpu::BackendOptions::default(), + }); + let surface = instance.create_surface(window.clone()).map_err( + |error| -> pill_core::PillError { + pill_core::PillError::from(format!("Failed to create surface: {}", error)) + }, + )?; + (instance, surface) + }; + + // Select GPU adapter and request logical device + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::default(), + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + .map_err(|error| -> pill_core::PillError { + pill_core::PillError::from(format!("Failed to request adapter: {}", error)) + })?; + + let info = adapter.get_info(); + info!(LogContext::Rendering => "Using GPU: {} ({:?})", info.name, info.backend); + + let (device, queue) = { + let wanted = wgpu::Features::DEPTH_CLIP_CONTROL + | wgpu::Features::TIMESTAMP_QUERY + | wgpu::Features::PIPELINE_STATISTICS_QUERY + | wgpu::Features::FLOAT32_FILTERABLE; + let features = wanted & adapter.features(); + + adapter + .request_device(&wgpu::DeviceDescriptor { + label: None, + required_features: features, + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::default(), + }) + .await + .map_err(|error| -> pill_core::PillError { + pill_core::PillError::from(format!("Failed to request device: {}", error)) + })? + }; + + // Configure swap chain format, present mode, and depth texture + let (surface_configuration, color_format, depth_format) = { + let preferred_format = wgpu::TextureFormat::Rgba8UnormSrgb; + let surface_capabilities = surface.get_capabilities(&adapter); + + #[cfg(target_arch = "wasm32")] + let present_mode = wgpu::PresentMode::Fifo; + #[cfg(not(target_arch = "wasm32"))] + let present_mode = if surface_capabilities + .present_modes + .contains(&wgpu::PresentMode::Mailbox) + { + wgpu::PresentMode::Mailbox + } else if surface_capabilities + .present_modes + .contains(&wgpu::PresentMode::Immediate) + { + wgpu::PresentMode::Immediate + } else { + wgpu::PresentMode::Fifo + }; + + let format = if surface_capabilities.formats.contains(&preferred_format) { + preferred_format + } else if surface_capabilities + .formats + .contains(&wgpu::TextureFormat::Bgra8UnormSrgb) + { + wgpu::TextureFormat::Bgra8UnormSrgb + } else if surface_capabilities + .formats + .contains(&wgpu::TextureFormat::Bgra8Unorm) + { + wgpu::TextureFormat::Bgra8Unorm + } else { + surface_capabilities.formats[0] + }; + + let surface_configuration = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + format, + width: window_size.width, + height: window_size.height, + desired_maximum_frame_latency: 2, + present_mode, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: if format.is_srgb() { + vec![] + } else { + vec![format.add_srgb_suffix()] + }, + }; + surface.configure(&device, &surface_configuration); + let color_format = surface_configuration.format; + let depth_format = wgpu::TextureFormat::Depth32Float; + (surface_configuration, color_format, depth_format) + }; + + let depth_texture = + RendererTexture::new_depth_texture(&device, &surface_configuration, "depth_texture") + .map_err(|error| -> pill_core::PillError { + pill_core::PillError::from(format!("Failed to create depth texture: {}", error)) + })?; + + let camera_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("camera_parameters_bind_group_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let engine_parameters = EngineParameters::new(&device)?; + let cameras = PillSlotMap::with_capacity_and_key(10); + let pass_textures = PillSlotMap::with_capacity_and_key(10); + + let screenshot = std::env::var("PILL_SCREENSHOT").ok().map(|s| { + let frame = std::env::var("PILL_SCREENSHOT_FRAME") + .ok() + .and_then(|f| f.parse::().ok()) + .unwrap_or(10); + (frame, s) + }); + + Ok(Self { + cameras, + engine_parameters, + pass_textures, + surface, + device, + queue, + surface_configuration, + color_format, + depth_format, + depth_texture, + passes: Vec::new(), + camera_bind_group_layout, + window, + screenshot, + frame_counter: 0, + }) + } + + fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize) { + if new_window_size.width > 0 && new_window_size.height > 0 { + self.surface_configuration.width = new_window_size.width; + self.surface_configuration.height = new_window_size.height; + self.surface + .configure(&self.device, &self.surface_configuration); + self.depth_texture = RendererTexture::new_depth_texture( + &self.device, + &self.surface_configuration, + "depth_texture", + ) + .unwrap(); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn save_screenshot( + path: &str, + data: &[u8], + width: u32, + height: u32, + bytes_per_row: u32, + format: wgpu::TextureFormat, +) { + use std::io::BufWriter; + + let is_bgra = matches!( + format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb + ); + + let mut rgba: Vec = Vec::with_capacity((width * height * 4) as usize); + for row in 0..height { + let start = (row * bytes_per_row) as usize; + let row_data = &data[start..start + (width * 4) as usize]; + for pixel in row_data.chunks(4) { + if is_bgra { + rgba.extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); + } else { + rgba.extend_from_slice(pixel); + } + } + } + + let file = match std::fs::File::create(path) { + Ok(f) => f, + Err(e) => { + eprintln!("screenshot: cannot create {path}: {e}"); + return; + } + }; + let mut enc = png::Encoder::new(BufWriter::new(file), width, height); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + if let Ok(mut writer) = enc.write_header() { + let _ = writer.write_image_data(&rgba); + println!("screenshot saved: {path}"); + } +} diff --git a/engine/pill_engine/src/resources/gltf_scene.rs b/engine/pill_engine/src/resources/gltf_scene.rs new file mode 100644 index 00000000..970fe26e --- /dev/null +++ b/engine/pill_engine/src/resources/gltf_scene.rs @@ -0,0 +1,617 @@ +//! Runtime glTF loader (feature `gltf_loading`). +//! +//! The single wasm-safe path for loading a glTF/glb obtained as bytes at runtime, however you got +//! them: fetched (multi-file `.gltf` + external `.bin`/textures) or dropped/embedded (self-contained +//! `.glb`). Opt-in: games that cook-and-embed at build time don't enable it and pay no binary cost. +//! +//! Parsing uses `goth-gltf` (nanoserde, no serde_json) to keep the wasm binary small; it's a +//! low-level reader, so attribute/index accessors are read out of the buffers here. The +//! vertex/tangent/matrix math and `RMSH`/`RTEX` writers are shared with the build-time cooker via +//! `pill_assets::convert`. Textures decode with the lean `zune-jpeg`/`zune-png` codecs. The caller +//! fetches external resources (see [`gltf_resource_uris`]) and uploads the result with +//! `Mesh::from_cooked_mesh_bytes` / `Texture::from_bytes`. + +use goth_gltf::{ComponentType, NodeTransform}; +use pill_assets::convert; +use pill_core::{PillError, Result}; + +use crate::resources::TextureType; + +type Doc = goth_gltf::Gltf; + +/// A texture ready to upload via `Texture::from_bytes(name, kind, &rtex)`. +pub struct GltfTextureData { + pub name: String, + pub kind: TextureType, + pub rtex: Vec, +} + +/// A material's factors and texture references (indices into [`GltfSceneData::textures`]). +pub struct GltfMaterialData { + pub name: String, + pub albedo: Option, + pub normal: Option, + pub metallic_roughness: Option, + pub emissive: Option, + pub base_color: [f32; 4], + pub metallic: f32, + pub roughness: f32, +} + +/// A mesh primitive ready to upload via `Mesh::from_cooked_mesh_bytes(name, &rmsh)`. +pub struct GltfMeshData { + pub name: String, + pub rmsh: Vec, + pub material: usize, +} + +/// The whole scene, flattened: one mesh entry per primitive (world transform baked into vertices). +pub struct GltfSceneData { + pub textures: Vec, + pub materials: Vec, + pub meshes: Vec, +} + +/// External resources a glTF document references, in document (index) order. An entry is `None` +/// when nothing needs fetching: an embedded BIN buffer or a buffer-view image (both resolved from +/// the document). A self-contained `.glb` yields all-`None` — nothing to fetch. +pub struct GltfResourceUris { + pub buffers: Vec>, + pub images: Vec>, +} + +/// Lists external buffer/image URIs to fetch, in document order. URIs are returned verbatim +/// (relative to the `.gltf`); the caller resolves them against its base URL. +pub fn gltf_resource_uris(gltf_bytes: &[u8]) -> Result { + let (doc, _) = + Doc::from_bytes(gltf_bytes).map_err(|e| PillError::from(format!("parse glTF: {e:?}")))?; + Ok(GltfResourceUris { + buffers: doc.buffers.iter().map(|b| b.uri.clone()).collect(), + images: doc.images.iter().map(|i| i.uri.clone()).collect(), + }) +} + +/// Parses a glTF/glb into uploadable scene data. +/// +/// `external_buffers` / `external_images` are indexed 1:1 with the document's buffers/images (the +/// order from [`gltf_resource_uris`]); entries that were `None` there (embedded BIN, buffer-view +/// images) may be empty — they are resolved from the document. A self-contained `.glb` is parsed +/// with both slices empty. +pub fn parse_gltf_scene( + gltf_bytes: &[u8], + external_buffers: &[Vec], + external_images: &[Vec], +) -> Result { + use std::collections::HashMap; + + let (doc, blob) = + Doc::from_bytes(gltf_bytes).map_err(|e| PillError::from(format!("parse glTF: {e:?}")))?; + + // Resolve every buffer: embedded BIN blob, or caller-supplied external bytes. + let mut buffers: Vec<&[u8]> = Vec::with_capacity(doc.buffers.len()); + for (i, buffer) in doc.buffers.iter().enumerate() { + let bytes: &[u8] = match &buffer.uri { + Some(_) => external_buffers + .get(i) + .map(|v| v.as_slice()) + .ok_or_else(|| PillError::from(format!("missing fetched bytes for buffer {i}")))?, + None => blob.ok_or_else(|| { + PillError::from("glTF has an embedded buffer but no blob".to_string()) + })?, + }; + buffers.push(bytes); + } + + // Decode every image to RGBA8, indexed by glTF image index. + let mut decoded: Vec = Vec::with_capacity(doc.images.len()); + for (i, image) in doc.images.iter().enumerate() { + let encoded: &[u8] = if image.uri.is_some() { + external_images + .get(i) + .map(|v| v.as_slice()) + .filter(|v| !v.is_empty()) + .ok_or_else(|| PillError::from(format!("missing fetched bytes for image {i}")))? + } else if let Some(bv) = image.buffer_view { + let view = &doc.buffer_views[bv]; + &buffers[view.buffer][view.byte_offset..view.byte_offset + view.byte_length] + } else { + return Err(PillError::from(format!( + "image {i} has neither uri nor buffer view" + ))); + }; + decoded.push(decode_image(encoded)?); + } + + // Textures, deduped by (image index, type): the same image bound as color (sRGB) vs normal + // (linear) is a distinct GPU texture. + let mut textures: Vec = Vec::new(); + let mut tex_cache: HashMap<(usize, TextureType), usize> = HashMap::new(); + let image_of = |tex_index: usize| doc.textures[tex_index].source; + + let mut materials: Vec = Vec::with_capacity(doc.materials.len() + 1); + for (idx, material) in doc.materials.iter().enumerate() { + let pbr = &material.pbr_metallic_roughness; + let albedo = pbr + .base_color_texture + .as_ref() + .and_then(|t| image_of(t.index)) + .map(|img| { + intern_tex( + &mut textures, + &mut tex_cache, + &decoded, + img, + TextureType::Color, + ) + }); + let normal = material + .normal_texture + .as_ref() + .and_then(|t| image_of(t.index)) + .map(|img| { + intern_tex( + &mut textures, + &mut tex_cache, + &decoded, + img, + TextureType::Normal, + ) + }); + let metallic_roughness = pbr + .metallic_roughness_texture + .as_ref() + .and_then(|t| image_of(t.index)) + .map(|img| { + intern_tex( + &mut textures, + &mut tex_cache, + &decoded, + img, + TextureType::MetallicRoughness, + ) + }); + let emissive = material + .emissive_texture + .as_ref() + .and_then(|t| image_of(t.index)) + .map(|img| { + intern_tex( + &mut textures, + &mut tex_cache, + &decoded, + img, + TextureType::Emissive, + ) + }); + + materials.push(GltfMaterialData { + name: format!("gltf_mat_{idx}"), + albedo, + normal, + metallic_roughness, + emissive, + base_color: pbr.base_color_factor, + metallic: pbr.metallic_factor, + roughness: pbr.roughness_factor, + }); + } + + // Fallback material for primitives that reference no material. + let default_material = materials.len(); + materials.push(GltfMaterialData { + name: "gltf_mat_default".to_string(), + albedo: None, + normal: None, + metallic_roughness: None, + emissive: None, + base_color: [1.0; 4], + metallic: 0.0, + roughness: 0.5, + }); + + // Meshes: walk the scene graph, baking each node's world transform into the vertices, one RMSH + // blob per primitive. + let mut meshes: Vec = Vec::new(); + let scene = doc + .scenes + .get(doc.scene) + .or_else(|| doc.scenes.first()) + .ok_or_else(|| PillError::from("glTF has no scene".to_string()))?; + let mut stack: Vec<(usize, convert::Mat4)> = scene + .nodes + .iter() + .map(|&n| (n, convert::IDENTITY)) + .collect(); + while let Some((node_idx, parent)) = stack.pop() { + let node = &doc.nodes[node_idx]; + let world = convert::mat4_mul(parent, node_transform(node)); + if let Some(mesh_idx) = node.mesh { + for (prim_idx, prim) in doc.meshes[mesh_idx].primitives.iter().enumerate() { + let positions = to_vec3(read_floats(&doc, &buffers, prim.attributes.position)?); + let normals = to_vec3(read_floats(&doc, &buffers, prim.attributes.normal)?); + let uvs = to_vec2(read_floats(&doc, &buffers, prim.attributes.texcoord_0)?); + let tangents = match prim.attributes.tangent { + Some(acc) => to_vec4(read_floats_at(&doc, &buffers, acc)?), + None => Vec::new(), + }; + let indices = read_indices(&doc, &buffers, prim.indices)?; + + let vertices = + convert::build_vertices(&positions, &normals, &uvs, &tangents, &indices, world); + meshes.push(GltfMeshData { + name: format!("gltf_mesh_{node_idx}_{prim_idx}"), + rmsh: convert::rmsh_bytes(&vertices, &indices), + material: prim.material.unwrap_or(default_material), + }); + } + } + for &child in &node.children { + stack.push((child, world)); + } + } + + Ok(GltfSceneData { + textures, + materials, + meshes, + }) +} + +// --- Accessor reading (goth-gltf is low-level; we read the typed arrays out of the buffers) --- + +/// Reads a required float-attribute accessor (POSITION/NORMAL/TEXCOORD/...) as a flat `Vec`. +fn read_floats(doc: &Doc, buffers: &[&[u8]], accessor: Option) -> Result> { + let accessor = accessor + .ok_or_else(|| PillError::from("primitive missing a required attribute".to_string()))?; + read_floats_at(doc, buffers, accessor) +} + +fn read_floats_at(doc: &Doc, buffers: &[&[u8]], accessor_idx: usize) -> Result> { + let acc = &doc.accessors[accessor_idx]; + let components = acc.accessor_type.num_components(); + let (buf, base, stride, comp_size) = accessor_layout(doc, buffers, accessor_idx)?; + let mut out = Vec::with_capacity(acc.count * components); + for i in 0..acc.count { + let elem = base + i * stride; + for c in 0..components { + let o = elem + c * comp_size; + let value = match acc.component_type { + ComponentType::Float => f32::from_le_bytes(buf[o..o + 4].try_into().unwrap()), + ComponentType::UnsignedByte => norm_u(buf[o] as f32, 255.0, acc.normalized), + ComponentType::UnsignedShort => norm_u( + u16::from_le_bytes(buf[o..o + 2].try_into().unwrap()) as f32, + 65535.0, + acc.normalized, + ), + ComponentType::Byte => norm_s(buf[o] as i8 as f32, 127.0, acc.normalized), + ComponentType::Short => norm_s( + i16::from_le_bytes(buf[o..o + 2].try_into().unwrap()) as f32, + 32767.0, + acc.normalized, + ), + ComponentType::UnsignedInt => { + u32::from_le_bytes(buf[o..o + 4].try_into().unwrap()) as f32 + } + }; + out.push(value); + } + } + Ok(out) +} + +/// Reads an index accessor as `u32` (glTF allows u8/u16/u32 indices). +fn read_indices(doc: &Doc, buffers: &[&[u8]], accessor: Option) -> Result> { + let accessor_idx = + accessor.ok_or_else(|| PillError::from("primitive missing indices".to_string()))?; + let acc = &doc.accessors[accessor_idx]; + let (buf, base, stride, _) = accessor_layout(doc, buffers, accessor_idx)?; + let mut out = Vec::with_capacity(acc.count); + for i in 0..acc.count { + let o = base + i * stride; + let value = match acc.component_type { + ComponentType::UnsignedInt => u32::from_le_bytes(buf[o..o + 4].try_into().unwrap()), + ComponentType::UnsignedShort => { + u16::from_le_bytes(buf[o..o + 2].try_into().unwrap()) as u32 + } + ComponentType::UnsignedByte => buf[o] as u32, + other => { + return Err(PillError::from(format!( + "unsupported index component type {other:?}" + ))) + } + }; + out.push(value); + } + Ok(out) +} + +/// Resolves (buffer slice, byte base offset, element stride, component byte size) for an accessor. +fn accessor_layout<'a>( + doc: &Doc, + buffers: &[&'a [u8]], + accessor_idx: usize, +) -> Result<(&'a [u8], usize, usize, usize)> { + let acc = &doc.accessors[accessor_idx]; + let bv = acc + .buffer_view + .ok_or_else(|| PillError::from("accessor without a buffer view".to_string()))?; + let view = &doc.buffer_views[bv]; + let buf = *buffers + .get(view.buffer) + .ok_or_else(|| PillError::from("accessor references missing buffer".to_string()))?; + let comp_size = acc.component_type.byte_size(); + let element = acc.accessor_type.num_components() * comp_size; + let stride = view.byte_stride.unwrap_or(element); + Ok((buf, view.byte_offset + acc.byte_offset, stride, comp_size)) +} + +fn norm_u(v: f32, max: f32, normalized: bool) -> f32 { + if normalized { + v / max + } else { + v + } +} + +fn norm_s(v: f32, max: f32, normalized: bool) -> f32 { + if normalized { + // glTF signed-normalized dequant: max(c / MAX, -1.0). The divisor is the type's positive + // max, so values can't exceed +1.0 — only the low end needs the clamp (spec 3.6.2.1.2). + (v / max).max(-1.0) + } else { + v + } +} + +fn to_vec3(flat: Vec) -> Vec<[f32; 3]> { + flat.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect() +} +fn to_vec2(flat: Vec) -> Vec<[f32; 2]> { + flat.chunks_exact(2).map(|c| [c[0], c[1]]).collect() +} +fn to_vec4(flat: Vec) -> Vec<[f32; 4]> { + flat.chunks_exact(4) + .map(|c| [c[0], c[1], c[2], c[3]]) + .collect() +} + +/// Node local transform → row-major Mat4 (matching `pill_assets::convert`). +fn node_transform( + node: &goth_gltf::Node, +) -> convert::Mat4 { + match node.transform() { + NodeTransform::Matrix(m) => { + // glTF matrices are column-major; group into columns for mat4_from_cols. + convert::mat4_from_cols([ + [m[0], m[1], m[2], m[3]], + [m[4], m[5], m[6], m[7]], + [m[8], m[9], m[10], m[11]], + [m[12], m[13], m[14], m[15]], + ]) + } + NodeTransform::Set { + translation: [tx, ty, tz], + rotation: [x, y, z, w], + scale: [sx, sy, sz], + } => { + // T * R(quat) * S, row-major. + let r00 = 1.0 - 2.0 * (y * y + z * z); + let r01 = 2.0 * (x * y - z * w); + let r02 = 2.0 * (x * z + y * w); + let r10 = 2.0 * (x * y + z * w); + let r11 = 1.0 - 2.0 * (x * x + z * z); + let r12 = 2.0 * (y * z - x * w); + let r20 = 2.0 * (x * z - y * w); + let r21 = 2.0 * (y * z + x * w); + let r22 = 1.0 - 2.0 * (x * x + y * y); + [ + [r00 * sx, r01 * sy, r02 * sz, tx], + [r10 * sx, r11 * sy, r12 * sz, ty], + [r20 * sx, r21 * sy, r22 * sz, tz], + [0.0, 0.0, 0.0, 1.0], + ] + } + } +} + +// --- Texture decode (lean zune codecs) --- + +struct DecodedImage { + rgba: Vec, + w: u32, + h: u32, +} + +fn decode_image(bytes: &[u8]) -> Result { + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + decode_jpeg(bytes) + } else if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) { + decode_png(bytes) + } else { + Err(PillError::from( + "unsupported texture image (expected JPEG or PNG)".to_string(), + )) + } +} + +fn decode_jpeg(bytes: &[u8]) -> Result { + let mut decoder = zune_jpeg::JpegDecoder::new(bytes); + let pixels = decoder + .decode() + .map_err(|e| PillError::from(format!("decode JPEG: {e:?}")))?; + let (w, h) = decoder + .dimensions() + .ok_or_else(|| PillError::from("JPEG has no dimensions".to_string()))?; + let components = decoder + .get_output_colorspace() + .map(|c| c.num_components()) + .unwrap_or(3); + Ok(DecodedImage { + rgba: expand_to_rgba(pixels, components), + w: w as u32, + h: h as u32, + }) +} + +fn decode_png(bytes: &[u8]) -> Result { + use zune_png::zune_core::result::DecodingResult; + let mut decoder = zune_png::PngDecoder::new(bytes); + let result = decoder + .decode() + .map_err(|e| PillError::from(format!("decode PNG: {e:?}")))?; + let (w, h) = decoder + .get_dimensions() + .ok_or_else(|| PillError::from("PNG has no dimensions".to_string()))?; + let components = decoder + .get_colorspace() + .map(|c| c.num_components()) + .unwrap_or(4); + let px8 = match result { + DecodingResult::U8(v) => v, + _ => return Err(PillError::from("16-bit PNG not supported".to_string())), + }; + Ok(DecodedImage { + rgba: expand_to_rgba(px8, components), + w: w as u32, + h: h as u32, + }) +} + +/// Expands decoded pixels (luma / luma-alpha / RGB / RGBA) to RGBA8. Takes ownership so the +/// already-RGBA case (the common one) is a move, not a full-image copy. +fn expand_to_rgba(pixels: Vec, components: usize) -> Vec { + match components { + 3 => pixels + .chunks_exact(3) + .flat_map(|p| [p[0], p[1], p[2], 255]) + .collect(), + 2 => pixels + .chunks_exact(2) + .flat_map(|p| [p[0], p[0], p[0], p[1]]) + .collect(), + 1 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(), + _ => pixels, // already RGBA (4) or unknown — pass through + } +} + +fn intern_tex( + textures: &mut Vec, + cache: &mut std::collections::HashMap<(usize, TextureType), usize>, + images: &[DecodedImage], + image_idx: usize, + kind: TextureType, +) -> usize { + if let Some(&existing) = cache.get(&(image_idx, kind)) { + return existing; + } + let role = match kind { + TextureType::Color => "albedo", + TextureType::Normal => "normal", + TextureType::MetallicRoughness => "metallic_roughness", + TextureType::Emissive => "emissive", + }; + let di = &images[image_idx]; + let idx = textures.len(); + textures.push(GltfTextureData { + name: format!("gltf_{role}_{image_idx}"), + kind, + rtex: convert::rtex_bytes(di.w, di.h, &di.rgba), + }); + cache.insert((image_idx, kind), idx); + idx +} + +#[cfg(test)] +mod tests { + use super::*; + + // Reads back the position + texcoord of vertex `i` from an RMSH blob. + // RMSH = b"RMSH" + u32 ver + u32 vcount + u32 icount + CookedVertex[]; CookedVertex is + // position[3] + texture_coordinates[2] + normal[3] + tangent[3] + bitangent[3] = 14 f32. + fn vertex(rmsh: &[u8], i: usize) -> ([f32; 3], [f32; 2]) { + let base = 16 + i * 14 * 4; + let f = + |o: usize| f32::from_le_bytes(rmsh[base + o * 4..base + o * 4 + 4].try_into().unwrap()); + ([f(0), f(1), f(2)], [f(3), f(4)]) + } + + // Exercises the paths Sponza never hits: a node `matrix` transform, u32 indices, and a + // normalized-u8 TEXCOORD. Expected values are computed by hand. + #[test] + fn parses_matrix_node_u32_indices_normalized_uv() { + // Buffer: positions(3×vec3 f32) | normals(3×vec3 f32) | uv(3×vec2 u8) | pad | indices(3×u32) + let mut buf = Vec::new(); + for p in [[1f32, 0., 0.], [0., 1., 0.], [0., 0., 1.]] { + for v in p { + buf.extend_from_slice(&v.to_le_bytes()); + } + } + for _ in 0..3 { + for v in [0f32, 0., 1.] { + buf.extend_from_slice(&v.to_le_bytes()); + } + } + buf.extend_from_slice(&[0, 0, 255, 0, 0, 255]); // uv u8: (0,0) (255,0) (0,255) + buf.extend_from_slice(&[0, 0]); // pad to 4-byte alignment for u32 indices + for i in [0u32, 1, 2] { + buf.extend_from_slice(&i.to_le_bytes()); + } + assert_eq!(buf.len(), 92); + + // Node matrix (column-major): scale 2 + translate (10,20,30). + let json = r#"{ + "asset":{"version":"2.0"},"scene":0,"scenes":[{"nodes":[0]}], + "nodes":[{"mesh":0,"matrix":[2,0,0,0, 0,2,0,0, 0,0,2,0, 10,20,30,1]}], + "meshes":[{"primitives":[{"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2},"indices":3}]}], + "buffers":[{"uri":"b.bin","byteLength":92}], + "bufferViews":[ + {"buffer":0,"byteOffset":0,"byteLength":36}, + {"buffer":0,"byteOffset":36,"byteLength":36}, + {"buffer":0,"byteOffset":72,"byteLength":6}, + {"buffer":0,"byteOffset":80,"byteLength":12}], + "accessors":[ + {"bufferView":0,"componentType":5126,"type":"VEC3","count":3}, + {"bufferView":1,"componentType":5126,"type":"VEC3","count":3}, + {"bufferView":2,"componentType":5121,"type":"VEC2","count":3,"normalized":true}, + {"bufferView":3,"componentType":5125,"type":"SCALAR","count":3}], + "materials":[],"textures":[],"images":[] + }"#; + + let scene = parse_gltf_scene(json.as_bytes(), &[buf], &[]).expect("parse"); + assert_eq!(scene.meshes.len(), 1); + let rmsh = &scene.meshes[0].rmsh; + assert_eq!(&rmsh[0..4], b"RMSH"); + assert_eq!(u32::from_le_bytes(rmsh[8..12].try_into().unwrap()), 3); // vertex count + assert_eq!(u32::from_le_bytes(rmsh[12..16].try_into().unwrap()), 3); // index count + + let approx = |a: [f32; 3], b: [f32; 3]| (0..3).all(|k| (a[k] - b[k]).abs() < 1e-4); + // matrix: world = scale 2 then translate (10,20,30) + let (p0, uv0) = vertex(rmsh, 0); + let (p1, uv1) = vertex(rmsh, 1); + let (p2, uv2) = vertex(rmsh, 2); + assert!(approx(p0, [12.0, 20.0, 30.0]), "p0 = {p0:?}"); + assert!(approx(p1, [10.0, 22.0, 30.0]), "p1 = {p1:?}"); + assert!(approx(p2, [10.0, 20.0, 32.0]), "p2 = {p2:?}"); + // normalized u8 → [0,1] + assert!( + (uv0[0]).abs() < 1e-4 && (uv0[1]).abs() < 1e-4, + "uv0 = {uv0:?}" + ); + assert!( + (uv1[0] - 1.0).abs() < 1e-4 && uv1[1].abs() < 1e-4, + "uv1 = {uv1:?}" + ); + assert!( + uv2[0].abs() < 1e-4 && (uv2[1] - 1.0).abs() < 1e-4, + "uv2 = {uv2:?}" + ); + + // indices preserved (u32 path) + let icount = 3usize; + let ibase = 16 + 3 * 14 * 4; + let indices: Vec = (0..icount) + .map(|i| u32::from_le_bytes(rmsh[ibase + i * 4..ibase + i * 4 + 4].try_into().unwrap())) + .collect(); + assert_eq!(indices, vec![0, 1, 2]); + } +} diff --git a/engine/pill_engine/src/resources/material.rs b/engine/pill_engine/src/resources/material.rs index 2d0b7287..92f143e3 100644 --- a/engine/pill_engine/src/resources/material.rs +++ b/engine/pill_engine/src/resources/material.rs @@ -2,10 +2,11 @@ use crate::{ config::*, ecs::{ DeferredUpdateComponent, DeferredUpdateManagerPointer, DeferredUpdateResourceRequest, - MeshRenderingComponent, + PbrRenderableComponent, }, engine::Engine, - graphics::{RendererMaterialHandle, RendererTextureHandle, RENDER_QUEUE_KEY_ORDER}, + graphics::RENDER_QUEUE_KEY_ORDER, + renderer::resources::{RendererMaterial, RendererShader, RendererTexture}, resources::{Resource, ResourceStorage, Shader, ShaderHandle, Texture, TextureHandle}, }; @@ -23,6 +24,172 @@ const DEFERRED_REQUEST_VARIANT_PARAMETER: usize = 1; const DEFERRED_REQUEST_VARIANT_TEXTURE_START: usize = 2; const DEFERRED_REQUEST_VARIANT_TEXTURE_END: usize = 10; +// --- PBRMaterial --- + +pill_core::define_new_pill_slotmap_key! { + pub struct PBRMaterialHandle; +} + +#[readonly::make] +pub struct PBRMaterial { + #[readonly] + pub name: String, + pub albedo: Color, + pub metallic: f32, + pub roughness: f32, + pub emissive: Color, + pub albedo_texture: Option, + pub normal_texture: Option, + pub metallic_roughness_texture: Option, + pub emissive_texture: Option, + handle: Option, +} + +impl PBRMaterial { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + albedo: Color::new(1.0, 1.0, 1.0), + metallic: 0.0, + roughness: 0.5, + emissive: Color::new(0.0, 0.0, 0.0), + albedo_texture: None, + normal_texture: None, + metallic_roughness_texture: None, + emissive_texture: None, + handle: None, + } + } + + pub fn albedo(mut self, color: Color) -> Self { + self.albedo = color; + self + } + + pub fn roughness(mut self, roughness: f32) -> Self { + self.roughness = roughness.clamp(0.0, 1.0); + self + } + + pub fn albedo_texture(mut self, handle: TextureHandle) -> Self { + self.albedo_texture = Some(handle); + self + } + + pub fn metallic(mut self, metallic: f32) -> Self { + self.metallic = metallic.clamp(0.0, 1.0); + self + } + + pub fn normal_texture(mut self, handle: TextureHandle) -> Self { + self.normal_texture = Some(handle); + self + } + + pub fn metallic_roughness_texture(mut self, handle: TextureHandle) -> Self { + self.metallic_roughness_texture = Some(handle); + self + } + + pub fn emissive_texture(mut self, handle: TextureHandle) -> Self { + self.emissive_texture = Some(handle); + self + } +} + +impl PillTypeMapKey for PBRMaterial { + type Storage = ResourceStorage; +} + +impl Resource for PBRMaterial { + type Handle = PBRMaterialHandle; + + fn get_name(&self) -> String { + self.name.clone() + } + + fn initialize(&mut self, engine: &mut Engine) -> Result<()> { + #[cfg(not(feature = "headless"))] + { + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + DEFAULT_LIT_SHADER_TINT_PARAMETER_SLOT_NAME.to_string(), + MaterialParameter::Color(self.albedo), + ); + parameters.insert( + DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME.to_string(), + MaterialParameter::Scalar(1.0 - self.roughness), + ); + parameters.insert( + DEFAULT_LIT_SHADER_METALLIC_FACTOR_PARAMETER_SLOT_NAME.to_string(), + MaterialParameter::Scalar(self.metallic), + ); + + let mut resolved_textures: HashMap = + HashMap::new(); + + for (opt_handle, slot_name) in [ + ( + self.albedo_texture, + DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME, + ), + ( + self.normal_texture, + DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME, + ), + ( + self.metallic_roughness_texture, + DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME, + ), + ( + self.emissive_texture, + DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_NAME, + ), + ] { + if let Some(texture_handle) = opt_handle { + let tex_name = engine + .resource_manager + .get_resource::(&texture_handle)? + .name + .clone(); + let renderer_texture_handle = engine + .resource_manager + .get_resource_handle::(&tex_name)?; + resolved_textures.insert(slot_name.to_string(), renderer_texture_handle); + } + } + + let renderer_shader_handle = engine + .resource_manager + .get_resource_handle::(DEFAULT_LIT_SHADER_NAME)?; + + let renderer_material = RendererMaterial::new( + engine.renderer.get_device(), + engine.renderer.get_queue(), + &engine.resource_manager, + &self.name, + renderer_shader_handle, + &resolved_textures, + ¶meters, + )?; + engine.resource_manager.add_resource(renderer_material)?; + } + Ok(()) + } + + fn pass_handle(&mut self, self_handle: H) { + self.handle = Some(PBRMaterialHandle::from(self_handle.data())); + } + + fn destroy(&mut self, engine: &mut Engine, _self_handle: H) -> Result<()> { + #[cfg(not(feature = "headless"))] + engine + .resource_manager + .remove_resource_by_name::(&self.name)?; + Ok(()) + } +} + // --- Material parameters --- #[derive(Debug)] @@ -36,28 +203,15 @@ pub enum MaterialParameter { #[derive(Clone)] pub struct MaterialTexture { - //pub texture_type: TextureType, pub texture_handle: TextureHandle, - pub(crate) renderer_resource_handle: Option, } impl MaterialTexture { pub fn new(texture_handle: TextureHandle) -> Self { - Self { - //texture_type, - texture_handle, - renderer_resource_handle: None, - } + Self { texture_handle } } } -// This needed so that renderer can get renderer texture handle from material texture while it is still hidden in game API -pub fn get_renderer_texture_handle_from_material_texture( - material_texture: &MaterialTexture, -) -> &Option { - &material_texture.renderer_resource_handle -} - // --- Builder --- pub struct MaterialBuilder { @@ -133,7 +287,6 @@ pub struct Material { #[readonly] pub rendering_order: u8, - pub(crate) renderer_resource_handle: Option, shader_name: Option, handle: Option, deferred_update_manager: Option, @@ -144,7 +297,6 @@ impl Material { MaterialBuilder::new(name) } - // Creates default lit material with default shader and textures pub fn new(name: &str) -> Self { Self { name: name.to_string(), @@ -153,7 +305,6 @@ impl Material { //textures_mapping: Vec::new(), parameters: HashMap::new(), rendering_order: RENDER_QUEUE_KEY_ORDER.max as u8, - renderer_resource_handle: None, shader_name: None, handle: None, deferred_update_manager: None, @@ -181,35 +332,14 @@ impl Material { // Set new handle but not renderer resource handle (it will be set by deferred update system) // l//et _ = texture_slot.texture_handle = texture_handle; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + // Post deferred update request (only if deferred_update_manager is set (it means that material is initialized)) + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_TEXTURE_START + slot_index); - // texture_slot_index); } Ok(()) } - // pub fn remove_texture(&mut self, slot_name: &str) -> Result<()> { - // // Get texture slot - // let texture_slot = self.textures.data.get_mut(slot_name) - // .ok_or( EngineError::MaterialTextureSlotNotFound(slot_name.to_string(), self.name.to_string()).into())?; - - // // Get texture slot index - // let texture_slot_index = self.textures.mapping.iter().position(|v| v == slot_name).expect("Critical: No mapping"); - - // // Set new handle and renderer resource handle - // texture_slot.texture_handle = None; - // texture_slot.renderer_texture_handle = None; - - // // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - // if self.renderer_resource_handle.is_some() { - // self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_TEXTURE_START + texture_slot_index); - // } - - // Ok(()) - // } - pub fn set_rendering_order(&mut self, order: u8) -> Result<()> { let error = EngineError::WrongRenderingOrder( order.to_string(), @@ -217,14 +347,12 @@ impl Material { ); if order < RENDER_QUEUE_KEY_ORDER.max as u8 { self.rendering_order = order; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_RENDERING_ORDER); } } else { return Err(error.into()); } - Ok(()) } @@ -281,8 +409,7 @@ impl Material { MaterialParameter::Scalar(v) => { if *v != value { *v = value; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_PARAMETER); } } @@ -306,8 +433,7 @@ impl Material { MaterialParameter::Bool(v) => { if *v != value { *v = value; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_PARAMETER); } } @@ -329,7 +455,6 @@ impl Material { .context(error.clone())?; match parameter { MaterialParameter::Color(v) => { - // Clamp color channel values between 0.0 and 1.0 let valid_color = Color::new( value.x.clamp(0.0, 1.0), value.y.clamp(0.0, 1.0), @@ -337,8 +462,7 @@ impl Material { ); if *v != valid_color { *v = valid_color; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_PARAMETER); } } @@ -356,19 +480,16 @@ impl Material { ) -> Result<()> { let shader = engine.get_resource::(&self.shader_handle)?; - // Get texture to be set let texture = engine .get_resource::(&texture_slot.texture_handle) .context(EngineError::InvalidTextureHandleForSlot( texture_slot_name.to_string(), ))?; - // Get texture slot from shader let shader_texture_slot = shader.texture_slots.get(texture_slot_name).context( EngineError::MaterialTextureSlotNotFound(texture_slot_name.to_string()), )?; - // Check if slots are of the same type if !enum_variant_eq(&texture.texture_type, &shader_texture_slot.texture_type) { return Err(EngineError::WrongTextureType( get_enum_variant_type_name(&texture.texture_type), @@ -405,39 +526,52 @@ impl Resource for Material { } fn initialize(&mut self, engine: &mut Engine) -> Result<()> { - // This resource is using DeferredUpdateSystem so keep DeferredUpdateManager let deferred_update_component = engine .get_global_component_mut::() .expect("Critical: No DeferredUpdateComponent"); self.deferred_update_manager = Some(deferred_update_component.borrow_deferred_update_manager()); - // Check if assigned textures are of correct type declared in shader for (texture_slot_name, texture_slot) in self.textures.iter() { self.validate_texture(engine, texture_slot_name, texture_slot)?; } - // Assign renderer resource handle to texture slot - for (texture_slot_name, texture_slot) in self.textures.iter_mut() { - let texture = engine - .get_resource::(&texture_slot.texture_handle) - .context(EngineError::InvalidTextureHandleForSlot( - texture_slot_name.to_string(), - ))?; - texture_slot.renderer_resource_handle = Some(texture.renderer_resource_handle.unwrap()); - } + #[cfg(not(feature = "headless"))] + { + let shader = engine.get_resource::(&self.shader_handle)?; + self.shader_name = Some(shader.name.clone()); + let shader_name = shader.name.clone(); + + let mut resolved_textures: HashMap = + HashMap::new(); + for (slot_name, tex_slot) in self.textures.iter() { + let tex_name = engine + .resource_manager + .get_resource::(&tex_slot.texture_handle) + .context(EngineError::InvalidTextureHandleForSlot(slot_name.clone()))? + .name + .clone(); + let renderer_tex_handle = engine + .resource_manager + .get_resource_handle::(&tex_name)?; + resolved_textures.insert(slot_name.clone(), renderer_tex_handle); + } - // Create new renderer material resource - let shader = engine.get_resource::(&self.shader_handle)?; - self.shader_name = Some(shader.get_name()); - let shader_renderer_resource_handle = shader.renderer_resource_handle.unwrap(); - let renderer_resource_handle = engine.renderer.create_material( - &self.name, - shader_renderer_resource_handle, - &self.textures, - &self.parameters, - )?; - self.renderer_resource_handle = Some(renderer_resource_handle); + let renderer_shader_handle = engine + .resource_manager + .get_resource_handle::(&shader_name)?; + + let renderer_material = RendererMaterial::new( + engine.renderer.get_device(), + engine.renderer.get_queue(), + &engine.resource_manager, + &self.name, + renderer_shader_handle, + &resolved_textures, + &self.parameters, + )?; + engine.resource_manager.add_resource(renderer_material)?; + } Ok(()) } @@ -449,15 +583,13 @@ impl Resource for Material { fn deferred_update(&mut self, engine: &mut Engine, request: usize) -> Result<()> { match request { DEFERRED_REQUEST_VARIANT_RENDERING_ORDER => { - // Find mesh rendering components that use this material and update them for (_scene_handle, scene) in engine.scene_manager.scenes.iter_mut() { - for (_entity_handle, mesh_rendering_component) in - scene.get_one_component_iterator_mut::()? + for (_entity_handle, pbr_renderable_component) in + scene.get_one_component_iterator_mut::()? { - if let Some(material_handle) = mesh_rendering_component.material_handle { - // If mesh rendering component has handle to this material + if let Some(material_handle) = pbr_renderable_component.material_handle { if material_handle.data() == self.handle.unwrap().data() { - mesh_rendering_component + pbr_renderable_component .update_render_queue_key(&engine.resource_manager) .unwrap(); } @@ -466,11 +598,32 @@ impl Resource for Material { } } DEFERRED_REQUEST_VARIANT_PARAMETER => { - // Update renderer counterpart - engine.renderer.update_material_parameters( - self.renderer_resource_handle.unwrap(), - &self.parameters, - )?; + #[cfg(not(feature = "headless"))] + { + let mat_name = self.name.clone(); + let parameter_slots = { + let renderer_mat = engine + .resource_manager + .get_resource_by_name::(&mat_name)?; + let shader = engine + .resource_manager + .get_resource::(&renderer_mat.shader_handle)?; + shader.parameter_slots.clone() + }; + + let queue = engine.renderer.get_queue(); + let renderer_mat = engine + .resource_manager + .get_resource_by_name_mut::(&mat_name)?; + if let Some(ref buffer) = renderer_mat.parameters_uniform_buffer { + RendererMaterial::write_parameters_to_buffer( + queue, + buffer, + ¶meter_slots, + &self.parameters, + )?; + } + } } DEFERRED_REQUEST_VARIANT_TEXTURE_START..=DEFERRED_REQUEST_VARIANT_TEXTURE_END => { // Check if assigned texture is of correct type @@ -478,21 +631,41 @@ impl Resource for Material { let (texture_slot_name, texture_slot) = &self.textures[idx]; self.validate_texture(engine, texture_slot_name, texture_slot)?; - // Assign renderer resource handle to texture slot - let (texture_slot_name, texture_slot) = &mut self.textures[idx]; - let texture = engine - .get_resource::(&texture_slot.texture_handle) - .context(EngineError::InvalidTextureHandleForSlot( - texture_slot_name.to_string(), - ))?; - texture_slot.renderer_resource_handle = - Some(texture.renderer_resource_handle.unwrap()); - - // Update renderer counterpart - engine.renderer.update_material_textures( - self.renderer_resource_handle.unwrap(), - &self.textures, - )?; + #[cfg(not(feature = "headless"))] + { + let mat_name = self.name.clone(); + let new_bind_group = { + let renderer_mat = engine + .resource_manager + .get_resource_by_name::(&mat_name)?; + let renderer_shader = engine + .resource_manager + .get_resource::(&renderer_mat.shader_handle)?; + let mut resolved: HashMap = + HashMap::new(); + for (slot_name, mat_tex) in &self.textures { + let tex = engine + .resource_manager + .get_resource::(&mat_tex.texture_handle)?; + let h = engine + .resource_manager + .get_resource_handle::(&tex.name)?; + resolved.insert(slot_name.clone(), h); + } + RendererMaterial::create_textures_bind_group( + engine.renderer.get_device(), + &engine.resource_manager, + renderer_shader.textures_bind_group_layout.as_ref().unwrap(), + &format!("{}_textures", mat_name), + &renderer_shader.texture_slots, + &resolved, + )? + }; + engine + .resource_manager + .get_resource_by_name_mut::(&mat_name)? + .textures_bind_group = Some(new_bind_group); + } } _ => { panic!("Critical: Processing deferred update request with value {} in {} failed. Handling is not implemented", request, get_type_name::().specific_object_style()); @@ -503,27 +676,10 @@ impl Resource for Material { } fn destroy(&mut self, engine: &mut Engine, _self_handle: H) -> Result<()> { - // Destroy renderer resource - if let Some(v) = self.renderer_resource_handle { - engine.renderer.destroy_material(v).unwrap(); - } - - // Find mesh rendering components that use this material and update them - - //for (_scene_handle, _scene) in engine.scene_manager.scenes.iter_mut() { - // let x = &engine.resource_manager; - - // for (entity_handle, mesh_rendering_component) in engine.iterate_one_component::()? { - // if let Some(material_handle) = mesh_rendering_component.material_handle { - // // If mesh rendering component has handle to this material - // if material_handle.data() == self_handle.data() { - // mesh_rendering_component.set_material_handle(Option::::None); - // mesh_rendering_component.update_render_queue_key(&engine.resource_manager).unwrap(); - // } - // } - // } - //} - + #[cfg(not(feature = "headless"))] + engine + .resource_manager + .remove_resource_by_name::(&self.name)?; Ok(()) } } diff --git a/engine/pill_engine/src/resources/mesh.rs b/engine/pill_engine/src/resources/mesh.rs index ae6daf0d..c2fed192 100644 --- a/engine/pill_engine/src/resources/mesh.rs +++ b/engine/pill_engine/src/resources/mesh.rs @@ -1,7 +1,7 @@ use crate::{ - ecs::MeshRenderingComponent, + ecs::{MeshComponent, PbrRenderableComponent}, engine::Engine, - graphics::RendererMeshHandle, + renderer::resources::RendererMesh, resources::{Resource, ResourceStorage}, }; @@ -31,7 +31,6 @@ pub struct Mesh { pub name: String, #[readonly] pub path: PathBuf, - pub(crate) renderer_resource_handle: Option, mesh_data: Option, // When exporting from Blender, V coordinate is flipped, so we need to flip it back @@ -45,7 +44,6 @@ impl Mesh { Self { name: name.to_string(), path, - renderer_resource_handle: None, mesh_data: None, flip_uv_y: false, } @@ -60,7 +58,6 @@ impl Mesh { Self { name: name.to_string(), path: PathBuf::new(), - renderer_resource_handle: None, mesh_data: Some(mesh_data), flip_uv_y: false, } @@ -170,34 +167,47 @@ impl Resource for Mesh { } } - // Create new renderer mesh resource - let renderer_resource_handle = engine - .renderer - .create_mesh(&self.name, self.mesh_data.as_ref().unwrap()) - .context(error_message.clone())?; - self.renderer_resource_handle = Some(renderer_resource_handle); + #[cfg(not(feature = "headless"))] + { + let renderer_mesh = RendererMesh::new( + engine.renderer.get_device(), + &self.name, + self.mesh_data.as_ref().unwrap(), + ) + .context(error_message)?; + engine.resource_manager.add_resource(renderer_mesh)?; + } Ok(()) } fn destroy(&mut self, engine: &mut Engine, self_handle: H) -> Result<()> { - // Destroy renderer resource - if let Some(v) = self.renderer_resource_handle { - engine.renderer.destroy_mesh(v).unwrap(); - } + // Destroy renderer counterpart + #[cfg(not(feature = "headless"))] + engine + .resource_manager + .remove_resource_by_name::(&self.name)?; - // Find mesh rendering components that use this mesh and update them + // Null out any MeshComponent or PbrRenderableComponent that references this mesh for (_scene_handle, scene) in engine.scene_manager.scenes.iter_mut() { - for (_entity_handle, mesh_rendering_component) in - scene.get_one_component_iterator_mut::()? - { - if let Some(mesh_handle) = mesh_rendering_component.mesh_handle { - // If mesh rendering component has handle to this mesh - if mesh_handle.data() == self_handle.data() { - mesh_rendering_component.set_mesh_handle(Option::::None); - mesh_rendering_component - .update_render_queue_key(&engine.resource_manager) - .unwrap(); + if let Ok(iter) = scene.get_one_component_iterator_mut::() { + for (_entity_handle, mesh_component) in iter { + if let Some(mesh_handle) = mesh_component.mesh_handle { + if mesh_handle.data() == self_handle.data() { + mesh_component.set_mesh_handle(Option::::None); + } + } + } + } + if let Ok(iter) = scene.get_one_component_iterator_mut::() { + for (_entity_handle, pbr_renderable_component) in iter { + if let Some(mesh_handle) = pbr_renderable_component.mesh_handle { + if mesh_handle.data() == self_handle.data() { + pbr_renderable_component.set_mesh_handle(Option::::None); + pbr_renderable_component + .update_render_queue_key(&engine.resource_manager) + .unwrap(); + } } } } diff --git a/engine/pill_engine/src/resources/mod.rs b/engine/pill_engine/src/resources/mod.rs index 3b2d8750..d54c8197 100644 --- a/engine/pill_engine/src/resources/mod.rs +++ b/engine/pill_engine/src/resources/mod.rs @@ -1,5 +1,7 @@ #![cfg_attr(debug_assertions, allow(dead_code, unused_imports, unused_variables))] +#[cfg(feature = "gltf_loading")] +mod gltf_scene; mod material; mod mesh; mod resource; @@ -23,11 +25,16 @@ pub use sound::{Sound, SoundHandle}; pub use mesh::{Mesh, MeshData, MeshHandle, MeshVertex}; +#[cfg(feature = "gltf_loading")] +pub use gltf_scene::{ + gltf_resource_uris, parse_gltf_scene, GltfMaterialData, GltfMeshData, GltfResourceUris, + GltfSceneData, GltfTextureData, +}; + pub use texture::{Texture, TextureHandle, TextureType}; pub use material::{ - get_renderer_texture_handle_from_material_texture, Material, MaterialHandle, MaterialParameter, - MaterialTexture, + Material, MaterialHandle, MaterialParameter, MaterialTexture, PBRMaterial, PBRMaterialHandle, }; pub use shader::{ diff --git a/engine/pill_engine/src/resources/shader.rs b/engine/pill_engine/src/resources/shader.rs index 21f1fddb..685046c6 100644 --- a/engine/pill_engine/src/resources/shader.rs +++ b/engine/pill_engine/src/resources/shader.rs @@ -1,11 +1,10 @@ use crate::{ ecs::DeferredUpdateManagerPointer, engine::Engine, + renderer::resources::RendererShader, resources::{Resource, ResourceLoader, ResourceStorage, TextureType}, }; -use crate::internal::RendererShaderHandle; - use pill_core::{get_type_name, PillSlotMapKey, PillStyle, PillTypeMapKey}; use std::{collections::HashMap, path::Path}; @@ -96,7 +95,6 @@ pub struct Shader { #[readonly] pub enable_camera_binding: bool, - pub(crate) renderer_resource_handle: Option, handle: Option, deferred_update_manager: Option, } @@ -122,7 +120,6 @@ impl Shader { texture_slots, enable_engine_binding, enable_camera_binding, - renderer_resource_handle: None, handle: None, deferred_update_manager: None, } @@ -151,12 +148,6 @@ impl Resource for Shader { get_type_name::().specific_object_style() ); - // This resource is using DeferredUpdateSystem so keep DeferredUpdateManager - //let deferred_update_component = engine.get_global_component_mut::().expect("Critical: No DeferredUpdateComponent"); - //self.deferred_update_manager = Some(deferred_update_component.borrow_deferred_update_manager()); - - // WGSL is the only accepted source format; wgpu consumes WGSL natively - // so there is no runtime compile step. let vertex_bytes = read_wgsl_bytes( &self.vertex_shader_resource_loader, &engine.game_resources_directory_path, @@ -176,22 +167,22 @@ impl Resource for Shader { format!("Fragment shader for {} is not valid UTF-8 WGSL", &self.name).into() })?; - // TODO: Parse shader files and validate texture and parameter slots, or create them automatically here, so the user does not have to do it manually - - // Load data - let renderer_resource_handle = engine - .renderer - .create_shader( - &self.name, - vertex_wgsl, - fragment_wgsl, - &self.texture_slots, - &self.parameter_slots, - self.enable_engine_binding, - self.enable_camera_binding, - ) - .context(error_message)?; - self.renderer_resource_handle = Some(renderer_resource_handle); + #[cfg(not(feature = "headless"))] + { + let renderer_shader = engine + .renderer + .create_shader_struct( + &self.name, + vertex_wgsl, + fragment_wgsl, + &self.texture_slots, + &self.parameter_slots, + self.enable_engine_binding, + self.enable_camera_binding, + ) + .context(error_message)?; + engine.resource_manager.add_resource(renderer_shader)?; + } Ok(()) } @@ -201,26 +192,10 @@ impl Resource for Shader { } fn destroy(&mut self, engine: &mut Engine, _self_handle: H) -> Result<()> { - // Destroy renderer resource - if let Some(v) = self.renderer_resource_handle { - engine.renderer.destroy_shader(v).unwrap(); - } - - // Find materials that use this shader and update them - // for (scene_handle, scene) in engine.scene_manager.scenes.iter_mut() { - // let x = &engine.resource_manager; - - // // for (entity_handle, mesh_rendering_component) in engine.iterate_one_component::()? { - // // if let Some(material_handle) = mesh_rendering_component.material_handle { - // // // If mesh rendering component has handle to this material - // // if material_handle.data() == self_handle.data() { - // // mesh_rendering_component.set_material_handle(Option::::None); - // // mesh_rendering_component.update_render_queue_key(&engine.resource_manager).unwrap(); - // // } - // // } - // // } - // } - + #[cfg(not(feature = "headless"))] + engine + .resource_manager + .remove_resource_by_name::(&self.name)?; Ok(()) } } diff --git a/engine/pill_engine/src/resources/texture.rs b/engine/pill_engine/src/resources/texture.rs index f322e11e..abab880c 100644 --- a/engine/pill_engine/src/resources/texture.rs +++ b/engine/pill_engine/src/resources/texture.rs @@ -1,18 +1,21 @@ use crate::{ engine::Engine, - graphics::RendererTextureHandle, + renderer::resources::{RendererMaterial, RendererTexture}, resources::{Material, Resource, ResourceLoader, ResourceStorage}, }; +use std::collections::HashMap; use pill_core::{get_type_name, ErrorContext, PillSlotMapKey, PillStyle, PillTypeMapKey, Result}; pill_core::define_new_pill_slotmap_key! { pub struct TextureHandle; } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum TextureType { Color, Normal, + MetallicRoughness, + Emissive, } #[readonly::make] @@ -23,7 +26,6 @@ pub struct Texture { pub resource_loader: ResourceLoader, #[readonly] pub texture_type: TextureType, - pub(crate) renderer_resource_handle: Option, } impl Texture { @@ -32,11 +34,9 @@ impl Texture { name: name.to_string(), resource_loader, texture_type, - renderer_resource_handle: None, } } - /// Decode image bytes (format auto-detected) into a texture. pub fn from_bytes(name: &str, texture_type: TextureType, bytes: &[u8]) -> Self { Self::new( name, @@ -161,49 +161,88 @@ impl Resource for Texture { }; // Create renderer texture resource - let renderer_resource_handle = engine - .renderer - .create_texture(&self.name, &rgba, width, height, self.texture_type) + #[cfg(not(feature = "headless"))] + { + let renderer_texture = RendererTexture::new_texture( + engine.renderer.get_device(), + engine.renderer.get_queue(), + &self.name, + &rgba, + width, + height, + self.texture_type, + ) .context(error_message)?; - self.renderer_resource_handle = Some(renderer_resource_handle); + engine.resource_manager.add_resource(renderer_texture)?; + } Ok(()) } fn destroy(&mut self, engine: &mut Engine, self_handle: H) -> Result<()> { - // Destroy renderer resource - if let Some(v) = self.renderer_resource_handle { - engine.renderer.destroy_texture(v).unwrap(); - } - - // Take resource storage from engine - let resource_storage = engine + #[cfg(not(feature = "headless"))] + engine .resource_manager - .get_resource_storage_mut::()?; - let materials = &mut resource_storage.data; - - // Find materials that use this texture and update them - for material_slot in materials.iter_mut() { - let material: &mut Material = material_slot - .1 - .as_mut() - .expect("Critical: Resource is None"); - - // Update texture slots - let before = material.textures.len(); - material - .textures - .retain(|(_, slot)| slot.texture_handle.data() != self_handle.data()); - - if material.textures.len() < before { - engine - .renderer - .update_material_textures( - material.renderer_resource_handle.unwrap(), - &material.textures, - ) - .unwrap(); + .remove_resource_by_name::(&self.name)?; + + // Phase 1: find and update affected materials (CPU side only) + let affected_material_names: Vec = { + let resource_storage = engine + .resource_manager + .get_resource_storage_mut::()?; + let mut affected = Vec::new(); + for (_, slot) in resource_storage.data.iter_mut() { + let material = slot.as_mut().expect("Critical: Resource is None"); + let before = material.textures.len(); + material + .textures + .retain(|(_, slot)| slot.texture_handle.data() != self_handle.data()); + if material.textures.len() < before { + affected.push(material.name.clone()); + } } + affected + }; + + // Phase 2: rebuild RendererMaterial textures bind group for affected materials + #[cfg(not(feature = "headless"))] + for mat_name in &affected_material_names { + let new_bind_group = { + let renderer_mat = engine + .resource_manager + .get_resource_by_name::(mat_name)?; + let renderer_shader = engine + .resource_manager + .get_resource::( + &renderer_mat.shader_handle, + )?; + let material = engine + .resource_manager + .get_resource_by_name::(mat_name)?; + let mut resolved: HashMap = + HashMap::new(); + for (slot_name, mat_tex) in &material.textures { + let tex = engine + .resource_manager + .get_resource::(&mat_tex.texture_handle)?; + let h = engine + .resource_manager + .get_resource_handle::(&tex.name)?; + resolved.insert(slot_name.clone(), h); + } + RendererMaterial::create_textures_bind_group( + engine.renderer.get_device(), + &engine.resource_manager, + renderer_shader.textures_bind_group_layout.as_ref().unwrap(), + &format!("{}_textures", mat_name), + &renderer_shader.texture_slots, + &resolved, + )? + }; + engine + .resource_manager + .get_resource_by_name_mut::(mat_name)? + .textures_bind_group = Some(new_bind_group); } Ok(()) diff --git a/engine/pill_launcher/res/templates/pill_default/res/config.ini b/engine/pill_launcher/res/templates/pill_default/res/config.ini index a8273c20..7e485638 100644 --- a/engine/pill_launcher/res/templates/pill_default/res/config.ini +++ b/engine/pill_launcher/res/templates/pill_default/res/config.ini @@ -2,7 +2,7 @@ TITLE=NoTitle # WINDOW -WINDOW_TITLE=NoTitle +WINDOW_TITLE=Pill Engine - NoTitle WINDOW_WIDTH=600 WINDOW_HEIGHT=600 WINDOW_FULLSCREEN=false diff --git a/engine/pill_launcher/res/templates/wasm/src/lib.rs b/engine/pill_launcher/res/templates/wasm/src/lib.rs index e342ba07..ab55531f 100644 --- a/engine/pill_launcher/res/templates/wasm/src/lib.rs +++ b/engine/pill_launcher/res/templates/wasm/src/lib.rs @@ -10,9 +10,13 @@ use wasm_bindgen::prelude::*; -use pill_game::WebGame; +#[cfg(target_arch = "wasm32")] +#[global_allocator] +// SAFETY: wasm32 is single-threaded, so assuming single-threaded allocator access is sound. +static ALLOC: lol_alloc::AssumeSingleThreaded = + unsafe { lol_alloc::AssumeSingleThreaded::new(lol_alloc::FreeListAllocator::new()) }; #[wasm_bindgen(start)] pub fn wasm_main() { - pill_web::run(Box::new(WebGame {}), include_str!("../config.ini")); + pill_web::run(pill_game::create_pill_game(), include_str!("../config.ini")); } diff --git a/engine/pill_launcher/src/main.rs b/engine/pill_launcher/src/main.rs index 955505f2..11a7b446 100644 --- a/engine/pill_launcher/src/main.rs +++ b/engine/pill_launcher/src/main.rs @@ -188,6 +188,18 @@ pub(crate) fn modify_file String>( Ok(()) } +#[cfg(target_os = "macos")] +fn codesign_adhoc(path: &PathBuf) -> Result<()> { + let status = Command::new("codesign") + .args(["--force", "--sign", "-", path.to_str().unwrap_or("")]) + .status() + .with_context(|| format!("codesign failed for {}", path.display()))?; + if !status.success() { + bail!("codesign returned non-zero for {}", path.display()); + } + Ok(()) +} + fn copy_if_newer(source: &PathBuf, destination: &PathBuf) -> Result { // returns true if copied if !source.exists() { @@ -420,6 +432,48 @@ fn try_remove_files_starting_with(directory_path: &PathBuf, file_name_prefix: &s } } +fn delete_cooked_files_recursive(dir: &Path) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? { + let entry = entry?; + let file_type = entry.file_type()?; + let path = entry.path(); + if file_type.is_dir() { + delete_cooked_files_recursive(&path)?; + } else if file_type.is_file() { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext == "cooked_mesh" || ext == "cooked_tex" { + fs::remove_file(&path) + .with_context(|| format!("Failed to delete {}", path.display()))?; + println!("Deleted {}", path.display()); + } + } + } + Ok(()) +} + +fn run_asset_pipeline(res_dir: &Path, force_rebuild: bool) -> Result<()> { + if force_rebuild { + println!("Force-rebuild: deleting cooked files under {}...", res_dir.display()); + delete_cooked_files_recursive(res_dir)?; + } + let pipeline = pill_assets::Pipeline { + root: res_dir.to_path_buf(), + rules: pill_assets::default_rules(), + }; + let stats = pipeline.run().context("Asset pipeline failed")?; + println!( + "Assets: discovered={} rebuilt={} skipped={} (root: {})", + stats.discovered.len(), + stats.rebuilt.len(), + stats.skipped.len(), + res_dir.display() + ); + Ok(()) +} + // Render all *.puml under /docs/uml into /docs/uml_out as SVGs fn render_puml_for_crate(crate_dir: &Path) -> Result<()> { let in_dir = crate_dir.join("docs").join("uml"); @@ -461,7 +515,8 @@ fn render_puml_for_crate(crate_dir: &Path) -> Result<()> { // Prefer "plantuml" CLI tool if available if !have_cli { - bail!("Please install plantuml!"); + println!("plantuml not found, skipping diagram rendering"); + return Ok(()); } for puml in &inputs { @@ -898,11 +953,15 @@ fn build_game_project( if *compile_mode != CompileMode::HotReload || !hot_reload_child { if copy_if_newer(&game_src, &data_dir.join(dylib("pill_game")))? { println!("Copied game dylib"); + #[cfg(target_os = "macos")] + codesign_adhoc(&data_dir.join(dylib("pill_game")))?; } else { println!("Skipping copying of game dylib"); } if copy_if_newer(&runtime_src, &data_dir.join(dylib("pill_runtime")))? { println!("Copied runtime dylib"); + #[cfg(target_os = "macos")] + codesign_adhoc(&data_dir.join(dylib("pill_runtime")))?; } else { println!("Skipping copying of runtime dylib"); } @@ -1177,6 +1236,12 @@ fn run_app() -> Result<()> { .required(false) .help("Fail WASM release build if final binary exceeds N KB"); + let force_rebuild_option = Arg::with_name("force-rebuild") + .long("force-rebuild") + .takes_value(false) + .required(false) + .help("Delete all cooked asset files and rebuild from source. For 'assets': rebuild the pipeline. For 'run'/'build': rebuild assets then build."); + let game_args = Arg::with_name("game-args") .help("Arguments passed through to cargo/game (use `--` to separate them)") .multiple(true) @@ -1192,6 +1257,7 @@ fn run_app() -> Result<()> { .arg(compile_mode_option) .arg(target_option) .arg(max_wasm_size_option) + .arg(force_rebuild_option) .arg(game_args) .setting(AppSettings::TrailingVarArg); @@ -1226,6 +1292,8 @@ fn run_app() -> Result<()> { .value_of("max-wasm-size") .and_then(|s| s.parse().ok()); + let force_rebuild = matches.is_present("force-rebuild"); + match action_argument { "create" => { let game_parent_directory_path = PathBuf::from(directory_path_argument.expect("Game project parent directory path has to be specified using --path flag. For example: --path ")) @@ -1241,6 +1309,11 @@ fn run_app() -> Result<()> { .absolutize().context("Failed to absolutize game project directory path")? .to_path_buf(); + if force_rebuild { + run_asset_pipeline(&game_project_directory_path.join("res"), true) + .context("Asset rebuild failed")?; + } + match target { BuildTarget::Native => { let mut output_directory_path = PathBuf::from(output_directory_path_argument.expect("Output directory path has to be specified using --output-path flag. For example: --output-path ")); @@ -1269,6 +1342,11 @@ fn run_app() -> Result<()> { .absolutize().context("Failed to absolutize game project directory path")? .to_path_buf(); + if force_rebuild { + run_asset_pipeline(&game_project_directory_path.join("res"), true) + .context("Asset rebuild failed")?; + } + match target { BuildTarget::Native => { let mut output_directory_path = PathBuf::from(output_directory_path_argument.expect("Output directory path has to be specified using --output-path flag. For example: --output-path ")); @@ -1326,18 +1404,8 @@ fn run_app() -> Result<()> { .context("Failed to absolutize project directory path")? .to_path_buf(); - let pipeline = pill_assets::Pipeline { - root: project_dir.join("res"), - rules: pill_assets::default_rules(), - }; - let stats = pipeline.run().context("Asset pipeline failed")?; - println!( - "Assets: discovered={} rebuilt={} skipped={} (root: {})", - stats.discovered.len(), - stats.rebuilt.len(), - stats.skipped.len(), - pipeline.root.display() - ); + run_asset_pipeline(&project_dir.join("res"), force_rebuild) + .context("Asset pipeline failed")?; } _ => { println!("Undefined action"); diff --git a/engine/pill_launcher/src/wasm_build.rs b/engine/pill_launcher/src/wasm_build.rs index cb25b4ad..c9b70a6f 100644 --- a/engine/pill_launcher/src/wasm_build.rs +++ b/engine/pill_launcher/src/wasm_build.rs @@ -164,9 +164,10 @@ fn rewrite_scratch_manifest(scratch_pill_web_app_dir: &Path, game_dir: &Path) -> // parent workspace; resolver matches engine/Cargo.toml (wgpu feature // unification is sensitive to resolver version) // - [profile.release] + wasm-pack's wasm-opt flags for Tier 1 size opt. - // `strip` is NOT set here: the size report analyzes the pre-opt wasm - // and needs the function-names subsection. wasm-opt strips the shipped - // binary via --strip-debug --strip-producers. + // `strip = true` drops the function-names subsection from the pre-opt + // wasm too (~4 KB off the final binary), so the size report's per-crate + // attribution reads 0 B — accepted: shipping under the size budget beats + // the dev-only breakdown. The final binary is also wasm-opt --strip-debug'd. let mut f = OpenOptions::new() .append(true) .open(&manifest) @@ -181,8 +182,11 @@ fn rewrite_scratch_manifest(scratch_pill_web_app_dir: &Path, game_dir: &Path) -> "lto = \"fat\"\n", "codegen-units = 1\n", "panic = \"abort\"\n", + "strip = true\n", "\n[package.metadata.wasm-pack.profile.release]\n", "wasm-opt = [\"-Oz\", \"--strip-debug\", \"--strip-producers\", \"--enable-nontrapping-float-to-int\", \"--enable-bulk-memory\", \"--enable-sign-ext\", \"--enable-mutable-globals\", \"--enable-reference-types\"]\n", + "\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\n", + "lol_alloc = \"0.4\"\n", ), pill_game = pill_game, ) diff --git a/engine/pill_native/src/main.rs b/engine/pill_native/src/main.rs index 534b6c5c..cb974edf 100644 --- a/engine/pill_native/src/main.rs +++ b/engine/pill_native/src/main.rs @@ -140,9 +140,7 @@ fn workspace_includes_game( } fn looks_like_engine_workspace(path: &Path) -> bool { - path.join("pill_core").exists() - && path.join("pill_engine").exists() - && path.join("pill_renderer").exists() + path.join("pill_core").exists() && path.join("pill_engine").exists() } fn engine_workspace_from_game_manifest(game_project_directory_path: &Path) -> Option { @@ -905,7 +903,7 @@ fn create_file_watchers(project_paths: &ProjectPaths) -> FileWatchers { let engine_engine_source_files_watcher = FileWatcher::new(engine_source_path).set_recursive(true); - let renderer_source_path = engine_workspace_directory_path.join("pill_renderer/src"); + let renderer_source_path = engine_workspace_directory_path.join("pill_engine/src/renderer"); let engine_renderer_source_files_watcher = FileWatcher::new(renderer_source_path).set_recursive(true); @@ -1375,10 +1373,8 @@ mod tests { let engine_b = root.join("Pill-Engine-Upstream").join("engine"); fs::create_dir_all(engine_a.join("pill_core")).unwrap(); fs::create_dir_all(engine_a.join("pill_engine")).unwrap(); - fs::create_dir_all(engine_a.join("pill_renderer")).unwrap(); fs::create_dir_all(engine_b.join("pill_core")).unwrap(); fs::create_dir_all(engine_b.join("pill_engine")).unwrap(); - fs::create_dir_all(engine_b.join("pill_renderer")).unwrap(); fs::write( engine_a.join("Cargo.toml"), diff --git a/engine/pill_renderer/Cargo.toml b/engine/pill_renderer/Cargo.toml deleted file mode 100644 index 36252329..00000000 --- a/engine/pill_renderer/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "pill_renderer" -version = "0.1.0" -edition = "2021" - -[dependencies] -pill_core = {path = "../pill_core"} -pill_engine = {path = "../pill_engine", features = ["internal"]} - -# Logging -log = { version = "0.4", features = ["release_max_level_off"] } - -# Other -glam = { version = "0.30.8", features = ["serde", "bytemuck"] } -winit = "0.30.12" -cgmath = "0.18" -bytemuck = { version = "1.4", features = [ "derive" ] } -# egui (optional — enabled by debug_ui feature) -egui = { version = "0.32.1", optional = true } -egui-wgpu = { version = "0.32.1", features = ["winit"], optional = true } - -# Native-only dependencies -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -wgpu = "25.0.0" -pollster = "0.2" -egui-winit = { version = "0.32.1", features = ["clipboard"], optional = true } - -# WASM dependencies -[target.'cfg(target_arch = "wasm32")'.dependencies] -wgpu = { version = "25.0.0", default-features = false, features = ["webgpu", "wgsl"] } -egui-winit = { version = "0.32.1", default-features = false, optional = true } - -[features] -debug_ui = ["dep:egui", "dep:egui-wgpu", "dep:egui-winit", "pill_engine/debug_ui"] diff --git a/engine/pill_renderer/src/config.rs b/engine/pill_renderer/src/config.rs deleted file mode 100644 index efe594bc..00000000 --- a/engine/pill_renderer/src/config.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub const MAX_INSTANCE_PER_DRAWCALL_COUNT: usize = 10000; -pub const INITIAL_INSTANCE_VECTOR_CAPACITY: usize = 10000; - -// Order of bind group layouts in all shaders -pub const ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 0; // (set = 0, binding = X) -pub const CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 1; // (set = 1, binding = X) -pub const MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 2; // (set = 2, binding = X) -pub const MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX: u32 = 3; // (set = 3, binding = X) diff --git a/engine/pill_renderer/src/drawers/mesh_drawer.rs b/engine/pill_renderer/src/drawers/mesh_drawer.rs deleted file mode 100644 index f2099971..00000000 --- a/engine/pill_renderer/src/drawers/mesh_drawer.rs +++ /dev/null @@ -1,379 +0,0 @@ -use std::{num::NonZeroU32, ops::Range}; - -use crate::{ - config::{ - CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, - INITIAL_INSTANCE_VECTOR_CAPACITY, MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, - MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX, - }, - resources::{RendererCamera, RendererResourceStorage, RendererShader}, - Instance, -}; -use pill_core::{debug, LogContext, PillStyle, RendererError, Timer}; -use pill_engine::{ - internal::{ - RenderQueueItem, RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, - TransformComponent, - }, - ComponentStorage, -}; - -use pill_core::Result; - -#[derive(Debug, Clone, Default)] -pub struct DrawingContext { - rendering_order: u8, - shader_handle: Option, - shader_name: String, - material_handle: Option, - material_name: String, - mesh_handle: Option, - mesh_name: String, - mesh_index_count: u32, // Number of indices in the current mesh - - accumulated_instance_range: Range, - accumulated_instance_count: u32, - - rendering_context_change_number: u32, - - instance_batch_number: u32, - instance_batch_size: u32, -} - -impl DrawingContext { - pub fn log(&self) { - debug!( - LogContext::Frame => - "Draw {} instance(s) {}->{}/{} command recorded [Batch: {}, Rendering order: {}, Shader: {}, Material: {}, Mesh: {}]", - self.accumulated_instance_count, - self.accumulated_instance_range.start, - self.accumulated_instance_range.end - 1, - self.instance_batch_size, - self.instance_batch_number, - self.rendering_order, - self.shader_name.name_style(), - self.material_name.name_style(), - self.mesh_name.name_style() - ); - } - - pub fn record_draw_accumulated_instances(&mut self, render_pass: &mut wgpu::RenderPass) { - if self.accumulated_instance_count > 0 { - render_pass.draw_indexed( - 0..self.mesh_index_count, - 0, - self.accumulated_instance_range.clone(), - ); - self.log(); - self.accumulated_instance_range = - self.accumulated_instance_range.end..self.accumulated_instance_range.end; - self.accumulated_instance_count = 0; - } - } - - pub fn accumulate_instance(&mut self) { - self.accumulated_instance_range = - self.accumulated_instance_range.start..self.accumulated_instance_range.end + 1; - self.accumulated_instance_count = - self.accumulated_instance_range.end - self.accumulated_instance_range.start; - } - - pub fn change_rendering_order(&mut self, new_order: u8) { - self.rendering_order = new_order; - - self.rendering_context_change_number += 1; - debug!(LogContext::Frame => "Rendering order changed to: {}", self.rendering_order); - } - - pub fn change_shader( - &mut self, - renderer_resource_storage: &RendererResourceStorage, - shader_handle: RendererShaderHandle, - render_pass: &mut wgpu::RenderPass, - camera: &RendererCamera, - ) { - self.shader_handle = Some(shader_handle); - let shader: &RendererShader = renderer_resource_storage - .shaders - .get(shader_handle) - .unwrap(); - self.shader_name = shader.name.clone(); - - debug!(LogContext::Frame => "Changing shader to: {}", self.shader_name.name_style()); - - render_pass.set_pipeline(&shader.render_pipeline); - - if shader.pass_engine_parameters { - render_pass.set_bind_group( - ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, - &renderer_resource_storage.engine_parameters.bind_group, - &[], - ); - debug!(LogContext::Frame => "Engine parameters bound"); - } - - if shader.pass_camera_parameters { - render_pass.set_bind_group( - CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, - &camera.bind_group, - &[], - ); - debug!(LogContext::Frame => "Camera parameters bound"); - } - - self.rendering_context_change_number += 1; - debug!(LogContext::Frame => "Renderer pipeline shader changed to: {}", self.shader_name.name_style()); - } - - pub fn change_material( - &mut self, - renderer_resource_storage: &RendererResourceStorage, - material_handle: RendererMaterialHandle, - render_pass: &mut wgpu::RenderPass, - ) { - self.material_handle = Some(material_handle); - let material = renderer_resource_storage - .materials - .get(material_handle) - .unwrap(); - self.material_name = material.name.clone(); - - debug!(LogContext::Frame => "Changing material to: {}", self.material_name.name_style()); - - if let Some(ref parameters_bind_group) = material.parameters_bind_group { - render_pass.set_bind_group( - MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, - parameters_bind_group, - &[], - ); - debug!(LogContext::Frame => "Material parameters bound"); - } - - if let Some(ref texture_bind_group) = material.textures_bind_group { - render_pass.set_bind_group( - MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX, - texture_bind_group, - &[], - ); - debug!(LogContext::Frame => "Material textures bound"); - } - - self.rendering_context_change_number += 1; - debug!(LogContext::Frame => "Renderer pipeline material changed to: {}", self.material_name.name_style()); - } - - pub fn change_mesh( - &mut self, - renderer_resource_storage: &RendererResourceStorage, - mesh_handle: RendererMeshHandle, - render_pass: &mut wgpu::RenderPass, - ) { - self.mesh_handle = Some(mesh_handle); - let mesh = renderer_resource_storage.meshes.get(mesh_handle).unwrap(); - self.mesh_name = mesh.name.clone(); - - self.mesh_index_count = mesh.index_count; - render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); - render_pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); - - self.rendering_context_change_number += 1; - debug!(LogContext::Frame => "Renderer pipeline mesh changed to: {}", self.mesh_name.name_style()); - } -} - -pub struct MeshDrawer { - max_instance_batch_size: u32, - instances: Vec, - instance_buffer: wgpu::Buffer, -} - -impl MeshDrawer { - pub fn new(device: &wgpu::Device, max_instance_batch_size: u32) -> Self { - // Create instance buffer - let buffer_size = (size_of::() * max_instance_batch_size as usize) as u64; - let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("instance_buffer"), - size: buffer_size, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - MeshDrawer { - max_instance_batch_size, - instances: Vec::::with_capacity(INITIAL_INSTANCE_VECTOR_CAPACITY), - instance_buffer, - } - } - - #[allow(clippy::too_many_arguments)] - pub fn record_draw_commands( - &mut self, - // Resources - queue: &wgpu::Queue, - encoder: &mut wgpu::CommandEncoder, - renderer_resource_storage: &RendererResourceStorage, - color_attachment: wgpu::RenderPassColorAttachment, - depth_stencil_attachment: wgpu::RenderPassDepthStencilAttachment, - // Rendring data - camera: &RendererCamera, - render_queue: &[RenderQueueItem], - transform_component_storage: &ComponentStorage, - timer: &mut Timer, - // profiler: &mut Profiler, - ) -> Result<()> { - timer.record("Prepare render pass"); - debug!(LogContext::Frame => "Recording mesh draw commands"); - - //let _timestamp_query_start = profiler.write_timestamp(encoder, "xx"); - - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("main pass"), - color_attachments: &[Some(color_attachment.clone())], - depth_stencil_attachment: Some(depth_stencil_attachment.clone()), - timestamp_writes: None, - occlusion_query_set: None, // profiler.get_occlusion_query_set(), // immut borrow ends after this stmt - //timestamp_writes: None, - // occlusion_query_set: profiler.get_occlusion_query_set(), // immut borrow ends after this stmt - // timestamp_writes: Some(wgpu::RenderPassTimestampWrites { - // query_set: profiler.get_timestamp_query_set().unwrap(), - // beginning_of_pass_write_index: Some(0), - // end_of_pass_write_index: Some(1), - // }), - }); - - // let _pipeline_statistics_query_start = profiler.begin_pipeline_statistics_query(&mut render_pass); - //let _occlusion_query_start = profiler.begin_occlusion_query(&mut render_pass); - - // let mut current_rendering_order: u8 = 0; - - // let mut current_shader_handle: Option = None; - // let mut current_shader_name = ""; - // let mut current_material_handle: Option = None; - // let mut current_material_name = ""; - // let mut current_mesh_handle: Option = None; - // let mut current_mesh_name = ""; - // let mut current_mesh_index_count: u32 = 0; // Number of indices in the current mesh - - let mut current_drawing_context = DrawingContext::default(); - - for (i, instance_batch) in render_queue - .chunks(self.max_instance_batch_size as usize) - .enumerate() - { - let batch_size = instance_batch.len(); - current_drawing_context.instance_batch_number = i as u32; - current_drawing_context.instance_batch_size = batch_size as u32; - - timer.begin_context(format!("Prepare draw instance batch {}", i)); - - timer.record("Write instance buffer".to_string()); - - // Prepare instance data and load it to buffer - self.instances.clear(); - self.instances.reserve(instance_batch.len()); // Pre-allocate exact capacity - - for render_queue_item in instance_batch { - let transform_slot = transform_component_storage - .data - .get(render_queue_item.entity_index as usize) - .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; - let transform_component = transform_slot - .as_ref() - .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; - //println!("Creating new instance with transform component: {:?} {:?}", i, transform_component.position); - self.instances.push(Instance::new(transform_component)); - } - - queue.write_buffer( - &self.instance_buffer, - 0, - bytemuck::cast_slice(&self.instances), - ); // Update instance buffer - - render_pass.set_vertex_buffer(1, self.instance_buffer.slice(..)); // Set instance buffer - - timer.record("Record draw instances commands"); - - // Reset instance range for each batch - current_drawing_context.accumulated_instance_range = 0..0; - current_drawing_context.accumulated_instance_count = 0; - - for (j, render_queue_item) in instance_batch.iter().enumerate() { - let render_queue_key_fields = - pill_engine::internal::decompose_render_queue_key(render_queue_item.key); - - // Recreate resource handles - let renderer_shader_handle = RendererShaderHandle::new( - render_queue_key_fields.shader_index.into(), - NonZeroU32::new(render_queue_key_fields.shader_version.into()).unwrap(), - ); - let renderer_material_handle = RendererMaterialHandle::new( - render_queue_key_fields.material_index.into(), - NonZeroU32::new(render_queue_key_fields.material_version.into()).unwrap(), - ); - let renderer_mesh_handle = RendererMeshHandle::new( - render_queue_key_fields.mesh_index.into(), - NonZeroU32::new(render_queue_key_fields.mesh_version.into()).unwrap(), - ); - - // Check for rendering order change - if current_drawing_context.rendering_order > render_queue_key_fields.order { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); - current_drawing_context.change_rendering_order(render_queue_key_fields.order); - } - - // Check for shader change - if current_drawing_context.shader_handle != Some(renderer_shader_handle) { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); - current_drawing_context.change_shader( - renderer_resource_storage, - renderer_shader_handle, - &mut render_pass, - camera, - ); - } - - // Check for material change - if current_drawing_context.material_handle != Some(renderer_material_handle) { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); - current_drawing_context.change_material( - renderer_resource_storage, - renderer_material_handle, - &mut render_pass, - ); - } - - // Check for mesh change - if current_drawing_context.mesh_handle != Some(renderer_mesh_handle) { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); - current_drawing_context.change_mesh( - renderer_resource_storage, - renderer_mesh_handle, - &mut render_pass, - ); - } - - // Add new instance - current_drawing_context.accumulate_instance(); - - // If last in batch, draw accumulated instances - if j == batch_size - 1 { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); - } - } - - timer.end_context()?; // End "Draw instance batch" context - } - - // Drop render_pass before finishing encoder - //let _occlusion_query_end = profiler.end_occlusion_query(&mut render_pass); - //let _pipeline_statistics_query_end = profiler.end_pipeline_statistics_query(&mut render_pass); - - drop(render_pass); - - // let _timestamp_query_end = profiler.write_timestamp(encoder, "xx12"); - - //queue.submit(std::iter::once(encoder.finish())); - Ok(()) - } -} diff --git a/engine/pill_renderer/src/drawers/mod.rs b/engine/pill_renderer/src/drawers/mod.rs deleted file mode 100644 index 2029e0fb..00000000 --- a/engine/pill_renderer/src/drawers/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -#[cfg(feature = "debug_ui")] -pub mod egui_drawer; -pub mod mesh_drawer; diff --git a/engine/pill_renderer/src/instance.rs b/engine/pill_renderer/src/instance.rs deleted file mode 100644 index a177f1ad..00000000 --- a/engine/pill_renderer/src/instance.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crate::resources::Vertex; - -use pill_core::Matrix3f; -use pill_engine::internal::TransformComponent; - -// --- Instance --- - -#[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct Instance { - pub(crate) transform: Matrix3f, // It is matrix3 because we only need the rotation componen -} - -impl Instance { - pub fn new(transform_component: &TransformComponent) -> Instance { - Instance { - transform: Matrix3f::from_cols( - transform_component.position, - transform_component.rotation, - transform_component.scale, - ), - } - } -} - -impl Vertex for Instance { - fn data_layout_descriptor<'a>() -> wgpu::VertexBufferLayout<'a> { - use std::mem; - wgpu::VertexBufferLayout { - array_stride: mem::size_of::() as wgpu::BufferAddress, - // We need to switch from using a step mode of Vertex to Instance - // This means that shaders will only change to use the next instance when the shader starts processing a new instance - step_mode: wgpu::VertexStepMode::Instance, - attributes: &[ - wgpu::VertexAttribute { - // Instance transform position - // slangc maps TEXCOORD1 → @location(1) - offset: 0, - shader_location: 1, - format: wgpu::VertexFormat::Float32x3, - }, - wgpu::VertexAttribute { - // Instance transform rotation - // slangc maps TEXCOORD2 → @location(2) - offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, - shader_location: 2, - format: wgpu::VertexFormat::Float32x3, - }, - wgpu::VertexAttribute { - // Instance transform scale - // slangc maps TEXCOORD3 → @location(3) - offset: mem::size_of::<[f32; 6]>() as wgpu::BufferAddress, - shader_location: 3, - format: wgpu::VertexFormat::Float32x3, - }, // Model matrix (mat4 takes up 4 vertex slots as it is technically 4 vec4s. We need to define a slot for each vec4) - // wgpu::VertexAttribute { - // offset: 0, - // shader_location: 5, - // format: wgpu::VertexFormat::Float32x4, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 4]>() as wgpu::BufferAddress, - // shader_location: 6, - // format: wgpu::VertexFormat::Float32x4, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 8]>() as wgpu::BufferAddress, - // shader_location: 7, - // format: wgpu::VertexFormat::Float32x4, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 12]>() as wgpu::BufferAddress, - // shader_location: 8, - // format: wgpu::VertexFormat::Float32x4, - // }, - - // // Normal matrix - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 16]>() as wgpu::BufferAddress, - // shader_location: 9, - // format: wgpu::VertexFormat::Float32x3, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 19]>() as wgpu::BufferAddress, - // shader_location: 10, - // format: wgpu::VertexFormat::Float32x3, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 22]>() as wgpu::BufferAddress, - // shader_location: 11, - // format: wgpu::VertexFormat::Float32x3, - // }, - ], - } - } -} diff --git a/engine/pill_renderer/src/lib.rs b/engine/pill_renderer/src/lib.rs deleted file mode 100644 index 374b5814..00000000 --- a/engine/pill_renderer/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -pub mod config; -pub mod drawers; -pub mod instance; -pub mod renderer; -pub mod resources; -//pub mod profiler; - -// --- Use --- - -pub use renderer::*; - -pub use instance::Instance; diff --git a/engine/pill_renderer/src/profiler.rs b/engine/pill_renderer/src/profiler.rs deleted file mode 100644 index 7e033eba..00000000 --- a/engine/pill_renderer/src/profiler.rs +++ /dev/null @@ -1,492 +0,0 @@ -use std::{cell::Cell, num::NonZeroU32}; -use wgpu::{ - core::device::queue, - Adapter, - Buffer, - BufferDescriptor, - BufferUsages, - CommandEncoder, - Device, - Features, - Maintain, - PipelineStatisticsTypes, - QuerySet, - QuerySetDescriptor, - QueryType, - Queue -}; - -/* -This module provides GPU-side profiling capabilities using wgpu. - -- Timestamp queries: - Measures: Time taken by sections of code (eg. shadow pass, main pass, post-processing, etc). - This is true GPU time, showing stalls and bottlenecks on GPU side - Result: Vector of u64 ticks, which can be converted to milliseconds using the provided conversion function. - Not available on the web (needs TIMESTAMP_QUERY feature) - Learnings: - - GPU bottlenecks: If a section takes too long, it indicates a GPU-side bottleneck. - - Frame time analysis: Helps understand how much time each rendering stage takes. - -- Occlusion queries: - Measures: How many fragments(pixels) passed depth/stencil during the draw region maked with begin_occlusion_tracking/end_occlusion_tracking. - Result: Vector of u64 counts, one per query. Will be 0 if fully occluded. - Learnings: - - Overdraw insights: If you see high occlusion counts, it means many fragments were discarded by depth test. - - Visibility culling: Can be used to skip rendering objects that are fully occluded. - - Bound analysis: Can help understand how many pixels are actually visible in the scene. - -- Pipeline statistics: - Collects various pipeline statistics like: - - VERTEX_SHADER_INVOCATIONS — how many times the vertex shader ran (accounts for the vertex cache with indexed draws). - - CLIPPER_INVOCATIONS — number of times the clipper stage was invoked (equals triangles output by the vertex stage). - - CLIPPER_PRIMITIVES_OUT — primitives that survived clipping (triangles that actually proceed to rasterization). - - FRAGMENT_SHADER_INVOCATIONS — how many fragments executed the fragment shader (per-sample with MSAA; GPUs often execute in 2×2 quads for derivatives). - - COMPUTE_SHADER_INVOCATIONS — total compute shader invocations (dispatch count × workgroup size). - Result: Vector of u64 values, one per statistic type requested. - Not available on the web (needs PIPELINE_STATISTICS_QUERY feature) -*/ - -/// How many frames we pipeline readbacks to avoid stalls. -/// You can set this to 2–4 depending on your swapchain latency. -const FRAMES_IN_FLIGHT: usize = 3; - -/// GPU-side profiling using wgpu -pub struct Profiler { - // Capabilities - has_timestamps: bool, - has_pipeline_stats: bool, - - // Query sets (optional if feature unsupported) - timestamp_query_set: Option, - timestamp_query_names: Vec, // To store names associated with each timestamp - occlusion_query_set: Option, - pipeline_statistics_query_set: Option, - pipeline_statistics_types: PipelineStatisticsTypes, - - // Maximum queries we allow per frame for each kind - max_timestamp_queries: u32, - max_occlusion_queries: u32, - max_pipeline_statistics_queries: u32, - - // Rolling per-frame indices - current_timestamp_query: Cell, - current_occlusion_query: Cell, - current_pipeline_statistics_query: Cell, - - // Resolve buffers (ring) for readback, one per in-flight frame - timestamp_buffers: Vec>, - occlusion_buffers: Vec>, - pipeline_buffers: Vec>, - - // Bytes per query result set - timestamp_queries_result_bytes: u64, - occlusion_queries_result_bytes: u64, - pipeline_statistics_queries_result_bytes: u64, - - // Frame index for the ring - frame_index: usize, - - // Conversion period (nanoseconds per timestamp tick) - timestamp_period_ns: f32, -} - -impl Profiler { - pub fn new( - device: &Device, - queue: &Queue, - adapter: &Adapter, - max_timestamp_queries: u32, // Number of timestamp writes planned to record per frame (start/end of sections) - max_occlusion_queries: u32, // Number of occlusion queries per frame - max_pipeline_statistics_queries: u32, // Number of pipeline statistics queries per frame - pipeline_statistics_types: PipelineStatisticsTypes, // Type of pipeline statistics to collect - ) -> Self { - let features = adapter.features(); - let has_timestamps = features.contains(Features::TIMESTAMP_QUERY); - let has_pipeline_statistics = features.contains(Features::PIPELINE_STATISTICS_QUERY); - - // Timestamp query set - let timestamp_query_set = if has_timestamps && max_timestamp_queries > 0 { - Some(device.create_query_set(&QuerySetDescriptor { - label: Some("gpu_profiler.timestamp_query_set"), - ty: QueryType::Timestamp, - count: max_timestamp_queries, - })) - } else { - None - }; - - // Occlusion query set - let occlusion_query_set = if max_occlusion_queries > 0 { - Some(device.create_query_set(&QuerySetDescriptor { - label: Some("gpu_profiler.occlusion_query_set"), - ty: QueryType::Occlusion, - count: max_occlusion_queries, - })) - } else { - None - }; - - // Pipeline statistics query set - let pipeline_statistics_query_set = if has_pipeline_statistics && !pipeline_statistics_types.is_empty() { - Some(device.create_query_set(&QuerySetDescriptor { - label: Some("gpu_profiler.pipeline_statistics_query_set"), - ty: QueryType::PipelineStatistics(pipeline_statistics_types), - count: max_pipeline_statistics_queries, - })) - } else { - None - }; - - // Bytes per query result entry - let timestamp_queries_result_bytes = std::mem::size_of::() as u64; - let occlusion_queries_result_bytes = std::mem::size_of::() as u64; - let pipeline_statistics_fields = pipeline_statistics_types.bits().count_ones() as u64; - let pipeline_statistics_queries_result_bytes = if pipeline_statistics_fields == 0 { 0 } else { pipeline_statistics_fields * std::mem::size_of::() as u64 }; - - // Resolve buffers ring (created lazily on first use) - let timestamp_buffers = (0..FRAMES_IN_FLIGHT).map(|_| None).collect(); - let occlusion_buffers = (0..FRAMES_IN_FLIGHT).map(|_| None).collect(); - let pipeline_buffers = (0..FRAMES_IN_FLIGHT).map(|_| None).collect(); - - // Timestamp period - let timestamp_period_ns = if has_timestamps { queue.get_timestamp_period() } else { 0.0 }; - - Self { - has_timestamps, - has_pipeline_stats: has_pipeline_statistics, - - timestamp_query_set, - timestamp_query_names: Vec::new(), - occlusion_query_set, - pipeline_statistics_query_set, - pipeline_statistics_types, - - max_timestamp_queries, - max_occlusion_queries, - max_pipeline_statistics_queries: max_occlusion_queries.max(1), // we bound ps queries with the same cap for simplicity - - current_timestamp_query: Cell::new(0), - current_occlusion_query: Cell::new(0), - current_pipeline_statistics_query: Cell::new(0), - - timestamp_buffers, - occlusion_buffers, - pipeline_buffers, - - timestamp_queries_result_bytes, - occlusion_queries_result_bytes, - pipeline_statistics_queries_result_bytes, - - frame_index: 0, - timestamp_period_ns, - } - } - - /// Call once at the start of frame. - pub fn begin_frame(&mut self) { - self.current_timestamp_query.set(0); - self.current_occlusion_query.set(0); - self.current_pipeline_statistics_query.set(0); - self.timestamp_query_names.clear(); - } - - /// Call once at the end of frame, after all resolves were scheduled into encoder - /// This advances the ring index so next frame resolves into a different buffer - pub fn end_frame(&mut self) { - self.frame_index = (self.frame_index + 1) % FRAMES_IN_FLIGHT; - } - - // --- Timestamps --- - - pub fn get_timestamp_query_set(&self) -> Option<&QuerySet> { - self.timestamp_query_set.as_ref() - } - - /// Write a timestamp (returns its query index) - /// Called before and after a region to time - pub fn write_timestamp(&mut self, encoder: &mut CommandEncoder, name: &str) -> Option { - if let Some(query_set) = &self.timestamp_query_set { - // Check if there is space for another timestamp - if self.current_timestamp_query.get() >= self.max_timestamp_queries { - println!("Profiler: Max timestamps reached for this frame"); - return None; - } - - let index = self.current_timestamp_query.get(); - self.current_timestamp_query.set(index + 1); - encoder.write_timestamp(query_set, index); - - // Store the name in parallel with the timestamp index - self.timestamp_query_names.push(name.to_string()); - - Some(index) - } else { - None - } - } - - /// Resolve all timestamps recorded so far this frame into the ring buffer - pub fn resolve_timestamp_queries(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder) { - if let Some(query_set) = &self.timestamp_query_set { - let count = self.current_timestamp_query.get(); - if count == 0 { return; } - let byte_len = self.timestamp_queries_result_bytes * count as u64; - - let index = self.frame_index; - let slot = &mut self.timestamp_buffers[index]; - let buffer = ensure_buffer_slot(device, slot, byte_len, "gpu_profiler.timestamp_queries.resolve"); - - encoder.resolve_query_set(query_set, 0..count, buffer, 0); - } - } - - /// Blocking readback of all timestamps for the frame that was resolved into the previous ring slot - /// Returns the raw u64 ticks and also a helper to convert ticks to ms - pub fn read_timestamp_queries_blocking(&self, device: &Device) -> Option> { - let index = (self.frame_index + FRAMES_IN_FLIGHT - 1) % FRAMES_IN_FLIGHT; - let buffer = self.timestamp_buffers[index].as_ref()?; - let slice = buffer.slice(..); - - // Map and wait - slice.map_async(wgpu::MapMode::Read, |_| {}); - device.poll(Maintain::Wait); - let data = slice.get_mapped_range(); - let values: Vec = bytemuck::cast_slice(&data).to_vec(); - - drop(data); - buffer.unmap(); - Some(values) - } - - /// Convert a delta of timestamp ticks to milliseconds. - pub fn timestamp_ticks_to_ms(&self, delta_ticks: u64) -> f32 { - (delta_ticks as f32 * self.timestamp_period_ns) / 1_000_000.0 - } - - pub fn summarize_timestamp_queries(&self, ticks: &[u64]) { - if ticks.len() < 2 { - println!("[GPU] no timestamp sections recorded"); - return; - } - let want = ticks.len() - 1; - let use_default = self.timestamp_query_names.len() != want; - - for (i, window) in ticks.windows(2).enumerate() { - let delta_ticks = window[1] - window[0]; - let ms = self.timestamp_ticks_to_ms(delta_ticks); - let label = if use_default { - format!("Section {}", i) - } else { - self.timestamp_query_names[i].to_string() - }; - println!("[GPU] {:<24}: {:6.3} ms", label, ms); - } - } - - // --- Occlusion --- - - /// Expose the occlusion query set for putting into `RenderPassDescriptor.occlusion_query_set`. - pub fn get_occlusion_query_set(&self) -> Option<&QuerySet> { - self.occlusion_query_set.as_ref() - } - - /// Begin an occlusion query within a render pass. Returns query index. - pub fn begin_occlusion_query(&self, render_pass: &mut wgpu::RenderPass<'_>) -> Option { - if let Some(_query_set) = &self.occlusion_query_set { - // Check if there is space for another occlusion query - if self.current_occlusion_query.get() >= self.max_occlusion_queries { - println!("Profiler: Max occlusion queries reached for this frame"); - return None; - } - - let index = self.current_occlusion_query.get(); - self.current_occlusion_query.set(index + 1); - render_pass.begin_occlusion_query(index); - Some(index) - } else { - None - } - } - - pub fn end_occlusion_query(&self, render_pass: &mut wgpu::RenderPass<'_>) { - render_pass.end_occlusion_query(); - } - - /// Resolve occlusion queries recorded this frame. - pub fn resolve_occlusion_queries(&mut self, device: &Device, encoder: &mut CommandEncoder) { - if let Some(query_set) = &self.occlusion_query_set { - let count = self.current_occlusion_query.get(); - if count == 0 { return; } - let byte_len = self.occlusion_queries_result_bytes * count as u64; - - let index = self.frame_index; - let slot = &mut self.occlusion_buffers[index]; - let buffer = ensure_buffer_slot(device, slot, byte_len, "gpu_profiler.occlusion_queries.resolve"); - - encoder.resolve_query_set(query_set, 0..count, buffer, 0); - } - } - - pub fn read_occlusion_queries_blocking(&self, device: &Device) -> Option> { - let index = (self.frame_index + FRAMES_IN_FLIGHT - 1) % FRAMES_IN_FLIGHT; - let buffer = self.occlusion_buffers[index].as_ref()?; - let slice = buffer.slice(..); - - // Map and wait - slice.map_async(wgpu::MapMode::Read, |_| {}); - device.poll(Maintain::Wait); - let data = slice.get_mapped_range(); - let values: Vec = bytemuck::cast_slice(&data).to_vec(); - - drop(data); - buffer.unmap(); - Some(values) - } - - pub fn summarize_occlusion_queries(&self, samples: &[u64]) { - if samples.is_empty() { - println!("[GPU] no occlusion queries recorded"); - return; - } - let mut visible = 0usize; - for (i, &sample) in samples.iter().enumerate() { - let is_visible = sample > 0; - if is_visible { visible += 1; } - println!("[GPU] occlusion[{:02}] = {:>12} {}", i, sample, if is_visible { "(visible)" } else { "(occluded)" }); - } - println!("[GPU] occlusion visible: {}/{}", visible, samples.len()); - } - - // --- Pipeline statistics --- - - /// Expose the pipeline stats query set (and mask) so you can begin/end in passes - pub fn pipeline_statistics_query_set(&self) -> Option<(&QuerySet, PipelineStatisticsTypes)> { - self.pipeline_statistics_query_set - .as_ref() - .map(|query_set| (query_set, self.pipeline_statistics_types)) - } - - /// Begin a pipeline statistics query in the pass; returns query index - pub fn begin_pipeline_statistics_query(&self, render_pass: &mut wgpu::RenderPass<'_>) -> Option { - if let Some(query_set) = &self.pipeline_statistics_query_set { - // Check if there is space for another pipeline statistics query - if self.current_pipeline_statistics_query.get() >= self.max_pipeline_statistics_queries { - println!("Profiler: Max pipeline statistics queries reached for this frame"); - return None; - } - - let index = self.current_pipeline_statistics_query.get(); - self.current_pipeline_statistics_query.set(index + 1); - render_pass.begin_pipeline_statistics_query(query_set, index); - Some(index) - } else { - None - } - } - - pub fn summarize_pipeline_statistics_queries(&self, raw: &[u64]) { - use wgpu::PipelineStatisticsTypes as PST; - - let mask = self.pipeline_statistics_types; - if raw.is_empty() || mask.is_empty() { - println!("[GPU] no pipeline statistics recorded"); - return; - } - - let mut layout: Vec<(&'static str, wgpu::PipelineStatisticsTypes)> = Vec::new(); - let push = |v: &mut Vec<_>, name, flag, mask: wgpu::PipelineStatisticsTypes| { if mask.contains(flag) { v.push((name, flag)); } }; - push(&mut layout, "VS invocations", wgpu::PipelineStatisticsTypes::VERTEX_SHADER_INVOCATIONS, mask); - push(&mut layout, "Clipper invocations", wgpu::PipelineStatisticsTypes::CLIPPER_INVOCATIONS, mask); - push(&mut layout, "Clipper primitives out", wgpu::PipelineStatisticsTypes::CLIPPER_PRIMITIVES_OUT, mask); - push(&mut layout, "FS invocations", wgpu::PipelineStatisticsTypes::FRAGMENT_SHADER_INVOCATIONS, mask); - push(&mut layout, "CS invocations", wgpu::PipelineStatisticsTypes::COMPUTE_SHADER_INVOCATIONS, mask); - - let stride = layout.len(); - if stride == 0 { - println!("[GPU] pipeline statistics mask is empty"); - return; - } - - for (query, chunk) in raw.chunks(stride).enumerate() { - if chunk.len() < stride { break; } - println!("[GPU] pipeline stats query {}:", query); - for ((name, _flag), &value) in layout.iter().zip(chunk.iter()) { - println!(" {:>24}: {}", name, value); - } - } - } - - pub fn end_pipeline_statistics_query(&self, render_pass: &mut wgpu::RenderPass<'_>) { - render_pass.end_pipeline_statistics_query(); - } - - pub fn resolve_pipeline_statistics_queries(&mut self, device: &Device, encoder: &mut CommandEncoder) { - if let Some(query_set) = &self.pipeline_statistics_query_set { - let count = self.current_pipeline_statistics_query.get(); - if count == 0 { return; } - let byte_len = self.pipeline_statistics_queries_result_bytes * count as u64; - - let index = self.frame_index; - let slot = &mut self.pipeline_buffers[index]; - let buffer = ensure_buffer_slot(device, slot, byte_len, "gpu_profiler.pipeline_statistics_queries.resolve"); - - encoder.resolve_query_set(query_set, 0..count, buffer, 0); - } - } - - pub fn read_pipeline_statistics_queries_blocking(&self, device: &Device) -> Option> { - let index = (self.frame_index + FRAMES_IN_FLIGHT - 1) % FRAMES_IN_FLIGHT; - let buffer = self.pipeline_buffers[index].as_ref()?; - let slice = buffer.slice(..); - - // Map and wait - slice.map_async(wgpu::MapMode::Read, |_| {}); - device.poll(Maintain::Wait); - let data = slice.get_mapped_range(); - let values: Vec = bytemuck::cast_slice(&data).to_vec(); - - drop(data); - buffer.unmap(); - Some(values) - } - - // --- Misc --- - - pub fn summarize_all_blocking(&self, device: &wgpu::Device) { - if let Some(timestamp_queries) = self.read_timestamp_queries_blocking(device) { - self.summarize_timestamp_queries(×tamp_queries); - } - if let Some(occlusion_queries) = self.read_occlusion_queries_blocking(device) { - self.summarize_occlusion_queries(&occlusion_queries); - } - if let Some(pipeline_statistics_queries) = self.read_pipeline_statistics_queries_blocking(device) { - self.summarize_pipeline_statistics_queries(&pipeline_statistics_queries); - } - } -} - -#[inline] -fn ensure_buffer_slot<'a>( - device: &wgpu::Device, - slot: &'a mut Option, - size: u64, - label: &str, -) -> &'a wgpu::Buffer { - let need_new = slot.as_ref().map(|b| b.size() < size).unwrap_or(true); - if need_new { - *slot = Some(device.create_buffer(&BufferDescriptor { - label: Some(label), - size, - usage: BufferUsages::COPY_SRC | wgpu::BufferUsages::MAP_READ | BufferUsages::QUERY_RESOLVE, - mapped_at_creation: false, - })); - // *slot = Some(device.create_buffer(&wgpu::BufferDescriptor { - // label: Some(label), - // size, - // usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::QUERY_RESOLVE, - // mapped_at_creation: false, - // })); - } - slot.as_ref().unwrap() -} \ No newline at end of file diff --git a/engine/pill_renderer/src/renderer.rs b/engine/pill_renderer/src/renderer.rs deleted file mode 100644 index 6d34ad57..00000000 --- a/engine/pill_renderer/src/renderer.rs +++ /dev/null @@ -1,736 +0,0 @@ -#![allow(clippy::too_many_arguments)] -use crate::{ - config::MAX_INSTANCE_PER_DRAWCALL_COUNT, - drawers::mesh_drawer::MeshDrawer, - instance::Instance, - resources::{ - RendererCamera, RendererMaterial, RendererMesh, RendererResourceStorage, RendererShader, - RendererTexture, Vertex, - }, -}; - -use pill_engine::internal::{ - get_renderer_resource_handle_from_camera_component, CameraComponent, ComponentStorage, - EngineConfig, EntityHandle, MaterialParameter, MaterialTexture, MeshData, PillRenderer, - RenderQueueItem, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, ShaderParameterSlot, ShaderTextureSlot, - TextureType, TransformComponent, -}; - -use pill_core::{debug, info, LogContext, PillSlotMapKey, PillStyle, RendererError, Timer}; - -use std::{collections::HashMap, sync::Arc}; - -use pill_core::{ErrorContext, Result}; - -pub struct Renderer { - pub state: State, -} - -impl Renderer { - /// Async constructor for WASM - call this instead of PillRenderer::new on web - pub async fn new_async( - window: Arc, - config: EngineConfig, - ) -> Result { - info!(LogContext::Rendering => "Initializing {}", "Renderer".module_object_style()); - let state: State = State::new(window, config).await?; - Ok(Self { state }) - } -} - -impl PillRenderer for Renderer { - #[cfg(not(target_arch = "wasm32"))] - fn new(window: Arc, config: EngineConfig) -> Result { - info!(LogContext::Rendering => "Initializing {}", "Renderer".module_object_style()); - let state: State = pollster::block_on(State::new(window, config))?; - - Ok(Self { state }) - } - - #[cfg(target_arch = "wasm32")] - fn new(_window: Arc, _config: EngineConfig) -> Result { - panic!("Use Renderer::new_async on WASM") - } - - // --- Create --- - - fn create_shader( - &mut self, - name: &str, - vertex_wgsl: &str, - fragment_wgsl: &str, - texture_slots: &HashMap, - parameter_slots: &[(String, ShaderParameterSlot)], - pass_engine_parameters: bool, - pass_camera_parameters: bool, - ) -> Result { - let shader = RendererShader::new( - name, - &self.state.device, - self.state.color_format, - Some(self.state.depth_format), - &[ - RendererMesh::data_layout_descriptor(), - Instance::data_layout_descriptor(), - ], - vertex_wgsl, - fragment_wgsl, - parameter_slots, - texture_slots, - &self - .state - .renderer_resource_storage - .engine_parameters - .bind_group_layout, - &self.state.camera_bind_group_layout, - pass_engine_parameters, - pass_camera_parameters, - )?; - let handle = self.state.renderer_resource_storage.shaders.insert(shader); - - Ok(handle) - } - - fn create_material( - &mut self, - name: &str, - renderer_shader_handle: RendererShaderHandle, - textures: &[(String, MaterialTexture)], - parameters: &HashMap, - ) -> Result { - let material = RendererMaterial::new( - &self.state.device, - &self.state.queue, - &self.state.renderer_resource_storage, - name, - renderer_shader_handle, - textures, - parameters, - )?; - let handle = self - .state - .renderer_resource_storage - .materials - .insert(material); - Ok(handle) - } - - fn create_texture( - &mut self, - name: &str, - rgba: &[u8], - width: u32, - height: u32, - texture_type: TextureType, - ) -> Result { - let texture = RendererTexture::new_texture( - &self.state.device, - &self.state.queue, - Some(name), - rgba, - width, - height, - texture_type, - )?; - let handle = self - .state - .renderer_resource_storage - .textures - .insert(texture); - Ok(handle) - } - - fn create_mesh(&mut self, name: &str, mesh_data: &MeshData) -> Result { - let mesh = RendererMesh::new(&self.state.device, name, mesh_data)?; - let handle = self.state.renderer_resource_storage.meshes.insert(mesh); - Ok(handle) - } - - fn create_camera(&mut self) -> Result { - let camera = RendererCamera::new( - &self.state.device, - self.state.camera_bind_group_layout.clone(), - )?; - let handle = self.state.renderer_resource_storage.cameras.insert(camera); - Ok(handle) - } - - // --- Update --- - - fn update_material_textures( - &mut self, - renderer_material_handle: RendererMaterialHandle, - textures: &[(String, MaterialTexture)], - ) -> Result<()> { - RendererMaterial::update_textures( - &self.state.device, - renderer_material_handle, - &mut self.state.renderer_resource_storage, - textures, - ) - } - - fn update_material_parameters( - &mut self, - renderer_material_handle: RendererMaterialHandle, - parameters: &HashMap, - ) -> Result<()> { - RendererMaterial::update_parameters( - &self.state.device, - &self.state.queue, - renderer_material_handle, - &mut self.state.renderer_resource_storage, - parameters, - ) - } - - // --- Destroy --- - - fn destroy_shader(&mut self, renderer_shader_handle: RendererShaderHandle) -> Result<()> { - self.state - .renderer_resource_storage - .shaders - .remove(renderer_shader_handle) - .unwrap(); - - // TODO: Check if there are no materials using this shader (engine should replace them with default shader), if there are prevent shader destruction - Ok(()) - } - - fn destroy_material(&mut self, renderer_material_handle: RendererMaterialHandle) -> Result<()> { - self.state - .renderer_resource_storage - .materials - .remove(renderer_material_handle) - .unwrap(); - Ok(()) - } - - fn destroy_texture(&mut self, renderer_texture_handle: RendererTextureHandle) -> Result<()> { - self.state - .renderer_resource_storage - .textures - .remove(renderer_texture_handle) - .unwrap(); - Ok(()) - } - - fn destroy_mesh(&mut self, renderer_mesh_handle: RendererMeshHandle) -> Result<()> { - self.state - .renderer_resource_storage - .meshes - .remove(renderer_mesh_handle) - .unwrap(); - Ok(()) - } - - fn destroy_camera(&mut self, renderer_camera_handle: RendererCameraHandle) -> Result<()> { - self.state - .renderer_resource_storage - .cameras - .remove(renderer_camera_handle) - .unwrap(); - Ok(()) - } - - // --- Other --- - - fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize) { - info!(LogContext::Rendering => "Resizing {} resources", "Renderer".module_object_style()); - self.state.resize(new_window_size) - } - - #[cfg(feature = "debug_ui")] - fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) -> Result<()> { - self.state.egui_drawer.handle_input(event); - Ok(()) - } - - #[cfg(feature = "debug_ui")] - fn render( - &mut self, - active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, - egui_ui: Box, - delta_time: f32, - timer: &mut Timer, - ) -> Result<()> { - self.state.pending_egui_ui = Some(egui_ui); - self.state.render( - active_camera_entity_handle, - render_queue, - camera_component_storage, - transform_component_storage, - delta_time, - timer, - ) - } - - #[cfg(not(feature = "debug_ui"))] - fn render( - &mut self, - active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, - delta_time: f32, - timer: &mut Timer, - ) -> Result<()> { - self.state.render( - active_camera_entity_handle, - render_queue, - camera_component_storage, - transform_component_storage, - delta_time, - timer, - ) - } -} - -pub struct State { - // Resources - renderer_resource_storage: RendererResourceStorage, - // Renderer variables - surface: wgpu::Surface<'static>, - device: wgpu::Device, - queue: wgpu::Queue, - surface_configuration: wgpu::SurfaceConfiguration, - window_size: winit::dpi::PhysicalSize, - color_format: wgpu::TextureFormat, - depth_format: wgpu::TextureFormat, - depth_texture: RendererTexture, - // Drawers - mesh_drawer: MeshDrawer, - #[cfg(feature = "debug_ui")] - egui_drawer: crate::drawers::egui_drawer::EguiDrawer, - #[cfg(feature = "debug_ui")] - pending_egui_ui: Option>, - // Other - camera_bind_group_layout: wgpu::BindGroupLayout, - //profiler: Profiler, -} - -impl State { - // Creating some of the wgpu types requires async code - async fn new(window: Arc, config: EngineConfig) -> Result { - let window_width = config - .get_int("WINDOW_WIDTH") - .context("WINDOW_WIDTH is missing from config")? as u32; - let window_height = config - .get_int("WINDOW_HEIGHT") - .context("WINDOW_HEIGHT is missing from config")? as u32; - let window_size = winit::dpi::PhysicalSize::new(window_width, window_height); - #[cfg(feature = "debug_ui")] - let window_ref = window.clone(); - - // 1. Create instance and surface - let (instance, surface) = { - let backends = match std::env::var("WGPU_BACKENDS").as_deref() { - std::result::Result::Ok("VULKAN") => wgpu::Backends::VULKAN, - std::result::Result::Ok("DX12") => wgpu::Backends::DX12, - std::result::Result::Ok("METAL") => wgpu::Backends::METAL, - std::result::Result::Ok("GL") => wgpu::Backends::GL, - std::result::Result::Ok("BROWSER_WEBGPU") => wgpu::Backends::BROWSER_WEBGPU, - _ => wgpu::Backends::all(), - }; - - let instance_descriptor = wgpu::InstanceDescriptor { - backends, - flags: wgpu::InstanceFlags::from_build_config().with_env(), - backend_options: wgpu::BackendOptions::default(), - }; - - let instance = wgpu::Instance::new(&instance_descriptor); - let surface = instance - .create_surface(window) - .context("Failed to create surface")?; - (instance, surface) - }; - - // 2. Adapter - let adapter = { - let request_adapter_options = wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::default(), - compatible_surface: Some(&surface), - force_fallback_adapter: false, - }; - instance - .request_adapter(&request_adapter_options) - .await - .context("Failed to request adapter")? - }; - - let info = adapter.get_info(); - info!(LogContext::Rendering => "Using GPU: {} ({:?})", info.name, info.backend); - - // 3. Device and queue - let (device, queue) = { - // Ask only for features the adapter actually supports. On Metal - // (macOS) PIPELINE_STATISTICS_QUERY isn't available; on some - // WebGPU adapters TIMESTAMP_QUERY isn't either. Downstream code - // (profiler.rs) checks `device.features().contains(...)` before - // using them, so narrowing here is safe. - let wanted = wgpu::Features::DEPTH_CLIP_CONTROL - | wgpu::Features::TIMESTAMP_QUERY - | wgpu::Features::PIPELINE_STATISTICS_QUERY; - let features = wanted & adapter.features(); - - let device_descriptor = wgpu::DeviceDescriptor { - label: None, - required_features: features, - required_limits: wgpu::Limits::default(), - memory_hints: wgpu::MemoryHints::default(), - trace: wgpu::Trace::default(), - }; - - adapter - .request_device(&device_descriptor) - .await - .context("Failed to request device")? - }; - - // 4. Surface configuration - let (surface_configuration, color_format, depth_format) = { - let preferred_format = wgpu::TextureFormat::Rgba8UnormSrgb; - - // Get supported present modes and choose the best one - let surface_capabilities = surface.get_capabilities(&adapter); - - // Present mode: on wasm, Mailbox/Immediate aren't universally - // supported — stick to Fifo. On native, prefer Mailbox → Immediate - // → Fifo based on what the surface advertises. - #[cfg(target_arch = "wasm32")] - let present_mode = wgpu::PresentMode::Fifo; - #[cfg(not(target_arch = "wasm32"))] - let present_mode = if surface_capabilities - .present_modes - .contains(&wgpu::PresentMode::Mailbox) - { - wgpu::PresentMode::Mailbox - } else if surface_capabilities - .present_modes - .contains(&wgpu::PresentMode::Immediate) - { - wgpu::PresentMode::Immediate - } else { - wgpu::PresentMode::Fifo - }; - - // Choose the best supported format - let format = if surface_capabilities.formats.contains(&preferred_format) { - preferred_format - } else if surface_capabilities - .formats - .contains(&wgpu::TextureFormat::Bgra8UnormSrgb) - { - wgpu::TextureFormat::Bgra8UnormSrgb - } else if surface_capabilities - .formats - .contains(&wgpu::TextureFormat::Bgra8Unorm) - { - wgpu::TextureFormat::Bgra8Unorm - } else { - surface_capabilities.formats[0] // Use first available format - }; - - let surface_configuration = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format, - width: window_size.width, - height: window_size.height, - desired_maximum_frame_latency: 2, - present_mode, - alpha_mode: wgpu::CompositeAlphaMode::Auto, - view_formats: vec![format], - }; - surface.configure(&device, &surface_configuration); - let color_format = surface_configuration.format; - let depth_format = wgpu::TextureFormat::Depth32Float; - (surface_configuration, color_format, depth_format) - }; - - // 5. Depth texture - let depth_texture = - RendererTexture::new_depth_texture(&device, &surface_configuration, "depth_texture") - .context("Failed to create depth texture")?; - - // 6. Define camera bind group layout - // Each camera instance has the same bind group layout is we define it here once - let camera_bind_group_layout = - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("camera_parameters_bind_group_layout"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, // (set = X, binding = 0) - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, // Specifies if this buffer will be changing size or not - min_binding_size: None, - }, - count: None, - }], - }); - - // 7. Resource storage - let renderer_resource_storage = RendererResourceStorage::new(&device, &config)?; - - // 8. Drawers - let mesh_drawer = MeshDrawer::new(&device, MAX_INSTANCE_PER_DRAWCALL_COUNT as u32); - #[cfg(feature = "debug_ui")] - let egui_drawer = crate::drawers::egui_drawer::EguiDrawer::new( - &device, - surface_configuration.format, - None, - 1, - window_ref, - ); - - // 9. Profiler - // let profiler = { - // Profiler::new( - // &device, - // &queue, - // &adapter, - // 16, // up to 16 timestamp marks per frame - // 64, // up to 64 occlusion queries per frame - // 64, // up to 64 pipeline statistics queries per frame - // wgpu::PipelineStatisticsTypes::VERTEX_SHADER_INVOCATIONS - // | wgpu::PipelineStatisticsTypes::FRAGMENT_SHADER_INVOCATIONS, - // ) - // }; - - // Create state - let renderer = Self { - // Resources - renderer_resource_storage, - // Renderer variables - surface, - device, - queue, - surface_configuration, - window_size, - color_format, - depth_format, - depth_texture, - // Drawers - mesh_drawer, - #[cfg(feature = "debug_ui")] - egui_drawer, - #[cfg(feature = "debug_ui")] - pending_egui_ui: None, - // Other - camera_bind_group_layout, - // profiler - }; - - Ok(renderer) - } - - fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize) { - if new_window_size.width > 0 && new_window_size.height > 0 { - self.window_size = new_window_size; - self.surface_configuration.width = new_window_size.width; - self.surface_configuration.height = new_window_size.height; - self.surface - .configure(&self.device, &self.surface_configuration); - self.depth_texture = RendererTexture::new_depth_texture( - &self.device, - &self.surface_configuration, - "depth_texture", - ) - .unwrap(); - } - } - - fn render( - &mut self, - active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, - _delta_time: f32, - timer: &mut Timer, - ) -> Result<()> { - debug!(LogContext::Frame => "Starting frame render"); - - timer.record("Get frame"); - // self.profiler.begin_frame(); - - // Get frame or return mapped error if failed - let frame = self.surface.get_current_texture(); - let frame = match frame { - std::result::Result::Ok(frame) => frame, - std::result::Result::Err(error) => match error { - wgpu::SurfaceError::Lost => return Err(RendererError::SurfaceLost.into()), - wgpu::SurfaceError::OutOfMemory => { - return Err(RendererError::SurfaceOutOfMemory.into()) - } - _ => return Err(RendererError::SurfaceOther.into()), - }, - }; - - let view = frame - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); - - timer.record("Update camera parameters"); - - // Get active camera and update it - let camera_storage = camera_component_storage - .data - .get(active_camera_entity_handle.data().index as usize) - .unwrap(); - let active_camera_component = camera_storage.as_ref().unwrap(); - - timer.record("Update engine parameters"); - - // Engine uniform is updated AFTER the camera lookup so fog (carried on - // CameraComponent) can be forwarded into the `engine` UBO alongside delta_time. - self.renderer_resource_storage.engine_parameters.update( - &self.queue, - active_camera_component.fog_density, - [ - active_camera_component.fog_color.x, - active_camera_component.fog_color.y, - active_camera_component.fog_color.z, - ], - ); - let renderer_camera = self - .renderer_resource_storage - .cameras - .get_mut(get_renderer_resource_handle_from_camera_component( - active_camera_component, - )) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - let camera_transform_storage = transform_component_storage - .data - .get(active_camera_entity_handle.data().index as usize) - .unwrap(); - let active_camera_transform_component = camera_transform_storage.as_ref().unwrap(); - renderer_camera.update( - &self.queue, - active_camera_component, - active_camera_transform_component, - ); - let renderer_camera = self - .renderer_resource_storage - .cameras - .get(get_renderer_resource_handle_from_camera_component( - active_camera_component, - )) - .unwrap(); - let clear_color = active_camera_component.clear_color; - - // Build a command buffer that can be sent to the GPU - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("render_encoder"), - }); - - // let _timestamp_query_start = self.profiler.write_timestamp(&mut encoder, "Start Render Pass"); - - // Render meshes - { - timer.record("Create render pass attachments"); - - // Create color attachment - let color_attachment = wgpu::RenderPassColorAttachment { - view: &view, // Specifies what texture to save the colors to - resolve_target: None, // Specifies what texture will receive the resolved output - ops: wgpu::Operations { - // Specifies what to do with the colors on the screen - load: wgpu::LoadOp::Clear(wgpu::Color { - r: clear_color.x as f64, - g: clear_color.y as f64, - b: clear_color.z as f64, - a: 1.0, - }), // Specifies how to handle colors stored from the previous frame - store: wgpu::StoreOp::Store, - }, - //depth_slice: None, - }; - - // Create depth attachment - let depth_stencil_attachment = wgpu::RenderPassDepthStencilAttachment { - view: &self.depth_texture.texture_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }; - - debug!(LogContext::Frame => "Start recording mesh draw commands"); - timer.begin_context("Mesh Drawer"); - - self.mesh_drawer.record_draw_commands( - &self.queue, - &mut encoder, - &self.renderer_resource_storage, - color_attachment, - depth_stencil_attachment, - renderer_camera, - render_queue, - transform_component_storage, - timer, - //&mut self.profiler - )?; - - timer.end_context()?; - } - - // Render egui UI - #[cfg(feature = "debug_ui")] - if let Some(egui_ui) = self.pending_egui_ui.take() { - timer.begin_context("Egui Draw"); - debug!(LogContext::Frame => "Start recording egui draw commands"); - - self.egui_drawer.record_draw_commands( - &self.device, - &self.queue, - &mut encoder, - &view, - egui_wgpu::ScreenDescriptor { - size_in_pixels: [ - self.surface_configuration.width, - self.surface_configuration.height, - ], - pixels_per_point: self.egui_drawer.window_scale_factor, - }, - egui_ui, - timer, - )?; - - timer.end_context()?; // End Egui Draw context - } - // let _timestamp_query_end = self.profiler.write_timestamp(&mut encoder, "End Render Pass"); - - // Resolve queries recorded this frame - // self.profiler.resolve_timestamp_queries(&self.device, &mut encoder); - // self.profiler.resolve_occlusion_queries(&self.device, &mut encoder); - // self.profiler.resolve_pipeline_statistics_queries(&self.device, &mut encoder); - - timer.record("Submit commands and present frame"); - - // Submit the command buffer to the GPU - self.queue.submit(std::iter::once(encoder.finish())); - - timer.record("Read profiling data"); - - // self.profiler.end_frame(); - - // Read profiling data - //self.profiler.summarize_all_blocking(&self.device); - - // Present the frame - frame.present(); - - Ok(()) - } -} diff --git a/engine/pill_renderer/src/resources/renderer_material.rs b/engine/pill_renderer/src/resources/renderer_material.rs deleted file mode 100644 index ed69cf21..00000000 --- a/engine/pill_renderer/src/resources/renderer_material.rs +++ /dev/null @@ -1,332 +0,0 @@ -#![cfg_attr(debug_assertions, allow(dead_code, unused_imports, unused_variables))] - -use pill_core::{debug, LogContext, PillStyle, RendererError}; -use pill_engine::internal::{ - get_default_texture_handles, get_renderer_texture_handle_from_material_texture, - MaterialParameter, MaterialTexture, RendererMaterialHandle, RendererShaderHandle, - ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, -}; - -use crate::resources::RendererResourceStorage; -use pill_core::Result; -use std::collections::HashMap; - -// --- Material --- - -pub struct RendererMaterial { - pub name: String, - pub shader_handle: RendererShaderHandle, - pub parameters_bind_group: Option, - pub textures_bind_group: Option, - pub(crate) parameters_uniform_buffer: Option, -} - -impl RendererMaterial { - pub fn new( - device: &wgpu::Device, - queue: &wgpu::Queue, - rendering_resource_storage: &RendererResourceStorage, - name: &str, - shader_handle: RendererShaderHandle, - textures: &[(String, MaterialTexture)], - parameters: &HashMap, - ) -> Result { - debug!(LogContext::Rendering => "Creating material {}", name.name_style()); - - let shader = rendering_resource_storage - .shaders - .get(shader_handle) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - - let parameter_slots = &shader.parameter_slots; - let texture_slots = &shader.texture_slots; - - // Create parameters uniform buffer and bind group if there are parameter slots - let (parameters_bind_group, parameters_uniform_buffer) = { - if !parameter_slots.is_empty() { - // Calculate uniform buffer size, create buffer if needed and write data to it - let parameters_uniform_buffer_size = Self::calculate_uniform_size(parameter_slots); - - let parameters_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some(&format!("{}_material_parameters_buffer", name)), - size: parameters_uniform_buffer_size as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - // Write parameter data to buffer - Self::write_parameters_to_buffer( - queue, - ¶meters_uniform_buffer, - parameter_slots, - parameters, - )?; - - debug!(LogContext::Rendering => "Uniform buffer of size {} bytes created", parameters_uniform_buffer_size); - - // Create parameters uniform buffer bind group - let parameters_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some(&format!("{}_material_parameters_bind_group", name)), - layout: shader.parameters_bind_group_layout.as_ref().unwrap(), - entries: &[wgpu::BindGroupEntry { - binding: 0, // (set = 2, binding = 0) - resource: parameters_uniform_buffer.as_entire_binding(), - }], - }); - - debug!(LogContext::Rendering => "Parameters bind group created"); - - (Some(parameters_bind_group), Some(parameters_uniform_buffer)) - } else { - debug!(LogContext::Rendering => "No parameter slots found, skipping uniform buffer and bind group creation"); - (None, None) - } - }; - - // Create texture bind group - let textures_bind_group = if !texture_slots.is_empty() { - Some(Self::create_textures_bind_group( - device, - rendering_resource_storage, - shader.textures_bind_group_layout.as_ref().unwrap(), - &format!("{}_textures", name), - texture_slots, - textures, - )?) - } else { - None - }; - - debug!(LogContext::Rendering => "Textures bind group created"); - - let renderer_material = Self { - name: name.to_string(), - shader_handle, - parameters_bind_group, - textures_bind_group, - parameters_uniform_buffer, - }; - - debug!(LogContext::Rendering => "Material creation successful"); - - Ok(renderer_material) - } - - pub fn update_textures( - _device: &wgpu::Device, - _material_renderer_handle: RendererMaterialHandle, - _rendering_resource_storage: &mut RendererResourceStorage, - _textures: &[(String, MaterialTexture)], - ) -> Result<()> { - // let material = rendering_resource_storage.materials.get(material_renderer_handle) - // .ok_or(RendererError::RendererResourceNotFound.into())?; - // let shader_handle = material.shader_handle; - // let shader = rendering_resource_storage.shaders.get(shader_handle) - // .ok_or(RendererError::RendererResourceNotFound.into())?; - - //let texture_slots = &shader.texture_slots; - - // TODO: Implement - // Recreate texture bind group - // if !texture_slots.is_empty() && !shader.bind_group_layouts.is_empty() { - // let texture_bind_group = Self::create_texture_bind_group( - // device, - // rendering_resource_storage, - // &shader.bind_group_layouts[0], - // &format!("{}_textures", material.name), - // texture_slots, - // textures - // )?; - - // let material = rendering_resource_storage.materials.get_mut(material_renderer_handle) - // .ok_or(RendererError::RendererResourceNotFound.into())?; - - // // if !material.bind_groups.is_empty() { - // // material.bind_groups[0] = texture_bind_group; - // // } - // } - - Ok(()) - } - - pub fn update_parameters( - _device: &wgpu::Device, - queue: &wgpu::Queue, - material_renderer_handle: RendererMaterialHandle, - rendering_resource_storage: &mut RendererResourceStorage, - parameters: &HashMap, - ) -> Result<()> { - let material = rendering_resource_storage - .materials - .get(material_renderer_handle) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - let shader_handle = material.shader_handle; - let shader = rendering_resource_storage - .shaders - .get(shader_handle) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - - let parameter_slots = &shader.parameter_slots; - - let material = rendering_resource_storage - .materials - .get_mut(material_renderer_handle) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - - // Update uniform buffer if it exists - if let Some(ref buffer) = material.parameters_uniform_buffer { - Self::write_parameters_to_buffer(queue, buffer, parameter_slots, parameters)?; - } - - Ok(()) - } - - fn calculate_uniform_size(parameter_slots: &[(String, ShaderParameterSlot)]) -> usize { - // Calculate total size needed for all parameters - // Each parameter slot gets 16 bytes (vec4 alignment in WGSL) - parameter_slots.len() * 16 - } - - fn write_parameters_to_buffer( - queue: &wgpu::Queue, - buffer: &wgpu::Buffer, - parameter_slots: &[(String, ShaderParameterSlot)], - parameters: &HashMap, - ) -> Result<()> { - // Create a temporary buffer to hold all parameter data - let mut data = Vec::new(); - - // NOTE: Each parameter is 16 bytes (vec4 alignment in WGSL) - // Padding is added to ensure each parameter takes 16 bytes - // This is not ideal because we could make it more efficient by packing parameters more tightly - // But for simplicity, we will keep it this way for now - for (slot_name, slot) in parameter_slots { - match slot.parameter_type { - ShaderParameterType::Color => { - // Color parameter (3 floats + padding) - if let Some(MaterialParameter::Color(value)) = parameters.get(slot_name) { - data.extend_from_slice(&value.x.to_le_bytes()); - data.extend_from_slice(&value.y.to_le_bytes()); - data.extend_from_slice(&value.z.to_le_bytes()); - data.extend_from_slice(&0.0f32.to_le_bytes()); // Padding - } else { - data.extend_from_slice(&[0u8; 16]); - } - } - ShaderParameterType::Scalar => { - // Scalar parameter (1 float + padding) - if let Some(MaterialParameter::Scalar(value)) = parameters.get(slot_name) { - data.extend_from_slice(&value.to_le_bytes()); - data.extend_from_slice(&[0u8; 12]); // Padding to 16 bytes - } else { - data.extend_from_slice(&[0u8; 16]); - } - } - ShaderParameterType::Bool => { - // Bool parameter (1 u32 + padding) - if let Some(MaterialParameter::Bool(value)) = parameters.get(slot_name) { - let value: u32 = if *value { 1 } else { 0 }; - data.extend_from_slice(&value.to_le_bytes()); - data.extend_from_slice(&[0u8; 12]); // Padding to 16 bytes - } else { - data.extend_from_slice(&[0u8; 16]); - } - } - } - } - - if !data.is_empty() { - queue.write_buffer(buffer, 0, &data); - } - - Ok(()) - } - - fn create_textures_bind_group( - device: &wgpu::Device, - rendering_resource_storage: &RendererResourceStorage, - texture_bind_group_layout: &wgpu::BindGroupLayout, - name: &str, - texture_slots: &HashMap, - textures: &[(String, MaterialTexture)], - ) -> Result { - let mut entries = Vec::new(); - - for (slot_name, slot) in texture_slots { - // Get texture from material texture map or use default - let renderer_texture_handle = match textures - .iter() - .find(|(k, _)| k == slot_name) - .map(|(_, v)| v) - { - Some(material_texture) => { - debug!(LogContext::Rendering => "Material texture slot {} found in material textures", slot_name.name_style()); - get_renderer_texture_handle_from_material_texture(material_texture).unwrap() - } - None => { - debug!(LogContext::Rendering => "Material texture slot {} not found in material textures, using default texture", slot_name.name_style()); - get_default_texture_handles(slot.texture_type).1 - } - }; - - let texture = rendering_resource_storage - .textures - .get(renderer_texture_handle) - .unwrap(); - - // Add texture view entry - entries.push(wgpu::BindGroupEntry { - binding: slot.texture_binding, - resource: wgpu::BindingResource::TextureView(&texture.texture_view), - }); - - // Add sampler entry - entries.push(wgpu::BindGroupEntry { - binding: slot.sampler_binding, - resource: wgpu::BindingResource::Sampler(&texture.sampler), - }); - } - - // Set texture resources to the bind group - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - layout: texture_bind_group_layout, - entries: &entries, - label: Some(name), - }); - - Ok(bind_group) - } - - // fn create_parameters_bind_group( - // device: &wgpu::Device, - // parameter_bind_group_layout: &wgpu::BindGroupLayout, - // name: &str, - // buffer: &wgpu::Buffer, - // ) -> Result { - // let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - // layout: parameter_bind_group_layout, - // entries: &[ - // wgpu::BindGroupEntry { - // binding: MATERIAL_PARAMETERS_BINDING_INDEX as u32, - // resource: buffer.as_entire_binding(), - // }, - // ], - // label: Some(name), - // }); - - // Ok(bind_group) - // } - - // Helper method to get bind group by index - // pub fn get_bind_group(&self, index: usize) -> Option<&wgpu::BindGroup> { - // self.bind_groups.get(index) - // } -} diff --git a/engine/pill_renderer/src/resources/renderer_resource_storage.rs b/engine/pill_renderer/src/resources/renderer_resource_storage.rs deleted file mode 100644 index 0dc8eba3..00000000 --- a/engine/pill_renderer/src/resources/renderer_resource_storage.rs +++ /dev/null @@ -1,61 +0,0 @@ -use crate::resources::{ - EngineParameters, RendererCamera, RendererMaterial, RendererMesh, RendererShader, - RendererTexture, -}; - -use pill_engine::internal::{ - EngineConfig, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, -}; - -use pill_core::PillSlotMap; -use pill_core::Result; - -pub const MAX_SHADERS: usize = 10; -pub const MAX_TEXTURES: usize = 10; -pub const MAX_MATERIALS: usize = 10; -pub const MAX_MESHES: usize = 10; -pub const MAX_CAMERAS: usize = 10; - -pub struct RendererResourceStorage { - pub(crate) shaders: PillSlotMap, - pub(crate) materials: PillSlotMap, - pub(crate) textures: PillSlotMap, - pub(crate) meshes: PillSlotMap, - pub(crate) cameras: PillSlotMap, - pub(crate) engine_parameters: EngineParameters, -} - -impl RendererResourceStorage { - pub fn new(device: &wgpu::Device, config: &EngineConfig) -> Result { - let max_shader_count = config.get_int("MAX_SHADERS").unwrap_or(MAX_SHADERS as i64) as usize; - let max_texture_count = config - .get_int("MAX_TEXTURES") - .unwrap_or(MAX_TEXTURES as i64) as usize; - let max_material_count = config - .get_int("MAX_MATERIALS") - .unwrap_or(MAX_MATERIALS as i64) as usize; - let max_mesh_count = config.get_int("MAX_MESHES").unwrap_or(MAX_MESHES as i64) as usize; - let max_camera_count = config.get_int("MAX_CAMERAS").unwrap_or(MAX_CAMERAS as i64) as usize; - - Ok(RendererResourceStorage { - shaders: PillSlotMap::::with_capacity_and_key( - max_shader_count, - ), - textures: PillSlotMap::::with_capacity_and_key( - max_texture_count, - ), - materials: - PillSlotMap::::with_capacity_and_key( - max_material_count, - ), - meshes: PillSlotMap::::with_capacity_and_key( - max_mesh_count, - ), - cameras: PillSlotMap::::with_capacity_and_key( - max_camera_count, - ), - engine_parameters: EngineParameters::new(device)?, - }) - } -} diff --git a/engine/pill_renderer/src/resources/renderer_texture.rs b/engine/pill_renderer/src/resources/renderer_texture.rs deleted file mode 100644 index 4ebcde7c..00000000 --- a/engine/pill_renderer/src/resources/renderer_texture.rs +++ /dev/null @@ -1,142 +0,0 @@ -use pill_engine::internal::TextureType; - -use pill_core::Result; - -// --- Texture --- - -pill_core::define_new_pill_slotmap_key! { - pub struct RendererTextureHandle; -} - -pub struct RendererTexture { - pub texture: wgpu::Texture, - pub texture_view: wgpu::TextureView, - pub sampler: wgpu::Sampler, -} - -impl RendererTexture { - pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; - - pub fn new_texture( - device: &wgpu::Device, - queue: &wgpu::Queue, - name: Option<&str>, - rgba: &[u8], - width: u32, - height: u32, - texture_type: TextureType, - ) -> Result { - // Get size - let size = wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }; - - // Specify texture format - let format = match texture_type { - TextureType::Color => wgpu::TextureFormat::Rgba8UnormSrgb, - TextureType::Normal => wgpu::TextureFormat::Rgba8Unorm, - }; - - // Create texture - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: name, - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - - // Write data to texture - queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - rgba, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4 * width), - rows_per_image: Some(height), - }, - size, - ); - - // Create texture view - let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - - // Create sampler - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - address_mode_u: wgpu::AddressMode::Repeat, - address_mode_v: wgpu::AddressMode::Repeat, - address_mode_w: wgpu::AddressMode::Repeat, - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Nearest, - mipmap_filter: wgpu::FilterMode::Nearest, - lod_min_clamp: 0.0, // Fix: Must be 0.0 or greater - lod_max_clamp: 100.0, // You can set this based on your texture's mipmap levels - ..Default::default() - }); - - Ok(Self { - texture, - texture_view, - sampler, - }) - } - - pub fn new_depth_texture( - device: &wgpu::Device, - surface_configuration: &wgpu::SurfaceConfiguration, - label: &str, - ) -> Result { - // Get size - let size = wgpu::Extent3d { - // Depth texture needs to be the same size as window - width: surface_configuration.width, - height: surface_configuration.height, - depth_or_array_layers: 1, - }; - - // Create texture - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some(label), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: Self::DEPTH_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, // Rendering to this texture so RENDER_ATTACHMENT flag is needed - view_formats: &[], - }); - - // Create texture view - let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - - // Create sampler - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - address_mode_u: wgpu::AddressMode::Repeat, - address_mode_v: wgpu::AddressMode::Repeat, - address_mode_w: wgpu::AddressMode::Repeat, - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - mipmap_filter: wgpu::FilterMode::Nearest, - compare: Some(wgpu::CompareFunction::LessEqual), - lod_min_clamp: 0.0, - lod_max_clamp: 100.0, - ..Default::default() - }); - - Ok(Self { - texture, - texture_view, - sampler, - }) - } -} diff --git a/engine/pill_runtime/Cargo.toml b/engine/pill_runtime/Cargo.toml index 22b4d6a0..2f2aea86 100644 --- a/engine/pill_runtime/Cargo.toml +++ b/engine/pill_runtime/Cargo.toml @@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"] pill_abi = { path = "../pill_abi" } pill_core = { path = "../pill_core" } pill_engine = { path = "../pill_engine", features = ["internal"] } -pill_renderer = { path = "../pill_renderer" } libloading = "0.8.8" winit = "0.30.12" diff --git a/engine/pill_runtime/src/lib.rs b/engine/pill_runtime/src/lib.rs index 978894de..30de9d39 100644 --- a/engine/pill_runtime/src/lib.rs +++ b/engine/pill_runtime/src/lib.rs @@ -10,7 +10,7 @@ use libloading::{Library, Symbol}; use pill_abi::*; use pill_core::{PillError, Result}; use pill_engine::internal::*; -use pill_renderer::Renderer; +use pill_engine::renderer::Renderer; use winit::{ dpi::{PhysicalPosition, PhysicalSize}, window::Window, diff --git a/engine/pill_web/Cargo.toml b/engine/pill_web/Cargo.toml index 04ba55ef..4ecc682c 100644 --- a/engine/pill_web/Cargo.toml +++ b/engine/pill_web/Cargo.toml @@ -17,7 +17,6 @@ crate-type = ["rlib"] [target.'cfg(target_arch = "wasm32")'.dependencies] pill_engine = { path = "../pill_engine", features = ["game", "internal"] } -pill_renderer = { path = "../pill_renderer" } pill_core = { path = "../pill_core" } wasm-bindgen = "0.2" diff --git a/engine/pill_web/src/lib.rs b/engine/pill_web/src/lib.rs index 53c59464..a1b57b83 100644 --- a/engine/pill_web/src/lib.rs +++ b/engine/pill_web/src/lib.rs @@ -16,7 +16,7 @@ use winit::{ }; use pill_engine::internal::*; -use pill_renderer::Renderer; +use pill_engine::renderer::Renderer; // In release, every fallible call uses unwrap_unchecked so LLVM sees the Err // branch as UB and DCEs the whole branch — including format strings, Display diff --git a/examples/Empty/res/config.ini b/examples/Empty/res/config.ini index 96131ded..eabaae67 100644 --- a/examples/Empty/res/config.ini +++ b/examples/Empty/res/config.ini @@ -2,7 +2,7 @@ TITLE=Empty # WINDOW -WINDOW_TITLE=Empty +WINDOW_TITLE=Pill Engine - Empty WINDOW_WIDTH=1200 WINDOW_HEIGHT=800 WINDOW_FULLSCREEN=false diff --git a/examples/cube/Cargo.toml b/examples/cube/Cargo.toml index 3433b742..241c1f4b 100644 --- a/examples/cube/Cargo.toml +++ b/examples/cube/Cargo.toml @@ -2,7 +2,7 @@ name = "pill_game" version = "0.1.0" edition = "2021" -workspace = "/Users/mk/dev/demo/Pill-Engine/engine" +workspace = "NO_PATH" [lib] crate-type = ["cdylib", "rlib"] diff --git a/examples/cube/res/config.ini b/examples/cube/res/config.ini index 85295b7f..d838977c 100644 --- a/examples/cube/res/config.ini +++ b/examples/cube/res/config.ini @@ -2,7 +2,7 @@ TITLE=Cube # WINDOW -WINDOW_TITLE=Cube +WINDOW_TITLE=Pill Engine - Cube WINDOW_WIDTH=1200 WINDOW_HEIGHT=800 WINDOW_FULLSCREEN=false diff --git a/examples/cube/src/game.rs b/examples/cube/src/game.rs index 08b240e6..86a5bba6 100644 --- a/examples/cube/src/game.rs +++ b/examples/cube/src/game.rs @@ -6,7 +6,7 @@ fn rotate_cubes_system(engine: &mut Engine) -> Result<()> { let dt = engine.get_global_component::()?.delta_time; for (_entity, transform, _mesh) in - engine.iterate_two_components_mut::()? + engine.iterate_two_components_mut::()? { let rot = transform.rotation; transform.set_rotation(Vector3f::new( @@ -26,14 +26,11 @@ impl PillGame for WebGame { engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; let cube_mesh_handle = engine.add_resource(Mesh::cube("cube", 2.0))?; - let material_handle = engine.add_resource( - Material::builder("cube_material") - .color_parameter("tint", Color::new(0.80, 0.80, 0.82))? - .build(), - )?; + let material_handle = engine + .add_resource(PBRMaterial::new("cube_material").albedo(Color::new(0.80, 0.80, 0.82)))?; engine .build_entity(active_scene) @@ -55,9 +52,9 @@ impl PillGame for WebGame { .build_entity(active_scene) .with_component(TransformComponent::new()) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .mesh(&cube_mesh_handle) - .material(&material_handle) + .pbr_material(&material_handle) .build(), ) .build(); diff --git a/examples/cube/src/lib.rs b/examples/cube/src/lib.rs index 1265e0a8..6dce3a0d 100644 --- a/examples/cube/src/lib.rs +++ b/examples/cube/src/lib.rs @@ -1,5 +1,5 @@ mod game; -pub use game::WebGame; use pill_engine::game::create_game; create_game!(crate::game::WebGame {}, pill_engine::game::PillGame); + diff --git a/examples/floating_pills/res/config.ini b/examples/floating_pills/res/config.ini index 9a4443a7..92aee2af 100644 --- a/examples/floating_pills/res/config.ini +++ b/examples/floating_pills/res/config.ini @@ -2,7 +2,7 @@ TITLE=Floating-Pills # WINDOW -WINDOW_TITLE=Floating Pills +WINDOW_TITLE=Pill Engine - Floating Pills WINDOW_WIDTH=1200 WINDOW_HEIGHT=800 WINDOW_FULLSCREEN=false diff --git a/examples/floating_pills/src/game.rs b/examples/floating_pills/src/game.rs index f45d9231..779d97d4 100644 --- a/examples/floating_pills/src/game.rs +++ b/examples/floating_pills/src/game.rs @@ -72,7 +72,7 @@ impl PillGame for Game { // Register components engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; @@ -390,7 +390,7 @@ fn object_appearance_changing_system(engine: &mut Engine) -> Result<()> { .get(demo_state.current_mesh) .unwrap(); for (_, mesh_rendering_component) in - engine.iterate_one_component_mut::()? + engine.iterate_one_component_mut::()? { mesh_rendering_component.set_mesh(&mesh_handle); } @@ -407,7 +407,7 @@ fn object_appearance_changing_system(engine: &mut Engine) -> Result<()> { }; for (_, mesh_rendering_component) in - engine.iterate_one_component_mut::()? + engine.iterate_one_component_mut::()? { let material_handle = current_material_set[rng.gen_range(0..=2)]; mesh_rendering_component.set_material(&material_handle); @@ -618,7 +618,7 @@ fn spawn_floating_objects(engine: &mut Engine, object_count: usize) -> Result<() }) .with_component(TransformComponent::new()) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .material(&material_handle) .mesh(&mesh_handle) .build(), diff --git a/examples/italian_brainrot/res/config.ini b/examples/italian_brainrot/res/config.ini index 56141243..d7935cb1 100644 --- a/examples/italian_brainrot/res/config.ini +++ b/examples/italian_brainrot/res/config.ini @@ -2,7 +2,7 @@ TITLE=Italian Brainrot # WINDOW -WINDOW_TITLE=Italian Brainrot +WINDOW_TITLE=Pill Engine - Italian Brainrot WINDOW_WIDTH=1200 WINDOW_HEIGHT=800 WINDOW_FULLSCREEN=false diff --git a/examples/italian_brainrot/src/game.rs b/examples/italian_brainrot/src/game.rs index 5a632aba..b1ba8957 100644 --- a/examples/italian_brainrot/src/game.rs +++ b/examples/italian_brainrot/src/game.rs @@ -13,7 +13,7 @@ impl PillGame for Game { // Register components engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; @@ -140,7 +140,7 @@ impl PillGame for Game { .build(), ) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .material(&chimpanzini_bananini_material_handle_lit) .mesh(&chimpanzini_bananini_mesh_handle) .build(), @@ -157,7 +157,7 @@ impl PillGame for Game { .build(), ) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .material(&chimpanzini_bananini_material_handle_unlit) .mesh(&chimpanzini_bananini_mesh_handle) .build(), diff --git a/examples/net_minimal/client/res/config.ini b/examples/net_minimal/client/res/config.ini index a093ebef..e0ec9a6d 100644 --- a/examples/net_minimal/client/res/config.ini +++ b/examples/net_minimal/client/res/config.ini @@ -2,7 +2,7 @@ TITLE=Net Minimal # WINDOW -WINDOW_TITLE=Net Minimal +WINDOW_TITLE=Pill Engine - Net Minimal WINDOW_WIDTH=600 WINDOW_HEIGHT=600 WINDOW_FULLSCREEN=false diff --git a/examples/net_minimal/client/src/game.rs b/examples/net_minimal/client/src/game.rs index fe34d76f..4faaeb83 100644 --- a/examples/net_minimal/client/src/game.rs +++ b/examples/net_minimal/client/src/game.rs @@ -48,7 +48,7 @@ impl PillGame for Game { // Register components engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; @@ -108,7 +108,7 @@ impl PillGame for Game { .rotation(Vector3f::new(-210.0, 0.0, 0.0)) .build(); engine.add_component_to_entity(active_scene, pill, transform_component.clone())?; - let mesh_rendering_component = MeshRenderingComponent::builder() + let mesh_rendering_component = PbrRenderableComponent::builder() .mesh(&pill_mesh_handle) .material(&pill_material_handle) .build(); @@ -310,7 +310,7 @@ fn spawn_player( engine.add_component_to_entity( scene, ent, - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .mesh(&mesh) .material(&mat) .build(), diff --git a/examples/pbr_balls/Cargo.toml b/examples/pbr_balls/Cargo.toml new file mode 100644 index 00000000..44b14c6f --- /dev/null +++ b/examples/pbr_balls/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pill_game" +version = "0.1.0" +edition = "2021" +workspace = "NO_PATH" + +[lib] +crate-type = ["cdylib", "rlib"] + +[build-dependencies] +ureq = "2" +pill_assets = { path = "../../engine/pill_assets" } + +[dependencies] +pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal", "ibl_bake"] } diff --git a/examples/pbr_balls/build.rs b/examples/pbr_balls/build.rs new file mode 100644 index 00000000..cff375bf --- /dev/null +++ b/examples/pbr_balls/build.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; + +use pill_assets::{GlbToCookedMesh, Rule}; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let models = manifest.join("res/models"); + std::fs::create_dir_all(&models).unwrap(); + + let glb = models.join("MetalRoughSpheres.glb"); + if !glb.exists() { + let url = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/MetalRoughSpheres/glTF-Binary/MetalRoughSpheres.glb"; + let resp = ureq::get(url) + .call() + .expect("download MetalRoughSpheres.glb"); + let mut f = std::fs::File::create(&glb).unwrap(); + std::io::copy(&mut resp.into_reader(), &mut f).unwrap(); + } + + let cooked_mesh = models.join("MetalRoughSpheres.cooked_mesh"); + if !cooked_mesh.exists() { + GlbToCookedMesh + .build(&glb, &cooked_mesh) + .expect("cook MetalRoughSpheres.glb"); + } +} diff --git a/examples/pbr_balls/res/config.ini b/examples/pbr_balls/res/config.ini new file mode 100644 index 00000000..65dd1fb3 --- /dev/null +++ b/examples/pbr_balls/res/config.ini @@ -0,0 +1,24 @@ +# GAME +TITLE=PBR Balls + +# WINDOW +WINDOW_TITLE=Pill Engine - PBR Balls +WINDOW_WIDTH=1920 +WINDOW_HEIGHT=1080 +WINDOW_FULLSCREEN=false + +# ENGINE GENERAL +MAX_ENTITYS=100 +MAX_CAMERAS=1 +MAX_CONCURRENT_2D_SOUNDS=0 +MAX_CONCURRENT_3D_SOUNDS=0 + +# ENGINE RESOURCES +MAX_TEXTURES=10 +MAX_MATERIALS=10 +MAX_MESHS=10 +MAX_SOUNDS=0 + +# OTHER +LOG_LEVEL=Info +PANIC_ON_GAME_ERRORS=true diff --git a/examples/pbr_balls/res/models/studio.studio b/examples/pbr_balls/res/models/studio.studio new file mode 100644 index 00000000..e69de29b diff --git a/examples/pbr_balls/res/studio.studio b/examples/pbr_balls/res/studio.studio new file mode 100644 index 00000000..e69de29b diff --git a/examples/pbr_balls/src/bake.rs b/examples/pbr_balls/src/bake.rs new file mode 100644 index 00000000..43c70822 --- /dev/null +++ b/examples/pbr_balls/src/bake.rs @@ -0,0 +1,78 @@ +use std::f32::consts::PI; + +// Procedural studio panorama for this example. The IBL bake itself (`bake_all`) is shared engine +// code — see `pill_engine::game::bake_all`. + +// Gradient (linear, inverse-Reinhard compensated: c/(1+c) → target on screen). +const TOP: [f32; 3] = [0.90, 0.90, 1.20]; // Reinhard → [0.47, 0.47, 0.55] cool blue ceiling +const HORIZON: [f32; 3] = [0.43, 0.43, 0.46]; // Reinhard → [0.30, 0.30, 0.32] neutral mid +const BOTTOM: [f32; 3] = [0.040, 0.040, 0.050]; // Reinhard → [0.04, 0.04, 0.05] dark floor + +// Key light: warm tungsten, upper-left, ~40° above horizon. +const KEY_U: f32 = 0.10; +const KEY_V: f32 = 0.28; +const KEY_INTENSITY: [f32; 3] = [6.0, 4.8, 2.5]; // Reinhard → [0.86, 0.83, 0.71] warm gold + +// Rim light: cool blue, upper-right, ~45° above horizon. +const RIM_U: f32 = 0.60; +const RIM_V: f32 = 0.25; +const RIM_INTENSITY: [f32; 3] = [1.5, 2.5, 5.0]; // Reinhard → [0.60, 0.71, 0.83] cool blue + +const LIGHT_K: f32 = 8.0; // glare falloff exponent +const KEY_SKY: [f32; 3] = [0.010, 0.060, 0.200]; // 0.2 * vec3(0.05, 0.3, 1.) — cool blue haze away from key + +/// Generates the studio equirect panorama as Rgba32Float pixels (512×256 linear HDR). +pub fn generate() -> (Vec, u32, u32) { + let (w, h) = (512u32, 256u32); + let key_dir = equirect_uv_to_dir(KEY_U, KEY_V); + let rim_dir = equirect_uv_to_dir(RIM_U, RIM_V); + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let v = (y as f32 + 0.5) / h as f32; + let pixel_dir = equirect_uv_to_dir(u, v); + let t = (pixel_dir[1] + 1.0) * 0.5; // pixel_dir[1] = sin(elev) ∈ [-1, 1] + let mut col = if t < 0.5 { + lerp3(BOTTOM, HORIZON, t * 2.0) + } else { + lerp3(HORIZON, TOP, (t - 0.5) * 2.0) + }; + // key: warm glare + cool sky haze (FF2023 sun glare formula) + let sun = dot(pixel_dir, key_dir).clamp(0.0, 1.0); + let sky = 1.0 - sun.powf(0.5); + col[0] += KEY_INTENSITY[0] * sun.powf(LIGHT_K) + KEY_SKY[0] * sky; + col[1] += KEY_INTENSITY[1] * sun.powf(LIGHT_K) + KEY_SKY[1] * sky; + col[2] += KEY_INTENSITY[2] * sun.powf(LIGHT_K) + KEY_SKY[2] * sky; + // rim: glare only + let w_rim = dot(pixel_dir, rim_dir).clamp(0.0, 1.0).powf(LIGHT_K); + col[0] += RIM_INTENSITY[0] * w_rim; + col[1] += RIM_INTENSITY[1] * w_rim; + col[2] += RIM_INTENSITY[2] * w_rim; + out.push(col[0]); + out.push(col[1]); + out.push(col[2]); + out.push(1.0); + } + } + (out, w, h) +} + +fn equirect_uv_to_dir(u: f32, v: f32) -> [f32; 3] { + let az = (u - 0.5) * 2.0 * PI; // consistent with dir_to_equirect_uv: u = 0.5 + az/(2π) + let elev = (0.5 - v) * PI; + let cos_e = elev.cos(); + [cos_e * az.cos(), elev.sin(), cos_e * az.sin()] +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} diff --git a/examples/pbr_balls/src/game.rs b/examples/pbr_balls/src/game.rs new file mode 100644 index 00000000..bb8cef25 --- /dev/null +++ b/examples/pbr_balls/src/game.rs @@ -0,0 +1,124 @@ +use pill_engine::{define_component, game::*}; + +use crate::bake; + +define_component!(OrbitCamera { + yaw: f32, + pitch: f32, + radius: f32, +}); + +pub struct Game {} + +impl PillGame for Game { + fn start(&self, engine: &mut Engine) -> Result<()> { + let (eq, eq_w, eq_h) = bake::generate(); + let (diffuse, specular_mips, brdf_lut) = bake_all(&eq, eq_w, eq_h); + + let bg_h = engine.create_gpu_texture_f32("equirect", &eq, eq_w, eq_h)?; + let diff_h = engine.create_gpu_texture_f32("diffuse_ibl", &diffuse, 32, 16)?; + let spec_h = + engine.create_gpu_mipped_texture_f32("specular_ibl", &specular_mips, 128, 64)?; + let lut_h = engine.create_gpu_texture_f32("brdf_lut", &brdf_lut, 256, 256)?; + + let rs = engine.get_global_component_mut::()?; + rs.background = bg_h; + rs.ibl_diffuse = diff_h; + rs.ibl_specular = spec_h; + rs.ibl_brdf_lut = lut_h; + + let scene = engine.create_scene("pbr_balls")?; + engine.set_active_scene(scene)?; + + engine.register_component::(scene)?; + engine.register_component::(scene)?; + engine.register_component::(scene)?; + engine.register_component::(scene)?; + + engine.add_system("orbit_camera", orbit_camera_system)?; + + let mesh_handle = engine.add_resource(Mesh::from_cooked_mesh_bytes( + "spheres_mesh", + include_bytes!("../res/models/MetalRoughSpheres.cooked_mesh"), + )?)?; + + let albedo_handle = engine.add_resource(Texture::from_bytes( + "spheres_albedo", + TextureType::Color, + include_bytes!("../res/models/MetalRoughSpheres_albedo.cooked_tex"), + ))?; + + let mr_handle = engine.add_resource(Texture::from_bytes( + "spheres_mr", + TextureType::MetallicRoughness, + include_bytes!("../res/models/MetalRoughSpheres_metallic_roughness.cooked_tex"), + ))?; + + let mat_handle = engine.add_resource( + PBRMaterial::new("spheres_mat") + .albedo_texture(albedo_handle) + .metallic_roughness_texture(mr_handle) + .metallic(1.0) + .roughness(1.0), + )?; + + engine + .build_entity(scene) + .with_component(TransformComponent::builder().build()) + .with_component( + PbrRenderableComponent::builder() + .mesh(&mesh_handle) + .pbr_material(&mat_handle) + .build(), + ) + .build(); + + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .position(Vector3f::new(0.0, 0.0, 10.0)) + .build(), + ) + .with_component( + CameraComponent::builder() + .enabled(true) + .fov(60.0) + .clear_color(Color::new(0.0, 0.0, 0.0)) + .look_at(Some(Vector3f::new(0.0, 0.0, 0.0))) + .build(), + ) + .with_component(OrbitCamera { + yaw: 0.0, + pitch: 0.0, + radius: 10.0, + }) + .build(); + + Ok(()) + } +} + +fn orbit_camera_system(engine: &mut Engine) -> Result<()> { + let input = engine.get_global_component_mut::()?; + let mouse_delta = input.get_mouse_delta(); + let scroll_delta = input.get_mouse_scroll_delta(); + let lmb = input.get_mouse_button(MouseButton::Left); + + for (_, tfm, orbit) in engine.iterate_two_components_mut::()? { + if lmb { + orbit.yaw -= mouse_delta.x * 0.3; + orbit.pitch = (orbit.pitch + mouse_delta.y * 0.3).clamp(-70.0, 70.0); + } + orbit.radius = (orbit.radius - scroll_delta.y * 0.5).clamp(1.0, 50.0); + + let pr = orbit.pitch.to_radians(); + let yr = orbit.yaw.to_radians(); + tfm.set_position(Vector3f::new( + orbit.radius * pr.cos() * yr.sin(), + orbit.radius * pr.sin(), + orbit.radius * pr.cos() * yr.cos(), + )); + } + Ok(()) +} diff --git a/examples/pbr_balls/src/lib.rs b/examples/pbr_balls/src/lib.rs new file mode 100644 index 00000000..f1d2fb91 --- /dev/null +++ b/examples/pbr_balls/src/lib.rs @@ -0,0 +1,6 @@ +mod bake; +mod game; +pub use game::Game; + +use pill_engine::game::create_game; +create_game!(crate::game::Game {}, pill_engine::game::PillGame); diff --git a/examples/pbr_helmet/Cargo.toml b/examples/pbr_helmet/Cargo.toml new file mode 100644 index 00000000..46f370c8 --- /dev/null +++ b/examples/pbr_helmet/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pill_game" +version = "0.1.0" +edition = "2021" +workspace = "NO_PATH" + +[lib] +crate-type = ["cdylib", "rlib"] + +[build-dependencies] +ureq = "2" +pill_assets = { path = "../../engine/pill_assets" } +image = { version = "0.25", features = ["hdr"] } + +[dependencies] +pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal", "ibl_bake"] } diff --git a/examples/pbr_helmet/build.rs b/examples/pbr_helmet/build.rs new file mode 100644 index 00000000..56d23b89 --- /dev/null +++ b/examples/pbr_helmet/build.rs @@ -0,0 +1,57 @@ +use std::io::Write; +use std::path::PathBuf; + +use pill_assets::{GlbToCookedMesh, Rule}; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let models = manifest.join("res/models"); + std::fs::create_dir_all(&models).unwrap(); + + let glb = models.join("DamagedHelmet.glb"); + if !glb.exists() { + let url = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/DamagedHelmet/glTF-Binary/DamagedHelmet.glb"; + let resp = ureq::get(url).call().expect("download DamagedHelmet.glb"); + let mut f = std::fs::File::create(&glb).unwrap(); + std::io::copy(&mut resp.into_reader(), &mut f).unwrap(); + } + + let cooked_mesh = models.join("DamagedHelmet.cooked_mesh"); + if !cooked_mesh.exists() { + GlbToCookedMesh + .build(&glb, &cooked_mesh) + .expect("cook DamagedHelmet.glb"); + } + + // Download studio HDR (low-frequency softbox, CC0) if missing. + let hdr_path = models.join("studio_small_08.hdr"); + if !hdr_path.exists() { + let url = "https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/studio_small_08_1k.hdr"; + let resp = ureq::get(url).call().expect("download studio_small_08 HDR"); + let mut f = std::fs::File::create(&hdr_path).unwrap(); + std::io::copy(&mut resp.into_reader(), &mut f).unwrap(); + } + + // Decode HDR → resize to 512×256 → write raw f32 RGBA binary to OUT_DIR for include_bytes!. + println!("cargo:rerun-if-changed=res/models/studio_small_08.hdr"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let f32bin = out_dir.join("equirect.f32bin"); + { + let img = image::open(&hdr_path).expect("open HDR"); + let src = img.to_rgb32f(); + let (out_w, out_h) = (512u32, 256u32); + let resized = + image::imageops::resize(&src, out_w, out_h, image::imageops::FilterType::Lanczos3); + let mut out = std::fs::File::create(&f32bin).unwrap(); + out.write_all(&out_w.to_le_bytes()).unwrap(); + out.write_all(&out_h.to_le_bytes()).unwrap(); + let mut row = Vec::with_capacity((out_w * 4 * 4) as usize); + for px in resized.pixels() { + row.extend_from_slice(&px.0[0].to_le_bytes()); + row.extend_from_slice(&px.0[1].to_le_bytes()); + row.extend_from_slice(&px.0[2].to_le_bytes()); + row.extend_from_slice(&1.0f32.to_le_bytes()); + } + out.write_all(&row).unwrap(); + } +} diff --git a/examples/pbr_helmet/res/config.ini b/examples/pbr_helmet/res/config.ini new file mode 100644 index 00000000..1ad0640d --- /dev/null +++ b/examples/pbr_helmet/res/config.ini @@ -0,0 +1,24 @@ +# GAME +TITLE=PBR Helmet + +# WINDOW +WINDOW_TITLE=Pill Engine - PBR Helmet +WINDOW_WIDTH=1920 +WINDOW_HEIGHT=1080 +WINDOW_FULLSCREEN=false + +# ENGINE GENERAL +MAX_ENTITYS=100 +MAX_CAMERAS=1 +MAX_CONCURRENT_2D_SOUNDS=0 +MAX_CONCURRENT_3D_SOUNDS=0 + +# ENGINE RESOURCES +MAX_TEXTURES=10 +MAX_MATERIALS=10 +MAX_MESHS=10 +MAX_SOUNDS=0 + +# OTHER +LOG_LEVEL=Info +PANIC_ON_GAME_ERRORS=true diff --git a/examples/pbr_helmet/src/bake.rs b/examples/pbr_helmet/src/bake.rs new file mode 100644 index 00000000..bd42665b --- /dev/null +++ b/examples/pbr_helmet/src/bake.rs @@ -0,0 +1,17 @@ +/// Loads the studio equirect panorama embedded at compile time from the build script's OUT_DIR. +/// Returns RGBA f32 pixels + (width, height). +/// +/// The IBL bake itself (`bake_all`) is shared engine code — see `pill_engine::game::bake_all`. +pub fn load_equirect() -> (Vec, u32, u32) { + // Blob layout written by build.rs: [u32 width][u32 height][f32 RGBA pixels...], all little-endian. + let raw: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/equirect.f32bin")); + let w = u32::from_le_bytes(raw[0..4].try_into().unwrap()); + let h = u32::from_le_bytes(raw[4..8].try_into().unwrap()); + let pixel_bytes = &raw[8..]; + // Reinterpret every 4 bytes as one f32 (chunks_exact(4) walks the blob 4 bytes at a time). + let pixels: Vec = pixel_bytes + .chunks_exact(4) + .map(|b| f32::from_le_bytes(b.try_into().unwrap())) + .collect(); + (pixels, w, h) +} diff --git a/examples/pbr_helmet/src/game.rs b/examples/pbr_helmet/src/game.rs new file mode 100644 index 00000000..63e9df0b --- /dev/null +++ b/examples/pbr_helmet/src/game.rs @@ -0,0 +1,147 @@ +use pill_engine::{define_component, game::*}; + +use crate::bake; + +define_component!(OrbitCamera { + yaw: f32, + pitch: f32, + radius: f32, +}); + +pub struct Game {} + +fn orbit_camera_system(engine: &mut Engine) -> Result<()> { + let input = engine.get_global_component_mut::()?; + let mouse_delta = input.get_mouse_delta(); + let scroll_delta = input.get_mouse_scroll_delta(); + let left_mouse_button_held = input.get_mouse_button(MouseButton::Left); + + for (_, transform, orbit) in + engine.iterate_two_components_mut::()? + { + if left_mouse_button_held { + orbit.yaw -= mouse_delta.x * 0.3; + orbit.pitch = (orbit.pitch + mouse_delta.y * 0.3).clamp(-70.0, 70.0); + } + orbit.radius = (orbit.radius - scroll_delta.y * 0.5).clamp(1.0, 20.0); + + let pitch_radians = orbit.pitch.to_radians(); + let yaw_radians = orbit.yaw.to_radians(); + let radius_cos_pitch = orbit.radius * pitch_radians.cos(); + transform.set_position(Vector3f::new( + radius_cos_pitch * yaw_radians.sin(), + orbit.radius * pitch_radians.sin(), + radius_cos_pitch * yaw_radians.cos(), + )); + } + Ok(()) +} + +impl PillGame for Game { + fn start(&self, engine: &mut Engine) -> Result<()> { + let (eq, eq_w, eq_h) = bake::load_equirect(); + let (diffuse, specular_mips, brdf_lut) = bake_all(&eq, eq_w, eq_h); + + let bg_h = engine.create_gpu_texture_f32("equirect", &eq, eq_w, eq_h)?; + let diff_h = engine.create_gpu_texture_f32("diffuse_ibl", &diffuse, 32, 16)?; + let spec_h = + engine.create_gpu_mipped_texture_f32("specular_ibl", &specular_mips, 128, 64)?; + let lut_h = engine.create_gpu_texture_f32("brdf_lut", &brdf_lut, 256, 256)?; + + { + let rs = engine.get_global_component_mut::()?; + rs.background = bg_h; + rs.ibl_diffuse = diff_h; + rs.ibl_specular = spec_h; + rs.ibl_brdf_lut = lut_h; + } + + let scene = engine.create_scene("helmet")?; + engine.set_active_scene(scene)?; + + engine.register_component::(scene)?; + engine.register_component::(scene)?; + engine.register_component::(scene)?; + engine.register_component::(scene)?; + + let mesh_handle = engine.add_resource(Mesh::from_cooked_mesh_bytes( + "helmet_mesh", + include_bytes!("../res/models/DamagedHelmet.cooked_mesh"), + )?)?; + + let albedo_handle = engine.add_resource(Texture::from_bytes( + "helmet_albedo", + TextureType::Color, + include_bytes!("../res/models/DamagedHelmet_albedo.cooked_tex"), + ))?; + + let normal_handle = engine.add_resource(Texture::from_bytes( + "helmet_normal", + TextureType::Normal, + include_bytes!("../res/models/DamagedHelmet_normal.cooked_tex"), + ))?; + + let metallic_roughness_handle = engine.add_resource(Texture::from_bytes( + "helmet_metallic_roughness", + TextureType::MetallicRoughness, + include_bytes!("../res/models/DamagedHelmet_metallic_roughness.cooked_tex"), + ))?; + + let emissive_handle = engine.add_resource(Texture::from_bytes( + "helmet_emissive", + TextureType::Emissive, + include_bytes!("../res/models/DamagedHelmet_emissive.cooked_tex"), + ))?; + + let material_handle = engine.add_resource( + PBRMaterial::new("helmet_material") + .albedo_texture(albedo_handle) + .normal_texture(normal_handle) + .metallic_roughness_texture(metallic_roughness_handle) + .metallic(1.0) + .roughness(1.0) + .emissive_texture(emissive_handle), + )?; + + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .rotation(Vector3f::new(0.0, 0.0, 0.0)) + .build(), + ) + .with_component( + PbrRenderableComponent::builder() + .mesh(&mesh_handle) + .pbr_material(&material_handle) + .build(), + ) + .build(); + + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .position(Vector3f::new(0.0, 0.0, 2.0)) + .build(), + ) + .with_component( + CameraComponent::builder() + .enabled(true) + .fov(60.0) + .clear_color(Color::new(0.05, 0.05, 0.06)) + .look_at(Some(Vector3f::new(0.0, 0.0, 0.0))) + .build(), + ) + .with_component(OrbitCamera { + yaw: 0.0, + pitch: 0.0, + radius: 2.5, + }) + .build(); + + engine.add_system("orbit_camera", orbit_camera_system)?; + + Ok(()) + } +} diff --git a/examples/pbr_helmet/src/lib.rs b/examples/pbr_helmet/src/lib.rs new file mode 100644 index 00000000..1ca7a9ea --- /dev/null +++ b/examples/pbr_helmet/src/lib.rs @@ -0,0 +1,5 @@ +mod bake; +mod game; + +use pill_engine::game::create_game; +create_game!(crate::game::Game {}, pill_engine::game::PillGame); diff --git a/examples/pill_tunel/Cargo.toml b/examples/pill_tunel/Cargo.toml index 241c1f4b..f16f85e6 100644 --- a/examples/pill_tunel/Cargo.toml +++ b/examples/pill_tunel/Cargo.toml @@ -8,4 +8,5 @@ workspace = "NO_PATH" crate-type = ["cdylib", "rlib"] [dependencies] -pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal"] } +pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal", "ui"] } +egui = "0.32.1" diff --git a/examples/pill_tunel/res/config.ini b/examples/pill_tunel/res/config.ini index 1ff66db1..3e29c271 100644 --- a/examples/pill_tunel/res/config.ini +++ b/examples/pill_tunel/res/config.ini @@ -1,24 +1,24 @@ # GAME -TITLE=Pill-Demo +TITLE=Pill-Tunel # WINDOW -WINDOW_TITLE=Pill-Demo +WINDOW_TITLE=Pill Engine - Pill Tunnel WINDOW_WIDTH=1920 WINDOW_HEIGHT=1200 WINDOW_FULLSCREEN=false # ENGINE GENERAL -MAX_ENTITIES=500000 +MAX_ENTITIES=2000 MAX_CAMERAS=10 MAX_CONCURRENT_2D_SOUNDS=10 MAX_CONCURRENT_3D_SOUNDS=10 # ENGINE RESOURCES -MAX_TEXTURES=1000 -MAX_MATERIALS=20000 -MAX_MESHES=1000 -MAX_MODELS=100 -MAX_SOUNDS=50 +MAX_TEXTURES=10 +MAX_MATERIALS=10 +MAX_MESHES=10 +MAX_MODELS=10 +MAX_SOUNDS=10 # OTHER LOG_LEVEL=Info diff --git a/examples/pill_tunel/src/game.rs b/examples/pill_tunel/src/game.rs index c2606853..1eedb66e 100644 --- a/examples/pill_tunel/src/game.rs +++ b/examples/pill_tunel/src/game.rs @@ -107,13 +107,12 @@ fn tinted_pill_material( tint: (f32, f32, f32), color_tex: TextureHandle, normal_tex: TextureHandle, -) -> Result { +) -> Result { engine.add_resource( - Material::builder(name) - .texture("color", color_tex)? - .texture("normal", normal_tex)? - .color_parameter("tint", Color::new(tint.0, tint.1, tint.2))? - .build(), + PBRMaterial::new(name) + .albedo(Color::new(tint.0, tint.1, tint.2)) + .albedo_texture(color_tex) + .normal_texture(normal_tex), ) } @@ -131,6 +130,19 @@ pub struct WebGame {} // --- Systems ----------------------------------------------------------------- +/// Fast sine approximation (~0.1% error) — replaces std `sin` in the 60k-pill hot loop, where the +/// transcendental dominated per-pill cost. Parabola fit + one refinement pass; range-reduced to +/// [-π, π]. [Nick, "A Fast, Compact Approximation of the Sine Function", devmaster.net 2006; +/// cf. Bhāskara I's sine approximation, 7th c.] +fn fast_sin(mut x: f32) -> f32 { + use std::f32::consts::{PI, TAU}; + x -= TAU * (x * (1.0 / TAU)).round(); // range-reduce to [-π, π] + const B: f32 = 4.0 / PI; + const C: f32 = -4.0 / (PI * PI); + let y = B * x + C * x * x.abs(); + 0.225 * (y * y.abs() - y) + y // refinement: ~1% → ~0.1% error +} + fn pill_particle_system(engine: &mut Engine) -> Result<()> { let dt = engine.get_global_component::()?.delta_time; let tunnel_length = TUNNEL_FAR_Z - TUNNEL_NEAR_Z; @@ -142,7 +154,7 @@ fn pill_particle_system(engine: &mut Engine) -> Result<()> { while z < TUNNEL_NEAR_Z { z += tunnel_length; } - let y_wobble = (z * PILL_WOBBLE_Z_K + pill.wobble_phase).sin() * PILL_WOBBLE_AMPLITUDE; + let y_wobble = fast_sin(z * PILL_WOBBLE_Z_K + pill.wobble_phase) * PILL_WOBBLE_AMPLITUDE; transform.set_position(Vector3f::new( transform.position.x, pill.base_y + y_wobble, @@ -191,9 +203,19 @@ impl PillGame for WebGame { let active_scene = engine.create_scene("default")?; engine.set_active_scene(active_scene)?; + let zero_ibl = + engine.create_gpu_texture_f32("zero_ibl", &[0.0_f32, 0.0, 0.0, 0.0], 1, 1)?; + { + let rs = engine.get_global_component_mut::()?; + rs.bg_color = [CLEAR_COLOR.0, CLEAR_COLOR.1, CLEAR_COLOR.2]; + rs.fog_density = FOG_DENSITY; + rs.ibl_diffuse = zero_ibl; + rs.ibl_specular = zero_ibl; + } + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; @@ -213,7 +235,7 @@ impl PillGame for WebGame { include_bytes!("../res/textures/pill_normal.cooked_tex"), ))?; - let tunnel_materials: Vec = PALETTE + let tunnel_materials: Vec = PALETTE .iter() .enumerate() .map(|(i, tint)| { @@ -226,7 +248,7 @@ impl PillGame for WebGame { ) }) .collect::>()?; - let hero_material = + let hero_material: PBRMaterialHandle = tinted_pill_material(engine, "hero_material", HERO_TINT, color_tex, normal_tex)?; // Camera @@ -241,10 +263,6 @@ impl PillGame for WebGame { CameraComponent::builder() .enabled(true) .fov(CAMERA_FOV) - .clear_color(Color::new(CLEAR_COLOR.0, CLEAR_COLOR.1, CLEAR_COLOR.2)) - .fog_density(FOG_DENSITY) - // Fade distant pills toward the clear color so the tunnel wrap seam disappears. - .fog_color(Color::new(CLEAR_COLOR.0, CLEAR_COLOR.1, CLEAR_COLOR.2)) .build(), ) .build(); @@ -259,9 +277,9 @@ impl PillGame for WebGame { .build(), ) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .mesh(&pill_mesh) - .material(&hero_material) + .pbr_material(&hero_material) .build(), ) .with_component(HeroPillComponent {}) @@ -297,9 +315,9 @@ impl PillGame for WebGame { .build(), ) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .mesh(&pill_mesh) - .material(&material) + .pbr_material(&material) .build(), ) .with_component(PillParticleComponent { diff --git a/examples/pill_tunel/src/lib.rs b/examples/pill_tunel/src/lib.rs index 1265e0a8..d6282808 100644 --- a/examples/pill_tunel/src/lib.rs +++ b/examples/pill_tunel/src/lib.rs @@ -1,5 +1,4 @@ mod game; -pub use game::WebGame; use pill_engine::game::create_game; create_game!(crate::game::WebGame {}, pill_engine::game::PillGame); diff --git a/examples/sponza/Cargo.toml b/examples/sponza/Cargo.toml new file mode 100644 index 00000000..3e2a35cd --- /dev/null +++ b/examples/sponza/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "pill_game" +version = "0.1.0" +edition = "2021" +workspace = "NO_PATH" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +# `gltf_loading` pulls the engine's runtime glTF loader + IBL bake (gltf + image decode) into this +# build. Everything model/IBL related lives in the engine/pill_assets now — nothing example-side. +pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal", "gltf_loading", "ibl_bake"] } +log = "0.4" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Request", "RequestInit", "Response", "RequestMode"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ureq = "2" diff --git a/examples/sponza/res/config.ini b/examples/sponza/res/config.ini new file mode 100644 index 00000000..d162459d --- /dev/null +++ b/examples/sponza/res/config.ini @@ -0,0 +1,26 @@ +# GAME +TITLE=Sponza + +# WINDOW +WINDOW_TITLE=Pill Engine - Sponza (runtime glTF fetch) +WINDOW_WIDTH=1920 +WINDOW_HEIGHT=1080 +WINDOW_FULLSCREEN=false + +# ENGINE GENERAL +# Sponza is 103 primitives → one entity per primitive, plus the camera. +MAX_ENTITIES=512 +MAX_CAMERAS=1 +MAX_CONCURRENT_2D_SOUNDS=0 +MAX_CONCURRENT_3D_SOUNDS=0 + +# ENGINE RESOURCES +# Sponza: 69 textures, 25 materials (+1 default), 103 mesh primitives. +4 textures for baked IBL. +MAX_TEXTURES=128 +MAX_MATERIALS=128 +MAX_MESHES=512 +MAX_SOUNDS=0 + +# OTHER +LOG_LEVEL=Info +PANIC_ON_GAME_ERRORS=true diff --git a/examples/sponza/src/fetch.rs b/examples/sponza/src/fetch.rs new file mode 100644 index 00000000..46d97b5c --- /dev/null +++ b/examples/sponza/src/fetch.rs @@ -0,0 +1,51 @@ +//! HTTP byte fetch. WASM uses the browser `fetch` API (cross-origin is allowed because +//! raw.githubusercontent.com serves `access-control-allow-origin: *`); native uses `ureq`. + +/// Base URL of the multi-file Sponza glTF. Buffer (`Sponza.bin`) and texture URIs in the +/// document are relative to this, exactly as three.js's GLTFLoader resolves them. +pub const SPONZA_BASE: &str = + "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Sponza/glTF/"; + +/// CC0 studio HDR for image-based lighting (Poly Haven also serves permissive CORS headers). +pub const HDR_URL: &str = + "https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/studio_small_08_1k.hdr"; + +#[cfg(target_arch = "wasm32")] +pub async fn get(url: &str) -> Result, String> { + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + use web_sys::{Request, Response}; + + // A plain GET request; cross-origin requests default to CORS mode, which is what we want. + let request = Request::new_with_str(url).map_err(|e| format!("build request {url}: {e:?}"))?; + let window = web_sys::window().ok_or_else(|| "no window".to_string())?; + let resp_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|e| format!("fetch {url}: {e:?}"))?; + let resp: Response = resp_value + .dyn_into() + .map_err(|_| format!("fetch {url}: response was not a Response"))?; + if !resp.ok() { + return Err(format!("HTTP {} for {url}", resp.status())); + } + let buf = JsFuture::from( + resp.array_buffer() + .map_err(|e| format!("array_buffer {url}: {e:?}"))?, + ) + .await + .map_err(|e| format!("array_buffer await {url}: {e:?}"))?; + Ok(js_sys::Uint8Array::new(&buf).to_vec()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn get(url: &str) -> Result, String> { + use std::io::Read; + let resp = ureq::get(url) + .call() + .map_err(|e| format!("GET {url}: {e}"))?; + let mut buf = Vec::new(); + resp.into_reader() + .read_to_end(&mut buf) + .map_err(|e| format!("read {url}: {e}"))?; + Ok(buf) +} diff --git a/examples/sponza/src/game.rs b/examples/sponza/src/game.rs new file mode 100644 index 00000000..51a517e4 --- /dev/null +++ b/examples/sponza/src/game.rs @@ -0,0 +1,199 @@ +use pill_engine::{define_component, game::*}; + +use crate::loader::{self, SponzaCpu}; + +define_component!(OrbitCamera { + yaw: f32, + pitch: f32, + radius: f32, +}); + +pub struct Game {} + +// On WASM the model is fetched asynchronously: the spawn_local task (which cannot hold &mut Engine, +// since the event loop owns it) deposits a finished CPU payload here, and `sponza_drain_system` +// picks it up on the next frame and performs the GPU uploads + entity build. +#[cfg(target_arch = "wasm32")] +thread_local! { + static PENDING: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + static DONE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +// Orbit around a point at mid-height of the atrium so the camera circles inside the hall. +const ORBIT_CENTER_Y: f32 = 5.0; + +fn orbit_camera_system(engine: &mut Engine) -> Result<()> { + let input = engine.get_global_component_mut::()?; + let mouse_delta = input.get_mouse_delta(); + let scroll_delta = input.get_mouse_scroll_delta(); + let left_mouse_button_held = input.get_mouse_button(MouseButton::Left); + + for (_, transform, orbit) in + engine.iterate_two_components_mut::()? + { + if left_mouse_button_held { + orbit.yaw -= mouse_delta.x * 0.3; + orbit.pitch = (orbit.pitch + mouse_delta.y * 0.3).clamp(-80.0, 80.0); + } + orbit.radius = (orbit.radius - scroll_delta.y * 0.8).clamp(2.0, 40.0); + + let pitch_radians = orbit.pitch.to_radians(); + let yaw_radians = orbit.yaw.to_radians(); + let radius_cos_pitch = orbit.radius * pitch_radians.cos(); + transform.set_position(Vector3f::new( + radius_cos_pitch * yaw_radians.sin(), + orbit.radius * pitch_radians.sin() + ORBIT_CENTER_Y, + radius_cos_pitch * yaw_radians.cos(), + )); + } + Ok(()) +} + +// Drains the fetched payload into the engine once loading finishes (WASM only). Idempotent. +#[cfg(target_arch = "wasm32")] +fn sponza_drain_system(engine: &mut Engine) -> Result<()> { + if DONE.with(|d| d.get()) { + return Ok(()); + } + let cpu = PENDING.with(|p| p.borrow_mut().take()); + if let Some(cpu) = cpu { + let scene = engine.get_active_scene_handle()?; + spawn_assets(engine, scene, cpu)?; + DONE.with(|d| d.set(true)); + } + Ok(()) +} + +// Uploads the CPU payload to the GPU and builds one entity per primitive. Shared by the native +// inline path and the WASM drain system. +fn spawn_assets(engine: &mut Engine, scene: SceneHandle, cpu: SponzaCpu) -> Result<()> { + // --- IBL (background + diffuse/specular/brdf, same layout as pbr_helmet) --- + let (equirect, eq_w, eq_h) = &cpu.equirect; + let background = engine.create_gpu_texture_f32("sponza_equirect", equirect, *eq_w, *eq_h)?; + let (diffuse, specular_mips, brdf_lut) = &cpu.ibl; + let diffuse_handle = engine.create_gpu_texture_f32("sponza_diffuse_ibl", diffuse, 32, 16)?; + let specular_handle = + engine.create_gpu_mipped_texture_f32("sponza_specular_ibl", specular_mips, 128, 64)?; + let brdf_handle = engine.create_gpu_texture_f32("sponza_brdf_lut", brdf_lut, 256, 256)?; + { + let render_state = engine.get_global_component_mut::()?; + render_state.background = background; + render_state.ibl_diffuse = diffuse_handle; + render_state.ibl_specular = specular_handle; + render_state.ibl_brdf_lut = brdf_handle; + } + + // --- Textures --- + let mut texture_handles: Vec = Vec::with_capacity(cpu.scene.textures.len()); + for texture in &cpu.scene.textures { + texture_handles.push(engine.add_resource(Texture::from_bytes( + &texture.name, + texture.kind, + &texture.rtex, + ))?); + } + + // --- Materials --- + let mut material_handles: Vec = + Vec::with_capacity(cpu.scene.materials.len()); + for material in &cpu.scene.materials { + let mut pbr = PBRMaterial::new(&material.name) + .albedo(Color::new( + material.base_color[0], + material.base_color[1], + material.base_color[2], + )) + .metallic(material.metallic) + .roughness(material.roughness); + if let Some(i) = material.albedo { + pbr = pbr.albedo_texture(texture_handles[i]); + } + if let Some(i) = material.normal { + pbr = pbr.normal_texture(texture_handles[i]); + } + if let Some(i) = material.metallic_roughness { + pbr = pbr.metallic_roughness_texture(texture_handles[i]); + } + if let Some(i) = material.emissive { + pbr = pbr.emissive_texture(texture_handles[i]); + } + material_handles.push(engine.add_resource(pbr)?); + } + + // --- Meshes + entities (world transform already baked into the vertices) --- + for mesh in &cpu.scene.meshes { + let mesh_handle = + engine.add_resource(Mesh::from_cooked_mesh_bytes(&mesh.name, &mesh.rmsh)?)?; + engine + .build_entity(scene) + .with_component(TransformComponent::builder().build()) + .with_component( + PbrRenderableComponent::builder() + .mesh(&mesh_handle) + .pbr_material(&material_handles[mesh.material]) + .build(), + ) + .build(); + } + + log::info!("Sponza: uploaded {} meshes to GPU", cpu.scene.meshes.len()); + Ok(()) +} + +impl PillGame for Game { + fn start(&self, engine: &mut Engine) -> Result<()> { + let scene = engine.create_scene("sponza")?; + engine.set_active_scene(scene)?; + + engine.register_component::(scene)?; + engine.register_component::(scene)?; + engine.register_component::(scene)?; + engine.register_component::(scene)?; + + // Build the camera up front so the window renders (clear color + sky once IBL lands) + // while the ~50 MB model downloads. + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .position(Vector3f::new(-9.0, ORBIT_CENTER_Y, 0.0)) + .build(), + ) + .with_component( + CameraComponent::builder() + .enabled(true) + .fov(60.0) + .clear_color(Color::new(0.02, 0.02, 0.03)) + .look_at(Some(Vector3f::new(0.0, ORBIT_CENTER_Y, 0.0))) + .build(), + ) + .with_component(OrbitCamera { + yaw: 90.0, + pitch: 5.0, + radius: 9.0, + }) + .build(); + + engine.add_system("orbit_camera", orbit_camera_system)?; + + // Kick off the runtime fetch (the whole point of this example). + #[cfg(target_arch = "wasm32")] + { + wasm_bindgen_futures::spawn_local(async { + match loader::load_wasm().await { + Ok(cpu) => PENDING.with(|p| *p.borrow_mut() = Some(cpu)), + Err(e) => log::error!("Sponza load failed: {e}"), + } + }); + engine.add_system("sponza_drain", sponza_drain_system)?; + } + #[cfg(not(target_arch = "wasm32"))] + { + log::info!("Sponza: loading from {}", loader::base_url()); + let cpu = loader::load_native().map_err(PillError::from)?; + spawn_assets(engine, scene, cpu)?; + } + + Ok(()) + } +} diff --git a/examples/sponza/src/lib.rs b/examples/sponza/src/lib.rs new file mode 100644 index 00000000..5410c552 --- /dev/null +++ b/examples/sponza/src/lib.rs @@ -0,0 +1,6 @@ +mod fetch; +mod game; +mod loader; + +use pill_engine::game::create_game; +create_game!(crate::game::Game {}, pill_engine::game::PillGame); diff --git a/examples/sponza/src/loader.rs b/examples/sponza/src/loader.rs new file mode 100644 index 00000000..6a3125b0 --- /dev/null +++ b/examples/sponza/src/loader.rs @@ -0,0 +1,144 @@ +//! Fetches the multi-file Sponza glTF over HTTP and hands the bytes to the engine's runtime glTF +//! loader. Mirrors three.js's GLTFLoader: fetch `Sponza.gltf`, then resolve+fetch `Sponza.bin` and +//! every texture relative to the base URL. +//! +//! All glTF parsing, image decoding and mesh/texture byte-format conversion lives in the engine +//! (`pill_engine`'s `gltf_loading` feature). This module is just I/O + orchestration: the fetch is +//! the only platform-specific part (async `fetch` on WASM, blocking `ureq` on native). + +use pill_engine::game::{ + bake_all, equirect_from_hdr, gltf_resource_uris, parse_gltf_scene, GltfSceneData, +}; + +use crate::fetch; + +pub struct SponzaCpu { + pub scene: GltfSceneData, + pub equirect: (Vec, u32, u32), // RGBA f32 background panorama (from fetched HDR) + pub ibl: (Vec, Vec>, Vec), // bake::bake_all output: diffuse, specular mips, brdf_lut +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn base_url() -> &'static str { + fetch::SPONZA_BASE +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn load_native() -> Result { + let gltf_bytes = fetch::get(&format!("{}Sponza.gltf", fetch::SPONZA_BASE))?; + let uris = gltf_resource_uris(&gltf_bytes).map_err(|e| e.to_string())?; + + let total = 2 + uris.buffers.len() + uris.images.len(); + let mut progress = Progress::new(total); + progress.tick(gltf_bytes.len()); + + let mut buffers: Vec> = Vec::with_capacity(uris.buffers.len()); + for uri in &uris.buffers { + let bytes = match uri { + Some(uri) => fetch::get(&format!("{}{}", fetch::SPONZA_BASE, uri))?, + None => Vec::new(), // embedded BIN; engine resolves it from the blob + }; + progress.tick(bytes.len()); + buffers.push(bytes); + } + + let mut images: Vec> = Vec::with_capacity(uris.images.len()); + for uri in &uris.images { + let bytes = match uri { + Some(uri) => fetch::get(&format!("{}{}", fetch::SPONZA_BASE, uri))?, + None => Vec::new(), // embedded in a buffer view; engine resolves it + }; + progress.tick(bytes.len()); + images.push(bytes); + } + + let scene = parse_gltf_scene(&gltf_bytes, &buffers, &images).map_err(|e| e.to_string())?; + + let hdr = fetch::get(fetch::HDR_URL)?; + progress.tick(hdr.len()); + + finish(scene, &hdr) +} + +#[cfg(target_arch = "wasm32")] +pub async fn load_wasm() -> Result { + let gltf_bytes = fetch::get(&format!("{}Sponza.gltf", fetch::SPONZA_BASE)).await?; + let uris = gltf_resource_uris(&gltf_bytes).map_err(|e| e.to_string())?; + + let total = 2 + uris.buffers.len() + uris.images.len(); + let mut progress = Progress::new(total); + progress.tick(gltf_bytes.len()); + + let mut buffers: Vec> = Vec::with_capacity(uris.buffers.len()); + for uri in &uris.buffers { + let bytes = match uri { + Some(uri) => fetch::get(&format!("{}{}", fetch::SPONZA_BASE, uri)).await?, + None => Vec::new(), // embedded BIN; engine resolves it from the blob + }; + progress.tick(bytes.len()); + buffers.push(bytes); + } + + let mut images: Vec> = Vec::with_capacity(uris.images.len()); + for uri in &uris.images { + let bytes = match uri { + Some(uri) => fetch::get(&format!("{}{}", fetch::SPONZA_BASE, uri)).await?, + None => Vec::new(), + }; + progress.tick(bytes.len()); + images.push(bytes); + } + + let scene = parse_gltf_scene(&gltf_bytes, &buffers, &images).map_err(|e| e.to_string())?; + + let hdr = fetch::get(fetch::HDR_URL).await?; + progress.tick(hdr.len()); + + finish(scene, &hdr) +} + +/// Bakes IBL from the fetched HDR and packages the result. +fn finish(scene: GltfSceneData, hdr_bytes: &[u8]) -> Result { + let equirect = equirect_from_hdr(hdr_bytes).map_err(|e| e.to_string())?; + let ibl = bake_all(&equirect.0, equirect.1, equirect.2); + log::info!( + "Sponza: parsed {} meshes, {} materials, {} textures", + scene.meshes.len(), + scene.materials.len(), + scene.textures.len(), + ); + Ok(SponzaCpu { + scene, + equirect, + ibl, + }) +} + +// --- Progress (log only, fully derived from the document) --- + +struct Progress { + done: usize, + total: usize, + bytes: usize, +} + +impl Progress { + fn new(total: usize) -> Self { + Self { + done: 0, + total, + bytes: 0, + } + } + fn tick(&mut self, last: usize) { + self.done += 1; + self.bytes += last; + log::info!( + "Sponza: file {}/{}, +{} KB, {:.1} MB so far", + self.done, + self.total, + last / 1024, + self.bytes as f64 / (1024.0 * 1024.0), + ); + } +}