Skip to content

Lens flare: stop the strobe with a temporally filtered state pass - #386

Open
taylnos wants to merge 5 commits into
AlchemyViewer:developfrom
taylnos:fixes/lens-flare-deflicker
Open

taylnos wants to merge 5 commits into
AlchemyViewer:developfrom
taylnos:fixes/lens-flare-deflicker

Conversation

@taylnos

@taylnos taylnos commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

The sun lens flare strobed whenever a moving camera alternately hid and revealed the sun:
fence posts, foliage, orbiting an avatar with the sun behind it. Every element of the flare
(a streak across the full frame width, halo, ghosts, starburst, glow) went out in one frame
and came back in the next. At that size and rate it is a photosensitivity hazard.

computeLensFlare decided visibility per fragment, from scratch, every frame: nine
effectively binary depth taps in a 0.02 UV disk, plus one point-sampled sun texel with a
hard return when it was not HDR-bright. Covering the sun's centre pixel switched the whole
flare off. The CPU lerp that looked like smoothing only touched the screen-edge fade, at a
per-frame rate.

What this does

A per-frame state pass. LLPipeline::generateLensFlareState() runs right before
colorCorrect (both HDR paths) and draws the new class1/alchemy/lensFlareStateF.glsl into
a 2x1 RGBA16F target pair, ping-ponged by handle swap like the exposure map. Texel 0 holds a
filtered, premultiplied flare drive (sun colour x HDR gate x unoccluded fraction x edge
fade) plus an instability score; texel 1 holds the raw target, a decaying reference
luminance and the step detector's anchor. computeLensFlare(vec2 uv) reads one texel and no
longer touches depth or the scene buffer, which also removes ten texture reads per fragment
for a frame-wide constant.

The probe. 256 fixed taps on a Fermat spiral around the sun, weighted exp(-8 r^2),
each gated on its own: an occluded tap contributes nothing, an unoccluded one its overbright
colour. The radius is the sun disc's own angular radius through the current FOV (with the
horizon enlargement the drawn quad gets, and the snapshot zoom), scaled by a setting. A
still scene gives an identical estimate every frame; an occluder narrower than the disc dims
the flare instead of cutting it.

The filter. In luminance with RGB following: fade-in tau = FadeTime/3, fade-out 0.6x,
a slew cap relative to the reference luminance so no frame moves the drive by more than a
bounded fraction of the sun's recent brightness (a full off-on-off cycle cannot complete in
under 1.6 FadeTime), and adaptive damping: direction reversals of the raw target, measured as
displacement from an anchor so the verdict does not depend on frame rate, slow the filter
above a dead zone. One reversal (an ordinary reveal) costs nothing; a fence-post train settles
to the mean coverage within a few crossings. The drive snaps to exact zero once the target is
black, because a half-float target pins a geometric decay at a denormal.

Settings. RenderLensFlareOcclusionRadius and RenderLensFlareOcclusionTaps are
replaced by RenderLensFlareOcclusionScale (multiple of the sun disc, 0.25-2, default 1)
and RenderLensFlareFadeTime (0.1-1 s, default 0.35), in settings_alchemy.xml, the Looks
whitelist, the three bundled Looks and the Lightbox Occlusion rows (Size, Fade). The
shader derives every rate from the one FadeTime uniform and is the declared source of truth;
scripts/content_tools/check_lens_flare_state.py mirrors it statement for statement and is
the regression test that chose the constants.

Numbers (from the script)

step rise 10-90% / fall 90-10% at FadeTime 0.35 0.333 s / 0.200 s, no overshoot, frame-rate independent within 0.016
residual swing after 1 / 2 / 3 / 5 / 8 Hz occlusion 0.34 / 0.11 / 0.07 / 0.05 / 0.04 of full scale
2 Hz fence with 0.05-0.2 s edge crossings, 30-144 fps damped at every rate, spread across rates 0.001
hard bound from the slew alone 1.79 full cycles per second
kernel: unoccluded drive vs centre texel; half-plane bias std / worst; max tap share 0.978; 0.015 / 0.036; 3.1%
thin poles, 0.25 / 0.5 / 1 / 2 disc radii 59% / 29% / 3% / 0% of the drive
sun behind needles, still camera / drifting camera 0 change per frame / 0.5% per frame after the filter

Verification

  • Simulation: 0 failures. Offline GLSL: every assembly of the touched shaders at
    #version 400 and 420, with and without REVERSE_Z, plus a reserved-word scan of the
    code lines (glslangValidator does not enforce the reserved list; packed got through it
    once).
  • alchemy-bin Release: 0 errors, 0 warnings in the touched files. ctest 139/139 on the
    committed state.
  • In world, on Windows: no flicker through fences, foliage, avatar orbits, or the needle
    scene that had still flickered mid-branch.

Checklist

Please ensure the following before requesting review:

  • I have provided a clear title and detailed description for this pull request.
  • If useful, I have included media such as screenshots and video to show off my changes.
  • I have tested the changes locally and verified they work as intended.
  • All new and existing tests pass.
  • Code follows the project's style guidelines.
  • Documentation has been updated if needed.
  • Any dependent changes have been merged and published in downstream modules
  • I have reviewed the contributing guidelines.

Additional Notes

  • The fourth commit is a review fix pass (fifteen findings, including the reserved word, a
    dead material-preview gate and a frame-rate-dependent step detector) plus the still-camera
    needle flicker. Its message carries the detail.
  • Merge overlap: the lens-effects branch inserts code immediately after the flare block in
    colorCorrect and in createLUTBuffers / renderFinalize; expect insertion-point
    conflicts only.
  • A Look saved before the settings swap carries the old keys and leaves Size and Fade
    untouched when applied; bundled Looks are updated, but seeding is once per name, so an
    existing install keeps its copies.
  • The tiled high-resolution snapshot fallback advances the filter once per tile, as every
    other screen-space pass there already is.
  • Only Windows has been run in world. The GL 4.1 path is validated offline, not on Apple
    hardware: every touched shader compiles at #version 400, a scan of all 237 shaders finds
    no reserved-word identifier outside the CAS and FXAA vendor headers' inactive sections,
    and the one texture read in non-uniform control flow uses an explicit level.

🤖 Generated with Claude Code

taylnos and others added 5 commits September 3, 2026 10:59
The sun lens flare decided its own visibility per fragment, from scratch,
every frame: nine effectively binary depth taps in a 0.02 UV disk plus a
single point-sampled texel of the sun for brightness, with a hard return
when that texel was not HDR-bright. Cover the sun's centre pixel and the
whole flare -- a streak across the frame, halo, ghosts, starburst -- went
out in one frame and came back in the next. A camera moving behind fence
posts or foliage turned that into a strobe, and the only smoothing on the
CPU was a per-frame lerp of the screen-edge fade, which the depth test
never passed through.

Move the measurement into a 2x1 state pass (generateLensFlareState,
lensFlareStateF.glsl) that runs right before colorCorrect against the
frame's final depth and ping-pongs like the exposure map. It probes 48
taps on a golden-angle Fermat spiral, aspect-corrected and centre
weighted, sized from the sun disc's own angular radius through the
current FOV, and takes the sun colour from the unoccluded taps only. The
result is filtered in luminance with RGB following: asymmetric time
constants, a slew cap relative to a decaying reference luminance so no
frame can move the drive by more than a bounded fraction of the sun's
recent brightness, and adaptive damping that slows the filter when the
raw target keeps reversing direction. One reversal (an ordinary reveal)
costs nothing; a fence-post train settles to the mean coverage within a
few crossings. computeLensFlare now reads one texel of that texture and
no longer touches depth or the scene buffer, which also removes ten
texture reads per fragment for a value that was constant across the frame.

Every constant comes from scripts/content_tools/check_lens_flare_state.py,
which mirrors the shader statement for statement and replays edges, poles
and square-wave occlusion at 30/60/144 fps: a 3 Hz strobe settles to a
0.07 swing, a clean step rises 10-90% in FadeTime, and the slew alone
bounds the flare to 1.8 full cycles per second at the default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RenderLensFlareOcclusionTaps has nothing left to control: the state pass
always uses all 48 taps, and at two fragments a frame they are free.
RenderLensFlareOcclusionRadius was a fixed fraction of the screen, so
zooming in shrank the probe relative to the sun and a thin post could
cut the flare. It becomes RenderLensFlareOcclusionScale, a multiple of
the sun disc's apparent radius, which follows zoom and the sky's sun
scale. RenderLensFlareFadeTime is the one temporal knob: seconds for a
full fade-in, with the fade-out and the rate limit derived from it.

Both keys are declared with their ranges, swapped into the Looks
whitelist and all three bundled Looks at their defaults, and take the
two rows of the Lightbox Occlusion group at the same heights. The
architecture note gains the state pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Size now runs 0.25 to 2.0 disc radii and Fade 0.1 to 1.0 seconds: the
upper halves of the old ranges were beyond anything a Look would want,
and a shorter slider puts the useful travel under the mouse. The clamps
in generateLensFlareState, the setting comments, the three bundled Looks
and the two Lightbox rows all move together.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fifteen review findings and one field report, in one pass because they
touch the same forty lines.

The identifier `packed` was a GLSL reserved word before 4.20: the state
shader would not have compiled on macOS GL 4.1 or on Mesa, and the loader
would have dropped the whole deferred set with it. The material-preview
gate compared `src` against `mRT->screen` after the preview had already
swapped `mRT` to the auxiliary pack, so it never fired and the world's
flare was painted onto the preview sphere; it is now `mRT == &mMainRT`,
and chromatic aberration on the preview is left as it was.

The step detector behind the adaptive damping keyed on the per-frame
delta, so a fence post crossing the probe over several frames never
registered at high frame rates while the same scene was damped at 30 fps.
It now measures displacement from an anchor (texel 1's blue channel) and
normalises by the brightest unoccluded tap rather than by a reference that
had decayed to the occluded level, which had made the reveal after a long
partial occlusion three times slower.

The sun behind alpha-masked pine needles flickered with the camera and
the trees perfectly still. The per-frame golden-angle rotation of the tap
pattern re-sampled the needle mask every frame, and the HDR gate on the
mean colour of the unoccluded taps opened and closed at the threshold.
The pattern is now fixed and has 256 taps (48 fixed taps read a half-plane
with a worst bias of 0.14 and give one tap 15% of the weight; 256 read
within 0.036 with 3.1%), and each tap is gated on its own: an occluded
tap contributes nothing, an unoccluded one its overbright colour. A still
scene now gives an identical estimate every frame; a drifting camera
moves the filtered drive by half a percent per frame.

Also: off-screen taps clamp to the frame edge for depth and colour, so
the screen-edge margin works again and nothing leaks through an occluder
at the edge, and the probe radius is capped; the drive snaps to exact
zero below 1e-4 once the target is black, because a half-float target
pins a geometric decay at a denormal and the reader's early-out needs
true black; a no-post snapshot holds the history instead of clearing it;
a sun/moon flip clears it; the probe carries the drawn disc's horizon
enlargement and the tiled-snapshot zoom; the ping-pong is a handle swap
and only the target read as history is cleared; colorCorrect no longer
binds a depth texture nothing samples; the shader derives its rates from
one FadeTime uniform and is the declared source of truth, with the script
as its mirror and regression test, now covering ramped edges, partial
occlusion under a held camera, and needles with a still and a drifting
camera. Stale prose is trimmed to pointers and LIGHTBOX.md names all
three clean-plate gates.

Tested in world: no flicker in the needle scene or elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The colour read in the lens flare state pass runs only for unoccluded
taps, so it sits in non-uniform control flow, where implicit derivatives
are undefined. The target has no mips and is sampled nearest, so every
driver was returning the right texel anyway; textureLod makes that
defined rather than incidental before the pass meets a stricter compiler.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 70aaf52c-c4a0-4287-b251-6df2aee42a81

📥 Commits

Reviewing files that changed from the base of the PR and between f8485b9 and 2d7cce9.

⛔ Files ignored due to path filters (3)
  • indra/newview/app_settings/shaders/class1/alchemy/colorCorrectF.glsl is excluded by !**/*.glsl
  • indra/newview/app_settings/shaders/class1/alchemy/lensFlareStateF.glsl is excluded by !**/*.glsl
  • indra/newview/app_settings/shaders/class1/alchemy/postEffectUtilsF.glsl is excluded by !**/*.glsl
📒 Files selected for processing (15)
  • doc/ARCHITECTURE.md
  • doc/LIGHTBOX.md
  • indra/llrender/llshadermgr.cpp
  • indra/llrender/llshadermgr.h
  • indra/newview/app_settings/looks/Golden%20Hour.xml
  • indra/newview/app_settings/looks/Neutral.xml
  • indra/newview/app_settings/looks/Soft%20Film.xml
  • indra/newview/app_settings/settings_alchemy.xml
  • indra/newview/llpresetsmanager.cpp
  • indra/newview/llviewershadermgr.cpp
  • indra/newview/llviewershadermgr.h
  • indra/newview/pipeline.cpp
  • indra/newview/pipeline.h
  • indra/newview/skins/default/xui/en/panel_lightbox_lens.xml
  • scripts/content_tools/check_lens_flare_state.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Summary

Summary by CodeRabbit

  • New Features

    • Lens flare visibility now uses smoother temporal fading and improved sun/moon occlusion detection.
    • Added controls for occlusion probe size and flare fade timing.
    • Updated graphics presets and Looks preset handling with the new lens flare controls.
  • Bug Fixes

    • Prevented lens flares from blinking after frames without post-processing.
    • Improved flare stability during partial occlusion, recovery, and changes between the sun and moon.

Walkthrough

The change adds a temporal lens-flare state pass. It replaces direct occlusion sampling with persistent state textures, configurable fade timing, and sun-disc-relative probe sizing. It updates shader contracts, presets, UI controls, documentation, and offline regression checks.

Changes

Lens flare state pipeline

Layer / File(s) Summary
Shader contract and lifecycle
indra/llrender/llshadermgr.*, indra/newview/llviewershadermgr.*
The shader uniform contract now exposes the generated state map and fade time. Deferred shader loading and unloading now manage gLensFlareStateProgram.
Temporal state generation and consumption
indra/newview/pipeline.*, doc/ARCHITECTURE.md, doc/LIGHTBOX.md
LLPipeline now maintains double-buffered lens-flare state, generates state during finalization, preserves history for no-post frames, resets stale state, and binds the result during main-target color correction.
Settings and preset controls
indra/newview/app_settings/looks/*, indra/newview/app_settings/settings_alchemy.xml, indra/newview/llpresetsmanager.cpp, indra/newview/skins/default/xui/en/panel_lightbox_lens.xml
The radius and tap-count settings are replaced by occlusion scale and fade time settings across presets, Looks handling, and the Lens Flare panel.
Offline state validation
scripts/content_tools/check_lens_flare_state.py
The new script models spatial sampling and temporal filtering, validates regression scenarios, and supports normal and --grid execution modes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: ⚪ Minimal · up to 2d7cc

The shader, pipeline, settings, preset, and UI contracts are aligned, with no actionable merge-blocking risk identified.

Sequence Diagram(s)

sequenceDiagram
  participant LLPipeline
  participant LensFlareStateShader
  participant StateHistory
  participant ColorCorrection
  LLPipeline->>LensFlareStateShader: Generate sun or moon state
  LensFlareStateShader->>StateHistory: Write filtered state
  LLPipeline->>ColorCorrection: Bind state texture
  ColorCorrection->>StateHistory: Sample lens-flare state
Loading

Suggested reviewers: ryemutt

Poem

A rabbit reads each line,
The patch grows clear beneath the moon,
Small changes hop in place,
Tests guard the garden path,
Reviews bloom before the dawn.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 40 functions across 8 files. (7 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: replacing unstable lens-flare visibility with a temporally filtered state pass.
Description check ✅ Passed The description is detailed, on-topic, and covers the problem, implementation, settings changes, verification results, checklist, and additional notes. It does not include the template's Related Issue…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 27.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 40 functions across 8 files. (7 skipped: 7 unsupported.)

  • Fix all pre-merge checks with AI

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Shadowolf7 pushed a commit to Shadowolf7/Vayu-Viewer that referenced this pull request Sep 15, 2026
Replaces per-fragment binary occlusion checks with a 256-tap spiral probe
and a temporally filtered ping-pong state pass (lensFlareStateF.glsl).
Texel 0 holds filtered flare drive with slew caps and adaptive damping to
eliminate violent strobing through thin occluders (fences, foliage, avatar
limbs). computeLensFlare() reads one state texel instead of running repeated
depth taps every fragment.

Replaces deprecated RenderLensFlareOcclusionRadius/Taps with
RenderLensFlareOcclusionScale and RenderLensFlareFadeTime.

Ported from Alchemy PR AlchemyViewer#386 by taylnos.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant