Skip to content

Physically-based lens flares - #7654

Open
The-E wants to merge 42 commits into
scp-fs2open:masterfrom
The-E:pbr_lensflares
Open

Physically-based lens flares#7654
The-E wants to merge 42 commits into
scp-fs2open:masterfrom
The-E:pbr_lensflares

Conversation

@The-E

@The-E The-E commented Jul 25, 2026

Copy link
Copy Markdown
Member

Implements physically-based camera lens flares using the matrix method from Lee & Eisemann, "Practical Real-Time Lens-Flare Rendering" (2013), with the iris/starburst synthesis ported from realflare's aperture kernels.

Every ordered pair of refractive surfaces in a lens prescription produces one two-reflection "ghost" image of the iris, and the starburst is the Fraunhofer transform of that same iris. Ghost enumeration, the per-ghost paraxial ray-transfer matrices and their coated-Fresnel tints are all computed once at table load, so a frame only has to draw one textured quad per ghost.

One camera, one lens

A mission is shot through one lens, and every sun in its background flares through it — that is what keeps two suns' flares consistent with each other instead of looking like they came through different glass. In precedence order:

Where What it sets
$Default Lens: in lens_flares.tbl / *-lens.tbm the lens every mission gets by default
$Camera Lens: in a mission's info section that mission's lens (FRED + qtFRED background editor)
set-camera-lens swaps it at runtime; <none> for no flares, <default> for the table's
lab override live, for tuning only

set-lens-aperture / -grating / -scratches / -dust restyle the mounted lens's iris from a mission. They deliberately take no lens name, so a mission cannot edit a lens it isn't looking through. All lens edits are undone when the mission ends.

Per-sun opt-out is the pre-existing $NoGlare:, which the flare path already honours.

Backwards compatibility

Nothing changes for existing content. The shipped lens_flares.tbl leaves $Default Lens: unset, so a mod gets no flares until it opts in — either with one line in a *-lens.tbm or per mission. When a lens is mounted it suppresses that sun's legacy sprite $Flare: path, and if the lens has a starburst the bitmap sun glow is skipped too, so the old and new effects never stack.

The pass needs post-processing; with it off, or in a full nebula, or in VR (a per-eye camera artifact would be wrong), it simply doesn't run. Both renderer backends are covered — opengl_post_pass_lens_flare() and VulkanLensFlare — so this is not a Vulkan-only feature.

Structure

Four translation units behind one public header, graphics/lens_flare.h, with lens_flare_internal.h as the private interface between them:

  • lens_flare_optics.cpp — ray-transfer matrices, ghost enumeration, coated-Fresnel reflectance
  • lens_flare_aperture.cpp — the iris mask and the starburst that is its Fraunhofer transform (CPU FFT)
  • lens_flare_table.cpplens_flares.tbl / *-lens.tbm parsing; holds no state
  • lens_flare.cpp — module state, the camera lens, the texture cache, the per-frame build

The first two are pure functions of a prescription or an iris and touch no engine state at all. Rendering is one instanced draw per visible sun (the flare axis and tint are per-sun; the prescription, iris and starburst are not), sharing one texture bind for the whole pass, composited additively into the HDR scene colour immediately before bloom so bloom and the tonemapper treat the flare's energy like any other scene light.

Because the flare is fused into the pre-tonemap buffer, the SDR and HDR tonemappers would otherwise render an identically-tuned flare at very different brightness, so the HDR contribution is rescaled by Hdr_flare_headroom / LENS_FLARE_SDR_REFERENCE_WHITE, with SDR as the calibration reference.

Commits

  1. Add physically-based lens flares — the engine feature, both backends, tables, tests
  2. Expose the camera lens to mission designers and the lab — mission field, editors, sexps, lab panel

The first stands alone (it configures and builds without the second), so bisect stays clean.

Testing

  • 18 unit tests covering the optics (analytic focal length, unimodular ghost matrices, coated-Fresnel edge cases), the FFT, the iris rasterizer's geometry and imperfection layers, table parsing of every aperture field, lens mounting/override/reset, and the sexp registration/argument-type tables.
  • Verified in-game on both backends with two suns in frame, each flaring through the mounted lens at its own field angle, with Vulkan validation layers enabled and clean.
  • Built warning-clean on Linux/GCC (Debug) with the tests enabled.

Not yet verified, and where review attention would help most:

  • The FRED (MFC) changes — new IDC_CAMERA_LENS combo, fred.rc dialog resize, resource.h id — are Windows-only and have not been compiled here. CI's Windows leg is their first real test.
  • Final visual sign-off on brightness calibration, and the HDR path in particular, is still in progress.
  • Table syntax documentation for the wiki is written but not yet published.

