Physically-based lens flares - #7654
Open
The-E wants to merge 17 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
4 times, most recently
from
July 31, 2026 15:20
705c2cb to
f959080
Compare
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>
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)
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