diff --git a/assets/shaders/cube.frag b/assets/shaders/cube.frag index 91d2c3f..500d987 100644 --- a/assets/shaders/cube.frag +++ b/assets/shaders/cube.frag @@ -1,9 +1,28 @@ #version 450 layout(location = 0) in vec3 frag_color; +layout(location = 1) in vec3 frag_normal; +layout(location = 2) in float frag_debug_tint; layout(location = 0) out vec4 out_color; -void main () { - out_color = vec4(frag_color, 1.0); -} \ No newline at end of file +// Direction the light travels, pointing downward and across both horizontal axes so that no cubic face orientation receives exactly the same amount of light as another. A light aligned with an axis would leave two of the three visible faces of a cube indistinguishable. +const vec3 LIGHT_DIRECTION = vec3(-0.4, -1.0, -0.3); + +// Fraction of the albedo retained by a fully unlit face, standing in for bounced light until a global-illumination term exists. Without it, faces turned away from the light collapse to black and their silhouettes disappear against one another. +const float AMBIENT = 0.25; + +// Colour applied to debug raster passes, chosen to contrast with terrain and to remain legible when overlaid on filled geometry. +const vec3 DEBUG_COLOR = vec3(1.0, 0.0, 1.0); + +void main() { + // The interpolated normal is renormalised: the six face normals are unit length and constant across a quad, but interpolation across a triangle is not guaranteed to preserve that. + vec3 normal = normalize(frag_normal); + + // Lambertian term. The light vector is negated because LIGHT_DIRECTION points along the light's travel, whereas the dot product requires the direction from the surface toward the light. The clamp discards the negative half, where the face points away from the light. + float diffuse = max(dot(normal, normalize(-LIGHT_DIRECTION)), 0.0); + vec3 lit = frag_color * (AMBIENT + (1.0 - AMBIENT) * diffuse); + + // The debug tint is applied after shading so debug passes draw flat and stay legible over the shaded geometry beneath them. + out_color = vec4(mix(lit, DEBUG_COLOR, frag_debug_tint), 1.0); +} diff --git a/assets/shaders/cube.vert b/assets/shaders/cube.vert index ecaf58f..bb9d2b3 100644 --- a/assets/shaders/cube.vert +++ b/assets/shaders/cube.vert @@ -3,8 +3,11 @@ layout(location = 0) in vec3 in_position; layout(location = 1) in vec3 in_color; +layout(location = 2) in uint in_face; layout(location = 0) out vec3 frag_color; +layout(location = 1) out vec3 frag_normal; +layout(location = 2) out float frag_debug_tint; layout(push_constant) uniform PushConstants { mat4 mvp; @@ -12,12 +15,19 @@ layout(push_constant) uniform PushConstants { vec4 chunk_offset; } push_constants; -// Colour applied to debug raster passes, chosen to contrast with terrain and to remain legible when overlaid on filled geometry. -const vec3 DEBUG_COLOR = vec3(1.0, 0.0, 1.0); - // Size, in pixels, of the points emitted under VK_POLYGON_MODE_POINT. Sizes above 1.0 require the largePoints device feature. const float DEBUG_POINT_SIZE = 5.0; +// Outward normals of the six cubic face directions, indexed by the packed face attribute. +const vec3 FACE_NORMALS[6] = vec3[6]( + vec3( 1.0, 0.0, 0.0), + vec3(-1.0, 0.0, 0.0), + vec3( 0.0, 1.0, 0.0), + vec3( 0.0, -1.0, 0.0), + vec3( 0.0, 0.0, 1.0), + vec3( 0.0, 0.0, -1.0) +); + void main() { // The chunk-local vertex is shifted into world space by the per-chunk offset before projection. vec3 world_position = in_position + push_constants.chunk_offset.xyz; @@ -26,6 +36,11 @@ void main() { // Point size is consulted whenever the polygon mode is POINT; leaving it unwritten renders points of undefined size. It is ignored by the FILL and LINE pipelines, so it is written unconditionally. gl_PointSize = DEBUG_POINT_SIZE; - float debug_tint = push_constants.chunk_offset.w; - frag_color = mix(in_color, DEBUG_COLOR, debug_tint); + frag_color = in_color; + + // Chunk placement is a pure translation, so a chunk-local face normal is already a world-space normal and no normal matrix is required. + frag_normal = FACE_NORMALS[in_face]; + + // Shading and the debug tint both resolve in the fragment stage, so the weight is forwarded rather than applied here. + frag_debug_tint = push_constants.chunk_offset.w; } diff --git a/crates/renderer/src/meshing.rs b/crates/renderer/src/meshing.rs index 1744d14..12c29c0 100644 --- a/crates/renderer/src/meshing.rs +++ b/crates/renderer/src/meshing.rs @@ -24,26 +24,33 @@ enum FaceDir { NegZ, } +impl FaceDir { + /// Returns the index identifying this direction's outward normal to the shader. + /// + /// The six values are a contract with the `FACE_NORMALS` table in `assets/shaders/cube.vert`, which is indexed by them directly. + const fn to_index(self) -> u32 { + match self { + Self::PosX => 0, + Self::NegX => 1, + Self::PosY => 2, + Self::NegY => 3, + Self::PosZ => 4, + Self::NegZ => 5, + } + } +} + /// Identifies whether two faces are mergeable. #[derive(Copy, Clone, PartialEq, Eq)] struct FaceKey { /// The material of the voxel owning the face. block: BlockId, - /// The face's signed axis direction, which selects its colour. + /// The face's signed axis direction, which selects its outward normal. dir: FaceDir, } -/// Returns the flat RGB colour for a face pointing in `dir`. -/// -/// The values reproduce the previous per-face emitter exactly so the rendered output is unchanged. -const fn color_of(dir: FaceDir) -> [f32; 3] { - match dir { - FaceDir::PosY => [0.2, 0.8, 0.2], - FaceDir::NegY => [0.1, 0.4, 0.1], - FaceDir::PosX | FaceDir::NegX => [0.15, 0.6, 0.15], - FaceDir::PosZ | FaceDir::NegZ => [0.18, 0.7, 0.18], - } -} +/// The flat RGB albedo emitted for every face. +const MATERIAL_COLOR: [f32; 3] = [0.2, 0.8, 0.2]; /// Converts a chunk-local integer coordinate to its floating-point value. #[expect( @@ -332,7 +339,7 @@ fn run_pass( vertices, indices, corners(slice, u0, v0, w, h), - color_of(key.dir), + key.dir.to_index(), ); }); } @@ -380,14 +387,14 @@ fn merge_mask( } } -/// Appends one quad (four vertices, six indices) with the given corners and colour. +/// Appends one quad (four vertices, six indices) with the given corners and packed face normal. /// -/// Indices wind the two triangles as `[base, base+1, base+2, base+2, base+3, base]`, matching the corner ordering supplied by the caller. +/// A quad is planar, so all four vertices share `normal`. Indices wind the two triangles as `[base, base+1, base+2, base+2, base+3, base]`, matching the corner ordering supplied by the caller. fn push_quad( vertices: &mut Vec, indices: &mut Vec, corners: [[f32; 3]; 4], - color: [f32; 3], + normal: u32, ) { #[expect( clippy::cast_possible_truncation, @@ -395,7 +402,11 @@ fn push_quad( )] let base = vertices.len() as u32; for position in corners { - vertices.push(Vertex { position, color }); + vertices.push(Vertex { + position, + color: MATERIAL_COLOR, + normal, + }); } indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]); } diff --git a/crates/renderer/src/tests/meshing.rs b/crates/renderer/src/tests/meshing.rs index 818861a..ec9c60b 100644 --- a/crates/renderer/src/tests/meshing.rs +++ b/crates/renderer/src/tests/meshing.rs @@ -114,6 +114,50 @@ fn single_block_emits_six_quads() { assert_eq!(indices.len(), 36); } +#[test] +fn face_direction_indices_match_the_shader_normal_table() { + // Pins the numeric contract with the FACE_NORMALS table in assets/shaders/cube.vert, which is indexed by these values. Nothing else connects the two, and a silent reordering would mis-light every face rather than fail to build. + assert_eq!(FaceDir::PosX.to_index(), 0); + assert_eq!(FaceDir::NegX.to_index(), 1); + assert_eq!(FaceDir::PosY.to_index(), 2); + assert_eq!(FaceDir::NegY.to_index(), 3); + assert_eq!(FaceDir::PosZ.to_index(), 4); + assert_eq!(FaceDir::NegZ.to_index(), 5); +} + +#[test] +fn single_block_quads_carry_their_own_face_normal() { + let mut chunk = Chunk::default(); + chunk.set(5, 5, 5, BlockId(1)); + let (vertices, _) = generate_mesh(&chunk, &Neighbors::default()); + + // The constant axis and plane coordinate of each face of a block at (5, 5, 5), indexed by packed normal. Deducing the expected direction from the geometry rather than from the emission order keeps the assertion valid if the passes are reordered. + let expected: [(usize, f32); 6] = [(0, 5.5), (0, 4.5), (1, 5.5), (1, 4.5), (2, 5.5), (2, 4.5)]; + + let mut seen = [false; 6]; + for quad in vertices.chunks_exact(4) { + let normal = quad[0].normal; + assert!( + quad.iter().all(|v| v.normal == normal), + "a planar quad carries more than one normal index" + ); + + let (axis, plane) = expected[normal as usize]; + assert!( + quad.iter() + .all(|v| (v.position[axis] - plane).abs() < f32::EPSILON), + "the quad tagged with normal index {normal} does not lie on that face's plane" + ); + + seen[normal as usize] = true; + } + + assert!( + seen.iter().all(|&s| s), + "an isolated block must emit one quad per face direction" + ); +} + #[test] fn full_chunk_merges_each_face_into_one_quad() { let mut chunk = Chunk::default(); diff --git a/crates/renderer/src/vertex.rs b/crates/renderer/src/vertex.rs index d2e0011..645b4cb 100644 --- a/crates/renderer/src/vertex.rs +++ b/crates/renderer/src/vertex.rs @@ -4,10 +4,10 @@ use bytemuck::{Pod, Zeroable}; -/// Represents a single vertex in 3D space with position and texture coordinates. +/// Represents a single vertex in 3D space with position, colour, and face orientation. /// /// Uses `repr(C)` to ensure the memory layout matches what the GPU expects (no Rust-specific reordering). -/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice. +/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice. Every field is 4-byte aligned and the struct is 28 bytes, so no implicit padding exists for `Pod` to expose. #[repr(C)] #[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)] pub struct Vertex { @@ -15,6 +15,10 @@ pub struct Vertex { pub position: [f32; 3], /// The RGB color of the vertex [r, g, b]. pub color: [f32; 3], + /// Index of the face's outward normal into the shader's normal table. + /// + /// Cubic geometry admits only six distinct normals, so the direction is packed as an index rather than a `vec3`, saving 8 bytes per vertex. The vertex shader decodes it; the index ordering is defined by `FaceDir::to_index` in `meshing.rs` and must stay in step with the `FACE_NORMALS` table in `cube.vert`. + pub normal: u32, } impl Vertex { @@ -40,7 +44,7 @@ impl Vertex { /// Describes the layout of individual fields (attributes) within a single vertex. /// /// These 'locations' must match the `layout(location = X)` qualifiers in the vertex shader. - pub fn get_attribute_descriptions() -> [ash::vk::VertexInputAttributeDescription; 2] { + pub fn get_attribute_descriptions() -> [ash::vk::VertexInputAttributeDescription; 3] { [ // Location 0: position (vec3 -> R32G32B32_SFLOAT) ash::vk::VertexInputAttributeDescription::default() @@ -53,6 +57,12 @@ impl Vertex { .location(1) .format(ash::vk::Format::R32G32B32_SFLOAT) .offset(12), + // Location 2: packed face normal index (uint -> R32_UINT). The shader input must be declared `uint`; reading an integer-formatted attribute through a float declaration is undefined and silently produces garbage on some drivers. + ash::vk::VertexInputAttributeDescription::default() + .binding(0) + .location(2) + .format(ash::vk::Format::R32_UINT) + .offset(24), ] } }