🤖 Generated with Claude Code

@The-E
The-E marked this pull request as ready for review July 26, 2026 10:27
@The-E The-E added enhancement A new feature or upgrade of an existing feature to add additional functionality. graphics A feature or issue related to graphics (2d and 3d) labels Jul 26, 2026
@The-E
The-E force-pushed the pbr_lensflares branch 5 times, most recently from 0768a0b to 81b18a9 Compare August 2, 2026 16:44
The-E and others added 20 commits August 7, 2026 18:55
Adds ImPlot as a build dependency, to be used for the upcoming ImGui
frame profiler overlay's frametime graph and pie chart.
Replaces the old text-dump frame profile display (-profile_frame_time)
with a runtime-toggleable ImGui/ImPlot overlay showing average/median
frametime, a scrolling frametime graph, and a pie chart of the top
contributors to frame time. Toggled via a new "Frame Profiler Overlay"
option, which also seeds from -profile_frame_time for backward
compatibility and now drives frame profiling collection at runtime
instead of only at startup.
- Integrated per-backend diagnostic counters into the ImGui frame profiler overlay, triggered by `-gr_debug`.
- Consolidated and cleaned up Vulkan rendering state and descriptor management for compatibility with ImGui's Vulkan pipeline.
- Enabled uniform buffer and draw statistics reporting for enhanced performance insights during debugging.
- Adjust copyright notices for extended license coverage (2025-2026).
- Modernize constants with `constexpr` replacements, improving type safety and clarity.
- Enforce `nullptr` usage over `NULL` throughout, aligning with modern C++ practices.
- Add various enhancements to ImPlot legends, markers, and axes behavior.
- Introduce additional helper methods for time handling and timestamp calculations.
- Address UI and typo improvements in comments and documentation, refining accuracy.
- Introduced `accumulate_self_times` for single-pass exclusive time computation, improving performance and accuracy.
- Updated `FrameProfiler` and overlay snapshot logic to leverage `Category`'s stable `getId` and `getCount`.
- Replaced pie chart with a stacked bar for frame budget visualization, optimizing rendering cost.
- Cached `get_tid` and `get_pid` with thread-local storage to minimize syscall overhead.
- Added support for unit testing frame profiling functionality and included new test cases.
- Improved code comments and clarified documentation.
…Vulkan

The Material and PerDraw descriptor sets were reallocated and fully rewritten on
essentially every draw call. model_draw_list::render_buffer rebinds ModelData with
a fresh uniform buffer offset for each queued draw, and that offset was part of the
Material set's memoization key, so the cache in VulkanDrawManager::applyMaterial
missed every time - taking a frame-pool allocation, a full template write, the
16-entry material texture resolution and a vkUpdateDescriptorSets with it.
GenericData/Matrices did the same to the PerDraw set. renderShadowDraw was worse
still: all three sets allocated and rewritten per shadow draw, with no memoization
at all.

A large asteroid field (Solaris m21) measured 2566 descriptor sets and 19107
descriptor writes per frame at 1606 model draws, and grew an extra descriptor pool
chunk every frame.

Declare the bindings whose offset moves per draw - MaterialBinding::ModelData,
MaterialBinding::ShadowMapData, PerDrawBinding::GenericData and
PerDrawBinding::Matrices - as eUniformBufferDynamic. DescriptorWriter::setBuffer
now splits a dynamic binding's offset out of the descriptor, writing
{buffer, 0, range} and stashing the offset for vkCmdBindDescriptorSets'
pDynamicOffsets, so an offset-only change leaves the set's contents identical and
the memoization caches start hitting. Shader sources are unchanged.

VulkanStateTracker::bindDescriptorSet now compares the dynamic offsets as well as
the set handle. This is required, not an optimization: the common case is now the
same set rebound with a new offset, and the old handle-only redundancy check would
have skipped that bind and fed every draw the previous draw's uniforms.

renderShadowDraw memoizes its three sets the way applyMaterial does. It does not
invalidate applyMaterial's caches - those record what a set contains, the shadow
pass allocates its own sets rather than overwriting theirs, and it binds through
the state tracker, so the next material cache hit still rebinds correctly.

Also switch allocateFrameSet to the non-allocating allocateDescriptorSets overload;
the vector-returning one heap-allocated once per set, thousands of times a frame.
MAX_SETS_PER_POOL stays at 1024 - the per-frame pool growth it used to log was a
symptom of the churn, and one chunk now covers the scene outright.

