From 6c0a45edd14a34169752ca9ab72cc054bbd589df Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 7 Feb 2026 21:52:42 +0000 Subject: [PATCH 1/9] feat(lighting): add LPV compute GI with debug profiling --- assets/shaders/vulkan/g_pass.frag | 2 + assets/shaders/vulkan/lpv_inject.comp | 49 ++ assets/shaders/vulkan/lpv_propagate.comp | 47 + assets/shaders/vulkan/terrain.frag | 53 +- assets/shaders/vulkan/terrain.frag.spv | Bin 46596 -> 52988 bytes assets/shaders/vulkan/terrain.vert | 2 + assets/shaders/vulkan/terrain.vert.spv | Bin 6104 -> 6200 bytes build.zig | 6 +- src/engine/graphics/lpv_system.zig | 805 ++++++++++++++++++ src/engine/graphics/render_graph.zig | 2 + src/engine/graphics/rhi.zig | 4 + src/engine/graphics/rhi_tests.zig | 11 + src/engine/graphics/rhi_types.zig | 6 + src/engine/graphics/rhi_vulkan.zig | 12 +- .../graphics/vulkan/descriptor_bindings.zig | 1 + .../graphics/vulkan/descriptor_manager.zig | 4 + .../graphics/vulkan/resource_manager.zig | 12 +- .../graphics/vulkan/resource_texture_ops.zig | 161 ++++ .../graphics/vulkan/rhi_context_factory.zig | 2 + .../graphics/vulkan/rhi_context_types.zig | 2 + .../vulkan/rhi_frame_orchestration.zig | 8 +- .../graphics/vulkan/rhi_init_deinit.zig | 4 +- .../graphics/vulkan/rhi_render_state.zig | 4 + src/engine/graphics/vulkan/rhi_timing.zig | 23 +- src/engine/ui/debug_lpv_overlay.zig | 30 + src/engine/ui/timing_overlay.zig | 3 +- src/game/app.zig | 20 + src/game/input_mapper.zig | 3 + src/game/screen.zig | 2 + src/game/screens/graphics.zig | 13 + src/game/screens/world.zig | 65 ++ src/game/settings/data.zig | 29 + src/game/settings/json_presets.zig | 38 + 33 files changed, 1404 insertions(+), 19 deletions(-) create mode 100644 assets/shaders/vulkan/lpv_inject.comp create mode 100644 assets/shaders/vulkan/lpv_propagate.comp create mode 100644 src/engine/graphics/lpv_system.zig create mode 100644 src/engine/ui/debug_lpv_overlay.zig diff --git a/assets/shaders/vulkan/g_pass.frag b/assets/shaders/vulkan/g_pass.frag index f14f09e..cc4f0b0 100644 --- a/assets/shaders/vulkan/g_pass.frag +++ b/assets/shaders/vulkan/g_pass.frag @@ -31,6 +31,8 @@ layout(set = 0, binding = 0) uniform GlobalUniforms { vec4 pbr_params; vec4 volumetric_params; vec4 viewport_size; + vec4 lpv_params; + vec4 lpv_origin; } global; // 4x4 Bayer matrix for dithered LOD transitions diff --git a/assets/shaders/vulkan/lpv_inject.comp b/assets/shaders/vulkan/lpv_inject.comp new file mode 100644 index 0000000..138f195 --- /dev/null +++ b/assets/shaders/vulkan/lpv_inject.comp @@ -0,0 +1,49 @@ +#version 450 + +layout(local_size_x = 4, local_size_y = 4, local_size_z = 4) in; + +struct LightData { + vec4 pos_radius; + vec4 color; +}; + +layout(set = 0, binding = 0, rgba32f) uniform writeonly image2D lpv_out; +layout(set = 0, binding = 1) readonly buffer Lights { + LightData lights[]; +} light_buffer; + +layout(push_constant) uniform InjectPush { + vec4 grid_origin_cell; + vec4 grid_params; + uint light_count; +} push_data; + +ivec2 atlasUV(ivec3 cell, int gridSize) { + return ivec2(cell.x, cell.y + cell.z * gridSize); +} + +void main() { + int gridSize = int(push_data.grid_params.x); + ivec3 cell = ivec3(gl_GlobalInvocationID.xyz); + if (any(greaterThanEqual(cell, ivec3(gridSize)))) { + return; + } + + vec3 world_pos = push_data.grid_origin_cell.xyz + vec3(cell) * push_data.grid_origin_cell.w + vec3(0.5 * push_data.grid_origin_cell.w); + + vec3 accum = vec3(0.0); + for (uint i = 0; i < push_data.light_count; i++) { + vec3 light_pos = light_buffer.lights[i].pos_radius.xyz; + float radius = max(light_buffer.lights[i].pos_radius.w, 0.001); + vec3 light_color = light_buffer.lights[i].color.rgb; + + float d = length(world_pos - light_pos); + if (d < radius) { + float att = 1.0 - (d / radius); + att *= att; + accum += light_color * att; + } + } + + imageStore(lpv_out, atlasUV(cell, gridSize), vec4(accum, 1.0)); +} diff --git a/assets/shaders/vulkan/lpv_propagate.comp b/assets/shaders/vulkan/lpv_propagate.comp new file mode 100644 index 0000000..4032939 --- /dev/null +++ b/assets/shaders/vulkan/lpv_propagate.comp @@ -0,0 +1,47 @@ +#version 450 + +layout(local_size_x = 4, local_size_y = 4, local_size_z = 4) in; + +layout(set = 0, binding = 0, rgba32f) uniform readonly image2D lpv_src; +layout(set = 0, binding = 1, rgba32f) uniform writeonly image2D lpv_dst; + +layout(push_constant) uniform PropPush { + uint grid_size; + vec4 propagation; +} push_data; + +ivec2 atlasUV(ivec3 cell, int gridSize) { + return ivec2(cell.x, cell.y + cell.z * gridSize); +} + +vec3 sampleCell(ivec3 cell, int gridSize) { + return imageLoad(lpv_src, atlasUV(cell, gridSize)).rgb; +} + +void main() { + int gridSize = int(push_data.grid_size); + ivec3 cell = ivec3(gl_GlobalInvocationID.xyz); + if (any(greaterThanEqual(cell, ivec3(gridSize)))) { + return; + } + + vec3 center = sampleCell(cell, gridSize) * 0.82; + vec3 accum = center; + float f = push_data.propagation.x; + + ivec3 off[6] = ivec3[6]( + ivec3(-1, 0, 0), ivec3(1, 0, 0), + ivec3(0, -1, 0), ivec3(0, 1, 0), + ivec3(0, 0, -1), ivec3(0, 0, 1) + ); + + for (int i = 0; i < 6; i++) { + ivec3 n = cell + off[i]; + if (any(lessThan(n, ivec3(0))) || any(greaterThanEqual(n, ivec3(gridSize)))) { + continue; + } + accum += sampleCell(n, gridSize) * f; + } + + imageStore(lpv_dst, atlasUV(cell, gridSize), vec4(accum, 1.0)); +} diff --git a/assets/shaders/vulkan/terrain.frag b/assets/shaders/vulkan/terrain.frag index c5b4a26..2d3c61a 100644 --- a/assets/shaders/vulkan/terrain.frag +++ b/assets/shaders/vulkan/terrain.frag @@ -32,6 +32,8 @@ layout(set = 0, binding = 0) uniform GlobalUniforms { vec4 pbr_params; // x = pbr_quality, y = exposure, z = saturation, w = ssao_strength vec4 volumetric_params; // x = enabled, y = density, z = steps, w = scattering vec4 viewport_size; // xy = width/height + vec4 lpv_params; // x = enabled, y = intensity, z = cell_size, w = grid_size + vec4 lpv_origin; // xyz = world origin } global; // Constants @@ -100,6 +102,7 @@ layout(set = 0, binding = 7) uniform sampler2D uRoughnessMap; // Roughness ma layout(set = 0, binding = 8) uniform sampler2D uDisplacementMap; // Displacement map (unused for now) layout(set = 0, binding = 9) uniform sampler2D uEnvMap; // Environment Map (EXR) layout(set = 0, binding = 10) uniform sampler2D uSSAOMap; // SSAO Map +layout(set = 0, binding = 11) uniform sampler2D uLPVGrid; // LPV 3D atlas (Z slices packed in Y) layout(set = 0, binding = 2) uniform ShadowUniforms { mat4 light_space_matrices[4]; @@ -277,6 +280,47 @@ vec3 computeIBLAmbient(vec3 N, float roughness) { return textureLod(uEnvMap, envUV, envMipLevel).rgb; } +vec3 sampleLPVVoxel(vec3 voxel, float gridSize) { + float u = (voxel.x + 0.5) / gridSize; + float v = (voxel.y + voxel.z * gridSize + 0.5) / (gridSize * gridSize); + return texture(uLPVGrid, vec2(u, v)).rgb; +} + +vec3 sampleLPVAtlas(vec3 worldPos) { + if (global.lpv_params.x < 0.5) return vec3(0.0); + + float gridSize = max(global.lpv_params.w, 1.0); + float cellSize = max(global.lpv_params.z, 0.001); + vec3 local = (worldPos - global.lpv_origin.xyz) / cellSize; + + if (any(lessThan(local, vec3(0.0))) || any(greaterThanEqual(local, vec3(gridSize)))) { + return vec3(0.0); + } + + vec3 base = floor(local); + vec3 frac = fract(local); + + vec3 p0 = clamp(base, vec3(0.0), vec3(gridSize - 1.0)); + vec3 p1 = clamp(base + vec3(1.0), vec3(0.0), vec3(gridSize - 1.0)); + + vec3 c000 = sampleLPVVoxel(vec3(p0.x, p0.y, p0.z), gridSize); + vec3 c100 = sampleLPVVoxel(vec3(p1.x, p0.y, p0.z), gridSize); + vec3 c010 = sampleLPVVoxel(vec3(p0.x, p1.y, p0.z), gridSize); + vec3 c110 = sampleLPVVoxel(vec3(p1.x, p1.y, p0.z), gridSize); + vec3 c001 = sampleLPVVoxel(vec3(p0.x, p0.y, p1.z), gridSize); + vec3 c101 = sampleLPVVoxel(vec3(p1.x, p0.y, p1.z), gridSize); + vec3 c011 = sampleLPVVoxel(vec3(p0.x, p1.y, p1.z), gridSize); + vec3 c111 = sampleLPVVoxel(vec3(p1.x, p1.y, p1.z), gridSize); + + vec3 c00 = mix(c000, c100, frac.x); + vec3 c10 = mix(c010, c110, frac.x); + vec3 c01 = mix(c001, c101, frac.x); + vec3 c11 = mix(c011, c111, frac.x); + vec3 c0 = mix(c00, c10, frac.y); + vec3 c1 = mix(c01, c11, frac.y); + return mix(c0, c1, frac.z) * global.lpv_params.y; +} + vec3 computeBRDF(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness) { vec3 H = normalize(V + L); vec3 F0 = mix(vec3(DIELECTRIC_F0), albedo, 0.0); @@ -307,14 +351,16 @@ vec3 computePBR(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness, float tota vec3 Lo = brdf * sunColor * NdotL_final * (1.0 - totalShadow); vec3 envColor = computeIBLAmbient(N, roughness); float shadowAmbientFactor = mix(1.0, 0.2, totalShadow); - vec3 ambientColor = albedo * (max(min(envColor, IBL_CLAMP) * skyLight * 0.8, vec3(global.lighting.x * 0.8)) + blockLight) * ao * ssao * shadowAmbientFactor; + vec3 indirect = sampleLPVAtlas(vFragPosWorld); + vec3 ambientColor = albedo * (max(min(envColor, IBL_CLAMP) * skyLight * 0.8, vec3(global.lighting.x * 0.8)) + blockLight + indirect) * ao * ssao * shadowAmbientFactor; return ambientColor + Lo; } vec3 computeNonPBR(vec3 albedo, vec3 N, float nDotL, float totalShadow, float skyLight, vec3 blockLight, float ao, float ssao) { vec3 envColor = computeIBLAmbient(N, NON_PBR_ROUGHNESS); float shadowAmbientFactor = mix(1.0, 0.2, totalShadow); - vec3 ambientColor = albedo * (max(min(envColor, IBL_CLAMP) * skyLight * 0.8, vec3(global.lighting.x * 0.8)) + blockLight) * ao * ssao * shadowAmbientFactor; + vec3 indirect = sampleLPVAtlas(vFragPosWorld); + vec3 ambientColor = albedo * (max(min(envColor, IBL_CLAMP) * skyLight * 0.8, vec3(global.lighting.x * 0.8)) + blockLight + indirect) * ao * ssao * shadowAmbientFactor; vec3 sunColor = global.sun_color.rgb * global.params.w * SUN_RADIANCE_TO_IRRADIANCE / PI; vec3 directColor = albedo * sunColor * nDotL * (1.0 - totalShadow); return ambientColor + directColor; @@ -322,7 +368,8 @@ vec3 computeNonPBR(vec3 albedo, vec3 N, float nDotL, float totalShadow, float sk vec3 computeLOD(vec3 albedo, float nDotL, float totalShadow, float skyLightVal, vec3 blockLight, float ao, float ssao) { float shadowAmbientFactor = mix(1.0, 0.2, totalShadow); - vec3 ambientColor = albedo * (max(vec3(skyLightVal * 0.8), vec3(global.lighting.x * 0.4)) + blockLight) * ao * ssao * shadowAmbientFactor; + vec3 indirect = sampleLPVAtlas(vFragPosWorld); + vec3 ambientColor = albedo * (max(vec3(skyLightVal * 0.8), vec3(global.lighting.x * 0.4)) + blockLight + indirect) * ao * ssao * shadowAmbientFactor; vec3 sunColor = global.sun_color.rgb * global.params.w * SUN_VOLUMETRIC_INTENSITY / PI; vec3 directColor = albedo * sunColor * nDotL * (1.0 - totalShadow); return ambientColor + directColor; diff --git a/assets/shaders/vulkan/terrain.frag.spv b/assets/shaders/vulkan/terrain.frag.spv index 30588452bbb7511442fab78792a6e46949d60563..e1a64e16663d95319f3d0bfdccc6d1f4bba8e834 100644 GIT binary patch literal 52988 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 literal 46596 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 diff --git a/assets/shaders/vulkan/terrain.vert b/assets/shaders/vulkan/terrain.vert index 9f5d940..54d42ec 100644 --- a/assets/shaders/vulkan/terrain.vert +++ b/assets/shaders/vulkan/terrain.vert @@ -39,6 +39,8 @@ layout(set = 0, binding = 0) uniform GlobalUniforms { vec4 pbr_params; // x = pbr_quality, y = exposure, z = saturation, w = ssao_strength vec4 volumetric_params; // x = enabled, y = density, z = steps, w = scattering vec4 viewport_size; // xy = width/height + vec4 lpv_params; // x = enabled, y = intensity, z = cell_size, w = grid_size + vec4 lpv_origin; // xyz = world origin } global; layout(push_constant) uniform ModelUniforms { diff --git a/assets/shaders/vulkan/terrain.vert.spv b/assets/shaders/vulkan/terrain.vert.spv index b08cac531d7200f855a314dd446ad5c1d94c8afb..dc977470a569e6dfd94f8c151193f62f9a3267fe 100644 GIT binary patch delta 117 zcmcbizr$d|4JJJ{1~vvc1_lORAkHZ$i!VqlO3W>00E$Ay`Jm$YMVaZDd7G~>#j&V* rFtEb)C~EPE1t-gMSx$D~;@BL))xZk?Z9o;L delta 28 kcmdmCa6^B?4W`W;%rPvRd)QwwG4f9?=d#?qgR6lT0HBo$DF6Tf diff --git a/build.zig b/build.zig index 63401c7..d1a8543 100644 --- a/build.zig +++ b/build.zig @@ -52,7 +52,7 @@ pub fn build(b: *std.Build) void { b.installArtifact(exe); - const shader_cmd = b.addSystemCommand(&.{ "sh", "-c", "for f in assets/shaders/vulkan/*.vert assets/shaders/vulkan/*.frag; do glslangValidator -V \"$f\" -o \"$f.spv\"; done" }); + const shader_cmd = b.addSystemCommand(&.{ "sh", "-c", "for f in assets/shaders/vulkan/*.vert assets/shaders/vulkan/*.frag assets/shaders/vulkan/*.comp; do glslangValidator -V \"$f\" -o \"$f.spv\"; done" }); const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); @@ -176,6 +176,8 @@ pub fn build(b: *std.Build) void { const validate_vulkan_ssao_frag = b.addSystemCommand(&.{ "glslangValidator", "-V", "assets/shaders/vulkan/ssao.frag" }); const validate_vulkan_ssao_blur_frag = b.addSystemCommand(&.{ "glslangValidator", "-V", "assets/shaders/vulkan/ssao_blur.frag" }); const validate_vulkan_g_pass_frag = b.addSystemCommand(&.{ "glslangValidator", "-V", "assets/shaders/vulkan/g_pass.frag" }); + const validate_vulkan_lpv_inject_comp = b.addSystemCommand(&.{ "glslangValidator", "-V", "assets/shaders/vulkan/lpv_inject.comp" }); + const validate_vulkan_lpv_propagate_comp = b.addSystemCommand(&.{ "glslangValidator", "-V", "assets/shaders/vulkan/lpv_propagate.comp" }); test_step.dependOn(&validate_vulkan_terrain_vert.step); test_step.dependOn(&validate_vulkan_terrain_frag.step); @@ -195,4 +197,6 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&validate_vulkan_ssao_frag.step); test_step.dependOn(&validate_vulkan_ssao_blur_frag.step); test_step.dependOn(&validate_vulkan_g_pass_frag.step); + test_step.dependOn(&validate_vulkan_lpv_inject_comp.step); + test_step.dependOn(&validate_vulkan_lpv_propagate_comp.step); } diff --git a/src/engine/graphics/lpv_system.zig b/src/engine/graphics/lpv_system.zig new file mode 100644 index 0000000..bf71b84 --- /dev/null +++ b/src/engine/graphics/lpv_system.zig @@ -0,0 +1,805 @@ +const std = @import("std"); +const c = @import("../../c.zig").c; +const rhi_pkg = @import("rhi.zig"); +const Vec3 = @import("../math/vec3.zig").Vec3; +const World = @import("../../world/world.zig").World; +const CHUNK_SIZE_X = @import("../../world/chunk.zig").CHUNK_SIZE_X; +const CHUNK_SIZE_Y = @import("../../world/chunk.zig").CHUNK_SIZE_Y; +const CHUNK_SIZE_Z = @import("../../world/chunk.zig").CHUNK_SIZE_Z; +const block_registry = @import("../../world/block_registry.zig"); +const VulkanContext = @import("vulkan/rhi_context_types.zig").VulkanContext; +const Utils = @import("vulkan/utils.zig"); + +const MAX_LIGHTS_PER_UPDATE: usize = 2048; + +const GpuLight = extern struct { + pos_radius: [4]f32, + color: [4]f32, +}; + +const InjectPush = extern struct { + grid_origin: [4]f32, + grid_params: [4]f32, + light_count: u32, + _pad0: [3]u32, +}; + +const PropagatePush = extern struct { + grid_size: u32, + _pad0: [3]u32, + propagation_factor: f32, + _pad1: [3]f32, +}; + +pub const LPVSystem = struct { + pub const Stats = struct { + updated_this_frame: bool = false, + light_count: u32 = 0, + cpu_update_ms: f32 = 0.0, + grid_size: u32 = 0, + propagation_iterations: u32 = 0, + update_interval_frames: u32 = 0, + }; + + allocator: std.mem.Allocator, + rhi: rhi_pkg.RHI, + vk_ctx: *VulkanContext, + + grid_texture_a: rhi_pkg.TextureHandle = 0, + grid_texture_b: rhi_pkg.TextureHandle = 0, + active_grid_texture: rhi_pkg.TextureHandle = 0, + debug_overlay_texture: rhi_pkg.TextureHandle = 0, + grid_size: u32, + cell_size: f32, + intensity: f32, + propagation_iterations: u32, + enabled: bool, + update_interval_frames: u32 = 6, + + origin: Vec3 = Vec3.zero, + current_frame: u32 = 0, + was_enabled_last_frame: bool = true, + + image_layout_a: c.VkImageLayout = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + image_layout_b: c.VkImageLayout = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + + debug_overlay_pixels: []f32, + stats: Stats, + + light_buffer: Utils.VulkanBuffer = .{}, + + descriptor_pool: c.VkDescriptorPool = null, + inject_set_layout: c.VkDescriptorSetLayout = null, + propagate_set_layout: c.VkDescriptorSetLayout = null, + inject_descriptor_set: c.VkDescriptorSet = null, + propagate_ab_descriptor_set: c.VkDescriptorSet = null, + propagate_ba_descriptor_set: c.VkDescriptorSet = null, + inject_pipeline_layout: c.VkPipelineLayout = null, + propagate_pipeline_layout: c.VkPipelineLayout = null, + inject_pipeline: c.VkPipeline = null, + propagate_pipeline: c.VkPipeline = null, + + pub fn init( + allocator: std.mem.Allocator, + rhi: rhi_pkg.RHI, + grid_size: u32, + cell_size: f32, + intensity: f32, + propagation_iterations: u32, + enabled: bool, + ) !*LPVSystem { + const self = try allocator.create(LPVSystem); + errdefer allocator.destroy(self); + + const vk_ctx: *VulkanContext = @ptrCast(@alignCast(rhi.ptr)); + const clamped_grid = std.math.clamp(grid_size, 16, 64); + + self.* = .{ + .allocator = allocator, + .rhi = rhi, + .vk_ctx = vk_ctx, + .grid_size = clamped_grid, + .cell_size = @max(cell_size, 0.5), + .intensity = std.math.clamp(intensity, 0.0, 4.0), + .propagation_iterations = std.math.clamp(propagation_iterations, 1, 8), + .enabled = enabled, + .was_enabled_last_frame = enabled, + .debug_overlay_pixels = &.{}, + .stats = .{ + .grid_size = clamped_grid, + .propagation_iterations = std.math.clamp(propagation_iterations, 1, 8), + .update_interval_frames = 6, + }, + }; + + try self.createGridTextures(); + errdefer self.destroyGridTextures(); + + const light_buffer_size = MAX_LIGHTS_PER_UPDATE * @sizeOf(GpuLight); + self.light_buffer = try Utils.createVulkanBuffer( + &vk_ctx.vulkan_device, + light_buffer_size, + c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + ); + errdefer self.destroyLightBuffer(); + + try self.initComputeResources(); + errdefer self.deinitComputeResources(); + + return self; + } + + pub fn deinit(self: *LPVSystem) void { + self.deinitComputeResources(); + self.destroyLightBuffer(); + self.destroyGridTextures(); + self.allocator.destroy(self); + } + + pub fn setSettings(self: *LPVSystem, enabled: bool, intensity: f32, cell_size: f32, propagation_iterations: u32, grid_size: u32, update_interval_frames: u32) !void { + self.enabled = enabled; + self.intensity = std.math.clamp(intensity, 0.0, 4.0); + self.cell_size = @max(cell_size, 0.5); + self.propagation_iterations = std.math.clamp(propagation_iterations, 1, 8); + self.update_interval_frames = std.math.clamp(update_interval_frames, 1, 16); + self.stats.propagation_iterations = self.propagation_iterations; + self.stats.update_interval_frames = self.update_interval_frames; + + const clamped_grid = std.math.clamp(grid_size, 16, 64); + if (clamped_grid == self.grid_size) return; + + self.destroyGridTextures(); + self.grid_size = clamped_grid; + self.stats.grid_size = clamped_grid; + self.origin = Vec3.zero; + try self.createGridTextures(); + try self.updateDescriptorSets(); + } + + pub fn getTextureHandle(self: *const LPVSystem) rhi_pkg.TextureHandle { + return self.active_grid_texture; + } + + pub fn getDebugOverlayTextureHandle(self: *const LPVSystem) rhi_pkg.TextureHandle { + return self.debug_overlay_texture; + } + + pub fn getStats(self: *const LPVSystem) Stats { + return self.stats; + } + + pub fn getOrigin(self: *const LPVSystem) Vec3 { + return self.origin; + } + + pub fn getGridSize(self: *const LPVSystem) u32 { + return self.grid_size; + } + + pub fn getCellSize(self: *const LPVSystem) f32 { + return self.cell_size; + } + + pub fn update(self: *LPVSystem, world: *World, camera_pos: Vec3) !void { + self.current_frame +%= 1; + var timer = std.time.Timer.start() catch unreachable; + self.stats.updated_this_frame = false; + self.stats.grid_size = self.grid_size; + self.stats.propagation_iterations = self.propagation_iterations; + self.stats.update_interval_frames = self.update_interval_frames; + + if (!self.enabled) { + self.active_grid_texture = self.grid_texture_a; + if (self.was_enabled_last_frame) { + self.buildDebugOverlay(&.{}, 0); + try self.uploadDebugOverlay(); + } + self.was_enabled_last_frame = false; + self.stats.light_count = 0; + self.stats.cpu_update_ms = 0.0; + return; + } + + const half_extent = (@as(f32, @floatFromInt(self.grid_size)) * self.cell_size) * 0.5; + const next_origin = Vec3.init( + quantizeToCell(camera_pos.x - half_extent, self.cell_size), + quantizeToCell(camera_pos.y - half_extent, self.cell_size), + quantizeToCell(camera_pos.z - half_extent, self.cell_size), + ); + + const moved = @abs(next_origin.x - self.origin.x) >= self.cell_size or + @abs(next_origin.y - self.origin.y) >= self.cell_size or + @abs(next_origin.z - self.origin.z) >= self.cell_size; + + const tick_update = (self.current_frame % self.update_interval_frames) == 0; + if (!moved and !tick_update and self.was_enabled_last_frame) { + self.stats.cpu_update_ms = 0.0; + return; + } + + self.origin = next_origin; + self.was_enabled_last_frame = true; + + var lights: [MAX_LIGHTS_PER_UPDATE]GpuLight = undefined; + const light_count = self.collectLights(world, lights[0..]); + if (self.light_buffer.mapped_ptr) |ptr| { + const bytes = std.mem.sliceAsBytes(lights[0..light_count]); + @memcpy(@as([*]u8, @ptrCast(ptr))[0..bytes.len], bytes); + } + + self.buildDebugOverlay(lights[0..], light_count); + try self.uploadDebugOverlay(); + + try self.dispatchCompute(light_count); + + const elapsed_ns = timer.read(); + const delta_ms: f32 = @floatCast(@as(f64, @floatFromInt(elapsed_ns)) / @as(f64, std.time.ns_per_ms)); + self.stats.updated_this_frame = true; + self.stats.light_count = @intCast(light_count); + self.stats.cpu_update_ms = delta_ms; + } + + fn collectLights(self: *LPVSystem, world: *World, out: []GpuLight) usize { + const grid_world_size = @as(f32, @floatFromInt(self.grid_size)) * self.cell_size; + const min_x = self.origin.x; + const min_y = self.origin.y; + const min_z = self.origin.z; + const max_x = min_x + grid_world_size; + const max_y = min_y + grid_world_size; + const max_z = min_z + grid_world_size; + + var emitted_lights: usize = 0; + + world.storage.chunks_mutex.lockShared(); + defer world.storage.chunks_mutex.unlockShared(); + + var iter = world.storage.iteratorUnsafe(); + while (iter.next()) |entry| { + const chunk_data = entry.value_ptr.*; + const chunk = &chunk_data.chunk; + + const chunk_min_x = @as(f32, @floatFromInt(chunk.chunk_x * CHUNK_SIZE_X)); + const chunk_min_z = @as(f32, @floatFromInt(chunk.chunk_z * CHUNK_SIZE_Z)); + const chunk_max_x = chunk_min_x + @as(f32, @floatFromInt(CHUNK_SIZE_X)); + const chunk_max_z = chunk_min_z + @as(f32, @floatFromInt(CHUNK_SIZE_Z)); + + if (chunk_max_x < min_x or chunk_min_x > max_x or chunk_max_z < min_z or chunk_min_z > max_z) { + continue; + } + + var y: u32 = 0; + while (y < CHUNK_SIZE_Y) : (y += 1) { + var z: u32 = 0; + while (z < CHUNK_SIZE_Z) : (z += 1) { + var x: u32 = 0; + while (x < CHUNK_SIZE_X) : (x += 1) { + const block = chunk.getBlock(x, y, z); + if (block == .air) continue; + + const def = block_registry.getBlockDefinition(block); + const r_u4 = def.light_emission[0]; + const g_u4 = def.light_emission[1]; + const b_u4 = def.light_emission[2]; + if (r_u4 == 0 and g_u4 == 0 and b_u4 == 0) continue; + + const world_x = chunk_min_x + @as(f32, @floatFromInt(x)) + 0.5; + const world_y = @as(f32, @floatFromInt(y)) + 0.5; + const world_z = chunk_min_z + @as(f32, @floatFromInt(z)) + 0.5; + if (world_x < min_x or world_x >= max_x or world_y < min_y or world_y >= max_y or world_z < min_z or world_z >= max_z) { + continue; + } + + const emission_r = @as(f32, @floatFromInt(r_u4)) / 15.0; + const emission_g = @as(f32, @floatFromInt(g_u4)) / 15.0; + const emission_b = @as(f32, @floatFromInt(b_u4)) / 15.0; + const max_emission = @max(emission_r, @max(emission_g, emission_b)); + const radius_cells = @max(1.0, max_emission * 6.0); + + out[emitted_lights] = .{ + .pos_radius = .{ world_x, world_y, world_z, radius_cells }, + .color = .{ emission_r, emission_g, emission_b, 1.0 }, + }; + + emitted_lights += 1; + if (emitted_lights >= out.len) return emitted_lights; + } + } + } + } + + return emitted_lights; + } + + fn dispatchCompute(self: *LPVSystem, light_count: usize) !void { + const cmd = self.vk_ctx.frames.command_buffers[self.vk_ctx.frames.current_frame]; + if (cmd == null) return; + + const tex_a = self.vk_ctx.resources.textures.get(self.grid_texture_a) orelse return; + const tex_b = self.vk_ctx.resources.textures.get(self.grid_texture_b) orelse return; + + try self.transitionImage(cmd, tex_a.image.?, self.image_layout_a, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_ACCESS_SHADER_READ_BIT, c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT); + self.image_layout_a = c.VK_IMAGE_LAYOUT_GENERAL; + try self.transitionImage(cmd, tex_b.image.?, self.image_layout_b, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_ACCESS_SHADER_READ_BIT, c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT); + self.image_layout_b = c.VK_IMAGE_LAYOUT_GENERAL; + + const groups = divCeil(self.grid_size, 4); + + const inject_push = InjectPush{ + .grid_origin = .{ self.origin.x, self.origin.y, self.origin.z, self.cell_size }, + .grid_params = .{ @floatFromInt(self.grid_size), 0.0, 0.0, 0.0 }, + .light_count = @intCast(light_count), + ._pad0 = .{ 0, 0, 0 }, + }; + + c.vkCmdBindPipeline(cmd, c.VK_PIPELINE_BIND_POINT_COMPUTE, self.inject_pipeline); + c.vkCmdBindDescriptorSets(cmd, c.VK_PIPELINE_BIND_POINT_COMPUTE, self.inject_pipeline_layout, 0, 1, &self.inject_descriptor_set, 0, null); + c.vkCmdPushConstants(cmd, self.inject_pipeline_layout, c.VK_SHADER_STAGE_COMPUTE_BIT, 0, @sizeOf(InjectPush), &inject_push); + c.vkCmdDispatch(cmd, groups, groups, groups); + + var mem_barrier = std.mem.zeroes(c.VkMemoryBarrier); + mem_barrier.sType = c.VK_STRUCTURE_TYPE_MEMORY_BARRIER; + mem_barrier.srcAccessMask = c.VK_ACCESS_SHADER_WRITE_BIT; + mem_barrier.dstAccessMask = c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT; + c.vkCmdPipelineBarrier(cmd, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 1, &mem_barrier, 0, null, 0, null); + + c.vkCmdBindPipeline(cmd, c.VK_PIPELINE_BIND_POINT_COMPUTE, self.propagate_pipeline); + const prop_push = PropagatePush{ + .grid_size = self.grid_size, + ._pad0 = .{ 0, 0, 0 }, + .propagation_factor = 0.14, + ._pad1 = .{ 0, 0, 0 }, + }; + + var use_ab = true; + var i: u32 = 0; + while (i < self.propagation_iterations) : (i += 1) { + const descriptor_set = if (use_ab) self.propagate_ab_descriptor_set else self.propagate_ba_descriptor_set; + c.vkCmdBindDescriptorSets(cmd, c.VK_PIPELINE_BIND_POINT_COMPUTE, self.propagate_pipeline_layout, 0, 1, &descriptor_set, 0, null); + c.vkCmdPushConstants(cmd, self.propagate_pipeline_layout, c.VK_SHADER_STAGE_COMPUTE_BIT, 0, @sizeOf(PropagatePush), &prop_push); + c.vkCmdDispatch(cmd, groups, groups, groups); + + c.vkCmdPipelineBarrier(cmd, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 1, &mem_barrier, 0, null, 0, null); + use_ab = !use_ab; + } + + const final_is_a = (self.propagation_iterations % 2) == 0; + const final_tex = if (final_is_a) tex_a else tex_b; + const final_image = final_tex.image.?; + + try self.transitionImage(cmd, final_image, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, c.VK_ACCESS_SHADER_WRITE_BIT, c.VK_ACCESS_SHADER_READ_BIT); + + if (final_is_a) { + self.image_layout_a = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + self.active_grid_texture = self.grid_texture_a; + } else { + self.image_layout_b = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + self.active_grid_texture = self.grid_texture_b; + } + } + + fn transitionImage( + self: *LPVSystem, + cmd: c.VkCommandBuffer, + image: c.VkImage, + old_layout: c.VkImageLayout, + new_layout: c.VkImageLayout, + src_stage: c.VkPipelineStageFlags, + dst_stage: c.VkPipelineStageFlags, + src_access: c.VkAccessFlags, + dst_access: c.VkAccessFlags, + ) !void { + _ = self; + if (old_layout == new_layout) return; + var barrier = std.mem.zeroes(c.VkImageMemoryBarrier); + barrier.sType = c.VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = old_layout; + barrier.newLayout = new_layout; + barrier.srcQueueFamilyIndex = c.VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = c.VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; + barrier.subresourceRange.aspectMask = c.VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.srcAccessMask = src_access; + barrier.dstAccessMask = dst_access; + + c.vkCmdPipelineBarrier(cmd, src_stage, dst_stage, 0, 0, null, 0, null, 1, &barrier); + } + + fn createGridTextures(self: *LPVSystem) !void { + const empty = try self.allocator.alloc(f32, @as(usize, self.grid_size) * @as(usize, self.grid_size) * @as(usize, self.grid_size) * 4); + defer self.allocator.free(empty); + @memset(empty, 0.0); + const bytes = std.mem.sliceAsBytes(empty); + + self.grid_texture_a = try self.rhi.createTexture( + self.grid_size, + self.grid_size * self.grid_size, + .rgba32f, + .{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + .generate_mipmaps = false, + .is_render_target = false, + }, + bytes, + ); + + self.grid_texture_b = try self.rhi.createTexture( + self.grid_size, + self.grid_size * self.grid_size, + .rgba32f, + .{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + .generate_mipmaps = false, + .is_render_target = false, + }, + bytes, + ); + + const debug_size = @as(usize, self.grid_size) * @as(usize, self.grid_size) * 4; + self.debug_overlay_pixels = try self.allocator.alloc(f32, debug_size); + @memset(self.debug_overlay_pixels, 0.0); + + self.debug_overlay_texture = try self.rhi.createTexture( + self.grid_size, + self.grid_size, + .rgba32f, + .{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + .generate_mipmaps = false, + .is_render_target = false, + }, + std.mem.sliceAsBytes(self.debug_overlay_pixels), + ); + + self.buildDebugOverlay(&.{}, 0); + try self.uploadDebugOverlay(); + + self.active_grid_texture = self.grid_texture_a; + self.image_layout_a = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + self.image_layout_b = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + } + + fn destroyGridTextures(self: *LPVSystem) void { + if (self.grid_texture_a != 0) { + self.rhi.destroyTexture(self.grid_texture_a); + self.grid_texture_a = 0; + } + if (self.grid_texture_b != 0) { + self.rhi.destroyTexture(self.grid_texture_b); + self.grid_texture_b = 0; + } + if (self.debug_overlay_texture != 0) { + self.rhi.destroyTexture(self.debug_overlay_texture); + self.debug_overlay_texture = 0; + } + if (self.debug_overlay_pixels.len > 0) { + self.allocator.free(self.debug_overlay_pixels); + self.debug_overlay_pixels = &.{}; + } + self.active_grid_texture = 0; + } + + fn buildDebugOverlay(self: *LPVSystem, lights: []const GpuLight, light_count: usize) void { + const gs = @as(usize, self.grid_size); + var y: usize = 0; + while (y < gs) : (y += 1) { + var x: usize = 0; + while (x < gs) : (x += 1) { + const idx = (y * gs + x) * 4; + const checker: f32 = if (((x / 4) + (y / 4)) % 2 == 0) @as(f32, 1.5) else @as(f32, 2.0); + self.debug_overlay_pixels[idx + 0] = checker; + self.debug_overlay_pixels[idx + 1] = checker; + self.debug_overlay_pixels[idx + 2] = checker; + self.debug_overlay_pixels[idx + 3] = 1.0; + + if (x == 0 or y == 0 or x + 1 == gs or y + 1 == gs) { + self.debug_overlay_pixels[idx + 0] = 4.0; + self.debug_overlay_pixels[idx + 1] = 4.0; + self.debug_overlay_pixels[idx + 2] = 4.0; + } + } + } + + for (lights[0..light_count]) |light| { + const cx: f32 = ((light.pos_radius[0] - self.origin.x) / self.cell_size); + const cz: f32 = ((light.pos_radius[2] - self.origin.z) / self.cell_size); + const radius = @max(light.pos_radius[3], 0.5); + + var ty: usize = 0; + while (ty < gs) : (ty += 1) { + var tx: usize = 0; + while (tx < gs) : (tx += 1) { + const dx = @as(f32, @floatFromInt(tx)) - cx; + const dz = @as(f32, @floatFromInt(ty)) - cz; + const dist = @sqrt(dx * dx + dz * dz); + if (dist > radius) continue; + + const falloff = std.math.pow(f32, 1.0 - (dist / radius), 2.0); + const idx = (ty * gs + tx) * 4; + self.debug_overlay_pixels[idx + 0] += light.color[0] * falloff * 6.0; + self.debug_overlay_pixels[idx + 1] += light.color[1] * falloff * 6.0; + self.debug_overlay_pixels[idx + 2] += light.color[2] * falloff * 6.0; + } + } + } + + for (0..gs * gs) |i| { + const idx = i * 4; + self.debug_overlay_pixels[idx + 0] = toneMap(self.debug_overlay_pixels[idx + 0]); + self.debug_overlay_pixels[idx + 1] = toneMap(self.debug_overlay_pixels[idx + 1]); + self.debug_overlay_pixels[idx + 2] = toneMap(self.debug_overlay_pixels[idx + 2]); + } + } + + fn uploadDebugOverlay(self: *LPVSystem) !void { + if (self.debug_overlay_texture == 0 or self.debug_overlay_pixels.len == 0) return; + try self.rhi.updateTexture(self.debug_overlay_texture, std.mem.sliceAsBytes(self.debug_overlay_pixels)); + } + + fn destroyLightBuffer(self: *LPVSystem) void { + if (self.light_buffer.buffer != null) { + if (self.light_buffer.mapped_ptr != null) { + c.vkUnmapMemory(self.vk_ctx.vulkan_device.vk_device, self.light_buffer.memory); + } + c.vkDestroyBuffer(self.vk_ctx.vulkan_device.vk_device, self.light_buffer.buffer, null); + c.vkFreeMemory(self.vk_ctx.vulkan_device.vk_device, self.light_buffer.memory, null); + self.light_buffer = .{}; + } + } + + fn initComputeResources(self: *LPVSystem) !void { + const vk = self.vk_ctx.vulkan_device.vk_device; + + var pool_sizes = [_]c.VkDescriptorPoolSize{ + .{ .type = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 8 }, + .{ .type = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 2 }, + }; + + var pool_info = std.mem.zeroes(c.VkDescriptorPoolCreateInfo); + pool_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + pool_info.maxSets = 4; + pool_info.poolSizeCount = pool_sizes.len; + pool_info.pPoolSizes = &pool_sizes; + try Utils.checkVk(c.vkCreateDescriptorPool(vk, &pool_info, null, &self.descriptor_pool)); + + const inject_bindings = [_]c.VkDescriptorSetLayoutBinding{ + .{ .binding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + }; + var inject_layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); + inject_layout_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + inject_layout_info.bindingCount = inject_bindings.len; + inject_layout_info.pBindings = &inject_bindings; + try Utils.checkVk(c.vkCreateDescriptorSetLayout(vk, &inject_layout_info, null, &self.inject_set_layout)); + + const prop_bindings = [_]c.VkDescriptorSetLayoutBinding{ + .{ .binding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + }; + var prop_layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); + prop_layout_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + prop_layout_info.bindingCount = prop_bindings.len; + prop_layout_info.pBindings = &prop_bindings; + try Utils.checkVk(c.vkCreateDescriptorSetLayout(vk, &prop_layout_info, null, &self.propagate_set_layout)); + + try self.allocateDescriptorSets(); + try self.updateDescriptorSets(); + + try self.createComputePipelines(); + } + + fn allocateDescriptorSets(self: *LPVSystem) !void { + const vk = self.vk_ctx.vulkan_device.vk_device; + + var inject_alloc = std.mem.zeroes(c.VkDescriptorSetAllocateInfo); + inject_alloc.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + inject_alloc.descriptorPool = self.descriptor_pool; + inject_alloc.descriptorSetCount = 1; + inject_alloc.pSetLayouts = &self.inject_set_layout; + try Utils.checkVk(c.vkAllocateDescriptorSets(vk, &inject_alloc, &self.inject_descriptor_set)); + + const layouts = [_]c.VkDescriptorSetLayout{ self.propagate_set_layout, self.propagate_set_layout }; + var prop_alloc = std.mem.zeroes(c.VkDescriptorSetAllocateInfo); + prop_alloc.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + prop_alloc.descriptorPool = self.descriptor_pool; + prop_alloc.descriptorSetCount = 2; + prop_alloc.pSetLayouts = &layouts; + var prop_sets: [2]c.VkDescriptorSet = .{ null, null }; + try Utils.checkVk(c.vkAllocateDescriptorSets(vk, &prop_alloc, &prop_sets)); + self.propagate_ab_descriptor_set = prop_sets[0]; + self.propagate_ba_descriptor_set = prop_sets[1]; + } + + fn updateDescriptorSets(self: *LPVSystem) !void { + const vk = self.vk_ctx.vulkan_device.vk_device; + _ = vk; + + const tex_a = self.vk_ctx.resources.textures.get(self.grid_texture_a) orelse return error.ResourceNotFound; + const tex_b = self.vk_ctx.resources.textures.get(self.grid_texture_b) orelse return error.ResourceNotFound; + + var img_a = c.VkDescriptorImageInfo{ .sampler = null, .imageView = tex_a.view, .imageLayout = c.VK_IMAGE_LAYOUT_GENERAL }; + var img_b = c.VkDescriptorImageInfo{ .sampler = null, .imageView = tex_b.view, .imageLayout = c.VK_IMAGE_LAYOUT_GENERAL }; + var light_info = c.VkDescriptorBufferInfo{ .buffer = self.light_buffer.buffer, .offset = 0, .range = @sizeOf(GpuLight) * MAX_LIGHTS_PER_UPDATE }; + + var writes: [6]c.VkWriteDescriptorSet = undefined; + var n: usize = 0; + + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.inject_descriptor_set; + writes[n].dstBinding = 0; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &img_a; + n += 1; + + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.inject_descriptor_set; + writes[n].dstBinding = 1; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + writes[n].descriptorCount = 1; + writes[n].pBufferInfo = &light_info; + n += 1; + + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ab_descriptor_set; + writes[n].dstBinding = 0; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &img_a; + n += 1; + + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ab_descriptor_set; + writes[n].dstBinding = 1; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &img_b; + n += 1; + + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ba_descriptor_set; + writes[n].dstBinding = 0; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &img_b; + n += 1; + + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ba_descriptor_set; + writes[n].dstBinding = 1; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &img_a; + n += 1; + + c.vkUpdateDescriptorSets(self.vk_ctx.vulkan_device.vk_device, @intCast(n), &writes[0], 0, null); + } + + fn createComputePipelines(self: *LPVSystem) !void { + const vk = self.vk_ctx.vulkan_device.vk_device; + + const inject_module = try createShaderModule(vk, "assets/shaders/vulkan/lpv_inject.comp.spv", self.allocator); + defer c.vkDestroyShaderModule(vk, inject_module, null); + const propagate_module = try createShaderModule(vk, "assets/shaders/vulkan/lpv_propagate.comp.spv", self.allocator); + defer c.vkDestroyShaderModule(vk, propagate_module, null); + + var inject_pc = std.mem.zeroes(c.VkPushConstantRange); + inject_pc.stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT; + inject_pc.offset = 0; + inject_pc.size = @sizeOf(InjectPush); + + var inject_layout_info = std.mem.zeroes(c.VkPipelineLayoutCreateInfo); + inject_layout_info.sType = c.VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + inject_layout_info.setLayoutCount = 1; + inject_layout_info.pSetLayouts = &self.inject_set_layout; + inject_layout_info.pushConstantRangeCount = 1; + inject_layout_info.pPushConstantRanges = &inject_pc; + try Utils.checkVk(c.vkCreatePipelineLayout(vk, &inject_layout_info, null, &self.inject_pipeline_layout)); + + var prop_pc = std.mem.zeroes(c.VkPushConstantRange); + prop_pc.stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT; + prop_pc.offset = 0; + prop_pc.size = @sizeOf(PropagatePush); + + var prop_layout_info = std.mem.zeroes(c.VkPipelineLayoutCreateInfo); + prop_layout_info.sType = c.VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + prop_layout_info.setLayoutCount = 1; + prop_layout_info.pSetLayouts = &self.propagate_set_layout; + prop_layout_info.pushConstantRangeCount = 1; + prop_layout_info.pPushConstantRanges = &prop_pc; + try Utils.checkVk(c.vkCreatePipelineLayout(vk, &prop_layout_info, null, &self.propagate_pipeline_layout)); + + var inject_stage = std.mem.zeroes(c.VkPipelineShaderStageCreateInfo); + inject_stage.sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + inject_stage.stage = c.VK_SHADER_STAGE_COMPUTE_BIT; + inject_stage.module = inject_module; + inject_stage.pName = "main"; + + var inject_info = std.mem.zeroes(c.VkComputePipelineCreateInfo); + inject_info.sType = c.VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO; + inject_info.stage = inject_stage; + inject_info.layout = self.inject_pipeline_layout; + try Utils.checkVk(c.vkCreateComputePipelines(vk, null, 1, &inject_info, null, &self.inject_pipeline)); + + var prop_stage = std.mem.zeroes(c.VkPipelineShaderStageCreateInfo); + prop_stage.sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + prop_stage.stage = c.VK_SHADER_STAGE_COMPUTE_BIT; + prop_stage.module = propagate_module; + prop_stage.pName = "main"; + + var prop_info = std.mem.zeroes(c.VkComputePipelineCreateInfo); + prop_info.sType = c.VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO; + prop_info.stage = prop_stage; + prop_info.layout = self.propagate_pipeline_layout; + try Utils.checkVk(c.vkCreateComputePipelines(vk, null, 1, &prop_info, null, &self.propagate_pipeline)); + } + + fn deinitComputeResources(self: *LPVSystem) void { + const vk = self.vk_ctx.vulkan_device.vk_device; + if (self.inject_pipeline != null) c.vkDestroyPipeline(vk, self.inject_pipeline, null); + if (self.propagate_pipeline != null) c.vkDestroyPipeline(vk, self.propagate_pipeline, null); + if (self.inject_pipeline_layout != null) c.vkDestroyPipelineLayout(vk, self.inject_pipeline_layout, null); + if (self.propagate_pipeline_layout != null) c.vkDestroyPipelineLayout(vk, self.propagate_pipeline_layout, null); + if (self.inject_set_layout != null) c.vkDestroyDescriptorSetLayout(vk, self.inject_set_layout, null); + if (self.propagate_set_layout != null) c.vkDestroyDescriptorSetLayout(vk, self.propagate_set_layout, null); + if (self.descriptor_pool != null) c.vkDestroyDescriptorPool(vk, self.descriptor_pool, null); + + self.inject_pipeline = null; + self.propagate_pipeline = null; + self.inject_pipeline_layout = null; + self.propagate_pipeline_layout = null; + self.inject_set_layout = null; + self.propagate_set_layout = null; + self.descriptor_pool = null; + self.inject_descriptor_set = null; + self.propagate_ab_descriptor_set = null; + self.propagate_ba_descriptor_set = null; + } +}; + +fn quantizeToCell(value: f32, cell_size: f32) f32 { + return @floor(value / cell_size) * cell_size; +} + +fn divCeil(v: u32, d: u32) u32 { + return @divFloor(v + d - 1, d); +} + +fn toneMap(v: f32) f32 { + const x = @max(v, 0.0); + return x / (1.0 + x); +} + +fn createShaderModule(vk: c.VkDevice, path: []const u8, allocator: std.mem.Allocator) !c.VkShaderModule { + const bytes = try std.fs.cwd().readFileAlloc(path, allocator, @enumFromInt(16 * 1024 * 1024)); + defer allocator.free(bytes); + if (bytes.len % 4 != 0) return error.InvalidState; + + var info = std.mem.zeroes(c.VkShaderModuleCreateInfo); + info.sType = c.VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + info.codeSize = bytes.len; + info.pCode = @ptrCast(@alignCast(bytes.ptr)); + + var module: c.VkShaderModule = null; + try Utils.checkVk(c.vkCreateShaderModule(vk, &info, null, &module)); + return module; +} diff --git a/src/engine/graphics/render_graph.zig b/src/engine/graphics/render_graph.zig index 42a3c66..9b0cfd5 100644 --- a/src/engine/graphics/render_graph.zig +++ b/src/engine/graphics/render_graph.zig @@ -35,6 +35,7 @@ pub const SceneContext = struct { bloom_enabled: bool = true, overlay_renderer: ?*const fn (ctx: SceneContext) void = null, overlay_ctx: ?*anyopaque = null, + lpv_texture_handle: rhi_pkg.TextureHandle = 0, // Pointer to frame-local cascade storage, computed once per frame by the first // ShadowPass and reused by subsequent cascade passes to guarantee consistency. cached_cascades: *?CSM.ShadowCascades, @@ -282,6 +283,7 @@ pub const OpaquePass = struct { const rhi = ctx.rhi; rhi.bindShader(ctx.main_shader); ctx.material_system.bindTerrainMaterial(ctx.env_map_handle); + rhi.bindTexture(ctx.lpv_texture_handle, 11); const view_proj = Mat4.perspectiveReverseZ(ctx.camera.fov, ctx.aspect, ctx.camera.near, ctx.camera.far).multiply(ctx.camera.getViewMatrixOriginCentered()); ctx.world.render(view_proj, ctx.camera.position, true); } diff --git a/src/engine/graphics/rhi.zig b/src/engine/graphics/rhi.zig index c898545..6643639 100644 --- a/src/engine/graphics/rhi.zig +++ b/src/engine/graphics/rhi.zig @@ -52,6 +52,7 @@ pub const IResourceFactory = struct { updateBuffer: *const fn (ptr: *anyopaque, handle: BufferHandle, offset: usize, data: []const u8) RhiError!void, destroyBuffer: *const fn (ptr: *anyopaque, handle: BufferHandle) void, createTexture: *const fn (ptr: *anyopaque, width: u32, height: u32, format: TextureFormat, config: TextureConfig, data: ?[]const u8) RhiError!TextureHandle, + createTexture3D: *const fn (ptr: *anyopaque, width: u32, height: u32, depth: u32, format: TextureFormat, config: TextureConfig, data: ?[]const u8) RhiError!TextureHandle, destroyTexture: *const fn (ptr: *anyopaque, handle: TextureHandle) void, updateTexture: *const fn (ptr: *anyopaque, handle: TextureHandle, data: []const u8) RhiError!void, createShader: *const fn (ptr: *anyopaque, vertex_src: [*c]const u8, fragment_src: [*c]const u8) RhiError!ShaderHandle, @@ -75,6 +76,9 @@ pub const IResourceFactory = struct { pub fn createTexture(self: IResourceFactory, width: u32, height: u32, format: TextureFormat, config: TextureConfig, data: ?[]const u8) RhiError!TextureHandle { return self.vtable.createTexture(self.ptr, width, height, format, config, data); } + pub fn createTexture3D(self: IResourceFactory, width: u32, height: u32, depth: u32, format: TextureFormat, config: TextureConfig, data: ?[]const u8) RhiError!TextureHandle { + return self.vtable.createTexture3D(self.ptr, width, height, depth, format, config, data); + } pub fn destroyTexture(self: IResourceFactory, handle: TextureHandle) void { self.vtable.destroyTexture(self.ptr, handle); } diff --git a/src/engine/graphics/rhi_tests.zig b/src/engine/graphics/rhi_tests.zig index 8445317..961250e 100644 --- a/src/engine/graphics/rhi_tests.zig +++ b/src/engine/graphics/rhi_tests.zig @@ -203,6 +203,7 @@ const MockContext = struct { .updateBuffer = updateBuffer, .destroyBuffer = destroyBuffer, .createTexture = createTexture, + .createTexture3D = createTexture3D, .destroyTexture = destroyTexture, .updateTexture = updateTexture, .createShader = createShader, @@ -241,6 +242,16 @@ const MockContext = struct { _ = data; return 1; } + fn createTexture3D(ptr: *anyopaque, width: u32, height: u32, depth: u32, format: rhi.TextureFormat, config: rhi.TextureConfig, data: ?[]const u8) rhi.RhiError!rhi.TextureHandle { + _ = ptr; + _ = width; + _ = height; + _ = depth; + _ = format; + _ = config; + _ = data; + return 1; + } fn destroyTexture(ptr: *anyopaque, handle: rhi.TextureHandle) void { _ = ptr; _ = handle; diff --git a/src/engine/graphics/rhi_types.zig b/src/engine/graphics/rhi_types.zig index 6d22e85..3ac517c 100644 --- a/src/engine/graphics/rhi_types.zig +++ b/src/engine/graphics/rhi_types.zig @@ -199,6 +199,11 @@ pub const CloudParams = struct { exposure: f32 = 0.9, saturation: f32 = 1.3, ssao_enabled: bool = true, + lpv_enabled: bool = true, + lpv_intensity: f32 = 0.5, + lpv_cell_size: f32 = 2.0, + lpv_grid_size: u32 = 32, + lpv_origin: Vec3 = Vec3.init(0.0, 0.0, 0.0), }; pub const Color = struct { @@ -233,6 +238,7 @@ pub const GpuTimingResults = struct { shadow_pass_ms: [SHADOW_CASCADE_COUNT]f32, g_pass_ms: f32, ssao_pass_ms: f32, + lpv_pass_ms: f32, sky_pass_ms: f32, opaque_pass_ms: f32, cloud_pass_ms: f32, diff --git a/src/engine/graphics/rhi_vulkan.zig b/src/engine/graphics/rhi_vulkan.zig index 8a9bba7..8c4503a 100644 --- a/src/engine/graphics/rhi_vulkan.zig +++ b/src/engine/graphics/rhi_vulkan.zig @@ -15,8 +15,9 @@ const shadow_bridge = @import("vulkan/rhi_shadow_bridge.zig"); const native_access = @import("vulkan/rhi_native_access.zig"); const render_state = @import("vulkan/rhi_render_state.zig"); const init_deinit = @import("vulkan/rhi_init_deinit.zig"); +const rhi_timing = @import("vulkan/rhi_timing.zig"); -const QUERY_COUNT_PER_FRAME = 22; +const QUERY_COUNT_PER_FRAME = rhi_timing.QUERY_COUNT_PER_FRAME; const VulkanContext = @import("vulkan/rhi_context_types.zig").VulkanContext; @@ -315,6 +316,13 @@ fn createTexture(ctx_ptr: *anyopaque, width: u32, height: u32, format: rhi.Textu return ctx.resources.createTexture(width, height, format, config, data_opt); } +fn createTexture3D(ctx_ptr: *anyopaque, width: u32, height: u32, depth: u32, format: rhi.TextureFormat, config: rhi.TextureConfig, data_opt: ?[]const u8) rhi.RhiError!rhi.TextureHandle { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + ctx.mutex.lock(); + defer ctx.mutex.unlock(); + return ctx.resources.createTexture3D(width, height, depth, format, config, data_opt); +} + fn destroyTexture(ctx_ptr: *anyopaque, handle: rhi.TextureHandle) void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); ctx.resources.destroyTexture(handle); @@ -338,6 +346,7 @@ fn bindTexture(ctx_ptr: *anyopaque, handle: rhi.TextureHandle, slot: u32) void { 7 => ctx.draw.current_roughness_texture = resolved, 8 => ctx.draw.current_displacement_texture = resolved, 9 => ctx.draw.current_env_texture = resolved, + 11 => ctx.draw.current_lpv_texture = resolved, else => ctx.draw.current_texture = resolved, } } @@ -661,6 +670,7 @@ const VULKAN_RHI_VTABLE = rhi.RHI.VTable{ .updateBuffer = updateBuffer, .destroyBuffer = destroyBuffer, .createTexture = createTexture, + .createTexture3D = createTexture3D, .destroyTexture = destroyTexture, .updateTexture = updateTexture, .createShader = createShader, diff --git a/src/engine/graphics/vulkan/descriptor_bindings.zig b/src/engine/graphics/vulkan/descriptor_bindings.zig index 1b8dc23..33e743a 100644 --- a/src/engine/graphics/vulkan/descriptor_bindings.zig +++ b/src/engine/graphics/vulkan/descriptor_bindings.zig @@ -9,3 +9,4 @@ pub const ROUGHNESS_TEXTURE = 7; pub const DISPLACEMENT_TEXTURE = 8; pub const ENV_TEXTURE = 9; pub const SSAO_TEXTURE = 10; +pub const LPV_TEXTURE = 11; diff --git a/src/engine/graphics/vulkan/descriptor_manager.zig b/src/engine/graphics/vulkan/descriptor_manager.zig index e3b5c64..3ffc170 100644 --- a/src/engine/graphics/vulkan/descriptor_manager.zig +++ b/src/engine/graphics/vulkan/descriptor_manager.zig @@ -22,6 +22,8 @@ const GlobalUniforms = extern struct { pbr_params: [4]f32, volumetric_params: [4]f32, viewport_size: [4]f32, + lpv_params: [4]f32, + lpv_origin: [4]f32, }; const ShadowUniforms = extern struct { @@ -154,6 +156,8 @@ pub const DescriptorManager = struct { .{ .binding = 9, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, // 10: SSAO Map .{ .binding = 10, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, + // 11: LPV Grid Atlas + .{ .binding = 11, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, }; var layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); diff --git a/src/engine/graphics/vulkan/resource_manager.zig b/src/engine/graphics/vulkan/resource_manager.zig index 2b70063..242ba8d 100644 --- a/src/engine/graphics/vulkan/resource_manager.zig +++ b/src/engine/graphics/vulkan/resource_manager.zig @@ -16,8 +16,10 @@ pub const TextureResource = struct { sampler: c.VkSampler, width: u32, height: u32, + depth: u32, format: rhi.TextureFormat, config: rhi.TextureConfig, + is_3d: bool = false, is_owned: bool = true, }; @@ -373,6 +375,10 @@ pub const ResourceManager = struct { return resource_texture_ops.createTexture(self, width, height, format, config, data_opt); } + pub fn createTexture3D(self: *ResourceManager, width: u32, height: u32, depth: u32, format: rhi.TextureFormat, config: rhi.TextureConfig, data_opt: ?[]const u8) rhi.RhiError!rhi.TextureHandle { + return resource_texture_ops.createTexture3D(self, width, height, depth, format, config, data_opt); + } + pub fn destroyTexture(self: *ResourceManager, handle: rhi.TextureHandle) void { const tex = self.textures.get(handle) orelse return; _ = self.textures.remove(handle); @@ -398,8 +404,10 @@ pub const ResourceManager = struct { .sampler = sampler, .width = width, .height = height, + .depth = 1, .format = format, .config = .{}, // Default config + .is_3d = false, .is_owned = false, }); @@ -419,8 +427,10 @@ pub const ResourceManager = struct { .sampler = sampler, .width = width, .height = height, + .depth = 1, .format = format, .config = .{}, + .is_3d = false, .is_owned = false, }); return handle; @@ -459,7 +469,7 @@ pub const ResourceManager = struct { region.bufferOffset = offset; region.imageSubresource.aspectMask = c.VK_IMAGE_ASPECT_COLOR_BIT; region.imageSubresource.layerCount = 1; - region.imageExtent = .{ .width = tex.width, .height = tex.height, .depth = 1 }; + region.imageExtent = .{ .width = tex.width, .height = tex.height, .depth = tex.depth }; c.vkCmdCopyBufferToImage(transfer_cb, staging.buffer, tex.image.?, c.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); diff --git a/src/engine/graphics/vulkan/resource_texture_ops.zig b/src/engine/graphics/vulkan/resource_texture_ops.zig index 1c42fa9..ca49ad2 100644 --- a/src/engine/graphics/vulkan/resource_texture_ops.zig +++ b/src/engine/graphics/vulkan/resource_texture_ops.zig @@ -30,6 +30,7 @@ pub fn createTexture(self: anytype, width: u32, height: u32, format: rhi.Texture if (mip_levels > 1) usage_flags |= c.VK_IMAGE_USAGE_TRANSFER_SRC_BIT; if (config.is_render_target) usage_flags |= c.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; + if (format == .rgba32f) usage_flags |= c.VK_IMAGE_USAGE_STORAGE_BIT; var staging_offset: u64 = 0; if (data_opt) |data| { @@ -212,8 +213,168 @@ pub fn createTexture(self: anytype, width: u32, height: u32, format: rhi.Texture .sampler = sampler, .width = width, .height = height, + .depth = 1, .format = format, .config = config, + .is_3d = false, + .is_owned = true, + }); + + return handle; +} + +pub fn createTexture3D(self: anytype, width: u32, height: u32, depth: u32, format: rhi.TextureFormat, config: rhi.TextureConfig, data_opt: ?[]const u8) rhi.RhiError!rhi.TextureHandle { + _ = config; + const vk_format: c.VkFormat = switch (format) { + .rgba => c.VK_FORMAT_R8G8B8A8_UNORM, + .rgba_srgb => c.VK_FORMAT_R8G8B8A8_SRGB, + .rgb => c.VK_FORMAT_R8G8B8_UNORM, + .red => c.VK_FORMAT_R8_UNORM, + .depth => c.VK_FORMAT_D32_SFLOAT, + .rgba32f => c.VK_FORMAT_R32G32B32A32_SFLOAT, + }; + + if (format == .depth) return error.FormatNotSupported; + if (depth == 0) return error.InvalidState; + + var usage_flags: c.VkImageUsageFlags = c.VK_IMAGE_USAGE_TRANSFER_DST_BIT | c.VK_IMAGE_USAGE_SAMPLED_BIT; + if (format == .rgba32f) usage_flags |= c.VK_IMAGE_USAGE_STORAGE_BIT; + + var staging_offset: u64 = 0; + if (data_opt) |data| { + const staging = &self.staging_buffers[self.current_frame_index]; + const offset = staging.allocate(data.len) orelse return error.OutOfMemory; + if (staging.mapped_ptr == null) return error.OutOfMemory; + staging_offset = offset; + } + + const device = self.vulkan_device.vk_device; + + var image: c.VkImage = null; + var image_info = std.mem.zeroes(c.VkImageCreateInfo); + image_info.sType = c.VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + image_info.imageType = c.VK_IMAGE_TYPE_3D; + image_info.extent.width = width; + image_info.extent.height = height; + image_info.extent.depth = depth; + image_info.mipLevels = 1; + image_info.arrayLayers = 1; + image_info.format = vk_format; + image_info.tiling = c.VK_IMAGE_TILING_OPTIMAL; + image_info.initialLayout = c.VK_IMAGE_LAYOUT_UNDEFINED; + image_info.usage = usage_flags; + image_info.samples = c.VK_SAMPLE_COUNT_1_BIT; + image_info.sharingMode = c.VK_SHARING_MODE_EXCLUSIVE; + + try Utils.checkVk(c.vkCreateImage(device, &image_info, null, &image)); + errdefer c.vkDestroyImage(device, image, null); + + var mem_reqs: c.VkMemoryRequirements = undefined; + c.vkGetImageMemoryRequirements(device, image, &mem_reqs); + + var memory: c.VkDeviceMemory = null; + var alloc_info = std.mem.zeroes(c.VkMemoryAllocateInfo); + alloc_info.sType = c.VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + alloc_info.allocationSize = mem_reqs.size; + alloc_info.memoryTypeIndex = try Utils.findMemoryType(self.vulkan_device.physical_device, mem_reqs.memoryTypeBits, c.VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + try Utils.checkVk(c.vkAllocateMemory(device, &alloc_info, null, &memory)); + errdefer c.vkFreeMemory(device, memory, null); + try Utils.checkVk(c.vkBindImageMemory(device, image, memory, 0)); + + const transfer_cb = try self.prepareTransfer(); + var barrier = std.mem.zeroes(c.VkImageMemoryBarrier); + barrier.sType = c.VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = c.VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = if (data_opt != null) c.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL else c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcQueueFamilyIndex = c.VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = c.VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; + barrier.subresourceRange.aspectMask = c.VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = if (data_opt != null) c.VK_ACCESS_TRANSFER_WRITE_BIT else c.VK_ACCESS_SHADER_READ_BIT; + + c.vkCmdPipelineBarrier( + transfer_cb, + c.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + if (data_opt != null) c.VK_PIPELINE_STAGE_TRANSFER_BIT else c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, + 0, + null, + 0, + null, + 1, + &barrier, + ); + + if (data_opt) |data| { + const staging = &self.staging_buffers[self.current_frame_index]; + if (staging.mapped_ptr == null) return error.OutOfMemory; + const dest = @as([*]u8, @ptrCast(staging.mapped_ptr.?)) + staging_offset; + @memcpy(dest[0..data.len], data); + + var region = std.mem.zeroes(c.VkBufferImageCopy); + region.bufferOffset = staging_offset; + region.imageSubresource.aspectMask = c.VK_IMAGE_ASPECT_COLOR_BIT; + region.imageSubresource.layerCount = 1; + region.imageExtent = .{ .width = width, .height = height, .depth = depth }; + c.vkCmdCopyBufferToImage(transfer_cb, staging.buffer, image, c.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); + + barrier.oldLayout = c.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcAccessMask = c.VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = c.VK_ACCESS_SHADER_READ_BIT; + c.vkCmdPipelineBarrier(transfer_cb, c.VK_PIPELINE_STAGE_TRANSFER_BIT, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, null, 0, null, 1, &barrier); + } + + var view: c.VkImageView = null; + var view_info = std.mem.zeroes(c.VkImageViewCreateInfo); + view_info.sType = c.VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + view_info.image = image; + view_info.viewType = c.VK_IMAGE_VIEW_TYPE_3D; + view_info.format = vk_format; + view_info.subresourceRange.aspectMask = c.VK_IMAGE_ASPECT_COLOR_BIT; + view_info.subresourceRange.baseMipLevel = 0; + view_info.subresourceRange.levelCount = 1; + view_info.subresourceRange.baseArrayLayer = 0; + view_info.subresourceRange.layerCount = 1; + + const sampler = try Utils.createSampler(self.vulkan_device, .{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + .generate_mipmaps = false, + .is_render_target = false, + }, 1, self.vulkan_device.max_anisotropy); + errdefer c.vkDestroySampler(device, sampler, null); + + try Utils.checkVk(c.vkCreateImageView(device, &view_info, null, &view)); + errdefer c.vkDestroyImageView(device, view, null); + + const handle = self.next_texture_handle; + self.next_texture_handle += 1; + try self.textures.put(handle, .{ + .image = image, + .memory = memory, + .view = view, + .sampler = sampler, + .width = width, + .height = height, + .depth = depth, + .format = format, + .config = .{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + .generate_mipmaps = false, + .is_render_target = false, + }, + .is_3d = true, .is_owned = true, }); diff --git a/src/engine/graphics/vulkan/rhi_context_factory.zig b/src/engine/graphics/vulkan/rhi_context_factory.zig index 4493808..00b368e 100644 --- a/src/engine/graphics/vulkan/rhi_context_factory.zig +++ b/src/engine/graphics/vulkan/rhi_context_factory.zig @@ -50,6 +50,7 @@ pub fn createRHI( ctx.draw.current_roughness_texture = 0; ctx.draw.current_displacement_texture = 0; ctx.draw.current_env_texture = 0; + ctx.draw.current_lpv_texture = 0; ctx.draw.dummy_texture = 0; ctx.draw.dummy_normal_texture = 0; ctx.draw.dummy_roughness_texture = 0; @@ -79,6 +80,7 @@ pub fn createRHI( ctx.draw.bound_roughness_texture = 0; ctx.draw.bound_displacement_texture = 0; ctx.draw.bound_env_texture = 0; + ctx.draw.bound_lpv_texture = 0; ctx.draw.current_mask_radius = 0; ctx.draw.lod_mode = false; ctx.draw.pending_instance_buffer = 0; diff --git a/src/engine/graphics/vulkan/rhi_context_types.zig b/src/engine/graphics/vulkan/rhi_context_types.zig index 4a9840f..5aaee46 100644 --- a/src/engine/graphics/vulkan/rhi_context_types.zig +++ b/src/engine/graphics/vulkan/rhi_context_types.zig @@ -122,6 +122,7 @@ const DrawState = struct { current_roughness_texture: rhi.TextureHandle, current_displacement_texture: rhi.TextureHandle, current_env_texture: rhi.TextureHandle, + current_lpv_texture: rhi.TextureHandle, dummy_texture: rhi.TextureHandle, dummy_normal_texture: rhi.TextureHandle, dummy_roughness_texture: rhi.TextureHandle, @@ -130,6 +131,7 @@ const DrawState = struct { bound_roughness_texture: rhi.TextureHandle, bound_displacement_texture: rhi.TextureHandle, bound_env_texture: rhi.TextureHandle, + bound_lpv_texture: rhi.TextureHandle, bound_ssao_handle: rhi.TextureHandle = 0, bound_shadow_views: [rhi.SHADOW_CASCADE_COUNT]c.VkImageView, descriptors_dirty: [MAX_FRAMES_IN_FLIGHT]bool, diff --git a/src/engine/graphics/vulkan/rhi_frame_orchestration.zig b/src/engine/graphics/vulkan/rhi_frame_orchestration.zig index ad61513..59458cf 100644 --- a/src/engine/graphics/vulkan/rhi_frame_orchestration.zig +++ b/src/engine/graphics/vulkan/rhi_frame_orchestration.zig @@ -153,6 +153,7 @@ pub fn prepareFrameState(ctx: anytype) void { const cur_rou = ctx.draw.current_roughness_texture; const cur_dis = ctx.draw.current_displacement_texture; const cur_env = ctx.draw.current_env_texture; + const cur_lpv = ctx.draw.current_lpv_texture; var needs_update = false; if (ctx.draw.bound_texture != cur_tex) needs_update = true; @@ -160,6 +161,7 @@ pub fn prepareFrameState(ctx: anytype) void { if (ctx.draw.bound_roughness_texture != cur_rou) needs_update = true; if (ctx.draw.bound_displacement_texture != cur_dis) needs_update = true; if (ctx.draw.bound_env_texture != cur_env) needs_update = true; + if (ctx.draw.bound_lpv_texture != cur_lpv) needs_update = true; for (0..rhi.SHADOW_CASCADE_COUNT) |si| { if (ctx.draw.bound_shadow_views[si] != ctx.shadow_system.shadow_image_views[si]) needs_update = true; @@ -172,6 +174,7 @@ pub fn prepareFrameState(ctx: anytype) void { ctx.draw.bound_roughness_texture = cur_rou; ctx.draw.bound_displacement_texture = cur_dis; ctx.draw.bound_env_texture = cur_env; + ctx.draw.bound_lpv_texture = cur_lpv; for (0..rhi.SHADOW_CASCADE_COUNT) |si| ctx.draw.bound_shadow_views[si] = ctx.shadow_system.shadow_image_views[si]; } @@ -180,9 +183,9 @@ pub fn prepareFrameState(ctx: anytype) void { std.log.err("CRITICAL: Descriptor set for frame {} is NULL!", .{ctx.frames.current_frame}); return; } - var writes: [10]c.VkWriteDescriptorSet = undefined; + var writes: [12]c.VkWriteDescriptorSet = undefined; var write_count: u32 = 0; - var image_infos: [10]c.VkDescriptorImageInfo = undefined; + var image_infos: [12]c.VkDescriptorImageInfo = undefined; var info_count: u32 = 0; const dummy_tex_entry = ctx.resources.textures.get(ctx.draw.dummy_texture); @@ -193,6 +196,7 @@ pub fn prepareFrameState(ctx: anytype) void { .{ .handle = cur_rou, .binding = bindings.ROUGHNESS_TEXTURE }, .{ .handle = cur_dis, .binding = bindings.DISPLACEMENT_TEXTURE }, .{ .handle = cur_env, .binding = bindings.ENV_TEXTURE }, + .{ .handle = cur_lpv, .binding = bindings.LPV_TEXTURE }, }; for (atlas_slots) |slot| { diff --git a/src/engine/graphics/vulkan/rhi_init_deinit.zig b/src/engine/graphics/vulkan/rhi_init_deinit.zig index 5a28e7f..7e6e151 100644 --- a/src/engine/graphics/vulkan/rhi_init_deinit.zig +++ b/src/engine/graphics/vulkan/rhi_init_deinit.zig @@ -13,9 +13,10 @@ const ShadowSystem = @import("shadow_system.zig").ShadowSystem; const Utils = @import("utils.zig"); const lifecycle = @import("rhi_resource_lifecycle.zig"); const setup = @import("rhi_resource_setup.zig"); +const rhi_timing = @import("rhi_timing.zig"); const MAX_FRAMES_IN_FLIGHT = rhi.MAX_FRAMES_IN_FLIGHT; -const TOTAL_QUERY_COUNT = 22 * MAX_FRAMES_IN_FLIGHT; +const TOTAL_QUERY_COUNT = rhi_timing.QUERY_COUNT_PER_FRAME * MAX_FRAMES_IN_FLIGHT; pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?*RenderDevice) !void { ctx.allocator = allocator; @@ -108,6 +109,7 @@ pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?* ctx.draw.current_roughness_texture = ctx.draw.dummy_roughness_texture; ctx.draw.current_displacement_texture = ctx.draw.dummy_roughness_texture; ctx.draw.current_env_texture = ctx.draw.dummy_texture; + ctx.draw.current_lpv_texture = ctx.draw.dummy_texture; const cloud_vbo_handle = try ctx.resources.createBuffer(8 * @sizeOf(f32), .vertex); std.log.info("Cloud VBO handle: {}, map count: {}", .{ cloud_vbo_handle, ctx.resources.buffers.count() }); diff --git a/src/engine/graphics/vulkan/rhi_render_state.zig b/src/engine/graphics/vulkan/rhi_render_state.zig index 7ee6b0b..d6db6b3 100644 --- a/src/engine/graphics/vulkan/rhi_render_state.zig +++ b/src/engine/graphics/vulkan/rhi_render_state.zig @@ -20,6 +20,8 @@ const GlobalUniforms = extern struct { pbr_params: [4]f32, volumetric_params: [4]f32, viewport_size: [4]f32, + lpv_params: [4]f32, + lpv_origin: [4]f32, }; const CloudPushConstants = extern struct { @@ -45,6 +47,8 @@ pub fn updateGlobalUniforms(ctx: anytype, view_proj: Mat4, cam_pos: Vec3, sun_di .pbr_params = .{ @floatFromInt(cloud_params.pbr_quality), cloud_params.exposure, cloud_params.saturation, if (cloud_params.ssao_enabled) 1.0 else 0.0 }, .volumetric_params = .{ if (cloud_params.volumetric_enabled) 1.0 else 0.0, cloud_params.volumetric_density, @floatFromInt(cloud_params.volumetric_steps), cloud_params.volumetric_scattering }, .viewport_size = .{ @floatFromInt(ctx.swapchain.swapchain.extent.width), @floatFromInt(ctx.swapchain.swapchain.extent.height), if (ctx.options.debug_shadows_active) 1.0 else 0.0, 0.0 }, + .lpv_params = .{ if (cloud_params.lpv_enabled) 1.0 else 0.0, cloud_params.lpv_intensity, cloud_params.lpv_cell_size, @floatFromInt(cloud_params.lpv_grid_size) }, + .lpv_origin = .{ cloud_params.lpv_origin.x, cloud_params.lpv_origin.y, cloud_params.lpv_origin.z, 0.0 }, }; try ctx.descriptors.updateGlobalUniforms(ctx.frames.current_frame, &global_uniforms); diff --git a/src/engine/graphics/vulkan/rhi_timing.zig b/src/engine/graphics/vulkan/rhi_timing.zig index ad143b3..22d84d1 100644 --- a/src/engine/graphics/vulkan/rhi_timing.zig +++ b/src/engine/graphics/vulkan/rhi_timing.zig @@ -8,6 +8,7 @@ const GpuPass = enum { shadow_2, g_pass, ssao, + lpv_compute, sky, opaque_pass, cloud, @@ -15,10 +16,10 @@ const GpuPass = enum { fxaa, post_process, - pub const COUNT = 11; + pub const COUNT = 12; }; -const QUERY_COUNT_PER_FRAME = GpuPass.COUNT * 2; +pub const QUERY_COUNT_PER_FRAME = GpuPass.COUNT * 2; fn mapPassName(name: []const u8) ?GpuPass { if (std.mem.eql(u8, name, "ShadowPass0")) return .shadow_0; @@ -26,6 +27,7 @@ fn mapPassName(name: []const u8) ?GpuPass { if (std.mem.eql(u8, name, "ShadowPass2")) return .shadow_2; if (std.mem.eql(u8, name, "GPass")) return .g_pass; if (std.mem.eql(u8, name, "SSAOPass")) return .ssao; + if (std.mem.eql(u8, name, "LPVPass")) return .lpv_compute; if (std.mem.eql(u8, name, "SkyPass")) return .sky; if (std.mem.eql(u8, name, "OpaquePass")) return .opaque_pass; if (std.mem.eql(u8, name, "CloudPass")) return .cloud; @@ -84,12 +86,13 @@ pub fn processTimingResults(ctx: anytype) void { ctx.timing.timing_results.shadow_pass_ms[2] = @as(f32, @floatFromInt(results[5] -% results[4])) * period / 1e6; ctx.timing.timing_results.g_pass_ms = @as(f32, @floatFromInt(results[7] -% results[6])) * period / 1e6; ctx.timing.timing_results.ssao_pass_ms = @as(f32, @floatFromInt(results[9] -% results[8])) * period / 1e6; - ctx.timing.timing_results.sky_pass_ms = @as(f32, @floatFromInt(results[11] -% results[10])) * period / 1e6; - ctx.timing.timing_results.opaque_pass_ms = @as(f32, @floatFromInt(results[13] -% results[12])) * period / 1e6; - ctx.timing.timing_results.cloud_pass_ms = @as(f32, @floatFromInt(results[15] -% results[14])) * period / 1e6; - ctx.timing.timing_results.bloom_pass_ms = @as(f32, @floatFromInt(results[17] -% results[16])) * period / 1e6; - ctx.timing.timing_results.fxaa_pass_ms = @as(f32, @floatFromInt(results[19] -% results[18])) * period / 1e6; - ctx.timing.timing_results.post_process_pass_ms = @as(f32, @floatFromInt(results[21] -% results[20])) * period / 1e6; + ctx.timing.timing_results.lpv_pass_ms = @as(f32, @floatFromInt(results[11] -% results[10])) * period / 1e6; + ctx.timing.timing_results.sky_pass_ms = @as(f32, @floatFromInt(results[13] -% results[12])) * period / 1e6; + ctx.timing.timing_results.opaque_pass_ms = @as(f32, @floatFromInt(results[15] -% results[14])) * period / 1e6; + ctx.timing.timing_results.cloud_pass_ms = @as(f32, @floatFromInt(results[17] -% results[16])) * period / 1e6; + ctx.timing.timing_results.bloom_pass_ms = @as(f32, @floatFromInt(results[19] -% results[18])) * period / 1e6; + ctx.timing.timing_results.fxaa_pass_ms = @as(f32, @floatFromInt(results[21] -% results[20])) * period / 1e6; + ctx.timing.timing_results.post_process_pass_ms = @as(f32, @floatFromInt(results[23] -% results[22])) * period / 1e6; ctx.timing.timing_results.main_pass_ms = ctx.timing.timing_results.sky_pass_ms + ctx.timing.timing_results.opaque_pass_ms + ctx.timing.timing_results.cloud_pass_ms; ctx.timing.timing_results.validate(); @@ -100,17 +103,19 @@ pub fn processTimingResults(ctx: anytype) void { ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.shadow_pass_ms[2]; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.g_pass_ms; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.ssao_pass_ms; + ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.lpv_pass_ms; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.main_pass_ms; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.bloom_pass_ms; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.fxaa_pass_ms; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.post_process_pass_ms; if (ctx.timing.timing_enabled) { - std.debug.print("GPU Frame Time: {d:.2}ms (Shadow: {d:.2}, G-Pass: {d:.2}, SSAO: {d:.2}, Main: {d:.2}, Bloom: {d:.2}, FXAA: {d:.2}, Post: {d:.2})\n", .{ + std.debug.print("GPU Frame Time: {d:.2}ms (Shadow: {d:.2}, G-Pass: {d:.2}, SSAO: {d:.2}, LPV: {d:.2}, Main: {d:.2}, Bloom: {d:.2}, FXAA: {d:.2}, Post: {d:.2})\n", .{ ctx.timing.timing_results.total_gpu_ms, ctx.timing.timing_results.shadow_pass_ms[0] + ctx.timing.timing_results.shadow_pass_ms[1] + ctx.timing.timing_results.shadow_pass_ms[2], ctx.timing.timing_results.g_pass_ms, ctx.timing.timing_results.ssao_pass_ms, + ctx.timing.timing_results.lpv_pass_ms, ctx.timing.timing_results.main_pass_ms, ctx.timing.timing_results.bloom_pass_ms, ctx.timing.timing_results.fxaa_pass_ms, diff --git a/src/engine/ui/debug_lpv_overlay.zig b/src/engine/ui/debug_lpv_overlay.zig new file mode 100644 index 0000000..97da827 --- /dev/null +++ b/src/engine/ui/debug_lpv_overlay.zig @@ -0,0 +1,30 @@ +const rhi = @import("../graphics/rhi.zig"); +const IUIContext = rhi.IUIContext; + +pub const DebugLPVOverlay = struct { + pub const Config = struct { + width: f32 = 220.0, + height: f32 = 220.0, + spacing: f32 = 10.0, + }; + + pub fn rect(screen_height: f32, config: Config) rhi.Rect { + return .{ + .x = config.spacing, + .y = screen_height - config.height - config.spacing, + .width = config.width, + .height = config.height, + }; + } + + pub fn draw(ui: IUIContext, lpv_texture: rhi.TextureHandle, screen_width: f32, screen_height: f32, config: Config) void { + if (lpv_texture == 0) return; + + const r = rect(screen_height, config); + + ui.beginPass(screen_width, screen_height); + defer ui.endPass(); + + ui.drawTexture(lpv_texture, r); + } +}; diff --git a/src/engine/ui/timing_overlay.zig b/src/engine/ui/timing_overlay.zig index 140c26f..421d9d5 100644 --- a/src/engine/ui/timing_overlay.zig +++ b/src/engine/ui/timing_overlay.zig @@ -16,7 +16,7 @@ pub const TimingOverlay = struct { const width: f32 = 280; const line_height: f32 = 15; const scale: f32 = 1.0; - const num_lines = 13; // Title + 11 passes + Total + const num_lines = 14; // Title + 12 passes + Total const padding = 20; // Spacers and margins // Background @@ -31,6 +31,7 @@ pub const TimingOverlay = struct { drawTimingLine(ui, "SHADOW 2:", results.shadow_pass_ms[2], x + 10, &y, scale, Color.gray); drawTimingLine(ui, "G-PASS:", results.g_pass_ms, x + 10, &y, scale, Color.gray); drawTimingLine(ui, "SSAO:", results.ssao_pass_ms, x + 10, &y, scale, Color.gray); + drawTimingLine(ui, "LPV:", results.lpv_pass_ms, x + 10, &y, scale, Color.gray); drawTimingLine(ui, "SKY:", results.sky_pass_ms, x + 10, &y, scale, Color.gray); drawTimingLine(ui, "OPAQUE:", results.opaque_pass_ms, x + 10, &y, scale, Color.gray); drawTimingLine(ui, "CLOUDS:", results.cloud_pass_ms, x + 10, &y, scale, Color.gray); diff --git a/src/game/app.zig b/src/game/app.zig index 35764cd..0bcf857 100644 --- a/src/game/app.zig +++ b/src/game/app.zig @@ -20,6 +20,7 @@ const render_graph_pkg = @import("../engine/graphics/render_graph.zig"); const RenderGraph = render_graph_pkg.RenderGraph; const AtmosphereSystem = @import("../engine/graphics/atmosphere_system.zig").AtmosphereSystem; const MaterialSystem = @import("../engine/graphics/material_system.zig").MaterialSystem; +const LPVSystem = @import("../engine/graphics/lpv_system.zig").LPVSystem; const ResourcePackManager = @import("../engine/graphics/resource_pack.zig").ResourcePackManager; const AudioSystem = @import("../engine/audio/system.zig").AudioSystem; const TimingOverlay = @import("../engine/ui/timing_overlay.zig").TimingOverlay; @@ -46,6 +47,7 @@ pub const App = struct { render_graph: RenderGraph, atmosphere_system: *AtmosphereSystem, material_system: *MaterialSystem, + lpv_system: *LPVSystem, audio_system: *AudioSystem, shadow_passes: [4]render_graph_pkg.ShadowPass, g_pass: render_graph_pkg.GPass, @@ -233,6 +235,7 @@ pub const App = struct { .render_graph = RenderGraph.init(allocator), .atmosphere_system = atmosphere_system, .material_system = undefined, + .lpv_system = undefined, .audio_system = audio_system, .shadow_passes = .{ render_graph_pkg.ShadowPass.init(0), @@ -272,6 +275,16 @@ pub const App = struct { app.material_system = try MaterialSystem.init(allocator, rhi, &app.atlas); errdefer app.material_system.deinit(); + app.lpv_system = try LPVSystem.init( + allocator, + rhi, + settings.lpv_grid_size, + settings.lpv_cell_size, + settings.lpv_intensity, + settings.lpv_propagation_iterations, + settings.lpv_enabled, + ); + errdefer app.lpv_system.deinit(); // Sync FXAA and Bloom settings to RHI after initialization app.rhi.setFXAA(settings.fxaa_enabled); @@ -327,6 +340,7 @@ pub const App = struct { self.render_graph.deinit(); self.atmosphere_system.deinit(); self.material_system.deinit(); + self.lpv_system.deinit(); self.audio_system.deinit(); self.atlas.deinit(); if (self.env_map) |*t| t.deinit(); @@ -352,6 +366,7 @@ pub const App = struct { .render_graph = &self.render_graph, .atmosphere_system = self.atmosphere_system, .material_system = self.material_system, + .lpv_system = self.lpv_system, .audio_system = self.audio_system, .env_map_ptr = &self.env_map, .shader = self.shader, @@ -427,6 +442,11 @@ pub const App = struct { .volumetric_steps = 0, .volumetric_scattering = 0, .ssao_enabled = false, + .lpv_enabled = false, + .lpv_intensity = 0, + .lpv_cell_size = 2.0, + .lpv_grid_size = 32, + .lpv_origin = Vec3.zero, }); // Update current screen. Transitions happen here. diff --git a/src/game/input_mapper.zig b/src/game/input_mapper.zig index b3be8ed..49a7782 100644 --- a/src/game/input_mapper.zig +++ b/src/game/input_mapper.zig @@ -155,6 +155,8 @@ pub const GameAction = enum(u8) { toggle_clouds, /// Toggle fog toggle_fog, + /// Toggle LPV debug overlay + toggle_lpv_overlay, pub const count = @typeInfo(GameAction).@"enum".fields.len; }; @@ -347,6 +349,7 @@ pub const DEFAULT_BINDINGS = blk: { bindings[@intFromEnum(GameAction.toggle_ssao)] = ActionBinding.init(.{ .key = .f8 }); bindings[@intFromEnum(GameAction.toggle_clouds)] = ActionBinding.init(.{ .key = .f9 }); bindings[@intFromEnum(GameAction.toggle_fog)] = ActionBinding.init(.{ .key = .f10 }); + bindings[@intFromEnum(GameAction.toggle_lpv_overlay)] = ActionBinding.init(.{ .key = .f11 }); // Map controls bindings[@intFromEnum(GameAction.toggle_map)] = ActionBinding.init(.{ .key = .m }); diff --git a/src/game/screen.zig b/src/game/screen.zig index de179f4..a7c80fe 100644 --- a/src/game/screen.zig +++ b/src/game/screen.zig @@ -15,6 +15,7 @@ const TextureAtlas = @import("../engine/graphics/texture_atlas.zig").TextureAtla const RenderGraph = @import("../engine/graphics/render_graph.zig").RenderGraph; const AtmosphereSystem = @import("../engine/graphics/atmosphere_system.zig").AtmosphereSystem; const MaterialSystem = @import("../engine/graphics/material_system.zig").MaterialSystem; +const LPVSystem = @import("../engine/graphics/lpv_system.zig").LPVSystem; const Texture = @import("../engine/graphics/texture.zig").Texture; const AudioSystem = @import("../engine/audio/system.zig").AudioSystem; const rhi_pkg = @import("../engine/graphics/rhi.zig"); @@ -28,6 +29,7 @@ pub const EngineContext = struct { render_graph: *RenderGraph, atmosphere_system: *AtmosphereSystem, material_system: *MaterialSystem, + lpv_system: *LPVSystem, audio_system: *AudioSystem, env_map_ptr: ?*?Texture, shader: rhi_pkg.ShaderHandle, diff --git a/src/game/screens/graphics.zig b/src/game/screens/graphics.zig index cbf91d8..e877e9e 100644 --- a/src/game/screens/graphics.zig +++ b/src/game/screens/graphics.zig @@ -231,6 +231,11 @@ pub const GraphicsScreen = struct { } } + if (std.mem.eql(u8, decl.name, "lpv_quality_preset")) { + const legend = getLPVQualityLegend(settings.lpv_quality_preset); + Font.drawText(ui, legend, vx - 90.0 * ui_scale, sy + row_height - 10.0 * ui_scale, 1.2 * ui_scale, Color.rgba(0.72, 0.86, 0.98, 1.0)); + } + sy += row_height; } @@ -255,3 +260,11 @@ fn getPresetLabel(idx: usize) []const u8 { if (idx >= settings_pkg.json_presets.graphics_presets.items.len) return "CUSTOM"; return settings_pkg.json_presets.graphics_presets.items[idx].name; } + +fn getLPVQualityLegend(preset: u32) []const u8 { + return switch (preset) { + 0 => "GRID16 ITER2 TICK8", + 2 => "GRID64 ITER5 TICK3", + else => "GRID32 ITER3 TICK6", + }; +} diff --git a/src/game/screens/world.zig b/src/game/screens/world.zig index fa00304..dd5073c 100644 --- a/src/game/screens/world.zig +++ b/src/game/screens/world.zig @@ -10,6 +10,8 @@ const rhi_pkg = @import("../../engine/graphics/rhi.zig"); const render_graph_pkg = @import("../../engine/graphics/render_graph.zig"); const PausedScreen = @import("paused.zig").PausedScreen; const DebugShadowOverlay = @import("../../engine/ui/debug_shadow_overlay.zig").DebugShadowOverlay; +const DebugLPVOverlay = @import("../../engine/ui/debug_lpv_overlay.zig").DebugLPVOverlay; +const Font = @import("../../engine/ui/font.zig"); const log = @import("../../engine/core/log.zig"); pub const WorldScreen = struct { @@ -110,6 +112,11 @@ pub const WorldScreen = struct { log.log.info("Fog {s}", .{if (self.session.atmosphere.fog_enabled) "enabled" else "disabled"}); self.last_debug_toggle_time = now; } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_lpv_overlay)) { + ctx.settings.debug_lpv_overlay_active = !ctx.settings.debug_lpv_overlay_active; + log.log.info("LPV overlay {s}", .{if (ctx.settings.debug_lpv_overlay_active) "enabled" else "disabled"}); + self.last_debug_toggle_time = now; + } // Update Audio Listener const cam = &self.session.player.camera; @@ -150,6 +157,21 @@ pub const WorldScreen = struct { const ssao_enabled = ctx.settings.ssao_enabled and !ctx.disable_ssao and !ctx.disable_gpass_draw; const cloud_shadows_enabled = ctx.settings.cloud_shadows_enabled and !ctx.disable_clouds; + + const lpv_quality = resolveLPVQuality(ctx.settings.lpv_quality_preset); + try ctx.lpv_system.setSettings( + ctx.settings.lpv_enabled, + ctx.settings.lpv_intensity, + ctx.settings.lpv_cell_size, + lpv_quality.propagation_iterations, + lpv_quality.grid_size, + lpv_quality.update_interval_frames, + ); + ctx.rhi.timing().beginPassTiming("LPVPass"); + try ctx.lpv_system.update(self.session.world, camera.position); + ctx.rhi.timing().endPassTiming("LPVPass"); + + const lpv_origin = ctx.lpv_system.getOrigin(); const cloud_params: rhi_pkg.CloudParams = blk: { const p = self.session.clouds.getShadowParams(); break :blk .{ @@ -181,6 +203,11 @@ pub const WorldScreen = struct { .volumetric_steps = ctx.settings.volumetric_steps, .volumetric_scattering = ctx.settings.volumetric_scattering, .ssao_enabled = ssao_enabled, + .lpv_enabled = ctx.settings.lpv_enabled, + .lpv_intensity = ctx.settings.lpv_intensity, + .lpv_cell_size = ctx.lpv_system.getCellSize(), + .lpv_grid_size = ctx.lpv_system.getGridSize(), + .lpv_origin = lpv_origin, }; }; @@ -216,6 +243,7 @@ pub const WorldScreen = struct { .overlay_renderer = renderOverlay, .overlay_ctx = self, .cached_cascades = &frame_cascades, + .lpv_texture_handle = ctx.lpv_system.getTextureHandle(), }; try ctx.render_graph.execute(render_ctx); } @@ -233,6 +261,29 @@ pub const WorldScreen = struct { if (ctx.settings.debug_shadows_active) { DebugShadowOverlay.draw(ctx.rhi.ui(), ctx.rhi.shadow(), screen_w, screen_h, .{}); } + if (ctx.settings.debug_lpv_overlay_active) { + const cfg = DebugLPVOverlay.Config{}; + const r = DebugLPVOverlay.rect(screen_h, cfg); + DebugLPVOverlay.draw(ctx.rhi.ui(), ctx.lpv_system.getDebugOverlayTextureHandle(), screen_w, screen_h, cfg); + + const stats = ctx.lpv_system.getStats(); + const timing_results = ctx.rhi.timing().getTimingResults(); + var line0_buf: [64]u8 = undefined; + var line1_buf: [64]u8 = undefined; + var line2_buf: [64]u8 = undefined; + var line3_buf: [64]u8 = undefined; + const line0 = std.fmt.bufPrint(&line0_buf, "LPV GRID:{d} ITER:{d}", .{ stats.grid_size, stats.propagation_iterations }) catch "LPV"; + const line1 = std.fmt.bufPrint(&line1_buf, "LIGHTS:{d} UPDATE:{d:.2}MS", .{ stats.light_count, stats.cpu_update_ms }) catch "LIGHTS"; + const line2 = std.fmt.bufPrint(&line2_buf, "TICK:{d} UPDATED:{d}", .{ stats.update_interval_frames, if (stats.updated_this_frame) @as(u8, 1) else @as(u8, 0) }) catch "TICK"; + const line3 = std.fmt.bufPrint(&line3_buf, "LPV GPU:{d:.2}MS", .{timing_results.lpv_pass_ms}) catch "GPU"; + + const text_x = r.x; + const text_y = r.y - 28.0; + Font.drawText(ui, line0, text_x, text_y, 1.5, .{ .r = 0.95, .g = 0.98, .b = 1.0, .a = 1.0 }); + Font.drawText(ui, line1, text_x, text_y + 10.0, 1.5, .{ .r = 0.95, .g = 0.98, .b = 1.0, .a = 1.0 }); + Font.drawText(ui, line2, text_x, text_y + 20.0, 1.5, .{ .r = 0.95, .g = 0.98, .b = 1.0, .a = 1.0 }); + Font.drawText(ui, line3, text_x, text_y + 30.0, 1.5, .{ .r = 0.95, .g = 0.98, .b = 1.0, .a = 1.0 }); + } } pub fn onEnter(ptr: *anyopaque) void { @@ -256,3 +307,17 @@ pub const WorldScreen = struct { self.session.hand_renderer.draw(scene_ctx.camera.position, scene_ctx.camera.yaw, scene_ctx.camera.pitch); } }; + +const LPVQualityResolved = struct { + grid_size: u32, + propagation_iterations: u32, + update_interval_frames: u32, +}; + +fn resolveLPVQuality(preset: u32) LPVQualityResolved { + return switch (preset) { + 0 => .{ .grid_size = 16, .propagation_iterations = 2, .update_interval_frames = 8 }, + 2 => .{ .grid_size = 64, .propagation_iterations = 5, .update_interval_frames = 3 }, + else => .{ .grid_size = 32, .propagation_iterations = 3, .update_interval_frames = 6 }, + }; +} diff --git a/src/game/settings/data.zig b/src/game/settings/data.zig index 794e38c..5580de7 100644 --- a/src/game/settings/data.zig +++ b/src/game/settings/data.zig @@ -36,6 +36,7 @@ pub const Settings = struct { textures_enabled: bool = true, wireframe_enabled: bool = false, debug_shadows_active: bool = false, // Reverted to false for normal gameplay + debug_lpv_overlay_active: bool = false, shadow_quality: u32 = 2, // 0=Low, 1=Medium, 2=High, 3=Ultra shadow_distance: f32 = 250.0, anisotropic_filtering: u8 = 16, @@ -67,6 +68,15 @@ pub const Settings = struct { volumetric_scattering: f32 = 0.8, // Mie scattering anisotropy (G) ssao_enabled: bool = true, + // LPV Settings (Issue #260) + lpv_enabled: bool = true, + lpv_quality_preset: u32 = 1, // 0=Fast, 1=Balanced, 2=Quality + lpv_intensity: f32 = 0.5, + lpv_cell_size: f32 = 2.0, + lpv_grid_size: u32 = 32, // Derived from lpv_quality_preset at runtime + lpv_propagation_iterations: u32 = 3, // Derived from lpv_quality_preset at runtime + lpv_update_interval_frames: u32 = 6, // Derived from lpv_quality_preset at runtime + // FXAA Settings (Phase 3) fxaa_enabled: bool = true, @@ -227,6 +237,25 @@ pub const Settings = struct { .label = "VOLUMETRIC SCATTERING", .kind = .{ .slider = .{ .min = 0.0, .max = 1.0, .step = 0.05 } }, }; + pub const lpv_enabled = SettingMetadata{ + .label = "LPV GI", + .kind = .toggle, + }; + pub const lpv_quality_preset = SettingMetadata{ + .label = "LPV QUALITY", + .kind = .{ .choice = .{ + .labels = &[_][]const u8{ "FAST", "BALANCED", "QUALITY" }, + .values = &[_]u32{ 0, 1, 2 }, + } }, + }; + pub const lpv_intensity = SettingMetadata{ + .label = "LPV INTENSITY", + .kind = .{ .slider = .{ .min = 0.0, .max = 2.0, .step = 0.1 } }, + }; + pub const lpv_cell_size = SettingMetadata{ + .label = "LPV CELL SIZE", + .kind = .{ .slider = .{ .min = 1.0, .max = 4.0, .step = 0.25 } }, + }; }; pub fn getShadowResolution(self: *const Settings) u32 { diff --git a/src/game/settings/json_presets.zig b/src/game/settings/json_presets.zig index 82ba0b3..e7fa7d2 100644 --- a/src/game/settings/json_presets.zig +++ b/src/game/settings/json_presets.zig @@ -22,6 +22,12 @@ pub const PresetConfig = struct { volumetric_steps: u32, volumetric_scattering: f32, ssao_enabled: bool, + lpv_quality_preset: u32 = 1, + lpv_enabled: bool = true, + lpv_intensity: f32 = 0.5, + lpv_cell_size: f32 = 2.0, + lpv_grid_size: u32 = 32, + lpv_propagation_iterations: u32 = 3, lod_enabled: bool, render_distance: i32, fxaa_enabled: bool, @@ -71,6 +77,26 @@ pub fn initPresets(allocator: std.mem.Allocator) !void { std.log.warn("Skipping preset '{s}': invalid bloom_intensity {}", .{ p.name, p.bloom_intensity }); continue; } + if (p.lpv_intensity < 0.0 or p.lpv_intensity > 2.0) { + std.log.warn("Skipping preset '{s}': invalid lpv_intensity {}", .{ p.name, p.lpv_intensity }); + continue; + } + if (p.lpv_quality_preset > 2) { + std.log.warn("Skipping preset '{s}': invalid lpv_quality_preset {}", .{ p.name, p.lpv_quality_preset }); + continue; + } + if (p.lpv_cell_size < 1.0 or p.lpv_cell_size > 4.0) { + std.log.warn("Skipping preset '{s}': invalid lpv_cell_size {}", .{ p.name, p.lpv_cell_size }); + continue; + } + if (p.lpv_grid_size != 16 and p.lpv_grid_size != 32 and p.lpv_grid_size != 64) { + std.log.warn("Skipping preset '{s}': invalid lpv_grid_size {}", .{ p.name, p.lpv_grid_size }); + continue; + } + if (p.lpv_propagation_iterations < 1 or p.lpv_propagation_iterations > 8) { + std.log.warn("Skipping preset '{s}': invalid lpv_propagation_iterations {}", .{ p.name, p.lpv_propagation_iterations }); + continue; + } // Duplicate name because parsed.deinit() will free strings p.name = try allocator.dupe(u8, preset.name); errdefer allocator.free(p.name); @@ -106,6 +132,12 @@ pub fn apply(settings: *Settings, preset_idx: usize) void { settings.volumetric_steps = config.volumetric_steps; settings.volumetric_scattering = config.volumetric_scattering; settings.ssao_enabled = config.ssao_enabled; + settings.lpv_quality_preset = config.lpv_quality_preset; + settings.lpv_enabled = config.lpv_enabled; + settings.lpv_intensity = config.lpv_intensity; + settings.lpv_cell_size = config.lpv_cell_size; + settings.lpv_grid_size = config.lpv_grid_size; + settings.lpv_propagation_iterations = config.lpv_propagation_iterations; settings.lod_enabled = config.lod_enabled; settings.render_distance = config.render_distance; settings.fxaa_enabled = config.fxaa_enabled; @@ -139,6 +171,12 @@ fn matches(settings: *const Settings, preset: PresetConfig) bool { std.math.approxEqAbs(f32, settings.volumetric_density, preset.volumetric_density, epsilon) and settings.volumetric_steps == preset.volumetric_steps and std.math.approxEqAbs(f32, settings.volumetric_scattering, preset.volumetric_scattering, epsilon) and + settings.lpv_quality_preset == preset.lpv_quality_preset and + settings.lpv_enabled == preset.lpv_enabled and + std.math.approxEqAbs(f32, settings.lpv_intensity, preset.lpv_intensity, epsilon) and + std.math.approxEqAbs(f32, settings.lpv_cell_size, preset.lpv_cell_size, epsilon) and + settings.lpv_grid_size == preset.lpv_grid_size and + settings.lpv_propagation_iterations == preset.lpv_propagation_iterations and settings.lod_enabled == preset.lod_enabled and settings.fxaa_enabled == preset.fxaa_enabled and settings.bloom_enabled == preset.bloom_enabled and From 9fab4ef8f6b15974315694fb9ca6437a075a5957 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 7 Feb 2026 22:13:39 +0000 Subject: [PATCH 2/9] fix(lighting): harden LPV shader loading and propagation tuning --- assets/shaders/vulkan/lpv_propagate.comp | 3 ++- src/engine/graphics/lpv_system.zig | 33 +++++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/assets/shaders/vulkan/lpv_propagate.comp b/assets/shaders/vulkan/lpv_propagate.comp index 4032939..e2b2293 100644 --- a/assets/shaders/vulkan/lpv_propagate.comp +++ b/assets/shaders/vulkan/lpv_propagate.comp @@ -25,7 +25,8 @@ void main() { return; } - vec3 center = sampleCell(cell, gridSize) * 0.82; + // propagation.x = neighbor propagation factor, propagation.y = center retention + vec3 center = sampleCell(cell, gridSize) * push_data.propagation.y; vec3 accum = center; float f = push_data.propagation.x; diff --git a/src/engine/graphics/lpv_system.zig b/src/engine/graphics/lpv_system.zig index bf71b84..e0e5a31 100644 --- a/src/engine/graphics/lpv_system.zig +++ b/src/engine/graphics/lpv_system.zig @@ -11,6 +11,10 @@ const VulkanContext = @import("vulkan/rhi_context_types.zig").VulkanContext; const Utils = @import("vulkan/utils.zig"); const MAX_LIGHTS_PER_UPDATE: usize = 2048; +const DEFAULT_PROPAGATION_FACTOR: f32 = 0.14; +const DEFAULT_CENTER_RETENTION: f32 = 0.82; +const INJECT_SHADER_PATH = "assets/shaders/vulkan/lpv_inject.comp.spv"; +const PROPAGATE_SHADER_PATH = "assets/shaders/vulkan/lpv_propagate.comp.spv"; const GpuLight = extern struct { pos_radius: [4]f32, @@ -27,8 +31,7 @@ const InjectPush = extern struct { const PropagatePush = extern struct { grid_size: u32, _pad0: [3]u32, - propagation_factor: f32, - _pad1: [3]f32, + propagation: [4]f32, }; pub const LPVSystem = struct { @@ -53,6 +56,8 @@ pub const LPVSystem = struct { cell_size: f32, intensity: f32, propagation_iterations: u32, + propagation_factor: f32, + center_retention: f32, enabled: bool, update_interval_frames: u32 = 6, @@ -102,6 +107,8 @@ pub const LPVSystem = struct { .cell_size = @max(cell_size, 0.5), .intensity = std.math.clamp(intensity, 0.0, 4.0), .propagation_iterations = std.math.clamp(propagation_iterations, 1, 8), + .propagation_factor = DEFAULT_PROPAGATION_FACTOR, + .center_retention = DEFAULT_CENTER_RETENTION, .enabled = enabled, .was_enabled_last_frame = enabled, .debug_overlay_pixels = &.{}, @@ -124,6 +131,9 @@ pub const LPVSystem = struct { ); errdefer self.destroyLightBuffer(); + try ensureShaderFileExists(INJECT_SHADER_PATH); + try ensureShaderFileExists(PROPAGATE_SHADER_PATH); + try self.initComputeResources(); errdefer self.deinitComputeResources(); @@ -347,8 +357,7 @@ pub const LPVSystem = struct { const prop_push = PropagatePush{ .grid_size = self.grid_size, ._pad0 = .{ 0, 0, 0 }, - .propagation_factor = 0.14, - ._pad1 = .{ 0, 0, 0 }, + .propagation = .{ self.propagation_factor, self.center_retention, 0, 0 }, }; var use_ab = true; @@ -551,8 +560,12 @@ pub const LPVSystem = struct { fn destroyLightBuffer(self: *LPVSystem) void { if (self.light_buffer.buffer != null) { + if (self.light_buffer.memory == null) { + std.log.warn("LPV light buffer has VkBuffer but null VkDeviceMemory during teardown", .{}); + } if (self.light_buffer.mapped_ptr != null) { c.vkUnmapMemory(self.vk_ctx.vulkan_device.vk_device, self.light_buffer.memory); + self.light_buffer.mapped_ptr = null; } c.vkDestroyBuffer(self.vk_ctx.vulkan_device.vk_device, self.light_buffer.buffer, null); c.vkFreeMemory(self.vk_ctx.vulkan_device.vk_device, self.light_buffer.memory, null); @@ -697,9 +710,9 @@ pub const LPVSystem = struct { fn createComputePipelines(self: *LPVSystem) !void { const vk = self.vk_ctx.vulkan_device.vk_device; - const inject_module = try createShaderModule(vk, "assets/shaders/vulkan/lpv_inject.comp.spv", self.allocator); + const inject_module = try createShaderModule(vk, INJECT_SHADER_PATH, self.allocator); defer c.vkDestroyShaderModule(vk, inject_module, null); - const propagate_module = try createShaderModule(vk, "assets/shaders/vulkan/lpv_propagate.comp.spv", self.allocator); + const propagate_module = try createShaderModule(vk, PROPAGATE_SHADER_PATH, self.allocator); defer c.vkDestroyShaderModule(vk, propagate_module, null); var inject_pc = std.mem.zeroes(c.VkPushConstantRange); @@ -803,3 +816,11 @@ fn createShaderModule(vk: c.VkDevice, path: []const u8, allocator: std.mem.Alloc try Utils.checkVk(c.vkCreateShaderModule(vk, &info, null, &module)); return module; } + +fn ensureShaderFileExists(path: []const u8) !void { + std.fs.cwd().access(path, .{}) catch |err| { + std.log.err("LPV shader artifact missing: {s} ({})", .{ path, err }); + std.log.err("Run `nix develop --command zig build` to regenerate Vulkan SPIR-V shaders.", .{}); + return err; + }; +} From 3b23ba6d3cfd13b2b1ee79d02d86f3938ea70f60 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 7 Feb 2026 22:24:44 +0000 Subject: [PATCH 3/9] fix(lighting): address LPV review blockers --- .gitignore | 2 ++ assets/shaders/vulkan/lpv_inject.comp.spv | Bin 0 -> 4036 bytes assets/shaders/vulkan/lpv_propagate.comp.spv | Bin 0 -> 3936 bytes src/engine/graphics/lpv_system.zig | 6 +++-- .../graphics/vulkan/resource_texture_ops.zig | 23 +++++------------- 5 files changed, 12 insertions(+), 19 deletions(-) create mode 100644 assets/shaders/vulkan/lpv_inject.comp.spv create mode 100644 assets/shaders/vulkan/lpv_propagate.comp.spv diff --git a/.gitignore b/.gitignore index 0398fc4..4a1ce0b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ test_output.txt *.swp .DS_Store *.spv +!assets/shaders/vulkan/lpv_inject.comp.spv +!assets/shaders/vulkan/lpv_propagate.comp.spv wiki/ *.exr *.hdr diff --git a/assets/shaders/vulkan/lpv_inject.comp.spv b/assets/shaders/vulkan/lpv_inject.comp.spv new file mode 100644 index 0000000000000000000000000000000000000000..1ea075519aae1f9545e20c28ad2af12a08de264f GIT binary patch literal 4036 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 Q3y>E+4WJR&vp#$N7hPIJ-~a#s literal 0 HcmV?d00001 diff --git a/src/engine/graphics/lpv_system.zig b/src/engine/graphics/lpv_system.zig index e0e5a31..d9a3e31 100644 --- a/src/engine/graphics/lpv_system.zig +++ b/src/engine/graphics/lpv_system.zig @@ -11,7 +11,9 @@ const VulkanContext = @import("vulkan/rhi_context_types.zig").VulkanContext; const Utils = @import("vulkan/utils.zig"); const MAX_LIGHTS_PER_UPDATE: usize = 2048; +// Tuned for stable 6-neighbor propagation without runaway energy. const DEFAULT_PROPAGATION_FACTOR: f32 = 0.14; +// Retain most energy in the current cell to prevent over-blur. const DEFAULT_CENTER_RETENTION: f32 = 0.82; const INJECT_SHADER_PATH = "assets/shaders/vulkan/lpv_inject.comp.spv"; const PROPAGATE_SHADER_PATH = "assets/shaders/vulkan/lpv_propagate.comp.spv"; @@ -41,7 +43,7 @@ pub const LPVSystem = struct { cpu_update_ms: f32 = 0.0, grid_size: u32 = 0, propagation_iterations: u32 = 0, - update_interval_frames: u32 = 0, + update_interval_frames: u32 = 6, }; allocator: std.mem.Allocator, @@ -134,8 +136,8 @@ pub const LPVSystem = struct { try ensureShaderFileExists(INJECT_SHADER_PATH); try ensureShaderFileExists(PROPAGATE_SHADER_PATH); - try self.initComputeResources(); errdefer self.deinitComputeResources(); + try self.initComputeResources(); return self; } diff --git a/src/engine/graphics/vulkan/resource_texture_ops.zig b/src/engine/graphics/vulkan/resource_texture_ops.zig index ca49ad2..2c06bac 100644 --- a/src/engine/graphics/vulkan/resource_texture_ops.zig +++ b/src/engine/graphics/vulkan/resource_texture_ops.zig @@ -224,7 +224,9 @@ pub fn createTexture(self: anytype, width: u32, height: u32, format: rhi.Texture } pub fn createTexture3D(self: anytype, width: u32, height: u32, depth: u32, format: rhi.TextureFormat, config: rhi.TextureConfig, data_opt: ?[]const u8) rhi.RhiError!rhi.TextureHandle { - _ = config; + var texture_config = config; + texture_config.generate_mipmaps = false; + const vk_format: c.VkFormat = switch (format) { .rgba => c.VK_FORMAT_R8G8B8A8_UNORM, .rgba_srgb => c.VK_FORMAT_R8G8B8A8_SRGB, @@ -239,6 +241,7 @@ pub fn createTexture3D(self: anytype, width: u32, height: u32, depth: u32, forma var usage_flags: c.VkImageUsageFlags = c.VK_IMAGE_USAGE_TRANSFER_DST_BIT | c.VK_IMAGE_USAGE_SAMPLED_BIT; if (format == .rgba32f) usage_flags |= c.VK_IMAGE_USAGE_STORAGE_BIT; + if (texture_config.is_render_target) usage_flags |= c.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; var staging_offset: u64 = 0; if (data_opt) |data| { @@ -342,14 +345,7 @@ pub fn createTexture3D(self: anytype, width: u32, height: u32, depth: u32, forma view_info.subresourceRange.baseArrayLayer = 0; view_info.subresourceRange.layerCount = 1; - const sampler = try Utils.createSampler(self.vulkan_device, .{ - .min_filter = .linear, - .mag_filter = .linear, - .wrap_s = .clamp_to_edge, - .wrap_t = .clamp_to_edge, - .generate_mipmaps = false, - .is_render_target = false, - }, 1, self.vulkan_device.max_anisotropy); + const sampler = try Utils.createSampler(self.vulkan_device, texture_config, 1, self.vulkan_device.max_anisotropy); errdefer c.vkDestroySampler(device, sampler, null); try Utils.checkVk(c.vkCreateImageView(device, &view_info, null, &view)); @@ -366,14 +362,7 @@ pub fn createTexture3D(self: anytype, width: u32, height: u32, depth: u32, forma .height = height, .depth = depth, .format = format, - .config = .{ - .min_filter = .linear, - .mag_filter = .linear, - .wrap_s = .clamp_to_edge, - .wrap_t = .clamp_to_edge, - .generate_mipmaps = false, - .is_render_target = false, - }, + .config = texture_config, .is_3d = true, .is_owned = true, }); From 4fee9a08f83f334b81e5712d360d33ccad10f7b8 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 7 Feb 2026 22:29:39 +0000 Subject: [PATCH 4/9] fix(lighting): clarify 3D texture config and LPV debug scaling --- src/engine/graphics/lpv_system.zig | 4 ++++ src/engine/graphics/vulkan/resource_texture_ops.zig | 5 ++++- src/game/screens/world.zig | 7 ++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/engine/graphics/lpv_system.zig b/src/engine/graphics/lpv_system.zig index d9a3e31..ad95fed 100644 --- a/src/engine/graphics/lpv_system.zig +++ b/src/engine/graphics/lpv_system.zig @@ -240,6 +240,7 @@ pub const LPVSystem = struct { @memcpy(@as([*]u8, @ptrCast(ptr))[0..bytes.len], bytes); } + // Keep debug overlay generation on LPV update ticks only (not every frame). self.buildDebugOverlay(lights[0..], light_count); try self.uploadDebugOverlay(); @@ -426,6 +427,9 @@ pub const LPVSystem = struct { @memset(empty, 0.0); const bytes = std.mem.sliceAsBytes(empty); + // Atlas fallback: store Z slices stacked in Y (height = grid_size * grid_size). + // This stays until terrain/material sampling fully migrates to native 3D textures. + self.grid_texture_a = try self.rhi.createTexture( self.grid_size, self.grid_size * self.grid_size, diff --git a/src/engine/graphics/vulkan/resource_texture_ops.zig b/src/engine/graphics/vulkan/resource_texture_ops.zig index 2c06bac..47ef4a2 100644 --- a/src/engine/graphics/vulkan/resource_texture_ops.zig +++ b/src/engine/graphics/vulkan/resource_texture_ops.zig @@ -225,7 +225,10 @@ pub fn createTexture(self: anytype, width: u32, height: u32, format: rhi.Texture pub fn createTexture3D(self: anytype, width: u32, height: u32, depth: u32, format: rhi.TextureFormat, config: rhi.TextureConfig, data_opt: ?[]const u8) rhi.RhiError!rhi.TextureHandle { var texture_config = config; - texture_config.generate_mipmaps = false; + if (texture_config.generate_mipmaps) { + std.log.warn("3D texture mipmaps are not supported yet; disabling generate_mipmaps", .{}); + texture_config.generate_mipmaps = false; + } const vk_format: c.VkFormat = switch (format) { .rgba => c.VK_FORMAT_R8G8B8A8_UNORM, diff --git a/src/game/screens/world.zig b/src/game/screens/world.zig index dd5073c..67d1b25 100644 --- a/src/game/screens/world.zig +++ b/src/game/screens/world.zig @@ -262,7 +262,12 @@ pub const WorldScreen = struct { DebugShadowOverlay.draw(ctx.rhi.ui(), ctx.rhi.shadow(), screen_w, screen_h, .{}); } if (ctx.settings.debug_lpv_overlay_active) { - const cfg = DebugLPVOverlay.Config{}; + const overlay_size = std.math.clamp(220.0 * ctx.settings.ui_scale, 160.0, screen_h * 0.4); + const cfg = DebugLPVOverlay.Config{ + .width = overlay_size, + .height = overlay_size, + .spacing = 10.0 * ctx.settings.ui_scale, + }; const r = DebugLPVOverlay.rect(screen_h, cfg); DebugLPVOverlay.draw(ctx.rhi.ui(), ctx.lpv_system.getDebugOverlayTextureHandle(), screen_w, screen_h, cfg); From 7a973c6d492ca93b04b656f4b2ecd4f297dee0a2 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 7 Feb 2026 22:34:30 +0000 Subject: [PATCH 5/9] chore(lighting): polish LPV docs and overlay sizing --- src/engine/graphics/lpv_system.zig | 4 ++-- src/engine/ui/debug_lpv_overlay.zig | 15 ++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/engine/graphics/lpv_system.zig b/src/engine/graphics/lpv_system.zig index ad95fed..21f06c2 100644 --- a/src/engine/graphics/lpv_system.zig +++ b/src/engine/graphics/lpv_system.zig @@ -11,9 +11,9 @@ const VulkanContext = @import("vulkan/rhi_context_types.zig").VulkanContext; const Utils = @import("vulkan/utils.zig"); const MAX_LIGHTS_PER_UPDATE: usize = 2048; -// Tuned for stable 6-neighbor propagation without runaway energy. +// Approximate 1/7 energy spread for 6-neighbor propagation, tuned to avoid runaway amplification. const DEFAULT_PROPAGATION_FACTOR: f32 = 0.14; -// Retain most energy in the current cell to prevent over-blur. +// Keep most energy in the center cell to reduce over-blur and preserve local contrast. const DEFAULT_CENTER_RETENTION: f32 = 0.82; const INJECT_SHADER_PATH = "assets/shaders/vulkan/lpv_inject.comp.spv"; const PROPAGATE_SHADER_PATH = "assets/shaders/vulkan/lpv_propagate.comp.spv"; diff --git a/src/engine/ui/debug_lpv_overlay.zig b/src/engine/ui/debug_lpv_overlay.zig index 97da827..06bb801 100644 --- a/src/engine/ui/debug_lpv_overlay.zig +++ b/src/engine/ui/debug_lpv_overlay.zig @@ -1,19 +1,24 @@ +const std = @import("std"); const rhi = @import("../graphics/rhi.zig"); const IUIContext = rhi.IUIContext; pub const DebugLPVOverlay = struct { pub const Config = struct { - width: f32 = 220.0, - height: f32 = 220.0, + // Optional explicit size; <= 0 uses screen-relative fallback. + width: f32 = 0.0, + height: f32 = 0.0, spacing: f32 = 10.0, }; pub fn rect(screen_height: f32, config: Config) rhi.Rect { + const fallback_size = std.math.clamp(screen_height * 0.28, 160.0, 280.0); + const width = if (config.width > 0.0) config.width else fallback_size; + const height = if (config.height > 0.0) config.height else fallback_size; return .{ .x = config.spacing, - .y = screen_height - config.height - config.spacing, - .width = config.width, - .height = config.height, + .y = screen_height - height - config.spacing, + .width = width, + .height = height, }; } From 9eee4c2f49c5067509520ac2ace9780761aaedd8 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 7 Feb 2026 22:37:01 +0000 Subject: [PATCH 6/9] docs(lighting): clarify LPV constants and 3D mipmap behavior --- src/engine/graphics/lpv_system.zig | 5 +++-- src/engine/graphics/vulkan/resource_texture_ops.zig | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/engine/graphics/lpv_system.zig b/src/engine/graphics/lpv_system.zig index 21f06c2..9d937a4 100644 --- a/src/engine/graphics/lpv_system.zig +++ b/src/engine/graphics/lpv_system.zig @@ -11,9 +11,10 @@ const VulkanContext = @import("vulkan/rhi_context_types.zig").VulkanContext; const Utils = @import("vulkan/utils.zig"); const MAX_LIGHTS_PER_UPDATE: usize = 2048; -// Approximate 1/7 energy spread for 6-neighbor propagation, tuned to avoid runaway amplification. +// Approximate 1/7 spread for 6-neighbor propagation (close to 1/6 with extra damping) +// to keep indirect light stable and avoid runaway amplification. const DEFAULT_PROPAGATION_FACTOR: f32 = 0.14; -// Keep most energy in the center cell to reduce over-blur and preserve local contrast. +// Retain 82% of center-cell energy so propagation does not over-blur local contrast. const DEFAULT_CENTER_RETENTION: f32 = 0.82; const INJECT_SHADER_PATH = "assets/shaders/vulkan/lpv_inject.comp.spv"; const PROPAGATE_SHADER_PATH = "assets/shaders/vulkan/lpv_propagate.comp.spv"; diff --git a/src/engine/graphics/vulkan/resource_texture_ops.zig b/src/engine/graphics/vulkan/resource_texture_ops.zig index 47ef4a2..4e6e6ce 100644 --- a/src/engine/graphics/vulkan/resource_texture_ops.zig +++ b/src/engine/graphics/vulkan/resource_texture_ops.zig @@ -223,6 +223,8 @@ pub fn createTexture(self: anytype, width: u32, height: u32, format: rhi.Texture return handle; } +/// Creates a 3D texture resource. +/// Note: `config.generate_mipmaps` is currently forced off for 3D textures. pub fn createTexture3D(self: anytype, width: u32, height: u32, depth: u32, format: rhi.TextureFormat, config: rhi.TextureConfig, data_opt: ?[]const u8) rhi.RhiError!rhi.TextureHandle { var texture_config = config; if (texture_config.generate_mipmaps) { From b80a8644342cca439cf86c760925e1e3c96ad608 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 7 Feb 2026 23:11:10 +0000 Subject: [PATCH 7/9] docs(vulkan): clarify createTexture3D config handling --- src/engine/graphics/vulkan/resource_texture_ops.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/engine/graphics/vulkan/resource_texture_ops.zig b/src/engine/graphics/vulkan/resource_texture_ops.zig index 4e6e6ce..04d3cf8 100644 --- a/src/engine/graphics/vulkan/resource_texture_ops.zig +++ b/src/engine/graphics/vulkan/resource_texture_ops.zig @@ -225,6 +225,7 @@ pub fn createTexture(self: anytype, width: u32, height: u32, format: rhi.Texture /// Creates a 3D texture resource. /// Note: `config.generate_mipmaps` is currently forced off for 3D textures. +/// Other config parameters (filtering, wrapping, render-target flag) are respected. pub fn createTexture3D(self: anytype, width: u32, height: u32, depth: u32, format: rhi.TextureFormat, config: rhi.TextureConfig, data_opt: ?[]const u8) rhi.RhiError!rhi.TextureHandle { var texture_config = config; if (texture_config.generate_mipmaps) { From bde4c99dcefeef2bffd37ddcd15b9b47c2de2222 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 8 Feb 2026 01:11:08 +0000 Subject: [PATCH 8/9] perf(lighting): gate LPV debug overlay work behind toggle --- src/engine/graphics/lpv_system.zig | 19 +++++++++++++------ src/game/screens/world.zig | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/engine/graphics/lpv_system.zig b/src/engine/graphics/lpv_system.zig index 9d937a4..6c71e03 100644 --- a/src/engine/graphics/lpv_system.zig +++ b/src/engine/graphics/lpv_system.zig @@ -67,6 +67,7 @@ pub const LPVSystem = struct { origin: Vec3 = Vec3.zero, current_frame: u32 = 0, was_enabled_last_frame: bool = true, + debug_overlay_was_enabled: bool = false, image_layout_a: c.VkImageLayout = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, image_layout_b: c.VkImageLayout = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, @@ -194,7 +195,7 @@ pub const LPVSystem = struct { return self.cell_size; } - pub fn update(self: *LPVSystem, world: *World, camera_pos: Vec3) !void { + pub fn update(self: *LPVSystem, world: *World, camera_pos: Vec3, debug_overlay_enabled: bool) !void { self.current_frame +%= 1; var timer = std.time.Timer.start() catch unreachable; self.stats.updated_this_frame = false; @@ -204,11 +205,12 @@ pub const LPVSystem = struct { if (!self.enabled) { self.active_grid_texture = self.grid_texture_a; - if (self.was_enabled_last_frame) { + if (self.was_enabled_last_frame and debug_overlay_enabled) { self.buildDebugOverlay(&.{}, 0); try self.uploadDebugOverlay(); } self.was_enabled_last_frame = false; + self.debug_overlay_was_enabled = debug_overlay_enabled; self.stats.light_count = 0; self.stats.cpu_update_ms = 0.0; return; @@ -226,7 +228,10 @@ pub const LPVSystem = struct { @abs(next_origin.z - self.origin.z) >= self.cell_size; const tick_update = (self.current_frame % self.update_interval_frames) == 0; - if (!moved and !tick_update and self.was_enabled_last_frame) { + const debug_toggle_on = debug_overlay_enabled and !self.debug_overlay_was_enabled; + self.debug_overlay_was_enabled = debug_overlay_enabled; + + if (!moved and !tick_update and !debug_toggle_on and self.was_enabled_last_frame) { self.stats.cpu_update_ms = 0.0; return; } @@ -241,9 +246,11 @@ pub const LPVSystem = struct { @memcpy(@as([*]u8, @ptrCast(ptr))[0..bytes.len], bytes); } - // Keep debug overlay generation on LPV update ticks only (not every frame). - self.buildDebugOverlay(lights[0..], light_count); - try self.uploadDebugOverlay(); + if (debug_overlay_enabled) { + // Keep debug overlay generation only when overlay is active. + self.buildDebugOverlay(lights[0..], light_count); + try self.uploadDebugOverlay(); + } try self.dispatchCompute(light_count); diff --git a/src/game/screens/world.zig b/src/game/screens/world.zig index 67d1b25..4e7b0c8 100644 --- a/src/game/screens/world.zig +++ b/src/game/screens/world.zig @@ -168,7 +168,7 @@ pub const WorldScreen = struct { lpv_quality.update_interval_frames, ); ctx.rhi.timing().beginPassTiming("LPVPass"); - try ctx.lpv_system.update(self.session.world, camera.position); + try ctx.lpv_system.update(self.session.world, camera.position, ctx.settings.debug_lpv_overlay_active); ctx.rhi.timing().endPassTiming("LPVPass"); const lpv_origin = ctx.lpv_system.getOrigin(); From de7b427f750609f532c9d0491a59e85bd6026d43 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 8 Feb 2026 03:40:26 +0000 Subject: [PATCH 9/9] feat(lighting): add 3D dummy texture for LPV sampler3D bindings, PCSS soft shadows, SH L1 LPV with occlusion, and LUT color grading Implement the full lighting overhaul (phases 4-6): - PCSS: Poisson-disk blocker search with penumbra-based variable-radius PCF - LPV: native 3D textures, SH L1 directional encoding (3 channels), occlusion-aware propagation - Post-process: LUT-based color grading with 32^3 neutral identity texture - Fix Vulkan validation error on bindings 11-13 by creating a 1x1x1 3D dummy texture so sampler3D descriptors have a correctly-typed fallback before LPV initialization Resolves #143 --- assets/shaders/vulkan/lpv_inject.comp | 40 +- assets/shaders/vulkan/lpv_inject.comp.spv | Bin 4036 -> 4844 bytes assets/shaders/vulkan/lpv_propagate.comp | 95 ++++- assets/shaders/vulkan/lpv_propagate.comp.spv | Bin 3936 -> 8316 bytes assets/shaders/vulkan/post_process.frag | 32 +- assets/shaders/vulkan/terrain.frag | 115 +++--- assets/shaders/vulkan/terrain.frag.spv | Bin 52988 -> 53192 bytes src/engine/graphics/lpv_system.zig | 382 +++++++++++++----- src/engine/graphics/render_graph.zig | 4 + src/engine/graphics/rhi.zig | 8 + src/engine/graphics/rhi_tests.zig | 2 + src/engine/graphics/rhi_types.zig | 2 + src/engine/graphics/rhi_vulkan.zig | 14 + .../graphics/vulkan/descriptor_bindings.zig | 4 +- .../graphics/vulkan/descriptor_manager.zig | 15 +- .../graphics/vulkan/post_process_system.zig | 28 ++ .../graphics/vulkan/rhi_context_factory.zig | 3 + .../graphics/vulkan/rhi_context_types.zig | 7 + .../vulkan/rhi_frame_orchestration.zig | 30 +- .../graphics/vulkan/rhi_init_deinit.zig | 6 +- .../vulkan/rhi_pass_orchestration.zig | 2 + .../graphics/vulkan/rhi_resource_setup.zig | 47 +++ .../graphics/vulkan/rhi_shadow_bridge.zig | 2 + src/game/screens/world.zig | 2 + 24 files changed, 652 insertions(+), 188 deletions(-) diff --git a/assets/shaders/vulkan/lpv_inject.comp b/assets/shaders/vulkan/lpv_inject.comp index 138f195..fb1889e 100644 --- a/assets/shaders/vulkan/lpv_inject.comp +++ b/assets/shaders/vulkan/lpv_inject.comp @@ -7,8 +7,12 @@ struct LightData { vec4 color; }; -layout(set = 0, binding = 0, rgba32f) uniform writeonly image2D lpv_out; -layout(set = 0, binding = 1) readonly buffer Lights { +// SH L1: 3 output images (R, G, B), each storing 4 SH coefficients (L0, L1x, L1y, L1z) +layout(set = 0, binding = 0, rgba32f) uniform writeonly image3D lpv_out_r; +layout(set = 0, binding = 1, rgba32f) uniform writeonly image3D lpv_out_g; +layout(set = 0, binding = 2, rgba32f) uniform writeonly image3D lpv_out_b; + +layout(set = 0, binding = 3) readonly buffer Lights { LightData lights[]; } light_buffer; @@ -18,9 +22,11 @@ layout(push_constant) uniform InjectPush { uint light_count; } push_data; -ivec2 atlasUV(ivec3 cell, int gridSize) { - return ivec2(cell.x, cell.y + cell.z * gridSize); -} +// SH L1 basis functions (unnormalized for compact storage) +// Y_00 = 0.282095 (DC) +// Y_1m = 0.488603 * {x, y, z} (directional) +const float SH_C0 = 0.282095; +const float SH_C1 = 0.488603; void main() { int gridSize = int(push_data.grid_params.x); @@ -31,19 +37,35 @@ void main() { vec3 world_pos = push_data.grid_origin_cell.xyz + vec3(cell) * push_data.grid_origin_cell.w + vec3(0.5 * push_data.grid_origin_cell.w); - vec3 accum = vec3(0.0); + // Accumulate SH coefficients per color channel + vec4 sh_r = vec4(0.0); // (L0, L1x, L1y, L1z) for red + vec4 sh_g = vec4(0.0); // for green + vec4 sh_b = vec4(0.0); // for blue + for (uint i = 0; i < push_data.light_count; i++) { vec3 light_pos = light_buffer.lights[i].pos_radius.xyz; float radius = max(light_buffer.lights[i].pos_radius.w, 0.001); vec3 light_color = light_buffer.lights[i].color.rgb; - float d = length(world_pos - light_pos); + vec3 diff = world_pos - light_pos; + float d = length(diff); if (d < radius) { float att = 1.0 - (d / radius); att *= att; - accum += light_color * att; + + // Direction from light to cell (normalized), used for SH L1 directional encoding + vec3 dir = (d > 0.001) ? normalize(diff) : vec3(0.0, 1.0, 0.0); + + // SH L1 projection: project the incoming radiance along direction + vec4 sh_coeffs = vec4(SH_C0, SH_C1 * dir.x, SH_C1 * dir.y, SH_C1 * dir.z); + + sh_r += sh_coeffs * light_color.r * att; + sh_g += sh_coeffs * light_color.g * att; + sh_b += sh_coeffs * light_color.b * att; } } - imageStore(lpv_out, atlasUV(cell, gridSize), vec4(accum, 1.0)); + imageStore(lpv_out_r, cell, sh_r); + imageStore(lpv_out_g, cell, sh_g); + imageStore(lpv_out_b, cell, sh_b); } diff --git a/assets/shaders/vulkan/lpv_inject.comp.spv b/assets/shaders/vulkan/lpv_inject.comp.spv index 1ea075519aae1f9545e20c28ad2af12a08de264f..0f5ce6485f7e361a119dea7690242f767164df15 100644 GIT binary patch literal 4844 zcmZvg2Xj|GEQ z6}vym!i+OM&)s|QPR5UU=bZDN@|~~U&CoP-+VCV9l8j15B)=w&F+LdzlfVs2W&6t2 zE9Y*icg?+c{yZbbBu#0cF=L4-Ae+I)Qn{+*G%yZcZah#1n#diB{X5A8*nD0AW(2<8 z{&Lsq@`K%|Fok3^a#{7B?#}wEflccZDvwAx{l*q+{pH?rwbrI`uj@# zrHz}8^Uhx>_pYxOJ8J{gdOF7#xIU^EyGr#Ey1h+CMol|&UA>iJd!^P#uYb`)Zp~t~t3HN!5NtTC=rb_lDipusa%dQ?eMP3|#jO$VQ)0 zuaK;QTWRl08hyG}IR{b2{!&+YfXO`-dH0~RR;k&5Yq|-U=4Ub5j&vTl0-0;VE_Mv` z^mHHZ{SNr3Z+|x3=&vz@^<7w3xu*v~L{qXsJJa2hQr-P-O18+C`_T)@E_C*&v)0|y zV~pHkWTkI&u{KaoH&57oeN3t?v@RSZMrJDAtR3{R@C&4j%c1 zKrRn+7r~k9I-{56=$@(m6-evppMvdMu%0-Rj(i7lQ_y__+UBiBT0_pg()ON5-CHu< zI>xU>+J|##--R?*JJ!4oy>`-$E5fd0C&{*jb2vr!awl+o^2&(Yjs9D8?}D)RV9%L# zEVW(N(_jeDue}dA_o>W&2x$-6`Jru&y?&WFjCHNrzDq~gzCB0SzAs12`7^q2!L@k5 zd~b4rOL!B`X^g|W@E44~%hq)7{9RRQdzbxf3)?$4GiQ5e{M`z_cIzO!EoU!6U!1e8 z=Wkc!dw%|Qgss10klmZJjo&cH_IDwlzh#iUHD}xJuFP&@&&H#l1pFTO4HyTmW*(;})=s?wPi+?)&NBG_VER@4y)uXRQ0FZLGdCfttC= zoYqw2*}z==`^jxVP6P6PGS9h4$1!~J=Kwj+Tm2typ1NEcHD@E|0M|YX*pKh(JRpAq zT}?;M%{Y0-5HJ+Pdd^38JvH);p9kb(e=kIjIW9mq&;7j!=zE4<%)1zvE5AMC=A%2m zzNmL8`v29t3|`+(a;<+k@Ovlk-a5w>Kt2%jTnXn~`aLu6yqvoCePO1z=5+6Q@ZNFn z&fo0J5~TaA{DZY#i);rD{Y#PhmF|V#j%C0ap34g4O5hrogNV5<;|~$jf_^tUu*>ij zc}IRdetYzr>wQ@dybEEM(OvV7?0(*ZZj8Kr_9EpXzJe|{n)~PaHUj5c1oWBX_e|a# zW2!(d>>9f3|1LZCK6GQ`efRxHhj&2Vy+F=8pdNJw(DQXSV_SDOb)EM^~zrI(n<^6V>^C~b;K6=`Z?%jx(*U*iVkGWn?^~U$! zI&T0uW9`jvuUy2xiJtH6Eo^(s@8JP#`Iz@@bn^~?=-hHL@Mj^idi@`S5s(moy#v-k!PtN=$Nb|2nnturS6*!#PlaOD7 z$(ilBev|6_e*^Kx>Tht3{BN<3q%n=Zk-kIE=YNmg3d}cFzg*1!1Nza-cK#nhKL00d P_r!c-^~?D;(PiLY{Z*dp literal 4036 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 literal 3936 zcmZXVX>(Ln5QcA-Az@#`B8UlC1;`?(VKD+m(121B#NciiCJUoO5;K#ih#C~PQgK&A zMO3Wvo8@2dKe<-9JkOomc!{^_o$h|S`<(7``rc`3T-%Z)4aw}JHTgcNKMRvam;`P{ zDtitcJ+!4-8`yGN=T;-;B~59dG4qKjAe+IdVtGVIH&_5Kw-6`;P2@IW|4ecLHlJ4@ zXCA&{tx~M^9a}$HzO}2ott*Y2178}fR1nyOWG-@eygYEUe0s3XwIppQ)#9nK%HTdC zucoK@i}B_2m;5ikIoSYL8Jp~{j+fG$jo4$w@!~0@wQjz~E+n1U*Va%-w!`&~kB;?D zR8OMP(TtQ+H@3f8FF1J5aE$okaIsb%9bunBvQv)y{()kxi0c5YzL~fH2%Ug|HYi$!=B#7a?r!t>K(u*>@{^0yD2?i zXX*eed;UIa>*6ZsIm;~ouF;L04OXRgQ(}E9QoT9V-On6j_5HVBb8-dST;mrr&Fle{w<6_)_RLH-&-htL_4?X{bU!)sj9toZ_RyYlDKmX{Lvyb(e!`QCO_WpdV zb1Tv{eD7ED>wQ_5y!ktj_9t(?w(o+j9&nQIl-56YnT)KQ#sz8O>}CVPXlXmfA?}- z*DyYMsG{3*taS!G>Qu%|_)htl=b2qft*O0ncHdz)1K08Ht^m2fc4pkUGsdS0{0`%P zk$ZSIoxpR{ZbkYH4cq(m8yU9#-kfc|-^lPA?>91R@7r%=*xqL;XPfW0GW@PTG0pay z8GhsaW`=FN-^j4_`;FA@rc39(3;1q$uDj{cci;{ne+Juo*qw2EI3dqN+gQ)^F3=5( zb=`d#XRK$XZLGfiKu+6r?*^{di>3dbjFT5XQsV*SL13=_i{$no?*;O|vCko-&!70@ z?*nq)rTSmIaq4nW%Mo;Iu}<$yJL)=$Ze9AkKkZ%A=zIJiXanZx>xTC|eh7GueZcRZ zYdMn-1AXV|#r#KrYskC4^CM@x{>OlvK4(Qc_IMIK_E?S{d-S90Gsk(-R(FbYzCKki!Qs+;S*PJ;Zs zKa1^J&Y&~;9I#gT$ay~Fr-*Tl)4+A)?Pnt6BK`$zIqP#~UIcP}J7Q*D%J?6s%N||^ za?a3Mq>sJGPXRf5QICCIL3bZ*XZTgn31WuNWt_Z-8Ga4jT>Vkc>*(^HdF-Y9g^sQ7 z4PbqqrLj}MTHiwYSgSE_0y%3{cRlY@TRv)k2i@8|KffE=(Z_jo`_Sh%L_22wJ@htU zjy`jo?e~H67PI{Uy0P-cIL{vf=lCr5qWw|E8S7s9i*C9Ux{!eJPAU_ArqPZ6`JNEhlyEF4$L~45mvyfkcdB7Zf=Gf0yz#fiJ zpJ(!Q#v!h9mou&*$96+obGqsvE52f8(! zV{i9a1KNRn-050$*VfmY^>rh?Pu}k~;2!!9b5r(l Q3y>E+4WJR&vp#$N7hPIJ-~a#s diff --git a/assets/shaders/vulkan/post_process.frag b/assets/shaders/vulkan/post_process.frag index da5e169..56436ad 100644 --- a/assets/shaders/vulkan/post_process.frag +++ b/assets/shaders/vulkan/post_process.frag @@ -5,12 +5,15 @@ layout(location = 0) out vec4 outColor; layout(set = 0, binding = 0) uniform sampler2D uHDRBuffer; layout(set = 0, binding = 2) uniform sampler2D uBloomTexture; +layout(set = 0, binding = 3) uniform sampler3D uColorLUT; layout(push_constant) uniform PostProcessParams { - float bloomEnabled; // 0.0 = disabled, 1.0 = enabled - float bloomIntensity; // Final bloom blend intensity - float vignetteIntensity; // 0.0 = none, 1.0 = full vignette - float filmGrainIntensity;// 0.0 = none, 1.0 = heavy grain + float bloomEnabled; // 0.0 = disabled, 1.0 = enabled + float bloomIntensity; // Final bloom blend intensity + float vignetteIntensity; // 0.0 = none, 1.0 = full vignette + float filmGrainIntensity; // 0.0 = none, 1.0 = heavy grain + float colorGradingEnabled; // 0.0 = disabled, 1.0 = enabled + float colorGradingIntensity; // LUT blend intensity (0.0 = original, 1.0 = full LUT) } postParams; layout(set = 0, binding = 1) uniform GlobalUniforms { @@ -131,6 +134,22 @@ float random(vec2 uv) { return fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453); } +// LUT-based color grading using a 3D lookup texture. +// Input color should be in [0,1] range (post-tonemapping). +vec3 applyColorGrading(vec3 color, float intensity) { + if (intensity <= 0.0) return color; + + // Clamp to valid LUT range and apply half-texel offset for correct sampling + vec3 lutCoord = clamp(color, 0.0, 1.0); + + // Scale and bias for correct 3D LUT sampling (avoid edge texels) + const float LUT_SIZE = 32.0; + lutCoord = lutCoord * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE; + + vec3 graded = texture(uColorLUT, lutCoord).rgb; + return mix(color, graded, intensity); +} + // Film grain effect - adds animated noise vec3 applyFilmGrain(vec3 color, vec2 uv, float intensity, float time) { if (intensity <= 0.0) return color; @@ -163,6 +182,11 @@ void main() { color = ACESFilm(hdrColor * global.pbr_params.y); } + // Apply LUT-based color grading (after tone mapping, in [0,1] range) + if (postParams.colorGradingEnabled > 0.5) { + color = applyColorGrading(color, postParams.colorGradingIntensity); + } + // Apply vignette effect color = applyVignette(color, inUV, postParams.vignetteIntensity); diff --git a/assets/shaders/vulkan/terrain.frag b/assets/shaders/vulkan/terrain.frag index 2d3c61a..7ae64f5 100644 --- a/assets/shaders/vulkan/terrain.frag +++ b/assets/shaders/vulkan/terrain.frag @@ -102,12 +102,15 @@ layout(set = 0, binding = 7) uniform sampler2D uRoughnessMap; // Roughness ma layout(set = 0, binding = 8) uniform sampler2D uDisplacementMap; // Displacement map (unused for now) layout(set = 0, binding = 9) uniform sampler2D uEnvMap; // Environment Map (EXR) layout(set = 0, binding = 10) uniform sampler2D uSSAOMap; // SSAO Map -layout(set = 0, binding = 11) uniform sampler2D uLPVGrid; // LPV 3D atlas (Z slices packed in Y) +layout(set = 0, binding = 11) uniform sampler3D uLPVGrid; // LPV SH Red channel (4 SH coefficients) +layout(set = 0, binding = 12) uniform sampler3D uLPVGridG; // LPV SH Green channel +layout(set = 0, binding = 13) uniform sampler3D uLPVGridB; // LPV SH Blue channel layout(set = 0, binding = 2) uniform ShadowUniforms { mat4 light_space_matrices[4]; vec4 cascade_splits; vec4 shadow_texel_sizes; + vec4 shadow_params; // x = light_size (PCSS), y/z/w reserved } shadows; layout(set = 0, binding = 3) uniform sampler2DArrayShadow uShadowMaps; @@ -144,18 +147,19 @@ float interleavedGradientNoise(vec2 fragCoord) { return fract(magic.z * fract(dot(fragCoord.xy, magic.xy))); } -float findBlocker(vec2 uv, float zReceiver, int layer) { +// PCSS blocker search using Poisson disk for better spatial distribution. +// searchRadius is derived from light size and receiver depth in light-space. +float findBlocker(vec2 uv, float zReceiver, int layer, float searchRadius, mat2 rot) { float blockerDepthSum = 0.0; int numBlockers = 0; - float searchRadius = 0.0015; - for (int i = -1; i <= 1; i++) { - for (int j = -1; j <= 1; j++) { - vec2 offset = vec2(i, j) * searchRadius; - float depth = texture(uShadowMapsRegular, vec3(uv + offset, float(layer))).r; - if (depth > zReceiver + 0.0001) { - blockerDepthSum += depth; - numBlockers++; - } + // Use first 8 Poisson samples for blocker search (cheaper than full 16) + for (int i = 0; i < 8; i++) { + vec2 offset = (rot * poissonDisk16[i]) * searchRadius; + float depth = texture(uShadowMapsRegular, vec3(uv + offset, float(layer))).r; + // Reverse-Z: blockers have GREATER depth than receiver + if (depth > zReceiver + 0.0002) { + blockerDepthSum += depth; + numBlockers++; } } if (numBlockers == 0) return -1.0; @@ -180,7 +184,6 @@ float computeShadowFactor(vec3 fragPosWorld, vec3 N, vec3 L, int layer) { float tanTheta = sinTheta / NdotL; // Reverse-Z Bias: push fragment CLOSER to light (towards Near=1.0) - // Increased base bias to ensure shadows work when sun is overhead (NdotL ≈ 1) const float BASE_BIAS = 0.0025; const float SLOPE_BIAS = 0.003; const float MAX_BIAS = 0.015; @@ -189,16 +192,38 @@ float computeShadowFactor(vec3 fragPosWorld, vec3 N, vec3 L, int layer) { bias = min(bias, MAX_BIAS); if (vTileID < 0) bias = max(bias, 0.006 * cascadeScale); + // Noise rotation for temporal stability float angle = interleavedGradientNoise(gl_FragCoord.xy) * PI * 0.25; float s = sin(angle); - float c = cos(angle); - mat2 rot = mat2(c, s, -s, c); - + float co = cos(angle); + mat2 rot = mat2(co, s, -s, co); + + // PCSS: Percentage-Closer Soft Shadows + // lightSize in shadow-map UV space, scaled per cascade + float lightSize = shadows.shadow_params.x * texelSize; + const float MIN_RADIUS = 0.0005; + const float MAX_RADIUS = 0.008; + + // Step 1: Blocker search with light-size-proportional search radius + float searchRadius = lightSize * 2.0 * cascadeScale; + searchRadius = clamp(searchRadius, MIN_RADIUS, MAX_RADIUS); + float avgBlockerDepth = findBlocker(projCoords.xy, currentDepth, layer, searchRadius, rot); + + float radius; + if (avgBlockerDepth < 0.0) { + // No blockers found — use minimum PCF radius for contact hardening + radius = MIN_RADIUS * cascadeScale; + } else { + // Step 2: Penumbra estimation + // Reverse-Z: blocker depth > receiver depth means blocker is closer to light + float penumbraWidth = (avgBlockerDepth - currentDepth) / max(avgBlockerDepth, 0.0001) * lightSize; + radius = clamp(penumbraWidth * cascadeScale, MIN_RADIUS * cascadeScale, MAX_RADIUS * cascadeScale); + } + + // Step 3: Variable-radius PCF filtering float shadow = 0.0; - float radius = 0.0015 * cascadeScale; for (int i = 0; i < 16; i++) { vec2 offset = (rot * poissonDisk16[i]) * radius; - // GREATER_OR_EQUAL comparison: returns 1.0 if (currentDepth + bias) >= mapDepth shadow += texture(uShadowMaps, vec4(projCoords.xy + offset, float(layer), currentDepth + bias)); } // shadow factor: 1.0 (Shadowed) to 0.0 (Lit) @@ -280,13 +305,17 @@ vec3 computeIBLAmbient(vec3 N, float roughness) { return textureLod(uEnvMap, envUV, envMipLevel).rgb; } -vec3 sampleLPVVoxel(vec3 voxel, float gridSize) { - float u = (voxel.x + 0.5) / gridSize; - float v = (voxel.y + voxel.z * gridSize + 0.5) / (gridSize * gridSize); - return texture(uLPVGrid, vec2(u, v)).rgb; +// SH L1 constants for irradiance reconstruction +const float LPV_SH_C0 = 0.282095; +const float LPV_SH_C1 = 0.488603; + +// Evaluate SH L1 irradiance for a given direction +float evaluateLPVSH(vec4 sh, vec3 dir) { + return max(0.0, sh.x * LPV_SH_C0 + sh.y * LPV_SH_C1 * dir.x + sh.z * LPV_SH_C1 * dir.y + sh.w * LPV_SH_C1 * dir.z); } -vec3 sampleLPVAtlas(vec3 worldPos) { +// Sample the native 3D LPV SH grid and reconstruct directional irradiance using surface normal. +vec3 sampleLPVAtlas(vec3 worldPos, vec3 normal) { if (global.lpv_params.x < 0.5) return vec3(0.0); float gridSize = max(global.lpv_params.w, 1.0); @@ -297,28 +326,20 @@ vec3 sampleLPVAtlas(vec3 worldPos) { return vec3(0.0); } - vec3 base = floor(local); - vec3 frac = fract(local); - - vec3 p0 = clamp(base, vec3(0.0), vec3(gridSize - 1.0)); - vec3 p1 = clamp(base + vec3(1.0), vec3(0.0), vec3(gridSize - 1.0)); - - vec3 c000 = sampleLPVVoxel(vec3(p0.x, p0.y, p0.z), gridSize); - vec3 c100 = sampleLPVVoxel(vec3(p1.x, p0.y, p0.z), gridSize); - vec3 c010 = sampleLPVVoxel(vec3(p0.x, p1.y, p0.z), gridSize); - vec3 c110 = sampleLPVVoxel(vec3(p1.x, p1.y, p0.z), gridSize); - vec3 c001 = sampleLPVVoxel(vec3(p0.x, p0.y, p1.z), gridSize); - vec3 c101 = sampleLPVVoxel(vec3(p1.x, p0.y, p1.z), gridSize); - vec3 c011 = sampleLPVVoxel(vec3(p0.x, p1.y, p1.z), gridSize); - vec3 c111 = sampleLPVVoxel(vec3(p1.x, p1.y, p1.z), gridSize); - - vec3 c00 = mix(c000, c100, frac.x); - vec3 c10 = mix(c010, c110, frac.x); - vec3 c01 = mix(c001, c101, frac.x); - vec3 c11 = mix(c011, c111, frac.x); - vec3 c0 = mix(c00, c10, frac.y); - vec3 c1 = mix(c01, c11, frac.y); - return mix(c0, c1, frac.z) * global.lpv_params.y; + // Normalize to [0,1] UV range for hardware trilinear sampling + vec3 uvw = (local + 0.5) / gridSize; + + // Sample 4 SH coefficients per color channel + vec4 sh_r = texture(uLPVGrid, uvw); + vec4 sh_g = texture(uLPVGridG, uvw); + vec4 sh_b = texture(uLPVGridB, uvw); + + // Reconstruct directional irradiance using the surface normal + float irr_r = evaluateLPVSH(sh_r, normal); + float irr_g = evaluateLPVSH(sh_g, normal); + float irr_b = evaluateLPVSH(sh_b, normal); + + return vec3(irr_r, irr_g, irr_b) * global.lpv_params.y; } vec3 computeBRDF(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness) { @@ -351,7 +372,7 @@ vec3 computePBR(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness, float tota vec3 Lo = brdf * sunColor * NdotL_final * (1.0 - totalShadow); vec3 envColor = computeIBLAmbient(N, roughness); float shadowAmbientFactor = mix(1.0, 0.2, totalShadow); - vec3 indirect = sampleLPVAtlas(vFragPosWorld); + vec3 indirect = sampleLPVAtlas(vFragPosWorld, N); vec3 ambientColor = albedo * (max(min(envColor, IBL_CLAMP) * skyLight * 0.8, vec3(global.lighting.x * 0.8)) + blockLight + indirect) * ao * ssao * shadowAmbientFactor; return ambientColor + Lo; } @@ -359,7 +380,7 @@ vec3 computePBR(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness, float tota vec3 computeNonPBR(vec3 albedo, vec3 N, float nDotL, float totalShadow, float skyLight, vec3 blockLight, float ao, float ssao) { vec3 envColor = computeIBLAmbient(N, NON_PBR_ROUGHNESS); float shadowAmbientFactor = mix(1.0, 0.2, totalShadow); - vec3 indirect = sampleLPVAtlas(vFragPosWorld); + vec3 indirect = sampleLPVAtlas(vFragPosWorld, N); vec3 ambientColor = albedo * (max(min(envColor, IBL_CLAMP) * skyLight * 0.8, vec3(global.lighting.x * 0.8)) + blockLight + indirect) * ao * ssao * shadowAmbientFactor; vec3 sunColor = global.sun_color.rgb * global.params.w * SUN_RADIANCE_TO_IRRADIANCE / PI; vec3 directColor = albedo * sunColor * nDotL * (1.0 - totalShadow); @@ -368,7 +389,7 @@ vec3 computeNonPBR(vec3 albedo, vec3 N, float nDotL, float totalShadow, float sk vec3 computeLOD(vec3 albedo, float nDotL, float totalShadow, float skyLightVal, vec3 blockLight, float ao, float ssao) { float shadowAmbientFactor = mix(1.0, 0.2, totalShadow); - vec3 indirect = sampleLPVAtlas(vFragPosWorld); + vec3 indirect = sampleLPVAtlas(vFragPosWorld, vec3(0.0, 1.0, 0.0)); // LOD uses up-facing normal vec3 ambientColor = albedo * (max(vec3(skyLightVal * 0.8), vec3(global.lighting.x * 0.4)) + blockLight + indirect) * ao * ssao * shadowAmbientFactor; vec3 sunColor = global.sun_color.rgb * global.params.w * SUN_VOLUMETRIC_INTENSITY / PI; vec3 directColor = albedo * sunColor * nDotL * (1.0 - totalShadow); diff --git a/assets/shaders/vulkan/terrain.frag.spv b/assets/shaders/vulkan/terrain.frag.spv index e1a64e16663d95319f3d0bfdccc6d1f4bba8e834..29f04cbb2ac58eb55ee761ba7dd724af613acd93 100644 GIT binary patch literal 53192 zcmb8Ycbr~T`MrH$WACQK%wL#QH3Q#y*END~$8 zA_Af!C@3PLhy^JMh$vVR(pO2}A{|P*N0W{reTZ}{X*t@kPRyVVBC4tf zX|M~mY^#eHm&7(@X5YN2dw0*BvBCUlTkWi0N3{g{9FjiE#OHv%-nl(3Cw-P4^jWN0 zF+O`uo>hDFZb?n8y52wN*IBIq?wc~uJ-=rzbuU(}0-xM{N>BgT-hmlC{o9N<+}?rSzItrB80Qb7P45}l!;mM;=$_hl68>B7Y@)udY7Ois_4Uu3dQjh7AWCPo zCi&cXv&Z)K%a^FuhWE}M=;@!?LmX4b^>p3*mK&b)!1R@e9Ho-)v946Ok$ zci5dX7n`v&3`aN|Q1p&!OY#F6ycPNQ8h2J(lP`>C>1r4Fkhb>do;#&`YR}vut+jfN zYu)Dd;gSiBkoAKN>3puczWJlDav zaYqhDK06j{@&aa{fk>_xH`4K4W&zTnDtPTD%%ZKCY*4R?k5HDHEp5nAtn! z1goSfYiq@@C;0(W`vwlLc~`X;`OtcnsM{N?XBKOBFe+yub#+wxT3209N3|b$tIm#k z3^ES;|DXDYR};{t_4mx3-7~WZ(-`YzUia-_>{I&Y9y+6Epc{zRQ5{0Q7ZdM!as+rn z_pCWHdnU}`yy~6OJ#+u=Idzb=uW_(E!HZWPB5&q;pE2V{&zkJnP`fVd$IcK1J@dO~&g&lN8Gq2>6ZWS3HiPkYR0cVhNjA>O;HjL9#_7Dzt>dKB=z*Et z3l|vnu4)?g_Val<+U&mmS=}?OZ;5)JYT_Sr$k@Gx%tU6xwRHmJx@S)AncCNigW`j8 zGYg!#nGMd|;M&&LrK?lWn)b%`Oz)m@3Oh^Bl)({W(3+`g4IeRfR<6H=ZFg1!@Y!Sg z2GY(v_(0!4_srIrUH9GjnR~)1<9nyi7}$rPJg-keo9x~*sJ8Tz(cRN#vt6Fj+86L& zrn&&ViS?i{hrHL&3<!PbV8?C)x=b)uu zz`C#Jk}vF6Yktl{>#pajvpOFmP^RqD zMetUiI;)R@%lY^iyglxV(c0s_1g$;pOVJYdWtJI?d&%m0c-GE=V{?+VVlD$V-YcwU zaJ_a`SHjB}uY$M7cr{vLJe(86bLbkh_86~4ON`f9W-!L#)s1kjv4{6Hi!o6mQ(H%M z19|J-psV^cc<3I!cy%+{jGo!2^qexTzlRk$(9=7c$_6)C>-Y>lp2x=bS@NNK*%H+q zwN_v2C_2ymx_8Rp^39xfRkz~T-+ju2f$n}!DQfGiZfj|K&gQBoza2iczP)J0{}8;l z_U))1A$N~4rbo%$r!4ar`JC?lZubRkkCVG^X?v>8_A9vipSIt$*?tH2;;ZesHroqu z&wFj}lDmEO)V!xz9yVFuV?lY_aNh16s^^l&T4sZ8S`3j_XYjd z1Gma_RbwnOduq?g-IHgw>K;~&L*s5@UiZxUcA>s|a=+OZeca5x$=x##o83E&O_z;q zSff*QJ@*sm^!FVP9>)Eg{`^^}W9(rg8%7k`7R5HDd)7qm>jt$BH15wPdQC{(osBl7 zZ)TtONYT5%)B2`QT&NGP>m7z(-^WfoiLOoTn>KB3j|(F0E(Wg0rigzy+Dtds-q|ii zd|UA^4sO+z5)0d30^6L){S#vu#x=}-Er~vVaG?&G2kTr)JNG(s`uYbZ&h0(baZjI? z2G5)`f3Q&WWg5M&zjwM%2dDK$udka&9u4oRX2a+7 zp4>BY@V>aKnrGSi+S?NKI}NSwJDZ1#(KgSp?BKc3S)Db6v&X{+W^l7Iqp!Zvw4Mu6 z&;H$Wz3y7i6=<`%r}wtbm0{J*@Ooe3?%!L$3G-&**HwKEefGRrV_LV^J|}ckx1eR6 ze-WGs*8c@bV_U8pPXKtxCaGImf1}8VUClte;aT@47e^ZOXj+!K3pLORS+B>snpRsklSHrt!PaoWqd=8#IbK+jbBUtTs9s0uc*iqdC zw^G;1&EP5ZJwr!zE4cOS)K%RDuIH-g-`6bnf1bI9RrjHH&!66`zt->>@BQct+wH6# zY~$Z=;}5m*huip1;XZr%wC6nhTzd~Ie{z5K5xr9fpNU>TZ_RhBk1xV=F1!XV&xO~u z4LKLOs<+XKwO^0n3M{ULb#_)O!ppv`+{RaJE&$wSI;-wI#&EZJFI z)bQ51(^*{#pFaI$T5rX78NBTG$J_XoEq@>5>lm+UvtKiWcT{)6xpdSqe@WYn-n!h^ zz`Ss&A0F+`J@CA*cnI7xd;b2tbH?|~@9`e8vwEbZ9oD+<@2nn$mvR2g@_SOKdk+3x z)w}TN{k>D0)2-GP<)S{NXJ)pTy53=E?DIa#!8@w8;Pd96RNK}E&z&*RA+O7Ctc}OS zan#$=SW7LR`uc6VTNFOy>T;dcHZ7mYsb|~5XH4U>BU*2Nf8Ch<-3eayZQH!VjdoN=fm_e@9o0ne0b}>7>zFj8OlLK@jZYoI zJF4mMjLD24w$AGKHa@eB&mO`%suSURHGP>s#MW7zG=z6mr^4CQdiuM0TUD3osurNt z=T`6RT6OL|hBkLj&y?1cxwRfHM>}C`YyNMrO#P@`U#2P1Ro!lx<{6o)Ywd2ddarLS zU$kM>clE3Hm*(NPiTyG3$^BD(1i-Je`U$+8_s84#6K(v-HvZHQ-c>yfudkI8d284_ zkNpmf``ta7TU*-gs9r)FUthPns@K7se}%oXdIPPD>mNh-V%3}QR{xs!H-qm0x~l(R zi5g@y|QxamluTg|J@<=Lb}R!hgBW$?$#TkBEGI_E%f%7M+_<3S*)9q+E{K(y9~w7TJa;IaDEYvliq z;UsMB@k~Z5=Va;-KCGGsf3J8>M{kek0<@v=aKDVU@LlMz>N9emSLz$o+_QI9ccM3s zVD(yh4L+SKiu1V?4@Nv6k8QpC?y8o9*Y6B`soJdhmCGV3N3`xKQ|8TL6By{3%6;bm zFX~$lXr9MAVIS!4o;{a$uH0WvNn1Okv!D3(s~&^h;jMe5j%sghbxa-Af#BA=i(%Da z;2Av*Xt^hJRmY;+|FMJhbyhuU#r?@N>nZv+V+bEsozT=#cEI>AL$BYLF#JvU#LGJx zb)S`**Z=z7fHoDsz54rRHIGqvc2+l|Pvc#D&gI){KR=%=ywTF{4)ij{ufhko-JZ%- z+&2BTG@qOPh2J+is{7Dd_W>Q%kH7~sbLlsz`3JOKx*FRP@YXw6uK2AN&2^nGTE6Ay ztpHj_^=Gt0#~e`G{f;&NQ10@1Floi#Re#r-Kc<)QXsMPNzF6KXc2-M+7ryuEtlkGN z&x=jqt+^@Zv9sC~z3l(yZG6i%zI7Ylb_nmPc7WH<#uHi#!7<)%h)-v={}A3)9SR@% zj;^yh3T@&0wyx@E_`C@dMju#@SZmB?qP5=7b=2Sd=8rzG9>24!&nauY7OAxbmS-LZ z=Jhvgg8NL%7w--)hUbdn++PnL`mT0Z^-1eFBp*y#!8jLppy$5tZt#?*aqICr zFTiSkmz-ZeJ>@#xS>1zH&c(fL{Ju8+KpTIsjeoC=|DcUO)W#of<3DQSPqgtT+xSy$ z{8w%K*KPcnHvZc-{`)rmLL2{M8-J;dztYBEZR4-C@&C5*|F!W&co|mCPe&W?Y~#b) z`0zHqR2yHWjW5^6-`B=hXyYpl;e4BgFMQwDy4Gzl#HX{`sEu#j#y1_pIalGWmkq6x z%eCyc_4!^I2d_1J|57`A;fG82L%+Sx-(zO)94^aywBEZ=n)fh;eo*};)L0j*jzgbE zy>n*r=4ck1;o!MHteS{z|Gud`#Vc28>Z*13%vsvVeaED4{f^0Va$+AJn0Yv27w{dT zHXi-Cw$D9bV)LTGK6RtlbxfSv?E~OA&hfmP-J3m4?9oNqo4>ctkAe4t+g8q&GsyNSD|s4skH|R%{cUXywDhmT6?z8 zeE+N8-wMt5zG@D3V)mV{+H!@qRHLm`XujXoZ;L|n-LBdwv~i5N?{!DOJ;K*+(VF}I zQoH@vE-&pXF@W}YRBi7Ve81?tr*ym#+=?(BF3t+MT!X((Zc$?bfMX zUfO+UpxwFCE-&rAFVOBhYL}OGe^$r%9Z&7v%gNI}@3Z8K*7lD2=XqlDzDnC7wXL)A z{>iq;>bm4*Ti!qEpT7DtL2}opZF?_a`I49AXJOYrW3iy|Py6!H-}?srJ*?k*1nVao z( zNOFDDSAnnF@Wi=Z!_`L+-TLHd*Rr;4uHOi1+MHZ_+A!D0vfI6fEp?2b)NXBdZN@#e z(d8{|v1*?-U)zcQL16nVS9g35Be$O|-c=n1)+gm0zvJM>-br$7CX!o5-8d$ZYgbP_ zJ>b;S(uP;Zw`uBWX9P3td$5!-#-xQlOIN2AJ}Do2yT5z{XZ3|`W#VUEmL0(k@rlCs z|LxaReG0$KXRDoI)h%d_N#au9nDNr@3#28EIDU`tFTyvx^XWY{Vf-A+FEu{u@%bwF z{67yLsGtNb9}rsi7= z`fpqF1-qvG?P|WY|s05j{R!*Ij>C^vkBhr zW!Jzhqn|d{@m=JOeYuYBMtghx6YozwUxq(>@T2$T9Q+DgANBZr75v)r{ex@gYw*>6 zIrjdP`8r%5^_2Mr_?qV*_)hpY;U~O0Yp~3>;QFY?=N|BRH{3P2X1@(z>!hn#qpf}9 zUbsH$Df1ohnI|1T*q{607asM>V43^j`lzSO1K=kgIB2lUci{&u(>)m5gK&M+Q|5c% zNtd27I0oN`zk1u~-4ojn;QFYi%tPRl|N7iue|`wxANBbB6#U$M`wq7AIQ-$$_81(u zpTYG}Pnjpc*W7*5U^_pD-?sZ%gJbX{Tp#t6`33mN84JhyDfmSz96wm*mvDX5Q|4FT zEj~MbaNM4T&p2eC!7{&w>!Y4B&w#Jl`~8DuegmI0b-BSY_$^!?^_2M?c<(=s7+eRx zhred7j*J z(>hmW=lL_a=RH|{wCAg_d%TLMpXLA3IH2 zgu4&BSNII;9;~(|k@#GxR_?{Ny9yeAYP;>qecn{lzFLE=EA1YSw(>WAwnO-d@V@t* zyHog?@Fjk^&fwm5HvE?B#}4vy;Z=3xsQ6z5zx2U*yV%~xz%*4~yNyTgGp$L_u>T8R2ZvcPn(eZ=l?m%s?2JQF4pIP>iQO5HV zaN@C+9A!K|#YaEmd7Rus&3NQK<9j5YXW-wRec9l-_Ivmy8@*j~$KhXapAmCydI#>k zMN-CXIE&9`aqsjqP;%cT zc%)xP!&!&BChlY4YDv*Qj^;gTQu=cvdVRg6ePe$M&OGgAeD8pb-D|b+$bEOAmU@Tr zaP0kVQuJ>0+$)(yZUrHg%)`(8#Zx)i zJ{~FmIrzrXTW9GOy4inHElkAgn z-yf-^U%o#|yT;-Bqj2|C-yfCS_ebH5o9~M})+8F={dmoAE~u(9hDm7NlWVh0x$n=_ zwxf)D1Kq6;<9Z9le(5`s-0!`1J=|})CHGry$^DiaPBZm4+>-kZx8#1W4POm^zuAWS zj>7M>;jSyc#fIzech+#@UAo}Dv+-MN?DDk>uD{=2W4FFd+qmCcOS|7(!!7SO*Kq6i zn`^jszqyut?}FQ&-(O>w?_Y4sAKJ$K_8NcferFALy!^fzZhd}74cFiArs3NCHd=DO zkCxo;o+bCYXUYBUS@H)8ZhH^5ald=UU%THu!;Q!9p5fa4?pbobdxq=pch8dh-80+g5ZaQ*%6S#rO7mfY{2;g{q9+Ezk8P4@1Eh7_q%7f<^Aqia=&|q>+g5ZaQ*%6S#rO7mfY{2;gmH_V8LwrZ0DFIXH#?p8OIN_vI>2w@e-+r{ZTz)eNm6rf6k88jbuE8+O}?k8=Q^;O z`~Sc3O&Rll618nz54KMCSpBWfHa|t~VVnAVlB8yv;`H~^VEcO@^*IhV!qrS3?&lec zo6s^A`X%nqfQ|cHq9Ch2%U}AcJ6`VbYVrRZSna~b|5mV?_a95~#)Yi9jlXPbW0LmV z>sX)H2<_JAwea&`pBK`HJK<_359_hq9dK|AYe-mu{-YaP{POo|D zDf2C`+Cju+nQw!Ao-;+e7tQ-MlVhykcfc8A>#&@DX+yhhxW?}XyT<$A!0G}1sSI1veKUT9|GHk_VoLQVEcV`)9;7D`l(;i zXpex^x2011<^6%0b$Gr%3jQ(4_T`U(ZGX?kW*P4x)YGq@fXnm!r*O5L?>_^3xDK>E zPEvF3#i`S02-iVu=GvYh_uhFlF&#lLPlCsj`#$m{l5rqazu+%z*)N^~tJwk9N3xM^jolP*%$q7$5?((?qMwY z{Enn%EaHsqvtY;eOU$3=*dO3(j;(E`jCtC84s4yqp}+On<_qL$Q=jKaYPKo1O`oa% z2!65Qo)>=tyH1WE2KSej!1}0Xe$*`Qn)wUZ=aH{w~<|tl#?7Unc(_$+|43kK_Mma&^Z`A2%~K zx%0IM+;iwab`fJ)6s~6SFm_{gkI>$Mu8-fb5>F>Q?-IDMspkNk7suv#cseP4SprQxeOVH$X7aEN`;vB-LRYh0eQnog z8uhHNWx%el@MYopTtOVj)pB5c)R&@TYRiMIcZ0_6_1L<7KGG)k*=T>9vah@!zAC}3 zhF0z?E1+x3zOo`%%{o1AR|b2yS7=*_q~>`mPW`KZQ~&+=+aF_H6 zw5M$y(z+zu6Fbg6b7nu(Uzg%(zJhm;su2XG3H_Kz&3hbKI<}ETT zq_n>iy0)ypoxy6JK+f^5Vh-1owp~bS&Z9W>?*_L18yTOn{@u~FrT$T1wWH~Tt(qN!&OJxJUn+rym}T666C674&( zB3u&(gFi@aJK9_ady}g>*2Zjn#u;1onb^#;HYR{AudnZN2c)N zXZcBB^_1@htC>73?|r^>GJjdWKDnow0yZXXxi6UtR`w(+0_onBNXKbdSYs>TE zbg*r1Msf_SOCQJLD01zVbq|;UF7Mxa;cB^mKLPCF`Kj%AlA7}@PMx;n8i>t2@y!O$ zDtI5*Jt@yVC&Ja{kkW^Kuzm2ESG()THe#RK*zKd=89c`<6Z=46x12Fp#&~CvYtOjO z13RuhQACY!uZ0bN(stCmT*y-N0Yv zoPQdwk9zi*8^P)xj^FiQwXB1iz&<~e>)gn5O!D{x+Sa7dyhFe$G$}M2E&yj9n z?tGrT6>M2;_Vadf5BsU@Hqv7x`zg*I{CTir?mbx6(Vb}OSw~+0tC>7-rIv;3=!@uH zH}fp>CAekO^ZalZ*m6EUXm@<>AXiU6zYMmY*>}DIwv79hHuoj>pZI?b?W?4$pRa?n zKFjs<4Rmd;bI0?WV9TmomuvsqVAs2G-9s*q?K|MyFWw85$96y1epv24a(Qgu1rKj* z50K0Cdyw3|>1RCO2OrwlzDF+C?+4_@n)-eS{#Ijqh+LjJ9s$p6Y!8#mWBW1qsK)jq za`}#Yjkn*Af-fMq&-Tqe7^^WFi!s=ibz0WA?k2aK=br1#HRak$dp`k}^YT;pd(F$^ z=-M(bKLcAxZSRUIiz?qjP!SclLOYqVp<9&);9^2F4jQ6j=a{YcyZVaja zH{gu-Ghlh@_#HUo{adg+wr9Z^@85&vJF==B?>~SqC$}%gY;4A0+qPj{mdQBlUyifm zEx(T3aet0H@xBPoSiJz2C#FAvGai2g%VT>Pobh-GERXHa;IER5=M{2!+WQMQKQY& za*P}+`I+R7*+0mw&wI9ig3Eihf5Fvq&-NDB!+liSo1`UqiEKUM#Px5mu+#{ZE>(;Z=HURlkdn+kr?w5;Ooha*SKuocCFuf^w0e0>wGx2j-%t5 z*p>#T?|$QxC$?qfP5XZDlgG9^IDPlqpFFnrgPj}O_dB3G?X3vTSolp)uAkoneIK1Q zyfWN1Y)rOo8`fnRebd$|;BpPG3b(9!d_Dkn9J7X3gX^cBF;XkX$gwKduxmMMZFPKI zYn@z7%In@5=-P5`u_jo}i>mhsYl%5r+uA-zQgcqk#_N6M+Tik;ZXLLq$-`@OVi}2M zEH`pLP{y(@y0(0GSr4pcI-Bb<-Ko|GTemjH!Edf=`L4Dh*q>Qa*X}o1f8H!}xd}Ya z?|zS!>*u#v%kD@k_GdHj9pskRPrH78^VOF6HV5Bbk8|rjehauh=g_~*<(6>u%;i>K z%c*Dlwgy`+_fy+|)l43apZ8OagXhw==+>j3aT$Y}^Pl-K&zNrq&X||?<=dlc%lz*E zR?GbFB<67bwe3h!bNM02j3^}3b#FfwkUnvO-xE3cL%GPJkrOE+bDFOThF3x z?}0|c)zYUi;Pk0{j@$!XTlUYfmLaKouW4O-f^E<5^xE_NcrUQ`kJ@vOIj-^b9#eas zjq|>KZ!~?9Eax2U)0Fd>+3~Tz`ewegJ6{=3^Ym?Buzfp@c`fH_KXh%Gukm2D%+~>8 z4(ChT{v%J{$~JOG+OOL35n!gWn7fgzK9=Xtxh(!#r!^FtBSP&pwC4 z)jXmd(P*|2?Z`%Ry&u$^k4K?dUVrV*kue+!*7jeb+=1lJfjPhK0UrKr*!KA0Qh&#G z9Q;{4V3 z2kWOT-#@+qwk~a{>ziQfPF>#u>t{+`_ki`&miW|0(=XpIe1~Km<$il#qsQlg#-}`& zzKf=wbLl~_<Ik%*Zx$&wg0N%+J94U?Y}Fy_UGF83k}cQ{19$GvQ{1jI~JDJ=3Lk(^_2M$ zxGeKyc*akgW$d?l%KQXuJ=*NEJlDLRf?eHglvRNy?0IT`$@bq_#)KcFs z!Pcj)-7!^5eNThS`ksNS{hFk|W2%<=ehaofb?wf9TI%~fxUBCFaJ6Si`a1_|sqcBP z^{H!j&eT%hi(tnu{ErRKIsPZOKI-v#rSb6^y%_k7*2c8qf#z7Dobd9JppQ`-0!xV?=x(bUt%Ti~=|eb%8*>i9RjflKaI<`2X7YA~YVmbu3zN&(Y-@?mSu7&q-qIF>tYU zJkjWu&wJ!fu<@Ax%ef_AjO5`pad=}lcOI5QUmR>5;Y)z+*WIiq-_b2a(nsBExV(#G z-L@spoGuMEPPu(t2JU#pzAW6>v!<5=>!WVJ?7LddtM`GIp}czb`}c!wM_cM$0qj`0 zpSYe^gsbVBaa4={N?_wmoh!rDGQQf>;=c;mxZ}SnSj}@W^P%P#JKm0qx#b<>4}cxx z1;i&`9jxYeWXo$)vwY^z@lE?{fbE}aR9vpnRnR>aCuNPU3^s;U$z6x8v$ZH!uDkUL zJ{mlx;W?K^z^$h|=hsG4zqlFakzloTNVA*1t_xPTU$!Yvo$G_G^L|qLwgH-Y>f8{l zR@S)@T-`d2Tb{Aq1#COvyMpyOuNmvzwUKrsW&B5hEvL;o?fcH8#JLApO~1rB7Od_Z zh|4+nApP-Jo#Y&>M!rUY*91EsYmqx2+Q(5x=EL)TLK~mj#%H(jc?BOy`O^w+eP^{Q&m8;r78X@%iaMlK21RJNJVcJ`ljlZC_jJIu2}nWnB}|)bkCx8_b_-1Cq8$q+G8jgSDITJ$MS(wzN4Wt^>7{nhP*^LRYTaqA^n?l}BTX!Nu(6Ru|RFiV}YAl9po z@w!gc5b#?3U994_|hrw#`zYwf8i4>oYfc0^{Y~v!ZTIusqxH0CQ=wo31 z)W849IxVA*F}N>iPyH8z)zY_1!S?MEQrWl5;M#J{yc}#8^@+^D< z#Pa&&S@Y9i{?yO1+SA_~!M3eEzBhsSQ~PRn9MsBj*c7|R`lMXX-Alywal;0?r*Bl~ z8#j18@=ZvNoA=_m551Xo?0@bDKG()?DY(~ZZ^gXE$8^Nroyx$!>fPOx?4Ui1rKHP@Q=qVn5F)@9vd%dSh=FM%D0 z)b$+Px{S-d{DI`Uv9B+J)%4Ndarz@z{c?Qde*#;lx;~y;FM%ER>?tq9^;5SU$52iG zSIPfMdadEs`8Tk0n>JsETPAz$8(@9Z^>Ix84tC!2Zx8+fSIfJqe}XON5$#`%rkwX{ zZ$k9dW*zxiq_@DvsXgWY4OaKCU(UT+#^G(STKIp!uG8#6|Ani)L&|%?cfpp^_9k^Q zef8nIYWp8a&3P54?nThbwibooj6H32z}56KR&8n-zfQ1gH1~pCaJALY^w*~5noRrV za@VWzn5VvB*j!hsZ!x%9>eHrHo&&Delpl`GHt%4aElIvOT+Q>waaj`V;kam9f}~~~ zV)qdL-vqgO#%UR_eNQ{f!fhw(W;wW8eg%+qA-{|Nk!cBJW|hAO&w( z;H?_$9e*tR8i@0m#M?^MZNWIZDb?%v|>Cy8A?sf|x<USzeriXzgwi_{!Wo_+w*scgj@c>f;*<)FSvGp zmq`3=-`^z?uHD}yQgVNnNXcI)xb^!xL}E7{e~(DG*Mip@p0)J>xby5if&ah8>Lh>0 z+BK|AzkR@KG&Z@{-ZkN_q3kmsgsXW(TdUD1KB$doG+pyPer<^5_1Erw)a9I0-rueR zw{3kq$6bGF#-V*(uv(sD)&r}VJZxL&^}_L7AEN&`7_7rk?5`Vw)sj5V?DIxw z*00@IMv|*#Og09q<$h!nu$sxkSaQAD6ivHg?zcB+4}``lzMMR$$Bc{F5?U!!2W6meEHoWwrsQos8ACXzKaduzYFJy3lIlA8O9IBo3&Zf|R6H1%9}b^)uoKHQU2k9mF$Z&z>~6F&OeM&jBHtlhXq zk$V`I`tBq(;}R#X(cp|*#$b$gQpR8pu$p6FT&c%Aag7Drwz2AO8;NUAuy*4bNA6)< z>U)vYj7yxj_6E1dwGWzl#%o`&TE;8&m?y6Nz_x9y`rAh08V}ZPTnCURF7^FMYQ`l_ zTnB=UtGtgt2(F&-IvA{$@k%}BiR%!sZ5ylpwvo6dfVCUfVdRNR{ZNvcafuVx;o$bT zjzCk-cpV8=%Xp<8^Tc%&*tU&Tf7>vQW5^SSK1Y+(j6>{o+5XDa6W4Lz_P8dZse4Wy zOD=cqc@NnQ-kLnmaFgMylJ@7k)0QSrTqEevq}1-`~~2$ zjSs{1Q$L&QoY%t(!M3f<6BeYz6<36gqZyb5fLu20MBlNhzRZ>KGD>-PTQ zdhoTRT+=@ZRx^26r}bP1*Ou=ep8~IqO+EARX|VbYg?1xYeG4kGo~yxX>Dy<(YFUS$ z1>5G*ByE`e1#v8P~1kUm|6%x*P0Xbvhj=_o^?WX-oTG0Z$@jtiKA@ zN8NhwB3Fz5*THJ>{{~p?WyUBz--MUz>RWLA)UESt@nLMn=a`(fvrOoewk0oh)kX)|K^X&&!<&Hlk!i3jU)UQV8<j2_&tU7w_2DmIwO2{55BZ-+)@9vd%erR&3cj`B*7Y~Abs5*| z`KCj8f^FOfu>iXD6nyk<7ycb#ou9kQg1s~h^dtL8}TSM&dJ$=dJ%5*L$=7S+o40X)e{1}0-{0L@a({Pg$^G4};kG}j z;f}TIeb?X`zY7uIj}m|vD0RM%_bI!!6XC1KhrtD2#>{6n_B!g0=t&NH-_tz=h{uc`l#EN&B#6M zi?&TkYW78(dbR+U^==8b-kcX(f%Q>OnXSRb9=;8@EVC_KAN8Db+kvf1oBeYgs#%9~ zyaU)V%{jg!T+K1n$F;RHJZsB!DvxbfaMqS8;&LAwL;W7Rk+P5NUf@v;b}t!Ca=q+9p6`_VQeV!0eGX{j z{*LC-encBTri~xl@Wj3!+;+pqH$3Ob{&0QN9UJ3Oi~oUOweW))p7IC7^-;IH@vG_Y z8k+!iJi-r!kAmkt#bI!L)N{{xIM})Mo>6=5l^hFgiRDPJTKG{7Pi#lS^-(uA$4)K& z9|9Y%HpfvO+i_t1!zY6E&3QZttmc|ad);u$Y0I^FGFV+d+i`x>QtuS7TI!ezzJa|@ zJw82cKGWd(si#i0_)iDhpYR!A+sOF#f-}C_PNco;E62kvuaE0pUat3X^uuE;$@T7< z-Lt@ZfnDQ!lV^?3qK1PyDmt)|)ZygR5mstzRwvCxX?&`x~Ayo(tDU zJ^h{sRv#carpBn2I!*%HPi@94kL{Gk7Je$+@>wgVfsIu?F`N#zoVJuZ18lkU^GvXQ z>bC2+sKx&*u(5h8*IdIGC<2=jDc|L%6J@z4GU)#69`xSV6gIx>z zlbrtp$us}wQ+GN47q#(A+xTT|{Nru>$~Jyg8^5OEj*ENH1#n{z|1jM6vj<%W*GD~L z_YrW$PJ8ws`=Tv%eH2{Q{V}-Xk-9I2>!Y5!F9D}+?Wx;w(U$qV9Bg~ey<;Me?Gs?f zTASk|&(COG0rot#j5d87WA&838f<&o96xz%*Md{GV<}JF*MY6uGTJQfe5j}V^@2a&SJ9Ngg5$PXd8#ttR74a?j}9qQpXH9X%ZKLht1 zO!C~c+-K3X>7RIS2HRfF-_OAf#tWs zZC4-9b+y~U_CcFtBF~t3?i@k#IE<9@@o=!?a3s0snqzY(<;rvIOKtpX1s{(8Jq^#e zeF1L0zB@IpFT(Aod!IJv+cwlk6T9d9U0}8H`_+4|K^Sf;3>>F_P%+EK$nICOSG5*eB-U)xJvHQ%WukAQC>Z$YFVB5|$ z;yYlqdr8il_pSGV)om>C-4AzM?W^_5^-H}EfNd-Feiy9fbw71H2)7RVXdQC>ti$>J zUSoHyh_hClvk#Fxjv{5P9Nl2&>ll*j=2-IV(GO8a){X1pk%GIf9&LE~@I$!uNzFQcO8x}tXARf?=U{yj z^~pTUI?Wya6^2ZP}mx4yO6~xy1cm zAJ>O^)`x3-2Fas`l=E*|fv1CAAHC!`|K6pJoPVC9i*_#LuBl;de651J{?>2fo44_8 z3O*A59Sd&xoeFMyyA)ji-3zY&sDkT1rr`STS#bULD!Bf8H$3lr-lYHjT=l=m%QgEJ zTwA_{{2Q$1M7+tl_a8Bb?`5>TO;U4w#m>(=4OdTl{{yGJa!pZ9r7d&52t0GH&1;Bz zk@KZ3^>u(#U%A#g(Y2+%E^zA8<~_OfIp5l{UoHlA&A9##pupmA>-Tr`=;MC21Xw+L z*pgs1lZWk4eSII}x?Bp~diBd5xir|ebDy*fSS`tGlx3Giv%GfWbv>#j-sQk*&Xd18 z=zSzL$NoCv_5I-c8$CahvI1Pqd0dIy!+BI+k)-B4iqp=@VB1OjtAL%0#J?(BE$O}{ zo)4f|R(s-E4Xo~apR{G3pIccSY<#X8eT~Cd*CbD@`m8}xGgfiA=F}7W2(Ymy*0sU) zBsoze_a4W39dv!Qr=KIiWk1)2t0iTg)OBpTi$(0E#XvukQq$ix zzcbjFw7F-=Q`fFw_f~E0U-CS+>;|5K%`)2baSv2a-J`+At8EmyJaz8@PTga`^3**R zY~7a8W_kBZ_0&BMY+tnPMJ`X>`+!ro`=UH`?+dnW%V@Lwp5*G5_kLtQuzd+154QdA z{lS*Wx;p@D8U3_n4?9qeb?6>^KlP^kLFn4@{BkhZI@De3^78sOhq4~WlXCq#p};c> zJgdO78$6P{kL3AuB6*%|4yB!3BfW+kRdCBpEV%YbZG3XU^`F|trx)DvGurs^ZG2|I zN8&%b;FdqJ;M(UGT>D9F{M3T$zo6jyf2@sP-f-v1ed92=;~9Q9+%e6*as*r-_1uRa z33mJO`A)m*%W+muJs$!)e_21r!oB}V@|nx_k3+XE-?<+Hww%7!>0D_~o!wy9fi~w! z9@`YKF=%tn8Jz&>{Hs??7{~4rh8h8x3^;u54ey$5`slOMTww+^nY$t#nL(4he z^4Mm99Zzk}y*zRCffJYOK%Q@EbHE2s&bF-6view$YfgLG=m*>P@VQ{)$@`=MxIXH+ zrsLK}JPqtvhMx{L{@h2O z0oF%7ZJr6Xp6nfGfz|Z24cEL{{LcnEH{s{N%l@AW*GE15KM$P#Yq$T2>3sM)81+e= z7l6w)J`8tWGsYLf^-)h79|5Ng?PVJm!EHyMJU@ICte*S31z@$ra531v%U!#dfc42a zb}3jbDcASQ&@8Ln@pDg5OTC`}JKlM}bp`n2Bz67WGt}aLC0H%|DzN9e~-W+@;qzZK%2RaSm*6+{O&gX-G+(Y2-A7r@q)cD@K!Gsdj-FTpLREq-@_ZBv`^IL2zJ_sd|l(X6wP)b$ncN#tcL zUqw@o&(|8C^4{d@XzKC#M&naHgMAZCJ?HYbz;wIbFDucX! zFWS<#d%+gWyYpm}>*OoE6AFO6@Y4ZViVzpj<(x$ew`5@SJl=q+CgImsPv2!KS z$FivIoz80O8-KIl*8iV2 z{%#xRG#%34j)rHBe~Q0z;`}=n^4NX`c8;_;F7nuZ4tCD8IY#o>egSq4wK-mLZSKLp z1TSc8j-A|}w{^Y!3hXn8aoC>qT3$cva_;m=JHG}SU-&a{+s*yJZ{Yf<=e+za*!Ax{ zmv+~`bvtj`+>d;3^Lw!8{%$1e_ZjwCH1)i<`2$$ZO}@UL+967`h(8`!eijLr40mRSA{&Kh&=%Txb9!3(I*a-IkB z-1q(q{2^?X(WZ~*l6uPj8*F^p``(5-N7-B7g6m^h&nxYT>wnGd2EYt4kc#K zFL~lx6zqFs%V@K1&r$W1?*x0!g?GV?-8JfbECx5ub*%#I@3Yp|c^M9N@5}fu4%c5j z+7e*(4R{}rwYVhwy{=_Tp=--EV`;FOWwY*!+TzxdPbn(v~q;5qw@T1}nk!Q;*Ne;PVTgRp9!m$7fZr^PX}0 z09-$H=gqxRE%B}fRtsMp?0Qc8YryqU_j)KVuZNctkH>|iTn|4|;ENhOlKi6u{#b!8 zZm@g*C53)zfiDAl-TOFsu6t_{OL^TJQE=N^r;V>$aNF9Tjc?e-H*VvbwedaM_<;@2 zSgsAX|Bkz{$;HOyBFp(T67G4gk2Zbmn|kJTJ+Nbz^L~AJ`+2XAW$mZ-#IX@L=e>QD z$F>PLG1^~w;@TAKd2bnQ*6o<7r~Kw%=R150xUuJa+7g`eUR!zIZv}VmvsSl;>#rVd z8?btL-fs(kuk(I8bZt5Bw+E}0=lu?F^`ykPBU;XT+te?8+6nBs%sH_$Tz~cW>;iUd z;e_qrF@7hPLo*$-@8>e0r7)ysQ<{o(I* zFK__5{@U$}=bc){{vdF<1`meolW{);tdDxeYXaE)DDfN$*H1k@hk-rkaxZW=+$vQb2tdF|;tGwJ_uctkaPmr>|UeRFpxGM{MRe`T= zu>0IKg??>;uLHZMev&-zBaWs0a!;Mq#wWM&scn2(8$Z2`U(oQ3@o{kD&b~Gg-oCHt z3qs(B5l*nu)G0b%_8N#qz|l~l>KQATJ|T~)i1H12zHLMruyOftH)<9*gnT+ z0IZ*S&Xajy$3a`hVLsS3ll6WQTtD^roD6m?#pe{be(Lc#73}=Q=QOZ>>duGjMlJi( z8Q}K)=}a{B>`!Nb)l44k`_lsS_u8M%M%R{D&H-DOdbD%F>Mv1m=H@*3d+kr>qwBBT zzPRSqGWH(^XKi^ed?CE{sanq}AA#$mp7FW}?75wIJ_^@QJw6`;d+ua^x)|>Mq@Hq@ zfGw-d*gXH#65FL<$3FZrus&HQmxJ|DcYl&+fAacuBgx}ar0h>OG}!&=(l+WNouwyPAvX=8Dojfyo}8lZF4#5`YhP7bDUP=qJ1;i*zAus{rrD{J_pt&cWvGR zcK-5j0dIw?c|^Od(I`Hs-QH;4ALQQ{-hpO${k6L`FRxnvFVN@Vwylr-a}B5&hwml6 z09MPn{zb5w$-}l=_ad!#wqHWm{~Qd~;o7_luAcMvZm?QX-Zgv~&HA+)i|bDtSsA zsHMz3V9U7PQs&!m%NUns^ifNhd%M_sHzx)7f+pcl_Z6k3#1lDd`50iTsm--J$ zYQ`l_T#tY=ZW)6gp{ZvKehgM~42&!Fm?y4B!M1Iz`rAh0dJL@HxPD6RVO;7zA*mUc zIB`7=Zjb9{XzCfSC%|eMuhe6nxPA_{ZDZBnHWJs9VC}~B6nWxO{{>0SxWtL;mtf;6 zzh(RiuAaD_2CF5m)MK8wehs#5W7Xd_jN>=ti9?@fNNUC*_S$2;a&_m-f1~|7aQSbv ze-Ag#oU6}*)l43q6P9(4`~%t*r2PBq=fG;|^Yh^LKEHsbes>x^~Azt-K!JY@-}wN!wG}edRL+ z{w&xr{2Y1i`Cp?B_oSQ$e}!8n{BI4g#5s{?PP~@gLGrkT5JIVR^JbB)8 zEk%9KPv&d6HokJfy}w$m;MTc%8z0feH)!JvMgprEhD4?VJ0!-@VoWJHP%P zMEZD6jzm+>{pGq~HIs*ZAjEo%os;#@)%GOmo9}V!gKbZ{v3PE&Wo>N)&e}SsX&J=!)Qm-{Y*tlEsfZYH;kHhnfAS5JSp0NZByoo`Dt_4Icuu$svu z{Y~6kqg%&~okQ=bw?WsI=gV!u)})pU=lN=A~aW7I6yAS30ZWP$_*4+Q^W(@frBoFWX#x{2I ztkXTg))BrJ*mZk1>&~As9|zV)-917+nq=L!B~ICWz}A&Dzb{xVYhpjJ*M?j#$HVne zkI(*K&xQ2;0I+`Q_IGb`HT_-F2Z7yp!ViWo)!50ZL-@-wnb!$mebkM^It~R}k2c$I z?^bgyy7wLqRx9V|2)KIoj3dFWKd;5wyx-3MQ+5- zSI^iS2X^kmCxZ1!y_3NDs9XM6a<%w(gVl1cI~lCz{~_hIU@F+ddDb?Cq~<(}Gse@w zj)!ebBbTR*Ua;e>Z3ekK=l${E8>!zi+VtrmS5MtD!Ja>t=yc3jhskAUq*o>MLY>*EpaqmAacMf+Hzc}>mp T&&6n#*I&DJd(Wiq{qz3^)u?E> literal 52988 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 diff --git a/src/engine/graphics/lpv_system.zig b/src/engine/graphics/lpv_system.zig index 6c71e03..5a82905 100644 --- a/src/engine/graphics/lpv_system.zig +++ b/src/engine/graphics/lpv_system.zig @@ -51,9 +51,10 @@ pub const LPVSystem = struct { rhi: rhi_pkg.RHI, vk_ctx: *VulkanContext, - grid_texture_a: rhi_pkg.TextureHandle = 0, - grid_texture_b: rhi_pkg.TextureHandle = 0, - active_grid_texture: rhi_pkg.TextureHandle = 0, + // SH L1: 3 textures per grid (R, G, B channels), each storing 4 SH coefficients as rgba32f + grid_textures_a: [3]rhi_pkg.TextureHandle = .{ 0, 0, 0 }, + grid_textures_b: [3]rhi_pkg.TextureHandle = .{ 0, 0, 0 }, + active_grid_textures: [3]rhi_pkg.TextureHandle = .{ 0, 0, 0 }, debug_overlay_texture: rhi_pkg.TextureHandle = 0, grid_size: u32, cell_size: f32, @@ -76,6 +77,8 @@ pub const LPVSystem = struct { stats: Stats, light_buffer: Utils.VulkanBuffer = .{}, + occlusion_buffer: Utils.VulkanBuffer = .{}, + occlusion_grid: []u32 = &.{}, descriptor_pool: c.VkDescriptorPool = null, inject_set_layout: c.VkDescriptorSetLayout = null, @@ -135,6 +138,16 @@ pub const LPVSystem = struct { ); errdefer self.destroyLightBuffer(); + // Occlusion grid buffer: one u32 per cell (1 = opaque, 0 = transparent) + const occlusion_buffer_size = @as(usize, clamped_grid) * @as(usize, clamped_grid) * @as(usize, clamped_grid) * @sizeOf(u32); + self.occlusion_buffer = try Utils.createVulkanBuffer( + &vk_ctx.vulkan_device, + occlusion_buffer_size, + c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + ); + errdefer self.destroyOcclusionBuffer(); + try ensureShaderFileExists(INJECT_SHADER_PATH); try ensureShaderFileExists(PROPAGATE_SHADER_PATH); @@ -146,6 +159,7 @@ pub const LPVSystem = struct { pub fn deinit(self: *LPVSystem) void { self.deinitComputeResources(); + self.destroyOcclusionBuffer(); self.destroyLightBuffer(); self.destroyGridTextures(); self.allocator.destroy(self); @@ -172,7 +186,15 @@ pub const LPVSystem = struct { } pub fn getTextureHandle(self: *const LPVSystem) rhi_pkg.TextureHandle { - return self.active_grid_texture; + return self.active_grid_textures[0]; // R channel (binding 11) + } + + pub fn getTextureHandleG(self: *const LPVSystem) rhi_pkg.TextureHandle { + return self.active_grid_textures[1]; // G channel (binding 12) + } + + pub fn getTextureHandleB(self: *const LPVSystem) rhi_pkg.TextureHandle { + return self.active_grid_textures[2]; // B channel (binding 13) } pub fn getDebugOverlayTextureHandle(self: *const LPVSystem) rhi_pkg.TextureHandle { @@ -204,7 +226,7 @@ pub const LPVSystem = struct { self.stats.update_interval_frames = self.update_interval_frames; if (!self.enabled) { - self.active_grid_texture = self.grid_texture_a; + self.active_grid_textures = self.grid_textures_a; if (self.was_enabled_last_frame and debug_overlay_enabled) { self.buildDebugOverlay(&.{}, 0); try self.uploadDebugOverlay(); @@ -246,6 +268,9 @@ pub const LPVSystem = struct { @memcpy(@as([*]u8, @ptrCast(ptr))[0..bytes.len], bytes); } + // Build occlusion grid for opaque block awareness during propagation + self.buildOcclusionGrid(world); + if (debug_overlay_enabled) { // Keep debug overlay generation only when overlay is active. self.buildDebugOverlay(lights[0..], light_count); @@ -332,16 +357,102 @@ pub const LPVSystem = struct { return emitted_lights; } + /// Build a per-cell occlusion grid (1 = opaque, 0 = transparent) for the current LPV volume. + /// Stored as packed u32 array where each u32 holds the opacity for one cell. + fn buildOcclusionGrid(self: *LPVSystem, world: *World) void { + const gs = @as(usize, self.grid_size); + const total_cells = gs * gs * gs; + + // Ensure CPU buffer is allocated + if (self.occlusion_grid.len != total_cells) { + if (self.occlusion_grid.len > 0) self.allocator.free(self.occlusion_grid); + self.occlusion_grid = self.allocator.alloc(u32, total_cells) catch { + self.occlusion_grid = &.{}; + return; + }; + } + + @memset(self.occlusion_grid, 0); + + world.storage.chunks_mutex.lockShared(); + defer world.storage.chunks_mutex.unlockShared(); + + const grid_world_size = @as(f32, @floatFromInt(self.grid_size)) * self.cell_size; + const min_x = self.origin.x; + const min_y = self.origin.y; + const min_z = self.origin.z; + const max_x = min_x + grid_world_size; + const max_z = min_z + grid_world_size; + + var iter = world.storage.iteratorUnsafe(); + while (iter.next()) |entry| { + const chunk_data = entry.value_ptr.*; + const chunk = &chunk_data.chunk; + + const chunk_min_x = @as(f32, @floatFromInt(chunk.chunk_x * CHUNK_SIZE_X)); + const chunk_min_z = @as(f32, @floatFromInt(chunk.chunk_z * CHUNK_SIZE_Z)); + const chunk_max_x = chunk_min_x + @as(f32, @floatFromInt(CHUNK_SIZE_X)); + const chunk_max_z = chunk_min_z + @as(f32, @floatFromInt(CHUNK_SIZE_Z)); + + if (chunk_max_x < min_x or chunk_min_x > max_x or chunk_max_z < min_z or chunk_min_z > max_z) { + continue; + } + + var y: u32 = 0; + while (y < CHUNK_SIZE_Y) : (y += 1) { + const world_y = @as(f32, @floatFromInt(y)) + 0.5; + if (world_y < min_y or world_y >= min_y + grid_world_size) continue; + + var z: u32 = 0; + while (z < CHUNK_SIZE_Z) : (z += 1) { + var x: u32 = 0; + while (x < CHUNK_SIZE_X) : (x += 1) { + const block = chunk.getBlock(x, y, z); + if (block == .air) continue; + + const def = block_registry.getBlockDefinition(block); + if (!def.isOpaque()) continue; + + const world_x = chunk_min_x + @as(f32, @floatFromInt(x)) + 0.5; + const world_z = chunk_min_z + @as(f32, @floatFromInt(z)) + 0.5; + + // Map world position to grid cell + const gx = @as(i32, @intFromFloat(@floor((world_x - self.origin.x) / self.cell_size))); + const gy = @as(i32, @intFromFloat(@floor((world_y - self.origin.y) / self.cell_size))); + const gz = @as(i32, @intFromFloat(@floor((world_z - self.origin.z) / self.cell_size))); + + if (gx < 0 or gy < 0 or gz < 0) continue; + const ugx = @as(usize, @intCast(gx)); + const ugy = @as(usize, @intCast(gy)); + const ugz = @as(usize, @intCast(gz)); + if (ugx >= gs or ugy >= gs or ugz >= gs) continue; + + const idx = ugx + ugy * gs + ugz * gs * gs; + self.occlusion_grid[idx] = 1; + } + } + } + } + + // Upload to GPU + if (self.occlusion_buffer.mapped_ptr) |ptr| { + const bytes = std.mem.sliceAsBytes(self.occlusion_grid); + @memcpy(@as([*]u8, @ptrCast(ptr))[0..bytes.len], bytes); + } + } + fn dispatchCompute(self: *LPVSystem, light_count: usize) !void { const cmd = self.vk_ctx.frames.command_buffers[self.vk_ctx.frames.current_frame]; if (cmd == null) return; - const tex_a = self.vk_ctx.resources.textures.get(self.grid_texture_a) orelse return; - const tex_b = self.vk_ctx.resources.textures.get(self.grid_texture_b) orelse return; - - try self.transitionImage(cmd, tex_a.image.?, self.image_layout_a, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_ACCESS_SHADER_READ_BIT, c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT); + // Transition all 6 SH channel textures (3 per grid) to GENERAL for compute access + for (0..3) |ch| { + const tex_a = self.vk_ctx.resources.textures.get(self.grid_textures_a[ch]) orelse return; + const tex_b = self.vk_ctx.resources.textures.get(self.grid_textures_b[ch]) orelse return; + try self.transitionImage(cmd, tex_a.image.?, self.image_layout_a, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_ACCESS_SHADER_READ_BIT, c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT); + try self.transitionImage(cmd, tex_b.image.?, self.image_layout_b, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_ACCESS_SHADER_READ_BIT, c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT); + } self.image_layout_a = c.VK_IMAGE_LAYOUT_GENERAL; - try self.transitionImage(cmd, tex_b.image.?, self.image_layout_b, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_ACCESS_SHADER_READ_BIT, c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT); self.image_layout_b = c.VK_IMAGE_LAYOUT_GENERAL; const groups = divCeil(self.grid_size, 4); @@ -383,18 +494,21 @@ pub const LPVSystem = struct { use_ab = !use_ab; } + // Transition final textures to SHADER_READ_ONLY for fragment shader sampling const final_is_a = (self.propagation_iterations % 2) == 0; - const final_tex = if (final_is_a) tex_a else tex_b; - const final_image = final_tex.image.?; + const final_textures = if (final_is_a) &self.grid_textures_a else &self.grid_textures_b; - try self.transitionImage(cmd, final_image, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, c.VK_ACCESS_SHADER_WRITE_BIT, c.VK_ACCESS_SHADER_READ_BIT); + for (0..3) |ch| { + const final_tex = self.vk_ctx.resources.textures.get(final_textures[ch]) orelse return; + try self.transitionImage(cmd, final_tex.image.?, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, c.VK_ACCESS_SHADER_WRITE_BIT, c.VK_ACCESS_SHADER_READ_BIT); + } if (final_is_a) { self.image_layout_a = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - self.active_grid_texture = self.grid_texture_a; + self.active_grid_textures = self.grid_textures_a; } else { self.image_layout_b = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - self.active_grid_texture = self.grid_texture_b; + self.active_grid_textures = self.grid_textures_b; } } @@ -435,38 +549,36 @@ pub const LPVSystem = struct { @memset(empty, 0.0); const bytes = std.mem.sliceAsBytes(empty); - // Atlas fallback: store Z slices stacked in Y (height = grid_size * grid_size). - // This stays until terrain/material sampling fully migrates to native 3D textures. - - self.grid_texture_a = try self.rhi.createTexture( - self.grid_size, - self.grid_size * self.grid_size, - .rgba32f, - .{ - .min_filter = .linear, - .mag_filter = .linear, - .wrap_s = .clamp_to_edge, - .wrap_t = .clamp_to_edge, - .generate_mipmaps = false, - .is_render_target = false, - }, - bytes, - ); + // Native 3D textures for SH L1 LPV: 3 channel textures per grid (R, G, B), + // each storing 4 SH coefficients (L0, L1x, L1y, L1z) as rgba32f. + const tex_config = rhi_pkg.TextureConfig{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + .generate_mipmaps = false, + .is_render_target = false, + }; - self.grid_texture_b = try self.rhi.createTexture( - self.grid_size, - self.grid_size * self.grid_size, - .rgba32f, - .{ - .min_filter = .linear, - .mag_filter = .linear, - .wrap_s = .clamp_to_edge, - .wrap_t = .clamp_to_edge, - .generate_mipmaps = false, - .is_render_target = false, - }, - bytes, - ); + for (0..3) |ch| { + self.grid_textures_a[ch] = try self.rhi.factory().createTexture3D( + self.grid_size, + self.grid_size, + self.grid_size, + .rgba32f, + tex_config, + bytes, + ); + + self.grid_textures_b[ch] = try self.rhi.factory().createTexture3D( + self.grid_size, + self.grid_size, + self.grid_size, + .rgba32f, + tex_config, + bytes, + ); + } const debug_size = @as(usize, self.grid_size) * @as(usize, self.grid_size) * 4; self.debug_overlay_pixels = try self.allocator.alloc(f32, debug_size); @@ -490,19 +602,21 @@ pub const LPVSystem = struct { self.buildDebugOverlay(&.{}, 0); try self.uploadDebugOverlay(); - self.active_grid_texture = self.grid_texture_a; + self.active_grid_textures = self.grid_textures_a; self.image_layout_a = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; self.image_layout_b = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; } fn destroyGridTextures(self: *LPVSystem) void { - if (self.grid_texture_a != 0) { - self.rhi.destroyTexture(self.grid_texture_a); - self.grid_texture_a = 0; - } - if (self.grid_texture_b != 0) { - self.rhi.destroyTexture(self.grid_texture_b); - self.grid_texture_b = 0; + for (0..3) |ch| { + if (self.grid_textures_a[ch] != 0) { + self.rhi.destroyTexture(self.grid_textures_a[ch]); + self.grid_textures_a[ch] = 0; + } + if (self.grid_textures_b[ch] != 0) { + self.rhi.destroyTexture(self.grid_textures_b[ch]); + self.grid_textures_b[ch] = 0; + } } if (self.debug_overlay_texture != 0) { self.rhi.destroyTexture(self.debug_overlay_texture); @@ -512,7 +626,7 @@ pub const LPVSystem = struct { self.allocator.free(self.debug_overlay_pixels); self.debug_overlay_pixels = &.{}; } - self.active_grid_texture = 0; + self.active_grid_textures = .{ 0, 0, 0 }; } fn buildDebugOverlay(self: *LPVSystem, lights: []const GpuLight, light_count: usize) void { @@ -587,12 +701,34 @@ pub const LPVSystem = struct { } } + fn destroyOcclusionBuffer(self: *LPVSystem) void { + if (self.occlusion_buffer.buffer != null) { + if (self.occlusion_buffer.mapped_ptr != null) { + c.vkUnmapMemory(self.vk_ctx.vulkan_device.vk_device, self.occlusion_buffer.memory); + self.occlusion_buffer.mapped_ptr = null; + } + c.vkDestroyBuffer(self.vk_ctx.vulkan_device.vk_device, self.occlusion_buffer.buffer, null); + if (self.occlusion_buffer.memory != null) { + c.vkFreeMemory(self.vk_ctx.vulkan_device.vk_device, self.occlusion_buffer.memory, null); + } + self.occlusion_buffer = .{}; + } + if (self.occlusion_grid.len > 0) { + self.allocator.free(self.occlusion_grid); + self.occlusion_grid = &.{}; + } + } + fn initComputeResources(self: *LPVSystem) !void { const vk = self.vk_ctx.vulkan_device.vk_device; + // SH L1: inject needs 3 output images + 1 SSBO = 4 bindings + // propagate needs 3 src + 3 dst images + 1 occlusion SSBO = 7 bindings + // Total images: inject(3) + prop_ab(6) + prop_ba(6) = 15 + // Total buffers: inject(1) + prop_ab(1) + prop_ba(1) = 3 var pool_sizes = [_]c.VkDescriptorPoolSize{ - .{ .type = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 8 }, - .{ .type = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 2 }, + .{ .type = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 16 }, + .{ .type = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 4 }, }; var pool_info = std.mem.zeroes(c.VkDescriptorPoolCreateInfo); @@ -602,9 +738,12 @@ pub const LPVSystem = struct { pool_info.pPoolSizes = &pool_sizes; try Utils.checkVk(c.vkCreateDescriptorPool(vk, &pool_info, null, &self.descriptor_pool)); + // Inject: binding 0,1,2 = output images (R,G,B SH channels), binding 3 = light buffer const inject_bindings = [_]c.VkDescriptorSetLayoutBinding{ .{ .binding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, - .{ .binding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 2, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 3, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, }; var inject_layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); inject_layout_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; @@ -612,9 +751,15 @@ pub const LPVSystem = struct { inject_layout_info.pBindings = &inject_bindings; try Utils.checkVk(c.vkCreateDescriptorSetLayout(vk, &inject_layout_info, null, &self.inject_set_layout)); + // Propagate: binding 0-2 = src (R,G,B), binding 3-5 = dst (R,G,B), binding 6 = occlusion const prop_bindings = [_]c.VkDescriptorSetLayoutBinding{ .{ .binding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, .{ .binding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 2, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 3, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 4, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 5, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 6, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, }; var prop_layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); prop_layout_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; @@ -651,71 +796,104 @@ pub const LPVSystem = struct { } fn updateDescriptorSets(self: *LPVSystem) !void { - const vk = self.vk_ctx.vulkan_device.vk_device; - _ = vk; - - const tex_a = self.vk_ctx.resources.textures.get(self.grid_texture_a) orelse return error.ResourceNotFound; - const tex_b = self.vk_ctx.resources.textures.get(self.grid_texture_b) orelse return error.ResourceNotFound; - - var img_a = c.VkDescriptorImageInfo{ .sampler = null, .imageView = tex_a.view, .imageLayout = c.VK_IMAGE_LAYOUT_GENERAL }; - var img_b = c.VkDescriptorImageInfo{ .sampler = null, .imageView = tex_b.view, .imageLayout = c.VK_IMAGE_LAYOUT_GENERAL }; + // Resolve all 6 texture resources (3 channels x 2 grids) + var imgs_a: [3]c.VkDescriptorImageInfo = undefined; + var imgs_b: [3]c.VkDescriptorImageInfo = undefined; + for (0..3) |ch| { + const tex_a = self.vk_ctx.resources.textures.get(self.grid_textures_a[ch]) orelse return error.ResourceNotFound; + const tex_b = self.vk_ctx.resources.textures.get(self.grid_textures_b[ch]) orelse return error.ResourceNotFound; + imgs_a[ch] = c.VkDescriptorImageInfo{ .sampler = null, .imageView = tex_a.view, .imageLayout = c.VK_IMAGE_LAYOUT_GENERAL }; + imgs_b[ch] = c.VkDescriptorImageInfo{ .sampler = null, .imageView = tex_b.view, .imageLayout = c.VK_IMAGE_LAYOUT_GENERAL }; + } var light_info = c.VkDescriptorBufferInfo{ .buffer = self.light_buffer.buffer, .offset = 0, .range = @sizeOf(GpuLight) * MAX_LIGHTS_PER_UPDATE }; - - var writes: [6]c.VkWriteDescriptorSet = undefined; + const occlusion_size = @as(usize, self.grid_size) * @as(usize, self.grid_size) * @as(usize, self.grid_size) * @sizeOf(u32); + var occlusion_info = c.VkDescriptorBufferInfo{ .buffer = self.occlusion_buffer.buffer, .offset = 0, .range = @intCast(occlusion_size) }; + + // Inject: bindings 0,1,2 = output R,G,B images (grid A), binding 3 = light buffer + // Propagate A->B: bindings 0-2 = src (A), bindings 3-5 = dst (B), binding 6 = occlusion + // Propagate B->A: bindings 0-2 = src (B), bindings 3-5 = dst (A), binding 6 = occlusion + // Total writes: 4 (inject) + 7 (prop_ab) + 7 (prop_ba) = 18 + var writes: [18]c.VkWriteDescriptorSet = undefined; var n: usize = 0; + // --- Inject set --- + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.inject_descriptor_set; + writes[n].dstBinding = @intCast(ch); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_a[ch]; + n += 1; + } writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; writes[n].dstSet = self.inject_descriptor_set; - writes[n].dstBinding = 0; - writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; - writes[n].descriptorCount = 1; - writes[n].pImageInfo = &img_a; - n += 1; - - writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); - writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[n].dstSet = self.inject_descriptor_set; - writes[n].dstBinding = 1; + writes[n].dstBinding = 3; writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; writes[n].descriptorCount = 1; writes[n].pBufferInfo = &light_info; n += 1; + // --- Propagate A->B set --- + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ab_descriptor_set; + writes[n].dstBinding = @intCast(ch); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_a[ch]; + n += 1; + } + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ab_descriptor_set; + writes[n].dstBinding = @intCast(ch + 3); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_b[ch]; + n += 1; + } writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; writes[n].dstSet = self.propagate_ab_descriptor_set; - writes[n].dstBinding = 0; - writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; - writes[n].descriptorCount = 1; - writes[n].pImageInfo = &img_a; - n += 1; - - writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); - writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[n].dstSet = self.propagate_ab_descriptor_set; - writes[n].dstBinding = 1; - writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; - writes[n].descriptorCount = 1; - writes[n].pImageInfo = &img_b; - n += 1; - - writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); - writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[n].dstSet = self.propagate_ba_descriptor_set; - writes[n].dstBinding = 0; - writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].dstBinding = 6; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; writes[n].descriptorCount = 1; - writes[n].pImageInfo = &img_b; + writes[n].pBufferInfo = &occlusion_info; n += 1; + // --- Propagate B->A set --- + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ba_descriptor_set; + writes[n].dstBinding = @intCast(ch); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_b[ch]; + n += 1; + } + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ba_descriptor_set; + writes[n].dstBinding = @intCast(ch + 3); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_a[ch]; + n += 1; + } writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; writes[n].dstSet = self.propagate_ba_descriptor_set; - writes[n].dstBinding = 1; - writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].dstBinding = 6; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; writes[n].descriptorCount = 1; - writes[n].pImageInfo = &img_a; + writes[n].pBufferInfo = &occlusion_info; n += 1; c.vkUpdateDescriptorSets(self.vk_ctx.vulkan_device.vk_device, @intCast(n), &writes[0], 0, null); diff --git a/src/engine/graphics/render_graph.zig b/src/engine/graphics/render_graph.zig index 9b0cfd5..216b10f 100644 --- a/src/engine/graphics/render_graph.zig +++ b/src/engine/graphics/render_graph.zig @@ -36,6 +36,8 @@ pub const SceneContext = struct { overlay_renderer: ?*const fn (ctx: SceneContext) void = null, overlay_ctx: ?*anyopaque = null, lpv_texture_handle: rhi_pkg.TextureHandle = 0, + lpv_texture_handle_g: rhi_pkg.TextureHandle = 0, + lpv_texture_handle_b: rhi_pkg.TextureHandle = 0, // Pointer to frame-local cascade storage, computed once per frame by the first // ShadowPass and reused by subsequent cascade passes to guarantee consistency. cached_cascades: *?CSM.ShadowCascades, @@ -284,6 +286,8 @@ pub const OpaquePass = struct { rhi.bindShader(ctx.main_shader); ctx.material_system.bindTerrainMaterial(ctx.env_map_handle); rhi.bindTexture(ctx.lpv_texture_handle, 11); + rhi.bindTexture(ctx.lpv_texture_handle_g, 12); + rhi.bindTexture(ctx.lpv_texture_handle_b, 13); const view_proj = Mat4.perspectiveReverseZ(ctx.camera.fov, ctx.aspect, ctx.camera.near, ctx.camera.far).multiply(ctx.camera.getViewMatrixOriginCentered()); ctx.world.render(view_proj, ctx.camera.position, true); } diff --git a/src/engine/graphics/rhi.zig b/src/engine/graphics/rhi.zig index 6643639..9fc1ea8 100644 --- a/src/engine/graphics/rhi.zig +++ b/src/engine/graphics/rhi.zig @@ -480,6 +480,8 @@ pub const RHI = struct { setVignetteIntensity: *const fn (ctx: *anyopaque, intensity: f32) void, setFilmGrainEnabled: *const fn (ctx: *anyopaque, enabled: bool) void, setFilmGrainIntensity: *const fn (ctx: *anyopaque, intensity: f32) void, + setColorGradingEnabled: *const fn (ctx: *anyopaque, enabled: bool) void, + setColorGradingIntensity: *const fn (ctx: *anyopaque, intensity: f32) void, }; pub fn factory(self: RHI) IResourceFactory { @@ -726,4 +728,10 @@ pub const RHI = struct { pub fn setFilmGrainIntensity(self: RHI, intensity: f32) void { self.vtable.setFilmGrainIntensity(self.ptr, intensity); } + pub fn setColorGradingEnabled(self: RHI, enabled: bool) void { + self.vtable.setColorGradingEnabled(self.ptr, enabled); + } + pub fn setColorGradingIntensity(self: RHI, intensity: f32) void { + self.vtable.setColorGradingIntensity(self.ptr, intensity); + } }; diff --git a/src/engine/graphics/rhi_tests.zig b/src/engine/graphics/rhi_tests.zig index 961250e..a497199 100644 --- a/src/engine/graphics/rhi_tests.zig +++ b/src/engine/graphics/rhi_tests.zig @@ -338,6 +338,8 @@ const MockContext = struct { .setVignetteIntensity = undefined, .setFilmGrainEnabled = undefined, .setFilmGrainIntensity = undefined, + .setColorGradingEnabled = undefined, + .setColorGradingIntensity = undefined, }; const MOCK_ENCODER_VTABLE = rhi.IGraphicsCommandEncoder.VTable{ diff --git a/src/engine/graphics/rhi_types.zig b/src/engine/graphics/rhi_types.zig index 3ac517c..2031a88 100644 --- a/src/engine/graphics/rhi_types.zig +++ b/src/engine/graphics/rhi_types.zig @@ -167,12 +167,14 @@ pub const ShadowConfig = struct { pcf_samples: u8 = 12, cascade_blend: bool = true, strength: f32 = 0.35, // Cloud shadow intensity (0-1) + light_size: f32 = 3.0, // PCSS light source size (world units) - controls penumbra softness }; pub const ShadowParams = struct { light_space_matrices: [SHADOW_CASCADE_COUNT]Mat4, cascade_splits: [SHADOW_CASCADE_COUNT]f32, shadow_texel_sizes: [SHADOW_CASCADE_COUNT]f32, + light_size: f32 = 3.0, // PCSS light source size for penumbra estimation }; pub const CloudParams = struct { diff --git a/src/engine/graphics/rhi_vulkan.zig b/src/engine/graphics/rhi_vulkan.zig index 8c4503a..5a6855c 100644 --- a/src/engine/graphics/rhi_vulkan.zig +++ b/src/engine/graphics/rhi_vulkan.zig @@ -218,6 +218,16 @@ fn setFilmGrainIntensity(ctx_ptr: *anyopaque, intensity: f32) void { ctx.post_process_state.film_grain_intensity = intensity; } +fn setColorGradingEnabled(ctx_ptr: *anyopaque, enabled: bool) void { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + ctx.post_process_state.color_grading_enabled = enabled; +} + +fn setColorGradingIntensity(ctx_ptr: *anyopaque, intensity: f32) void { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + ctx.post_process_state.color_grading_intensity = intensity; +} + fn endFrame(ctx_ptr: *anyopaque) void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); ctx.mutex.lock(); @@ -347,6 +357,8 @@ fn bindTexture(ctx_ptr: *anyopaque, handle: rhi.TextureHandle, slot: u32) void { 8 => ctx.draw.current_displacement_texture = resolved, 9 => ctx.draw.current_env_texture = resolved, 11 => ctx.draw.current_lpv_texture = resolved, + 12 => ctx.draw.current_lpv_texture_g = resolved, + 13 => ctx.draw.current_lpv_texture_b = resolved, else => ctx.draw.current_texture = resolved, } } @@ -739,6 +751,8 @@ const VULKAN_RHI_VTABLE = rhi.RHI.VTable{ .setVignetteIntensity = setVignetteIntensity, .setFilmGrainEnabled = setFilmGrainEnabled, .setFilmGrainIntensity = setFilmGrainIntensity, + .setColorGradingEnabled = setColorGradingEnabled, + .setColorGradingIntensity = setColorGradingIntensity, }; fn beginPassTiming(ctx_ptr: *anyopaque, pass_name: []const u8) void { diff --git a/src/engine/graphics/vulkan/descriptor_bindings.zig b/src/engine/graphics/vulkan/descriptor_bindings.zig index 33e743a..e777bea 100644 --- a/src/engine/graphics/vulkan/descriptor_bindings.zig +++ b/src/engine/graphics/vulkan/descriptor_bindings.zig @@ -9,4 +9,6 @@ pub const ROUGHNESS_TEXTURE = 7; pub const DISPLACEMENT_TEXTURE = 8; pub const ENV_TEXTURE = 9; pub const SSAO_TEXTURE = 10; -pub const LPV_TEXTURE = 11; +pub const LPV_TEXTURE = 11; // LPV SH Red channel (rgba32f = 4 SH coefficients) +pub const LPV_TEXTURE_G = 12; // LPV SH Green channel +pub const LPV_TEXTURE_B = 13; // LPV SH Blue channel diff --git a/src/engine/graphics/vulkan/descriptor_manager.zig b/src/engine/graphics/vulkan/descriptor_manager.zig index 3ffc170..f2de24d 100644 --- a/src/engine/graphics/vulkan/descriptor_manager.zig +++ b/src/engine/graphics/vulkan/descriptor_manager.zig @@ -30,6 +30,7 @@ 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, // x = light_size (PCSS), y/z/w reserved }; pub const DescriptorManager = struct { @@ -50,6 +51,7 @@ pub const DescriptorManager = struct { // Dummy textures dummy_texture: rhi.TextureHandle, + dummy_texture_3d: rhi.TextureHandle, dummy_normal_texture: rhi.TextureHandle, dummy_roughness_texture: rhi.TextureHandle, @@ -67,6 +69,7 @@ pub const DescriptorManager = struct { .shadow_ubos = std.mem.zeroes([rhi.MAX_FRAMES_IN_FLIGHT]VulkanBuffer), .shadow_ubos_mapped = std.mem.zeroes([rhi.MAX_FRAMES_IN_FLIGHT]?*anyopaque), .dummy_texture = 0, + .dummy_texture_3d = 0, .dummy_normal_texture = 0, .dummy_roughness_texture = 0, }; @@ -107,6 +110,12 @@ pub const DescriptorManager = struct { return err; }; + // 1x1x1 3D dummy texture for sampler3D bindings (LPV) + self.dummy_texture_3d = resource_manager.createTexture3D(1, 1, 1, .rgba, .{}, &white_pixel) catch |err| { + self.deinit(); + return err; + }; + resource_manager.flushTransfer() catch |err| { self.deinit(); return err; @@ -156,8 +165,12 @@ pub const DescriptorManager = struct { .{ .binding = 9, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, // 10: SSAO Map .{ .binding = 10, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, - // 11: LPV Grid Atlas + // 11: LPV SH Red channel (or scalar RGB when SH disabled) .{ .binding = 11, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, + // 12: LPV SH Green channel + .{ .binding = 12, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, + // 13: LPV SH Blue channel + .{ .binding = 13, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, }; var layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); diff --git a/src/engine/graphics/vulkan/post_process_system.zig b/src/engine/graphics/vulkan/post_process_system.zig index 47a9067..36b3eaa 100644 --- a/src/engine/graphics/vulkan/post_process_system.zig +++ b/src/engine/graphics/vulkan/post_process_system.zig @@ -10,6 +10,10 @@ pub const PostProcessPushConstants = extern struct { bloom_intensity: f32, vignette_intensity: f32, film_grain_intensity: f32, + color_grading_enabled: f32, // 0.0 = disabled, 1.0 = enabled + color_grading_intensity: f32, // LUT blend intensity (0.0 = original, 1.0 = full LUT) + _pad0: f32 = 0.0, + _pad1: f32 = 0.0, }; pub const PostProcessSystem = struct { @@ -19,6 +23,7 @@ pub const PostProcessSystem = struct { descriptor_sets: [rhi.MAX_FRAMES_IN_FLIGHT]c.VkDescriptorSet = .{null} ** rhi.MAX_FRAMES_IN_FLIGHT, sampler: c.VkSampler = null, pass_active: bool = false, + lut_texture: rhi.TextureHandle = 0, pub fn init( self: *PostProcessSystem, @@ -37,6 +42,7 @@ pub const PostProcessSystem = struct { .{ .binding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, .{ .binding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, .{ .binding = 2, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, + .{ .binding = 3, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, }; var layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); layout_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; @@ -165,6 +171,7 @@ pub const PostProcessSystem = struct { .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .pImageInfo = &image_info }, .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, .descriptorCount = 1, .pBufferInfo = &buffer_info }, .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 2, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .pImageInfo = &image_info }, + .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 3, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .pImageInfo = &image_info }, // placeholder for LUT, updated later }; c.vkUpdateDescriptorSets(vk, writes.len, &writes[0], 0, null); } @@ -191,6 +198,27 @@ pub const PostProcessSystem = struct { } } + pub fn updateLUTDescriptor(self: *PostProcessSystem, vk: c.VkDevice, lut_view: c.VkImageView, lut_sampler: c.VkSampler) void { + for (0..rhi.MAX_FRAMES_IN_FLIGHT) |i| { + if (self.descriptor_sets[i] == null) continue; + + var lut_image_info = std.mem.zeroes(c.VkDescriptorImageInfo); + lut_image_info.imageLayout = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + lut_image_info.imageView = lut_view; + lut_image_info.sampler = lut_sampler; + + var write = std.mem.zeroes(c.VkWriteDescriptorSet); + write.sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = self.descriptor_sets[i]; + write.dstBinding = 3; + write.descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.descriptorCount = 1; + write.pImageInfo = &lut_image_info; + + c.vkUpdateDescriptorSets(vk, 1, &write, 0, null); + } + } + pub fn deinit(self: *PostProcessSystem, vk: c.VkDevice, descriptor_pool: c.VkDescriptorPool) void { if (self.sampler != null) { c.vkDestroySampler(vk, self.sampler, null); diff --git a/src/engine/graphics/vulkan/rhi_context_factory.zig b/src/engine/graphics/vulkan/rhi_context_factory.zig index 00b368e..ca33851 100644 --- a/src/engine/graphics/vulkan/rhi_context_factory.zig +++ b/src/engine/graphics/vulkan/rhi_context_factory.zig @@ -51,7 +51,10 @@ pub fn createRHI( ctx.draw.current_displacement_texture = 0; ctx.draw.current_env_texture = 0; ctx.draw.current_lpv_texture = 0; + ctx.draw.current_lpv_texture_g = 0; + ctx.draw.current_lpv_texture_b = 0; ctx.draw.dummy_texture = 0; + ctx.draw.dummy_texture_3d = 0; ctx.draw.dummy_normal_texture = 0; ctx.draw.dummy_roughness_texture = 0; ctx.mutex = .{}; diff --git a/src/engine/graphics/vulkan/rhi_context_types.zig b/src/engine/graphics/vulkan/rhi_context_types.zig index 5aaee46..1e7ec1a 100644 --- a/src/engine/graphics/vulkan/rhi_context_types.zig +++ b/src/engine/graphics/vulkan/rhi_context_types.zig @@ -103,6 +103,8 @@ const PostProcessState = struct { vignette_intensity: f32 = 0.3, film_grain_enabled: bool = false, film_grain_intensity: f32 = 0.15, + color_grading_enabled: bool = false, + color_grading_intensity: f32 = 1.0, }; const RenderOptions = struct { @@ -123,7 +125,10 @@ const DrawState = struct { current_displacement_texture: rhi.TextureHandle, current_env_texture: rhi.TextureHandle, current_lpv_texture: rhi.TextureHandle, + current_lpv_texture_g: rhi.TextureHandle, + current_lpv_texture_b: rhi.TextureHandle, dummy_texture: rhi.TextureHandle, + dummy_texture_3d: rhi.TextureHandle, dummy_normal_texture: rhi.TextureHandle, dummy_roughness_texture: rhi.TextureHandle, bound_texture: rhi.TextureHandle, @@ -132,6 +137,8 @@ const DrawState = struct { bound_displacement_texture: rhi.TextureHandle, bound_env_texture: rhi.TextureHandle, bound_lpv_texture: rhi.TextureHandle, + bound_lpv_texture_g: rhi.TextureHandle = 0, + bound_lpv_texture_b: rhi.TextureHandle = 0, bound_ssao_handle: rhi.TextureHandle = 0, bound_shadow_views: [rhi.SHADOW_CASCADE_COUNT]c.VkImageView, descriptors_dirty: [MAX_FRAMES_IN_FLIGHT]bool, diff --git a/src/engine/graphics/vulkan/rhi_frame_orchestration.zig b/src/engine/graphics/vulkan/rhi_frame_orchestration.zig index 59458cf..232a299 100644 --- a/src/engine/graphics/vulkan/rhi_frame_orchestration.zig +++ b/src/engine/graphics/vulkan/rhi_frame_orchestration.zig @@ -154,6 +154,8 @@ pub fn prepareFrameState(ctx: anytype) void { const cur_dis = ctx.draw.current_displacement_texture; const cur_env = ctx.draw.current_env_texture; const cur_lpv = ctx.draw.current_lpv_texture; + const cur_lpv_g = ctx.draw.current_lpv_texture_g; + const cur_lpv_b = ctx.draw.current_lpv_texture_b; var needs_update = false; if (ctx.draw.bound_texture != cur_tex) needs_update = true; @@ -162,6 +164,8 @@ pub fn prepareFrameState(ctx: anytype) void { if (ctx.draw.bound_displacement_texture != cur_dis) needs_update = true; if (ctx.draw.bound_env_texture != cur_env) needs_update = true; if (ctx.draw.bound_lpv_texture != cur_lpv) needs_update = true; + if (ctx.draw.bound_lpv_texture_g != cur_lpv_g) needs_update = true; + if (ctx.draw.bound_lpv_texture_b != cur_lpv_b) needs_update = true; for (0..rhi.SHADOW_CASCADE_COUNT) |si| { if (ctx.draw.bound_shadow_views[si] != ctx.shadow_system.shadow_image_views[si]) needs_update = true; @@ -175,6 +179,8 @@ pub fn prepareFrameState(ctx: anytype) void { ctx.draw.bound_displacement_texture = cur_dis; ctx.draw.bound_env_texture = cur_env; ctx.draw.bound_lpv_texture = cur_lpv; + ctx.draw.bound_lpv_texture_g = cur_lpv_g; + ctx.draw.bound_lpv_texture_b = cur_lpv_b; for (0..rhi.SHADOW_CASCADE_COUNT) |si| ctx.draw.bound_shadow_views[si] = ctx.shadow_system.shadow_image_views[si]; } @@ -183,24 +189,28 @@ pub fn prepareFrameState(ctx: anytype) void { std.log.err("CRITICAL: Descriptor set for frame {} is NULL!", .{ctx.frames.current_frame}); return; } - var writes: [12]c.VkWriteDescriptorSet = undefined; + var writes: [14]c.VkWriteDescriptorSet = undefined; var write_count: u32 = 0; - var image_infos: [12]c.VkDescriptorImageInfo = undefined; + var image_infos: [14]c.VkDescriptorImageInfo = undefined; var info_count: u32 = 0; const dummy_tex_entry = ctx.resources.textures.get(ctx.draw.dummy_texture); + const dummy_tex_3d_entry = ctx.resources.textures.get(ctx.draw.dummy_texture_3d); - const atlas_slots = [_]struct { handle: rhi.TextureHandle, binding: u32 }{ - .{ .handle = cur_tex, .binding = bindings.ALBEDO_TEXTURE }, - .{ .handle = cur_nor, .binding = bindings.NORMAL_TEXTURE }, - .{ .handle = cur_rou, .binding = bindings.ROUGHNESS_TEXTURE }, - .{ .handle = cur_dis, .binding = bindings.DISPLACEMENT_TEXTURE }, - .{ .handle = cur_env, .binding = bindings.ENV_TEXTURE }, - .{ .handle = cur_lpv, .binding = bindings.LPV_TEXTURE }, + const atlas_slots = [_]struct { handle: rhi.TextureHandle, binding: u32, is_3d: bool }{ + .{ .handle = cur_tex, .binding = bindings.ALBEDO_TEXTURE, .is_3d = false }, + .{ .handle = cur_nor, .binding = bindings.NORMAL_TEXTURE, .is_3d = false }, + .{ .handle = cur_rou, .binding = bindings.ROUGHNESS_TEXTURE, .is_3d = false }, + .{ .handle = cur_dis, .binding = bindings.DISPLACEMENT_TEXTURE, .is_3d = false }, + .{ .handle = cur_env, .binding = bindings.ENV_TEXTURE, .is_3d = false }, + .{ .handle = cur_lpv, .binding = bindings.LPV_TEXTURE, .is_3d = true }, + .{ .handle = cur_lpv_g, .binding = bindings.LPV_TEXTURE_G, .is_3d = true }, + .{ .handle = cur_lpv_b, .binding = bindings.LPV_TEXTURE_B, .is_3d = true }, }; for (atlas_slots) |slot| { - const entry = ctx.resources.textures.get(slot.handle) orelse dummy_tex_entry; + const fallback = if (slot.is_3d) dummy_tex_3d_entry else dummy_tex_entry; + const entry = ctx.resources.textures.get(slot.handle) orelse fallback; if (entry) |tex| { image_infos[info_count] = .{ .sampler = tex.sampler, diff --git a/src/engine/graphics/vulkan/rhi_init_deinit.zig b/src/engine/graphics/vulkan/rhi_init_deinit.zig index 7e6e151..3924279 100644 --- a/src/engine/graphics/vulkan/rhi_init_deinit.zig +++ b/src/engine/graphics/vulkan/rhi_init_deinit.zig @@ -102,6 +102,7 @@ pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?* setup.updatePostProcessDescriptorsWithBloom(ctx); ctx.draw.dummy_texture = ctx.descriptors.dummy_texture; + ctx.draw.dummy_texture_3d = ctx.descriptors.dummy_texture_3d; ctx.draw.dummy_normal_texture = ctx.descriptors.dummy_normal_texture; ctx.draw.dummy_roughness_texture = ctx.descriptors.dummy_roughness_texture; ctx.draw.current_texture = ctx.draw.dummy_texture; @@ -109,7 +110,9 @@ pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?* ctx.draw.current_roughness_texture = ctx.draw.dummy_roughness_texture; ctx.draw.current_displacement_texture = ctx.draw.dummy_roughness_texture; ctx.draw.current_env_texture = ctx.draw.dummy_texture; - ctx.draw.current_lpv_texture = ctx.draw.dummy_texture; + ctx.draw.current_lpv_texture = ctx.draw.dummy_texture_3d; + ctx.draw.current_lpv_texture_g = ctx.draw.dummy_texture_3d; + ctx.draw.current_lpv_texture_b = ctx.draw.dummy_texture_3d; const cloud_vbo_handle = try ctx.resources.createBuffer(8 * @sizeOf(f32), .vertex); std.log.info("Cloud VBO handle: {}, map count: {}", .{ cloud_vbo_handle, ctx.resources.buffers.count() }); @@ -228,6 +231,7 @@ pub fn deinit(ctx: anytype) void { } ctx.resources.destroyTexture(ctx.draw.dummy_texture); + ctx.resources.destroyTexture(ctx.draw.dummy_texture_3d); ctx.resources.destroyTexture(ctx.draw.dummy_normal_texture); ctx.resources.destroyTexture(ctx.draw.dummy_roughness_texture); if (ctx.legacy.dummy_shadow_view != null) c.vkDestroyImageView(ctx.vulkan_device.vk_device, ctx.legacy.dummy_shadow_view, null); diff --git a/src/engine/graphics/vulkan/rhi_pass_orchestration.zig b/src/engine/graphics/vulkan/rhi_pass_orchestration.zig index 7f513bd..e11a1cc 100644 --- a/src/engine/graphics/vulkan/rhi_pass_orchestration.zig +++ b/src/engine/graphics/vulkan/rhi_pass_orchestration.zig @@ -326,6 +326,8 @@ pub fn beginPostProcessPassInternal(ctx: anytype) void { .bloom_intensity = ctx.bloom.intensity, .vignette_intensity = if (ctx.post_process_state.vignette_enabled) ctx.post_process_state.vignette_intensity else 0.0, .film_grain_intensity = if (ctx.post_process_state.film_grain_enabled) ctx.post_process_state.film_grain_intensity else 0.0, + .color_grading_enabled = if (ctx.post_process_state.color_grading_enabled) 1.0 else 0.0, + .color_grading_intensity = ctx.post_process_state.color_grading_intensity, }; c.vkCmdPushConstants(command_buffer, ctx.post_process.pipeline_layout, c.VK_SHADER_STAGE_FRAGMENT_BIT, 0, @sizeOf(PostProcessPushConstants), &push); diff --git a/src/engine/graphics/vulkan/rhi_resource_setup.zig b/src/engine/graphics/vulkan/rhi_resource_setup.zig index a20edc1..961057b 100644 --- a/src/engine/graphics/vulkan/rhi_resource_setup.zig +++ b/src/engine/graphics/vulkan/rhi_resource_setup.zig @@ -389,9 +389,56 @@ pub fn createPostProcessResources(ctx: anytype) !void { global_uniform_size, ); + // Create neutral (identity) 3D LUT for color grading and bind it + if (ctx.post_process.lut_texture == 0) { + ctx.post_process.lut_texture = try createNeutralLUT3D(ctx); + } + updatePostProcessLUTDescriptor(ctx); + try ctx.render_pass_manager.createPostProcessFramebuffers(vk, ctx.allocator, ctx.swapchain.getExtent(), ctx.swapchain.getImageViews()); } +/// Generate a 32x32x32 identity LUT where each texel maps to itself: color(r,g,b) = (r,g,b). +fn createNeutralLUT3D(ctx: anytype) !rhi.TextureHandle { + const LUT_SIZE: u32 = 32; + const total = LUT_SIZE * LUT_SIZE * LUT_SIZE; + var data = try ctx.allocator.alloc(u8, total * 4); + defer ctx.allocator.free(data); + + var i: u32 = 0; + var z: u32 = 0; + while (z < LUT_SIZE) : (z += 1) { + var y: u32 = 0; + while (y < LUT_SIZE) : (y += 1) { + var x: u32 = 0; + while (x < LUT_SIZE) : (x += 1) { + data[i + 0] = @intCast((x * 255 + (LUT_SIZE - 1) / 2) / (LUT_SIZE - 1)); + data[i + 1] = @intCast((y * 255 + (LUT_SIZE - 1) / 2) / (LUT_SIZE - 1)); + data[i + 2] = @intCast((z * 255 + (LUT_SIZE - 1) / 2) / (LUT_SIZE - 1)); + data[i + 3] = 255; + i += 4; + } + } + } + + const handle = try ctx.resources.createTexture3D(LUT_SIZE, LUT_SIZE, LUT_SIZE, .rgba, .{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + .generate_mipmaps = false, + .is_render_target = false, + }, data); + return handle; +} + +fn updatePostProcessLUTDescriptor(ctx: anytype) void { + const vk = ctx.vulkan_device.vk_device; + if (ctx.post_process.lut_texture == 0) return; + const tex = ctx.resources.textures.get(ctx.post_process.lut_texture) orelse return; + ctx.post_process.updateLUTDescriptor(vk, tex.view, tex.sampler); +} + pub fn updatePostProcessDescriptorsWithBloom(ctx: anytype) void { const vk = ctx.vulkan_device.vk_device; const bloom_view = if (ctx.bloom.mip_views[0] != null) ctx.bloom.mip_views[0] else return; diff --git a/src/engine/graphics/vulkan/rhi_shadow_bridge.zig b/src/engine/graphics/vulkan/rhi_shadow_bridge.zig index 569d66e..451c2dd 100644 --- a/src/engine/graphics/vulkan/rhi_shadow_bridge.zig +++ b/src/engine/graphics/vulkan/rhi_shadow_bridge.zig @@ -5,6 +5,7 @@ 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, // x = light_size (PCSS), y/z/w reserved }; pub fn beginShadowPassInternal(ctx: anytype, cascade_index: u32, light_space_matrix: Mat4) void { @@ -35,6 +36,7 @@ pub fn updateShadowUniforms(ctx: anytype, params: rhi.ShadowParams) !void { .light_space_matrices = params.light_space_matrices, .cascade_splits = splits, .shadow_texel_sizes = sizes, + .shadow_params = .{ params.light_size, 0.0, 0.0, 0.0 }, }; try ctx.descriptors.updateShadowUniforms(ctx.frames.current_frame, &shadow_uniforms); diff --git a/src/game/screens/world.zig b/src/game/screens/world.zig index 4e7b0c8..c190003 100644 --- a/src/game/screens/world.zig +++ b/src/game/screens/world.zig @@ -244,6 +244,8 @@ pub const WorldScreen = struct { .overlay_ctx = self, .cached_cascades = &frame_cascades, .lpv_texture_handle = ctx.lpv_system.getTextureHandle(), + .lpv_texture_handle_g = ctx.lpv_system.getTextureHandleG(), + .lpv_texture_handle_b = ctx.lpv_system.getTextureHandleB(), }; try ctx.render_graph.execute(render_ctx); }