Skip to content

Physically-based lens flares - #7654

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

Physically-based lens flares#7654
The-E wants to merge 17 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 4 times, most recently from 705c2cb to f959080 Compare July 31, 2026 15:20
The-E and others added 16 commits August 2, 2026 18:41
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)
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.

1 participant