Measured on that mission in a Debug build, A/B on the same tree with and without
this change:

  descriptor sets/frame  @ ~1370 draws  2566   -> 250-345
  descriptor writes/frame                19107 -> ~1700-2300
  median frametime                       92.9/83.6 ms -> 72.0/66.5 ms
  Render Buffer (profiler overlay)       24.68 ms (30.8%) -> 12.73 ms (18.4%)
  Build Shadow Map                       5.44 ms -> off the top-5 entirely

Release builds already inlined most of this away and are unchanged, as expected.
Verified with the validation layers (sync validation enabled): no new VUIDs versus
the same build without the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Consolidated dynamic descriptor offset logic into `DescriptorWriter::bindSets`, replacing repetitive `vkCmdBindDescriptorSets` patterns across Vulkan modules.
- Eliminated redundant descriptor binding operations for Material and PerDraw sets by centralizing logic in `bindSets`.
- Simplified descriptors by directly splitting offset changes from descriptor contents, avoiding unnecessary updates.
- Updated comments and documentation to align with the new approach while maintaining reference consistency.
- Applied optimizations across Vulkan modules (`VulkanPostProcessing`, `VulkanDescriptorManager`, etc.) to reduce per-draw overhead and improve readability.
- Added `topologySupportsPrimitiveRestart` to determine restart eligibility for each topology.
- Updated `primitiveRestartEnable` to be conditionally set based on topology, addressing compatibility issues with Metal/MoltenVK.
- Prevented crash scenarios on macOS by ensuring pipelines are valid for strip/fan topologies requiring primitive restart.
…dings

