Rondo is the platform's renderer. The Renderer object: the internal viewport it draws into, how that viewport is scaled and
letterboxed onto the window, the once-per-frame submission entry point, the amortized atlas/palette
uploads, and how shaders reach the GPU. The content model a frame carries (layers, tiles, sprites,
colour) is draw-state.md + tiles-and-colour.md; this page is
the object and the output path.
#include "retropp/renderer.h" // Renderer
#include "retropp/viewport.h" // ViewportResolution
#include "retropp/geometry.h" // PixelSize, IntRect, integerScaleToFitRect, fitWindowScale
#include "retropp/output.h" // SamplingMode- The model
- The internal viewport:
ViewportResolution - Filling the window:
integerScaleToFitRect - Sampling:
SamplingMode - Evaluation grid:
EvaluationGrid - Per-frame submission:
renderFrame - Offscreen capture:
captureViewport - Post-process effects:
postEffects - Custom shader stages: register a shader by path
- Amortized resources:
uploadAtlas/uploadPalette - Render statistics:
renderStats - Layer-key collision policy
- How shaders reach the GPU
- Where to change things
The renderer draws every frame into an offscreen internal viewport at a fixed retro resolution
(160×144 by default), then blits that viewport onto the window at the largest integer scale that
fits, centred, with letterbox/pillarbox bars filling any leftover. Game content is authored once at
the small native resolution; the renderer always fills whatever window it's given, crisply. This
two-stage path (render small → fill window) is what keeps pixels square at any window size. The
presentation size — how big that window is — is the window-scale concern owned by the platform
(EngineConfig::enhancements.windowScale + window().size(), see
platform-and-windowing.md); the renderer's only output knob is
sampling (nearest/bilinear, below). Post-process display filters (CRT and friends) are a later
stage (planned — see the Coverage table in the guide index).
The renderer is constructed from a live device + window (handed out by SdlPlatform) — drawing is
the renderer's job, the platform owns the window/device/input.
class Renderer {
public:
// Settable defaults — EngineConfig::setActive() seeds each from the host config so a bare
// Renderer{device, window} inherits them (see platform-and-windowing.md).
static inline ViewportResolution defaultViewport = ViewportResolution::GameBoyColor; // 160×144
static inline SamplingMode defaultSamplingMode = SamplingMode::Nearest; // crisp
static inline bool defaultInterpolation = true; // smooth motion
static inline EvaluationGrid defaultEvaluationGrid = EvaluationGrid::Viewport; // crisp analytic paths
Renderer(SDL_GPUDevice* device, SDL_Window* window,
ViewportResolution viewport = defaultViewport); // window == nullptr → compose-only
// Amortized indexed-atlas + palette uploads (see "Amortized resources" below).
AtlasManifest uploadAtlas(const std::uint8_t* indices, int width, int height,
TransparentIndices transparent = TransparentIndices::None);
// also uint16_t / uint32_t overloads, and assetSize/kind carve
// overloads declaring the sheet's grid (tiles-and-colour.md)
AtlasManifest uploadAtlas(const LoadedImage&); // always throws — load PNGs via loadAtlas()
PaletteId uploadPalette(std::span<const Rgba8> colors); // 8-bit source, widened ×257 into the store
PaletteId uploadPalette(std::span<const Rgba16> colors); // 16-bit colour source, appended direct
// PNG loading + slicing (loadAtlas / loadAtlasFromMemory / loadPaletteImage) lives in
// images-and-transparency.md and tiles-and-colour.md.
// Bake a cubic / arbitrary curved boundary into an SDF mask a Region samples (see draw-state.md).
CurveMaskId bakeCurveMask(const Curve& boundary, float padding = 8.0f, int maxResolution = 256);
ShapePoints bakeCurveRegion(const Curve& boundary, float radius = 0.0f, Transform t = {},
float padding = 8.0f, int maxResolution = 256);
PostProcessStageId registerPostProcessStage(LiteralPath shaderPath); // custom shader, by .hlsl path (string literal)
PostProcessStageId registerPostProcessStage(const ShaderVariants& fragment); // lower-level seam the path form resolves to
void renderFrame(const FrameDrawState& frame); // composite + present; auto-interpolates (default)
std::vector<Rgba8> captureViewport(const FrameDrawState& frame); // compose offscreen, download pixels (scale 1)
std::vector<Rgba8> captureViewport(const FrameDrawState& frame, int composeScale); // same, at an explicit compose scale
void setLayerCollisionPolicy(LayerKeyCollisionPolicy) noexcept;
LayerKeyCollisionPolicy layerCollisionPolicy() const noexcept;
void automaticInterpolation(bool enabled) noexcept; // automatic per-object easing; default on
bool automaticInterpolation() const noexcept;
void evaluationGrid(EvaluationGrid) noexcept; // analytic-path grid: Viewport / Output
EvaluationGrid evaluationGrid() const noexcept;
void samplingMode(SamplingMode) noexcept; // blit sampler: Nearest / Bilinear
SamplingMode samplingMode() const noexcept;
};struct ViewportResolution {
int width = 160, height = 144;
PixelSize size() const; // {width, height} — always integer pixels
Vec2 center() const; // {width/2, height/2} — float; a half-pixel on odd dimensions
static const ViewportResolution GameBoy, GameBoyColor, GameBoyAdvance,
Nes, Snes, Genesis, MasterSystem;
};The internal render resolution defaults to the original Game Boy 160×144 (the faithful default) and
is configurable so a game can request a larger internal viewport — e.g. a wider visible world for a
zoom-out feature — without the platform assuming a fixed size. Common-platform resolutions are named
presets (the self-type-constant idiom shared with PaletteSize / TimingProfile); a resolution is
a {width, height} tuple, so a preset and a raw value are interchangeable:
Renderer{dev, win, ViewportResolution::Nes}; // a preset (256×240)
Renderer{dev, win, {256, 224}}; // or any raw {width, height}size() returns the dimensions as a PixelSize — always integer, since a render target is a whole count
of pixels. center() returns the geometric middle as a Vec2: a float, because an arbitrary odd-width
viewport centers on a half-pixel (a 257-wide viewport at 128.5). Feed it to a sprite's placement to sit
content dead-center on the viewport; even dimensions (every preset) give a whole number.
It is not an exhaustive registry — add platforms as needed. The platform generalizes beyond the Game Boy, so a fixed resolution baked into the type would be the hardcoded-dimensions mistake the project avoids elsewhere.
IntRect integerScaleToFitRect(PixelSize drawable, PixelSize viewport) noexcept; // geometry.hEach frame the renderer reads the window's drawableSize() and fills it at the largest integer
multiple of the viewport that fits, centred, with the leftover split into letterbox/pillarbox bars
(a pure, GPU-independent, unit-tested helper). It tracks resizes, so the content follows the window
live. This is fill, not size — the renderer crisply fills whatever window it's handed. Choosing
how big that window is (the presentation scale) is windowScale on the platform side; see
platform-and-windowing.md. The two compose: the window is sized to an
integer multiple of the viewport, and the renderer then fills it exactly with no bars.
enum class SamplingMode { Nearest, Bilinear }; // output.h
void Renderer::samplingMode(SamplingMode) noexcept; // runtime-dynamic
SamplingMode Renderer::samplingMode() const noexcept;Nearest (the default) is point sampling — crisp, square pixels, the faithful look. Bilinear
smooths the upscale. This is a blit sampler swap, not a shader change: the renderer builds both
samplers up front and binds the one the current mode selects. The tile/atlas path always samples
nearest; only the final viewport→window blit honours the mode. The default (Nearest) reproduces the
faithful crisp-pixel output value-for-value; a consumer reads EngineConfig::enhancements.sampling and calls
the setter, then toggles it live from a settings menu.
Native fullscreen is a Platform-seam concern, not a renderer one — see
platform-and-windowing.md. The fill blit absorbs the fullscreen target
size with no renderer change. High-DPI is automatic: the window opts into
SDL_WINDOW_HIGH_PIXEL_DENSITY, drawableSize() reports physical pixels, and the fill picks the
larger integer scale so the art renders crisp at native resolution.
enum class EvaluationGrid { Viewport, Output }; // output.h
void Renderer::evaluationGrid(EvaluationGrid) noexcept; // runtime-dynamic
EvaluationGrid Renderer::evaluationGrid() const noexcept;Viewport (the default) evaluates every analytic render path on the viewport grid — transformed tile
layers, the effect regions (select / stencil, in polygon / analytic-curve / baked-mask form), the
sampling effects (RowDisplacement / Ripple / Swirl), geometrically-transformed sprites (Sprite::transform
/ DrawLayer::transform), a Bloom or Glow halo read from a field — a region Bloom's, and a Below
lens's (the field is held per viewport pixel and read back through the grid, so the glow quantizes to whole
viewport pixels along with the content it grades), and
custom shader stages (automatically — see
Custom shader stages). The image is then
pixel-identical to the viewport-resolution rasterization,
nearest-upscaled: crisp, square pixels — even when placement composites onto a finer grid for steady
motion. Output evaluates each path per output pixel instead, so edges and displacement resolve smoothly
at the higher resolution (softer under upscale). The setting is a mathematical no-op when the compositor
runs at viewport resolution — it only bites once compositing runs above it.
A transformed sprite resolves its coverage per viewport cell: the rotated / scaled / foreshortened silhouette AND the internal texel stairs land on the viewport grid, so a spun sprite reads as chunky blocks that upscale cleanly rather than resampled edges. An untransformed sprite is unaffected either way — its texels are already solid blocks and its sub-pixel placement is the steady-motion mechanism.
Evaluation granularity is independent of placement granularity: object placement stays sub-pixel on
either setting (the steady-motion mechanism), so EvaluationGrid chooses only where the geometry is
sampled, not where whole objects land. Seed it from EngineConfig::evaluationGrid (which setActive()
fans into Renderer::defaultEvaluationGrid), then flip it live from a settings menu.
void renderFrame(const FrameDrawState& frame);Call this once per render callback. The game hands a whole FrameDrawState (the Z-sorted layer
stack — see draw-state.md); the renderer composites the layers back-to-front into
the offscreen viewport — applying any per-layer screen-space effects (DrawLayer::effects,
Layer / Below scope) as it goes — then runs the frame-level post-process chain (postEffects,
below) and the frame's shape-confined regions, and blits the viewport integer-scaled + letterboxed
onto the swapchain and presents. There is no mid-frame state-change API — a frame is computed whole
and submitted whole, every frame. A frame with no per-layer effects composites in a single pass — the
per-layer path is paid for only where used.
Ordering. Layers stack by DrawLayer::z (unique within the frame, ascending, back-to-front).
Within one sprite layer, sprites stack by Sprite::z — non-unique, ascending, equal values keeping
submission order (a stable sort) — so one layer holds an articulated creature or a Y-sorted crowd
without a layer per rank. See
draw-state.md for the field and
anchors-and-articulation.md for the articulated use.
Automatic interpolation. With interpolation on (the default),
the renderer eases each layer and sprite between its previous and current simulation-tick state by the
run loop's sub-tick factor, matching each object to its prior tick by its key (see
draw-state.md); motion stays smooth when the display refreshes faster than the
simulation ticks. The game submits its latest state each frame and the platform blends.
automaticInterpolation(false) (or EngineConfig::interpolation = false) turns it off, and each submission
composites verbatim — the game then owns any blending itself. The full model, including the
developer-owned path, is in run-loop-and-timing.md.
std::vector<Rgba8> captureViewport(const FrameDrawState& frame);captureViewport composites frame exactly as renderFrame does — the copy pass, the layer
composite, the post-process chain — but downloads the finished viewport as packed Rgba8 instead
of blitting it to the window. The result is viewport.width × viewport.height pixels, row-major, top
row first; the bytes are what renderFrame would have shown. Use it for a thumbnail, a screenshot, a
server-side render, or an image-diff test.
It works on any renderer, including a windowless one. A Renderer built with nullptr for the
window is compose-only: it builds the device-side compose resources but no blit pipeline and never
acquires a swapchain, so it composites and captures but cannot present.
Renderer offscreen{device, /*window=*/nullptr, ViewportResolution::GameBoyColor};
std::vector<Rgba8> pixels = offscreen.captureViewport(frame); // composes on the GPU, no windowA compose-only renderer needs a GPU device but no display, so it runs on a headless machine. The call blocks until the download lands (a fence wait); it is a capture, not part of the per-frame loop, so keep it off the hot path.
After compositing, the renderer runs FrameDrawState::postEffects — a list of screen-space effects
applied to the whole composited viewport before the blit. Each effect is one full-viewport pass;
the renderer ping-pongs two internal scratch targets so any number of effects chain in submission
order. An empty list is the faithful path — the blit samples the composited viewport
directly. To confine an effect to a shape, put it in a Region (which owns the shape and the
effect) on FrameDrawState::regions or DrawLayer::regions — the renderer gates it with an extra
select pass that leaves the rest of the image untouched; see
draw-state.md.
By default these effects and their region gates evaluate on the viewport grid (crisp — the result is
pixel-identical to the viewport-resolution rasterization, nearest-upscaled); switch to per-output-pixel
(smooth) evaluation with EvaluationGrid::Output — see Evaluation grid.
The platform ships a growing set of built-in effects behind ScreenSpaceEffectKind — you name the
kind and set parameters; the platform owns the shader (no registration, no shader authoring). Shipped:
RowDisplacement— wavy water / heat haze / per-line scroll (axis-aligned), with a developer- selectable frame-edge (Blankdefault /Stretch).Ripple— a radial concentric ripple (a water droplet): rings expand outward from acenter, faded with radius bydecay. Aspect-corrected so the rings stay circular.Swirl— an angular twist about acenter(a whirlpool): content withinradiusrotates, the full turn at the centre easing smoothly to nothing at the rim, content outside the disc untouched. Ripple's angular sibling — Ripple pushes the sample along the radius, Swirl carries it around. The turn isamplitude+phasein degrees (advancephaseto spin it); positive turns the content clockwise, matchingTransform::rotation. A zero turn or a zeroradiusis an exact identity.ColorFill— paint a colour (Rgba8 fill, scaled byfloat fillIntensity) into the effect's region:out.rgb = fill · fillIntensity. A filled region is a solid shape, a stroked region a drawn colored line/path; aRegion'salphamakes its fill translucent. (Writes over existing pixels — see draw-state.md for the opaque-source scopes.)Transparency— make the effect's region see-through (reveal what's behind it) instead of painting a colour — the subtractive sibling ofColorFill.stencil(TransparentInsidedefault /TransparentOutside) chooses which side of the shape clears;feathersoftens the edge. Thestencil()free helper is its shorthand (see draw-state.md).Gleam— a luminance-keyed diagonal sheen sweep (a marquee "shine"):sweepslides the band across,widthsets its extent,slantits diagonal angle, andgainits strength (gain = 0is an exact identity). Bright content catches the light; dark stays dark.ColorSaturation— a cross-channel colour drain: pull each pixel toward its own luminance. Thesaturationfield is auint8—255is full colour (the exact identity),0is greyscale, values between desaturate partway. A mood/pause grade aColorFillcannot express (it works per-channel toward a solid colour; this pulls every channel toward the pixel's own brightness).Bloom— a threshold-blur-add glow: pixels brighter thanthreshold(auint8luminance floor;0blooms everything) blur outward byradius(a Gaussian blur, in the site's own pixels) and add back over the source scaled byintensity(auint8;0is the exact identity default,255full strength) — bright content radiates its OWN light as a soft halo. On a sprite the radius is in the sprite's own art pixels, scaled by the placement's transform.Glow— an authored-colour aura, Bloom's sibling: the content radiates a colour YOU pick (fill×fillIntensity, per channel;fillIntensity > 1is an HDR-hot aura), not its own light — the halo's chroma is the tint, never the source hue, so dark art radiates gold or ember at full strength.radius/intensitybehave as Bloom's (intensityorradiusat0is the exact identity);thresholdis the emission floor —0makes the WHOLE silhouette emit (dark pixels included), higher keys the emission on brightness like Bloom's floor. On a sprite it is chain-only (whole-silhouette or a Below lens; a sprite-regionGlowis skipped with a warning — layer and frame regions confine it fully).
radius means something different once a halo is confined. Unconfined — a frame or layer postEffects
entry, or a sprite's own chain — the halo spills past the content that emits it, and radius is how far it
reaches: widen it and the glow grows. Confined — inside a Region, or through a sprite's silhouette at
Below scope — everything outside the shape is clipped away and never drawn, so the light cannot leave. The
blur still spreads a fixed amount of light over a larger area, and the part that lands outside is discarded
rather than shown, so widening radius there softens and dims the halo instead of growing it.
Author it accordingly: inside a shape, radius is a softness control and intensity is the strength one.
Reach for intensity when a confined halo should read stronger, and expect a wide radius to give a faint,
diffuse interior light. This is what a bloom becomes when it cannot spill — the spill is the part radius
normally buys, and a confined halo does not have it.
What a halo costs. Bloom and Glow are the only built-ins that read a neighbourhood, and both
realize as a fixed chain of cheap passes rather than one expensive gather: the emission is extracted (or,
on sprites, rasterized) into a scratch buffer, reduced when the radius is wide, blurred separately on each
axis, and added back. The consequence is that radius is nearly free — a reach of 20 costs about what a
reach of 2 does. Author the radius the scene wants.
On sprites the blur work tracks the distinct reaches a layer authors rather than its sprite count: every halo at one radius blurs together in a single pass, however many sprites or lenses carry it and however far apart they sit. Two radii cost two passes.
Each halo on a sprite occupies a field sized to that sprite's own drawn footprint, packed with the layer's
others into a shared atlas, so a halo costs the area it covers rather than a screenful. Every site runs the
chain — frame, layer, region, sprite chain, sprite Below lens, and both Layer- and Below-scope sprite
regions; none gathers.
A game's own Custom stage reaches this same chain by declaration: mark it an emission consumer and it
gets an extracted, blurred field of its own, read back through sampleEmission(), at the same cost — no
per-pixel gather. See Emission: a custom stage's own glow below.
You build one with plain designated-init — set .kind and the fields that kind consults; every field is
settable inline, nothing is hidden:
frame.postEffects.push_back(ScreenSpaceEffect{
.kind = ScreenSpaceEffectKind::Ripple, .amplitude = 6.0f, .frequency = 6.0f,
.phase = t * 0.012f, .center = {80, 72}, .decay = 2.5f});
frame.postEffects.push_back(ScreenSpaceEffect{
.kind = ScreenSpaceEffectKind::RowDisplacement, .amplitude = 3.0f, .frequency = 3.0f,
.phase = t * 0.01f, .axis = Axis::Horizontal});center is in viewport pixels (the platform normalizes to UV); advance phase slowly off your frame
counter to animate (slow expansion — no strobing). The effect type, its scopes (frame-level here vs.
per-layer), the edge choice, and which fields each kind consults are documented in
draw-state.md. These are content effects declared on the
draw state — distinct from output-side display filters (CRT/scanlines), a separate planned stage (below).
When the built-in effect vocabulary stops, a game writes its own fragment shader and uses it as a first-class screen-space effect. Registration is just the shader's path:
auto stage = renderer.registerPostProcessStage("game/shaders/my_effect.frag.hlsl");That is the whole thing — no ShaderVariants, no uniform type, no generated-header include, no CMake
rule. A build-time source scan sees that .hlsl path referenced in your code, compiles it to this
platform's GPU bytecode, embeds it in the executable, and registers it under the path string; the call
resolves the path against the embedded registry at load time and builds the pipeline pair.
The path must be a string literal. Because the scan reads it out of your source verbatim, a path passed as a variable, a
std::string, or a computed/concatenated value is invisible to it. The parameter type enforces this: a non-literal is a compile error, not a runtime surprise. Write the literal directly at the call —registerPostProcessStage("game/shaders/my_effect.frag.hlsl").
Your shader declares its own parameters in a cbuffer — its own names — and writes only main(); the
platform supplies the source texture and sampler:
// game/shaders/my_effect.frag.hlsl — you write only the cbuffer + main()
cbuffer Params : register(b1, space3) {
float2 center;
float strength;
};
float4 main(float2 uv : TEXCOORD0) : SV_Target0 {
return sampleSource(uv + (center - uv) * strength); // sampleSource() honours the effect's edge policy
}The build reflects that cbuffer and surfaces its fields on ScreenSpaceEffect by name, so you set them
inline — exactly like a built-in's named params, with no uniform struct, no as_bytes, no size:
frame.postEffects.push_back(ScreenSpaceEffect{
.kind = ScreenSpaceEffectKind::Custom, .customShader = stage,
.center = {0.5f, 0.5f}, .strength = 0.2f}); // your shader's OWN params, mutated live per frameIt composes with the built-ins in submission order, at either attachment point (postEffects or
DrawLayer::effects), and is region-gateable — wherever a built-in works, a custom shader does too.
The fragment contract. The game supplies a fragment only — the platform's shared fullscreen-triangle
postprocess.vert is the vertex stage. The platform supplies sampleSource(uv) (the
composited image, or the prior chain pass) plus a platform cbuffer (b0, space3). Your shader adds its own
parameter cbuffer at b1, space3, which the build reflects into the effect's inline fields and fills
per frame from them. Always sample through sampleSource() — it obeys the effect's edge policy
(ScreenSpaceEffect::edge): Blank (the default) returns transparent outside [0,1] so an effect that
displaces past the frame edge reveals the backdrop / layers below; Stretch clamps (smears) the border.
The edge behaviour is the layer/effect's choice, not the shader's — sampling SourceTexture directly
opts out and always clamps. No runtime shader compiler — the bytecode is built and embedded; nothing is
loaded from disk at run time. Handles live until the renderer is destroyed.
Crisp automatically. A custom shader written to this contract — spatial math on the uv your
main() receives, sampling through sampleSource() — is crisp on the default
EvaluationGrid::Viewport with no shader-side work. The platform's
generated entry point hands your main() the centre of the viewport cell its fragment falls in, so your
math evaluates once per viewport pixel; sampleSource() quantizes the displacement you request to whole
viewport pixels; paramRowAtUv() lands on the row the viewport-resolution rasterization reads. The
result upscales as solid square pixels even when the compositor runs at output resolution for steady
motion. EvaluationGrid::Output hands main() the raw output-resolution uv and samples exactly what you
request — smooth evaluation, wholesale.
Build wiring. The platform's own examples get the source scan automatically. A standalone game applies it once
per target — retropp_autocompile_shaders(<target>) after defining the target — and then never touches
CMake again, however many shaders it adds; re-run CMake after adding a new path reference (the scan is a
configure-time read of your sources). The custom path is for the long tail the built-in library doesn't
cover — effects that do have a use case are built-ins that need none of this. The custom_stage_test
exercises the reflection + packing device-free.
Bloom and Glow are cheap because the platform extracts the emission into a scratch field, blurs it once,
and hands it back — rather than gathering a neighbourhood per pixel (What a halo costs).
A custom stage joins that same chain with one declaration line near the top of its .hlsl:
// @retropp:emissionThat marks the stage an emission consumer: the platform extracts a field for it, blurs the field by the
effect's .radius, and hands it back to main() through sampleEmission(uv). The reach is declared — it
rides the submission — never computed in the shader. Demand rides the same .radius / .threshold a built-in
Bloom / Glow reads, so there are no new effect fields and no API call:
frame.postEffects.push_back(ScreenSpaceEffect{
.kind = ScreenSpaceEffectKind::Custom, .customShader = stage,
.radius = 15.0f, .threshold = 200}); // the reach, and the stock brightpass floorThere are two ways to fill the field:
-
Stock brightpass — declaration only. With no
emission()body, the platform fills the field with the built-in brightpass at.threshold(threshold0 = the whole content emits). That is aBloomof the source's own bright light, obtained in one line:// @retropp:emission float4 main(float2 uv : TEXCOORD0) : SV_Target0 { return sampleSource(uv) + sampleEmission(uv); // the source plus its own bloomed light }
-
Authored — an
emission()body. Definefloat4 emission(float2 uv)besidemain()and it authors the field itself, so it can emit a signal the luminance brightpass cannot single out. The platform runs the body in the extract pass, reading the scene through the samesampleSource()the stock extract has:// @retropp:emission float4 emission(float2 uv) { float3 c = sampleSource(uv).rgb; float e = saturate(c.b - max(c.r, c.g)); // a blue-dominant mask a luminance key would miss return float4(e, e, e, e); } float4 main(float2 uv : TEXCOORD0) : SV_Target0 { return sampleSource(uv) + sampleEmission(uv); // the source plus its authored glow }
sampleEmission(uv) takes the same uv sampleSource(uv) does, and the stage works at every site a
built-in Glow / Bloom does — a frame postEffect, a DrawLayer::effects (either scope), a region, a
sprite effect chain, and a Below-scope sprite lens. On the Layer-scope sprite chain there is no
composited scene to hand an emission() body, so the field content there is the sprite's own art brightpass
at .threshold; a body is used at the scene-facing sites — the frame chain and the below lens.
examples/custom_emission/ shows both fill modes, at a frame stage and on a below lens.
Two things follow from the chain being the platform's, not the shader's. An emission stage has no gather
variant, so declaring both // @retropp:emission and // @retropp:additive on one shader is a build error
(an additive delta and a retained-scratch consumer are different contracts). And the blur is obtained by
declaration, never computed — the platform cannot verify a hand-rolled blur any more than it can verify the
additive promise, so declaring the stage an emission consumer is the whole surface.
Fast path for many additive regions — one declaration line. When a custom shader is used across many
region-confined effects on one frame (e.g. a glow per pickup, a light per particle), the naive cost is two
full-frame passes per region — a serialized cliff as the count grows. If your shader's output is its source
plus a term that does not depend on the source's contents — out = sampleSource(uv) + D(uv) (a bloom, a
light, an additive tint) — declare it additive with a single comment line near the top of the .hlsl:
// @retropp:additiveThat is the entire opt-in — no API call, no Region field, no build rule. The platform compiles a second
variant and collapses all eligible same-shader regions into one instanced additive pass (pass count
becomes independent of region count). It engages automatically for a region on Normal blend at full
alpha whose shape is a circle or capsule; any other region silently keeps the per-region path with
identical output, so you never structure your scene around it. The declaration is a promise: a shader that
multiplies the source, samples its neighbours, or otherwise depends on the source contents will render
incorrectly on the fast path — the platform cannot verify additivity, which is exactly why the safe (per-
region) path is the default and the fast path is the thing you vouch for. Built-in additive effects need no
declaration (the platform already knows their math). An additive glow that tints and adds without reading
what is beneath it is the worked case.
Fast path for many source-dependent regions — automatic. The additive declaration above only covers
effects whose output does not read the source's contents. A shader that does read the source — a
displacement, a refraction, a hue rotation of the sampled image (out = sampleSource(uv + duv)) — has no
source-independent term to extract, so it cannot use the additive path. For these, the platform collapses many
same-shader region-confined effects into one union-shape pass that reads the previous image once and, per
pixel, applies the effect with the winning region's own params (each region's inline params ride the pass, so
they may differ — a warp whose amplitude tapers per segment gathers fine). This engages automatically — a
custom stage can only see sampleSource and its own params, so the gathered result is always well-defined;
you write nothing. It applies to a region on Normal blend at full alpha whose shape is a circle or
capsule; any other region keeps the per-region path. The worked case is Ferryman's mutant reality-warp
wake — many trailing warp circles on one layer, now one pass regardless of count.
Two things to know about the semantics:
-
Where same-shader regions overlap, the effect applies once with the last (topmost) region's params — not once per overlapping region compounded. The union of the shapes reads as a single region; displaced samples always read the pristine previous image, never a neighbouring region's output. For most animated content this is invisible, and it is the more predictable behaviour; but if you want an effect to compound where two regions overlap (
eff(eff(src))), opt out:// @retropp:no-gatherwhich keeps every region on the sequential per-region path.
-
The same effect chained twice on one region also gathers to a single application. Intentional re-application of one shader needs the
no-gatherline (or a second shader file).
Fast path for many ColorFill regions — automatic, and wider. Drawing UI as ColorFill regions —
panels, swatches, meters, whole-frame grades — is the built-in case of the same cliff, and it collapses
the same way: a contiguous run of ColorFill-confined regions renders as one pass regardless of count.
Nothing to declare, and unlike the custom fast paths there are no shape or blend carve-outs to design
around: any Region::alpha, any blend mode, inverted regions, stroked outlines, transformed shapes, and
polygons all take the fast path (only a curve-boundary shape keeps the per-region path). The output is
pixel-identical to the sequential per-region result — overlapping grades still compound, and translucent
fills still stack, because the pass composites every covering region in submission order rather than
picking a winner. A lone ColorFill region keeps the per-region path at identical cost. The worked case is
examples/input_probe/ — its ~30 solid-rectangle UI regions render as one pass per frame.
Pixel art and colour are uploaded once (at load time / on change), not per frame; the draw state then references them by handle each frame. See tiles-and-colour.md for the colour model and images-and-transparency.md for loading art from PNG.
uploadAtlas(indices, w, h, transparent = TransparentIndices::None)→AtlasManifest(the sheet; take the handle explicitly viasheet.atlasId). An indexed atlas: one palette index per pixel (not RGBA). Every upload declares the sheet's carve — this form carvesSingle; theassetSize/kindoverloads declare a grid (see tiles-and-colour.md). The optionalTransparentIndicesset declares that source's structural index holes (defaultNone= every index draws) — see images-and-transparency.md.uploadPalette(colors)→PaletteId. One palette's colours, written to a row of the renderer-owned palette store.
Each upload writes only what it adds, so uploading during play costs the new colours or pixels rather than everything already in the store; the store texture is recreated only when the content outgrows the room it has.
Handles stay valid until the renderer is destroyed (there is no per-handle eviction yet).
You describe the whole frame every time. The renderer does not send the whole frame to the GPU every time — it compares what you submitted against what it last uploaded and skips the transfer when nothing moved. This happens at three levels, all automatic:
- Tile layers. The packed cells are hashed each frame; an unchanged map costs no DMA. A huge map
can skip even the hash by answering the question itself with
TileContent::contentChanged— see tilemaps.md. - Sprite layers. The built sprite records are hashed the same way, so a layer of stationary sprites re-uploads nothing. Sprites that actually move upload every frame, by correctness.
- The whole frame. When a submission is bit-identical to the last one and every interpolated value has settled, the composite is not re-run at all — the retained output is re-blitted instead. An idle screen composes zero times per second while still presenting at the refresh rate.
Each layer is tracked by its key, which is why the key is required and why it must be stable across
frames: it is the identity the comparison is keyed on. A layer whose key changes every frame looks
like a new layer each time and cannot be skipped.
The consequence worth knowing: whether you rebuild your FrameDrawState from scratch every frame
or retain it and mutate what changed, the GPU traffic is the same, because the decision to upload is
made from the content rather than from how you assembled it. That choice is about how you prefer to
write your game — see Retained vs rebuilt frame state — not
about render cost. What it does not cover is the CPU work you do assembling the frame; rebuilding a
very large cell array every frame still costs you the rebuild, whatever the renderer then decides.
renderStats() reports how often each skip fired, which is how you confirm it rather than assume it.
renderStats() returns a by-value RenderStats — cumulative counters of how much work the renderer
issued versus skipped as redundant, since construction. Read two snapshots and diff them to measure a
window (one frame, or a run of frames):
const RenderStats before = renderer.renderStats();
// ... run a while ...
const RenderStats now = renderer.renderStats();
const auto composed = now.composePasses - before.composePasses;
const auto skipped = now.composeSkips - before.composeSkips; // idle screen -> composed 0, skipped ~refresh rate| Field | Meaning |
|---|---|
tilemapUploads / tilemapSkips |
per-layer tilemap uploads issued / skipped (cell content unchanged) |
spriteUploads / spriteSkips |
per-layer sprite-record uploads issued / skipped (built records unchanged) |
composePasses / composeSkips |
full-frame composes run / skipped (frame bit-identical, retained output re-blitted) |
presentPasses / presentSkips |
renderFrame calls that blitted a swapchain texture / had none to blit |
lastFrame |
the most recent frame's phase split (below) |
Each skip counter reads against its issued counterpart — the pair is the ratio. On a still screen the
skip counters climb while the issued counters hold steady; under motion they invert. composePasses
counts both renderFrame and captureViewport composes; only renderFrame skips — captureViewport
always composes. A headless renderer (no window) presents nothing, so presentSkips counts every frame.
lastFrame is a RenderStats::PhaseTimings: the CPU milliseconds the most recent renderFrame spent,
split by phase, plus two flags describing what that frame did.
| Field | Meaning |
|---|---|
acquireMs |
acquiring the command buffer |
interpMs |
interpolation — reconciling the submission and easing it |
composeMs |
composing the viewport image; 0 when the frame skipped it |
presentMs |
swapchain acquire, blit and submit |
presented |
this frame reached the screen |
composeSkipped |
the retained output was re-blitted instead of recomposed |
Read it alongside the counters: a phase that grows while its counter holds steady is doing more work per unit rather than more units of work.
Count presented frames, not render calls. On Metal the swapchain is acquired non-blocking, so when the
GPU falls behind the render callback keeps firing at full rate while presents are silently skipped. A frame
rate computed from callbacks reports 60 for a scene the eye sees as frozen. Diff presentPasses for the
true rate and read the callback rate beside it — callbacks far above presents means the GPU is behind,
both falling together means a CPU or callback stall.
const RenderStats a = renderer.renderStats();
// ... one second of frames ...
const RenderStats b = renderer.renderStats();
const auto fps = b.presentPasses - a.presentPasses; // what the viewer actually saw
const auto missed = b.presentSkips - a.presentSkips;
if (b.lastFrame.composeMs > 8.0) { /* the image is expensive to draw */ }A frame's layers must have unique z and unique key (see draw-state.md). The
renderer validates this each frame and reacts per a policy you can override:
renderer.setLayerCollisionPolicy(LayerKeyCollisionPolicy::Throw); // fail fast (dev default)
renderer.setLayerCollisionPolicy(LayerKeyCollisionPolicy::WarnAndResolve); // keep a shipped game upThe default is Throw in debug builds (a collision surfaces the instant its frame runs) and
WarnAndResolve in release (a deterministic order is still produced, the game stays up).
The platform authors its shaders once in HLSL and compiles them to the running platform's native
format at build time — metallib on macOS (Metal), SPIR-V on Linux (Vulkan), DXIL on Windows (D3D12) —
using that platform's standard tools (glslang / spirv-cross + the Metal toolchain / dxc). Nothing is cross-compiled
and no bytecode is committed; a clone builds its own shaders. At runtime the renderer picks the
variant the live device accepts:
std::optional<std::pair<ShaderBytecode, SDL_GPUShaderFormat>>
selectShader(SDL_GPUShaderFormat supported, const ShaderVariants& variants) noexcept;A device never reports a format its backend can't run, and a variant not generated on this platform
is a null entry selectShader treats as absent — so the renderer code is identical on every
platform. This is internal plumbing; a consumer never calls it. The shader source and the build-time
generator live under shaders/ (see shaders/README.md); the build-time tools are dependencies of
building the platform, never of the shipped binary.
shaders/ holds shared shader text in two places, working in opposite directions:
shaders/include/— the preambles the build prepends to a custom shader you register. This is what hands your shadersampleSource()and the platform cbuffer without you declaring anything. You never#includethese; they are already at the top of your translation unit. See "Custom shader stages" above.shaders/common/— the platform's own shared kernels, reached by a real#includefromshaders/src/. Only this directory is on the HLSL include search path, which is what stops a custom shader from#include-ing a declaration it has already been handed.
A custom shader stage neither includes nor is included by anything in shaders/common/ — the plumbing it
gets comes from shaders/include/ as a prepended preamble, not an #include. That includes the emission
grammar: a // @retropp:emission stage receives sampleEmission() the same way (see
Emission: a custom stage's own glow), while the multi-pass blur behind
it is the platform's, run before the stage's own fragment. The layout, and the rule for headers that read
shader-declared state, are in shaders/README.md.
- Internal render resolution: the
Rendererconstructor'sViewportResolution(orEngineConfig::viewport). - A new shader / shader edit: edit the HLSL under
shaders/src/— the next build regenerates the affected per-platform header automatically (shaders/README.md). - Sampling (crisp vs smoothed):
Renderer::samplingMode(Nearest/Bilinear), or seed it fromEngineConfig::enhancements.sampling. - Analytic-path evaluation (crisp vs smooth edges/displacement):
Renderer::evaluationGrid(Viewport/Output), or seed it fromEngineConfig::evaluationGrid— see "Evaluation grid" above. - Window size / presentation scale: that's
windowScale+window().size()/window().fullscreen(), on the platform side (platform-and-windowing.md) — the renderer always fills whatever window it's given. - Screen-space content effects (wavy water, heat haze):
FrameDrawState::postEffectsfor the whole frame, orDrawLayer::effects(Layer/Belowscope) for a single layer / everything below a layer — seepostEffectsabove and draw-state.md. - Scale / rotate / skew / perspective a layer (Mode-7-style floors):
DrawLayer::transform(aTransform) +DrawLayer::transformEdge— see draw-state.md. - Transform a single sprite (spin/scale/foreshorten about its own pivot):
Sprite::transform, composing with the layer transform — see draw-state.md. - Capture a frame offscreen (thumbnail, screenshot, headless render):
Renderer::captureViewporton a windowless (nullptr-window) renderer — composes on the GPU and downloads pixels, no display needed; see "Offscreen capture" above. - Your own shader effect (the built-ins don't cover it):
registerPostProcessStage+ aCustom-kind effect — see "Custom shader stages" above. - Post-process display filters (CRT, scanlines): author them as a
Customstage today (a full-frame fragment over the composited image), or wait for a planned built-in stage on the same chain machinery; the fill + sampling above is the faithful default it builds on.