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/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_inject.comp.spv b/assets/shaders/vulkan/lpv_inject.comp.spv new file mode 100644 index 0000000..1ea0755 Binary files /dev/null and b/assets/shaders/vulkan/lpv_inject.comp.spv differ diff --git a/assets/shaders/vulkan/lpv_propagate.comp b/assets/shaders/vulkan/lpv_propagate.comp new file mode 100644 index 0000000..e2b2293 --- /dev/null +++ b/assets/shaders/vulkan/lpv_propagate.comp @@ -0,0 +1,48 @@ +#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; + } + + // 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; + + 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/lpv_propagate.comp.spv b/assets/shaders/vulkan/lpv_propagate.comp.spv new file mode 100644 index 0000000..4fe29eb Binary files /dev/null and b/assets/shaders/vulkan/lpv_propagate.comp.spv differ 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 3058845..e1a64e1 100644 Binary files a/assets/shaders/vulkan/terrain.frag.spv and b/assets/shaders/vulkan/terrain.frag.spv differ 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 b08cac5..dc97747 100644 Binary files a/assets/shaders/vulkan/terrain.vert.spv and b/assets/shaders/vulkan/terrain.vert.spv differ 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..6c71e03 --- /dev/null +++ b/src/engine/graphics/lpv_system.zig @@ -0,0 +1,840 @@ +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; +// 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; +// 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"; + +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: [4]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 = 6, + }; + + 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, + propagation_factor: f32, + center_retention: f32, + enabled: bool, + update_interval_frames: u32 = 6, + + 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, + + 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), + .propagation_factor = DEFAULT_PROPAGATION_FACTOR, + .center_retention = DEFAULT_CENTER_RETENTION, + .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 ensureShaderFileExists(INJECT_SHADER_PATH); + try ensureShaderFileExists(PROPAGATE_SHADER_PATH); + + errdefer self.deinitComputeResources(); + try self.initComputeResources(); + + 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, debug_overlay_enabled: bool) !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 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; + } + + 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; + 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; + } + + 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); + } + + 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); + + 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 = .{ self.propagation_factor, self.center_retention, 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); + + // 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, + ); + + 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.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); + 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, INJECT_SHADER_PATH, self.allocator); + defer c.vkDestroyShaderModule(vk, inject_module, null); + 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); + 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; +} + +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; + }; +} 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..04d3cf8 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,163 @@ 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; +} + +/// 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) { + 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, + .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; + if (texture_config.is_render_target) usage_flags |= c.VK_IMAGE_USAGE_COLOR_ATTACHMENT_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, texture_config, 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 = texture_config, + .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..06bb801 --- /dev/null +++ b/src/engine/ui/debug_lpv_overlay.zig @@ -0,0 +1,35 @@ +const std = @import("std"); +const rhi = @import("../graphics/rhi.zig"); +const IUIContext = rhi.IUIContext; + +pub const DebugLPVOverlay = struct { + pub const Config = struct { + // 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 - height - config.spacing, + .width = width, + .height = 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..4e7b0c8 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.settings.debug_lpv_overlay_active); + 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,34 @@ 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 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); + + 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 +312,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