Render shading
This file covers what the render tier’s passes compute: materials, textures and image-based
lighting, the temporal core, shadows, and the lighting and sky the frame is drawn with. How
that work reaches the GPU — the RHI and scene-view seams, and the frame graph the passes
register into — is docs/architecture/PRESENTATION_RENDER.md.
1. Materials, textures, and image-based lighting
The vertex format carries position, normal, tangent, two UV sets, and a vertex colour — the minimum that makes normal mapping, parallax, and a detail set possible, and therefore the thing everything else in this section rests on. A zero tangent is a legal value meaning “none authored”; the shader falls back to screen-space derivatives, so an imported mesh without tangents still normal-maps.
The material (engine/domain/material/include/SushiEngine/material/material.hpp) is the
authored surface: albedo, packed metallic-roughness (ORM supported), normal, height, occlusion,
emission, a Unity-style detail set, per-set tiling/offset, the advanced lobes (anisotropy,
clearcoat, sheen, transmission), and the rendering state (surface type, cull mode, blend mode,
render queue, shadow flags, sampler settings). Every map is optional. That is not a convenience:
an unset slot resolves to one of four neutral default textures — white, flat normal, black,
neutral MR — so the shader samples unconditionally and branches only on behaviour a default
cannot stand in for, which travels as flag bits. A material with no textures shades exactly as
it did before textures existed.
engine/presentation/render/source/material/ holds the machinery:
-
AssetLibrary— the device’s shared store. Textures, meshes, the bindless heap, the shader library, and the pipeline and sampler caches are device-level, so two viewports drawing the same model share one upload and one pipeline. It is also the implementation of the publicIAssetLibraryseam (IWindowRenderer::assets()), which is how a host loads a texture or a glTF file without seeing a Vulkan type — includingmorph_target_count(MeshId), which lets a host (the editor’s blend-shape sliders) size a per-target weight buffer without reaching into the mesh registry. -
TextureLibrary— decode (stb), upload with a GPU-generated mip chain, bindless registration, path and asset-id deduplication (viaAssetRegistry, survives renames). Residency is mip-based against a budget: a texture that will not fit is uploaded from a lower mip and upgraded later, at most one per frame. Uploads never block the frame.Where the Vulkan 1.4
hostImageCopyfeature is offered (common at that floor, though it stays an optional feature), a mip chain is box-filtered on the CPU and copied straight into the optimal-tiled image with no staging buffer, no submit, and no fence — the superseded image and heap slot are instead reclaimed once a shared frame counter has advanced past every view that could still be sampling them. The staging-plus-blit path remains the fallback: each of its uploads carries its own fence, and its superseded image, staging memory, and heap slot are reclaimed only once that fence signals. -
MaterialSystem— packs authored materials into a per-frame storage-buffer array of fixed-layout records holding heap indices. A draw then carries one material index in its push constant rather than a payload of parameters, which is the shape the indirect-draw work in Phase 7 needs. -
gltf_importer— one mesh per primitive, baked into its node’s world transform so a multi-part asset assembles without a scene graph on the render side. The core material maps across directly;KHR_materials_*drives the advanced lobes; spec-gloss is converted (lossily, and deliberately so); missing tangents are generated.
Image-based lighting (engine/presentation/render/source/passes/ibl_pass.*) is captured from
the engine’s own analytic sky rather than an imported HDRI: six 90-degree views of the atmosphere
are rendered into a cubemap with the same engine/presentation/render/shaders/sky.frag,
GGX-prefiltered into a roughness mip chain, and cosine-convolved into an irradiance cube. A
split-sum BRDF LUT is generated once at bring-up — it depends on nothing but the BRDF. Capture is
rate-limited and gated on the sun or the atmosphere having measurably moved, so a slowly turning sun
costs almost nothing, and the lighting tracks time of day for free.
The BRDF itself (engine/presentation/render/shaders/pbr_common.glsl) is no longer
single-scatter: height-correlated Smith visibility, Kulla-Conty multi-scatter compensation
driven by the same BRDF LUT the IBL needs (which is why rough metals stop losing most of their
energy), roughness-aware Fresnel, specular occlusion from AO, and a dielectric F0 derived from
the material’s index of refraction instead of the 0.04 every plastic uses.
Indirect diffuse is no longer a single global value. Probe-volume global illumination
(engine/presentation/render/source/gi/, Phase 6) places three nested camera-relative cascades of
irradiance probes (32×8×32 each, 4/8/16 m spacing, ~124/248/496 m reach), every one snapped to its
own world grid (so probes hold fixed world positions and do not swim as the camera moves). Each
probe stores the same nine SH coefficients the IBL build produces, and
engine/presentation/render/shaders/pbr.frag walks the cascades finest-to-coarsest, blends the
eight probes around a surface trilinearly in the first cascade that contains it in place of the
single global set — falling back to the global environment SH beyond the coarsest cascade or when GI
is off, so nine coefficients always shade a pixel.
IrradianceVolumePass owns the probe SH grid (scene set-0 bindings 29–30, pass-owned and
hand-barriered like the IBL SH buffer) and relights it each frame through a pluggable
IProbeTracer — the strategy seam (DIP) that decides how a probe gathers incident radiance. The
default SDFProbeTracer (Tier A, all hardware) rebuilds a coarse scene distance clipmap (64³)
each frame from the frame’s analytic primitives and the per-mesh signed-distance bricks
MeshRegistry bakes at import (Geometry::bake_signed_distance_field, reached through the
engine/presentation/render/source/gi/mesh_sdf_baker.hpp adapter), then sphere-traces it per
probe: a hit contributes one coloured bounce plus any emitted radiance (a parallel emissive
clipmap injects material.emissive at probe rate — lights from materials, no separate light
path), a miss the distant environment, projected back to SH.
The trace is amortized — a
round-robin quarter of all probes per frame, forced full only for a cascade that shifts a cell
(the coarse grids shift far less often) or on a sun move — and rough reflections fall back to the
same probe cache before the sky. EnvironmentProbeTracer (broadcast the environment SH, no
trace) is the cheaper floor-tier strategy behind the same seam. Tier-gated (High/Ultra) and
author-gated (Environment::gi).
2. The temporal core
Everything that relates one frame to the next lives behind one small block,
Scene::TemporalUniforms at set 0 binding 9, deliberately separate from the scene block: that
one describes the world and is declared as a truncated prefix by most shaders, which makes
appending to it fragile, while this one has different readers and a different reason to change.
-
Motion vectors. The geometry pass writes a third target holding each pixel’s UV displacement since the previous frame. The current clip position is
gl_Position; the previous one comes fromScene::MotionSystem, a per-frame array of previous transforms keyed by picking id that a draw indexes with one push-constant slot — the same shape as the material array, and for the same reason. Each side is camera-relative against its own frame’s eye, subtracted in double before the float cast, so the camera’s own translation shows up in the motion vector without planet-scale metres ever entering single precision. Pixels no draw covered get theirs fromSkyVelocityPass, reprojected at the cloud’s marched range where there is cloud and at infinity elsewhere; see VOLUMETRIC_CLOUDS.md section 13. -
Jitter.
Frame::frame_jitterwalks a Halton (2, 3) sequence and the offset is added to the projection’s third column and to the sky and cloud ray directions — nowhere else, so no world-space value moves. The sign is not free: the third column is scaled by view z and the perspective divide is by −z, so a positive entry shifts the result negative, and the ray offset and the motion vector’s jitter removal are both matched against that. -
The resolve.
TAAPassdilates the motion vector to the closest surface in a 3×3 neighbourhood, clips the reprojected history into that neighbourhood’s colour distribution in YCoCg, reconstructs both inputs with a Catmull-Rom filter, blends under Karis tone weighting, and sharpens to offset the temporal softening. The history lives at the output extent while the scene is rendered at the internal extent, which is what makes rendering small and resolving large an upscale rather than a blur. Two history images ping-pong by frame parity, not by frame slot: with two frames in flight, the other slot’s image is two frames old. -
Dynamic resolution.
Frame::ResolutionControllermaps the GPU time the Phase 0 timers measured to an internal render scale, dropping quickly on an overrun and recovering gradually, quantised to sixteenths so a scale hovering near a boundary does not reallocate every transient. Picking follows the internal extent andpick()scales the click into it. -
Variable rate shading. A compute pass derives a per-tile rate image from the previous frame’s luminance contrast and this frame’s motion, and the sky pass binds it through
RenderPassBuilder::shading_rate_attachment. It sits between the geometry and the sky because that is the only non-circular ordering — the mask wants the motion vectors the geometry pass writes — and because at planet scale the sky is the pass worth steering. AbsentVK_KHR_fragment_shading_ratethe handle stays invalid and every declaration of it is a no-op, so no pass branches on support.
RenderSettings
(engine/presentation/render/include/SushiEngine/render/render_settings.hpp) is the public seam
for all of it, kept apart from Environment on the same principle: Environment describes the
world being drawn, RenderSettings describes the machinery drawing it.
3. Shadows
The sun is shadowed at three scales, because no single mechanism spans the range a planet-scale scene does.
-
Cascades (
Scene::fit_shadow_cascades,ShadowPass) carry the body of it. Four cascades share one atlas as a two-by-two grid of tiles, so four cascades cost one image, one pass, one barrier, and one profiler entry — the cascade being drawn changes with a viewport and a push-constant slot, never a rebind.The split positions are the practical scheme; each cascade is bounded by a sphere rather than by its frustum corners and its light-space origin is snapped to whole shadow texels, which together are what stop the edges crawling: a sphere’s extent does not change with rotation, and a texel-aligned origin cannot shift sub-texel. The whole fit is camera-relative. The maps are orthographic and therefore linear in depth, so unlike the camera they do not use reverse-Z — there is no precision to redistribute.
-
A depth prepass (
DepthPrepass) runs the sameengine/presentation/render/shaders/mesh.vertthe shading pass does, with no fragment stage. Sharing the shader is a correctness requirement, not a convenience: the opaque pass tests against depths it recomputes itself, and only the same shader guarantees the two agree bit for bit. -
Screen-space contact shadows (
ContactShadowPass) march that depth buffer toward the sun over a distance measured in metres, recovering the contact a cascade texel is orders of magnitude too coarse to resolve. This is why the prepass exists — the answer has to be known before the surface is shaded. -
The clouds shadow lit surfaces through
engine/presentation/render/shaders/cloud_shadow_common.glsl’scloud_sun_transmittance, one blend ofCloudShadowMapPass’s two baked optical-depth cascades (see the cloud pass) turned into a transmittance with the cloud march’s own Beer-Lambert scale. The sky pass’s analytic-ground shadow and a mesh’s own shadow both call this same function against the same map — a single shadow authority instead of two independent approximations — so a surface standing on the ground and the ground itself always agree on where a cloud is. -
A traced ray (
RayTracing::SceneAccelerator,RayTracedShadowPass) replaces all of that on the Ultra tier, where the device offersVK_KHR_acceleration_structureandVK_KHR_ray_query. Two levels: a bottom-level structure per distinct mesh and a top-level rebuilt each frame from 64-byte instance records, which is why a thousand copies of one mesh cost a thousand records rather than a thousand rebuilds. A bottom-level structure is not immortal. Two revisions per entry decide what it needs — nothing, a refit, a rebuild, or release — and skinned characters and host-simulated surfaces are in the structure alongside registry meshes, each behind its ownIGeometrySource(engine/presentation/render/source/raytracing/geometry_source.hpp, and thesources/directory beside it).Every structure a frame needs is created and sized before any build is recorded, because they share one scratch buffer and growing it between builds whose scratch address is already baked in is a use-after-free the GPU finds long after the call that caused it. The result is a screen-space mask, not a term inside the material shader: only the trace shader then needs the ray-query extension, so the material shader stays one build that runs everywhere. It is also the one place in the renderer that writes its own barrier — an acceleration structure is neither an image nor a buffer, so there is no declaration the graph could have derived one from.
Two paths trace a ray, and one question decides between them: does the ray need to know what it
struck? A ray that needs only hit or miss is traced inline with VK_KHR_ray_query, inside the
fragment shader that wants the answer — that is
engine/presentation/render/shaders/ray_shadow.frag, and it needs no shader table, no second
pipeline and no payload. A ray that must resolve the surface it struck goes through
VK_KHR_ray_tracing_pipeline: the hit lands in a closest-hit stage that holds
gl_InstanceCustomIndexEXT, gl_PrimitiveID and barycentrics and nothing else, so it reads its
per-draw data out of the geometry table rather than out of push constants a draw would have
supplied. GraphicsPipelineFactory::create_ray_tracing builds that pipeline and hands back the
group handles plan_binding_table packs into the three regions vkCmdTraceRaysKHR takes.
TraversalDebugPass is the first user of the second path and, for RT0, the only one. It writes
the hit record as colour so a break in any ray tracing layer shows up as a specific wrong image;
engine/presentation/render/README.md lists which colour means which fault.
Scene::ShadowUniforms at set 0 binding 10 carries all of it, separate from the scene block for
the same reason the temporal block is.
4. Lighting, materials, and the sky
The environment the renderer lights against is a neutral seam,
engine/domain/environment/include/SushiEngine/environment/environment.hpp, depending only on
engine/foundation/core/include/SushiEngine/core/types.hpp — so the simulation authors it and
the renderer consumes it without either depending on the other. It holds the sun
(DirectionalLight), the sky’s derived CelestialLight list, the ReferenceEllipsoid
(defaulting to WGS84: equatorial radius 6378137 m, inverse flattening 298.257223563), and the
AtmosphereScatteringParameters, PlanetParameters, the genus-driven Cloudscape (up to
CLOUD_MAX_DECKS CloudDecks, each a WMO CloudGenus resolved through
cloud_genus_profile), StarParameters, and metallic-roughness Material that describe how the
planet is lit and surrounded. The simulation
carries one Environment on RenderScene and a Material per RenderInstance (its albedo kept
in sync with the entity’s Tint), authored through IWorldEditor’s
environment()/set_environment() and material()/set_material(); the editor’s
Environment panel and the Inspector’s material section drive them.
ISceneView::render takes one FrameView; the Environment and the CameraView (eye position
among them) are its fields. It draws in three HDR passes (the Vulkan scene view’s targets are
now linear R16G16B16A16_SFLOAT, resolved to the R8G8B8A8_UNORM image the editor samples):
-
Opaque — the grid and meshes into the HDR colour +
R32_UINTid + depth targets, drawn in the same camera-relative space the sky pass uses. Each model’s translation has the camera eye subtracted in double precision before thefloatcast (make_push), and the uploaded view matrix carries no translation — so a mesh far from the ECEF origin reaches the GPU as a small offset from the camera and keeps fullfloatprecision instead of jittering on the ~16 m grid a raw 1.5e8 m coordinate collapses to.engine/presentation/render/shaders/pbr.fragshades each mesh with a Cook-Torrance metallic-roughness BRDF (GGX distribution, Smith geometry, Schlick Fresnel) lit by the sun and an ambient floor, taking the view direction as-v_world_position(the camera is the origin of this frame), and reads a per-frame scene uniform block — the scene view’s first descriptor set, shared by every pass. -
Sky — a fullscreen pass (
engine/presentation/render/shaders/sky.frag) that works in camera-relative space (the camera is the origin; the planet centre arrives relative to it, so planet-scale metres never leave double precision on the CPU). It intersects the body’s own reference ellipsoid for the lit ground (onto which the cloud stack casts its combined shadow), grown by the deck height on a body whose air stops at an opaque cloud deck rather than at a surface — Venus and the gas giants — so the floor the sky pass sees is the floor the LUT builder bounded its march at.It evaluates the atmosphere’s Rayleigh + Mie scattering not as a per-pixel march but through the Hillaire 2020 LUT stack a preceding
AtmosphereLUTPassbuilds (engine/presentation/render/source/passes/atmosphere_lut_pass.*,engine/presentation/render/shaders/atmosphere_common.glsl): a transmittance LUT and a multiple-scattering LUT (view-independent, change-gated), a per-frame sky-view LUT (a 192x108x32 volume: the sky in the camera’s local frame, baked at 32 altitudes up a radius axis, and read by the sky, the cloud decks, the water and the traced reflection each at its own shell radius; only the slices up to the highest radius those readers can reach this frame are built, which from the ground is about half of them), and a per-frame aerial-perspective froxel pair (the air in front of each mesh): one 32x32x32rgba16fvolume for the in-scatter, and since 2026-09-04 a second for the transmittance in colour, because a single one has four channels and the froxel needs six. The scene descriptor set is full at its 32 guaranteed bindings, so the second volume rides the bindless heap’s volume array andscene.sky_view_axis.ycarries its slot plus one.A terrestrial medium can carry a boundary layer:
atmo_boundary_hazeaddsAtmosphereScatteringParameters::haze_densitytimes the sea-level Mie density belowhaze_top, closing over the top fifth of that height. Every LUT, the froxels, the cloud composite and the fog read it throughAtmosphereMedium. Both parameters are zero on every body preset, so the term is off; switched on, the ground horizon shows a coloured line that is not yet fixed.engine/presentation/render/shaders/sky.frag’s branch chain routes each pixel class to one of three estimators. A pixel that escapes to space and a pixel on the analytic ground both take a single sky-view fetch: below the horizon the table’s own march stops at the floor, so one read answers both sides of it and the limb carries no seam between two estimators. A mesh withinAERIAL_MAX_DISTANCE, 32 km, takes an aerial-volume fetch. Geometry past that volume keeps the 32-step march that reads the sun’s transmittance and the multiple scattering from the LUTs, and so does every pixel of the IBL cube capture, which runs withscene.ibl_params.wat zero and reads neither the sky-view volume nor the froxels. Over the top aVolumetricFogPass(engine/presentation/render/source/passes/volumetric_fog_pass.*,engine/presentation/render/shaders/fog_scatter.comp) marches an authored ground-hugging fog — a global height term plus up to eight local box/ellipsoid volumes — into a second froxel volume folded over every pixel in the composite.It composites over the opaque scene by the sampled depth, so geometry occludes the sky and thin air is added over it as aerial perspective.
It does not draw the stars. What it writes for them is a third attachment,
frame.targets.star_visibility: how much of a star behind each pixel reaches the camera, carrying every occluder and attenuation only this shader can know — whether the ray escapes to space past the depth buffer and the analytic ground, the air the ray crossed, a body’s disk or ring covering the pixel, and the fog in front. Because that mask fades with the atmosphere’s optical depth, the blue sky still thins into black space as the camera climbs and the stars still emerge from the physics rather than from a switch.The stars themselves are geometry, drawn immediately afterwards by
StarPass(engine/presentation/render/source/passes/star_pass.hpp,engine/presentation/render/shaders/star.vert,engine/presentation/render/shaders/star.frag): one instanced quad per catalogued star, additive into the same composite target, multiplied by that mask. The cost is stars times the few pixels each covers rather than pixels times stars, which is what lets the catalogue grow —Render::MAX_SKY_STARSis 8,192, above the 5,044 stars of the whole naked-eye sky, and the catalogue reaching the renderer is the whole ofengine/domain/astro/include/SushiEngine/astro/star_catalog.hpprather than a prefix of it. The table is J2000 equatorial and is uploaded once; the vertex stage rotates it byEnvironment::equatorial_axes, the same basis the constellation overlay draws through, so the figures stay on the stars they join.A star’s radiance is derived, not authored. A catalogue magnitude is a flux ratio against a magnitude-zero star, which is not a radiance at all, so
Render::star_flux_scaleconverts it by conservation: summed over the whole published catalogue, the drawn field deliversRender::STARLIGHT_SOLAR_RATIOtimes the sun’s own radiance scale — the same productAstro::fill_environment_skyspends on the ambient floor a moonless night leaves. The two are the same light, so they agree by construction rather than by two numbers that look right.StarParameters::brightnessis the author’s multiplier over that, and 1 is physical, exactly asNightLighting::star_intensity’s already is.That calibration says where the light is; a second, separate step says what a display can show of it. The magnitude scale is logarithmic because the eye’s response is, and this renderer’s tone mapper has no scotopic model, so nothing downstream compresses the star range: drawn linearly the catalogue spans a hundred to one from Sirius to magnitude 3.5, and at any exposure that shows Sirius the faint end is at one percent of it.
star_display_responseis the compression,radiance^0.466about a fixed point, and like every display transform it deliberately does not conserve energy. Everything physical is measured before it; everything about size and visible extent after it.Its two anchors are stated at
Render::STAR_REFERENCE_EXPOSURE, 200×: magnitude 3.5, the faintest star in the catalogue, reaches 0.12 of display white, and Sirius reaches exactly 1.0.StarPass::register_passfoldsSTAR_REFERENCE_EXPOSURE / applied_exposureinto the pushed sprite block andengine/presentation/render/shaders/star.vertmultiplies the written radiance by it, cancelling whatever exposure the frame actually lands on out of the star field alone. So the anchors above hold at every frame, not only the ones that happen to meter near 200× — the auto-exposure pass swings from roughly 1× to itskey / 1e-4= 1800× ceiling depending on everything else in the frame, and a star field with no cancellation was invisible at the low end of that range and nine times overexposed at the high end, both observed, neither fixable by picking a different single reference. Cancelling the real exposure is what a reference exposure was always meant to approximate; 200× is now only where the anchors are stated, not an assumption about where any given frame will land.The two anchors split into a contrast and a level. The exponent follows from the ratio 1.0 / 0.12 across the catalogue’s hundredfold flux range and is the same at any reference exposure, while
Render::STAR_DISPLAY_REFERENCEcarries the level and is the only constant the exposure choice moves. What that gives at 200×, a 60-degree vertical field, 1080 rows andbrightness1 — and, with the cancellation, at every other exposure too:magnitude drawn radiance × 200 exposure at brightness8quad radius −1.44 (Sirius) 5.00e-3 1.000 2.635 2.68 px 0.0 2.69e-3 0.539 1.420 2.44 px 1.0 1.75e-3 0.351 0.925 2.25 px 2.0 1.14e-3 0.228 0.602 2.05 px 3.0 7.44e-4 0.149 0.392 1.83 px 3.5 6.00e-4 0.120 0.316 1.71 px All 288 catalogued stars clear 5% of display and 169 clear 15%, against 13 of 288 reaching half display without the response.
The level costs energy conservation, and the cost is stated rather than hidden. The response now lifts every star rather than pivoting inside the catalogue: magnitude 3.5 by about 26× and Sirius by about 2.3×.
brightness1 is still exactly physical in the light the field carries —star_flux_scaleis untouched, andUnit_StarField.TheFieldDeliversTheMeasuredStarlightpins it — but what a display shows of it is about 9× a level-neutral curve, the same image an author gain of about 112 would have produced at the previous level. With the exposure cancellation above, this holds at whatever exposure the frame actually reaches, including the pass’s 1800× ceiling — a scene with nothing else in it no longer blows the star field out nine times over just because it metered dark.A star is a point source, so its footprint is the imager’s rather than its own: its flux is spread over a normalised Gaussian one pixel wide and the quad reaches out to where the compressed profile falls below the visible threshold. That threshold,
Render::STAR_VISIBLE_RADIANCE, is measured on the physical radiance, so changing the response curve cannot lengthen or shorten the catalogue: which stars exist in the frame is a question about the sky, not the display. Narrowing the field of view shrinks the solid angle a pixel covers and raises the physical peak as its inverse square, which is what lifts stars over that threshold so more sky appears. At the default 60-degree vertical field on a 1080-row image the cutoff sits just past magnitude 6, the naked-eye limit; a 6-degree field reaches past magnitude 11. The compression reaches the drawn image too: the inverse square arrives as an inverse 0.932 power, andStarParameters::brightnessarrives asbrightness^0.466, which is why the Environment panel sliders it logarithmically over four decades.The drawn radius follows the same rise only logarithmically and stops at the profile’s three-sigma support. At the default field nothing in the catalogue reaches that cap, so the quad varies across the whole sky — 1.71 px at magnitude 3.5 against 2.68 px for Sirius — and a faint star’s quad grows further as the camera narrows. A bright star’s saturates there instead; what makes it look larger past that point is the bloom pass downstream working on a genuinely higher peak, not a wider sprite.
StarParameters::densitydraws a prefix of a list sorted brightest first, so lowering it drops the faintest stars — and it removes light rather than redistributing it, because the calibration is anchored on the whole catalogue. All of that arithmetic isengine/domain/environment/include/SushiEngine/environment/star_sprite.hpp, covered bytests/unit/test_star_catalog.cpp; the pass holds the draw call and the upload. -
Cloud — the genus-driven volumetric cloudscape, ray-marched in a dedicated pass (
engine/presentation/render/shaders/cloud.frag) atQualityParameters::cloud_buffer_scaleof the render extent (design doc §4.7’s “Cloud buffer” row — a third on Low, half on Medium/High/Ultra; Ultra’s former ¾ was half of a measured 2.4× tier blowup and was retired, seedocs/architecture/VOLUMETRIC_CLOUDS.md§3), amortized overFrame::CLOUD_MARCH_BLOCKsquared frames: the target is that resolved grid divided by the block, 2, on each axis, and each texel marches one pixel of its 2x2 block per frame, in the diagonal-first ordercloud_amortization.glsl’scloud_march_slotpicks from the frame counter. The march binds no shading-rate image. It writes two MRT targets: (scattered.rgb,transmittance) and, since W3, the transmittance-weighted mean march depth (frame.targets.cloud_depth, accumulated the same wayscatteredis, normalized by the total in-scatter weight) — the aerial-perspective coupling’s input, described with the composite below.Density comes from
CloudscapeCompilePass(engine/presentation/render/source/passes/cloudscape_compile_pass.{hpp,cpp},engine/presentation/render/shaders/cloudscape_field.comp,engine/presentation/render/shaders/cloudscape_skip.comp): a compute bake that runs the genus loop once per texel of a 3D field — since atmosphere phase B1 (seedocs/architecture/DOMAIN_ATMOSPHERE.md), two camera-centred, non-wrapping windows (a near one at a 32,768 m span/128 m texel, a far one at 262,144 m/~1 km texel, addressed through the sharedengine/presentation/render/shaders/cloud_field_window.glsl) rather than a periodic, camera-locked tile — instead of once per march sample, gated by the window drifting out of range, an authored change, or the weather/wind/sun advancing (see the phase B1 cadence indocs/architecture/DOMAIN_ATMOSPHERE.mdfor the exact rules).Two more passes bake light toward the sun, both amortized across 8 frames instead of change-gated (the sun moves every frame the clock advances, so there is no “settled” state to gate on):
CloudLightVolumePass(engine/presentation/render/source/passes/cloud_light_volume_pass.{hpp,cpp},engine/presentation/render/shaders/cloud_light_volume.comp) bakes a 256x256x32 volume of summed density toward the sun, refreshing a Y-slice group at a time, andCloudShadowMapPass(engine/presentation/render/source/passes/cloud_shadow_map_pass.{hpp,cpp},engine/presentation/render/shaders/cloud_shadow_map.comp) traces the sun through the genus models into two 768² cascades spanning 2 and 8 near windows, refreshing a row group at a time: the single shadow authorityengine/presentation/render/shaders/cloud_shadow_common.glsland the analytic ground both read (see shadows).All three bakes register first in the frame, ahead of every consumer.
engine/presentation/render/shaders/cloud.frag’s march reads the field with one or two fetches (plus a coarser max-pooled skip field for its coarse probe) where it used to run the full per-deck loop; wind is absorbed into the window’s own origin rather than applied as a UV offset at sample time (see the phase B1 window indocs/architecture/DOMAIN_ATMOSPHERE.md), so the bake stays cacheable while the sky still advects every frame. Since phase B1, the field the march reads is itself already spatially resolved from the simulation (see the spatial weather field and the window section) — there is no separate per-sample weather-field fetch layered on top of the bake any more; that mechanism (the coverage-scale/reference-column lookup) was deleted once the bake itself carried the simulation’s per-column coverage.The march’s step length grows with distance already travelled from the camera (not with camera altitude, the variable it keyed on before W0 — that starved the march exactly when a climb made the cloud band thinner on screen, so quality fell with altitude for no geometric reason), floored by the tier’s near-camera budget and capped by its far-field step count; an empty skip cell advances the march by the skip field’s own cell size rather than consuming the sample budget (the Nubis3
step = max(skip_distance, 0.08*sqrt(dist), min_step)rule).Within 200 m of the camera (design doc §4.4/§4.7’s near-band radius) an extra fixed-scale (811 m) erosion tap, with a curl-noise warp folded in near cloud bases, adds detail the field’s own camera-independent bake cannot carry. The march’s dither animates at every distance, so the cloud TAA below averages its quadrature error away; the literal dual-viewport near/far resolution split design doc §4.4 also describes is a scoped W3 deferral (see the CHANGELOG entry).
Lighting samples the baked light volume for one fetch per lit sample (
QualityParameters::cloud_light_taps, 0-3, tiers how often a costlier inline cone march layers a correction on top) and shapes it with dual-lobe Henyey-Greenstein scattering (the author’s forwardforward_scatteringg paired with a fixed back lobe) through the analytic in-step energy-conserving integration(1 - exp(-sigma*ds)); ambient uses the field’s own baked vertical-profile channel (pow(1 - profile, 0.5)) for dark edges and inner glow instead of a flat height blend. The march stops at the opaque depth or analytic ground so clouds draw over terrain. Running at a quarter of the pixels is the largest single saving on top of the field; variable-rate shading steers this pass the same way it steers the sky pass. Disabling clouds (Environment::Clouds::enabled) skips every bake and the march entirely — their render-graph nodes clear/no-op in hardware instead of running a shader. -
Tonemap — before the composite,
CloudTAAPass(engine/presentation/render/source/passes/cloud_taa_pass.{hpp,cpp},engine/presentation/render/shaders/cloud_taa.comp, W3) resolves the cloud buffer’s own dedicated temporal history: a YCoCg neighbourhood variance clip (gamma ≈ 1.2,QualityParameters::cloud_variance_clip— Low instead takes a cheaper 5-tap cross min/max clamp, design doc §4.7’s TAA row; the flag selects which neighbourhood rejection runs, never whether one does, because the rejection test is this feedback loop’s stability condition rather than a quality feature layered on it, and both paths clamp alpha as well as colour since the composite folds the sky through that alpha). The history is reprojected at the nearest marched range around the pixel and read through a Catmull-Rom filter, with history acceptance ramping up over accepted frames and boosted further under sub-pixel motion.Only the pixel the march traced this frame blends a new sample; the other three of its block carry their clipped history forward, and with no history take the march target’s bilinear value. The clip reads its neighbourhood bilinearly, one march texel apart around the pixel, so the four pixels of a block are clipped to different boxes.
It owns its resolved colour and a per-pixel history-acceptance weight as two pairs of pass-owned images ping-ponged by frame parity — sized at a fixed half of the view’s output extent rather than the dynamic render extent, so tier and dynamic-resolution changes reach it the same render/output-extent reconciliation
engine/presentation/render/shaders/taa.fragalready does for the main resolve, without forcing a history resize on every step. This runs entirely before — and is independent of — the frame’s owntaa_pass_, which only ever sees the already-composited cloud contribution as an ordinary shaded pixel.cloud_composite_passthen resolves CloudTAAPass’s output over the full-resolution sky (sky * transmittance + scattered) with a nearest-depth-aware upsample — weighting each of the four nearest cloud texels by whether its depth roughly matches the output pixel’s, so a background cloud sample cannot bleed a halo across a foreground silhouette, the same four weights also reconstructing the range CloudTAAPass resolved beside the colour; where all four match it reads a cubic B-spline over 4x4 texels instead, leaving out any tap whose depth differs, so the half-resolution grid does not show as steps on a cloud’s edge — and folds in aerial perspective by sampling the Hillaire aerial-perspective froxel volume (AtmosphereLUTPass) once per pixel at that reconstructed depth: the cloud’s own in-scattered light is attenuated by the air in front of it and the air’s own in-scatter is added back, weighted by how much cloud is actually there, so a distant deck hazes, desaturates, and sinks toward the horizon exactly like a mesh already does.engine/presentation/render/shaders/tonemap.fragthen applies exposure, the ACES filmic curve, and a gamma encode, resolving the HDR composite into the sampled LDR image.
The planet is drawn analytically in the sky pass rather than as tessellated terrain, which is why a real WGS84 Earth and its atmosphere render with no level-of-detail machinery; the ground grid the editor shows sits tangent to the ellipsoid at the local origin. The existing floating-origin / ECEF types (see the value-type seam) anchor the camera’s world position that the sky pass places the planet relative to.
Depth is reverse-Z with an infinite far plane on a D32_SFLOAT_S8_UINT buffer: perspective
maps the near plane to clip depth 1 and infinity to 0, the pipeline clears depth to 0 and
compares GREATER_OR_EQUAL, and floating-point precision is spread almost uniformly across the
whole range — so one camera resolves a few-centimetre prop and a planet 10⁷ m away in the same
frame without z-fighting, and nothing is ever clipped for distance. The sky pass reconstructs
view-z straight from the projection matrix, so it is independent of the depth convention; its
“geometry is here” tests key on depth > 0.

