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..fb1889e --- /dev/null +++ b/assets/shaders/vulkan/lpv_inject.comp @@ -0,0 +1,71 @@ +#version 450 + +layout(local_size_x = 4, local_size_y = 4, local_size_z = 4) in; + +struct LightData { + vec4 pos_radius; + vec4 color; +}; + +// SH L1: 3 output images (R, G, B), each storing 4 SH coefficients (L0, L1x, L1y, L1z) +layout(set = 0, binding = 0, rgba32f) uniform writeonly image3D lpv_out_r; +layout(set = 0, binding = 1, rgba32f) uniform writeonly image3D lpv_out_g; +layout(set = 0, binding = 2, rgba32f) uniform writeonly image3D lpv_out_b; + +layout(set = 0, binding = 3) readonly buffer Lights { + LightData lights[]; +} light_buffer; + +layout(push_constant) uniform InjectPush { + vec4 grid_origin_cell; + vec4 grid_params; + uint light_count; +} push_data; + +// SH L1 basis functions (unnormalized for compact storage) +// Y_00 = 0.282095 (DC) +// Y_1m = 0.488603 * {x, y, z} (directional) +const float SH_C0 = 0.282095; +const float SH_C1 = 0.488603; + +void main() { + int gridSize = int(push_data.grid_params.x); + 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); + + // Accumulate SH coefficients per color channel + vec4 sh_r = vec4(0.0); // (L0, L1x, L1y, L1z) for red + vec4 sh_g = vec4(0.0); // for green + vec4 sh_b = vec4(0.0); // for blue + + for (uint i = 0; i < push_data.light_count; i++) { + vec3 light_pos = light_buffer.lights[i].pos_radius.xyz; + float radius = max(light_buffer.lights[i].pos_radius.w, 0.001); + vec3 light_color = light_buffer.lights[i].color.rgb; + + vec3 diff = world_pos - light_pos; + float d = length(diff); + if (d < radius) { + float att = 1.0 - (d / radius); + att *= att; + + // Direction from light to cell (normalized), used for SH L1 directional encoding + vec3 dir = (d > 0.001) ? normalize(diff) : vec3(0.0, 1.0, 0.0); + + // SH L1 projection: project the incoming radiance along direction + vec4 sh_coeffs = vec4(SH_C0, SH_C1 * dir.x, SH_C1 * dir.y, SH_C1 * dir.z); + + sh_r += sh_coeffs * light_color.r * att; + sh_g += sh_coeffs * light_color.g * att; + sh_b += sh_coeffs * light_color.b * att; + } + } + + imageStore(lpv_out_r, cell, sh_r); + imageStore(lpv_out_g, cell, sh_g); + imageStore(lpv_out_b, cell, sh_b); +} diff --git a/assets/shaders/vulkan/lpv_inject.comp.spv b/assets/shaders/vulkan/lpv_inject.comp.spv new file mode 100644 index 0000000..0f5ce64 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..a607ee0 --- /dev/null +++ b/assets/shaders/vulkan/lpv_propagate.comp @@ -0,0 +1,117 @@ +#version 450 + +layout(local_size_x = 4, local_size_y = 4, local_size_z = 4) in; + +// Source SH grids (read) +layout(set = 0, binding = 0, rgba32f) uniform readonly image3D src_r; +layout(set = 0, binding = 1, rgba32f) uniform readonly image3D src_g; +layout(set = 0, binding = 2, rgba32f) uniform readonly image3D src_b; + +// Destination SH grids (write) +layout(set = 0, binding = 3, rgba32f) uniform writeonly image3D dst_r; +layout(set = 0, binding = 4, rgba32f) uniform writeonly image3D dst_g; +layout(set = 0, binding = 5, rgba32f) uniform writeonly image3D dst_b; + +// Occlusion grid +layout(set = 0, binding = 6) readonly buffer OcclusionGrid { + uint data[]; +} occlusion; + +layout(push_constant) uniform PropPush { + uint grid_size; + uint _pad0[3]; + vec4 propagation; +} push_data; + +uint flatIndex(ivec3 cell, int gridSize) { + return uint(cell.x) + uint(cell.y) * uint(gridSize) + uint(cell.z) * uint(gridSize) * uint(gridSize); +} + +// SH L1 constants +const float SH_C0 = 0.282095; +const float SH_C1 = 0.488603; + +// Evaluate SH in a given direction to get the scalar irradiance contribution +float evaluateSH(vec4 sh, vec3 dir) { + return max(0.0, sh.x * SH_C0 + sh.y * SH_C1 * dir.x + sh.z * SH_C1 * dir.y + sh.w * SH_C1 * dir.z); +} + +// Project a scalar value in a given direction into SH coefficients +vec4 projectSH(float value, vec3 dir) { + return vec4(value * SH_C0, value * SH_C1 * dir.x, value * SH_C1 * dir.y, value * SH_C1 * dir.z); +} + +void main() { + int gridSize = int(push_data.grid_size); + ivec3 cell = ivec3(gl_GlobalInvocationID.xyz); + if (any(greaterThanEqual(cell, ivec3(gridSize)))) { + return; + } + + // If current cell is opaque, zero out + uint selfOcc = occlusion.data[flatIndex(cell, gridSize)]; + if (selfOcc != 0u) { + imageStore(dst_r, cell, vec4(0.0)); + imageStore(dst_g, cell, vec4(0.0)); + imageStore(dst_b, cell, vec4(0.0)); + return; + } + + // Center retention + float retention = push_data.propagation.y; + vec4 center_r = imageLoad(src_r, cell) * retention; + vec4 center_g = imageLoad(src_g, cell) * retention; + vec4 center_b = imageLoad(src_b, cell) * retention; + + vec4 accum_r = center_r; + vec4 accum_g = center_g; + vec4 accum_b = center_b; + + float f = push_data.propagation.x; + + // 6-connected neighbor propagation with SH directional transfer + // For each face, evaluate neighbor SH in the transfer direction and re-project + ivec3 offsets[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) + ); + // Direction from neighbor to current cell (transfer direction) + vec3 dirs[6] = vec3[6]( + vec3( 1, 0, 0), vec3(-1, 0, 0), + vec3( 0, 1, 0), vec3( 0,-1, 0), + vec3( 0, 0, 1), vec3( 0, 0,-1) + ); + + for (int i = 0; i < 6; i++) { + ivec3 n = cell + offsets[i]; + if (any(lessThan(n, ivec3(0))) || any(greaterThanEqual(n, ivec3(gridSize)))) { + continue; + } + uint nOcc = occlusion.data[flatIndex(n, gridSize)]; + if (nOcc != 0u) { + continue; + } + + vec3 transferDir = dirs[i]; + + // Load neighbor SH coefficients + vec4 n_r = imageLoad(src_r, n); + vec4 n_g = imageLoad(src_g, n); + vec4 n_b = imageLoad(src_b, n); + + // Evaluate how much light the neighbor sends in the transfer direction + float eval_r = evaluateSH(n_r, transferDir); + float eval_g = evaluateSH(n_g, transferDir); + float eval_b = evaluateSH(n_b, transferDir); + + // Re-project into SH at current cell using the same direction + accum_r += projectSH(eval_r, transferDir) * f; + accum_g += projectSH(eval_g, transferDir) * f; + accum_b += projectSH(eval_b, transferDir) * f; + } + + imageStore(dst_r, cell, accum_r); + imageStore(dst_g, cell, accum_g); + imageStore(dst_b, cell, accum_b); +} diff --git a/assets/shaders/vulkan/lpv_propagate.comp.spv b/assets/shaders/vulkan/lpv_propagate.comp.spv new file mode 100644 index 0000000..fd724bc Binary files /dev/null and b/assets/shaders/vulkan/lpv_propagate.comp.spv differ diff --git a/assets/shaders/vulkan/post_process.frag b/assets/shaders/vulkan/post_process.frag index da5e169..56436ad 100644 --- a/assets/shaders/vulkan/post_process.frag +++ b/assets/shaders/vulkan/post_process.frag @@ -5,12 +5,15 @@ layout(location = 0) out vec4 outColor; layout(set = 0, binding = 0) uniform sampler2D uHDRBuffer; layout(set = 0, binding = 2) uniform sampler2D uBloomTexture; +layout(set = 0, binding = 3) uniform sampler3D uColorLUT; layout(push_constant) uniform PostProcessParams { - float bloomEnabled; // 0.0 = disabled, 1.0 = enabled - float bloomIntensity; // Final bloom blend intensity - float vignetteIntensity; // 0.0 = none, 1.0 = full vignette - float filmGrainIntensity;// 0.0 = none, 1.0 = heavy grain + float bloomEnabled; // 0.0 = disabled, 1.0 = enabled + float bloomIntensity; // Final bloom blend intensity + float vignetteIntensity; // 0.0 = none, 1.0 = full vignette + float filmGrainIntensity; // 0.0 = none, 1.0 = heavy grain + float colorGradingEnabled; // 0.0 = disabled, 1.0 = enabled + float colorGradingIntensity; // LUT blend intensity (0.0 = original, 1.0 = full LUT) } postParams; layout(set = 0, binding = 1) uniform GlobalUniforms { @@ -131,6 +134,22 @@ float random(vec2 uv) { return fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453); } +// LUT-based color grading using a 3D lookup texture. +// Input color should be in [0,1] range (post-tonemapping). +vec3 applyColorGrading(vec3 color, float intensity) { + if (intensity <= 0.0) return color; + + // Clamp to valid LUT range and apply half-texel offset for correct sampling + vec3 lutCoord = clamp(color, 0.0, 1.0); + + // Scale and bias for correct 3D LUT sampling (avoid edge texels) + const float LUT_SIZE = 32.0; + lutCoord = lutCoord * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE; + + vec3 graded = texture(uColorLUT, lutCoord).rgb; + return mix(color, graded, intensity); +} + // Film grain effect - adds animated noise vec3 applyFilmGrain(vec3 color, vec2 uv, float intensity, float time) { if (intensity <= 0.0) return color; @@ -163,6 +182,11 @@ void main() { color = ACESFilm(hdrColor * global.pbr_params.y); } + // Apply LUT-based color grading (after tone mapping, in [0,1] range) + if (postParams.colorGradingEnabled > 0.5) { + color = applyColorGrading(color, postParams.colorGradingIntensity); + } + // Apply vignette effect color = applyVignette(color, inUV, postParams.vignetteIntensity); diff --git a/assets/shaders/vulkan/terrain.frag b/assets/shaders/vulkan/terrain.frag index c5b4a26..7ae64f5 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,11 +102,15 @@ layout(set = 0, binding = 7) uniform sampler2D uRoughnessMap; // Roughness ma layout(set = 0, binding = 8) uniform sampler2D uDisplacementMap; // Displacement map (unused for now) layout(set = 0, binding = 9) uniform sampler2D uEnvMap; // Environment Map (EXR) layout(set = 0, binding = 10) uniform sampler2D uSSAOMap; // SSAO Map +layout(set = 0, binding = 11) uniform sampler3D uLPVGrid; // LPV SH Red channel (4 SH coefficients) +layout(set = 0, binding = 12) uniform sampler3D uLPVGridG; // LPV SH Green channel +layout(set = 0, binding = 13) uniform sampler3D uLPVGridB; // LPV SH Blue channel layout(set = 0, binding = 2) uniform ShadowUniforms { mat4 light_space_matrices[4]; vec4 cascade_splits; vec4 shadow_texel_sizes; + vec4 shadow_params; // x = light_size (PCSS), y/z/w reserved } shadows; layout(set = 0, binding = 3) uniform sampler2DArrayShadow uShadowMaps; @@ -141,18 +147,19 @@ float interleavedGradientNoise(vec2 fragCoord) { return fract(magic.z * fract(dot(fragCoord.xy, magic.xy))); } -float findBlocker(vec2 uv, float zReceiver, int layer) { +// PCSS blocker search using Poisson disk for better spatial distribution. +// searchRadius is derived from light size and receiver depth in light-space. +float findBlocker(vec2 uv, float zReceiver, int layer, float searchRadius, mat2 rot) { float blockerDepthSum = 0.0; int numBlockers = 0; - float searchRadius = 0.0015; - for (int i = -1; i <= 1; i++) { - for (int j = -1; j <= 1; j++) { - vec2 offset = vec2(i, j) * searchRadius; - float depth = texture(uShadowMapsRegular, vec3(uv + offset, float(layer))).r; - if (depth > zReceiver + 0.0001) { - blockerDepthSum += depth; - numBlockers++; - } + // Use first 8 Poisson samples for blocker search (cheaper than full 16) + for (int i = 0; i < 8; i++) { + vec2 offset = (rot * poissonDisk16[i]) * searchRadius; + float depth = texture(uShadowMapsRegular, vec3(uv + offset, float(layer))).r; + // Reverse-Z: blockers have GREATER depth than receiver + if (depth > zReceiver + 0.0002) { + blockerDepthSum += depth; + numBlockers++; } } if (numBlockers == 0) return -1.0; @@ -177,7 +184,6 @@ float computeShadowFactor(vec3 fragPosWorld, vec3 N, vec3 L, int layer) { float tanTheta = sinTheta / NdotL; // Reverse-Z Bias: push fragment CLOSER to light (towards Near=1.0) - // Increased base bias to ensure shadows work when sun is overhead (NdotL ≈ 1) const float BASE_BIAS = 0.0025; const float SLOPE_BIAS = 0.003; const float MAX_BIAS = 0.015; @@ -186,16 +192,38 @@ float computeShadowFactor(vec3 fragPosWorld, vec3 N, vec3 L, int layer) { bias = min(bias, MAX_BIAS); if (vTileID < 0) bias = max(bias, 0.006 * cascadeScale); + // Noise rotation for temporal stability float angle = interleavedGradientNoise(gl_FragCoord.xy) * PI * 0.25; float s = sin(angle); - float c = cos(angle); - mat2 rot = mat2(c, s, -s, c); - + float co = cos(angle); + mat2 rot = mat2(co, s, -s, co); + + // PCSS: Percentage-Closer Soft Shadows + // lightSize in shadow-map UV space, scaled per cascade + float lightSize = shadows.shadow_params.x * texelSize; + const float MIN_RADIUS = 0.0005; + const float MAX_RADIUS = 0.008; + + // Step 1: Blocker search with light-size-proportional search radius + float searchRadius = lightSize * 2.0 * cascadeScale; + searchRadius = clamp(searchRadius, MIN_RADIUS, MAX_RADIUS); + float avgBlockerDepth = findBlocker(projCoords.xy, currentDepth, layer, searchRadius, rot); + + float radius; + if (avgBlockerDepth < 0.0) { + // No blockers found — use minimum PCF radius for contact hardening + radius = MIN_RADIUS * cascadeScale; + } else { + // Step 2: Penumbra estimation + // Reverse-Z: blocker depth > receiver depth means blocker is closer to light + float penumbraWidth = (avgBlockerDepth - currentDepth) / max(avgBlockerDepth, 0.0001) * lightSize; + radius = clamp(penumbraWidth * cascadeScale, MIN_RADIUS * cascadeScale, MAX_RADIUS * cascadeScale); + } + + // Step 3: Variable-radius PCF filtering float shadow = 0.0; - float radius = 0.0015 * cascadeScale; for (int i = 0; i < 16; i++) { vec2 offset = (rot * poissonDisk16[i]) * radius; - // GREATER_OR_EQUAL comparison: returns 1.0 if (currentDepth + bias) >= mapDepth shadow += texture(uShadowMaps, vec4(projCoords.xy + offset, float(layer), currentDepth + bias)); } // shadow factor: 1.0 (Shadowed) to 0.0 (Lit) @@ -277,6 +305,43 @@ vec3 computeIBLAmbient(vec3 N, float roughness) { return textureLod(uEnvMap, envUV, envMipLevel).rgb; } +// SH L1 constants for irradiance reconstruction +const float LPV_SH_C0 = 0.282095; +const float LPV_SH_C1 = 0.488603; + +// Evaluate SH L1 irradiance for a given direction +float evaluateLPVSH(vec4 sh, vec3 dir) { + return max(0.0, sh.x * LPV_SH_C0 + sh.y * LPV_SH_C1 * dir.x + sh.z * LPV_SH_C1 * dir.y + sh.w * LPV_SH_C1 * dir.z); +} + +// Sample the native 3D LPV SH grid and reconstruct directional irradiance using surface normal. +vec3 sampleLPVAtlas(vec3 worldPos, vec3 normal) { + if (global.lpv_params.x < 0.5) return vec3(0.0); + + float gridSize = max(global.lpv_params.w, 1.0); + 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); + } + + // Normalize to [0,1] UV range for hardware trilinear sampling + vec3 uvw = (local + 0.5) / gridSize; + + // Sample 4 SH coefficients per color channel + vec4 sh_r = texture(uLPVGrid, uvw); + vec4 sh_g = texture(uLPVGridG, uvw); + vec4 sh_b = texture(uLPVGridB, uvw); + + // Reconstruct directional irradiance using the surface normal + float irr_r = evaluateLPVSH(sh_r, normal); + float irr_g = evaluateLPVSH(sh_g, normal); + float irr_b = evaluateLPVSH(sh_b, normal); + + return vec3(irr_r, irr_g, irr_b) * global.lpv_params.y; +} + vec3 computeBRDF(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness) { vec3 H = normalize(V + L); vec3 F0 = mix(vec3(DIELECTRIC_F0), albedo, 0.0); @@ -307,14 +372,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, N); + vec3 ambientColor = albedo * (max(min(envColor, IBL_CLAMP) * skyLight * 0.8, vec3(global.lighting.x * 0.8)) + blockLight + indirect) * ao * ssao * shadowAmbientFactor; return ambientColor + Lo; } 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, N); + vec3 ambientColor = albedo * (max(min(envColor, IBL_CLAMP) * skyLight * 0.8, vec3(global.lighting.x * 0.8)) + blockLight + indirect) * ao * ssao * shadowAmbientFactor; vec3 sunColor = global.sun_color.rgb * global.params.w * SUN_RADIANCE_TO_IRRADIANCE / PI; vec3 directColor = albedo * sunColor * nDotL * (1.0 - totalShadow); return ambientColor + directColor; @@ -322,7 +389,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(0.0, 1.0, 0.0)); // LOD uses up-facing normal + vec3 ambientColor = albedo * (max(vec3(skyLightVal * 0.8), vec3(global.lighting.x * 0.4)) + blockLight + indirect) * ao * ssao * shadowAmbientFactor; vec3 sunColor = global.sun_color.rgb * global.params.w * SUN_VOLUMETRIC_INTENSITY / PI; vec3 directColor = albedo * sunColor * nDotL * (1.0 - totalShadow); return ambientColor + directColor; diff --git a/assets/shaders/vulkan/terrain.frag.spv b/assets/shaders/vulkan/terrain.frag.spv index 3058845..29f04cb 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..5a82905 --- /dev/null +++ b/src/engine/graphics/lpv_system.zig @@ -0,0 +1,1018 @@ +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, + + // SH L1: 3 textures per grid (R, G, B channels), each storing 4 SH coefficients as rgba32f + grid_textures_a: [3]rhi_pkg.TextureHandle = .{ 0, 0, 0 }, + grid_textures_b: [3]rhi_pkg.TextureHandle = .{ 0, 0, 0 }, + active_grid_textures: [3]rhi_pkg.TextureHandle = .{ 0, 0, 0 }, + debug_overlay_texture: rhi_pkg.TextureHandle = 0, + grid_size: u32, + cell_size: f32, + 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 = .{}, + occlusion_buffer: Utils.VulkanBuffer = .{}, + occlusion_grid: []u32 = &.{}, + + 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(); + + // Occlusion grid buffer: one u32 per cell (1 = opaque, 0 = transparent) + const occlusion_buffer_size = @as(usize, clamped_grid) * @as(usize, clamped_grid) * @as(usize, clamped_grid) * @sizeOf(u32); + self.occlusion_buffer = try Utils.createVulkanBuffer( + &vk_ctx.vulkan_device, + occlusion_buffer_size, + c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + ); + errdefer self.destroyOcclusionBuffer(); + + try ensureShaderFileExists(INJECT_SHADER_PATH); + try ensureShaderFileExists(PROPAGATE_SHADER_PATH); + + errdefer self.deinitComputeResources(); + try self.initComputeResources(); + + return self; + } + + pub fn deinit(self: *LPVSystem) void { + self.deinitComputeResources(); + self.destroyOcclusionBuffer(); + 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_textures[0]; // R channel (binding 11) + } + + pub fn getTextureHandleG(self: *const LPVSystem) rhi_pkg.TextureHandle { + return self.active_grid_textures[1]; // G channel (binding 12) + } + + pub fn getTextureHandleB(self: *const LPVSystem) rhi_pkg.TextureHandle { + return self.active_grid_textures[2]; // B channel (binding 13) + } + + pub fn getDebugOverlayTextureHandle(self: *const LPVSystem) rhi_pkg.TextureHandle { + 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_textures = self.grid_textures_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); + } + + // Build occlusion grid for opaque block awareness during propagation + self.buildOcclusionGrid(world); + + if (debug_overlay_enabled) { + // Keep debug overlay generation only when overlay is active. + self.buildDebugOverlay(lights[0..], light_count); + 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; + } + + /// Build a per-cell occlusion grid (1 = opaque, 0 = transparent) for the current LPV volume. + /// Stored as packed u32 array where each u32 holds the opacity for one cell. + fn buildOcclusionGrid(self: *LPVSystem, world: *World) void { + const gs = @as(usize, self.grid_size); + const total_cells = gs * gs * gs; + + // Ensure CPU buffer is allocated + if (self.occlusion_grid.len != total_cells) { + if (self.occlusion_grid.len > 0) self.allocator.free(self.occlusion_grid); + self.occlusion_grid = self.allocator.alloc(u32, total_cells) catch { + self.occlusion_grid = &.{}; + return; + }; + } + + @memset(self.occlusion_grid, 0); + + world.storage.chunks_mutex.lockShared(); + defer world.storage.chunks_mutex.unlockShared(); + + const grid_world_size = @as(f32, @floatFromInt(self.grid_size)) * self.cell_size; + const min_x = self.origin.x; + const min_y = self.origin.y; + const min_z = self.origin.z; + const max_x = min_x + grid_world_size; + const max_z = min_z + grid_world_size; + + var iter = world.storage.iteratorUnsafe(); + while (iter.next()) |entry| { + const chunk_data = entry.value_ptr.*; + const chunk = &chunk_data.chunk; + + const chunk_min_x = @as(f32, @floatFromInt(chunk.chunk_x * CHUNK_SIZE_X)); + const chunk_min_z = @as(f32, @floatFromInt(chunk.chunk_z * CHUNK_SIZE_Z)); + const chunk_max_x = chunk_min_x + @as(f32, @floatFromInt(CHUNK_SIZE_X)); + const chunk_max_z = chunk_min_z + @as(f32, @floatFromInt(CHUNK_SIZE_Z)); + + if (chunk_max_x < min_x or chunk_min_x > max_x or chunk_max_z < min_z or chunk_min_z > max_z) { + continue; + } + + var y: u32 = 0; + while (y < CHUNK_SIZE_Y) : (y += 1) { + const world_y = @as(f32, @floatFromInt(y)) + 0.5; + if (world_y < min_y or world_y >= min_y + grid_world_size) continue; + + var z: u32 = 0; + while (z < CHUNK_SIZE_Z) : (z += 1) { + var x: u32 = 0; + while (x < CHUNK_SIZE_X) : (x += 1) { + const block = chunk.getBlock(x, y, z); + if (block == .air) continue; + + const def = block_registry.getBlockDefinition(block); + if (!def.isOpaque()) continue; + + const world_x = chunk_min_x + @as(f32, @floatFromInt(x)) + 0.5; + const world_z = chunk_min_z + @as(f32, @floatFromInt(z)) + 0.5; + + // Map world position to grid cell + const gx = @as(i32, @intFromFloat(@floor((world_x - self.origin.x) / self.cell_size))); + const gy = @as(i32, @intFromFloat(@floor((world_y - self.origin.y) / self.cell_size))); + const gz = @as(i32, @intFromFloat(@floor((world_z - self.origin.z) / self.cell_size))); + + if (gx < 0 or gy < 0 or gz < 0) continue; + const ugx = @as(usize, @intCast(gx)); + const ugy = @as(usize, @intCast(gy)); + const ugz = @as(usize, @intCast(gz)); + if (ugx >= gs or ugy >= gs or ugz >= gs) continue; + + const idx = ugx + ugy * gs + ugz * gs * gs; + self.occlusion_grid[idx] = 1; + } + } + } + } + + // Upload to GPU + if (self.occlusion_buffer.mapped_ptr) |ptr| { + const bytes = std.mem.sliceAsBytes(self.occlusion_grid); + @memcpy(@as([*]u8, @ptrCast(ptr))[0..bytes.len], bytes); + } + } + + fn dispatchCompute(self: *LPVSystem, light_count: usize) !void { + const cmd = self.vk_ctx.frames.command_buffers[self.vk_ctx.frames.current_frame]; + if (cmd == null) return; + + // Transition all 6 SH channel textures (3 per grid) to GENERAL for compute access + for (0..3) |ch| { + const tex_a = self.vk_ctx.resources.textures.get(self.grid_textures_a[ch]) orelse return; + const tex_b = self.vk_ctx.resources.textures.get(self.grid_textures_b[ch]) orelse return; + try self.transitionImage(cmd, tex_a.image.?, self.image_layout_a, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_ACCESS_SHADER_READ_BIT, c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT); + try self.transitionImage(cmd, tex_b.image.?, self.image_layout_b, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_ACCESS_SHADER_READ_BIT, c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT); + } + self.image_layout_a = c.VK_IMAGE_LAYOUT_GENERAL; + 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; + } + + // Transition final textures to SHADER_READ_ONLY for fragment shader sampling + const final_is_a = (self.propagation_iterations % 2) == 0; + const final_textures = if (final_is_a) &self.grid_textures_a else &self.grid_textures_b; + + for (0..3) |ch| { + const final_tex = self.vk_ctx.resources.textures.get(final_textures[ch]) orelse return; + try self.transitionImage(cmd, final_tex.image.?, c.VK_IMAGE_LAYOUT_GENERAL, c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, c.VK_ACCESS_SHADER_WRITE_BIT, c.VK_ACCESS_SHADER_READ_BIT); + } + + if (final_is_a) { + self.image_layout_a = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + self.active_grid_textures = self.grid_textures_a; + } else { + self.image_layout_b = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + self.active_grid_textures = self.grid_textures_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); + + // Native 3D textures for SH L1 LPV: 3 channel textures per grid (R, G, B), + // each storing 4 SH coefficients (L0, L1x, L1y, L1z) as rgba32f. + const tex_config = rhi_pkg.TextureConfig{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + .generate_mipmaps = false, + .is_render_target = false, + }; + + for (0..3) |ch| { + self.grid_textures_a[ch] = try self.rhi.factory().createTexture3D( + self.grid_size, + self.grid_size, + self.grid_size, + .rgba32f, + tex_config, + bytes, + ); + + self.grid_textures_b[ch] = try self.rhi.factory().createTexture3D( + self.grid_size, + self.grid_size, + self.grid_size, + .rgba32f, + tex_config, + bytes, + ); + } + + const debug_size = @as(usize, self.grid_size) * @as(usize, self.grid_size) * 4; + self.debug_overlay_pixels = try self.allocator.alloc(f32, debug_size); + @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_textures = self.grid_textures_a; + self.image_layout_a = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + self.image_layout_b = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + } + + fn destroyGridTextures(self: *LPVSystem) void { + for (0..3) |ch| { + if (self.grid_textures_a[ch] != 0) { + self.rhi.destroyTexture(self.grid_textures_a[ch]); + self.grid_textures_a[ch] = 0; + } + if (self.grid_textures_b[ch] != 0) { + self.rhi.destroyTexture(self.grid_textures_b[ch]); + self.grid_textures_b[ch] = 0; + } + } + if (self.debug_overlay_texture != 0) { + self.rhi.destroyTexture(self.debug_overlay_texture); + 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_textures = .{ 0, 0, 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 destroyOcclusionBuffer(self: *LPVSystem) void { + if (self.occlusion_buffer.buffer != null) { + if (self.occlusion_buffer.mapped_ptr != null) { + c.vkUnmapMemory(self.vk_ctx.vulkan_device.vk_device, self.occlusion_buffer.memory); + self.occlusion_buffer.mapped_ptr = null; + } + c.vkDestroyBuffer(self.vk_ctx.vulkan_device.vk_device, self.occlusion_buffer.buffer, null); + if (self.occlusion_buffer.memory != null) { + c.vkFreeMemory(self.vk_ctx.vulkan_device.vk_device, self.occlusion_buffer.memory, null); + } + self.occlusion_buffer = .{}; + } + if (self.occlusion_grid.len > 0) { + self.allocator.free(self.occlusion_grid); + self.occlusion_grid = &.{}; + } + } + + fn initComputeResources(self: *LPVSystem) !void { + const vk = self.vk_ctx.vulkan_device.vk_device; + + // SH L1: inject needs 3 output images + 1 SSBO = 4 bindings + // propagate needs 3 src + 3 dst images + 1 occlusion SSBO = 7 bindings + // Total images: inject(3) + prop_ab(6) + prop_ba(6) = 15 + // Total buffers: inject(1) + prop_ab(1) + prop_ba(1) = 3 + var pool_sizes = [_]c.VkDescriptorPoolSize{ + .{ .type = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 16 }, + .{ .type = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 4 }, + }; + + 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)); + + // Inject: binding 0,1,2 = output images (R,G,B SH channels), binding 3 = light buffer + const inject_bindings = [_]c.VkDescriptorSetLayoutBinding{ + .{ .binding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 2, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 3, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + }; + var inject_layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); + inject_layout_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + 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)); + + // Propagate: binding 0-2 = src (R,G,B), binding 3-5 = dst (R,G,B), binding 6 = occlusion + const prop_bindings = [_]c.VkDescriptorSetLayoutBinding{ + .{ .binding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 2, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 3, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 4, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 5, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + .{ .binding = 6, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }, + }; + var prop_layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); + prop_layout_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + 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 { + // Resolve all 6 texture resources (3 channels x 2 grids) + var imgs_a: [3]c.VkDescriptorImageInfo = undefined; + var imgs_b: [3]c.VkDescriptorImageInfo = undefined; + for (0..3) |ch| { + const tex_a = self.vk_ctx.resources.textures.get(self.grid_textures_a[ch]) orelse return error.ResourceNotFound; + const tex_b = self.vk_ctx.resources.textures.get(self.grid_textures_b[ch]) orelse return error.ResourceNotFound; + imgs_a[ch] = c.VkDescriptorImageInfo{ .sampler = null, .imageView = tex_a.view, .imageLayout = c.VK_IMAGE_LAYOUT_GENERAL }; + imgs_b[ch] = c.VkDescriptorImageInfo{ .sampler = null, .imageView = tex_b.view, .imageLayout = c.VK_IMAGE_LAYOUT_GENERAL }; + } + var light_info = c.VkDescriptorBufferInfo{ .buffer = self.light_buffer.buffer, .offset = 0, .range = @sizeOf(GpuLight) * MAX_LIGHTS_PER_UPDATE }; + const occlusion_size = @as(usize, self.grid_size) * @as(usize, self.grid_size) * @as(usize, self.grid_size) * @sizeOf(u32); + var occlusion_info = c.VkDescriptorBufferInfo{ .buffer = self.occlusion_buffer.buffer, .offset = 0, .range = @intCast(occlusion_size) }; + + // Inject: bindings 0,1,2 = output R,G,B images (grid A), binding 3 = light buffer + // Propagate A->B: bindings 0-2 = src (A), bindings 3-5 = dst (B), binding 6 = occlusion + // Propagate B->A: bindings 0-2 = src (B), bindings 3-5 = dst (A), binding 6 = occlusion + // Total writes: 4 (inject) + 7 (prop_ab) + 7 (prop_ba) = 18 + var writes: [18]c.VkWriteDescriptorSet = undefined; + var n: usize = 0; + + // --- Inject set --- + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.inject_descriptor_set; + writes[n].dstBinding = @intCast(ch); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_a[ch]; + n += 1; + } + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.inject_descriptor_set; + writes[n].dstBinding = 3; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + writes[n].descriptorCount = 1; + writes[n].pBufferInfo = &light_info; + n += 1; + + // --- Propagate A->B set --- + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ab_descriptor_set; + writes[n].dstBinding = @intCast(ch); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_a[ch]; + n += 1; + } + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ab_descriptor_set; + writes[n].dstBinding = @intCast(ch + 3); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_b[ch]; + n += 1; + } + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ab_descriptor_set; + writes[n].dstBinding = 6; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + writes[n].descriptorCount = 1; + writes[n].pBufferInfo = &occlusion_info; + n += 1; + + // --- Propagate B->A set --- + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ba_descriptor_set; + writes[n].dstBinding = @intCast(ch); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_b[ch]; + n += 1; + } + for (0..3) |ch| { + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ba_descriptor_set; + writes[n].dstBinding = @intCast(ch + 3); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + writes[n].descriptorCount = 1; + writes[n].pImageInfo = &imgs_a[ch]; + n += 1; + } + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.propagate_ba_descriptor_set; + writes[n].dstBinding = 6; + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + writes[n].descriptorCount = 1; + writes[n].pBufferInfo = &occlusion_info; + 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..216b10f 100644 --- a/src/engine/graphics/render_graph.zig +++ b/src/engine/graphics/render_graph.zig @@ -35,6 +35,9 @@ 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, + lpv_texture_handle_g: rhi_pkg.TextureHandle = 0, + lpv_texture_handle_b: rhi_pkg.TextureHandle = 0, // Pointer to frame-local cascade storage, computed once per frame by the first // ShadowPass and reused by subsequent cascade passes to guarantee consistency. cached_cascades: *?CSM.ShadowCascades, @@ -282,6 +285,9 @@ 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); + rhi.bindTexture(ctx.lpv_texture_handle_g, 12); + rhi.bindTexture(ctx.lpv_texture_handle_b, 13); const view_proj = Mat4.perspectiveReverseZ(ctx.camera.fov, ctx.aspect, ctx.camera.near, ctx.camera.far).multiply(ctx.camera.getViewMatrixOriginCentered()); ctx.world.render(view_proj, ctx.camera.position, true); } diff --git a/src/engine/graphics/rhi.zig b/src/engine/graphics/rhi.zig index c898545..9fc1ea8 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); } @@ -476,6 +480,8 @@ pub const RHI = struct { setVignetteIntensity: *const fn (ctx: *anyopaque, intensity: f32) void, setFilmGrainEnabled: *const fn (ctx: *anyopaque, enabled: bool) void, setFilmGrainIntensity: *const fn (ctx: *anyopaque, intensity: f32) void, + setColorGradingEnabled: *const fn (ctx: *anyopaque, enabled: bool) void, + setColorGradingIntensity: *const fn (ctx: *anyopaque, intensity: f32) void, }; pub fn factory(self: RHI) IResourceFactory { @@ -722,4 +728,10 @@ pub const RHI = struct { pub fn setFilmGrainIntensity(self: RHI, intensity: f32) void { self.vtable.setFilmGrainIntensity(self.ptr, intensity); } + pub fn setColorGradingEnabled(self: RHI, enabled: bool) void { + self.vtable.setColorGradingEnabled(self.ptr, enabled); + } + pub fn setColorGradingIntensity(self: RHI, intensity: f32) void { + self.vtable.setColorGradingIntensity(self.ptr, intensity); + } }; diff --git a/src/engine/graphics/rhi_tests.zig b/src/engine/graphics/rhi_tests.zig index 8445317..a497199 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; @@ -327,6 +338,8 @@ const MockContext = struct { .setVignetteIntensity = undefined, .setFilmGrainEnabled = undefined, .setFilmGrainIntensity = undefined, + .setColorGradingEnabled = undefined, + .setColorGradingIntensity = undefined, }; const MOCK_ENCODER_VTABLE = rhi.IGraphicsCommandEncoder.VTable{ diff --git a/src/engine/graphics/rhi_types.zig b/src/engine/graphics/rhi_types.zig index 6d22e85..2031a88 100644 --- a/src/engine/graphics/rhi_types.zig +++ b/src/engine/graphics/rhi_types.zig @@ -167,12 +167,14 @@ pub const ShadowConfig = struct { pcf_samples: u8 = 12, cascade_blend: bool = true, strength: f32 = 0.35, // Cloud shadow intensity (0-1) + light_size: f32 = 3.0, // PCSS light source size (world units) - controls penumbra softness }; pub const ShadowParams = struct { light_space_matrices: [SHADOW_CASCADE_COUNT]Mat4, cascade_splits: [SHADOW_CASCADE_COUNT]f32, shadow_texel_sizes: [SHADOW_CASCADE_COUNT]f32, + light_size: f32 = 3.0, // PCSS light source size for penumbra estimation }; pub const CloudParams = struct { @@ -199,6 +201,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 +240,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..5a6855c 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; @@ -217,6 +218,16 @@ fn setFilmGrainIntensity(ctx_ptr: *anyopaque, intensity: f32) void { ctx.post_process_state.film_grain_intensity = intensity; } +fn setColorGradingEnabled(ctx_ptr: *anyopaque, enabled: bool) void { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + ctx.post_process_state.color_grading_enabled = enabled; +} + +fn setColorGradingIntensity(ctx_ptr: *anyopaque, intensity: f32) void { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + ctx.post_process_state.color_grading_intensity = intensity; +} + fn endFrame(ctx_ptr: *anyopaque) void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); ctx.mutex.lock(); @@ -315,6 +326,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 +356,9 @@ 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, + 12 => ctx.draw.current_lpv_texture_g = resolved, + 13 => ctx.draw.current_lpv_texture_b = resolved, else => ctx.draw.current_texture = resolved, } } @@ -661,6 +682,7 @@ const VULKAN_RHI_VTABLE = rhi.RHI.VTable{ .updateBuffer = updateBuffer, .destroyBuffer = destroyBuffer, .createTexture = createTexture, + .createTexture3D = createTexture3D, .destroyTexture = destroyTexture, .updateTexture = updateTexture, .createShader = createShader, @@ -729,6 +751,8 @@ const VULKAN_RHI_VTABLE = rhi.RHI.VTable{ .setVignetteIntensity = setVignetteIntensity, .setFilmGrainEnabled = setFilmGrainEnabled, .setFilmGrainIntensity = setFilmGrainIntensity, + .setColorGradingEnabled = setColorGradingEnabled, + .setColorGradingIntensity = setColorGradingIntensity, }; fn beginPassTiming(ctx_ptr: *anyopaque, pass_name: []const u8) void { diff --git a/src/engine/graphics/vulkan/descriptor_bindings.zig b/src/engine/graphics/vulkan/descriptor_bindings.zig index 1b8dc23..e777bea 100644 --- a/src/engine/graphics/vulkan/descriptor_bindings.zig +++ b/src/engine/graphics/vulkan/descriptor_bindings.zig @@ -9,3 +9,6 @@ pub const ROUGHNESS_TEXTURE = 7; pub const DISPLACEMENT_TEXTURE = 8; pub const ENV_TEXTURE = 9; pub const SSAO_TEXTURE = 10; +pub const LPV_TEXTURE = 11; // LPV SH Red channel (rgba32f = 4 SH coefficients) +pub const LPV_TEXTURE_G = 12; // LPV SH Green channel +pub const LPV_TEXTURE_B = 13; // LPV SH Blue channel diff --git a/src/engine/graphics/vulkan/descriptor_manager.zig b/src/engine/graphics/vulkan/descriptor_manager.zig index e3b5c64..f2de24d 100644 --- a/src/engine/graphics/vulkan/descriptor_manager.zig +++ b/src/engine/graphics/vulkan/descriptor_manager.zig @@ -22,12 +22,15 @@ 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 { light_space_matrices: [rhi.SHADOW_CASCADE_COUNT]Mat4, cascade_splits: [4]f32, shadow_texel_sizes: [4]f32, + shadow_params: [4]f32, // x = light_size (PCSS), y/z/w reserved }; pub const DescriptorManager = struct { @@ -48,6 +51,7 @@ pub const DescriptorManager = struct { // Dummy textures dummy_texture: rhi.TextureHandle, + dummy_texture_3d: rhi.TextureHandle, dummy_normal_texture: rhi.TextureHandle, dummy_roughness_texture: rhi.TextureHandle, @@ -65,6 +69,7 @@ pub const DescriptorManager = struct { .shadow_ubos = std.mem.zeroes([rhi.MAX_FRAMES_IN_FLIGHT]VulkanBuffer), .shadow_ubos_mapped = std.mem.zeroes([rhi.MAX_FRAMES_IN_FLIGHT]?*anyopaque), .dummy_texture = 0, + .dummy_texture_3d = 0, .dummy_normal_texture = 0, .dummy_roughness_texture = 0, }; @@ -105,6 +110,12 @@ pub const DescriptorManager = struct { return err; }; + // 1x1x1 3D dummy texture for sampler3D bindings (LPV) + self.dummy_texture_3d = resource_manager.createTexture3D(1, 1, 1, .rgba, .{}, &white_pixel) catch |err| { + self.deinit(); + return err; + }; + resource_manager.flushTransfer() catch |err| { self.deinit(); return err; @@ -154,6 +165,12 @@ pub const DescriptorManager = struct { .{ .binding = 9, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, // 10: SSAO Map .{ .binding = 10, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, + // 11: LPV SH Red channel (or scalar RGB when SH disabled) + .{ .binding = 11, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, + // 12: LPV SH Green channel + .{ .binding = 12, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, + // 13: LPV SH Blue channel + .{ .binding = 13, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, }; var layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); diff --git a/src/engine/graphics/vulkan/post_process_system.zig b/src/engine/graphics/vulkan/post_process_system.zig index 47a9067..36b3eaa 100644 --- a/src/engine/graphics/vulkan/post_process_system.zig +++ b/src/engine/graphics/vulkan/post_process_system.zig @@ -10,6 +10,10 @@ pub const PostProcessPushConstants = extern struct { bloom_intensity: f32, vignette_intensity: f32, film_grain_intensity: f32, + color_grading_enabled: f32, // 0.0 = disabled, 1.0 = enabled + color_grading_intensity: f32, // LUT blend intensity (0.0 = original, 1.0 = full LUT) + _pad0: f32 = 0.0, + _pad1: f32 = 0.0, }; pub const PostProcessSystem = struct { @@ -19,6 +23,7 @@ pub const PostProcessSystem = struct { descriptor_sets: [rhi.MAX_FRAMES_IN_FLIGHT]c.VkDescriptorSet = .{null} ** rhi.MAX_FRAMES_IN_FLIGHT, sampler: c.VkSampler = null, pass_active: bool = false, + lut_texture: rhi.TextureHandle = 0, pub fn init( self: *PostProcessSystem, @@ -37,6 +42,7 @@ pub const PostProcessSystem = struct { .{ .binding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, .{ .binding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, .{ .binding = 2, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, + .{ .binding = 3, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, }; var layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); layout_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; @@ -165,6 +171,7 @@ pub const PostProcessSystem = struct { .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 0, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .pImageInfo = &image_info }, .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 1, .descriptorType = c.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, .descriptorCount = 1, .pBufferInfo = &buffer_info }, .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 2, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .pImageInfo = &image_info }, + .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 3, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .pImageInfo = &image_info }, // placeholder for LUT, updated later }; c.vkUpdateDescriptorSets(vk, writes.len, &writes[0], 0, null); } @@ -191,6 +198,27 @@ pub const PostProcessSystem = struct { } } + pub fn updateLUTDescriptor(self: *PostProcessSystem, vk: c.VkDevice, lut_view: c.VkImageView, lut_sampler: c.VkSampler) void { + for (0..rhi.MAX_FRAMES_IN_FLIGHT) |i| { + if (self.descriptor_sets[i] == null) continue; + + var lut_image_info = std.mem.zeroes(c.VkDescriptorImageInfo); + lut_image_info.imageLayout = c.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + lut_image_info.imageView = lut_view; + lut_image_info.sampler = lut_sampler; + + var write = std.mem.zeroes(c.VkWriteDescriptorSet); + write.sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = self.descriptor_sets[i]; + write.dstBinding = 3; + write.descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.descriptorCount = 1; + write.pImageInfo = &lut_image_info; + + c.vkUpdateDescriptorSets(vk, 1, &write, 0, null); + } + } + pub fn deinit(self: *PostProcessSystem, vk: c.VkDevice, descriptor_pool: c.VkDescriptorPool) void { if (self.sampler != null) { c.vkDestroySampler(vk, self.sampler, null); diff --git a/src/engine/graphics/vulkan/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..ca33851 100644 --- a/src/engine/graphics/vulkan/rhi_context_factory.zig +++ b/src/engine/graphics/vulkan/rhi_context_factory.zig @@ -50,7 +50,11 @@ 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.current_lpv_texture_g = 0; + ctx.draw.current_lpv_texture_b = 0; ctx.draw.dummy_texture = 0; + ctx.draw.dummy_texture_3d = 0; ctx.draw.dummy_normal_texture = 0; ctx.draw.dummy_roughness_texture = 0; ctx.mutex = .{}; @@ -79,6 +83,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..1e7ec1a 100644 --- a/src/engine/graphics/vulkan/rhi_context_types.zig +++ b/src/engine/graphics/vulkan/rhi_context_types.zig @@ -103,6 +103,8 @@ const PostProcessState = struct { vignette_intensity: f32 = 0.3, film_grain_enabled: bool = false, film_grain_intensity: f32 = 0.15, + color_grading_enabled: bool = false, + color_grading_intensity: f32 = 1.0, }; const RenderOptions = struct { @@ -122,7 +124,11 @@ const DrawState = struct { current_roughness_texture: rhi.TextureHandle, current_displacement_texture: rhi.TextureHandle, current_env_texture: rhi.TextureHandle, + current_lpv_texture: rhi.TextureHandle, + current_lpv_texture_g: rhi.TextureHandle, + current_lpv_texture_b: rhi.TextureHandle, dummy_texture: rhi.TextureHandle, + dummy_texture_3d: rhi.TextureHandle, dummy_normal_texture: rhi.TextureHandle, dummy_roughness_texture: rhi.TextureHandle, bound_texture: rhi.TextureHandle, @@ -130,6 +136,9 @@ const DrawState = struct { bound_roughness_texture: rhi.TextureHandle, bound_displacement_texture: rhi.TextureHandle, bound_env_texture: rhi.TextureHandle, + bound_lpv_texture: rhi.TextureHandle, + bound_lpv_texture_g: rhi.TextureHandle = 0, + bound_lpv_texture_b: rhi.TextureHandle = 0, bound_ssao_handle: rhi.TextureHandle = 0, bound_shadow_views: [rhi.SHADOW_CASCADE_COUNT]c.VkImageView, descriptors_dirty: [MAX_FRAMES_IN_FLIGHT]bool, diff --git a/src/engine/graphics/vulkan/rhi_frame_orchestration.zig b/src/engine/graphics/vulkan/rhi_frame_orchestration.zig index ad61513..232a299 100644 --- a/src/engine/graphics/vulkan/rhi_frame_orchestration.zig +++ b/src/engine/graphics/vulkan/rhi_frame_orchestration.zig @@ -153,6 +153,9 @@ 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; + const cur_lpv_g = ctx.draw.current_lpv_texture_g; + const cur_lpv_b = ctx.draw.current_lpv_texture_b; var needs_update = false; if (ctx.draw.bound_texture != cur_tex) needs_update = true; @@ -160,6 +163,9 @@ 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; + if (ctx.draw.bound_lpv_texture_g != cur_lpv_g) needs_update = true; + if (ctx.draw.bound_lpv_texture_b != cur_lpv_b) needs_update = true; for (0..rhi.SHADOW_CASCADE_COUNT) |si| { if (ctx.draw.bound_shadow_views[si] != ctx.shadow_system.shadow_image_views[si]) needs_update = true; @@ -172,6 +178,9 @@ 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; + ctx.draw.bound_lpv_texture_g = cur_lpv_g; + ctx.draw.bound_lpv_texture_b = cur_lpv_b; for (0..rhi.SHADOW_CASCADE_COUNT) |si| ctx.draw.bound_shadow_views[si] = ctx.shadow_system.shadow_image_views[si]; } @@ -180,23 +189,28 @@ pub fn prepareFrameState(ctx: anytype) void { std.log.err("CRITICAL: Descriptor set for frame {} is NULL!", .{ctx.frames.current_frame}); return; } - var writes: [10]c.VkWriteDescriptorSet = undefined; + var writes: [14]c.VkWriteDescriptorSet = undefined; var write_count: u32 = 0; - var image_infos: [10]c.VkDescriptorImageInfo = undefined; + var image_infos: [14]c.VkDescriptorImageInfo = undefined; var info_count: u32 = 0; const dummy_tex_entry = ctx.resources.textures.get(ctx.draw.dummy_texture); + const dummy_tex_3d_entry = ctx.resources.textures.get(ctx.draw.dummy_texture_3d); - const atlas_slots = [_]struct { handle: rhi.TextureHandle, binding: u32 }{ - .{ .handle = cur_tex, .binding = bindings.ALBEDO_TEXTURE }, - .{ .handle = cur_nor, .binding = bindings.NORMAL_TEXTURE }, - .{ .handle = cur_rou, .binding = bindings.ROUGHNESS_TEXTURE }, - .{ .handle = cur_dis, .binding = bindings.DISPLACEMENT_TEXTURE }, - .{ .handle = cur_env, .binding = bindings.ENV_TEXTURE }, + const atlas_slots = [_]struct { handle: rhi.TextureHandle, binding: u32, is_3d: bool }{ + .{ .handle = cur_tex, .binding = bindings.ALBEDO_TEXTURE, .is_3d = false }, + .{ .handle = cur_nor, .binding = bindings.NORMAL_TEXTURE, .is_3d = false }, + .{ .handle = cur_rou, .binding = bindings.ROUGHNESS_TEXTURE, .is_3d = false }, + .{ .handle = cur_dis, .binding = bindings.DISPLACEMENT_TEXTURE, .is_3d = false }, + .{ .handle = cur_env, .binding = bindings.ENV_TEXTURE, .is_3d = false }, + .{ .handle = cur_lpv, .binding = bindings.LPV_TEXTURE, .is_3d = true }, + .{ .handle = cur_lpv_g, .binding = bindings.LPV_TEXTURE_G, .is_3d = true }, + .{ .handle = cur_lpv_b, .binding = bindings.LPV_TEXTURE_B, .is_3d = true }, }; for (atlas_slots) |slot| { - const entry = ctx.resources.textures.get(slot.handle) orelse dummy_tex_entry; + const fallback = if (slot.is_3d) dummy_tex_3d_entry else dummy_tex_entry; + const entry = ctx.resources.textures.get(slot.handle) orelse fallback; if (entry) |tex| { image_infos[info_count] = .{ .sampler = tex.sampler, diff --git a/src/engine/graphics/vulkan/rhi_init_deinit.zig b/src/engine/graphics/vulkan/rhi_init_deinit.zig index 5a28e7f..3924279 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; @@ -101,6 +102,7 @@ pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?* setup.updatePostProcessDescriptorsWithBloom(ctx); ctx.draw.dummy_texture = ctx.descriptors.dummy_texture; + ctx.draw.dummy_texture_3d = ctx.descriptors.dummy_texture_3d; ctx.draw.dummy_normal_texture = ctx.descriptors.dummy_normal_texture; ctx.draw.dummy_roughness_texture = ctx.descriptors.dummy_roughness_texture; ctx.draw.current_texture = ctx.draw.dummy_texture; @@ -108,6 +110,9 @@ pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?* ctx.draw.current_roughness_texture = ctx.draw.dummy_roughness_texture; ctx.draw.current_displacement_texture = ctx.draw.dummy_roughness_texture; ctx.draw.current_env_texture = ctx.draw.dummy_texture; + ctx.draw.current_lpv_texture = ctx.draw.dummy_texture_3d; + ctx.draw.current_lpv_texture_g = ctx.draw.dummy_texture_3d; + ctx.draw.current_lpv_texture_b = ctx.draw.dummy_texture_3d; const cloud_vbo_handle = try ctx.resources.createBuffer(8 * @sizeOf(f32), .vertex); std.log.info("Cloud VBO handle: {}, map count: {}", .{ cloud_vbo_handle, ctx.resources.buffers.count() }); @@ -226,6 +231,7 @@ pub fn deinit(ctx: anytype) void { } ctx.resources.destroyTexture(ctx.draw.dummy_texture); + ctx.resources.destroyTexture(ctx.draw.dummy_texture_3d); ctx.resources.destroyTexture(ctx.draw.dummy_normal_texture); ctx.resources.destroyTexture(ctx.draw.dummy_roughness_texture); if (ctx.legacy.dummy_shadow_view != null) c.vkDestroyImageView(ctx.vulkan_device.vk_device, ctx.legacy.dummy_shadow_view, null); diff --git a/src/engine/graphics/vulkan/rhi_pass_orchestration.zig b/src/engine/graphics/vulkan/rhi_pass_orchestration.zig index 7f513bd..e11a1cc 100644 --- a/src/engine/graphics/vulkan/rhi_pass_orchestration.zig +++ b/src/engine/graphics/vulkan/rhi_pass_orchestration.zig @@ -326,6 +326,8 @@ pub fn beginPostProcessPassInternal(ctx: anytype) void { .bloom_intensity = ctx.bloom.intensity, .vignette_intensity = if (ctx.post_process_state.vignette_enabled) ctx.post_process_state.vignette_intensity else 0.0, .film_grain_intensity = if (ctx.post_process_state.film_grain_enabled) ctx.post_process_state.film_grain_intensity else 0.0, + .color_grading_enabled = if (ctx.post_process_state.color_grading_enabled) 1.0 else 0.0, + .color_grading_intensity = ctx.post_process_state.color_grading_intensity, }; c.vkCmdPushConstants(command_buffer, ctx.post_process.pipeline_layout, c.VK_SHADER_STAGE_FRAGMENT_BIT, 0, @sizeOf(PostProcessPushConstants), &push); diff --git a/src/engine/graphics/vulkan/rhi_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_resource_setup.zig b/src/engine/graphics/vulkan/rhi_resource_setup.zig index a20edc1..961057b 100644 --- a/src/engine/graphics/vulkan/rhi_resource_setup.zig +++ b/src/engine/graphics/vulkan/rhi_resource_setup.zig @@ -389,9 +389,56 @@ pub fn createPostProcessResources(ctx: anytype) !void { global_uniform_size, ); + // Create neutral (identity) 3D LUT for color grading and bind it + if (ctx.post_process.lut_texture == 0) { + ctx.post_process.lut_texture = try createNeutralLUT3D(ctx); + } + updatePostProcessLUTDescriptor(ctx); + try ctx.render_pass_manager.createPostProcessFramebuffers(vk, ctx.allocator, ctx.swapchain.getExtent(), ctx.swapchain.getImageViews()); } +/// Generate a 32x32x32 identity LUT where each texel maps to itself: color(r,g,b) = (r,g,b). +fn createNeutralLUT3D(ctx: anytype) !rhi.TextureHandle { + const LUT_SIZE: u32 = 32; + const total = LUT_SIZE * LUT_SIZE * LUT_SIZE; + var data = try ctx.allocator.alloc(u8, total * 4); + defer ctx.allocator.free(data); + + var i: u32 = 0; + var z: u32 = 0; + while (z < LUT_SIZE) : (z += 1) { + var y: u32 = 0; + while (y < LUT_SIZE) : (y += 1) { + var x: u32 = 0; + while (x < LUT_SIZE) : (x += 1) { + data[i + 0] = @intCast((x * 255 + (LUT_SIZE - 1) / 2) / (LUT_SIZE - 1)); + data[i + 1] = @intCast((y * 255 + (LUT_SIZE - 1) / 2) / (LUT_SIZE - 1)); + data[i + 2] = @intCast((z * 255 + (LUT_SIZE - 1) / 2) / (LUT_SIZE - 1)); + data[i + 3] = 255; + i += 4; + } + } + } + + const handle = try ctx.resources.createTexture3D(LUT_SIZE, LUT_SIZE, LUT_SIZE, .rgba, .{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + .generate_mipmaps = false, + .is_render_target = false, + }, data); + return handle; +} + +fn updatePostProcessLUTDescriptor(ctx: anytype) void { + const vk = ctx.vulkan_device.vk_device; + if (ctx.post_process.lut_texture == 0) return; + const tex = ctx.resources.textures.get(ctx.post_process.lut_texture) orelse return; + ctx.post_process.updateLUTDescriptor(vk, tex.view, tex.sampler); +} + pub fn updatePostProcessDescriptorsWithBloom(ctx: anytype) void { const vk = ctx.vulkan_device.vk_device; const bloom_view = if (ctx.bloom.mip_views[0] != null) ctx.bloom.mip_views[0] else return; diff --git a/src/engine/graphics/vulkan/rhi_shadow_bridge.zig b/src/engine/graphics/vulkan/rhi_shadow_bridge.zig index 569d66e..451c2dd 100644 --- a/src/engine/graphics/vulkan/rhi_shadow_bridge.zig +++ b/src/engine/graphics/vulkan/rhi_shadow_bridge.zig @@ -5,6 +5,7 @@ const ShadowUniforms = extern struct { light_space_matrices: [rhi.SHADOW_CASCADE_COUNT]Mat4, cascade_splits: [4]f32, shadow_texel_sizes: [4]f32, + shadow_params: [4]f32, // x = light_size (PCSS), y/z/w reserved }; pub fn beginShadowPassInternal(ctx: anytype, cascade_index: u32, light_space_matrix: Mat4) void { @@ -35,6 +36,7 @@ pub fn updateShadowUniforms(ctx: anytype, params: rhi.ShadowParams) !void { .light_space_matrices = params.light_space_matrices, .cascade_splits = splits, .shadow_texel_sizes = sizes, + .shadow_params = .{ params.light_size, 0.0, 0.0, 0.0 }, }; try ctx.descriptors.updateShadowUniforms(ctx.frames.current_frame, &shadow_uniforms); diff --git a/src/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..c190003 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,9 @@ pub const WorldScreen = struct { .overlay_renderer = renderOverlay, .overlay_ctx = self, .cached_cascades = &frame_cascades, + .lpv_texture_handle = ctx.lpv_system.getTextureHandle(), + .lpv_texture_handle_g = ctx.lpv_system.getTextureHandleG(), + .lpv_texture_handle_b = ctx.lpv_system.getTextureHandleB(), }; try ctx.render_graph.execute(render_ctx); } @@ -233,6 +263,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 +314,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