Files
LibretroAdmin 24cb5e2e51 vulkan: SSAA in both raster renderers
A new core option, SSAA (reicast_ssaa): Off, 2x, 4x, 8x; Off by default.
Each pixel of the Internal Resolution is drawn from that many samples
and resolved by the shared rule (rend/tile/pvr_resolve.h). It is taken
up when the renderer starts and when the option changes, which is a
change of size (Resize()); nothing is made, compiled or read back while
a game runs for it.

How many samples (VulkanContext::SsaaSamples()): the most of those asked
for that the device can draw colour, depth and stencil with
(ssaa_device_samples()), and then that the video memory budget the
context read at start-up has room for (ssaa_clamp_samples()): the
screen's colour and depth, per sample, after the per-pixel renderer's
lists; nothing per sample for the per-triangle renderer where the device
has lazily allocated memory, since its sample attachments are transient. One line in the log says what was asked and
what was taken. It needs sample rate shading, which the core enables on
the device it makes (VkCreateDevice); a device the frontend made without
the core gets no SSAA, and says so.

The per-triangle renderer (drawer.cpp, pipeline.cpp):
- The screen's and the textures' render passes (MakeSsaaRenderPass())
  draw into colour and depth attachments of N samples, transient, cleared
  and never stored (load CLEAR, store DONT_CARE), and a second subpass
  resolves the colour into the picture (SsaaResolve, quad.cpp): a
  triangle over the framebuffer reads each sample through a
  subpassInputMS, as the unorm8 the attachment holds (packUnorm4x8()),
  and writes (sum + N/2) / N per channel. Never the device's resolve.
  The picture is all written by it, so it is not loaded.
- Pipelines get the sample count and a rate bit in their key (bit 30):
  shaded at each sample (sample shading, minSampleShading 1, the inputs
  "sample") or once a pixel. Once a pixel are the polygons drawn at a
  single rate (SsaaSingleRate(): sprites, the background, and flat strips
  where the decoder looks for them); their inputs are taken at the
  pixel's centre (interpolateAtOffset(v, 0)) where they cover all of a
  pixel's samples, which is where a frame without SSAA takes them (with
  more than one sample the device may take them elsewhere), and at the
  centroid where they cover only some, so that an edge pixel is not
  shaded from beyond the polygon. Both rates of a key are made together,
  so SSAA adds no compile of its own to the lazy creation that is there
  already.
- The last picture under a partial render, and modifier volumes, go into
  the samples; the VMUs and the OSD are drawn after the resolve.
- Renders to a texture are supersampled only where their sample
  attachments cost nothing (lazily allocated memory: tilers), with the
  attachments made at start-up as large as such a texture can be (1024 x
  the render-to-texture upscale a side). Elsewhere they are drawn with one
  sample, as without SSAA: at 4x that preallocation is 48 MB at 1024 x
  1024, more than the whole of what brute force (twice the Internal
  Resolution) adds, and what a game reads back of such a render stays what
  it is without SSAA.

The per-pixel renderer (oit/):
- Four subpasses with SSAA (RenderPasses::MakeSsaaRenderPass()): 0 depth
  and stencil, 1 colour and the translucent lists, 2 the translucent
  modifier volumes, 3 the picture put together and resolved, in one
  pass: the lists are read and sorted once a pixel, blended at each
  sample onto that sample's opaque colour, and the samples resolved by
  the shared rule. The sample attachments are one image each for all
  the render passes of a frame: a frame of one render pass never stores
  them; one that is not the last puts each sample together into them in
  subpass 2 (a per-sample variant of the same shader) and writes the
  translucent depth there, for the next render pass to draw over. They
  are not transient, since such a frame stores them.
- Textures are rendered to with one sample a pixel, as without SSAA,
  with shaders of their own (rttShaderManager): see the per-triangle
  renderer for why.
- A translucent fragment of a sorted pass is put in the lists once a
  pixel, with the samples it covers and is in front of the opaque depth
  at in eight bits of its polygon number's word (polygon numbers are
  under 2^22 then): the lists do not grow by N. Where triangles that
  share an edge cross a pixel each has a fragment there, with the
  samples that are its own; small triangles make a pixel's lists longer
  that way, and the most fragments sorted a pixel goes from 32 to 96
  with SSAA (without it, unchanged). Translucent modifier volumes count
  where they cover a pixel's first sample, so that two triangles of a
  volume sharing an edge count once.
- Stencil and depth are read as subpassInputMS at the sample a shader
  runs for (or the first one it covers, once a pixel).

Shaders without SSAA are built from the same text as before but for the
names of their inputs: a frame with SSAA off is drawn as it was (checked
against the build before, byte for byte, below).

GPU-side TA processing: the GPU's decode does not look for flat strips
(it would have to read every vertex on the CPU to choose a pipeline), so
with TA Processing = GPU only sprites and the background are drawn once
a pixel, and flat strips are shaded at each sample; ta_set_flat_strips()
is asked for only when frames are parsed on the CPU. The two give the
same picture with SSAA off, and pictures that differ by that with it on.

The log, for measuring: the GPU time of the screen's frames (two
timestamps a frame, read when the frame's slot of the command pool comes
round again, after its fence; never waited for) as mean, 99th
percentile and longest at the end (GpuFrameTimer), and the video memory
in use then (LogVramUsage()).

No change to the save state; run-ahead stays exact.

