fix(vk): give samples-passed reports the GPU's counts (RX_VK_OCC_QUERIES) #2871

Open
github-sync wants to merge 1 commit from github/pr/2871 into main
Owner

@jordanella opened this pull request on GitHub · 2026-11-12 18:02 UTC · original

On Vulkan every occlusion report reads 4096 (since #487), so Ocarina of Time never culls an object hidden behind something: it draws everything, every frame. This gives the game the GPU's real counts again, behind an opt-in switch, so it can be tested on more hardware before it is on by default.

How the game culls

The z-prepass draws each object between a samples-passed reset and a report into the object's slot. The game reads those slots back on the CPU a frame or two later and leaves the objects whose count was 0 out of its draw lists. In a dumped Kokiri Forest frame there are 491 such reports and no conditional render at all; with every report forced to 0 (RX_VK_OCC_FIXED=0) the game makes ~565 fewer draws a frame and none are "skipped" by a condition. So the counts only have to reach guest memory within a frame or two; no VK_EXT_conditional_rendering is needed.

What changes (RX_VK_OCC_QUERIES=1, off by default)

  • Each draw from a samples-passed reset to its report gets a Vulkan occlusion query (precise where the device has it). The queries come from a 65,536-entry ring and are numbered in NvnState, so a parallel replay's dry pass and its workers agree on them.
  • draw() begins the query once its pass is open and ends it as it returns, before each_guard can end the pass.
  • The report is still written as 4096 at replay, as today. As each recording begins, the replay thread reads the reports whose queries are done and writes their sum into the slot, unless the slot's timestamp word shows it was reported into again.
  • A report whose draw never ran, or that took more than 256 draws, stays 4096. So a late or unknown count costs culling, never a missing object.
  • Needs hostQueryReset (core in Vulkan 1.2); without it the switch logs and stays off.
  • RX_VK_OCC_LOG=1 logs, every ~5,000 reports, how many were written, how many read 0 and how many recordings late they were.

Measured

Kokiri Forest, first save, standing still ~100 s after loading; headless, 2048x1152, RTX 3080 (driver 621.14), Windows; 200 s each, back to back on a quiet machine.

Reports read Draws a frame GPU time a frame Frame rate Picture
4096 (default) ~1,570 ~11.5 ms 26-28 fps normal
0 (RX_VK_OCC_FIXED=0, the ceiling) ~1,010 ~7.5 ms ~30 fps nearly everything gone
GPU counts (RX_VK_OCC_QUERIES=1) ~1,330 ~11.0 ms 27.5-29 fps same as default

About 32% of reports read 0, each written ~1 recording (about a sixth of a frame) after its report; none had to stay "visible". Screenshots at the same present match the default run apart from the falling leaves and fairies (no visual change is expected, so none are attached; happy to add the pair).

Also measured: HOOKSHOT_OCCLUSION_OFF=1 gives the same picture and the same GPU time and frame rate as the default on Vulkan, as expected since nothing was culled before.

Tests

  • New tests/test_vk_occlusion_queries.py: runs the ring's code (cut from vk_replay.cpp) against a fake query pool and guest memory (sum when done, "visible" when a draw never ran / overflowed / was reported again, in order, ring reset once read, dry-pass numbering), and checks the wiring in the source. It fails without the change.
  • tests/fsr_uniform_ownership_test.cpp: stubs occ_collect / occ_value, which begin_recording now uses.
  • python -I -B tests/run_all.py on Windows: the failures left are test_bake_portability (no zstandard in that Python), the same without this change.

Not tested yet

  • A moving camera and indoor scenes (pop-in when something comes out from behind cover; bigger savings where more is hidden).
  • The sun with real counts: its haze should now dim when the sun is hidden.
  • AMD (Tier 1, half the reports), the Steam Deck, integrated GPUs and Linux. Testing wanted there before this is on by default.

🤖 Generated with Claude Code

> [@jordanella](https://github.com/jordanella) opened this pull request on GitHub · 2026-11-12 18:02 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2871) On Vulkan every occlusion report reads 4096 (since #487), so Ocarina of Time never culls an object hidden behind something: it draws everything, every frame. This gives the game the GPU's real counts again, behind an opt-in switch, so it can be tested on more hardware before it is on by default. ## How the game culls The z-prepass draws each object between a samples-passed reset and a report into the object's slot. The game reads those slots back on the CPU a frame or two later and leaves the objects whose count was 0 out of its draw lists. In a dumped Kokiri Forest frame there are 491 such reports and no conditional render at all; with every report forced to 0 (`RX_VK_OCC_FIXED=0`) the game makes ~565 fewer draws a frame and none are "skipped" by a condition. So the counts only have to reach guest memory within a frame or two; no `VK_EXT_conditional_rendering` is needed. ## What changes (`RX_VK_OCC_QUERIES=1`, off by default) - Each draw from a samples-passed reset to its report gets a Vulkan occlusion query (precise where the device has it). The queries come from a 65,536-entry ring and are numbered in `NvnState`, so a parallel replay's dry pass and its workers agree on them. - `draw()` begins the query once its pass is open and ends it as it returns, before `each_guard` can end the pass. - The report is still written as 4096 at replay, as today. As each recording begins, the replay thread reads the reports whose queries are done and writes their sum into the slot, unless the slot's timestamp word shows it was reported into again. - A report whose draw never ran, or that took more than 256 draws, stays 4096. So a late or unknown count costs culling, never a missing object. - Needs `hostQueryReset` (core in Vulkan 1.2); without it the switch logs and stays off. - `RX_VK_OCC_LOG=1` logs, every ~5,000 reports, how many were written, how many read 0 and how many recordings late they were. ## Measured Kokiri Forest, first save, standing still ~100 s after loading; headless, 2048x1152, RTX 3080 (driver 621.14), Windows; 200 s each, back to back on a quiet machine. | Reports read | Draws a frame | GPU time a frame | Frame rate | Picture | |---|---|---|---|---| | 4096 (default) | ~1,570 | ~11.5 ms | 26-28 fps | normal | | 0 (`RX_VK_OCC_FIXED=0`, the ceiling) | ~1,010 | ~7.5 ms | ~30 fps | nearly everything gone | | GPU counts (`RX_VK_OCC_QUERIES=1`) | ~1,330 | ~11.0 ms | 27.5-29 fps | same as default | About 32% of reports read 0, each written ~1 recording (about a sixth of a frame) after its report; none had to stay "visible". Screenshots at the same present match the default run apart from the falling leaves and fairies (no visual change is expected, so none are attached; happy to add the pair). Also measured: `HOOKSHOT_OCCLUSION_OFF=1` gives the same picture and the same GPU time and frame rate as the default on Vulkan, as expected since nothing was culled before. ## Tests - New `tests/test_vk_occlusion_queries.py`: runs the ring's code (cut from `vk_replay.cpp`) against a fake query pool and guest memory (sum when done, "visible" when a draw never ran / overflowed / was reported again, in order, ring reset once read, dry-pass numbering), and checks the wiring in the source. It fails without the change. - `tests/fsr_uniform_ownership_test.cpp`: stubs `occ_collect` / `occ_value`, which `begin_recording` now uses. - `python -I -B tests/run_all.py` on Windows: the failures left are `test_bake_portability` (no `zstandard` in that Python), the same without this change. ## Not tested yet - A moving camera and indoor scenes (pop-in when something comes out from behind cover; bigger savings where more is hidden). - The sun with real counts: its haze should now dim when the sun is hidden. - AMD (Tier 1, half the reports), the Steam Deck, integrated GPUs and Linux. Testing wanted there before this is on by default. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- github-sync:item:2871 -->
Ocarina of Time reads its z-prepass occlusion reports back on the CPU a frame
or two later and leaves out of its draw lists the objects whose count was 0.
The Vulkan renderer writes every samples-passed report as 4096 (PR #487), so
the game never culled anything.

With RX_VK_OCC_QUERIES=1 each draw from a samples-passed reset to its report
gets a Vulkan occlusion query, numbered in the replay state so a parallel
replay's dry pass and its workers agree. The report is still written as 4096
at replay; once its queries are done the replay thread writes their sum over
it, unless the slot was reported into again since. A report whose draw did not
run, or that took more than 256 draws, stays 4096. Off by default.

Kokiri Forest, standing still, RTX 3080: ~1,570 -> ~1,330 draws a frame,
~11.5 -> ~11.0 ms of GPU time, 26-28 -> 27.5-29 fps, the same picture; ~32% of
reports read 0, each written about one recording after the report.

tests/test_vk_occlusion_queries.py runs the ring's code against a fake query
pool and checks the wiring in the source; tests/fsr_uniform_ownership_test.cpp
stubs the harvest begin_recording now calls.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Commenting is not possible because the repository is archived.
No description provided.