From cea863797283cf8295d5da39011173758504a9f2 Mon Sep 17 00:00:00 2001 From: "opencode[bot]" Date: Mon, 13 Apr 2026 21:02:03 +0000 Subject: [PATCH 1/2] test: add shadow system tests for graphics/shadows --- src/engine/graphics/shadow_tests.zig | 406 +++++++++++++++++++++++++++ src/tests.zig | 1 + 2 files changed, 407 insertions(+) create mode 100644 src/engine/graphics/shadow_tests.zig diff --git a/src/engine/graphics/shadow_tests.zig b/src/engine/graphics/shadow_tests.zig new file mode 100644 index 00000000..418792a3 --- /dev/null +++ b/src/engine/graphics/shadow_tests.zig @@ -0,0 +1,406 @@ +const std = @import("std"); +const testing = std.testing; +const c = @import("../../c.zig").c; +const rhi = @import("rhi.zig"); +const Mat4 = @import("../math/mat4.zig").Mat4; +const Vec3 = @import("../math/vec3.zig").Vec3; +const ShadowSystem = @import("shadow_system.zig").ShadowSystem; +const computeCascades = @import("csm.zig").computeCascades; +const ShadowCascades = @import("csm.zig").ShadowCascades; +const CASCADE_COUNT = @import("csm.zig").CASCADE_COUNT; + +fn mat4IsIdentity(m: Mat4) bool { + for (0..4) |row| { + for (0..4) |col| { + const expected: f32 = if (row == col) 1.0 else 0.0; + if (@abs(m.data[row][col] - expected) > 0.0001) return false; + } + } + return true; +} + +test "ShadowSystem init rejects zero resolution" { + try testing.expectError(error.InvalidResolution, ShadowSystem.init(testing.allocator, 0)); +} + +test "ShadowSystem init accepts valid resolution" { + var sys = try ShadowSystem.init(testing.allocator, 1024); + defer sys.deinit(null); + + try testing.expectEqual(@as(u32, 1024), sys.shadow_extent.width); + try testing.expectEqual(@as(u32, 1024), sys.shadow_extent.height); +} + +test "ShadowSystem state defaults after init" { + var sys = try ShadowSystem.init(testing.allocator, 2048); + defer sys.deinit(null); + + try testing.expect(!sys.pass_active); + try testing.expectEqual(@as(u32, 0), sys.pass_index); + try testing.expect(!sys.pipeline_bound); + try testing.expect(mat4IsIdentity(sys.pass_matrix)); +} + +test "ShadowSystem init various resolutions" { + const resolutions = [_]u32{ 512, 1024, 2048, 4096 }; + for (resolutions) |res| { + var sys = try ShadowSystem.init(testing.allocator, res); + defer sys.deinit(null); + + try testing.expectEqual(res, sys.shadow_extent.width); + try testing.expectEqual(res, sys.shadow_extent.height); + } +} + +test "ShadowCascades initZero sets identity matrices" { + const cascades = ShadowCascades.initZero(); + + for (0..CASCADE_COUNT) |i| { + try testing.expectEqual(@as(f32, 0.0), cascades.cascade_splits[i]); + try testing.expectEqual(@as(f32, 0.0), cascades.texel_sizes[i]); + try testing.expect(mat4IsIdentity(cascades.light_space_matrices[i])); + } +} + +test "ShadowCascades isValid returns true for valid cascades" { + var cascades = ShadowCascades.initZero(); + cascades.cascade_splits = .{ 10.0, 50.0, 150.0, 500.0 }; + cascades.texel_sizes = .{ 0.5, 1.0, 2.0, 4.0 }; + cascades.light_space_matrices = .{Mat4.identity} ** CASCADE_COUNT; + + try testing.expect(cascades.isValid()); +} + +test "ShadowCascades isValid returns false for non-finite splits" { + var cascades = ShadowCascades.initZero(); + cascades.cascade_splits = .{ 10.0, std.math.nan(f32), 150.0, 500.0 }; + cascades.texel_sizes = .{ 0.5, 1.0, 2.0, 4.0 }; + + try testing.expect(!cascades.isValid()); +} + +test "ShadowCascades isValid returns false for zero texel size" { + var cascades = ShadowCascades.initZero(); + cascades.cascade_splits = .{ 10.0, 50.0, 150.0, 500.0 }; + cascades.texel_sizes = .{ 0.5, 0.0, 2.0, 4.0 }; + + try testing.expect(!cascades.isValid()); +} + +test "ShadowCascades isValid returns false for non-increasing splits" { + var cascades = ShadowCascades.initZero(); + cascades.cascade_splits = .{ 10.0, 50.0, 40.0, 500.0 }; + cascades.texel_sizes = .{ 0.5, 1.0, 2.0, 4.0 }; + + try testing.expect(!cascades.isValid()); +} + +test "computeCascades returns zeroed cascades on invalid input" { + const cascades = computeCascades( + 0, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.1, + 200.0, + Vec3.init(0.3, -1.0, 0.2).normalize(), + Mat4.identity, + true, + ); + + try testing.expect(!cascades.isValid()); +} + +test "computeCascades returns zeroed cascades when far equals near" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 100.0, + 100.0, + Vec3.init(0.3, -1.0, 0.2).normalize(), + Mat4.identity, + true, + ); + + try testing.expect(!cascades.isValid()); +} + +test "computeCascades returns zeroed cascades when near is zero" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.0, + 200.0, + Vec3.init(0.3, -1.0, 0.2).normalize(), + Mat4.identity, + true, + ); + + try testing.expect(!cascades.isValid()); +} + +test "computeCascades returns zeroed cascades when near is negative" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + -10.0, + 200.0, + Vec3.init(0.3, -1.0, 0.2).normalize(), + Mat4.identity, + true, + ); + + try testing.expect(!cascades.isValid()); +} + +test "computeCascades produces increasing splits" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.1, + 200.0, + Vec3.init(0.3, -1.0, 0.2).normalize(), + Mat4.identity, + true, + ); + + var last_split: f32 = 0.0; + for (0..CASCADE_COUNT) |i| { + try testing.expect(cascades.cascade_splits[i] > last_split); + last_split = cascades.cascade_splits[i]; + } +} + +test "computeCascades produces positive texel sizes" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.1, + 200.0, + Vec3.init(0.3, -1.0, 0.2).normalize(), + Mat4.identity, + true, + ); + + for (0..CASCADE_COUNT) |i| { + try testing.expect(cascades.texel_sizes[i] > 0.0); + } +} + +test "computeCascades uses fixed splits for large shadow distance" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.1, + 1000.0, + Vec3.init(0.3, -1.0, 0.2).normalize(), + Mat4.identity, + true, + ); + + const expected_splits = [4]f32{ 80.0, 250.0, 600.0, 1000.0 }; + for (0..CASCADE_COUNT) |i| { + try testing.expectApproxEqAbs(expected_splits[i], cascades.cascade_splits[i], 0.001); + } +} + +test "computeCascades uses logarithmic splits for small shadow distance" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.1, + 200.0, + Vec3.init(0.3, -1.0, 0.2).normalize(), + Mat4.identity, + true, + ); + + try testing.expect(cascades.cascade_splits[0] < 200.0); + try testing.expect(cascades.cascade_splits[3] == 200.0); +} + +test "computeCascades with reverse-Z produces valid matrices" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.1, + 500.0, + Vec3.init(0.0, -1.0, 0.0), + Mat4.identity, + true, + ); + + try testing.expect(cascades.isValid()); +} + +test "computeCascades with standard Z produces valid matrices" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.1, + 500.0, + Vec3.init(0.0, -1.0, 0.0), + Mat4.identity, + false, + ); + + try testing.expect(cascades.isValid()); +} + +test "computeCascades handles extreme sun direction (up)" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.1, + 200.0, + Vec3.init(0.0, 1.0, 0.0), + Mat4.identity, + true, + ); + + try testing.expect(cascades.isValid()); +} + +test "computeCascades handles extreme sun direction (near-horizontal)" { + const cascades = computeCascades( + 1024, + std.math.degreesToRadians(60.0), + 16.0 / 9.0, + 0.1, + 200.0, + Vec3.init(1.0, 0.1, 0.0).normalize(), + Mat4.identity, + true, + ); + + try testing.expect(cascades.isValid()); +} + +test "ShadowUniforms extern struct has correct size" { + const ShadowUniforms = extern struct { + light_space_matrices: [rhi.SHADOW_CASCADE_COUNT]Mat4, + cascade_splits: [4]f32, + shadow_texel_sizes: [4]f32, + shadow_params: [4]f32, + }; + + const expected_size = @sizeOf([rhi.SHADOW_CASCADE_COUNT]Mat4) + (@sizeOf(f32) * 12); + try testing.expectEqual(@as(usize, expected_size), @sizeOf(ShadowUniforms)); +} + +test "ShadowUniforms field offsets" { + const ShadowUniforms = extern struct { + light_space_matrices: [rhi.SHADOW_CASCADE_COUNT]Mat4, + cascade_splits: [4]f32, + shadow_texel_sizes: [4]f32, + shadow_params: [4]f32, + }; + + const matrices_size = @sizeOf([rhi.SHADOW_CASCADE_COUNT]Mat4); + const splits_offset = @offsetOf(ShadowUniforms, "cascade_splits"); + const texel_offset = @offsetOf(ShadowUniforms, "shadow_texel_sizes"); + const params_offset = @offsetOf(ShadowUniforms, "shadow_params"); + + try testing.expectEqual(matrices_size, splits_offset); + try testing.expectEqual(matrices_size + @sizeOf([4]f32), texel_offset); + try testing.expectEqual(matrices_size + @sizeOf([4]f32) * 2, params_offset); +} + +test "getShadowMapHandle returns 0 for out-of-bounds cascade" { + const MockShadowCtx = struct { + fn getShadowMapHandle(_: *anyopaque, cascade_index: u32) rhi.TextureHandle { + if (cascade_index >= rhi.SHADOW_CASCADE_COUNT) return 0; + return cascade_index + 1; + } + }; + + const VTable = rhi.IShadowContext.VTable{ + .beginPass = undefined, + .endPass = undefined, + .updateUniforms = undefined, + .getShadowMapHandle = MockShadowCtx.getShadowMapHandle, + }; + + const ctx = @as(*anyopaque, @ptrFromInt(0x1234)); + const shadow_ctx = rhi.IShadowContext{ .ptr = ctx, .vtable = &VTable }; + + try testing.expectEqual(@as(rhi.TextureHandle, 0), shadow_ctx.getShadowMapHandle(rhi.SHADOW_CASCADE_COUNT)); + try testing.expectEqual(@as(rhi.TextureHandle, 0), shadow_ctx.getShadowMapHandle(rhi.SHADOW_CASCADE_COUNT + 1)); +} + +test "getShadowMapHandle returns valid handle for valid cascade" { + const MockShadowCtx = struct { + fn getShadowMapHandle(_: *anyopaque, cascade_index: u32) rhi.TextureHandle { + if (cascade_index >= rhi.SHADOW_CASCADE_COUNT) return 0; + return cascade_index + 1; + } + }; + + const VTable = rhi.IShadowContext.VTable{ + .beginPass = undefined, + .endPass = undefined, + .updateUniforms = undefined, + .getShadowMapHandle = MockShadowCtx.getShadowMapHandle, + }; + + const ctx = @as(*anyopaque, @ptrFromInt(0x1234)); + const shadow_ctx = rhi.IShadowContext{ .ptr = ctx, .vtable = &VTable }; + + try testing.expectEqual(@as(rhi.TextureHandle, 1), shadow_ctx.getShadowMapHandle(0)); + try testing.expectEqual(@as(rhi.TextureHandle, 2), shadow_ctx.getShadowMapHandle(1)); + try testing.expectEqual(@as(rhi.TextureHandle, 3), shadow_ctx.getShadowMapHandle(2)); + try testing.expectEqual(@as(rhi.TextureHandle, 4), shadow_ctx.getShadowMapHandle(3)); +} + +test "ShadowConfig default values" { + const config = rhi.ShadowConfig{}; + try testing.expectEqual(@as(f32, 250.0), config.distance); + try testing.expectEqual(@as(u32, 4096), config.resolution); + try testing.expectEqual(@as(u8, 12), config.pcf_samples); + try testing.expect(config.cascade_blend); + try testing.expectEqual(@as(f32, 0.35), config.strength); + try testing.expectEqual(@as(f32, 3.0), config.light_size); + try testing.expectEqual(@as(f32, 250.0), config.caster_distance); +} + +test "ShadowParams struct layout" { + const params = rhi.ShadowParams{ + .light_space_matrices = .{Mat4.identity} ** rhi.SHADOW_CASCADE_COUNT, + .cascade_splits = .{ 10.0, 50.0, 150.0, 500.0 }, + .shadow_texel_sizes = .{ 0.5, 1.0, 2.0, 4.0 }, + .light_size = 3.0, + }; + + try testing.expectEqual(@as(f32, 10.0), params.cascade_splits[0]); + try testing.expectEqual(@as(f32, 500.0), params.cascade_splits[3]); + try testing.expectEqual(@as(f32, 3.0), params.light_size); +} + +test "ShadowCascades isValid detects non-finite light space matrix" { + var cascades = ShadowCascades.initZero(); + cascades.cascade_splits = .{ 10.0, 50.0, 150.0, 500.0 }; + cascades.texel_sizes = .{ 0.5, 1.0, 2.0, 4.0 }; + + var bad_matrix = Mat4.identity; + bad_matrix.data[0][0] = std.math.nan(f32); + cascades.light_space_matrices[1] = bad_matrix; + + try testing.expect(!cascades.isValid()); +} + +test "ShadowCascades isValid returns false for zero split" { + var cascades = ShadowCascades.initZero(); + cascades.cascade_splits = .{ 0.0, 50.0, 150.0, 500.0 }; + cascades.texel_sizes = .{ 0.5, 1.0, 2.0, 4.0 }; + + try testing.expect(!cascades.isValid()); +} diff --git a/src/tests.zig b/src/tests.zig index 83d8d951..4eb720fd 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -77,6 +77,7 @@ test { _ = @import("engine/graphics/vulkan/frame_manager_tests.zig"); _ = @import("vulkan_tests.zig"); _ = @import("engine/graphics/rhi_tests.zig"); + _ = @import("engine/graphics/shadow_tests.zig"); _ = @import("engine/math/utils_tests.zig"); _ = @import("world/world_tests.zig"); _ = @import("world/worldgen/schematics.zig"); From 5a87b6dbb2064dcca79279c96ab7522349a51a77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 01:44:56 +0000 Subject: [PATCH 2/2] fix(shadow_tests): import actual ShadowUniforms struct instead of local copy Address PR review feedback: - [MEDIUM] shadow_tests.zig:288-315 - Import ShadowUniforms from descriptor_manager.zig instead of defining local struct copy. Made ShadowUniforms pub const in descriptor_manager.zig to enable import. This ensures tests validate actual struct layout. - [LOW] shadow_tests.zig:331-345 - Use explicit ctx parameter name with _ = ctx; discard instead of _ anonymous parameter. - [LOW] shadow_tests.zig:28 - Use block-style defer for consistency. --- src/engine/graphics/shadow_tests.zig | 24 +++++++------------ .../graphics/vulkan/descriptor_manager.zig | 2 +- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/engine/graphics/shadow_tests.zig b/src/engine/graphics/shadow_tests.zig index 418792a3..64ddd562 100644 --- a/src/engine/graphics/shadow_tests.zig +++ b/src/engine/graphics/shadow_tests.zig @@ -25,7 +25,9 @@ test "ShadowSystem init rejects zero resolution" { test "ShadowSystem init accepts valid resolution" { var sys = try ShadowSystem.init(testing.allocator, 1024); - defer sys.deinit(null); + defer { + sys.deinit(null); + } try testing.expectEqual(@as(u32, 1024), sys.shadow_extent.width); try testing.expectEqual(@as(u32, 1024), sys.shadow_extent.height); @@ -286,24 +288,14 @@ test "computeCascades handles extreme sun direction (near-horizontal)" { } test "ShadowUniforms extern struct has correct size" { - const ShadowUniforms = extern struct { - light_space_matrices: [rhi.SHADOW_CASCADE_COUNT]Mat4, - cascade_splits: [4]f32, - shadow_texel_sizes: [4]f32, - shadow_params: [4]f32, - }; + const ShadowUniforms = @import("vulkan/descriptor_manager.zig").ShadowUniforms; const expected_size = @sizeOf([rhi.SHADOW_CASCADE_COUNT]Mat4) + (@sizeOf(f32) * 12); try testing.expectEqual(@as(usize, expected_size), @sizeOf(ShadowUniforms)); } test "ShadowUniforms field offsets" { - const ShadowUniforms = extern struct { - light_space_matrices: [rhi.SHADOW_CASCADE_COUNT]Mat4, - cascade_splits: [4]f32, - shadow_texel_sizes: [4]f32, - shadow_params: [4]f32, - }; + const ShadowUniforms = @import("vulkan/descriptor_manager.zig").ShadowUniforms; const matrices_size = @sizeOf([rhi.SHADOW_CASCADE_COUNT]Mat4); const splits_offset = @offsetOf(ShadowUniforms, "cascade_splits"); @@ -317,7 +309,8 @@ test "ShadowUniforms field offsets" { test "getShadowMapHandle returns 0 for out-of-bounds cascade" { const MockShadowCtx = struct { - fn getShadowMapHandle(_: *anyopaque, cascade_index: u32) rhi.TextureHandle { + fn getShadowMapHandle(ctx: *anyopaque, cascade_index: u32) rhi.TextureHandle { + _ = ctx; if (cascade_index >= rhi.SHADOW_CASCADE_COUNT) return 0; return cascade_index + 1; } @@ -339,7 +332,8 @@ test "getShadowMapHandle returns 0 for out-of-bounds cascade" { test "getShadowMapHandle returns valid handle for valid cascade" { const MockShadowCtx = struct { - fn getShadowMapHandle(_: *anyopaque, cascade_index: u32) rhi.TextureHandle { + fn getShadowMapHandle(ctx: *anyopaque, cascade_index: u32) rhi.TextureHandle { + _ = ctx; if (cascade_index >= rhi.SHADOW_CASCADE_COUNT) return 0; return cascade_index + 1; } diff --git a/src/engine/graphics/vulkan/descriptor_manager.zig b/src/engine/graphics/vulkan/descriptor_manager.zig index 9d690bed..dd69c179 100644 --- a/src/engine/graphics/vulkan/descriptor_manager.zig +++ b/src/engine/graphics/vulkan/descriptor_manager.zig @@ -28,7 +28,7 @@ const GlobalUniforms = extern struct { lpv_origin: [4]f32, }; -const ShadowUniforms = extern struct { +pub const ShadowUniforms = extern struct { light_space_matrices: [rhi.SHADOW_CASCADE_COUNT]Mat4, cascade_splits: [4]f32, shadow_texel_sizes: [4]f32,