- Introduced `DYNAMIC_SLOT_INVALID` sentinel to replace the throw in `dynamic_offset_slot` for MSVC compatibility.
- Added `static_assert` checks to validate binding constants, ensuring compile-time validation for dynamic offsets.
- Updated comments to clarify design rationale and avoid potential misuse.
…ular 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 <noreply@anthropic.com>
The shadow-mapped path's penumbra width was a fixed per-cascade value
(shadow_smoothness_scale()'s ceiling), so every occluder blurred its
shadow by the same amount regardless of how close it actually was to
the receiver -- unlike the raytraced path, which gets that for free
from traceShadowRayCone()'s real occluder distance. Close that gap
with PCSS: search nearby texels for occluders, then scale the filter
radius by how far away they were.

The blocker search (pcssBlockerSearch() in shadows.sdr) needs raw,
uncompared depth reads, which the existing PCF sampler can't do -- it's
bound with a compare mode for the hardware shadow-compare instructions
the filtered sampling itself relies on. So shadow maps now get a second
sampler, shadow_map_raw, bound to the same texture with compare mode
off; on OpenGL that needs a second sampler object and GL 3.3
(shadow_contact_hardening_supported()), Vulkan can always bind it.
Where the raw sampler isn't available, or the new Graphics.ShadowContactHardening
option is off, shadows_start_render() sets Shadow_penumbra_scale[cascade]
to -1 as a sentinel and pcssPenumbraRadius() (shadows.sdr) falls straight
back to the fixed radius, bit-identical to the pre-contact-hardening
behavior and skipping the extra texture reads.

When it is on, shadows_start_render() computes each cascade's penumbra
scale from its own frustum extents and the sun's tanθ; the shader turns
that plus the blocker search's average depth into a per-pixel radius,
clamped to [1 texel, the old fixed ceiling]. A blocker search that finds
nothing doesn't fall back to "no shadow" -- a sparse 8-tap search can
miss a thin occluder, so it instead runs the full filter at the hardest
(1-texel) radius, trading a touch of extra blur for not popping a real
shadow to nothing.

Drops RT_SHADOW_SUN_SIZE_CALIBRATION accordingly: that constant existed
only to fake a blocker-distance-driven penumbra on the shadow-mapped path
by pre-scaling the raytraced cone to match it, and the shadow-mapped path
now has a real blocker search of its own, so the raytraced cone goes back
to its physically correct size.
The offscreen render targets that back post-processing -- the scene
textures and the post-processing surfaces -- are allocated once from
gr_screen.max_w/max_h at renderer init and never resized. The game never
notices, because its window size is fixed after gr_init(). qtFRED does:
FredRenderer::render_frame() calls gr_screen_resize() every frame to match
a dockable, resizable widget, so growing the viewport past the startup size
clipped the render to the old, smaller texture and then stretched it back
over the new, larger viewport.

Add Gr_min_render_target_w/h as a floor those allocations respect, and have
qtFRED set it to the largest size its viewport can reach (the biggest screen
the window could be maximized onto, in device pixels) before calling
gr_init(). Left at 0 for the game, which keeps sizing them purely from
gr_screen.

That makes Scene_texture_u_scale/v_scale meaningful outside the game for the
first time, which turns up two latent bugs:

  - Seven post-processing passes passed Scene_texture_u_scale for both axes.
    Harmless while the scene texture matches the screen exactly (u == v),
    wrong the moment it doesn't.
  - The bloom bright pass hardcoded 1.0/1.0. Unlike the passes after it, it
    reads the scene texture directly rather than an already-cropped
    intermediate, so it has to confine itself to the sub-rectangle that was
    actually rendered into.

Finally, expose the pipeline in qtFRED at all: a View menu toggle brackets
the 3D scene in gr_scene_texture_begin/end, the same way game_render_frame()
brackets its own. Off by default, and it covers only the 3D world content --
the 2D overlays stay outside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduce post-processing, shadow quality, anti-aliasing, anisotropic filtering, texture filtering, MSAA, and gamma controls to qtFRED's Preferences. Ensures real-time application of certain settings and requires restart for others. Adjust graphics initialization to support baked settings before `gr_init()`. Update documentation to reflect these additions.
opengl_tcache_init() read GL_mipmap_filter out of TextureFilteringOption
before seeding it from the legacy config key. That option's default_func
returns GL_mipmap_filter itself, so with no persisted "Graphics.TextureFilter"
value getValue() fell through to a still-zero-initialized global -- bilinear,
where the config default is trilinear. Since in-game options are on by default,
that affected every player who had never explicitly set the option, and it
ignored the legacy TextureFilter key existing installs were configured through.

Seed from config first, then let the option override, matching the order the
anisotropy setting below already uses (its default_func queries the hardware
directly, which is why it was never affected).

Also route the option's value enumerator through the new shared
gr_get_supported_anisotropy_levels() rather than a private copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The Preferences > Graphics settings were read from QSettings in two places --
management.cpp before gr_init(), and EditorViewport once the editor exists --
each with its own copy of the key strings and default values, so renaming a key
in one would silently stop the other from working. The split between settings
that apply live and settings that need a restart was recorded only in comments
that had to stay in sync across five files.

Move both into GraphicsSettings, which owns the keys, the defaults, and the two
apply paths (applyLive() and applyBeforeGrInit()). ViewSettings holds one of
these instead of seven loose fields.

Behaviour fixes that fall out of having one reader:

 - Values are range-checked before being cast to ShadowQuality/AntiAliasMode
   and validated against the MSAA list, matching what the neighbouring
   DataMenuStyle load already did. A hand-edited settings file no longer
   produces an out-of-range enum.
 - The two OptionsManager overrides are now consistent. Texture filtering was
   overridden unconditionally with a hardcoded default while anisotropy was
   guarded, so a fresh install masked the engine's own texture-filter default
   for no reason. Both now use a sentinel for "the user has not chosen" and
   leave the engine option alone until there is a real choice.
 - gr_set_gamma(3.0f) is no longer duplicated as a literal next to the struct
   default.

The dialog's value lists now come from the engine option definitions
(Graphics.Shadows, Graphics.AAMode, Graphics.TextureFilter) instead of the
hardcoded .ui items that duplicated them, and anisotropy uses the shared
gr_get_supported_anisotropy_levels() rather than a near-verbatim copy of the
engine's enumerator. Adding an AA mode upstream no longer silently desyncs
qtFRED's dropdown. Note that the shadow-quality entries therefore lose the
"(restart required)" suffix they carried in the .ui; the control's tooltip and
the help page still say it.

Populating combos now blocks signals: setupUi() has already run
connectSlotsByName(), so filling them would otherwise fire the change slots and
mark the model modified before the user touched anything.

Finally, render_frame() gets its three correlated EnablePostProcessing branches
replaced by a scoped ScenePostProcessing guard, and the shadow pass -- with the
HTL matrix-stack dance it needs -- moves into its own named function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Graphics tab was documented in help-src/doc/dialogs/PreferencesDialog.html,
which is not listed in doc/qtfred.qhp and so is never compiled into
qtfred_help.qch. It is a stale duplicate of general/PreferencesDialog.html --
the page the table of contents, the Preferences keyword, and Viewport.html all
point at -- and one of only two orphaned files under help-src/doc. None of the
new documentation reached the Help viewer.

Document the tab on the page that actually ships, and revert the edit to the
orphan so nothing is left stranded there. The orphan itself predates this
branch and is left alone; it should probably be deleted, but that is a separate
question from this branch.

The content is also corrected against what the settings now do:

 - Shadow quality requires a restart. The orphan did not say so, and the
   dropdown entries no longer carry a "(restart required)" suffix now that
   their labels come from the engine's own option definition.
 - Texture filtering and anisotropy default to the engine's own choice
   (the config default, and the hardware maximum) until the user picks one.
 - Anisotropy is disabled outright on hardware that does not support it.
 - Gamma documents its actual range and default.
 - The page's "changes take effect immediately" claim is qualified, since it
   is not true of the four restart-only settings.

Also notes the View > Enable Post Processing menu equivalent, which had no
documentation anywhere -- no help page covers the View menu's display toggles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The-E and others added 22 commits August 7, 2026 18:55
documentation/qtfred-post-processing-viewport-resize.md still described the
Gr_min_render_target_w/h floor, which no longer exists, and listed
reallocate-on-resize as rejected -- which is now the implemented approach.

Rewrite it around gf_resize_render_targets, and keep the two failed attempts as
history, because the reason they failed is the reason the current one works.
That document had already identified the prerequisite correctly: the scene
teardown left FBOs attached to stale texture handles, and draws to an incomplete
FBO go nowhere, hence the black viewport. Completing the teardown was what made
reallocation viable. Also records the two properties that are easy to get wrong
(clamp to the hardware limit before deciding whether to resize, or a viewport
past that limit rebuilds every frame; never resize mid-frame), the sites fixed
in the deferred and Vulkan paths, and why the volumetric nebula pass is
deliberately left unscaled.

Corrections to the Preferences help page from the same review:

 - Enabling post-processing does not by itself produce shadows. Shadow quality
   defaults to Disabled and needs a restart to change, so the page said the
   opposite of what a user will experience.
 - The anisotropy control also greys out when the GPU reports a maximum of 2x,
   not only when it lacks the feature outright.

The help keyword index is left alone: keywords there are page-level, with
multiple entries only ever used as synonyms for one page, so per-setting
keywords would be inventing a convention rather than following one.

Finally, note in the qtfred module guide that its viewport calls
gr_screen_resize() every frame -- the assumption whose violation caused all of
the above -- and where the graphics preferences now live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit introduces `gr_end_offscreen_frame()` to manage per-frame state recycling for renders that skip `gr_flip()`. It adds generation-based sub-allocation tracking in `VulkanBuffer` to prevent stale memory access after allocator rewinds. Improves resource management, resolves memory growth issues in non-flipping workflows (e.g., qtFRED briefing map), and ensures consistent handling of uniform segments and descriptor pools.

Integrate Vulkan render backend in qtFRED with updated rendering workflows, multi-target support, and enhanced resource lifecycle management.
bindDescriptorSet took a bare const uint32_t*, and the number of entries
vkCmdBindDescriptorSets would read off it came from the set layout, not
from the caller -- so a caller passing too few was undetectable and read
past the end of its own array. Every call site got the length right by
convention: either a *_DYNAMIC_OFFSET_COUNT-sized array or the writer's
storage.

Take ArrayView<uint32_t> instead, which the subsystem already has for
exactly this (VulkanConstants.h, used three lines away in bindSets), and
assert size >= getDynamicOffsetCount(). DescriptorWriter::dynamicOffsets()
returns a view for the same reason. No call site needed touching -- both
the fixed-size arrays and the writer's std::array convert implicitly,
which is the argument for the type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Multi-viewport support introduced a real new concept -- a presentable
surface and everything sized to it -- but gave it nowhere to live. The
struct sat in VulkanRenderer.h and its lifecycle in VulkanRendererSetup.cpp
next to unrelated instance/physical-device/logical-device bring-up, which
pushed that file from 1217 to 1420 lines and VulkanRenderer.cpp past 1000
for the first time.

Move VulkanSurfaceHandle, VulkanPresentTarget and FrameSyncPoint into
VulkanPresentTarget.h, and everything that builds or tears one down into
VulkanPresentTarget.cpp: the surface, swap chain, depth/composition
resources, framebuffers, per-target sync objects, and the choose* helpers
that only createSwapChain/recreateSwapChain ever used. This is the split
VulkanRenderFrame.{h,cpp} already sets the precedent for.

checkSwapChainSupport stops being anonymous-namespace: it is now shared
between the target path and isDeviceUnsuitable, which stays behind with
device selection.

Pure code movement, no behavior change -- verified by diffing the line
multiset of the three original files against all five afterwards. Also
drops a now-dead SDL3/SDL_vulkan.h include, orphaned when the surface
calls moved behind os::VulkanSurfaceProvider.

  VulkanRendererSetup.cpp  1420 -> 935  (master: 1217)
  VulkanRenderer.cpp       1015 -> 806  (master:  956)
  VulkanRenderer.h          787 -> 656  (master:  488)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VulkanDrawManager grew a third hand-rolled memoization cache with the
shadow pass, each one a key struct plus a set handle plus a valid flag,
each with its own comparison, its own store, and its own line in
resetFrameStats() and invalidateDrawStateCaches(). Three copies of a
pattern is where "did we forget to invalidate one" stops being answerable
by reading.

Fold them into MemoizedDescriptorSet<Inputs, Payload>. The payload is a
parameter because the shadow pass memoizes all three of its sets against a
single key while applyMaterial memoizes one set per key; that grouping is
now expressed as a ShadowSets struct rather than three parallel members.

store() refuses an incomplete payload, which is slightly stronger than
what it replaces: allocateFrameSet() returns a null handle on pool
exhaustion and the Assert() catching it is gone in release builds, so the
old code could cache a null set and hand it to every later draw. The old
read-time null check covered Material and PerDraw but not the shadow sets
at all.

The Global set's cache is deliberately left alone -- it is a dirty-flag
shape with no key struct and does not fit.

Verified in-mission: peak descriptor sets/frame 144 -> 139 with zero
descriptor-pool growth, i.e. the cache still hits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…location handling.

Unified Vulkan buffer management with `getVkBufferForBinding` to handle outlived streaming bindings safely. Eliminated `clearPendingUniformBindings` to prevent premature state cleanup. Introduced `isFrameAllocCurrent` tracking to avoid stale memory access across frames, ensuring consistent fallback to placeholder buffers for deleted or recycled allocations. Updated descriptor writes and shadow cascades to rely on these safeguards. Fixed various out-of-lifecycle issues in deferred rendering paths and external consumers like qtFRED.
…cp-fs2open#7629)

Bind the existing fsspeech_* wrappers into the ui.Briefing, ui.CommandBriefing,
ui.Debriefing, and ui.TechRoom scripting sections so Lua UIs (e.g. SCPUI, which
overrides the native screens) can drive synthesized speech again.

Each section gets playTextToSpeech, stopTextToSpeech, pauseTextToSpeech,
isTextToSpeechPlaying, and isTextToSpeechEnabled. Briefing/command-brief/debrief
use FSSPEECH_FROM_BRIEFING; the tech room uses FSSPEECH_FROM_TECHROOM. fsspeech_play
already self-gates on the Speech.* options, so these are no-ops when speech is off.
Convert ship/weapon loadout pools from naive per-class arrays to ordered maps that list loadout entries.  Each entry represents a ship/weapon class and its count.  An absent entry means the class is not in this mission's loadout.  Since these are ordered maps, iteration of the map keys occurs in the same sequence as iteration of `Ship_info` or `Weapon_info`.

Includes several bugfixes:
- `csg_read_loadout` now clears both pools before reading.  Previously the pools were only zeroed in `mission_campaign_init`
- `restore_wss_data`: non-transmitted classes are now absent rather than memset to 0
- a copy-paste bug where the weapon pool entries were bounds-checked against MAX_SHIP_CLASSES
- `wss_maybe_restore_loadout`: the pool write-back now preserves loadout membership instead of writing a count for every class
- `ss_dump_to_list`/`ss_swap_list_slot`: returning a slot ship whose class is not in the pool now makes it available instead of silently losing it.  Unreachable in retail but reachable via script
- off-by-one errors in the Lua `Ship_Pool`/`Weapon_Pool` indexers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix two small issues

Fix copy-paste bug where setting `$Ask Help Hull Percent:` never properly got set. Fortunately this does not affect the AI, only when the AI sends a message to the player asking for help.

Fix edge-case issue: in `find_turret_enemy`, the `turret-tgt-ship-tgt` shortcut takes the parent ship's target and indexes Ships[] with it unconditionally, but `aip->target_objnum` could be a weapon or asteroid, which could be at an index higher than the ships index, and then trying to look up team could lead to bad data.

* comment formatting
documentation/qtfred-post-processing-viewport-resize.md still described the
Gr_min_render_target_w/h floor, which no longer exists, and listed
reallocate-on-resize as rejected -- which is now the implemented approach.

Rewrite it around gf_resize_render_targets, and keep the two failed attempts as
history, because the reason they failed is the reason the current one works.
That document had already identified the prerequisite correctly: the scene
teardown left FBOs attached to stale texture handles, and draws to an incomplete
FBO go nowhere, hence the black viewport. Completing the teardown was what made
reallocation viable. Also records the two properties that are easy to get wrong
(clamp to the hardware limit before deciding whether to resize, or a viewport
past that limit rebuilds every frame; never resize mid-frame), the sites fixed
in the deferred and Vulkan paths, and why the volumetric nebula pass is
deliberately left unscaled.

Corrections to the Preferences help page from the same review:

 - Enabling post-processing does not by itself produce shadows. Shadow quality
   defaults to Disabled and needs a restart to change, so the page said the
   opposite of what a user will experience.
 - The anisotropy control also greys out when the GPU reports a maximum of 2x,
   not only when it lacks the feature outright.

The help keyword index is left alone: keywords there are page-level, with
multiple entries only ever used as synonyms for one page, so per-setting
keywords would be inventing a convention rather than following one.

Finally, note in the qtfred module guide that its viewport calls
gr_screen_resize() every frame -- the assumption whose violation caused all of
the above -- and where the graphics preferences now live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a physically-based camera lens flare system (Lee & Eisemann 2013 matrix
approximation): a lens is an ordered stack of spherical surfaces parsed from
lens_flares.tbl / *-lens.tbm, precomputed at load time into paraxial
ray-transfer matrices, one per two-reflection "ghost" the prescription
produces. Each render backend (GL and Vulkan) draws one instanced quad per
ghost plus a starburst -- the Fraunhofer diffraction pattern of the iris mask,
sharing the same aperture so the two artifacts can never disagree.

There is one camera lens for the whole mission (every sun in the background
flares through the same glass), mounted from the mission's "$Camera Lens:",
changeable by the lab, and editable in FRED's Background Editor and via
set-lens-* sexps. Includes:

  - The optics/aperture/table-parsing core (graphics/lens_flare*.{h,cpp}),
    kept free of engine state so it can be unit tested directly.
  - GL and Vulkan post-processing passes that composite the flare additively
    into the HDR scene before bloom, so the energy is bloomed and tonemapped
    like any other scene light.
  - Anamorphic lens effects: a squeeze (how much wider than tall flare
    footprints are) and a horizontal streak artifact, both off by default so
    every lens written before they existed is untouched.
  - Exposure to mission designers and the lab: the mission field, FRED2 (MFC)
    and qtFRED dialogs, four set-lens-* sexps for restyling the iris live, and
    an ImGui panel in the lab for tuning and diagnostics.
  - A frame-logic pass that centralizes what the flare pass draws each frame
    into one publish/consume model (lens_flare_frame_update() /
    lens_flare_get_frame_draws()), which is what lets the sun sprite renderer
    read back "did the flare draw this sun's starburst" instead of predicting
    it independently -- the two could otherwise disagree about occluded or
    off-screen suns.
  - Unit tests covering the optics math, table parsing, texture generation,
    and the frame-publish contract.

Off by default: with no lens mounted (the common case for content that
predates this), nothing changes.
…a lens

Two related pieces of vocabulary for the camera lens (graphics/lens_flare.h):

  - The mission's "$Camera Lens:", the set-camera-lens sexp and both editors
    now share one resolution: an empty/absent value means the mission has no
    opinion and takes the tabled "$Default Lens:", "<none>" explicitly means
    no lens even when a default exists, and "<default>" says the default
    explicitly. Without this, a mod adding a $Default Lens: later would
    silently give flares to missions that had deliberately mounted none.

  - Suns opt into the camera lens with "+Camera Lens Flare:" in stars.tbl,
    independent of the legacy sprite "$Flare:" block that used to double as
    the only opt-in. Content decides *whether* a sun flares; the mounted lens
    only decides *how*. A table predating this option keeps behaving exactly
    as before, since "$Flare:" still implies it when the new option is absent.
Engine nozzles are the other intensely bright thing in a FreeSpace scene, so
they flare through the same camera lens the suns do. Every lit nozzle is its
own source (a capital ship's engine banks are set far enough apart to read as
separate points in frame, so a single flare at their centroid would sit where
no engine is), budgeted and ranked by brightness so a fleet engagement's
worth of nozzles stays affordable, and brightness follows throttle,
afterburner state, facing and distance -- calibrated against a reference
apparent size, the same way beam muzzles will be.

Declared per species via species_defs.tbl's "$Thruster Flare:", off unless a
species asks for it, so no existing mod gains flares it never tabled. Ghost
trains are off by default for thrusters specifically (there can be dozens on
screen at once, where a full ghost train each is noise rather than a readable
effect); the lab can turn them on to see what they cost and look like.

Includes a test asserting that the species-table syntax mods actually write
round-trips correctly.
A tbm entry that names an existing lens replaced it outright, so
restyling one of the shipped prescriptions meant transcribing all twelve
of its surfaces to change one number. "+override" after the $Name: now
edits the lens in place: every option the entry gives is applied, and
everything it leaves out keeps the value it had. Following the
muzzleflash precedent, it goes directly after the name; an entry without
it still replaces outright, so nothing existing changes meaning.

The surfaces are now wrapped in $Lens Stack Start: / $Lens Stack End
rather than being a bare run of $Surface: lines. That reads as one block
in a twenty-surface lens, and it gives an override a way to say "replace
the prescription" -- which it does wholesale, because a stack is an
ordered run whose focal length, ghost set and iris position all follow
from the run as a whole, so a merged stack would be neither lens. Bare
surfaces outside the pair are now diagnosed; left to the old loop they
ended the entry and then failed against $Name:/#End with a message about
the wrong thing.

lens_system's texture cache becomes a shared_ptr so the struct is
copyable, which is what lets an override start from a copy of what it is
editing. The alternative -- a copy constructor listing every field --
would silently stop carrying any field added afterwards, which is the
same hazard lens_aperture::operator== already documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b2868970f3710257d0b57630ec53ede08999274d)
A firing beam's muzzle flares through the camera lens for as long as the beam
exists, at one flare per beam. Brightness is not invented separately: it
reads straight off the beam's own muzzle light (beam_get_muzzle_glow()),
which already ramps up over warmup, holds while firing, and ramps back down
over warmdown, so the flare tracks the glow it belongs to instead of a second
curve that could disagree with it.

