Physically-based lens flares - #7654
Open
The-E wants to merge 42 commits into
Open
Conversation
The-E
marked this pull request as ready for review
July 26, 2026 10:27
The-E
force-pushed
the
pbr_lensflares
branch
5 times, most recently
from
August 2, 2026 16:44
0768a0b to
81b18a9
Compare
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>
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.
… always display _something_
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
$Default Lens:inlens_flares.tbl/*-lens.tbm$Camera Lens:in a mission's info sectionset-camera-lens<none>for no flares,<default>for the table'sset-lens-aperture/-grating/-scratches/-dustrestyle 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.tblleaves$Default Lens:unset, so a mod gets no flares until it opts in — either with one line in a*-lens.tbmor 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()andVulkanLensFlare— so this is not a Vulkan-only feature.Structure
Four translation units behind one public header,
graphics/lens_flare.h, withlens_flare_internal.has the private interface between them:lens_flare_optics.cpp— ray-transfer matrices, ghost enumeration, coated-Fresnel reflectancelens_flare_aperture.cpp— the iris mask and the starburst that is its Fraunhofer transform (CPU FFT)lens_flare_table.cpp—lens_flares.tbl/*-lens.tbmparsing; holds no statelens_flare.cpp— module state, the camera lens, the texture cache, the per-frame buildThe 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
The first stands alone (it configures and builds without the second), so bisect stays clean.
Testing
Not yet verified, and where review attention would help most:
IDC_CAMERA_LENScombo,fred.rcdialog resize,resource.hid — are Windows-only and have not been compiled here. CI's Windows leg is their first real test.🤖 Generated with Claude Code