Files
fs2open.github.com/code/graphics/vulkan/VulkanPostProcessingLighting.cpp
the-eandClaude Opus 5 4c0ff8414d Grow the scene/post render targets when the viewport does
The offscreen targets backing the scene texture and post-processing were
allocated once from gr_screen at gr_init() and never revisited. Anything drawn
while gr_screen was larger got clipped to their edge and stretched back over
the viewport. The game hits this on the SDL window-resize path
(osapi.cpp calls gr_screen_resize()); qtFRED hits it constantly, since its 3D
viewport is a resizable dock widget.

Add gf_resize_render_targets, called from gr_screen_resize(), and implement it
for OpenGL: grow the targets to cover the new gr_screen, rebuilding only the
resolution-dependent resources. The post-processing table, the compiled
shaders and the SMAA lookup textures are resolution-independent and stay
alive, which is what makes this cheap enough to run off a window drag. This
mirrors what the Vulkan backend already does in VulkanPostProcessor::resize();
Vulkan leaves the hook unset because recreateSwapChain() owns it there.

The allocation only ever grows: gr_screen_resize() is called every frame by
qtFRED and repeatedly by the briefing map widget, so tracking the high-water
mark avoids thrashing, and the shrunk state is already handled by
Scene_texture_u_scale/v_scale. The size is also clamped to the hardware limit
up front, so a viewport past that limit stops asking to be resized instead of
rebuilding every frame. If the larger allocation fails outright -- most likely
precisely when growing -- the resize stops before rebuilding the
post-processing targets on top of scene textures that no longer exist.

This replaces Gr_min_render_target_w/h, which sized the targets for the
largest attached display up front -- on a 4K display at 2x scaling that was
over a gigabyte of VRAM, allocated at launch, for a feature that is off by
default and may never be switched on.

Deletions now go through GL_state.Texture.Delete(): a freed texture name the
state cache still holds would make a later Enable() of the recycled name a
silent no-op. This mattered little when teardown only ran at shutdown. While
there, the scene teardown now releases everything setup allocates -- it was
leaking Scene_ldr/composite/luminance/Cockpit_depth and all six MSAA targets --
and post-processing shutdown releases the SMAA lookup textures.

Separately, fix the sampling extents that the above makes reachable:

 - deferred-f.sdr turns gl_FragCoord into a G-buffer coordinate using
   invScreenWidth/Height, which described gr_screen rather than the G-buffer.
   Fixed in both backends; it is currently a no-op under Vulkan, where
   resize() keeps the extent equal to gr_screen, but states the requirement
   instead of relying on that.
 - the MSAA scene-colour copy, the MSAA resolve and the fog pass sampled the
   full [0,1] range of targets that are only filled to the u/v scale.
 - fxaa-v.sdr derived its texcoord from vertPosition, ignoring the
   sub-rectangle the draw call asked for.

Those extents now come from opengl_draw_full_screen_scene_texture() rather
than being open-coded, so a new pass cannot quietly reintroduce the bug. The
volumetric nebula pass is deliberately left unscaled and commented: it uses
fragTexCoord both to reconstruct a ray direction and to sample, which needs a
shader change to separate.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-07 19:45:48 +01:00

882 lines
32 KiB
C++

#include "VulkanPostProcessing.h"
#include <array>
#include "gr_vulkan.h"
#include "VulkanRenderer.h"
#include "VulkanBuffer.h"
#include "VulkanTexture.h"
#include "VulkanPipeline.h"
#include "VulkanDescriptorManager.h"
#include "graphics/util/uniform_structs.h"
#include "graphics/util/primitives.h"
#include "graphics/grinternal.h"
#include "graphics/light.h"
#include "graphics/matrix.h"
#include "graphics/rtao.h"
#include "graphics/shadows.h"
#include "graphics/2d.h"
#include "bmpman/bmpman.h"
#include "lighting/lighting_profiles.h"
#include "lighting/lighting.h"
#include "math/vecmat.h"
#include "mod_table/mod_table.h"
#include "render/3d.h"
#include "tracing/tracing.h"
#include "nebula/neb.h"
#include "mission/missionparse.h"
extern float Sun_spot;
extern int Game_subspace_effect;
extern SCP_vector<light> Lights;
extern int Num_lights;
namespace graphics::vulkan {
// ===== Light Accumulation (Deferred Lighting) =====
void VulkanDeferredLighting::init(PostProcessContext& ctx, const RenderTarget& sceneColor,
const VulkanDeferredGBuffer& gbuffer, const VulkanShadowMap& shadow)
{
m_ctx = &ctx;
m_sceneColor = &sceneColor;
m_gbuffer = &gbuffer;
m_shadow = &shadow;
}
bool VulkanDeferredLighting::initLightVolumes()
{
if (m_lightVolumesInitialized) {
return true;
}
// Generate sphere mesh (16 rings x 16 segments)
{
auto mesh = graphics::util::generate_sphere_mesh(16, 16);
m_sphereMesh.vertexCount = mesh.vertex_count;
m_sphereMesh.indexCount = mesh.index_count;
// Create VBO
vk::BufferCreateInfo vboInfo;
vboInfo.size = mesh.vertices.size() * sizeof(float);
vboInfo.usage = vk::BufferUsageFlagBits::eVertexBuffer;
vboInfo.sharingMode = vk::SharingMode::eExclusive;
try {
m_sphereMesh.vbo = m_ctx->device.createBuffer(vboInfo);
} catch (const vk::SystemError& e) {
nprintf(("vulkan", "VulkanPostProcessor: Failed to create sphere VBO: %s\n", e.what()));
return false;
}
if (!m_ctx->memoryManager->allocateBufferMemory(
m_sphereMesh.vbo, MemoryUsage::CpuToGpu, m_sphereMesh.vboAlloc, MemoryPurpose::Geometry)) {
m_ctx->device.destroyBuffer(m_sphereMesh.vbo);
m_sphereMesh.vbo = nullptr;
return false;
}
auto* mapped = m_ctx->memoryManager->mapMemory(m_sphereMesh.vboAlloc);
if (mapped) {
memcpy(mapped, mesh.vertices.data(), mesh.vertices.size() * sizeof(float));
m_ctx->memoryManager->unmapMemory(m_sphereMesh.vboAlloc);
}
// Create IBO
vk::BufferCreateInfo iboInfo;
iboInfo.size = mesh.indices.size() * sizeof(ushort);
iboInfo.usage = vk::BufferUsageFlagBits::eIndexBuffer;
iboInfo.sharingMode = vk::SharingMode::eExclusive;
try {
m_sphereMesh.ibo = m_ctx->device.createBuffer(iboInfo);
} catch (const vk::SystemError& e) {
nprintf(("vulkan", "VulkanPostProcessor: Failed to create sphere IBO: %s\n", e.what()));
return false;
}
if (!m_ctx->memoryManager->allocateBufferMemory(
m_sphereMesh.ibo, MemoryUsage::CpuToGpu, m_sphereMesh.iboAlloc, MemoryPurpose::Geometry)) {
m_ctx->device.destroyBuffer(m_sphereMesh.ibo);
m_sphereMesh.ibo = nullptr;
return false;
}
mapped = m_ctx->memoryManager->mapMemory(m_sphereMesh.iboAlloc);
if (mapped) {
memcpy(mapped, mesh.indices.data(), mesh.indices.size() * sizeof(ushort));
m_ctx->memoryManager->unmapMemory(m_sphereMesh.iboAlloc);
}
}
// Generate cylinder mesh (16 segments)
{
auto mesh = graphics::util::generate_cylinder_mesh(16);
m_cylinderMesh.vertexCount = mesh.vertex_count;
m_cylinderMesh.indexCount = mesh.index_count;
vk::BufferCreateInfo vboInfo;
vboInfo.size = mesh.vertices.size() * sizeof(float);
vboInfo.usage = vk::BufferUsageFlagBits::eVertexBuffer;
vboInfo.sharingMode = vk::SharingMode::eExclusive;
try {
m_cylinderMesh.vbo = m_ctx->device.createBuffer(vboInfo);
} catch (const vk::SystemError& e) {
nprintf(("vulkan", "VulkanPostProcessor: Failed to create cylinder VBO: %s\n", e.what()));
return false;
}
if (!m_ctx->memoryManager->allocateBufferMemory(
m_cylinderMesh.vbo, MemoryUsage::CpuToGpu, m_cylinderMesh.vboAlloc, MemoryPurpose::Geometry)) {
m_ctx->device.destroyBuffer(m_cylinderMesh.vbo);
m_cylinderMesh.vbo = nullptr;
return false;
}
auto* mapped = m_ctx->memoryManager->mapMemory(m_cylinderMesh.vboAlloc);
if (mapped) {
memcpy(mapped, mesh.vertices.data(), mesh.vertices.size() * sizeof(float));
m_ctx->memoryManager->unmapMemory(m_cylinderMesh.vboAlloc);
}
vk::BufferCreateInfo iboInfo;
iboInfo.size = mesh.indices.size() * sizeof(ushort);
iboInfo.usage = vk::BufferUsageFlagBits::eIndexBuffer;
iboInfo.sharingMode = vk::SharingMode::eExclusive;
try {
m_cylinderMesh.ibo = m_ctx->device.createBuffer(iboInfo);
} catch (const vk::SystemError& e) {
nprintf(("vulkan", "VulkanPostProcessor: Failed to create cylinder IBO: %s\n", e.what()));
return false;
}
if (!m_ctx->memoryManager->allocateBufferMemory(
m_cylinderMesh.ibo, MemoryUsage::CpuToGpu, m_cylinderMesh.iboAlloc, MemoryPurpose::Geometry)) {
m_ctx->device.destroyBuffer(m_cylinderMesh.ibo);
m_cylinderMesh.ibo = nullptr;
return false;
}
mapped = m_ctx->memoryManager->mapMemory(m_cylinderMesh.iboAlloc);
if (mapped) {
memcpy(mapped, mesh.indices.data(), mesh.indices.size() * sizeof(ushort));
m_ctx->memoryManager->unmapMemory(m_cylinderMesh.iboAlloc);
}
}
// Create deferred UBO for light data (host-visible). One full-size region per
// (frame-in-flight, lighting mode); see DEFERRED_UBO_REGIONS.
{
vk::BufferCreateInfo bufInfo;
bufInfo.size = static_cast<vk::DeviceSize>(DEFERRED_UBO_REGIONS) * DEFERRED_UBO_SIZE;
bufInfo.usage = vk::BufferUsageFlagBits::eUniformBuffer;
bufInfo.sharingMode = vk::SharingMode::eExclusive;
try {
m_deferredUBO = m_ctx->device.createBuffer(bufInfo);
} catch (const vk::SystemError& e) {
nprintf(("vulkan", "VulkanPostProcessor: Failed to create deferred UBO: %s\n", e.what()));
return false;
}
if (!m_ctx->memoryManager->allocateBufferMemory(m_deferredUBO, MemoryUsage::CpuToGpu, m_deferredUBOAlloc)) {
m_ctx->device.destroyBuffer(m_deferredUBO);
m_deferredUBO = nullptr;
return false;
}
}
m_lightVolumesInitialized = true;
nprintf(("vulkan", "VulkanPostProcessor: Light volumes initialized (sphere: %u verts/%u idx, cylinder: %u verts/%u idx)\n",
m_sphereMesh.vertexCount, m_sphereMesh.indexCount,
m_cylinderMesh.vertexCount, m_cylinderMesh.indexCount));
return true;
}
void VulkanDeferredLighting::shutdown()
{
if (!m_ctx || !m_ctx->device) {
return;
}
auto destroyMesh = [&](LightVolumeMesh& mesh) {
if (mesh.vbo) { m_ctx->device.destroyBuffer(mesh.vbo); mesh.vbo = nullptr; }
if (mesh.vboAlloc.isValid()) { m_ctx->memoryManager->freeAllocation(mesh.vboAlloc); }
if (mesh.ibo) { m_ctx->device.destroyBuffer(mesh.ibo); mesh.ibo = nullptr; }
if (mesh.iboAlloc.isValid()) { m_ctx->memoryManager->freeAllocation(mesh.iboAlloc); }
mesh.vertexCount = 0;
mesh.indexCount = 0;
};
destroyMesh(m_sphereMesh);
destroyMesh(m_cylinderMesh);
if (m_deferredUBO) {
m_ctx->device.destroyBuffer(m_deferredUBO);
m_deferredUBO = nullptr;
}
if (m_deferredUBOAlloc.isValid()) {
m_ctx->memoryManager->freeAllocation(m_deferredUBOAlloc);
}
if (m_lightAccumFramebuffer) {
m_ctx->device.destroyFramebuffer(m_lightAccumFramebuffer);
m_lightAccumFramebuffer = nullptr;
}
if (m_lightAccumRenderPass) {
m_ctx->device.destroyRenderPass(m_lightAccumRenderPass);
m_lightAccumRenderPass = nullptr;
}
m_lightVolumesInitialized = false;
}
bool VulkanDeferredLighting::initLightAccumPass()
{
// Light accumulation render pass: single RGBA16F color attachment
// loadOp=eLoad (preserves emissive copy), storeOp=eStore
// initialLayout=eColorAttachmentOptimal, finalLayout=eShaderReadOnlyOptimal
{
vk::AttachmentDescription att;
att.format = HDR_COLOR_FORMAT;
att.samples = vk::SampleCountFlagBits::e1;
att.loadOp = vk::AttachmentLoadOp::eLoad;
att.storeOp = vk::AttachmentStoreOp::eStore;
att.stencilLoadOp = vk::AttachmentLoadOp::eDontCare;
att.stencilStoreOp = vk::AttachmentStoreOp::eDontCare;
att.initialLayout = vk::ImageLayout::eColorAttachmentOptimal;
att.finalLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
vk::AttachmentReference colorRef;
colorRef.attachment = 0;
colorRef.layout = vk::ImageLayout::eColorAttachmentOptimal;
vk::SubpassDescription subpass;
subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments = &colorRef;
vk::SubpassDependency dep;
dep.srcSubpass = VK_SUBPASS_EXTERNAL;
dep.dstSubpass = 0;
dep.srcStageMask = vk::PipelineStageFlagBits::eTransfer
| vk::PipelineStageFlagBits::eColorAttachmentOutput;
dep.dstStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput
| vk::PipelineStageFlagBits::eFragmentShader;
dep.srcAccessMask = vk::AccessFlagBits::eTransferWrite
| vk::AccessFlagBits::eColorAttachmentWrite;
dep.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead
| vk::AccessFlagBits::eColorAttachmentWrite
| vk::AccessFlagBits::eShaderRead;
vk::RenderPassCreateInfo rpInfo;
rpInfo.attachmentCount = 1;
rpInfo.pAttachments = &att;
rpInfo.subpassCount = 1;
rpInfo.pSubpasses = &subpass;
rpInfo.dependencyCount = 1;
rpInfo.pDependencies = &dep;
try {
m_lightAccumRenderPass = m_ctx->device.createRenderPass(rpInfo);
} catch (const vk::SystemError& e) {
nprintf(("vulkan", "VulkanPostProcessor: Failed to create light accum render pass: %s\n", e.what()));
return false;
}
}
// Framebuffer using composite image as sole color attachment
return createLightAccumFramebuffer();
}
bool VulkanDeferredLighting::createLightAccumFramebuffer()
{
std::array<vk::ImageView, 1> attachments = { m_gbuffer->compositeView() };
vk::FramebufferCreateInfo fbInfo;
fbInfo.renderPass = m_lightAccumRenderPass;
fbInfo.attachmentCount = static_cast<uint32_t>(attachments.size());
fbInfo.pAttachments = attachments.data();
fbInfo.width = m_ctx->sceneExtent.width;
fbInfo.height = m_ctx->sceneExtent.height;
fbInfo.layers = 1;
try {
m_lightAccumFramebuffer = m_ctx->device.createFramebuffer(fbInfo);
} catch (const vk::SystemError& e) {
nprintf(("vulkan", "VulkanPostProcessor: Failed to create light accum framebuffer: %s\n", e.what()));
return false;
}
return true;
}
bool VulkanDeferredLighting::onResize()
{
if (!m_lightVolumesInitialized) {
// Lazy resources don't exist yet; first render picks up the new views.
return true;
}
// Only the framebuffer is extent/view-dependent: it attaches the G-buffer
// composite view, which the G-buffer resize just recreated. Meshes, the
// deferred UBO, and the render pass are unaffected.
if (m_lightAccumFramebuffer) {
m_ctx->device.destroyFramebuffer(m_lightAccumFramebuffer);
m_lightAccumFramebuffer = nullptr;
}
// If the G-buffer got disabled by a failed resize there is no composite
// view to attach anymore; disable deferred lighting too.
if (!m_gbuffer->isInitialized()) {
return false;
}
return createLightAccumFramebuffer();
}
namespace ltp = lighting_profiles;
static graphics::deferred_light_data* prepare_light_uniforms(light& l, uint8_t* dest, const ltp::profile* lp)
{
auto* light_data = reinterpret_cast<graphics::deferred_light_data*>(dest);
memset(light_data, 0, sizeof(graphics::deferred_light_data));
light_data->lightType = static_cast<int>(l.type);
float intensity =
(Lighting_mode == lighting_mode::COCKPIT) ? lp->cockpit_light_intensity_modifier.handle(l.intensity) : l.intensity;
vec3d diffuse;
diffuse.xyz.x = l.r * intensity;
diffuse.xyz.y = l.g * intensity;
diffuse.xyz.z = l.b * intensity;
light_data->diffuseLightColor = diffuse;
light_data->enable_shadows = 0;
light_data->sourceRadius = l.source_radius;
return light_data;
}
void VulkanDeferredLighting::render(vk::CommandBuffer cmd)
{
TRACE_SCOPE(tracing::ApplyLights);
if (!m_gbuffer->isInitialized()) {
return;
}
// Lazy-init light volumes and accumulation pass on first use
if (!m_lightVolumesInitialized) {
if (!initLightVolumes() || !initLightAccumPass()) {
return;
}
}
auto* pipelineMgr = getPipelineManager();
auto* descriptorMgr = getDescriptorManager();
auto* bufferMgr = getBufferManager();
auto* texMgr = getTextureManager();
if (!pipelineMgr || !descriptorMgr || !bufferMgr || !texMgr) {
return;
}
GR_DEBUG_SCOPE("Build buffer data");
// Sort lights by type (same stable sort as OpenGL)
std::stable_sort(Lights.begin(), Lights.end(), light_compare_by_type);
// Categorize lights
SCP_vector<light> full_frame_lights;
SCP_vector<light> sphere_lights;
SCP_vector<light> cylinder_lights;
for (auto& l : Lights) {
switch (l.type) {
case Light_Type::Directional:
full_frame_lights.push_back(l);
break;
case Light_Type::Cone:
case Light_Type::Point:
sphere_lights.push_back(l);
break;
case Light_Type::Tube:
cylinder_lights.push_back(l);
break;
case Light_Type::Ambient:
break;
}
}
// Add ambient light
{
light& l = full_frame_lights.emplace_back();
memset(&l, 0, sizeof(light));
vec3d ambient;
gr_get_ambient_light(&ambient);
l.r = ambient.xyz.x;
l.g = ambient.xyz.y;
l.b = ambient.xyz.z;
l.type = Light_Type::Ambient;
l.intensity = 1.f;
l.source_radius = 0.f;
}
size_t total_lights = full_frame_lights.size() + sphere_lights.size() + cylinder_lights.size();
if (total_lights == 0) {
return;
}
// Map UBO and pack data
auto* uboMapped = static_cast<uint8_t*>(m_ctx->memoryManager->mapMemory(m_deferredUBOAlloc));
if (!uboMapped) {
return;
}
// Determine alignment requirement
uint32_t uboAlign = getRendererInstance()->getMinUniformBufferOffsetAlignment();
auto alignUp = [uboAlign](uint32_t v) -> uint32_t {
return (v + uboAlign - 1) & ~(uboAlign - 1);
};
// Layout within this pass's region:
// [0]: deferred_global_data (header)
// [aligned offset 1..N]: deferred_light_data per light
// [aligned offset N+1..2N]: matrix_uniforms per light
uint32_t globalDataSize = alignUp(static_cast<uint32_t>(sizeof(graphics::deferred_global_data)));
uint32_t lightDataSize = alignUp(static_cast<uint32_t>(sizeof(graphics::deferred_light_data)));
uint32_t matrixDataSize = alignUp(static_cast<uint32_t>(sizeof(graphics::matrix_uniforms)));
uint32_t lightDataOffset = globalDataSize;
uint32_t matrixDataOffset = lightDataOffset + (static_cast<uint32_t>(total_lights) * lightDataSize);
uint32_t totalUBOSize = matrixDataOffset + (static_cast<uint32_t>(total_lights) * matrixDataSize);
if (totalUBOSize > DEFERRED_UBO_SIZE) {
// Fire a release-visible warning once so a user who hits this knows to
// report it and we know to bump DEFERRED_MAX_LIGHTS / DEFERRED_UBO_SIZE.
// Subsequent overflows this session only log quietly to avoid spam.
static bool warned_deferred_ubo_overflow = false;
if (!warned_deferred_ubo_overflow) {
warned_deferred_ubo_overflow = true;
ReleaseWarning(LOCATION,
"Deferred lighting UBO overflow: needed %u bytes but the buffer is only %u "
"(%d lights this frame). Some lights will not be rendered. Please report this so "
"the deferred light budget (DEFERRED_MAX_LIGHTS) can be increased.",
totalUBOSize, DEFERRED_UBO_SIZE, static_cast<int>(total_lights));
} else {
nprintf(("vulkan", "VulkanPostProcessor: Deferred UBO overflow (%u > %u), skipping lights\n", totalUBOSize, DEFERRED_UBO_SIZE));
}
m_ctx->memoryManager->unmapMemory(m_deferredUBOAlloc);
return;
}
// Nothing may pack over bytes another pass could still be reading. The scene and
// cockpit passes share one command buffer, so the second would otherwise clobber
// the first before either ran; and flip() waits only on the fence of the slot it
// reuses, so frame N+1 packs while frame N's light draws may still be executing.
const uint32_t regionIndex = (descriptorMgr->getCurrentFrame() * static_cast<uint32_t>(lighting_mode::MAX_LIGHTING_MODES))
+ static_cast<uint32_t>(Lighting_mode);
const uint32_t regionBase = regionIndex * DEFERRED_UBO_SIZE;
lightDataOffset += regionBase;
matrixDataOffset += regionBase;
// Pack global header
auto lp = ltp::current();
// Determine if environment maps are available
bool envMapAvailable = (ENVMAP > 0);
tcache_slot_vulkan* envMapSlot = nullptr;
tcache_slot_vulkan* irrMapSlot = nullptr;
if (envMapAvailable) {
texMgr->ensureCubemapUploaded(ENVMAP);
envMapSlot = texMgr->getTextureSlot(ENVMAP);
if (!envMapSlot || !envMapSlot->imageView || !envMapSlot->isCubemap) {
envMapAvailable = false;
}
}
if (envMapAvailable && IRRMAP > 0) {
irrMapSlot = texMgr->getTextureSlot(IRRMAP);
if (!irrMapSlot || !irrMapSlot->imageView || !irrMapSlot->isCubemap) {
irrMapSlot = nullptr; // Fall back to fallback cube for irrmap
}
}
{
auto* header = reinterpret_cast<graphics::deferred_global_data*>(uboMapped + regionBase);
memset(header, 0, sizeof(graphics::deferred_global_data));
// Same as the OpenGL backend: deferred-f.sdr normalizes gl_FragCoord against these to
// sample the G-buffer, so they must describe the G-buffer, not gr_screen. resize() keeps
// sceneExtent equal to gr_screen today, which is why deriving it from the extent is a
// no-op here -- but it states the actual requirement instead of relying on that.
header->invScreenWidth = 1.0f / static_cast<float>(m_ctx->sceneExtent.width);
header->invScreenHeight = 1.0f / static_cast<float>(m_ctx->sceneExtent.height);
header->nearPlane = gr_near_plane;
if (m_shadow->isInitialized() && Shadow_quality != ShadowQuality::Disabled) {
vm_inverse_matrix4(&header->inv_view_matrix, &Shadow_view_matrix_render);
shadow_cascade_params_bind_deferred();
}
}
// Pack per-light data
size_t lightIdx = 0;
int shadowedDirectionalCount = 0;
// Cascade shadow maps are only ever built for a single light (Static_light.front()),
// so only raytraced shadows can shadow more than one directional light here -- a second
// light reading the first light's cascade data would produce incorrect shadows.
int maxShadowedDirectionalLights = shadows_use_raytracing() ? Max_rt_shadow_lights : 1;
for (auto& l : full_frame_lights) {
auto* ld = prepare_light_uniforms(l, uboMapped + lightDataOffset + (lightIdx * lightDataSize), lp);
if (l.type == Light_Type::Directional) {
if (m_shadow->isInitialized() && Shadow_quality != ShadowQuality::Disabled
&& shadowedDirectionalCount < maxShadowedDirectionalLights) {
ld->enable_shadows = 1;
++shadowedDirectionalCount;
} else {
ld->enable_shadows = 0;
}
vec4 light_dir;
light_dir.xyzw.x = -l.vec.xyz.x;
light_dir.xyzw.y = -l.vec.xyz.y;
light_dir.xyzw.z = -l.vec.xyz.z;
light_dir.xyzw.w = 0.0f;
vec4 view_dir;
vm_vec_transform(&view_dir, &light_dir, &gr_view_matrix);
ld->lightDir.xyz.x = view_dir.xyzw.x;
ld->lightDir.xyz.y = view_dir.xyzw.y;
ld->lightDir.xyz.z = view_dir.xyzw.z;
}
// Matrix: env texture matrix for full-frame lights
auto* md = reinterpret_cast<graphics::matrix_uniforms*>(uboMapped + matrixDataOffset + (lightIdx * matrixDataSize));
memset(md, 0, sizeof(graphics::matrix_uniforms));
md->modelViewMatrix = gr_env_texture_matrix;
++lightIdx;
}
// Decide which local (point/tube/cone) lights get raytraced shadows this frame. This
// cap is independent of maxShadowedDirectionalLights above -- directional and local
// lights have very different per-mission counts, so one slider can't sensibly tune
// both. Priority is nearest-to-camera first, ranked across both light-volume groups
// together (sphere_lights covers point/cone, cylinder_lights covers tube) so the cap
// isn't biased toward whichever group happens to be packed first.
SCP_vector<bool> sphereLightShadowed(sphere_lights.size(), false);
SCP_vector<bool> cylinderLightShadowed(cylinder_lights.size(), false);
if (shadows_use_raytraced_local_lights() && m_shadow->isInitialized() && Shadow_quality != ShadowQuality::Disabled) {
struct LocalLightCandidate {
float dist_sq;
bool is_cylinder;
size_t index;
};
SCP_vector<LocalLightCandidate> candidates;
candidates.reserve(sphere_lights.size() + cylinder_lights.size());
auto distSqToEye = [](const vec3d& pos) {
vec3d diff;
vm_vec_sub(&diff, &pos, &Eye_position);
return vm_vec_mag_squared(&diff);
};
for (size_t i = 0; i < sphere_lights.size(); ++i) {
// e.g. WRT_LASER weapon bolt lights -- these fire far too often to be worth
// raytracing a shadow for, and excluding them here is a large, cheap cut to
// the number of lights this selection has to consider every frame.
if (sphere_lights[i].flags & LF_NO_RT_SHADOW) {
continue;
}
candidates.push_back({distSqToEye(sphere_lights[i].vec), false, i});
}
for (size_t i = 0; i < cylinder_lights.size(); ++i) {
if (cylinder_lights[i].flags & LF_NO_RT_SHADOW) {
continue;
}
candidates.push_back({distSqToEye(cylinder_lights[i].vec), true, i});
}
std::sort(candidates.begin(), candidates.end(), [](const LocalLightCandidate& a, const LocalLightCandidate& b) {
return a.dist_sq < b.dist_sq;
});
size_t shadowCount = std::min(candidates.size(), static_cast<size_t>(std::max(Max_rt_shadow_local_lights, 0)));
for (size_t i = 0; i < shadowCount; ++i) {
if (candidates[i].is_cylinder) {
cylinderLightShadowed[candidates[i].index] = true;
} else {
sphereLightShadowed[candidates[i].index] = true;
}
}
}
for (size_t sphereIdx = 0; sphereIdx < sphere_lights.size(); ++sphereIdx) {
light& l = sphere_lights[sphereIdx];
auto* ld = prepare_light_uniforms(l, uboMapped + lightDataOffset + (lightIdx * lightDataSize), lp);
if (l.type == Light_Type::Cone) {
ld->dualCone = (l.flags & LF_DUAL_CONE) ? 1 : 0;
ld->coneAngle = l.cone_angle;
ld->coneInnerAngle = l.cone_inner_angle;
ld->coneDir = l.vec2;
}
ld->enable_shadows = sphereLightShadowed[sphereIdx] ? 1 : 0;
float rad = (Lighting_mode == lighting_mode::COCKPIT)
? lp->cockpit_light_radius_modifier.handle(MAX(l.rada, l.radb))
: MAX(l.rada, l.radb);
ld->lightRadius = rad;
ld->scale.xyz.x = rad * 1.05f;
ld->scale.xyz.y = rad * 1.05f;
ld->scale.xyz.z = rad * 1.05f;
// Matrix: model-view + projection for light volume
auto* md = reinterpret_cast<graphics::matrix_uniforms*>(uboMapped + matrixDataOffset + (lightIdx * matrixDataSize));
g3_start_instance_matrix(&l.vec, &vmd_identity_matrix, true);
md->modelViewMatrix = gr_model_view_matrix;
md->projMatrix = gr_projection_matrix;
g3_done_instance(true);
++lightIdx;
}
for (size_t cylinderIdx = 0; cylinderIdx < cylinder_lights.size(); ++cylinderIdx) {
light& l = cylinder_lights[cylinderIdx];
auto* ld = prepare_light_uniforms(l, uboMapped + lightDataOffset + (lightIdx * lightDataSize), lp);
float rad =
(Lighting_mode == lighting_mode::COCKPIT) ? lp->cockpit_light_radius_modifier.handle(l.radb) : l.radb;
ld->lightRadius = rad;
ld->lightType = LT_TUBE;
ld->enable_shadows = cylinderLightShadowed[cylinderIdx] ? 1 : 0;
vec3d a;
vm_vec_sub(&a, &l.vec, &l.vec2);
auto length = vm_vec_mag(&a);
length += ld->lightRadius * 2.0f;
ld->scale.xyz.x = rad * 1.05f;
ld->scale.xyz.y = rad * 1.05f;
ld->scale.xyz.z = length;
// Matrix: oriented instance matrix for cylinder
auto* md = reinterpret_cast<graphics::matrix_uniforms*>(uboMapped + matrixDataOffset + (lightIdx * matrixDataSize));
vec3d dir, newPos;
matrix orient;
vm_vec_normalized_dir(&dir, &l.vec, &l.vec2);
vm_vector_2_matrix_norm(&orient, &dir, nullptr, nullptr);
vm_vec_scale_sub(&newPos, &l.vec2, &dir, l.radb);
g3_start_instance_matrix(&newPos, &orient, true);
md->modelViewMatrix = gr_model_view_matrix;
md->projMatrix = gr_projection_matrix;
g3_done_instance(true);
++lightIdx;
}
m_ctx->memoryManager->flushMemory(m_deferredUBOAlloc, regionBase, totalUBOSize);
m_ctx->memoryManager->unmapMemory(m_deferredUBOAlloc);
GR_DEBUG_SCOPE("Render light geometry");
// Both fullscreen and volume lights use the same vertex layout (POSITION3).
// For fullscreen lights the shader ignores vertex data and generates positions
// from gl_VertexIndex, but Vulkan requires all declared vertex inputs to have
// matching pipeline attributes and bound buffers.
vertex_layout volLayout;
volLayout.add_vertex_component(vertex_format_data::POSITION3, sizeof(float) * 3, 0);
bool rtShadowsActive = shadows_use_raytracing() && m_shadow->isInitialized() && Shadow_quality != ShadowQuality::Disabled;
unsigned int lightShaderFlags = 0;
if (rtShadowsActive) {
lightShaderFlags |= SDR_FLAG_DEFERRED_RT_SHADOWS;
}
if (rtao_enabled()) {
lightShaderFlags |= SDR_FLAG_DEFERRED_RTAO;
}
if (envMapAvailable) {
lightShaderFlags |= SDR_FLAG_ENV_MAP;
}
PipelineConfig lightConfig;
lightConfig.shaderType = SDR_TYPE_DEFERRED_LIGHTING;
lightConfig.shaderFlags = lightShaderFlags;
lightConfig.vertexLayoutHash = volLayout.hash();
lightConfig.primitiveType = PRIM_TYPE_TRIS;
lightConfig.depthMode = ZBUFFER_TYPE_NONE;
lightConfig.blendMode = ALPHA_BLEND_ADDITIVE;
lightConfig.cullEnabled = false;
lightConfig.depthWriteEnabled = false;
lightConfig.renderPass = m_lightAccumRenderPass;
vk::Pipeline lightPipeline = pipelineMgr->getPipeline(lightConfig, volLayout);
if (!lightPipeline) {
return;
}
vk::PipelineLayout pipelineLayout = pipelineMgr->getPipelineLayout();
// Begin light accumulation render pass
{
vk::RenderPassBeginInfo rpBegin;
rpBegin.renderPass = m_lightAccumRenderPass;
rpBegin.framebuffer = m_lightAccumFramebuffer;
rpBegin.renderArea.offset = vk::Offset2D(0, 0);
rpBegin.renderArea.extent = m_ctx->sceneExtent;
cmd.beginRenderPass(rpBegin, vk::SubpassContents::eInline);
}
// Set viewport and scissor.
// Negative-height viewport (Y-flip) to match the G-buffer render pass: the sphere/cylinder
// light volumes are transformed with the same projMatrix/modelViewMatrix convention used
// for regular scene geometry, which assumes this flip. Without it, point/tube light volumes
// rasterize mirrored vertically relative to the G-buffer they're sampling, misaligning them
// with the actual light position (full-frame directional/ambient lights are unaffected since
// their fullscreen triangle covers every pixel regardless of orientation).
vk::Viewport viewport;
viewport.x = 0.0f;
viewport.y = static_cast<float>(m_ctx->sceneExtent.height);
viewport.width = static_cast<float>(m_ctx->sceneExtent.width);
viewport.height = -static_cast<float>(m_ctx->sceneExtent.height);
viewport.minDepth = 0.0f;
viewport.maxDepth = 1.0f;
cmd.setViewport(0, viewport);
vk::Rect2D scissor;
scissor.offset = vk::Offset2D(0, 0);
scissor.extent = m_ctx->sceneExtent;
cmd.setScissor(0, scissor);
// Pre-build G-buffer texture array (shared across all light draws)
const auto& fallbacks = descriptorMgr->getFallbacks();
std::array<vk::DescriptorImageInfo, VulkanDescriptorManager::MAX_TEXTURE_BINDINGS> gbufTexArray;
gbufTexArray.fill(fallbacks.texture2D);
gbufTexArray[0] = {m_ctx->linearSampler, m_sceneColor->view, vk::ImageLayout::eShaderReadOnlyOptimal};
gbufTexArray[1] = {m_ctx->linearSampler, m_gbuffer->normalView(), vk::ImageLayout::eShaderReadOnlyOptimal};
gbufTexArray[2] = {m_ctx->linearSampler, m_gbuffer->positionView(), vk::ImageLayout::eShaderReadOnlyOptimal};
gbufTexArray[3] = {m_ctx->linearSampler, m_gbuffer->specularView(), vk::ImageLayout::eShaderReadOnlyOptimal};
// Pre-build shadow/env/irr image infos (shared across all light draws).
// Shadow map is depth-only, sampled with a depth-compare sampler
// (sampler2DArrayShadow) for hardware PCF.
vk::DescriptorImageInfo shadowTexInfo;
vk::DescriptorImageInfo shadowRawTexInfo;
if (m_shadow->isInitialized() && m_shadow->depthView()) {
shadowTexInfo = {m_shadow->compareSampler(), m_shadow->depthView(), vk::ImageLayout::eShaderReadOnlyOptimal};
shadowRawTexInfo = {m_shadow->rawSampler(), m_shadow->depthView(), vk::ImageLayout::eShaderReadOnlyOptimal};
}
// Shadow cascade params (projection matrices/distances) are bound per-frame via
// shadow_cascade_params_bind() -> gr_bind_uniform_buffer(), landing in the draw
// manager's pending uniform binding table; pick it up the same way applyMaterial()
// does for other pending UBOs.
vk::DescriptorBufferInfo shadowCascadeParamsInfo;
{
auto* drawManager = getDrawManager();
const auto& pending = drawManager->getPendingUniformBinding(static_cast<size_t>(uniform_block_type::ShadowCascadeParams));
if (pending.valid) {
vk::Buffer buf = bufferMgr->getVkBuffer(pending.bufferHandle);
if (buf) {
shadowCascadeParamsInfo = vk::DescriptorBufferInfo(buf, pending.offset, pending.size);
}
}
}
vk::DescriptorImageInfo envTexInfo;
if (envMapAvailable && envMapSlot) {
envTexInfo = fallbacks.textureCube;
envTexInfo.imageView = envMapSlot->imageView;
}
vk::DescriptorImageInfo irrTexInfo;
if (envMapAvailable && irrMapSlot) {
irrTexInfo = fallbacks.textureCube;
irrTexInfo.imageView = irrMapSlot->imageView;
}
// Helper lambda to allocate + write descriptor sets for a single light draw
auto bindLightDescriptors = [&](size_t li) {
DescriptorWriter writer;
writer.reset(m_ctx->device, fallbacks);
// Set 0: Global
vk::DescriptorSet globalSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Global);
if (!globalSet) return false;
writer.writeSet(DescriptorSetIndex::Global, globalSet);
writer.setBuffer(GlobalBinding::Lights, {m_deferredUBO,
lightDataOffset + (li * lightDataSize), sizeof(graphics::deferred_light_data)});
writer.setBuffer(GlobalBinding::DeferredData, {m_deferredUBO,
regionBase, sizeof(graphics::deferred_global_data)});
writer.setImage(GlobalBinding::ShadowMap, shadowTexInfo);
writer.setImage(GlobalBinding::EnvMap, envTexInfo);
writer.setImage(GlobalBinding::IrradianceMap, irrTexInfo);
writer.setBuffer(GlobalBinding::ShadowCascadeParams, shadowCascadeParamsInfo);
writer.setImage(GlobalBinding::ShadowMapRaw, shadowRawTexInfo);
// Set 1: Material
vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material);
if (!materialSet) return false;
writer.writeSet(DescriptorSetIndex::Material, materialSet);
writer.setImageArray(MaterialBinding::TextureArray, gbufTexArray);
// Set 2: PerDraw
vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw);
if (!perDrawSet) return false;
writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet);
writer.setBuffer(PerDrawBinding::Matrices, {m_deferredUBO,
matrixDataOffset + (li * matrixDataSize), sizeof(graphics::matrix_uniforms)});
writer.flush();
const vk::DescriptorSet sets[] = {globalSet, materialSet, perDrawSet};
writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Global, sets);
return true;
};
cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, lightPipeline);
// Draw full-frame lights (directional + ambient)
// Bind sphere VBO as dummy — shader ignores vertex data for these light types.
lightIdx = 0;
if (!full_frame_lights.empty()) {
cmd.bindVertexBuffers(0, m_sphereMesh.vbo, vk::DeviceSize(0));
for (size_t i = 0; i < full_frame_lights.size(); ++i) {
GR_DEBUG_SCOPE("Deferred apply single dir light");
if (bindLightDescriptors(lightIdx)) {
cmd.draw(3, 1, 0, 0);
}
++lightIdx;
}
}
// Draw sphere lights (point + cone)
if (!sphere_lights.empty()) {
cmd.bindVertexBuffers(0, m_sphereMesh.vbo, vk::DeviceSize(0));
cmd.bindIndexBuffer(m_sphereMesh.ibo, 0, vk::IndexType::eUint16);
for (size_t i = 0; i < sphere_lights.size(); ++i) {
if (bindLightDescriptors(lightIdx)) {
cmd.drawIndexed(m_sphereMesh.indexCount, 1, 0, 0, 0);
}
++lightIdx;
}
}
// Draw cylinder lights (tube)
if (!cylinder_lights.empty()) {
cmd.bindVertexBuffers(0, m_cylinderMesh.vbo, vk::DeviceSize(0));
cmd.bindIndexBuffer(m_cylinderMesh.ibo, 0, vk::IndexType::eUint16);
for (size_t i = 0; i < cylinder_lights.size(); ++i) {
if (bindLightDescriptors(lightIdx)) {
cmd.drawIndexed(m_cylinderMesh.indexCount, 1, 0, 0, 0);
}
++lightIdx;
}
}
// End render pass (composite → eShaderReadOnlyOptimal)
cmd.endRenderPass();
}
} // namespace graphics::vulkan