RENDERING_PRINCIPLES.md — a stable, sharp image, earned per frame¶
This is the rendering doctrine for KKE, written after reviewing the criticisms of modern real-time rendering made by the YouTube channel Threat Interactive (2026-09-26). The channel is harsh in tone; this file ignores the tone and the personal attacks and keeps only the technical claims, weighed against what this engine actually does today.
It sits next to OPTIMIZATION.md (performance doctrine, measured log, backlog). Anything here that ships gets a row in that log.
The one hard rule: no dithering¶
KKE never uses dithering to render anything. Not ordered/Bayer or blue-noise dither, not screen-door or stochastic transparency, not checkerboard or interleaved rendering, not any effect whose raw output is a noisy pattern that only looks right after a temporal filter or a blur. This is the project owner's decision (Kees, 2026-09-26): it makes a game look bad, and no performance gain changes that. It is also in AI_GUIDE.md as a non-negotiable rule.
What to use instead, by problem:
| Problem that dithering is usually used for | KKE's answer |
|---|---|
| Foliage, fences, hair cards (cutout alpha) | Alpha test + coverage-preserving mips (done, below); MSAA on the edges when MSAA lands |
| See-through objects, fades, LOD cross-fades | Real alpha blending, sorted; or a short opacity fade with blending, not a screen-door pattern |
| Banding in dark gradients (sky, fog) | More precision: an HDR scene target and a 10-bit swapchain where the device has one (both done, ENG-G17) |
| Soft shadow edges | Filtered shadow lookups (PCF, hardware compare), not random taps |
| Cheap volumetrics / AO / reflections | Lower resolution with an edge-aware (bilateral) upsample, or fewer, stable samples; never noise left for TAA |
One caveat to know about: MSAA alpha-to-coverage is implemented by some drivers with a dithered per-pixel sample mask. If we ever use it, it must be verified on the target drivers to produce no pattern; otherwise we stay with alpha test.
How this review was done (and its limits)¶
- All 33 videos on the channel were listed (titles, lengths).
- Transcripts could not be downloaded: YouTube currently answers this
cloud server with "sign in to confirm you're not a bot" (HTTP 429 /
LOGIN_REQUIREDon every client yt-dlp offers), and the public mirrors that proxy captions are blocked the same way. A retry is scheduled; if it succeeds this file will be revised. - What was read: each video's full description from the channel feed, which for the technical videos includes the chapter list and the papers and talks they cite (SIGGRAPH/GDC material from Crytek, Frostbite, Guerrilla, Polyphony, Activision, Valve, AMD). Their argument is largely "do what these papers did", so the cited sources were used to pin down what each claim means technically. Where the channel's own public posts were findable (e.g. their clarification about Nanite and quad overdraw), they were used too.
- So the summaries below are the technical positions as they come across from chapter structure, cited sources and public statements, in our own words, not quotes. Treat nuance inside a single video as possibly missed.
Videos weighted most (technical, with chapters or sources): 33 Tutorial: How to optimize almost every step in modern game rendering, 28/29 (Crysis 3's deferred MSAA), 26/27 (Fox Engine / MGSV), 25 (textures and GPU utilisation), 24 (Dead Space menu), 21/22 (The Callisto Protocol frame breakdown), 32 (vendor-agnostic ray tracing), 19 (top 10 neglected areas), and the TAA / Nanite / "fake optimization" videos.
Where KKE stands today (the baseline)¶
Read from the code on 2026-09-26:
- Forward renderer: one scene pass, lit in the fragment shader
(
shaders/pbr_common.glsl: GGX, up to 16 lights, flat ambient, shadow on light 0). No G-buffer. - No anti-aliasing of any kind: every attachment is
VK_SAMPLE_COUNT_1_BIT, no post AA, nothing temporal. - Output straight to an 8-bit sRGB swapchain, with Reinhard tone mapping per channel inside every surface shader. No HDR intermediate.
- One 2D shadow map, manual 3x3 PCF (9 single taps), fixed bias.
- Textures: RGBA8 uncompressed, CPU-built mips in linear light, trilinear; until today no anisotropic filtering.
- Culling / batching: frustum culling and instancing done (OPTIMIZATION.md #23, #25); no LODs, no depth prepass, no draw sorting.
- Render scale 0.5-1.0 as an opt-in min-spec knob (bilinear, in the tone-map pass since ENG-G17), default 1.0 (OPTIMIZATION.md #26).
- Nothing temporal and nothing dithered anywhere. That is a strong starting point for everything below: we don't have to unwind a TAA dependency, we just must not grow one.
The criticisms, one by one¶
Each entry: what they argue (our words) → is it right → what it means here.
1. Temporal AA and upscalers used as a crutch¶
Argument. Modern pipelines render many effects deliberately under-sampled (half resolution, checkerboard, a few noisy rays per pixel, dithered transparency) and rely on TAA / TSR / DLSS to accumulate over frames and hide it. The result is a soft image, ghosting and smearing in motion, and effects that fall apart when the camera moves. Then upscaling is sold as "optimization" when it's really a lower resolution.
Weighing. The artifacts are real and well documented (Epic's own TAA talk, which they cite, describes the history-rejection trade-offs). The counterpoint is also real: TAA was adopted because it cheaply fixes specular and sub-pixel aliasing that deferred renderers can't fix with MSAA, and a well-tuned TAA looks better than no AA. Where they are most clearly right is the dependency: once effects are authored to be noisy, TAA becomes mandatory and can't be turned off.
For KKE. Adopt the principle: every frame must be correct on its own. No effect may need temporal accumulation to look right. If a temporal filter is ever added, it's an optional setting on top of an already clean image, never something an effect depends on. Our render scale stays an opt-in min-spec knob, never a budget assumption for content.
2. Non-temporal anti-aliasing: MSAA, SMAA, specular AA¶
Argument. (Videos 28, 29, 33 "AA non-temporal aspect".) MSAA was dropped because of deferred shading's memory and bandwidth cost, but Crysis 3 shipped MSAA in a deferred renderer in 2013, and the real cost drivers (per-sample shading everywhere, full-size MSAA G-buffers) can be contained: detect edge pixels, shade per-sample only there, and add a morphological filter (SMAA) for what MSAA misses. Also: aliasing on shiny surfaces should be treated at the material (specular AA), not blurred away afterwards.
Weighing. Technically sound; Crytek's, Valve's and Jimenez's papers back every piece. The honest limits: MSAA does nothing for shader aliasing (specular highlights, alpha-tested textures) by itself, and it costs memory and bandwidth, which matters on our min-spec (software rasteriser). So it must be a setting, not a requirement.
For KKE. This is the biggest easy win, because we are forward: MSAA in a forward renderer needs no G-buffer tricks at all.
- Specular AA — done today (below).
- Coverage-preserving alpha mips — done today (below).
- MSAA 2x/4x as a setting, off on min-spec, plus SMAA 1x as a spatial post filter: done (ENG-F05, below), on by default. #36.
3. Overdraw, quad utilisation, LODs and Nanite¶
Argument. GPUs shade pixels in 2x2 quads; triangles that cover only a few pixels waste most of the lanes, so dense meshes cost far more than their pixel count. Classic LODs keep triangles large on screen. Nanite avoids quad waste with its own rasteriser but carries a large fixed cost, and in typical scenes well-authored LODs are faster. (Their later clarification: they never claimed Nanite itself uses quads; the point is that a frame of well-sized triangles beats it.)
Weighing. The quad-efficiency facts are standard GPU behaviour. The Nanite comparison depends heavily on content density; for kitbash-heavy film-quality scenes Nanite wins. For KKE's content (Synty low-poly packs, play-to-make levels) classic LODs are clearly the right tool.
For KKE. LOD selection by screen size, using the LODs Synty packs already ship, belongs to world streaming/LOD work (#23); a triangle-density / quad-overdraw debug view goes with the draw-order work in #37.
4. Pass order, prepass, sorting and resource clears¶
Argument. (Video 33 chapters: resource clearing, "how to properly prepass content", "how to properly sort basepass content"; video 22's pipeline breakdown.) Many frames waste time on clears nothing needed, prepasses that include geometry that never occludes anything, and base passes drawn in an order that defeats early-Z.
Weighing. Correct and uncontroversial; it's the kind of thing only a GPU capture shows. The payoff depends on overdraw, which we haven't measured.
For KKE. Sort opaque draws front to back (by instance batch), try a
depth prepass for large occluders only and keep it if it measures
faster, and add an overdraw view to see it:
#37. Our depth
attachments already use STORE_OP_DONT_CARE; the colour clear is kept
because it's the cheapest way to start a tile on tiled GPUs.
5. Shadows: optimized shadow maps beat ray-traced shadows¶
Argument. (Videos 21, 22, 33 "perspective shadow maps".) Ray-traced shadows were chosen where well-optimized shadow maps would have looked as good for a fraction of the cost. Shadow maps are fast when their resolution is spent where the camera looks (perspective / cascaded projections), when static casters are cached, and when softening is done by filtering rather than noise.
Weighing. Agreed for our scale. RT shadows win for area-light softness and huge draw distances, which isn't where we are.
For KKE. Our single shadow map is fine for small scenes; improve it in this order: hardware depth-compare sampler (bilinear PCF for free), slope-scaled bias, texel-snapped light projection (no shimmer when the camera moves), then cascades as already planned in #20: #38.
Ray-traced shadows (ENG-F07, 2026-10-18). The setting shadowRays
(off by default; KKE_SHADOW_RAYS) is kke or classic, on GPUs with
ray query. KKE keeps the argument above: the shadow maps stay, and a ray
towards the sun is cast only where they don't reach (past 75 m, up to
600 m), through the stand-ins, so a far grandstand or hill shades the land
behind it again instead of everything past 80 m being lit. classic
casts a ray from every pixel through the full geometry and uses it instead
of the map: hard shadows everywhere, the textbook ray-traced look, for
comparison. Both are cast after the frame from its depth (one compute
dispatch, sharing the frame's RayScene with reflections and refraction)
and read by the next frame's lit shaders, reprojected; where a pixel showed
something else last frame, the shadow map answers. Measured on the RX
9070 XT, the racing demo at 1019 x 1384 with 4x MSAA: 0.716 ms off, 0.98
kke (+0.26, mostly building the ray scene of moving cars every frame),
1.16 classic (+0.44).
Many lights without a deferred path (ENG-F08, 2026-10-18). The gap
asked for a deferred path; a deferred renderer pays for many lights with
a G-buffer: no MSAA (section 2), no glass, a fat target read back per
light. KKE stays forward and clusters instead (Olsson et al. 2012): the
16 key lights (with shadows) are untouched, and the small lights past
them (Lighting::manyLights, up to 1024; script lights spill over there
when the key slots are full) go into a storage buffer. A compute pass
lists per cell of the view (16 x 9 cells, 24 slices spaced exponentially
in depth) the lights whose reach touches it, and each lit pixel walks
only its cell's list. Measured on the RX 9070 XT, anim_demo at night at
1019 x 1384 with 4x MSAA (KKE_ANIM_LIGHTS; KKE_CLUSTERS=0 walks every
light, which is what forward rendering without clusters costs): no lamps
0.27 ms; 256 lamps 0.52 clustered against 1.16; 1024 lamps 0.75 against
2.70. Hair, cloth and see-through surfaces take the 16 key lights only.
6. Lighting and GI: stable beats noisy¶
Argument. (Video "Dynamic lighting was better nine years ago", Fox Engine videos, 33 "WIP GI & hybrid reflections", 32.) Games from ~2015 had convincing dynamic lighting from precomputed or probe-based indirect light and cheap light volumes; today's per-frame traced GI is noisy, needs denoising and temporal accumulation, and costs far more. Ray tracing, when used, should run on any vendor's hardware (compute or standard ray queries), not be tied to one vendor's features.
Weighing. The cost/stability argument is right for most games; the flexibility of fully dynamic GI (destructible or player-built worlds) is the real reason engines moved on. Our play-to-make worlds change at runtime, so fully baked lighting is not enough on its own.
For KKE. Prefer probe-based ambient (spherical harmonics or a small
irradiance cubemap, re-baked in the background when the level changes)
over per-frame traced GI. This is already planned in
#20 (image-based
ambient) and #21
(cheap GI); the principle "stable, no noise, no temporal dependency"
applies to both. Any future ray tracing uses Vulkan's vendor-neutral
VK_KHR_ray_query/compute, with a raster fallback.
Screen-space GI (ENG-F01, 2026-10-17). An optional setting
(globalIllumination, off by default; KKE_GI=1) for light bounced
between nearby surfaces and shadow in corners and under things, kept to
this rule: a visibility bitmask (Therrien et al. 2023) over 4 fixed
directions and 8 fixed distances per pixel, the same for every pixel and
every frame, so there is no noise to hide and nothing is blended over
frames. It reads the finished frame and is used by the next one (light
arrives one frame late, reprojected; where that frame didn't see a
surface, the sky alone lights it, as without GI): the one temporal
dependency, accepted for it (Kees, 2026-10-17). Half resolution, read
back with depth-aware weights so it never bleeds across edges. Cost on
the RX 9070 XT at 844 x 1384 with 4x MSAA: 0.29 ms (synty_demo, 722 to
611 fps); on lavapipe it halves the frame rate, which is why it is off by
default. KKE_GI_VIEW=1 shows what it sees: lit surfaces turn grey by how
much sky they see.
Reflections (ENG-F02, 2026-10-17). The setting reflections (off by
default; KKE_REFLECTIONS) is screen, kke or classic. Screen: lit
opaque surfaces write 2 + smoothness in the scene's alpha (translucents,
particles and the sky write 1 or less, so no extra target or G-buffer is
needed); after the scene a half-resolution pass marches each smooth
surface's reflected ray along its line on screen in 48 equal steps, then
four halvings, and keeps the light where it meets something; the next
frame's lit shaders mix it over the prefiltered sky by smoothness, one
frame late like the GI. One ray, fixed steps: nothing noisy, nothing
blended over frames. Cost on the RX 9070 XT at 844 x 1384 with 4x MSAA:
0.10 ms (anim_demo's polished floor, 420 to 407 fps); lavapipe 13.6 to
12.3 fps. kke adds rays through simple stand-ins for what is off
screen, within a budget, and classic rays through the full geometry
(both on GPUs with VK_KHR_ray_query; screen elsewhere).
KKE and classic ray tracing (ENG-F02). Both trace inline ray queries
(any vendor, lavapipe too) through a kke::RayScene that what is drawn
adds itself to each frame: models (ModelModule) and meshes
(DynamicMeshRenderer), skinned parts refitted from their skinned
vertices every frame, so a reflection moves as the character does. A hit
is shaded with its interpolated normal and vertex colour under the sky
and the sun. classic: every triangle, every smooth pixel traced
directly, the sun's shadow traced too. kke (Kees's ask: keep the look,
cut the cost): screen first, rays only for reflections that leave the
screen, through stand-ins with about a tenth of the triangles over the
same vertices (meshoptimizer), within a ray budget per frame, no shadow
ray. Measured on the RX 9070 XT at 844 x 1384 with 4x MSAA: anim_demo's
polished floor 0.26 ms off, 0.34 screen, 0.58 kke, 0.64 classic; the Synty
demo with every surface reflecting (KKE_REFLECT_ROUGHNESS=1) 0.33 off,
0.44 screen, 0.68 kke, 0.72 classic; lavapipe there 33.5 fps off, 22.6
kke, 21.8 classic. On this GPU classic is only a little dearer: KKE's
gains are the budget (a frame never pays more than it allows) and that it
falls back to screen space where there is no ray tracing.
Refraction (ENG-F02 part 4, 2026-10-18). With reflections on (one
view; KKE_REFRACTIONS=0 turns it off for comparisons), see-through
surfaces drawn with DynamicMeshRenderer::drawTranslucent bend what is
behind them by their index of refraction (ior: water 1.33, glass 1.5).
Before the translucent draws the 3D pass pauses: one compute dispatch
copies the scene drawn so far, with each pixel's distance from the camera
in alpha, and a pass that loads the same colour and depth goes on. The
first translucent pass then reads that copy where the refracted ray
leads, taken as solid for up to a metre behind the surface, times the
transmittance. What a plain grab pass gets wrong: a sample that lands on
something nearer than the glass (in front of it) is not used, so nothing
in front smears into the glass; the straight-through pixel is read
instead. Measured on the RX 9070 XT with 4x MSAA, anim_demo's glass block
(KKE_ANIM_GLASS=1): +0.02 to +0.03 ms (905 x 682: 0.209 to 0.237 ms;
1020 x 1384: 0.319 to 0.341 ms); lavapipe at 960 x 540, 39.8 to 38.1 fps.
Translucents seen through other translucents show only the opaque scene
behind both.
With reflections = kke or classic, refraction traces rays too,
through the same ray scene as reflections, built once a frame while the
3D pass is paused (outside a render pass, as acceleration structures
need). kke traces a refracted ray only where the screen could not show
what is behind the surface: the bent spot is off screen or something in
front of the glass covers it. classic traces every refracting pixel.
A hit is shaded as reflections' hits are (normal and vertex colour under
the sky and the sun; classic adds the sun's shadow), so classic shows
textured models in their vertex and material colours only. Measured on
the RX 9070 XT, 1288 x 682 with 4x MSAA, anim_demo's glass block: kke
0.473 ms without refraction and 0.519 with (+0.05), classic 0.503 and
0.795 (+0.29): KKE refraction costs a sixth of classic's. (Lavapipe was
not measured: the build machine was busy with CI, load 24.)
Volumetric fog (ENG-F03, 2026-10-18). The graphics setting
volumetricFog (off by default; KKE_VOLUMETRIC_FOG=1) lights the height
fog for real: a grid of 128 x 72 cells over the view, in 48 slices spaced
exponentially from 0.5 m to 96 m, is lit after the shadow pass (one
compute dispatch: the fog's thickness there, the sky's light, the sun's
where its shadow map says it reaches, the point lights' halos) and
summed per column from the camera outwards (a second dispatch, Hillaire's
energy-conserving step). Every lit shader and the sky read it through
the same fogAlong(); past 96 m the analytic fog takes over, so far
hills are fogged exactly as before. Fully lit, a cell sends exactly what
the analytic fog does (fogInscatter): turning it on only darkens fog in
the sun's shadow (light shafts) and adds lamp halos. What the usual
froxel fog (Frostbite, Unreal) does differently: it jitters the cells and
relies on TAA to blend the noise over frames, which ghosts behind moving
things. Ours has no jitter and nothing carried between frames: stable on
its own (section 1). Measured on the RX 9070 XT, the Synty demo in the
misty-morning mood, 1328 x 682 with 4x MSAA: 0.331 ms off, 0.377 on
(+0.05; the grid's cost does not grow with the resolution). On lavapipe it
costs too much for a PC without a GPU, so it stays off there.
Bloom (ENG-F10, 2026-10-18). The graphics setting bloom (on by
default at a 4% share; KKE_BLOOM=0 or 1 wins; off on the software-GPU
target) follows Call of Duty: Advanced Warfare (Jimenez 2014): no
brightness threshold (everything glows a little, the brightest the most,
as through real glass), six halvings with a 13-tap filter, the first
weighting its five boxes by 1 / (1 + brightness) so one hot pixel can't
flicker into a blob, then the build back up with a 3x3 tent, normalised
so a flat image blooms to itself; the tone map mixes it in. Compute only,
one frame, nothing carried over. Measured on the RX 9070 XT, the Synty
demo at 836 x 1384 with 4x MSAA: 0.336 ms off, 0.384 on (+0.05).
LUT colour grading (ENG-F10 part 2, 2026-10-18). A mood's lut:
names a .cube file (docs/MOODS.md), the format every grading tool
exports. The tone map applies it last, to the display colours, with a
tetrahedral read (each cell of the table cut into six tetrahedra, four
corners per lookup, as colour tools do) instead of the GPU's trilinear
filter, which bends straight lines through the table and tints greys;
the tests check greys stay grey through a contrast table. One 3D texture
and eight texel reads per pixel at most; no measurable cost on the RX
9070 XT.
Depth of field (ENG-F10 part 3, 2026-10-18). The graphics setting
depthOfField (off by default; KKE_DOF=0 or 1 wins, KKE_DOF_FOCUS
fixes the focus in metres) blurs what is nearer or farther than the focus
by a thin lens's circle of confusion: a 50 mm lens at f/1.4 by default
(the lens that matches a game's wide view leaves almost everything sharp,
as on a real camera), the circle capped at 1.2% of the image height. The
usual method (Call of Duty: Advanced Warfare) needs tiles of the largest
nearby circle and separate near and far layers. Ours is one gather at half
size: every pixel looks out to the largest circle with 32 golden-angle
samples, and a sample counts where its own circle reaches the pixel,
weighed by one over its area. A sample behind the pixel counts only within
the pixel's own circle, so the background never blurs over a sharp thing
in front of it, while a near sample spreads freely over what is behind.
Auto focus averages 64 depths at the middle of the screen in log distance
and follows them over about a sixth of a second, all on the GPU with
nothing read back; a game can fix the focus instead
(Application::depthOfFieldSettings()). Measured on the RX 9070 XT, the
Synty demo at 844 x 1384 with 4x MSAA: 0.385 ms off, 0.479 on (+0.094;
48 samples cost +0.125, and 24 left a visible pattern in heavy blur).
Motion blur (ENG-F10 part 4, 2026-10-18). The graphics setting
motionBlur (off by default; KKE_MOTION_BLUR=0 or 1 wins) smears the
image along the camera's movement over a 180-degree shutter (half the
frame time), at most 4% of the image width. The usual method (McGuire et
al. 2012, as in most engines) draws a velocity buffer in every shader and
then finds the largest motion per tile. Ours draws nothing extra: each
pixel's motion comes from its depth and last frame's camera, the
reprojection TAA already uses, so every module and shader gets it for
free and the image stays still when the camera is still. Moving things
seen by a still camera are not smeared; that needs a velocity buffer and
waits until a game asks for it. One compute pass gathers 12 samples along
the motion, shifted per pixel and frame by interleaved gradient noise
(noise, not bands), weighed by depth as McGuire does: a sample in front
counts where its own smear reaches the pixel, one behind only within the
pixel's smear, so a sharp thing near the camera keeps its edge. Measured
on the RX 9070 XT, the Synty demo turning at 1.2 rad/s at 1329 x 682 with
4x MSAA: 0.274 ms off, 0.42 on (+0.14); with a still camera every pixel
leaves at its first test and the cost is within the noise.
7. Tone mapping, exposure and colour management¶
Argument. (Fox Engine videos, 33 "tone mapping & exposure mechanics", sources from Polyphony (GT7) and Frostbite.) Much of what makes an older game look "right" is careful colour handling: HDR lighting, a tone curve that doesn't shift hues or crush saturation, proper exposure. Cheap per-channel curves make bright colours go yellow/white and look fake.
Weighing. Right, and it's a quality issue more than a speed one.
For KKE. Our per-channel Reinhard inside each surface shader is the
weakest part of the current look: it desaturates highlights, it's
duplicated in translucent_light.frag, it happens before blending (so
blended surfaces mix tone-mapped values), and with an 8-bit target there
is no room for bloom or exposure. Move to an HDR scene target, one tone
map + exposure pass, and a 10-bit swapchain where available (which is
also our no-dither answer to banding):
#35. Changing the
curve changes the look of every scene, so it lands as its own reviewed
change, not a silent tweak.
Chosen (Kees, 2026-09-26): AgX by default, ACES and Reinhard on
request. The curve now lives once, in shaders/tonemap.glsl, and every
lit shader calls it (pbr_common.glsl, translucent_light.frag,
ocean.frag, fluid_composite.frag). A game picks it on its lighting:
app.lighting().toneMapper = kke::ToneMapper::ACES; // AgX (default), ACES, Reinhard
app.lighting().exposure = 1.2f; // scene light is multiplied by this first
KKE_TONEMAP=agx|aces|reinhard|none forces one for every scene, to compare
them. AgX keeps hues as things get bright (a lit orange wall stays
orange instead of going saturated yellow-orange); ACES has more contrast
and saturation; Reinhard is the old look; None shows finished display
colours as they are (the logo intro uses it).
Done (ENG-G17): one tone-map pass on an HDR scene. The 3D pass draws
scene light into an RGBA16F image (Renderer::kSceneColorFormat); lit,
unlit (grid, particles, debug lines) and additive shaders all write
unbounded light and blend in it. The curve, exposure and look then run
once per pixel in shaders/tonemap_post.frag, the first draw of the UI
pass: one full-screen triangle that samples the scene image (a linear
sampler does the render-scale upscale, so there is no blit and no LDR
copy), before the UI draws on top. Renderer::setClearColor keeps its
meaning: the scene is cleared to the light the curve shows as that sRGB
colour (kke::sceneClearColor, an inverse of the CPU mirror
kke::toneMapCpu, which thumbnails use too). Compared with the usual
design (a separate tone-map render pass into an LDR image, then the UI
pass) it saves a render pass, an image and its bandwidth, and the curve
no longer runs once per shaded fragment (overdraw).
Done (ENG-G17): 10-bit output. The swapchain is A2B10G10R10 or
A2R10G10B10 (sRGB colour space) wherever the surface offers it
(kke::chooseSurfaceFormat; KKE_SWAPCHAIN_8BIT=1 keeps 8-bit). No sRGB
10-bit format exists, so on such a UNORM swapchain the tone-map pass
encodes sRGB itself, and the UI writes sRGB-encoded colour and blends in
that space (kTargetEncoded in the RmlUi shaders, raw ImGui colours), as
a browser does; filters and the thumbnail atlas follow. A 30-bit X
display, which offers nothing but A2R10G10B10, used to get the 8-bit
pipeline's linear colour and showed everything far too dark.
8. Textures and GPU utilisation¶
Argument. (Video 25.) Texture bandwidth is often the real bottleneck. Block compression (BC1/BC4/BC5/BC7) cuts memory and bandwidth 4-8x at little visual cost, and a frame should balance arithmetic against memory traffic instead of piling on one.
Weighing. Correct and standard practice.
For KKE. Anisotropic filtering — done today. Compression at asset cooking (BC7 for colour, BC5 for normals, ASTC on mobile) was already backlog item 7 in OPTIMIZATION.md; now tracked in #39.
9. Rendering what nobody can see: menus¶
Argument. (Video 24, Dead Space remake.) The full 3D scene kept rendering at full cost behind an opaque menu, so the menu itself had bad frame rates.
For KKE. When a full-screen opaque UI (marketplace, pause menu) is open, skip the 3D scene (or reuse the last frame) and draw only the UI: #40.
10. "Fake realism" and post effects¶
Argument. Heavy film grain, chromatic aberration, lens dirt, motion blur and general blur are used to make images feel "cinematic" but mostly hide artifacts and make the image harder to read.
For KKE. We have none of these. Rule: they may exist later only as optional look settings, off by default, and never to hide an artifact.
Where we don't follow them (or can't yet)¶
- Velocity compensation, TAA tuning (video 33's longest chapters): not applicable while we have nothing temporal.
- Deferred-specific MSAA tricks: we are forward; we get MSAA the easy way.
- Screen-space subsurface scattering, decals: no current need.
- Vendor-agnostic ray tracing (video 32): agreed in principle, not on our roadmap soon.
Done in this change (2026-09-26)¶
All three are spatial and per-frame: no temporal filter, no dithering.
- Anisotropic filtering (
kke::Texture,VulkanDevice): thesamplerAnisotropyfeature is enabled where the device has it, and material samplers use up to 8x. Floors and walls at glancing angles stay sharp instead of dropping to a blurry mip. - Specular anti-aliasing (
pbr_common.glsl:specularAAKernel,shadeSurfaceAA): the spread of the normal within a pixel (its screen derivatives) is added to GGX roughness (Kaplanyan/Tokuyoshi, with Filament's constants), so distant or tightly curved shiny surfaces don't sparkle. Shaders thatdiscardtake the kernel before the discard, since derivatives after it are undefined. - Coverage-preserving alpha mips (
kke::TextureMips): for any texture with transparent texels, each mip's alpha is scaled so the 0.5 alpha test keeps the same fraction of texels as the full-size image (Castaño 2010). Without it, box-filtered alpha drifts below the cutoff and foliage thins out and vanishes with distance. Unit-tested (tests/test_texture_mips.cpp).
Also: Application::setSceneCovered(true) for full-screen opaque
menus (#40): the shadow pass and every module's render() are skipped,
so a menu frame costs a clear plus the UI. prepass() still runs (the
marketplace's thumbnails are rendered there) with
PrepassContext::sceneCovered set, and scene-only prepass work (fluid
surfaces in the showcase and melt demo) returns early. Checked headless
in the Synty demo: 0 draw calls, UI intact, validation clean.
Also: opaque model draws are sorted nearest first
(kke::frontToBackOrder), inside instanced draws and across them (#37).
The measured depth prepass and the overdraw view remain open in #37.
Also: MSAA (#36). graphics.msaa in settings.json (1, 2, 4 or 8;
default 4) is read at startup, because every scene pipeline bakes the
sample count in; KKE_MSAA=n overrides it, and software rasterisers
(the min-spec reference) run at 1x. With MSAA the 3D pass renders into a
multisampled offscreen target that the render pass resolves, then the
tone-map pass reads the single-sample image (ENG-G17), and UI draws on
top at full resolution. kke::Pipeline takes the
sample count from the render pass it's built for
(VulkanDevice::renderPassSamples), so modules need no changes; RmlUi
and ImGui pipelines use Renderer::overlayRenderPass(). Thumbnails use
Renderer::createSceneCompatiblePass, so they're anti-aliased too.
Checked headless at 4x (Synty demo, sandbox with thumbnails, UI
showcase): smoother edges, validation clean. SMAA 1x is still open (#36).
SMAA (ENG-F05, 2026-10-18). The graphics setting smaa (on by
default, Kees; KKE_SMAA=0 or 1 wins) runs three compute steps over the
finished scene before the tone map reads it: edges where the perceived
brightness of two neighbours differs by more than 0.1; along each edge,
the run of edge pixels followed both ways (up to 16) and the shape its
two ends stand for (a step of a slanted line, an L corner, a U notch);
then each pixel mixes in the neighbour across the edge by the share of it
the shape's smooth line gives that side. What we do differently from
SMAA 1x: the area comes straight from the line (kke::smaaLineArea), not
from a precomputed area texture, so there is no data file to ship and
the CPU copy (kke/Smaa.h) is the tests' reference. Tested against 16 x
16 supersampled coverage: slanted edges come out 40% or more closer to
the true picture than aliased ones, and the image's mean brightness
moves by under 2%. One frame, no history: nothing ghosts. Measured on
the RX 9070 XT, the Synty demo at 836 x 1384 with 4x MSAA: 0.356 ms off,
0.422 on (+0.07; the machine was busy with CI, best runs compared). The
software-GPU target keeps it off until it is measured there.
TAA, an option (ENG-F05 part 2, 2026-10-18). Kees wanted both: SMAA by
default and TAA beside it. The graphics setting taa (off by default;
KKE_TAA=1 or 0 wins) follows rule 1 above: nothing needs it, it only
adds on top of the clean image. The projection moves by under a pixel each
frame (Halton 2, 3 over 8 frames); after SMAA, one compute dispatch mixes
the frame into the history of earlier ones (10% new), found through the
depth with last frame's camera and read between pixels by a Catmull-Rom
curve (a bilinear read blurred the floor lines a little more each frame);
the history is clipped to the mean and spread of the colours around the
pixel now (YCoCg), so a moving arm leaves no trail. Honest limits: there
are no motion vectors, so on moving things the history is clipped rather
than followed (a little softer in motion, no ghost), and split screen
draws without it. Measured on the RX 9070 XT, the Synty demo at 1019 x 1384
with 4x MSAA and SMAA: 0.393 ms off, 0.448 on (+0.055).
Later the same day: shadow map quality (#38). The shadow map is
sampled with a depth-compare sampler (each of the 9 PCF taps is a
hardware bilinear comparison), casters get slope-scaled depth bias from
ShadowMap::casterConfig() so the shader bias shrank from 0.003 to
0.0005, and the 15 m shadow region now follows the camera's target,
snapped to whole shadow texels so edges don't shimmer. Cascades remain
in #20.
Adopt list (short)¶
- No dithering, ever. (rule, done)
- Every frame correct on its own; nothing depends on temporal accumulation. (rule)
- Specular AA, coverage-preserving alpha mips, anisotropic filtering. (done)
- MSAA as a setting (done) + SMAA 1x (done, ENG-F05, on by default). (#36)
- AgX tone curve with exposure, selectable per game (done); HDR target, one tone map pass and 10-bit output (done, ENG-G17).
- Front-to-back sorting (done), measured prepass, overdraw view. (#37)
- Shadow map quality: compare sampler, slope bias, stable projection. (done, #38)
- Texture compression at asset cooking. (#39)
- Don't render the 3D scene behind opaque menus. (done, #40)
- LODs by screen size (#23); probe-based, stable ambient and GI (#20, #21).