Tested on lavapipe (Mesa's software Vulkan, samples 1 and 4 only: 8x is
4x there, 2x off), RetroArch with the validation layer, over saved and
booted scenes (Jet Grind Radio, Sega Rally 2, Virtua Fighter 4, the live
test's disc), both renderers, threaded rendering on and off, TA
processing on the CPU and the GPU, run-ahead, Internal Resolution 2x:
- no validation error in any of them, SSAA on or off;
- SSAA off: the screenshots are the build before's, byte for byte;
- the live test's disc (2D: sprites and flat polygons, two render passes,
  two volumes, modifier volumes, a render to a texture, punch-through and
  sorted translucent polygons) with SSAA 4x: the build before's picture,
  byte for byte, in every one of the eight ways it was run;
- tools/threads/headless.sh, with and without reicast_ssaa=4x: the
  verdicts and the sound of the build before; ta_gpu_test and
  texdecode_test pass.

GPU time (the log above; lavapipe, so the figures only show the
direction), Jet Grind Radio, 175 frames, against brute force - SSAA off
at twice the Internal Resolution, the same samples a pixel:
- per-triangle: SSAA off 38.0 ms mean (p99 111.1), SSAA 4x 128.1
  (p99 174.9), brute force 111.0 (p99 154.6);
- per-pixel: off 110.9 (p99 155.7), 4x 472.1 (p99 575.0), brute force
  369.1 (p99 441.5).
A software rasteriser shades every sample of a pixel as it shades a
pixel, has no compressed samples and no faster MSAA path, so SSAA cannot
win there; the comparison that decides it is on the NVIDIA machines.
Video memory, worked out from the attachments made (lavapipe's heap is
the host's, shared): SSAA 4x at 640 x 480 adds 12 MB to the per-triangle
renderer (colour and depth of 4 samples, 14.7 MB, in the place of one
sample's depth) and 10 MB to the per-pixel one (in the place of its two
colour attachments and its depth); brute force adds 15 to 22 MB and 26
to 30 MB (every image at four times the pixels - the two or three
pictures of the frontend's ring, depth, and for the per-pixel renderer
its colour attachments and list heads), and the frontend's own scaling
on top.
2026-10-15 15:32:49 +02:00

88 lines
3.0 KiB
C++

/* The GPU time of the screen's renders, for the log: a timestamp before a
* frame's commands and one after, read back when the frame's slot of the
* command pool comes round again - its fence has been waited for by then,
* so the read never waits, and nothing is read before that. Nothing is
* allocated after Init(): the times are kept in room made there.
*
* At Term(), one line: how many frames, and their mean, 99th percentile
* and longest GPU time. */
#pragma once
#include <algorithm>
#include <vector>
#include "vulkan_context.h"
class GpuFrameTimer
{
public:
void Init(int slots, const char *what)
{
Term();
this->what = what;
VulkanContext *context = VulkanContext::Instance();
const std::vector<vk::QueueFamilyProperties> families = context->GetPhysicalDevice().getQueueFamilyProperties();
const u32 family = context->GetGraphicsQueueFamilyIndex();
const u32 bits = family < families.size() ? families[family].timestampValidBits : 0;
if (bits == 0 || slots <= 0)
return;
queries = context->GetDevice().createQueryPoolUnique(vk::QueryPoolCreateInfo(vk::QueryPoolCreateFlags(),
vk::QueryType::eTimestamp, 2 * slots));
period = context->GetPhysicalDevice().getProperties().limits.timestampPeriod;
mask = bits >= 64 ? ~0ull : (1ull << bits) - 1;
written.assign(slots, false);
times.clear();
times.reserve(MaxTimes);
}
/* Before the first command of the frame in @slot of the command pool,
* outside a render pass: the time the slot's last frame took is taken */
void Begin(vk::CommandBuffer cmd, int slot)
{
if (!queries || slot < 0 || slot >= (int)written.size())
return;
if (written[slot])
{
u64 ts[2];
if (VulkanContext::Instance()->GetDevice().getQueryPoolResults(*queries, 2 * slot, 2, sizeof(ts), ts, sizeof(u64),
vk::QueryResultFlagBits::e64) == vk::Result::eSuccess && times.size() < MaxTimes)
times.push_back((float)((double)((ts[1] - ts[0]) & mask) * period / 1e6));
}
cmd.resetQueryPool(*queries, 2 * slot, 2);
cmd.writeTimestamp(vk::PipelineStageFlagBits::eTopOfPipe, *queries, 2 * slot);
written[slot] = true;
}
// ...and after its last
void End(vk::CommandBuffer cmd, int slot)
{
if (!queries || slot < 0 || slot >= (int)written.size())
return;
cmd.writeTimestamp(vk::PipelineStageFlagBits::eBottomOfPipe, *queries, 2 * slot + 1);
}
void Term()
{
if (!times.empty())
{
std::vector<float> sorted(times);
std::sort(sorted.begin(), sorted.end());
double sum = 0;
for (float t : sorted)
sum += t;
NOTICE_LOG(RENDERER, "Vulkan %s: GPU time of %u frames: mean %.3f ms, 99th percentile %.3f ms, longest %.3f ms", what,
(u32)sorted.size(), sum / sorted.size(), sorted[(sorted.size() * 99) / 100 < sorted.size() ? (sorted.size() * 99) / 100 : sorted.size() - 1],
sorted.back());
}
times.clear();
written.clear();
queries.reset();
}
private:
static constexpr size_t MaxTimes = 1 << 16;
vk::UniqueQueryPool queries;
double period = 0; // ns a tick
u64 mask = 0;
std::vector<bool> written;
std::vector<float> times; // ms
const char *what = "";
};