Unlike thrusters there is no table opt-in, since there is nothing to opt into
that the beam hasn't already said: a beam with no muzzle light throws no
flare, and no flare of any kind renders unless the mission has a camera lens
mounted in the first place. Deliberately ignores the Detail.lighting setting
that gates the dynamic muzzle light itself, since a lens flare is an artifact
of the camera rather than a scene light and shouldn't disappear when a player
lowers their lighting detail.
A nozzle or beam muzzle is bright and squarely facing the camera just as
often tucked behind its own ship's hull, a wing, or another ship entirely as
it is out in the open, so both gathers now raycast for a clear line of sight
before a source is allowed to draw -- the same test AI targeting uses, and
deliberately not excluding the emitting ship, since a nozzle on the far side
of its own hull should occlude exactly like anything else would.

The test runs last, against only the sources that already survived the
brightness rank/cut, since it costs a scene-wide raycast per source and the
budget is what bounds how many of those a frame can afford; testing every
candidate before the cut would scale the cost with the mission instead of
with the budget.

Also fixes two bugs in the occlusion math the visibility test itself
introduced: axis sign and epsilon issues that could pass an occluded source or
fail a clear one.
lens_flare_internal.h defines the apparent-size calibration every finite
source is stated against, with a comment saying its whole point is that
re-tuning it moves one constant. Beams used it; thrusters carried a private
copy of all four constants -- with its own copy of the same justification --
and open-coded the ratio. They agreed by luck.

The two gathers also ended with the same fifteen lines: rank by intensity,
cut to budget, drop what the eye can't see, append. The order is load-bearing
(ranking is what bounds the pass; the raycast has to come after the cut or it
scales with every candidate in the mission rather than with the budget), which
makes it a function rather than a convention each gather is trusted to follow.

Also drops history-narration from the lens flare comments -- what was tried
first and rejected belongs in the log, not in a header -- and corrects
lens_flare_prime_textures()' explanation of where it is not called from:
parse_mission_info() returns at its `if (basic)` guard well before it reaches
$Camera Lens:, so a mission-info scan never mounts a lens at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 19b2c496507b97e3f69ac1ec648b1b92651ddacd)
Implements `saveSceneDepth` and `restoreSceneDepth` methods to isolate cockpit depth rendering from the main scene, mirroring OpenGL's depth attachment approach. Ensures the post-processing chain sees the correct depth data while preserving cockpit rendering accuracy. Updates VulkanPostProcessor and VulkanRenderer with required image transitions, multi-sampled depth handling, and depth-buffer parking logic.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement A new feature or upgrade of an existing feature to add additional functionality. graphics A feature or issue related to graphics (2d and 3d)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants