Skip to content

Stop compositing a special effect on frames that did not request it - #178

Merged
themuffinator merged 1 commit into
themuffinator:mainfrom
jm2:fix/rvspecial-stale-effect-state
Sep 22, 2026
Merged

themuffinator merged 1 commit into
themuffinator:mainfrom
jm2:fix/rvspecial-stale-effect-state

Conversation

@jm2

@jm2 jm2 commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #171. Supersedes #172.

After the multiplayer join card is answered, the world can stay blurred until vid_restart, even though the game has cleared its soft focus and the renderer's blur bit.

Cause

R_AddSpecialEffects issues no RB_DrawSpecialEffects command once every effect is off. That command is the only place rbRVSpecialActiveMask, rbRVSpecialBlurPrepared and the depth captures are refreshed, so after the last effect frame they keep their old values. RB_DisplaySpecialEffects never checked their age, so it kept compositing the blur every frame, from a depth mask captured while the card was up. The stale mask is why the lingering blur looks different from the card's intended soft focus, and why it can look inconsistent.

After the join card was answered on GL (game code at 9889d9e, unmodified), a debugger read showed:

value
tr.specialEffectsEnabled / joinScreenSoftFocusEnabled 0 / false
rbRVSpecialActiveMask / rbRVSpecialBlurPrepared 1 / true
rbRVSpecialCommandFrame frozen at 1271 while backEnd.frameCount advanced 1940 → 2093

Fix

RB_DisplaySpecialEffects now returns unless rbRVSpecialCommandFrame == backEnd.frameCount, so effect state is only honoured on the frame whose command produced it. The special-frame record at the end of the view (draw_common.cpp, rbRVSpecialCommandFrame == backEnd.frameCount && rbRVSpecialCommandView == viewDef) already applies the same rule. No game-module change is needed: the game's own bookkeeping was already correct.

Repro

Console only, on OpenGL:

  1. +set si_gameType DM +set ui_autoJoin 1 +spawnServer mp/q4dm1
  2. r_forceSpecialEffects 1, wait a moment, then r_forceSpecialEffects 0
  3. The blur stays until vid_restart.

The player-facing path is the join card: +set ui_autoJoin 0, then click JOIN GAME.

What was tested, and what wasn't

macOS arm64 (M1 Max), OpenGL (Apple GL 2.1 compatibility path), fullscreen 3456x2104, 4x MSAA, openQ4-game at 9889d9e. Each pair of compared frames shows the same view. The join card was answered through idSessionLocal::MenuEvent with a left click on the button, the same path a real click takes. Sharpness is the mean absolute luminance gradient over the distant region of the frame (higher is sharper):

distant near floor
auto-join, card never shown (reference) 13.12 7.08
before: after clicking JOIN GAME 4.64 6.52
after: after clicking JOIN GAME 12.81 7.08

Console repro, same view within each run (distant region):

before r_forceSpecialEffects 1 back to 0
before the fix 12.56 10.57 10.53 (stuck)
with the fix 10.06 8.83 9.83 (recovers)

The two rows start at different values because the spawn view differs between runs.

  • Before the fix, after clicking JOIN GAME: waiting 20 s, r_skipPostProcess 1 and g_doubleVision 0 all leave the blur in place. vid_restart restores 12.81.
  • With the fix: the card's soft focus still applies while it is up. SPECTATE also clears it, though there is no before-fix capture from the same view to compare against.
  • renderer_classic_special_frame_domain.py, renderer_vulkan_world_interaction_compatibility.py and renderer_classic_gui_domain.py pass.
  • Not yet tested: Vulkan, Windows, Linux. Results will follow.

🤖 Generated with Claude Code

https://claude.ai/code/session_017RU1i6R3gN4cdKRELGiCEc

R_AddSpecialEffects issues no RB_DrawSpecialEffects command once every
effect is off, so the mask and depth captures that command leaves in
rbRVSpecialActiveMask and rbRVSpecialBlurPrepared are never refreshed.
RB_DisplaySpecialEffects kept honouring them, and went on compositing the
last frame's blur on every later frame.

Answering the multiplayer join card is the common way in: the game clears
its soft focus and the renderer's blur bit, yet the world stays blurred,
from a depth mask captured when the card was up, until a vid_restart.

Only honour that state on the frame whose command produced it, as the
special-frame record at the end of the view already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RU1i6R3gN4cdKRELGiCEc
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@jm2

jm2 commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Windows and Linux results. Commits: base 71c3230c, this PR efd83814, openQ4-game 9889d9e (upstream main), built through the wrapper on both machines.

Correction to the repro above: r_forceSpecialEffects is a cheat cvar and is refused in multiplayer, so step 1 needs +set sv_cheats 1. Without it the blur never turns on.

Distant-region sharpness. Same view within each run, and the same spawn view in base and fix on each platform:

before r_forceSpecialEffects 1 back to 0 after vid_restart
Windows GL, base 12.67 10.19 10.23 (stuck) 12.50
Windows GL, this PR 12.62 10.35 12.62 (recovers) 12.19
Linux GL, base 21.52 18.19 17.88 (stuck) 21.52
Linux GL, this PR 21.50 18.19 21.52 (recovers) 21.52
  • Windows: Windows 11, AMD RX 6400, Adrenalin 25.10.30.02, windowed.
  • Linux: Fedora 44, Wayland, AMD RX 6400, Mesa 26.2.2, windowed 1280x720.
  • GL, base: the world stays soft with crisp HUD text after the effect is turned off, until vid_restart. On Linux the stuck frame is also about 10 luminance levels brighter than the first.
  • GL, this PR: frame 3 matches frame 1. On Linux it is pixel-identical in the measured region to base's sharp frames, and the forced-blur frame is pixel-identical between builds, so the effect itself is unchanged. Three more Linux runs from other spawn views all recover.
  • Vulkan: on both platforms the forced blur produces no visible effect in either build, so the leak doesn't apply there. The validation layer (1.4.357 on Windows, 1.4.341 on Linux) reported nothing.
  • Join card: with this PR, the world behind the card is still clearly soft on both platforms (world-only strip above the card: 1.83 on Windows; 4.24 on Linux against 14.30 for a sharp gameplay frame).
  • No new warnings or errors against base on either platform. renderer_classic_special_frame_domain.py and renderer_classic_gui_domain.py pass on both.

@themuffinator themuffinator left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right fix, and the diagnosis holds up. Thank you for reworking it — the difference between this and #172 is the difference between describing the bug and guessing at it.

The guard matches the rule the special-frame record at the end of the view already applies (draw_common.cpp:9993), and backEnd.frameCount is assigned from cmd->frameCount in tr_backend.cpp:505, so it is stable for the whole backend frame and the comparison means exactly what you want it to mean.

Reproduced and verified on Windows

I ran your console repro headlessly on Windows x64, OpenGL, at a frozen view (g_stopTime 1) so all three captures show the same frame. Sharpness is the mean absolute luminance gradient over the centre of the frame:

reference r_forceSpecialEffects 1 back to 0
main (71c3230c) 0.699 0.543 0.543 — stuck
with this PR 0.645 0.505 0.645 — recovers exactly

So it is not macOS-specific, and the fix restores the reference frame precisely rather than approximately. (The two rows start at different absolute values because the spawn view differs between runs, as you noted.)

One extra data point

The same probe on Vulkan recovers correctly on main without this change — 0.656 → 0.541 → 0.656. That corroborates the diagnosis rather than contradicting it: VK_GuiExecutor_PrepareSpecialEffects recomputes its mask from tr.specialEffectsEnabled every frame, so it has no stale state to honour. The bug is specific to the GL backend's file-scope rbRVSpecial* statics, which is exactly where you put the fix.

renderer_classic_special_frame_domain.py, renderer_classic_gui_domain.py, gui_clipping_contract.py, renderer_vulkan_probe_safety.py and renderer_vulkan_gui_residency.py pass on Windows. Builds clean on MSVC.

Merging. I will close #171 once you confirm the join-card path on a build from main.

@themuffinator
themuffinator merged commit a230e96 into themuffinator:main Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[macOS rendering]: distant rendering very blurry/double pixelated

2 participants