Files
fs2open.github.com/code/graphics/vulkan/VulkanRaytracingTlas.cpp
the-eandClaude Opus 5 5e745f7535 Add RTAO and unify raytraced/shadow-mapped shadow softness on sun angular size
Raytraced shadow penumbras needed a hand-authored $SunAngularSize: in
stars.tbl, which no shipped content sets -- so in practice nothing ever
got a penumbra, and the shadow-mapped path's $Shadow Smoothness Factor:
had no relation to it at all. Derive a size from the sun bitmap instead
and drive both methods from the same number.

g3_render_rect_screen_aligned_2d() sizes the sun quad so its rad is the
tangent of the half-angle it subtends, making tan(angular radius) equal
to 0.05 * the mission's +Scale: * the emitting disc's fraction of the
bitmap. That fraction is the area of the pixels at or above 90% of the
brightest one, converted to an equivalent radius. Across 90 sun bitmaps
from retail, the MediaVPs, Blue Planet and BtA it lands at a median of
0.261 (retail's chunkier art at 0.487), and the threshold acts as a
constant factor rather than a per-bitmap judgement call -- Spearman rho
0.96 between a 90% and a 50% cutoff.

Taken literally that yields suns 3.9-8.5x Sol's apparent diameter once
+Scale: is folded in (median 1.55, up to 5.0 in retail's own missions),
so the measurement is scaled by 0.25 and clamped to 1.5 degrees, putting
typical content near Sol. Derivation is the default: $SunAngularSize: 0
asks for hard shadows, an explicit value still wins, and missions can
also set +AngularSize: on an individual sun to override both, with FRED
and QtFRED UI (validation and fallback included) to author it. Bitmaps
that can't be read -- and the deliberately blank sun bitmaps mods ship
to get a light source with no visible disc -- fall back to hard shadows
rather than to the widest possible penumbra.

That angular size now sizes the raytraced penumbra cone directly
(traceShadowRayCone() in shadows.sdr), with sample count following the
same Shadow_quality tier that picks the shadow map's resolution (1 ray
at Low, up to 16 at Ultra) rather than being a separate setting. The
shadow-mapped path's tabled smoothness values were tuned by eye against
Sol-sized retail/MediaVPs art, so shadow_smoothness_scale() in
shadows.cpp scales them by the active sun's size relative to Sol,
clamped to what the shadow map can represent (shadow_clamp_smoothness()).
The two methods still disagree about occluder distance -- the raytracer
uses the real one, the shadow map's tabled values stand in for a blocker
distance pinned to the cascade's own extent -- so RT_SHADOW_SUN_SIZE_CALIBRATION
in shadows.sdr is a constant factor that makes a given $SunAngularSize:
read as roughly the same softness either way; it's the number to revisit
if the shadow map ever grows a real blocker search.

Also adds RTAO (raytraced ambient occlusion) sharing the same TLAS as
the shadow rays, with lighting-profile-driven radius/strength, and lab
controls for RT shadow quality (Low/High), the local light cap, RT
shadow samples, RTAO, and an angular-size override for comparing both
shadow methods against the same sun. All are safe to change live: they
only gate which lights are picked as shadow casters or scale existing
uniforms while filling the per-frame light data.

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

514 lines
18 KiB
C++

// Per-frame TLAS: gathers the current shadow-casting object set (ships,
// asteroids, debris, raw POFs/props) into one top-level acceleration
// structure each frame.
// Split out from VulkanRaytracing.cpp since this is the only part of the
// raytracing manager that reaches into Ships/Asteroids/Debris/Objects, a
// distinctly different dependency set from the BLAS cache in
// VulkanRaytracingBlas.cpp, and the part most likely to churn as shadow-caster
// selection evolves.
#include "VulkanRaytracing.h"
#include "VulkanBarrier.h"
#include "VulkanDeletionQueue.h"
#include "VulkanState.h"
#include "asteroid/asteroid.h"
#include "debris/debris.h"
#include "globalincs/systemvars.h"
#include "graphics/shadows.h"
#include "mod_table/mod_table.h"
#include "model/model.h"
#include "model/modelrender.h"
#include "object/object.h"
#include "render/3d.h"
#include "ship/ship.h"
#include "ship/ship_flags.h"
namespace graphics::vulkan {
// Mirrors model_draw_list::get_view_position() (modelrender.cpp): expresses the camera
// position in the local space of a submodel given that submodel's already-composed
// world orientation/position, which is exactly what model_render_check_detail_box()'s
// render-box/render-sphere distance checks are evaluated against.
//
// `rel_pos` is TLAS-relative (see Shadow_rt_tlas_origin, shadows.h), so the eye is
// converted to the same frame before subtracting.
static bool submodelPassesDetailBox(const polymodel* pm, int submodel_num, const matrix& world_orient, const vec3d& rel_pos)
{
vec3d eye_to_submodel;
vm_vec_sub(&eye_to_submodel, &Eye_position, &Shadow_rt_tlas_origin);
vm_vec_sub2(&eye_to_submodel, &rel_pos);
vec3d local_view_pos;
vm_vec_rotate(&local_view_pos, &eye_to_submodel, &world_orient);
return model_render_check_detail_box(&local_view_pos, pm, submodel_num, 0);
}
void VulkanRaytracingManager::pushInstance(SCP_vector<vk::AccelerationStructureInstanceKHR>& instances,
vk::DeviceAddress blasAddress,
const matrix& orient,
const vec3d& pos,
uint8_t mask)
{
vk::AccelerationStructureInstanceKHR instance;
instance.transform = toVkTransform(orient, pos);
instance.instanceCustomIndex = 0;
instance.mask = mask;
instance.instanceShaderBindingTableRecordOffset = 0;
instance.flags = static_cast<VkGeometryInstanceFlagsKHR>(vk::GeometryInstanceFlagBitsKHR::eTriangleFacingCullDisable);
instance.accelerationStructureReference = blasAddress;
instances.push_back(instance);
}
void VulkanRaytracingManager::addSingleSubmodelInstance(SCP_vector<vk::AccelerationStructureInstanceKHR>& instances,
const polymodel* pm,
const polymodel_instance* pmi,
int submodel_num,
const matrix& orient,
const vec3d& pos,
uint8_t mask)
{
if (submodel_num < 0 || submodel_num >= pm->n_models) {
return;
}
if (pmi != nullptr && pmi->submodel[submodel_num].blown_off) {
return;
}
if (pm->submodel[submodel_num].flags[Model::Submodel_flags::Is_thruster]) {
return;
}
if (!submodelPassesDetailBox(pm, submodel_num, orient, pos)) {
return;
}
const BlasEntry* entry = getOrBuildBlasEntry(pm->id, submodel_num);
if (entry == nullptr) {
return;
}
pushInstance(instances, entry->address, orient, pos, mask);
}
void VulkanRaytracingManager::walkSubmodelTree(SCP_vector<vk::AccelerationStructureInstanceKHR>& instances,
transform_stack& stack,
const polymodel* pm,
const polymodel_instance* pmi,
int submodel_num,
const SubmodelWalkOptions& options)
{
if (submodel_num < 0 || submodel_num >= pm->n_models) {
return;
}
const bsp_info& sm = pm->submodel[submodel_num];
const submodel_instance* smi = nullptr;
if (pmi != nullptr) {
smi = &pmi->submodel[submodel_num];
if (smi->blown_off) {
return;
}
}
// Thrusters are visual effects, not solid geometry -- CSM's shadow pass
// never sets MR_SHOW_THRUSTERS either, so they're skipped unconditionally.
if (sm.flags[Model::Submodel_flags::Is_thruster]) {
return;
}
// Mirrors model_render_children_buffers' transform composition exactly
// (modelrender.cpp) so BLAS-local geometry lands in the same world
// position the rasterized path would draw it at.
matrix submodel_orient = vmd_identity_matrix;
vec3d submodel_offset = sm.offset;
if (smi != nullptr) {
submodel_orient = smi->canonical_orient;
vm_vec_add2(&submodel_offset, &smi->canonical_offset);
}
stack.push(&submodel_offset, &submodel_orient);
matrix4 world_transform = stack.get_transform();
matrix world_orient;
vec3d world_pos;
vm_matrix4_get_orientation(&world_orient, &world_transform);
vm_matrix4_get_offset(&world_pos, &world_transform);
// Mirrors the detail-box gate model_render_children_buffers applies (modelrender.cpp)
// before rendering or recursing into a submodel's children. Without this, a submodel
// the rasterizer only draws inside (or outside) a configured render-box/render-sphere
// distance -- e.g. small greeble/antennae visible up close -- still cast raytraced
// shadows regardless of camera distance, mismatching what was actually on screen and
// producing self-shadowing flicker that only stabilized once the camera cleared the
// model's detail-box distance. A failing check also skips this submodel's whole
// subtree, exactly like the rasterized path (a culled parent hides its children too).
if (!options.skipDetailBoxCheck && !submodelPassesDetailBox(pm, submodel_num, world_orient, world_pos)) {
stack.pop();
return;
}
const BlasEntry* entry = getOrBuildBlasEntry(pm->id, submodel_num);
if (entry != nullptr) {
pushInstance(instances, entry->address, world_orient, world_pos, options.mask);
}
for (int child = sm.first_child; child >= 0; child = pm->submodel[child].next_sibling) {
if (!pm->submodel[child].flags[Model::Submodel_flags::Is_thruster]) {
walkSubmodelTree(instances, stack, pm, pmi, child, options);
}
}
stack.pop();
}
void VulkanRaytracingManager::gatherShadowCasterInstances(SCP_vector<vk::AccelerationStructureInstanceKHR>& instances)
{
// Mirrors the object selection in shadows_render_all() (shadows.cpp) --
// ships/asteroids/debris/raw POFs/props -- but without its per-cascade frustum
// pre-filter, which lives in shadows.cpp's private state (Shadow_frustums)
// and is specific to the rasterized cascade layout. Starting unfiltered is
// simpler and safe (never wrongly excludes a caster); spatial culling of
// the instance set can be added later if profiling shows it's needed.
object* objp = Objects;
for (int i = 0; i <= Highest_object_index; i++, objp++) {
if (objp->flags[Object::Object_Flags::Should_be_dead]) {
continue;
}
switch (objp->type) {
case OBJ_SHIP: {
ship* shipp = &Ships[objp->instance];
if (shipp->flags[Ship::Ship_Flags::Cloaked]) {
continue; // matches ship_render()'s cloak gate around model_render_queue
}
if (shipp->model_instance_num < 0) {
continue; // model_get_instance() asserts on a negative index
}
polymodel_instance* pmi = model_get_instance(shipp->model_instance_num);
if (pmi == nullptr) {
continue;
}
polymodel* pm = model_get(pmi->model_num);
if (pm == nullptr || pm->detail[0] < 0) {
continue;
}
// Tag the viewer's own hull so shadow rays can exclude it (see shadows.h).
uint8_t instanceMask = (objp == Viewer_obj) ? TLAS_MASK_VIEWER_HULL : TLAS_MASK_ALL;
vec3d rel_pos = shadow_rt_relative(objp->pos);
transform_stack stack;
stack.push(&rel_pos, &objp->orient);
walkSubmodelTree(instances, stack, pm, pmi, pm->detail[0], {instanceMask});
break;
}
case OBJ_ASTEROID: {
const asteroid& asp = Asteroids[objp->instance];
int model_num = Asteroid_info[asp.asteroid_type].subtypes[asp.asteroid_subtype].model_number;
polymodel* pm = model_get(model_num);
if (pm == nullptr || pm->detail[0] < 0) {
continue;
}
// Asteroids have no polymodel_instance -- no submodel animation.
vec3d rel_pos = shadow_rt_relative(objp->pos);
transform_stack stack;
stack.push(&rel_pos, &objp->orient);
walkSubmodelTree(instances, stack, pm, nullptr, pm->detail[0]);
break;
}
case OBJ_DEBRIS: {
const debris& db = Debris[objp->instance];
if (!db.flags[Debris_Flags::Used]) {
continue;
}
polymodel* pm = model_get(db.model_num);
if (pm == nullptr) {
continue;
}
polymodel_instance* pmi = db.model_instance_num < 0 ? nullptr : model_get_instance(db.model_instance_num);
// Debris renders a single specific submodel directly in object
// space (submodel_render_queue pushes only the object transform,
// no submodel-local offset -- see modelrender.cpp), not a tree walk.
addSingleSubmodelInstance(instances, pm, pmi, db.submodel_num, objp->orient, shadow_rt_relative(objp->pos));
break;
}
case OBJ_RAW_POF:
case OBJ_PROP: {
// Mirrors shadows.cpp's OBJ_RAW_POF/OBJ_PROP case: same shape as
// OBJ_SHIP (a polymodel + optional polymodel_instance), just
// resolved generically instead of via ship_info/Ships[].
int model_num = object_get_model_num(objp);
polymodel* pm = model_get(model_num);
if (pm == nullptr || pm->detail[0] < 0) {
continue;
}
int instance_num = object_get_model_instance_num(objp);
polymodel_instance* pmi = instance_num < 0 ? nullptr : model_get_instance(instance_num);
vec3d rel_pos = shadow_rt_relative(objp->pos);
transform_stack stack;
stack.push(&rel_pos, &objp->orient);
walkSubmodelTree(instances, stack, pm, pmi, pm->detail[0]);
break;
}
default:
break;
}
}
}
void VulkanRaytracingManager::gatherCockpitShadowCasterInstance(SCP_vector<vk::AccelerationStructureInstanceKHR>& instances)
{
object* objp = Viewer_obj;
if (objp == nullptr || objp->type != OBJ_SHIP || objp->instance < 0) {
return;
}
ship* shipp = &Ships[objp->instance];
ship_info* sip = &Ship_info[shipp->ship_info_index];
if (!shadows_cockpit_casts_shadow(sip)) {
return;
}
polymodel* cockpit_pm = model_get(sip->cockpit_model_num);
if (cockpit_pm == nullptr || cockpit_pm->detail[0] < 0) {
return;
}
polymodel_instance* cockpit_pmi =
shipp->cockpit_model_instance < 0 ? nullptr : model_get_instance(shipp->cockpit_model_instance);
// ship_cockpit_render_offset() is camera-relative when drawing; the TLAS is anchored at the
// true ship position, so add it onto objp->pos (rebased first, to keep the sum precise).
vec3d cockpit_render_offset = ship_cockpit_render_offset(sip, objp);
vec3d cockpit_world_pos = shadow_rt_relative(objp->pos);
vm_vec_add2(&cockpit_world_pos, &cockpit_render_offset);
transform_stack stack;
stack.push(&cockpit_world_pos, &objp->orient);
walkSubmodelTree(instances, stack, cockpit_pm, cockpit_pmi, cockpit_pm->detail[0], {TLAS_MASK_ALL, /* skipDetailBoxCheck */ true});
}
bool VulkanRaytracingManager::ensureInstanceCapacity(FrameTlasResources& frame, vk::DeviceSize requiredBytes)
{
if (requiredBytes <= frame.instanceCapacity) {
return true;
}
m_graphicsQueue.waitIdle();
if (frame.instanceBuffer) {
if (frame.instanceMapped) {
m_memoryManager->unmapMemory(frame.instanceAllocation);
frame.instanceMapped = nullptr;
}
m_device.destroyBuffer(frame.instanceBuffer);
frame.instanceBuffer = nullptr;
}
if (frame.instanceAllocation.isValid()) {
m_memoryManager->freeAllocation(frame.instanceAllocation);
}
frame.instanceCapacity = 0;
if (!createRawBuffer(requiredBytes,
vk::BufferUsageFlagBits::eShaderDeviceAddress |
vk::BufferUsageFlagBits::eAccelerationStructureBuildInputReadOnlyKHR,
MemoryUsage::CpuToGpu,
frame.instanceBuffer,
frame.instanceAllocation)) {
return false;
}
frame.instanceMapped = m_memoryManager->mapMemory(frame.instanceAllocation);
if (frame.instanceMapped == nullptr) {
nprintf(("vulkan", "VulkanRaytracingManager: failed to map TLAS instance buffer\n"));
return false;
}
frame.instanceCapacity = requiredBytes;
return true;
}
bool VulkanRaytracingManager::ensureTlasCapacity(FrameTlasResources& frame, vk::DeviceSize requiredBytes)
{
if (requiredBytes <= frame.tlasCapacity && frame.tlas) {
return true;
}
// The old TLAS may still be referenced by a descriptor set bound earlier in
// the command buffer currently being recorded this frame (e.g. an
// already-rendered model with RT_SHADOWS), not just by prior in-flight
// frames -- destroying it synchronously here invalidates that command
// buffer even though it hasn't been submitted yet. Defer the actual
// destruction via the deletion queue instead of an immediate reset/destroy.
if (frame.tlas) {
getDeletionQueue()->queueAccelerationStructure(frame.tlas.release());
}
if (frame.tlasBuffer) {
getDeletionQueue()->queueBuffer(frame.tlasBuffer, frame.tlasAllocation);
frame.tlasBuffer = nullptr;
frame.tlasAllocation = VulkanAllocation();
}
frame.tlasCapacity = 0;
if (!createRawBuffer(requiredBytes,
vk::BufferUsageFlagBits::eAccelerationStructureStorageKHR | vk::BufferUsageFlagBits::eShaderDeviceAddress,
MemoryUsage::GpuOnly,
frame.tlasBuffer,
frame.tlasAllocation)) {
return false;
}
vk::AccelerationStructureCreateInfoKHR createInfo;
createInfo.buffer = frame.tlasBuffer;
createInfo.offset = 0;
createInfo.size = requiredBytes;
createInfo.type = vk::AccelerationStructureTypeKHR::eTopLevel;
try {
frame.tlas = m_device.createAccelerationStructureKHRUnique(createInfo);
} catch (const vk::SystemError& e) {
nprintf(("vulkan", "VulkanRaytracingManager: failed to create TLAS: %s\n", e.what()));
m_device.destroyBuffer(frame.tlasBuffer);
frame.tlasBuffer = nullptr;
m_memoryManager->freeAllocation(frame.tlasAllocation);
return false;
}
frame.tlasCapacity = requiredBytes;
return true;
}
bool VulkanRaytracingManager::ensureScratchCapacity(FrameTlasResources& frame, vk::DeviceSize requiredBytes)
{
// Over-allocate so the aligned address always fits within the buffer.
vk::DeviceSize paddedSize = requiredBytes + m_scratchAlignment;
if (paddedSize <= frame.tlasScratchCapacity) {
return true;
}
m_graphicsQueue.waitIdle();
if (frame.tlasScratchBuffer) {
m_device.destroyBuffer(frame.tlasScratchBuffer);
frame.tlasScratchBuffer = nullptr;
}
if (frame.tlasScratchAllocation.isValid()) {
m_memoryManager->freeAllocation(frame.tlasScratchAllocation);
}
frame.tlasScratchCapacity = 0;
if (!createRawBuffer(paddedSize,
vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eShaderDeviceAddress,
MemoryUsage::GpuOnly,
frame.tlasScratchBuffer,
frame.tlasScratchAllocation)) {
return false;
}
frame.tlasScratchCapacity = paddedSize;
return true;
}
void VulkanRaytracingManager::buildTlas()
{
if (!m_enabled) {
return;
}
// Idempotence guard: both the shadow path (shadows_render_all ->
// gr_build_shadow_tlas) and the RTAO fallback trigger
// (vulkan_deferred_lighting_finish, for when shadow rendering is disabled)
// may request a TLAS in the same frame -- only the first request builds.
const uint64_t frameNumber = m_bufferManager->getCurrentFrameNumber();
if (m_lastTlasBuildFrame == frameNumber) {
return;
}
m_lastTlasBuildFrame = frameNumber;
// Each frame-in-flight slot owns its own instance/TLAS/scratch buffers (see
// FrameTlasResources' declaration for why a single shared set would race
// across overlapping in-flight frames).
FrameTlasResources& frame = m_frameTlas[currentFrameIndex()];
// Rebase the whole TLAS onto the camera so every coordinate near the viewer stays small;
// float32 has ~1 mm steps at 10 km, which made cockpit-scale shadows shimmer far from the
// mission origin. Shaders receive the matching offset via shadow_ray_view_origin.
Shadow_rt_tlas_origin = Eye_position;
SCP_vector<vk::AccelerationStructureInstanceKHR> instances;
gatherShadowCasterInstances(instances);
gatherCockpitShadowCasterInstance(instances);
if (instances.empty()) {
return; // keep whatever TLAS (if any) was built last time this slot was used
}
vk::DeviceSize requiredInstanceBytes = sizeof(vk::AccelerationStructureInstanceKHR) * instances.size();
if (!ensureInstanceCapacity(frame, requiredInstanceBytes)) {
return;
}
memcpy(frame.instanceMapped, instances.data(), static_cast<size_t>(requiredInstanceBytes));
m_memoryManager->flushMemory(frame.instanceAllocation, 0, requiredInstanceBytes);
vk::AccelerationStructureGeometryInstancesDataKHR instancesData;
instancesData.arrayOfPointers = VK_FALSE;
instancesData.data.deviceAddress = m_device.getBufferAddress(vk::BufferDeviceAddressInfo{frame.instanceBuffer});
vk::AccelerationStructureGeometryKHR geometry;
geometry.geometryType = vk::GeometryTypeKHR::eInstances;
geometry.geometry = instancesData;
geometry.flags = vk::GeometryFlagBitsKHR::eOpaque;
vk::AccelerationStructureBuildGeometryInfoKHR buildInfo;
buildInfo.type = vk::AccelerationStructureTypeKHR::eTopLevel;
buildInfo.flags = vk::BuildAccelerationStructureFlagBitsKHR::ePreferFastTrace;
buildInfo.mode = vk::BuildAccelerationStructureModeKHR::eBuild;
buildInfo.geometryCount = 1;
buildInfo.pGeometries = &geometry;
auto primitiveCount = static_cast<uint32_t>(instances.size());
vk::AccelerationStructureBuildSizesInfoKHR sizeInfo = m_device.getAccelerationStructureBuildSizesKHR(
vk::AccelerationStructureBuildTypeKHR::eDevice, buildInfo, primitiveCount);
if (!ensureTlasCapacity(frame, sizeInfo.accelerationStructureSize)) {
return;
}
if (!ensureScratchCapacity(frame, sizeInfo.buildScratchSize)) {
return;
}
buildInfo.dstAccelerationStructure = frame.tlas.get();
vk::DeviceAddress scratchRaw = m_device.getBufferAddress(vk::BufferDeviceAddressInfo{frame.tlasScratchBuffer});
buildInfo.scratchData.deviceAddress = alignUp(scratchRaw, m_scratchAlignment);
vk::AccelerationStructureBuildRangeInfoKHR range;
range.primitiveCount = primitiveCount;
const vk::AccelerationStructureBuildRangeInfoKHR* pRange = &range;
// Recorded into the *current frame's* command buffer (not a one-shot
// submission) so this doesn't stall the CPU/GPU every frame the way
// buildBlas()'s load-time waitIdle() path would.
vk::CommandBuffer cmd = getStateTracker()->getCommandBuffer();
cmd.buildAccelerationStructuresKHR(1, &buildInfo, &pRange);
// Acceleration structure write -> future shader reads (the fragment shaders
// that ray-query against the TLAS via GlobalBinding::Tlas).
cmdMemoryBarrier(cmd,
vk::PipelineStageFlagBits2::eAccelerationStructureBuildKHR,
vk::AccessFlagBits2::eAccelerationStructureWriteKHR,
vk::PipelineStageFlagBits2::eFragmentShader,
vk::AccessFlagBits2::eAccelerationStructureReadKHR);
}
} // namespace graphics::vulkan