Skip to content

Physically-based lens flares#7654

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

Physically-based lens flares#7654
The-E wants to merge 6 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 and others added 6 commits July 26, 2026 18:05
Implements the Lee & Eisemann 2013 matrix method for camera lens flares:
every ordered pair of refractive surfaces in a lens prescription produces a
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 computed
once at table load, so a frame only has to place one textured quad per
ghost.

There is one lens, because there is one camera: every sun in a background
is imaged through the same glass, which is what keeps their flares
consistent with each other. Which lens that is comes from "$Default Lens:"
in lens_flares.tbl, unset by default so retail content is unaffected.

  graphics/lens_flare.h            the module's whole API
  graphics/lens_flare_optics.cpp   ray-transfer matrices, ghost enumeration,
                                   coated-Fresnel reflectance
  graphics/lens_flare_aperture.cpp iris mask + its Fraunhofer starburst
  graphics/lens_flare_table.cpp    lens_flares.tbl / *-lens.tbm parsing
  graphics/lens_flare.cpp          module state, the camera lens, the texture
                                   cache, and the per-frame build

The optics and image-synthesis halves are pure functions of a prescription
or an iris -- no engine state -- with one exception: ghost enumeration stops
at the shader's instance budget, since a ghost with no instance slot to draw
it in is not worth keeping.

Rendering is one instanced draw per visible sun, sharing the mounted lens's
iris and starburst textures 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. Both backends are
covered (opengl_post_pass_lens_flare / VulkanLensFlare); Vulkan needs the
negative-viewport Y flip that the other post passes use.

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 path is rescaled by
Hdr_flare_headroom / LENS_FLARE_SDR_REFERENCE_WHITE with SDR as the
calibration reference.

A mounted lens suppresses the legacy sprite $Flare: path, and when it has a
starburst the bitmap sun glow is skipped too, so the two never stack.
Per-sun opt-out is the pre-existing $NoGlare:.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gives content authors a way to pick the lens a mission is shot through, and
tooling to see what it does.

Mission side, following $Lighting Profile: exactly since it is the same kind
of setting -- a global render choice belonging to one mission:

  $Camera Lens:  in the mission info section (missionparse), written back by
                 missionsave behind a version bypass comment, and edited in
                 the background editors of both FRED (IDC_CAMERA_LENS) and
                 qtFRED (cameraLensCombo)
  set-camera-lens  swaps the mounted lens at runtime; <none> for no flares,
                   <default> for what lens_flares.tbl declares
  set-lens-aperture / -grating / -scratches / -dust
                   restyle the mounted lens's iris. They take no lens name,
                   so a mission cannot edit a lens it isn't looking through.

The lens sexps all edit tabled state in place, which
lens_flare_reset_for_level() undoes when the mission ends, so nothing leaks
into the next one. Like the other background operators they are not packed
for multiplayer, so in a networked game they affect the host only.

The lab gets a "Lens flare options" panel (its own translation unit, since
lab_ui.cpp has no room for another one): an override of the mission's lens,
live iris editing coalesced so a slider drag doesn't rebuild a 512^2 mask
and its FFT every frame, the global brightness/HDR-headroom calibration, and
a per-sun readout of what the last flare pass actually drew. The lab's
background loader parses $Camera Lens: the same way it parses the lighting
profile, so switching backgrounds there mounts the right lens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduce "Anamorphic Squeeze" and "Anamorphic Streak" options for lens flares, enabling stretched ghost footprints and horizontal streak artifacts for cinematic anamorphic looks. Changes include:

- Shader updates and new geometry for streak rendering.
- Table parsing for customization of squeeze, streak length, thickness, and tint.
- Per-lens configuration in the lab and live updates without texture rebuilds.
- Default behavior preserves prior spherical lens content.
…sistencies

Decouple lens flare state setup from rendering by introducing `lens_flare_frame_update()` for reusable, deterministic frame data. Replace `build_frame_data()` usages, ensuring sun sprite and lens flare passes rely consistently on published results. Update shaders, backends, and logic for more precise artifact type handling and clarity.
Ensure missions requesting no flares or default lens retain their behavior even with a declared $Default Lens:. Update lens selection logic, mission parsing, and editor handling to preserve intentional "<none>" values. Add tests and refactor lens APIs for consistency and clarity.
Introduce a "+Camera Lens Flare:" option in *stars.tbl* to explicitly enable or disable physically-based camera lens flares for individual suns. This allows fine-grained control over flare behavior, complementing or replacing legacy $Flare: blocks. Update parsing, rendering logic, and add comprehensive tests to verify correct behavior across all combinations.
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