dx12_dxil.inc has every raster shader and the HDR output's as DXIL,
shader model 6.0, made by dxc and signed, each held to the raster root
signature (-verifyrootsignature); kernels_dx12.sh update-dxil makes it,
and check compares it. DX12Pipelines::Init() asks the device for its
shader model before the pipeline file is read: DXIL from 6.0 on, DXBC
below, a pipeline's shaders always of one kind. The pipeline file's sum is
of the shaders chosen.
tools/rend/d3d_dxil.sh runs the test discs on Direct3D 12 as the device
answers and with it saying 5.1 (d3d_hdr.c's D3DH_SM51): the shaders are
DXIL only where the device takes it, every pipeline is made, and the
pictures are the DXBC ones.
The polygon pixel shader writes a second colour, and the factors of the
TSP's instructions that read only the source - zero, one, its alpha and
one less it, and for the destination also its colour and one less it -
are applied there: the pipeline has one for the source and the second
colour for the destination (dual-source blending). DX12PsoKey::Pack()
folds the key to match, so the 64 pairs of instructions are 15 pipelines,
all of them made at the start, and the startup set is queued with the keys
from earlier runs first and the commonest pipelines before the rare ones.
On the test disc no pipeline is made in a frame any more (two or three
were, a run).
The pipeline file is version 3 and keeps a sum of the shaders' bytecode:
a library made with other shaders is started again (a pipeline kept for
other bytecode never loaded and its name could not be stored again), and
a version 2 file's keys are folded. kernels_dx12.sh update-raster makes
only dx12_dxbc.inc again; the raster and HDR DXBC are made with the
d3dcompiler_47.dll that made hdr_encode_dxbc.inc.
tools/rend/dx12_blend_fold_test.sh checks every pair of instructions, with
colours in and out of range, against the PowerVR's blend.
The scheduler's clock stands still for a time slice, and a game waiting
on the beam reads SPG_STATUS many times in one: spg_sync() keeps the
clock it last brought the beam up to and returns at once while it has
not moved and the event has not been aimed again. ReadMem_area0() looks
for SPG_STATUS before the rest of the area.
The headless disc runs 3000 frames in 7.5 s, from 11.5 s.
tools/pvr/spg_beam_test.sh compares every read with the beam found
afresh, and so covers the clock kept as well.
A game waiting on the beam reads SPG_STATUS hundreds of times a line,
and each read found the next line due and divided twice to place the
beam. spg_beam() now keeps what it found - the line, the pending
event's end, the cycles that bound the line - and answers a read on the
same line, aimed by the same event, with a subtraction. Aiming the
event, a timing change or a state loaded starts it afresh.
The headless disc runs 3000 frames in 11.5 s, from 17.8 s.
tools/pvr/spg_beam_test.sh makes every read twice, kept and afresh,
straight and with every frame run ahead, and fails where they differ.
Where the adapter's memory is the processor's, kept coherent with its
cache (CacheCoherentUMA), the frame's vertices, indices and modifier
volumes' triangles are parsed straight into a buffer of a custom heap
with write-back pages, mapped for good, in four slices
(dx12/parse_arena_d3d12.h, C89): no copy into the upload ring. The
sorted passes' indices go after the frame's there. A slice is taken when
the context's fence has passed the value of the submission that last
drew from it, polled; with none free, or another adapter, the frame is
copied as ever. Render() is the base's, final, around RenderFrame().
d3d_hdr.c's D3DH_NOT_UMA has the adapter say otherwise and D3DH_EVERY
prints every frame's sum; d3d_parse_arena.sh checks every render drawn
from where it was parsed and every frame the one of the lists copied,
under wine.
The parse's arena has a part for each list the card draws from: the
vertices, the indices (with the sorted passes' room after them), and the
modifier volumes' triangles and those cut (rend/modvol_clip.h); a frame's
lists are moved into a slice together (parse_arena_attach()). The volumes'
triangles a frame draws are drawn from there with Vulkan and OpenGL, not
copied into the main buffer or uploaded each frame. With OpenGL the
per-triangle sorted passes' indices are written after the frame's, in its
index buffer, not uploaded to a buffer of their own; the per-pixel
renderer's volume array is set again when its buffer changes.
The heavy disc has a shadow volume a billion pixels off the screen,
first in its list, over a polygon that takes shadows: the volumes' cut
triangles, and those after it, drawn from their part (vk_parse_arena.sh,
gl_parse_arena.sh), which also take the disc with volumes of each cull
mode now.
The headless job installs gcc-aarch64-linux-gnu without what it
recommends, so libc6-dev-arm64-cross, which has the C library's headers,
is installed by name. ta_index_test.sh builds and runs a program with
those headers under qemu before it takes the NEON lane; without them it
says so and goes on, and with NEON=required, as the job runs it, fails
saying why.
With ARB_buffer_storage (OpenGL 4.4) the frame's vertices and indices are
parsed straight into a vertex and an index buffer the card reads, of
immutable storage mapped persistently in the client's memory, one pair a
frame in flight (gles/parse_arena_gl.h, C89): no glBufferData() copy of
them each frame. A slot is taken when the fence after the last frame
drawn from it has passed, asked without waiting; with none free, or where
the mapping reads back slowly, the frame is copied as ever. Both renderers
draw their strips from the frame's buffers and their own quads (VMU
screens, crosshairs, framebuffer) through their own. parse_arena.h's
slices can be bound apart, a buffer each.
A desktop build on an OpenGL ES context clears depth with glClearDepthf():
glClearDepth() is not ES's, and left the clear value as it was.
gl_parse_arena.sh checks every frame against the copy (GLF_NO_STORAGE),
no OpenGL error under MESA_DEBUG, and OpenGL ES as ever; gl_frames counts
no persistent mapping as a read.
The fast vertex loop tests each vertex it converts for being left out
of the indices - three words at once with SSE2 or NEON, as it has them
in hand - and marks a strip it wrote whole with none such in a bit a
polygon (ta_strip_ok, ta_index.h). make_index() then reads none of that
strip's vertices again. Any other strip is looked at as before. The
parse is 21-26% faster on the heavy disc (2.17 to 1.60 ns a vertex
with no vertex left out), its output the same on every test disc.
The heavy disc has three strips a list with a vertex left out (an
infinite x, a NaN depth, a huge y). ta_parse_bench.sh parses each frame
once more with no strip known good and fails if anything differs;
ta_index_test.sh tests the many-at-once test against the reference, as
SSE2, scalar and NEON (under qemu where there is an AArch64 compiler).
Each read back of an image (the picture, the upscaler's motion vectors
and depth) makes a buffer, its memory and a command pool: they are
destroyed after it, on every path. The buffer left over after the
core's teardown was this frontend's, not the core's.
vk_parse_arena.sh's change of size now asks for no buffer left at all,
in place and copied, rather than as many as with the lists copied.
The frame's lists are ta_list.c's (C89): List<T> is a typed facade over
it. A strip's indices are ta_index.c's C89 kernel: one branch-free check
of the whole strip, then a straight run of indices; the per-vertex loop
only where a vertex is bad or the room runs out. The parse is about 20%
faster (2.74 to 2.17 ns a vertex on the heavy disc), its output the same
byte for byte.
With Vulkan on a machine whose video memory the CPU reads back fast (one
memory, as lavapipe's and integrated GPUs'), the vertices and indices
are parsed straight into a slice of memory the card reads, one slice a
frame in flight (rend/parse_arena.h, vulkan/parse_arena_vk.h), and the
translucent sort writes after them: no copy into the main buffer. Other
machines copy as ever.
Tests: tools/pvr/ta_index_test.sh (against the old loop, 1M strips),
tools/rend/parse_arena_test.sh, tools/vulkan/vk_parse_arena.sh (every
render in place, pictures those of the copy, each renderer, threaded,
upscaler, a change of size), tools/pvr/ta_parse_bench.sh; vk_hdr gains
VKH_UNCACHED, VKH_OPTS_AT and VKH_BUFFERS.
A texture read through a stride narrower than it - a part of a render
drawn through TEXT_CONTROL - is made from the render on the card by every
renderer now, not only Vulkan: the stride's columns copied and the rest of
each row cleared, to what zeros are in the texture's format (ClearOpaque():
opaque black in 565, as the read-back has it; Vulkan's was clear).
vk_hdr counts what the core reads back off the card (VKH_READS), and
tools/vulkan/vk_dma.sh holds the DMA disc to 40 reads as gl_dma.sh does.
The disc's view of a render made every frame is a stride texture now: blue
up to the stride and black past it, the same as the read-back's picture,
alpha included.
Direct3D 11, 12 and 9 make a render still on the graphics card into a
texture of another shape there too (CopyRender()), at every scale, not
read back every frame: Direct3D 11 copies the render's part, drawn the
console's size first where it is finer (shrink(), which its read-back
draws with as well); Direct3D 12 draws it onto a picture of the pool's to
render to; Direct3D 9 stretches it onto a texture that can be rendered to.
A renderer that cannot leaves the texture's type as it was.
A render still on the graphics card, drawn as a texture of another shape -
fewer lines, through a stride, a part of it - is made into that texture on
the card (CopyRender(): a copy, or a blit where the render is finer), not
fetched into video memory and made from there, which waited for the card
every frame such a render was made again. Its rows have to be the render's
and all it reads inside what was rendered, in the render's format; it stays
watched by write-protection and is made again with the next render there.
Vulkan and OpenGL 3 / OpenGL ES 3 have it; the rest fetch as before. A page
a dropped lock left protected is not protected again.
gl_frames counts reads off the card (GLF_READS). The DMA disc draws the
bottom half of a render made every frame: tools/gl/gl_dma.sh holds it to 40
reads in its run, where reading it back costs 70.
A render to a texture writes video memory on the console. Here its picture
stays on the graphics card, and video memory gets it later without a write
that faults, so a texture made from that memory - another size, another
format, a part of it - went on showing what was there before: a render made
every frame and drawn as a texture of another shape froze at its first
frame. rtt_watch_add() now has every texture made from what the render
covers made again (VramRendered()), all but the one rendered to, with no
change of page protection; every renderer names that one in its watch.
The DMA test disc renders a picture every frame, red then blue, and draws
it as a texture of another size: tools/vulkan/vk_dma.sh, tools/gl/gl_dma.sh
and tools/rend/d3d_dma.sh have it blue.
glsm's glFenceSync returns a void *, which clang does not make a GLsync
without a cast, and glsm has no glFlush: the copies off the card are now
flushed by a wait of no time on the last one's fence, with
GL_SYNC_FLUSH_COMMANDS_BIT.
tools/gl/gl_shapes.sh compiles the OpenGL renderers on Linux as macOS
(clang) and the Switch (libnx's GL symbols) build them, in CI.
GD-ROM sets that come with the network DIMM board now have it answer, as
firmware 3.17 with its network settings, instead of seeing no board at
all: games that check the board's version at start-up (error 14 below
3.17) boot. The network commands are still refused, and those games go on
after their network check times out. What the plain and the network board
leave in the NAOMI's memory is in naomi_dimm.c, C89, with a regression
lane in tools/dimm. The network board's part of the save state is a new
V31 part; states from before it load with the board silent until reset.
A state saved or loaded copied the Dreamcast's SRAM, which it does not
have: no memory and a size of 0, a copy from or to a null pointer
(UBSan). Parts of no size are now nothing to copy.
headless.sh's run-ahead stage passes: its do-nothing OpenGL read back
from the mapping the core writes through, so what it fetched off the
card depended on the core's history, and frames run from a state loaded
were not the frames run straight. Readbacks are now the same bytes every
time. headless.sh runs in CI (Headless).
gl_frames runs frames ahead (GLF_AHEAD), and tools/gl/gl_ahead.sh runs a
disc (live_disc.py --keep) that renders to a texture at the very end of
a frame and only draws with it after: every frame run ahead has to show
what a plain run shows, the picture saved in the state while still on
the card. On OpenGL and OpenGL ES, and under ASan and UBSan in CI.
A block holds the blocks that jump to it, so two that jump to each other
hold each other. At the end the block tables were left as they were, and
when they went with the core's statics - the core unloaded with
dlclose(), or the process ending - those blocks and what they held were
never freed: about half a megabyte for a few hundred frames of the test
disc, each time a frontend unloads the core. bm_Term() now cuts the
links between them and empties the tables, nothing running the code by
then.
tools/threads/unload_leaks.sh runs the test discs' frames with the core
built with AddressSanitizer, unloads it, ends the process and fails on
anything LeakSanitizer finds; the sanitizers workflow runs it.
gl_frames.c unloads the core when no frame was drawn too.
The raster and per-pixel renderers' render passes made the frontend's reading
of their picture wait for them by region. The frontend reads it from a render
pass of its own, or by a copy, and anywhere in it: a GPU that draws in tiles
let it read tiles whose translucent polygons were not drawn yet, a lattice of
blocks with only the opaque ones. The dependency is now on the whole picture,
for a shader, a compute upscaler or a copy reading it.
tools/rend/speckle_test.sh checks that no dependency out to what reads a
render pass's picture is by region.
The per-triangle and per-strip renderer's program discards what is
inside the clipping rectangle when the polygon asks to be drawn outside
it, as the per-pixel one's does: it compared the flag with -1, which it
never is, and drew the whole polygon. tools/gl/gl_clip.sh draws three
such polygons of one program, the middle one's rectangle another, on
each renderer with OpenGL and OpenGL ES (the test disc built with
-DLIVE_CLIP: tools/threads/live_disc.py --clip), and is run in CI.
That renderer sets a polygon's trilinear blend, palette and clipping
uniforms only when they differ from what its program was last given,
and its textures' LOD bias and anisotropy only when they differ from
what the texture has. The texture parameters are kept by texture name
in a table, not a map, and every texture deleted goes through the cache.
The screen's render pass clears and draws into an image the frontend was
handed before, on the queue the two share, and now depends on whatever read
it there - a shader, a compute upscaler, a copy - being done first, as the
per-pixel, SSAA and render to texture passes already do. A GPU that draws in
tiles showed tiles of the next frame with only its opaque polygons drawn, the
translucent ones over them missing, per triangle and per strip alike.
tools/rend/speckle_test.sh checks the dependency in the sources.
What a frame slot counted is read by whichever drawer comes to the slot next
and goes to the bands of the kind of frame drawn there, the screen's or a
render to a texture's, kept apart in OITBuffers (rend/oit_bands.h). The two
take turns at the slots, so a screen whose translucent fragments overflow the
buffer is drawn in bands with any number of renders to a texture a frame and
any count of slots, as the other per-pixel renderers do.
tools/rend/oit_bands_test.sh checks it over every count of slots and of
renders to a texture a frame.
The SSA optimizer's sets and maps of (register, version) are sorted
arrays in memory kept from one block to the next, in the order std::map
had them; the register allocator keeps its guest registers in a table
by register with a bit each, its free host registers in a ring and the
spilled ones in an array. A block compiles to the same code, with no
allocation in the optimizer or the allocator.
A texture locked in video memory takes its block from those unlocked
before, so a texture rendered to each frame allocates nothing, and each
page's locks are a list that counts the live ones, so whether a page is
to be protected is known without going through the list.
The command pool keeps, for each frame's place in the ring, its pool,
fence and the command buffers made from that pool in plain arrays: the
pool reset each time round frees what they recorded, so a frame takes
them again by count, and submits them where they are. What is retired
to a place is a pointer and its deleter in a list, not a shared_ptr
made for each.
The texture cache's textures in flight are a list a place and a bit a
place in each texture, instead of a hash set a place: one test of a bit
on each use, nothing allocated once the lists have grown, and the
textures of a frame in the order it used them. The caches' cleanups
gather what they drop in a few words on the stack.
The sorter keeps its lists in memory only ever grown (grow_buf.h): a
SortList of draws a render pass, no std::vector anywhere in it or in the
renderers that call it. Triangles are sorted by depth with a stable
radix sort of (key, index) pairs (rend/sort_pairs.h, C89: three passes of
11 bits, a pass of one digit skipped; merged runs below 512, insertion
below 64), the same order a stable sort by < gives - about 8 times
std::stable_sort at 20,000 triangles. The strips (per-strip sorting) and
the prepass's runs go the same way, by the radix sort and a C merge sort.
SortPassesTo() sorts every pass and its prepass into one index array, and
SortPassesRoom() says how large it can be: Vulkan, Direct3D 11 and 12 call
these instead of each having the loop. Direct3D 9 now sorts straight into
its locked index buffer, as the others do into their mapped ones, instead
of into a vector per pass copied in after. OpenGL sorts into one buffer
and narrows it to 16 bits in place where the indices are.
tools/rend/sort_pairs_test.sh checks the sort against a stable sort by <
(CI). tools/rend/d3d9_frames.c is a Direct3D 9 frontend that checksums
every frame, and tools/rend/d3d_same.sh compares two Windows builds with
it and with d3d_hdr (Direct3D 9 needs Microsoft's d3dcompiler_47 under
wine: D3DCOMPILER).
The uniforms that change by the polygon are set only when a polygon wants
another value than its program has: what each program's were last set to
is kept with the program (every record drawn with a general program has
that program's), bit for bit, and forgotten when the program is linked.
The two samplers every texture is drawn through are told a parameter only
when it differs from what they have, and the anisotropic minification
filter is set once, not set and then set again. On the test disc that
leaves about one call in ten of each.
A polygon's texture descriptor set is written once and kept, a frame slot's
for as long as its frames bind it, known by the serials of the views it
holds (a view's serial is never another's, where a handle can be made
again at the same value), and handed to the draw that binds it with no
second lookup: plain arrays, an open-addressed table and a stack of free
sets. A render to a texture keeps its framebuffer while its attachments
are the same ones, the command pools keep the memory their commands were
recorded in and submit from a list kept for it, and the descriptor writes
and the main buffer's pieces of a frame are arrays on the stack.
The temporal upscaler writes where each vertex was straight into the
memory the GPU reads it from (the Vulkan buffer mapped, Direct3D 12's
upload ring), and matches strips through grow_buf.h memory and tables kept
from frame to frame - the kept strips found by signature through a
counting sort's index - instead of vectors and maps made again each frame.
The framebuffer a game writes itself is read with the chip's 32-bit view
mapped inline (pvr_map32() in pvr_mem.h), not through a call for every
pixel. OpenGL keeps its texture from one frame to the next, made again only
for another size, and converts the picture straight into a pixel buffer,
mapped, on OpenGL 3 and OpenGL ES 3; without one, into memory it keeps.
The per-pixel renderer's translucent polygon words are made in their buffer
mapped, new each frame. What is kept from frame to frame for the rest -
OpenGL ES 2's 16-bit indices, the framebuffer's memory, the polygon words
where the buffer cannot be mapped - is grow_buf.h's: C89 memory that is
only ever grown, never zeroed or copied when it is.
A block copy into video memory (PVR-DMA, the channel 2 texture path) does
once, for the whole of it, what its writes did a page at a time through
faults: a picture rendered there that it replaces whole is dropped instead
of read back from the card and overwritten, one it covers in part is
fetched first, and the protected pages are opened and marked written with
one change of protection a run. A picture that settles is copied off the
card the frame before it is read, several fetched at once share one batch
and one wait, and a slot taken when all are full is the oldest one's.
Vulkan, Direct3D 11 and 12 and OpenGL 3 copy into memory of their own per
picture (Vulkan without a staging image at the console's scale, OpenGL
into pixel buffers, mapped and handed over with no copy of this side's,
its framebuffers kept). tools/threads/live_prog.c has a DMA build;
vk_dma.sh, gl_dma.sh (gl_frames.c: OpenGL on Mesa's surfaceless EGL),
d3d_dma.sh and rtt_watch_test.sh check it, in CI.
rchd_feed_borrow said lent bytes had to outlive every rchd_read_step()
that returned RCHD_PENDING. The decoder is done with them sooner: once
it has decoded the hunk, which is before it asks for the next range.
Spell that out, so a caller without a mapping can lend one staging
buffer per request and refill it for the next instead of paying for
rchd_feed_at()'s copy, and say that lent bytes are used once.
Documentation only; the decoder already behaves this way.
Reading a CHD allocated on the hot path. Every hunk fetched from the
file malloc'd a fresh staging buffer for its compressed bytes and freed
the last one; every CD FLAC hunk built a FLAC decoder (four
allocations) and tore it down; and the decoded-hunk cache, the CD
codecs' sector scratch and rflac's rewind point were all allocated on
the first read rather than at open. For an emulator core each of these
lands in the middle of a game.
- The staging buffer is kept and reused. It is sized to one hunk when
the open finishes (a compressed blob never exceeds a hunk) and only
grows for a blob that does. A borrowed supply points at the
caller's memory without freeing it.
- A borrowed range is handed out once: a later request for the same
range collects it again instead of reusing a pointer the caller only
promised for one read.
- One CD FLAC decoder per image, made at open and rewound with
rflac_reset() per hunk.
- The hunk cache and the CD sector scratch are allocated at open. Each
falls back to allocating on first use if that fails.
- rflac: a headerless decoder allocates its rewind point with itself,
not on its first frame.
Checked with a malloc counter over every sector of CD LZMA, CD FLAC
and mixed images at the default and 1 MiB hunk sizes: 299 and 25
allocations during reads before, 0 after. rchd_supply_test and
rchd_read_test pass under ASan/UBSan with leak detection on 23 chdman
images (four CD codecs plus the default set, hunks of 19584, 523872,
526320 and 1047744 bytes), exercising both the borrow and the copy
paths.
The version 5 hunk ceiling was 512 KiB, but chdman writes hunks of up
to 1 MiB (its HUNK_SIZE_MAX). Every CD image made with a hunk above
524288 bytes -- 526320 (215 frames) and up -- was refused at open.
libchdr, which rchd replaced, had no such cap. Backport of the
RetroArch libretro-common fix (reported against beetle-saturn,
libretro/beetle-saturn-libretro#98).
live_disc.py --atlas draws textures of every layout texel for texel and
writes a little of each every frame. tools/vulkan/vk_parts.sh runs it on
each renderer, with GPU decode and threaded rendering on and off: the
pictures have to be the same, and the same as another core's when given.
A texture watched by its hashes is hashed by 4 KB pieces of video memory.
When only some of them changed, and nothing else (palette, stride), the
regions of the image those pieces are in are made again and the rest is
left: twiddled, planar, compressed (unless the codebook changed), paletted
and mipmapped textures down to their 8x8 level, up to 16 regions, while
they cover less than half of it. Vulkan makes them on the GPU over those
groups only (vkCmdDispatchBase) from just their bytes, or copies the rows
of a planar one; converted on the CPU, Vulkan, Direct3D 11 and 12 and
OpenGL upload just them. Direct3D's and OpenGL's decoders make the texture
whole; so do textures scaled up, dumped or replaced.
tools/rend/texconv_test.sh part holds every update of textures of each
kind, written a few bytes at a time, to what a full upload makes, on the
CPU and through the kernel's text.
A texture that changes often is watched by a hash of its bytes in video
memory, and a render's parse asks for it for every polygon that uses it.
It is hashed the first time in a render, and again in that render only
after the render thread has collected a write to video memory.
tools/rend/texconv_test.sh holds every request against what a full upload
makes, with video memory written between renders and in the middle of one,
and a texture asked for 300 times in a render to one hash.
live_disc.py --cull adds modifier volumes and translucent polygons of
each cull mode. vk_hdr counts pipelines by cull mode and the cull modes
set, and can hide a device extension (VKH_HIDE_EXT). vk_pipelines.sh
runs on that disc, and each renderer again without
VK_EXT_extended_dynamic_state: no pipeline with the cull mode dynamic
then, and the same picture.
Where the device has VK_EXT_extended_dynamic_state, the modifier volume
and depth prepass pipelines are made once for the three cull modes, with
the cull mode dynamic, and a block of their draws sets it when it
changes. Without it they are made for each cull mode as before; the
choice is made once when the renderer starts.
GenSortedTo() and GenPrepassTo() write the indices to a pointer, with
room for them from SortedTriangles(), once each and in order. Vulkan,
Direct3D 11 and Direct3D 12 sort into the mapped buffer the card reads
them from - from the vertices as the game sent them, before their
colours are moved - instead of into vectors copied after. The vertices,
indices and volumes keep their copy: the CPU reads them again.
A renderer's read hands rtt_watch the picture with its row pitch, and it
is packed into video memory from there: the Vulkan staging buffer when the
host's cache is in front of it, the Direct3D 11 staging texture and the
Direct3D 12 readback buffer, mapped, instead of copied out first. OpenGL
and Direct3D 9 read into the scratch memory and hand that over.
ReadFramebufferGeometry() gives the picture's size first and
ReadFramebufferTo() converts it to a pointer and row pitch: the Vulkan
and Direct3D 12 upload rings and the mapped Direct3D 11 texture take it
there, instead of from the texture cache's memory copied after. Each
texel is stored once, whole. ReadFramebuffer() stays for OpenGL,
Direct3D 9, and Vulkan when the ring has no room.
The per-pixel renderer writes each translucent polygon's two words where the shaders read them, instead of into a vector made each render and copied after.
vk_hdr counts the pipelines made on each thread (VKH_COUNT_PIPELINES),
can wait before its first frame (VKH_LOAD_PAUSE_MS) and unload the game
so the core saves its pipeline cache (VKH_UNLOAD). vk_pipelines.sh runs
each renderer without the cache file, with it, and with its driver data
spoiled: none made on the frontend's thread while the context is made,
the keys kept and taken again, the pictures the same.
A renderer hands its general, modifier volume, prepass and other fixed
pipelines to the pipeline thread when it starts, and no longer waits for
them; a draw takes one made, makes it itself if the thread has not
started on it, or waits for the thread if it has. The quad, SSAA resolve,
motion vector, texture decoder, TA decoder and compute renderer
pipelines go the same way, and one the thread could not make is made
where it is first used, failing there as it did at start. The compute
renderer's kernels are handed over first, as every frame uses them.