Source of truth for the Vulkan render path. Companion to vulkan.md (backend + GPU-assisted compute) and ARCHITECTURE.md §Rendering & Compute Backend.
Status (2026-08-23). The shipping game uses the Vulkan path for macro and subworld rendering. The subworld renderer has terrain, sky, water, structures, tree billboards, BODY billboards (drawn or procedural, one pass), and a two-level cascaded directional object shadow map (near/far, §Shadow mapping). The 2D macro view is the macro fragment synth (shaders/macro.frag).
All backend objects live in src/gpu/; game logic never includes Vulkan
headers. Shaders are GLSL compiled to SPIR-V by glslc at build time (see
vulkan.md §Shader toolchain).
Each subworld frame is two passes recorded into one command buffer: a
depth-only shadow pass (scene from the sun's point of view) followed by the
main pass (scene from the camera). The split is why
vk_renderer.h exposes acquire_frame() /
begin_render_pass() separately instead of only begin_frame().
flowchart TD
A[acquire_frame: wait fence, acquire image, begin cmd] --> B[shadowMap.begin]
B --> C[object casters only]
C --> D[shadow_bb: tree silhouettes from sun]
D --> Ds[shadow_struct: wall/house boxes from sun]
Ds --> Dn[shadow_body: drawn alpha or procedural coverage, from sun]
Dn --> E[shadowMap.end -> DEPTH_STENCIL_READ_ONLY]
E --> F[begin_render_pass: main color+depth]
F --> G[Sky: fullscreen, depth OFF]
G --> H[Terrain: mesh + PCF shadow sample]
H --> I[Trees: instanced billboards]
I --> Is[Structures: instanced boxes + PCF shadow sample]
Is --> In[Bodies: ONE pass, sprite bank or procedural]
In --> Ip[Particles: additive FX billboards, depth read / no write]
Ip --> J[Water: transparent plane, depth read / no write]
J --> K[end_frame: end pass, submit, present]
Draw order and depth policy (main pass):
| # | Pass | Pipeline | Depth test | Depth write | Blend |
|---|---|---|---|---|---|
| 1 | Sky | create() fullscreen |
off | off | off |
| 2 | Terrain | create_mesh() |
LESS | on | off |
| 3 | Trees | create_mesh() instanced |
LESS | on | off (alpha-test discard) |
| 4 | Structures | create_mesh() instanced |
LESS | on | off |
| 5 | Bodies (drawn + procedural, ONE pass) | create_mesh() instanced |
LESS | on | alpha |
| 6 | Particles | create_mesh(additive) instanced |
LESS | off | additive |
| 7 | Water | create_mesh() stride 0 |
LESS | off | alpha |
Sky is drawn first as a backdrop; everything else depth-tests over it. Particles and water are drawn last (after all opaque geometry) so they read existing depth and blend without occluding it. Particles come before water so a torch/impact glow does not double-count through a transparent surface it sits behind.
Lighting is driven entirely by a time-of-day scalar tod ∈ [0,1) and is
extensible — every pass takes the same sun/ambient inputs, so adding a light
consumer is a push-constant field, not an engine change.
- Sun direction —
sunAng = (tod − 0.25)·2π;sunDir = (cos, sin, 0). Shaded receivers use this asL, the direction from the world toward the sun, forN·L. Do not negate it when fillingMeshPush::sunDir; negating it makes horizontal terrain receive no direct sun term, so ground shadows become invisible. - Sun colour — warm orange near the horizon, neutral white overhead, scaled
by a day-intensity
smoothstepof the sun elevation (zero at night). - Ambient — cool blue moonlight at night lerping to neutral grey by day, so night is moonlit, never black. Ambient is applied unshadowed (shadows only attenuate the sun term). The night floor is kept deliberately low so the directional moonlight (next) does the sculpting, not a flat ambient wash.
- Moonlight (directional). At night the dominant moon is not merely ambient
fill — it is a weak directional light in its own right, and WHICH moon that
is comes from macro/celestial.h's
night_light(day, tod): the procedural orbits (a moon lags the sun by its phase) put a full moon opposite the sun and a new moon beside it, and the dominant one is the lit-and-up maximum of illumination × horizon fade.compute_light_parameters(src/sub/lighting.h) folds it onto the samesunDir/sunColorslot the sun uses: as the sun sinks, the moon's authored tint (cooled, ×kMoonDirGain0.42× its illumination) fades up and the direction re-points at the real moon — the flip happens while both terms are ≈0, so there is no pop. The world stays directionally sculpted at night, and because the light arrives from the moon above, it does not re-introduce the night-glow the contract below guards against. This bearing is exactly where sky.frag draws that moon's disc and what the water specular (water.frag) reflects — the agreement is now emergent (one orbit law feeds all three), not a-sunDirdecree. On an all-new-moon nightstrength01is 0 and only the ambient floor remains: honest dark nights, an owner-approved feature. - Point lights (positional). On top of the one directional body, the world
carries up to
kSubworldMaxLights(16) positional lights — torches, the player's carried lantern, spell / projectile glows, lit windows. They live in a per-frame storage buffer at set 0 / binding 1 (the same set every lit pipeline already binds for the shadow sampler), summed bypoint_lights()in lighting.glsl additively overlit_surface():col += base · Σ light.color · gain · atten · N·L, with a smooth quadraticatten = clamp(1 − d/radius, 0, 1)². The sum is unshadowed (the sun shadow map does not gate it) and returns exactlyvec3(0)when the count is zero, so the whole feature is provably inert until an emitter exists.- Three forms, one curve. Surfaces with a real normal (terrain, structures)
use
point_lights()with theN·Lterm above. Camera-facing billboards (trees, NPCs, creatures) have no meaningful per-pixel normal, so they usepoint_lights_flat()— the same buffer, gain andpoint_light_atten()curve but distance attenuation alone (noN·L). DroppingN·Lis deliberate: a chest-height torch givesN·L ≈ 0on an upright card and would leave an actor dark while lighting the ground at its feet — visually incoherent. This mirrors howlit_surface()already feeds billboards a flatsunTerminstead of a per-pixel one. Result: a tree, NPC or creature standing in a torch / spell / lantern pool warms with it exactly as its ground does. The water surface (water.frag) is neither diffuse ground nor a flat sprite — it is a mirror, so it uses the third formpoint_lights_spec(): a half-vector glint (pow(N·H, ·), a tight core plus a soft wider halo) that reflects the light source off the wave normal, the point-light analogue of the sun/moon "лунная дорожка" the same shader already draws for the directional body. A lantern on the shore paints a shimmering coloured streak that rides the ripples. It is added after the day/night ambient wash (a torch reflection is its own light, not scaled by the sun's time of day), so it reads at night and washes out under a bright day surface. All three forms sharepoint_light_atten(dist, radius)so a pool reaches the same distance whether it lands on the floor, on a body standing in it, or as a glint on the water beside it. - One universal source. Any subworld entity may carry a
LightEmitter(src/ecs/components.h); the renderer'sgather_point_lights()packs everyview<Position, LightEmitter, SubworldTag>entity into the buffer each frame with no per-emitter code — the player lantern, an NPC torch and a fireball glow all take the identical path. Positions are built in the same window/composite space as terrainvWorld(tile_to_worldfor XZ,Position.zfor the entity's actual world-space altitude as Y) plus the emitter's metres offset, so a light rides at the entity's true height — flying entities and projectiles light from the air, ground-walkers from the surface. - Nearest-N cull. The gather has no upper bound while collecting — a
dense settlement full of torches or lit windows can nominate far more than
the SSBO's
kSubworldMaxLights(16 — since the light field took over the small lights, this loop is the HERO set only).cull_nearest_lights()(src/sub/lighting.h) then keeps the 16 closest to the camera (std::nth_elementon squared distance, O(n)); at or under budget it is a no-op and the buffer is byte-identical to the pre-cull renderer, so every scene today is unaffected. The player's own light rides the camera at ≈0 distance, so it is always in the near set and never dropped for a distant torch. The shader sum is order-independent, so partitioning the survivors changes nothing visible. The cull is a pure, Vulkan-free helper unit-tested inpoint_light_cull_test(the overflow path is not otherwise reachable in-game yet). - No-stall upload. The buffer is a persistently-mapped host-visible ring
(one per frame in flight,
create_host_mapped). It is written straight through the mapping inrecord_mainafteracquire_framereset the frame's fence — the slot is GPU-idle, so there is no staging copy, no barrier and no queue stall (the no-stall transfer contract). - First emitter — the player lantern. The player entity carries a warm
LightEmitter(radius 16 m, intensity 1.35, RGB{1.00, 0.72, 0.42}, seated 1.2 m up). Because it is additive over the directional term it reads as a warm pool at night and is washed out by daylight on its own — verified by eye at 01:00 (clear warm ground pool against the cool moonlit distance) and 08:00 (indistinguishable from no lantern). Possession moves onlyPlayerTag, so lighting simply follows whatever body is possessed with no special case. - Second emitter — travelling spell bolts. Every spell projectile from the
shared
emplace_projectile()(src/content/spells/effects.cpp — fireball, ice shard, magic bolt, lightning chain) carries aLightEmitterbuilt bybolt_light()from data the spell already passes: the light colour is the bolt's sprite element tint normalised so the brightest channel is 1 (a pale ice tint still reads bright), and the reach/intensity scale from the projectile radius (a fat fireball throws a wider, brighter pool than a thin bolt). One formula, no per-spell code — a new spell lights in its own colour for free. Because the light rides the bolt'sPosition, which the projectile sim advances every tick, the pool travels with the bolt and sweeps the ground as it flies. Note this is also the bolt's only visual presence in the 3D subworld: the body pass skips anything whose row has neither drawn art nor a procedural body plan — a projectile card is not a body — so the glow is what you see. Verified at 01:00 (settlement walls bathed in warm fireball light;cast_bolt_capturereportslit=1and, after a short pre-capture flight,alive=1). - Third emitter — data-driven NPC torches. A humanoid NPC type can carry a
torch purely by data:
NpcTypeDef(src/macro/npc.h) has six opt-in light fields (lightRadius,lightIntensity,lightR/G/B,lightHeight), all defaulting to0— a row that sets none is dark, so the feature is strictly opt-in and a torch is tuned by editing one table row, never code. Today only the Guard row lights (radius 11 m, intensity 1.15, RGB{1.00, 0.66, 0.34}, seated 1.1 m up — a warmer, tighter pool than the player lantern so a patrolling guard reads as carrying a torch rather than being a second sun). A single file-localmaybe_emplace_carried_lighthelper copies those fields verbatim into anecs::LightEmitterat every humanoid spawn site — settlement population, the player squad and macro→subworld projection (src/sub/spawn.cpp) plus the console / encounter pathspawn_hostile_npc(src/sub/engine.cpp) — so a guard is lit identically however it enters the world, and from there it rides the exact same universalgather_point_lights()path as the lantern and the bolts with zero renderer code. The helper is deliberately duplicated per-TU (mirroringmaybe_emplace_missile_attack), not shared. The spawn-layer wiring — opt-in data contract, verbatim copy,+Yseating, and only the lit types getting an emitter — is pinned bycarried_light_spawn_test(pure ECS + data, through the shipping City spawn path). Verified on-screen at 01:00 with an A/B that isolates the torch: a seed-lockedguardprobe vs apeasantprobe at identical staging, with every non-probe light stripped (TIMAERT_SMOKE_SOLO_PROBE_LIGHT) so the guard's own torch is the only emitter in frame — the guard frame shows a warm radial ground pool (pool region RGB ≈{30,19,12}, warm R>G>B) where the peasant frame is flat night ground (≈{6,6,6}, neutral), a ~5× localized lift that appears iff the guard carries itsLightEmitter.
- Three forms, one curve. Surfaces with a real normal (terrain, structures)
use
The shaded surface colour — defined once for every lit object in
shaders/lighting.glsl as lit_surface():
col = base · (ambient.rgb + sunColor.rgb · sunTerm
· shadowFactor // object maps, near/far handoff
· terrain_visibility // heightfield march: ridges occlude
· cloud_visibility) // drifting cloud field overhead
Three occluder classes, each answered by the data that owns it, multiplied in this one place. Facts that keep the product honest (all 2026-08-11):
- the shadow maps' light frame is anchored in the world, not on the
camera (
compute_shadow_basis): the texel snap only holds the map still because the snap grid itself stands still; - billboards receive shadow per fragment — light-clip xy from the true
fragment position, z from the trunk axis at the same height, so a shadow
edge covers exactly the part of the sprite it mathematically reaches and
self-shadow acne is impossible by construction (
billboard.vert); - the march heightfield
u_heightMis the window plus a one-window apron of macro-skeleton heights on every side (skeleton_cell_height01, base_generator.h — the generator's own per-cell column law, one door), so massifs beyond the loaded window keep casting and shadows never depend on where the player stands; its span ridesterrainParams.z; - the cloud-shadow term keys the cloud field to absolute world coords
(composite origin in
sunDirW.w/terrainParams.w— the same anchor mesh.frag uses for ground detail), so the pattern survives a seam recenter.
Diagnostics: console lightdbg [march|clouds|map|nl|off] lifts one member of
the product to 1 at a time (bisect-by-eye; mask in terrainParams.y);
sunfreeze pins the sun for rendering only. Both are per-run TOOLS, not
settings — boot_world resets them, so every new game or load starts at the
universal default with everything on.
Every lit fragment stage — terrain (mesh.frag), structures
(struct.frag), and the tree / body billboards
(tree, body) — does
#include "lighting.glsl" and calls
lit_surface(), so the day/night response lives in one place and cannot
drift or be re-implemented (subtly wrong) per shader. Only the sunTerm
differs: terrain and structures quantise N·L to 4 bands for a pixel-retro
look; billboards pass a flat constant (0.7) since they have no meaningful
per-pixel normal.
This paragraph described the NPC pass as lit for a long time while it was not, and named a constant (
0.75) that existed in no shader. When the paper-doll composition moved to the GPU (7cd71e2),npc.frag(since merged intobody.frag, 2026-08-20) lost its#include "lighting.glsl"and became the one lit pass that was not lit: people stood at full palette brightness at midnight, took no shadow, and were not touched by the torch they were themselves carrying. Restored 2026-08-06, proven by a night pair fromgpu_smoke3d(GPU_SMOKE_NPC_CLOSE=1): a body that measured 76/54/68 in both the torch-lit and the torchless night frame — identical, because it ignored lighting entirely — now measures 8/7/15 with no torch and 159/98/84 inside one, while midday is unchanged. A document is not a mechanism; the frame is.
Night-glow contract (universal day/night switch). Because sunColor carries
the day-intensity — the sun's contribution scaled to zero as it drops below
the horizon in compute_light_parameters
(src/sub/lighting.h) — the direct term
sunColor · sunTerm · shadow falls away together for every object as the sun
sets, with no per-object drift. At night that same slot is repurposed to carry
the moon (the weak cool term from -sunDir, above), so the world is still lit
directionally, just from overhead instead of from a sun below the horizon. This
is deliberately the single switch. An earlier build left the intensity out of
sunColor and open-coded the combine in each shader, so billboards (flat sun
term) and vertical wall faces (N·L still catches the below-horizon sun's large
horizontal component) "glowed" at night, while flat terrain escaped only by
geometry (upward N·L ≤ 0 against a sun that is down). Fold intensity in at the
source + combine in one place and all object classes track day/night together —
and the moon, riding the same slot, inherits that safety for free (its light
comes from above, so it lights without glowing). Do not re-scale sunColor
in a shader or add a per-shader ambient floor — either re-introduces the glow.
The subworld game maps tod from WorldTime; see
src/sub/lighting.h compute_sun() for the production
sun/colour curves this harness mirrors.
The flat top-down macro view (shaders/macro.frag) has its
own night-glow system, separate from the subworld sun/shadow path above. A
per-cell light field is baked on the CPU whenever the world changes and
sampled once in the macro synth's nightDarken stage:
- Emitters are enumerated data-drivenly from world state
(
collect_macro_lights): settlements/villages glow population-scaled, active spires at a fixed strength, off each type'sLandmarkDef.lightColor. - Spread is terrain-occluded — a bounded Dijkstra over the feature grid's per-feature optical cost (roads carry light furthest, forest canopy smothers it), so light flows over open ground and around dense stands. With no feature layer it falls back to an exact Euclidean radial.
- Brightness has one director knob,
kMacroGlowGain(macro_lighting.h), applied in the bake before thekMacroGlowCeilclamp/encode. - Upload is surgical:
vk_macro_renderer::upload_light_fieldrewrites only descriptor set 0 / binding 3, leaving the master/feature/zone synth inputs live. - Re-baked on world-gen, save-load, and daily population drift only — never per frame.
The shader decodes (· kMacroGlowCeil) and adds the glow scaled by the same
nightDarken day/night curve that darkens the base map. Full pipeline, cost
table, rebake triggers and the mountains→biome occlusion caveat:
macro-lighting.md.
This is the answer to the standing requirement: no floating shadow blobs — cast shadows must land on the terrain and on other objects. We render a real depth-map from the sun and sample it with PCF.
Shadow resource — vk_shadow.h
gpu::VulkanShadowMap owns one square depth target, and the renderer owns
two of them (shadow_ + shadowFar_, vk_renderer_3d.h):
| Property | Value |
|---|---|
| Size | 4096×4096 (both levels) |
| Format | VK_FORMAT_D32_SFLOAT |
| Usage | DEPTH_STENCIL_ATTACHMENT + SAMPLED |
| Sampler | hardware-PCF: compareEnable + LINEAR, clamp-to-edge (vk_shadow.cpp) |
| Render pass | depth-only, CLEAR → STORE, final layout DEPTH_STENCIL_READ_ONLY_OPTIMAL |
| Sync | two subpass dependencies (fragment-read ↔ depth-write) |
begin(cmd) opens the depth pass (clear depth = 1, viewport/scissor = size),
end(cmd) closes it and transitions the image to read-only so the main pass can
sample it.
The sun is directional, so the shadow camera is an orthographic box in light
space. The object map is ONE idea at TWO scales
(compute_shadow_basis, vk_renderer_3d.cpp):
- Near — fitted
kShadowNearRadiusM(±256 m) around the camera, every caster. ±256 m over a 4096 map ≈ 15 cm/texel — a 2-metre person casts a real silhouette (±1024 m gave 54 cm: a body was 4 texels, the square-blob artifact). - Far —
kShadowFarRadiusM(±1024 m), trees and masonry only: a body's shadow past 256 m is subpixel, so bodies never enter this level.
The boxes follow the camera, but the light eye stands on the sun axis over a
FIXED world anchor at kShadowEyeDistanceM (8192 m) — never over the camera:
the texel snap (box centre floored to the map's texel grid) only holds the map
still if the snap grid itself stands still in the world. Near/far depth planes
are quantised to a coarse step for the same reason (the receiver bias is
normalised by far − near).
Receivers read the near level wherever it applies and hand off to the far
level across the near volume's edge band
(shadow_common.glsl shadowFactorHandoff) — a
takeover, never a union, so the two levels cannot double-shadow. Neither level
carries terrain: ridge occlusion is the heightfield march's member of the law
(terrain_visibility in lit_surface()).
vk_ortho/vk_perspective are the subworld-local Vulkan-depth (0..1) helpers
(src/sub/vk_camera_math.h); core/math.h projection
matrices are GL-style (depth −1..1) and must not be used directly for Vulkan clip
space.
Depth-only pipeline — create_shadow() in vk_pipeline.h
- Colour blend
attachmentCount = 0(no colour target). - Depth test + write, compare
LESS. - Raster depth bias is enabled on the caster side (
rs.depthBiasEnable, vk_pipeline.cpp) and does the heavy lifting; the receiver-side bias in shadow_common.glsl stays small. Large raster bias on a wide frustum still erases small casters (bodies/trees) via peter-panning — the fitted near volume is what keeps it small. - Push constants
VERTEX | FRAGMENT. - Optional descriptor set layout is supported for alpha-tested casters such as drawn bodies, which throw the alpha of their own picture.
| Caster | Vertex | Fragment | Levels |
|---|---|---|---|
| Trees | shadow_bb.vert — expand instance quad along sun-derived lightRight |
shadow_tree.frag — shared treeCoverage() discard (real silhouette) |
near + far |
| Bodies | shadow_bb.vert — expand instance quad along sun-derived lightRight |
shadow_body.frag — asks the body's ROW: a drawn kind discards on its picture's alpha, a bare row on its procedural coverage | near only |
| Structures | shadow_struct.vert — expand the per-instance box (cube from gl_VertexIndex) by lightMvp |
shadow_struct.frag — empty (depth only) | near + far |
Universal billboard shadows (no bespoke silhouettes). A billboard's shadow is
cast from its own visible coverage, not a hand-authored blob. Whatever produces
the silhouette produces the shadow (sprites.md): procedural trees
share treeCoverage() between lit and depth-only passes, and a body's lit pass
(body.frag) and caster
(shadow_body.frag) unpack the same kind through the
ONE header doll_pool.glsl — a drawn kind samples its
sm::SpriteBank slot (one 256×256 layer per KIND, sprite_bank.h),
a bare row its procedural coverage, so no body can cast a shadow it does not
have. The same pattern applies to future sprite classes: lit pass samples/draws
coverage, shadow pass samples the same coverage and only writes depth for opaque
pixels.
Every lit fragment stage binds the near map at set 0, binding 0 and the far
map at binding 3, and samples them through the ONE shared receiver policy in
shadow_common.glsl: a 3×3 loop of hardware-PCF
taps (sampler2DShadow, each texture() a bilinear 2×2 depth compare) with a
small slope-scaled receiver bias, near→far handoff across the near volume's
edge band. A fragment is lit when its light-space depth (minus bias) is nearer
than the stored depth:
- Terrain (mesh.frag) and structures
(struct.frag) sample per-fragment at
lightMvp · vWorld. - Billboards (tree.frag, body.frag) receive per fragment too, with the split that killed self-shadow acne by construction: light-clip xy from the true fragment position, z from the trunk axis at the same height (billboard.vert) — a shadow edge covers exactly the part of the sprite it mathematically reaches.
Result: terrain, structures, trees and bodies all receive; trees, structures
and bodies cast into the two-level object map under one law. Terrain is
intentionally not rendered as a caster in the object maps: self-shadowing the
coarse receiver mesh creates tile-scale zebra bands instead of useful object
shadows — ridges occlude through the heightfield march term instead. Shadows
swing through the day/night cycle because lightMvp tracks sunDir every
frame.
- New caster — add a
create_shadow()pipeline for the mesh and draw it insideshadowMap.begin()/end(). Give it the samelightMvp(and decide which level(s) it belongs in — a small caster has no business in the far map). - New receiver — bind the shadow set (set 0: binding 0 near, binding 3 far),
push the near
lightMvp(the far matrix rides the set-0 per-frame SSBO aslightMvpFar),#include "shadow_common.glsl", multiply the sun term byshadowFactorHandoff. - Softer shadows — the PCF spread knob is
TIMAERT_SHADOW_SPREAD_*in shadow_common.glsl; the cascade pair already exists (near/far above).
shaders/sky.frag renders a texture-free procedural
celestial dome. It is the pure-shader half of the sky submodule:
src/sub/sky.h's SkyContext is the one door (tod, the sun
vector, 1–3 moons, star-size scale, and reserved weather fields —
cloudiness/wind/precip — that the future macro weather field will fill), built
per frame by build_sky_context and copied verbatim into SkyPush beside the
camera basis. One fullscreen triangle
(fullscreen.vert), depth off, drawn first; skipping
the draw is the whole off-switch.
Layers: day/night/twilight gradient; sun disc + glow + horizon scatter on the
exact sunDir the world is lit by (no second copy of the arc formula); 1–3
procedural moons from macro/celestial.h's orbits,
each with its authored tint and size, a geometric crescent (the terminator
is where the disc turns away from the sun — because a moon lags the sun by its
phase, the drawn lit fraction automatically equals moon_illumination01) and a
two-lobe bloom in the moon's own tint scaled by illumination (a crimson moon
glows crimson, a new moon vanishes); three equirectangular star densities +
a Milky-Way band (dot(rd, mwN)) + per-star twinkle and colour temperature,
disc radii scaled by the celestial kSkyStarSizeScale seam; and drifting clouds from shaders/clouds.glsl — ONE domain-warped FBM field whose wind and cloudiness come from the context, shared with the cloud-shadow term every lit pass applies (cloud_sun_visibility in lighting.glsl, fed time/wind/cloudiness through the light SSBO's skyParams lane): the cloud you see overhead is the shadow crawling under your feet, zero extra descriptors.
GPU_SMOKE_SKY=1 aims the gpu_smoke3d camera at the dome for LOOK-able
captures.
Precipitation is the submodule's SECOND fullscreen pass
(shaders/precip.frag): drawn LAST in the main pass
(the weather falls between the camera and the world), depth off, alpha over,
three hash-grid depth layers for parallax — no particles, no textures. What
falls and how hard is the calendar's weather (sub/sky.h weather_at — a pure
per-day derivation until the macro weather field lands): winter snows, summer
rains, spring may hail, autumn rain/snow, with smooth in/out shower windows
and per-day kinds (owner ruling, data in kSeasonPrecip). A wet spell drags
cloudiness01 up, so rain falls from a heavy sky and the cloud-shadow system
darkens the world for free. Thunderstorms ride heavy rain: storm_flash01
(pure function of the render clock, hash-gated ~6 s windows, double-strike
envelope) is ADDED TO AMBIENT — the channel every lit pass already receives —
so the whole world blinks; the dome whitens via SkyPush.p3.w and the rain
sheet silvers in its own pass. On a dry day the pass is skipped entirely:
verified byte-identical (md5) to a build without it. Harness overrides:
GPU_SMOKE_PRECIP=<0..1>, GPU_SMOKE_PRECIP_KIND=<0 rain|1 snow|2 hail>,
GPU_SMOKE_STORM=1.
-
Terrain — a heightmap quad mesh (shipping: 192×192 quads, harness: 128×128) with central-difference normals, indexed triangles, lit + shadowed as above. Vertex =
{vec3 pos, vec3 normal, vec2 uv}, whereuvis the normalised grid position (0..1). Ground colour is a procedural per-biome synth (shaders/mesh.fraggroundColor/materialBase), but the material id that drives it is sampled per-fragment, not interpolated from the mesh vertices. The renderer bakes a full-resolution R8 tile-material texture (u_material, descriptor set 1) inupload()— one texel per world tile, independent of the terrain tessellation — and the fragment stage looks it up atvUv.This is the fix for roads / field bands / shorelines rendering as disconnected blobs (пятна) instead of connected lines. The mesh is coarse — ~16 world tiles between vertices at 192² (more at the harness's 128²) — so a 1-tile-wide road carried as a per-vertex material attribute simply falls between vertices and dissolves. Sampling the id per-fragment from the full-res grid keeps thin features crisp and continuous, exactly like the TS authority's per-fragment
u_tileGridlookup (v_uv = a_posin renderer-3d). The synth then layers the quantised pixel-art per-material variation on top (no atlas, same philosophy as the macro synth). In the shipping game the ids come from the seamless tile grid (src/sub/vk_renderer_3d.h), resolved once per biome cell while baking; the harness fakes a small grid (biome-by-height + a cross road) so the standalone smoke drives the identical path. The set-1 material descriptor is allocated once and rewritten on eachupload()(load-time / seam-cross only — never per frame) — and, on a seam crossing, re-pointed to the ping-pong sibling image after an on-GPU relocation (see §Seam crossing below and seamless-crossing.md).Next polish: the per-material surface variation still reads "fabric-like" (a woven micro-pattern) rather than natural ground. The material routing above is correct and shipped; it is the texture synth inside
groundColorthat still needs a pass. -
Trees — instanced procedural billboards. One
vkCmdDraw(6, treeCount)draws the whole forest: the quad corners come fromgl_VertexIndex, and a per-instance buffer supplies{vec3 pos, halfWidth, height, species, seed}— both extents in metres, decided once on the CPU bytree_billboard()(sub/tree_atlas.h) so the lit pass and the shadow caster can never drift apart on an aspect ratio. A tree's height is the biome's metric band (BiomeConfig::treeMinHeightM/treeMaxHeightM, ~10-20 m in a mature stand) rolled per tree and stored inStructure::height, its crown footprint inStructure::radius; the species scales both (aspect preserved, no field decorative); its base is seated on the same surface a body's feet use (sample_height_m) and sunk less than one sprite row (5 % of the height) so the trunk stays above ground. The fragment stage draws 7 species (pine/birch/willow/jungle/oak/cherry/autumn) per pixel keyed by species+seed — no atlas, no per-tree CPU cost, full variety. Camera facing is cylindrical (world-up stays vertical, right follows the camera). The macro-map tree decor in macro.frag intentionally keeps the old irregular single-cell blob as the visual base, then uses the project's 3×3 context rule only to add small neighbouring crown caps across shared forest edges/corners. That preserves crisp organic tree shapes, avoids square forest fills or one-direction smears, and lets neighbouring biomes/temperatures mix their tree colours naturally on forest borders. -
Bodies — one instanced pass for every living thing (2026-08-20, the sprite track; the law is sprites.md). A peasant and a wolf are the same instance record in the same buffer through the same draw, because what decides how a body looks is its ROW, not what sort of thing it is. The renderer used to ask the second question —
archetype == 0xFFmeant "NPC pass" (the field has since been renamedecs::Sprite::spriteRow, components.h) — and paid for it with two pipelines, two shadow pipelines, two instance buffers, two fill loops and four draw blocks.kindcarries both halves of the law (gpu::bb_body_kind): the low 16 bits are the bank slot orkBbNoSlot, the high 16 the procedural body plan. Both stages unpack through the ONE header shaders/doll_pool.glsl, so the lit pass shaders/body.frag and the caster shaders/shadow_body.frag cannot disagree about a silhouette.Drawn art is resident in
sm::SpriteBank(gpu::SpriteArray): one 256×256 layer per drawn kind, decoded once at boot — five slots, 1.3 MB, no LRU, no staging ring, no cold start. What it replaced needed 8192 slots and 75.5 MB because the paper-doll composite generated a face per SOUL, so a 5k city's working set measured ~7.2k unique frames (Session 28, 2026-08-13) and the pool below that capacity thrashed forever. A picture per KIND is two orders of magnitude smaller, which is what paid for storing art at the resolution the artist drew it.Accepted cost until the artist delivers sheets: a humanoid is one static picture per kind, camera-facing — the terms every procedural creature has always had. Walk cycles and facings return with the art.
Scar worth keeping: a PNG arrives head-first (row 0 = the top) and the bank stores the world convention (v = 0 at the FEET). The pool this replaced flipped rows at upload; the bank did not, and the entire town stood on its head through a green build, a green suite and a passing smoke. One capture found it.
shaders/water.vert builds a flat quad at waterLevel
straight from gl_VertexIndex (no vertex buffer — the pipeline is created with
vertexStride = 0). shaders/water.frag animates a wave
normal from two drifting noise fields, then adds a Fresnel sky reflection, a
sun/moon specular highlight, positional-light glints (see below), and a
depth tint; output alpha 0.82. Depth-test on, depth-write off, alpha blend — so
it fills valleys below the water line while hills poke through. The water pipeline
binds set 0 (the shared shadow-sampler + point-light SSBO layout) so the
fragment shader can reflect the same positional lights every other lit pass sees.
The specular is a half-vector two-lobe model sharing the one
sunDir/sunColor slot, so it serves the sun by day and the moon by night with
no branch: a tight core glint (pow(N·H, 80)) is the compact daytime highlight,
and a far wider lobe whose spread is gated by 1 − |L.y| opens only when the
light sits low over the horizon — smearing the reflection into the long
shimmering "glitter road" (the лунная дорожка) that points back at the viewer
for a setting sun or a risen moon, while an overhead midday sun keeps a compact
spot (the daytime look is unchanged). The road stages at real shorelines; a
landlocked or massif-occluded spawn (e.g. seed 12345) may show no open water
along the celestial bearing — use TIMAERT_SMOKE_WATERSCAN (see §Frame capture)
to find a coast.
Positional lights reflect on the water too. point_lights_spec()
(lighting.glsl, the third of the three point-light forms
in §Dynamic lighting) adds a half-vector glint for every active point light —
same buffer, same point_light_atten() radius curve as the ground and billboard
forms. A torch, the player's lantern or a spell bolt drifting near the shore
throws a small coloured reflection that shimmers across the ripples, added on top
of the day/night wash so it reads at night exactly as the light pools on the
ground beside it. Inert when no emitter exists (the buffer count is zero). Stage
it headless with GPU_SMOKE_LIGHT_WATER=1 (see §Frame capture), which finds the
scene's deepest water cell and aims the camera across it.
Transient visual effects — spell trails, impact bursts, blood, dust, embers,
explosions — render as additive billboards in a pass drawn after all opaque
geometry and creatures, immediately before water. The pass is emissive: it
binds no descriptor set (no lighting, no shadow — the particles are the
light), keeps depth-test on so terrain/trees/creatures occlude them, and
depth-write off + additive blend (dstColorBlendFactor = ONE) so
overlapping cards accumulate into a glow with no back-to-front sort. Additive
blend is order-independent by construction, which is the whole reason the pool
needs no per-frame depth sort. At the 8-bit LDR swapchain, dense overlap
saturates to white — exactly the white-hot-core-to-warm-halo look of pixel-art /
Final-Fantasy magic FX.
The pipeline flag. create_mesh(..., additive) (the trailing bool on both
overloads, vk_pipeline.h) is the only renderer-side
switch: additive=true flips the colour-blend dst factor from
ONE_MINUS_SRC_ALPHA to ONE. Every other pass passes the default false and
is byte-for-byte unchanged. The particle pipeline is the sole caller today.
The sim is Vulkan-free and lives on the engine, not the renderer. Transient
VFX are deliberately not ECS entities — they are a flat POD pool
(Particle pool_[2048]) advanced by a pure CPU integrator in
src/sub/particles.h / .cpp. This keeps the sim
standalone-unit-testable (particle_sim_test asserts table ranges, emit
counts, lifecycle reaping, physics sign, pack envelope, and seed determinism with
zero GPU) — the project's correctness brake. The engine owns a ParticleSystem,
ticks it in SubworldEngine::tick(dt) (pure integrate + reap, no ECS churn), and
in prepare_frame packs the live pool into a reused scratch buffer and hands it
to Renderer3DVk::stage_particles(). Emitters stay universal ECS components;
the combat / spell ticks feed the pool, so there is no per-effect hardcoding at
the call sites.
One table, no hardcoding. A FxKind enum + a constexpr kFxPresets[] table
(mirrors the monster/loot/NpcTypeDef "one table" rule) is the single source of
truth for every effect's count, speed, gravity, lifetime, size envelope, drag,
colour and spread. Adding an effect is one table row, not code at the spawn site.
Upload path mirrors the NPC dynamic-instance pattern exactly:
stage_particles() clamps the count to kMaxParticleInstances (2048), then a
per-frame vkCmdUpdateBuffer + TRANSFER_WRITE → VERTEX_ATTRIBUTE_READ barrier
into the device-local instance buffer. The 2048 ceiling is not arbitrary: at 32 B
per ParticleInstance it is exactly the 64 KiB vkCmdUpdateBuffer per-call
maximum. A static_assert pins sizeof(ParticleInstance) == 32 and
kMaxParticleInstances == ParticleSystem::kMaxParticles so the vertex-attribute
layout and the pool size can never silently desync.
The billboard. shaders/particle.vert is a centred,
fully camera-facing quad — it needs both camera axes (camRight and
camUp in ParticlePush), unlike the cylindrical tree billboards that pivot on
world-up, because a spark has no up. Three instanced attributes: vec3 pos,
float size, vec4 colour (rgb + envelope alpha).
shaders/particle.frag is a soft radial spark: a
squared 1 − r falloff from the quad centre, alpha-premultiplied, whitening the
core (mix(colour, white, fall·0.35)) so dense cores read as white-hot; it
discards once alpha drops below a threshold.
The pass self-skips when the pool is empty (particleCount_ == 0, the common
case) → zero cost when nothing is emitting. This is why every non-FX scene today
is unchanged.
Verify it headless with GPU_SMOKE_FX=1 (see §Frame capture): it stages a
standing additive burst over the cluster centre. Paired with GPU_SMOKE_NIGHT=1
it produces an unmistakable warm bloom against the dark forest; the A/B proof
(FX on vs off, same camera) shows only brightening — zero pixels darken,
because additive can only add — with the core saturating from near-black night
ground to (255,255,254). Midday shows no blowout (0 % near-white pixels).
The first gameplay emitter. Every flying spell bolt now sheds a glowing wake as it travels and blooms an impact burst where it detonates — both tinted by the bolt's own colour, with zero per-spell branching.
Routing keeps spell_effects.cpp renderer-free. The spell tick already used
C-style void* user callbacks (SpellDamageLogFn, SpellCanHitFn) so the pure
combat TU never sees engine/renderer types. Inc B adds one more in the same shape:
SpellFxEmitFn(user, event, entity, ax,ay, bx,by, blastRadius). The tick fires it
at two points — a Trail event after each bolt-advance (with the tile-space
segment prev→pos the bolt just crossed) and an Impact event at a hit or an
explode-on-expiry (ax,ay == bx,by, the detonation point). entity is always the
bolt, still valid at both call sites (fired before it is reaped), so the engine
can read its Sprite for the colour. spell_effects.cpp stays free of every
particle / renderer symbol; the engine's static spell_fx_emit_callback is the
only place the two worlds meet.
Colour comes from the bolt, not a lookup. The callback reads the bolt's
Sprite{r,g,b} (u8) and normalises it so the peak channel → 1.0, hue
preserved — the same formula bolt_light() uses for the bolt's point-light
(content/spells/effects.cpp), so wake, dynamic light and burst all share one hue. No Sprite
(e.g. a bare meteor) ⇒ fall back to the FxKind preset colour. A fireball trails
warm orange, a frost bolt icy blue, an arcane charge violet — all from data the
bolt already carries.
Archetype comes from one physical field. The impact picks its preset from the
bolt's blastRadius: > 0 ⇒ an explosive FireBurst bloom whose scale grows
(gently, capped 1..3×) with the blast; == 0 ⇒ a crisp MagicBurst point-pop.
That is the same field the blast-damage path keys off, so the visual can never
disagree with the mechanics — a fat AoE fireball erupts big, a single-target charge
pops small, with no per-spell table.
The trail is stateless and framerate-independent. A transient bolt lives only
for the tick, so it carries no caller-owned accumulator. emit_streak(kind, a, b, spacingM, tint) lays one mote every spacingM metres of travel (here 1.5 m),
interpolating along a→b — density is per-metre, not per-frame, so a 280 u/s and a
400 u/s bolt read as the same continuous wake. It caps at 64 motes per call
(a huge single-tick jump can't flood the pool) and degenerates to a single head
mote when the segment is shorter than the spacing. The endpoints are converted
tile→world and seated at the bolt's actual Position.z (the projectile's true
world-space altitude) so the wake rides exactly where the bolt is — a bolt fired
upward trails upward, not along the ground.
Verify it headless with GPU_SMOKE_FX_TRAIL=1 (optionally GPU_SMOKE_FX_VIOLET=1
to recolour). The harness doesn't link particles.cpp; it hand-stages an
Inc-B-shaped layout (a fading 16-mote line into a 4-ring head burst) through the
byte-identical shipping particle pass, so the A/B isolates exactly the additive
draw. Night A/B (trail on vs off, same camera): zero pixels darken (additive
can only add), and the mean added RGB is warm for fire (R>G>B) vs violet for
arcane (B>R) — the tints are measurably, and visibly, distinct.
City walls and houses render as instanced oriented boxes, and round bodies
(wall towers, gate jambs, the spire) as instanced 12-sided prisms — the same
geometry-from-gl_VertexIndex trick as the trees/water, so one
vkCmdDraw(36, structCount) plus one vkCmdDraw(108, cylCount) draw the whole
settlement with no geometry vertex buffer. The per-instance buffer supplies
{vec3 centre, vec3 halfExtent, type, seed, yaw} (Structure::Shape splits the
set into the two buffers); struct.vert builds a unit cube
with outward face normals, scales it by the per-axis half-extents and rotates
it about the vertical by yaw — houses stand at random orientations with
independent width/length, wall pieces follow the ring's curvature as yawed
chords. struct_cyl.vert generates the prism (sides +
top cap) for the round bodies. struct.frag is shared by
both, lit by the shared sun + ambient + PCF shadow; both shapes cast
(shadow_struct.vert /
shadow_cyl.vert) and receive shadows.
Material is a column, not a flag. The instance's type is the prop's
StructureKindRow::Material index (map_data.h), and the
fragment stage has one branch per row — masonry, house body + roof band,
plank timber, a door's framed leaf with its handle, a lantern's burning head, a
banded chest, a cave mouth's near-black opening, a well's waterline, a painted
board. Giving a new prop its own look is a row there and a branch here; there
is no second pipeline and no atlas. It used to be a single wood/stone BOOL that
really meant "draw me like a house", which is why every door and every bed
wore the house's red roof across its top and a door was invisible on a
reddish wall.
Two things make those patterns hold their shape. The branches are ordered by
ascending index (a chain of > thresholds silently drifts out of step with
the enum — a well once drew as a signboard), and both vertex stages emit
face-local [-1,1]² (vFace): a pattern in world metres stretches with the
box and slides when the prop moves, so a door's frame and handle are drawn in
the face's own space. The cylinder stage maps its angle onto the same range —
the two stages share one fragment stage, so a varying missing from either is
undefined data in the shader.
Seating comes from the shared helpers in
src/sub/map_data.h (structure_half_x/y,
structure_visible_height, structure_solid_span): grounded bodies keep the
legacy centre-at-terrain seat (lower half buried — hides the downhill gap on
slopes), while a zBase-lifted body (a gate lintel) floats with its bottom at
terrain + zBase. The SAME helpers drive the collision index
(src/sub/collide.h), so the visible silhouette is exactly
the volume that blocks, carries and stops bodies — see battle-steering /
sub/collide.h for the movement side.
Extensible by design: a new structure kind is one more type value + one
branch in the fragment stage; a new silhouette is one more Shape + one
vertex-pull shader — no new material, no new instance layout.
Renderer3DVk::upload() rebuilds the terrain mesh + material image from the
seamless manager. It runs load-time / on seam-cross only, never per frame,
and is scoped by a CompositeDirty struct so a boundary crossing costs
O(new content) instead of a full 3072² rebuild. Full design +
verification + gotchas are in seamless-crossing.md,
and what a new feature may cost the seam is in problems.md entry 15;
the renderer-side mechanics in brief:
- Three modes, chosen from
CompositeDirty: full (first upload, height smooth, or the two-crossing fallback), shift (a re-centre), per-cell (an async worker drain stitched one 1024-tile cell at a time). - Height (shift) — the persistent
Nv×Nvvertex gridheightVtxM_is slid in place bystd::memmove(toroidal, in vertices), then only the clamped 1-vertex border ring + the fresh cells are resampled. The vertex buffer is rebuilt whole from the grid (trivial) so all normals stay correct. - A placeholder cell is a CONSTANT, and is treated as one. A freshly exposed
cell holds one height and one tile id across all of its 1024²
(
LoadedCell::heightIsFlat, tracked by the manager and cleared by any write of real terrain). The height path writes the value straight into the interior vertex block and samples only the four shared edges; the material path resolves the cell to one byte (a memset) — or, if its height lands inside the treeline dither band, to two bytes chosen per tile by the hash. Together that is 3.10 → 0.19 ms of height and up to 19.7 → 2.60 ms of material on a crossing. - Instance buffers are REUSED, not re-created. Trees, boxes and cylinders keep
their device-local allocation and are overwritten in place, growing by half
again when the set outgrows them;
*Count_bounds the draw, so spare capacity is never read. Measured: the CPU loop that builds ten thousand tree instances costs 0.08 ms, and 87-97 % of the old cost was the buffer churn around it. - Material (shift) — a GPU ping-pong: two R8 images
(
materialTex_ ↔ materialTexAlt_). OnevkCmdCopyImagerelocates the unchanged 6/9 (axis) or 4/9 (diagonal) overlap on the GPU,vkCmdCopyBufferToImagefills only the fresh cells, then the two imagesstd::swapandmatFront_flips between TWO descriptor sets written once at image birth — no descriptor is ever rewritten at runtime (gpu::blit_shift_r8_recorded, vk_texture.cpp). Valid becausematerial_new[cell] == material_old[shifted-from cell]over the overlap.
The paragraph this section used to end with claimed upload() ran "at a fenced
point". It did not: the seam path performed five-to-seven blocking
submit+vkQueueWaitIdle round-trips per crossing INSIDE the open frame (each a
full queue drain — §20's sin, at the seam), destroyed grown instance buffers the
in-flight frame could still read, and rewrote the material set in place (audit
III.9/III.14). The contract is now real, and it is barriers, not stalls:
upload()is the CPU stage only. It runs in the sim tick (overlapping the GPU's previous frame), fills persistent scratch, and queues ops inPendingGpu.flush_uploads(cmd)records the GPU writes onto the frame's command buffer (first thing inprepare_frame) through a per-frame-in-flight staging arena (host-mapped ring, 8 MiB po2 floor, grows on demand) — the same contract the light field already used.- Ordering: one queue-scope
VERTEX_INPUT→TRANSFERexecution barrier per frame covers every write-after-read against the frame in flight (including the NPC/creature/particlevkCmdUpdateBufferpaths, which previously had no guard and survived only because the blocking submits drained the queue);TRANSFER→VERTEX_INPUT|INDEX_READcovers this frame's read-after-write; images keep their queue-scopeFRAGMENT_SHADER→TRANSFERlayout transitions. - Destruction goes to the graveyard (
VulkanDevice::defer_destroy): handles park forkGraveyardDelayFramesand die after their fence provably passed — nevervkDestroyBuffermid-flight, nevervkDeviceWaitIdlemid-frame. - Consecutive uploads between flushes MERGE (a skipped frame on resize, or a
smoke that ticks framelessly): absolute scratches overwrite, cells OR, a full
write supersedes, and a NEW shift landing on unflushed material degrades that
round to a full rebuild —
CompositeDirty::merge's own fallback, one accumulator downstream. A drain cell landing inside a shift's relocated overlap is applied AFTER the blit (overlapping transfer writes in one batch are unordered). - Frameless smokes must drain explicitly —
SubworldEngine:: debug_flush_gpu_uploads()(one-shot fenced submit); without it every upload sees unborn GPU buffers and degrades to the always-correct full path, and the incremental machinery under test never runs (subworld_seamdoes this).
Result: the crossing's CPU stage dropped 5.5 ms → 1.35 ms and the GPU copies
now overlap the frame instead of draining the queue; frames are byte-identical
(md5) to the blocking build, and the GPU-readback self-checks
(TIMAERT_SEAM_SELFCHECK, which switches the material shift to the blocking
twin so the readback sees executed results) still prove the incremental result
identical to a full rebuild.
All matrices + lighting travel as push constants (VERTEX | FRAGMENT).
| Pass | Struct | Bytes | Contents |
|---|---|---|---|
| Macro synth | Push (macro.frag) |
32 | resolution, mapSize, viewCells, seaLevel, seed, time |
| Sky | SkyPush |
224 | forward+moonCount, right+starScale, up, (resX,resY,fov,tod), (fogRGB,time), sunDir, 3×moon(dir,size), 3×moon(tint,illum), (cloudiness,wind,precip), seasonTint |
| Terrain | MeshPush |
176 | mvp, sunDir, sunColor, ambient, lightMvp |
| Trees | BbPush |
176 | mvp, camRight, sunColor, ambient, lightMvp |
| Structures | MeshPush (reused) |
176 | mvp, sunDir, sunColor, ambient, lightMvp |
| Water | WaterPush |
128 | mvp, camPos, sunDir, sunColor, (time,ambient,waterLevel,extent) |
| Precipitation | PrecipPush |
32 | (resX,resY,time,precip01), (kind,tilt,flash,·) |
| Shadow (mesh) | ShadowPush |
64 | lightMvp |
| Shadow (trees) | ShadowBbPush |
80 | lightMvp, lightRight |
| Shadow (struct) | ShadowPush (reused) |
64 | lightMvp |
| Shadow (bodies) | ShadowBbPush |
80 | lightMvp, lightRight |
Portability. MoltenVK allows 4096-byte pushes, so 176 B is fine on macOS. AMD desktop caps
maxPushConstantsSizeat 128, so before broad Windows/GPU support the per-frame matrices (mvp / lightMvp) should move into a per-frame UBO, keeping only small per-draw data in push constants.
Pipeline factory — vk_pipeline.h
One gpu::VulkanPipeline type, three constructors:
| Method | Use | Notes |
|---|---|---|
create() |
Fullscreen fragment passes (macro synth, sky) | No vertex input; depth-stencil state present but disabled (valid against the depth render pass); optional descriptor-set layout |
create_mesh() |
Terrain, trees, structures, bodies, water | Vertex input binding (rate VERTEX or INSTANCE); depth test/write, optional blend, optional back-face cull, one or more descriptor-set layouts. vertexStride == 0 ⇒ no vertex input (geometry from gl_VertexIndex, used by water) |
create_shadow() |
Depth-only casters | No colour attachment; optional descriptor-set layout for alpha-tested sprite casters; used inside the shadow pass |
Adding a pass = pick the right constructor, add the SPIR-V pair to the glslc
foreach in CMakeLists.txt, record the draw in the correct
phase. No new pipeline abstraction needed.
The renderer can dump a rendered frame to a PNG so an agent (or a human) can look at the actual image instead of trusting that a smoke "passed". It is test/tooling only and degrades to a clean no-op where unsupported.
- Swapchain — vk_swapchain.cpp adds
VK_IMAGE_USAGE_TRANSFER_SRC_BITto the presentable images when the surface supports it and records that inVulkanSwapchain::transferSrc; capture is a no-op when absent. - Renderer — vk_renderer.h: arm with
request_capture()beforeend_frame();end_frame()copies the presented image into a persistent host-visible buffer inside the same command buffer; drain withtake_capture(px, w, h, fmt)afterend_frame(). Pixels come back in the swapchain's native format (BGRA on MoltenVK). - App smoke — main.cpp
write_smoke_frame_png()swizzles BGRA→RGBA, forces opaque alpha, and writes viastb_image_write. Triggered from a smoke script by thecapture_frameaction token.
Environment knobs (all opt-in; the smoke composes them, so one run can pose the camera, set the hour, and dump a frame):
| Env var | Effect |
|---|---|
TIMAERT_SMOKE_SCRIPT |
comma-separated action tokens; include capture_frame to dump |
TIMAERT_SHOT_PATH |
output PNG path (else /tmp/timaert_shot_<NN>_<label>.png) |
TIMAERT_SMOKE_SEED |
world seed |
TIMAERT_SMOKE_HOUR |
force the game clock to 0..23 (picks day vs night lighting) |
TIMAERT_SMOKE_YAW / _PITCH |
camera aim in degrees (yaw 0 = +X) |
TIMAERT_SMOKE_SUBPOS |
teleport the player to "x,y" in the subworld |
TIMAERT_SMOKE_WATERSCAN |
report the longest east–west open-water run (find a coast to stage the moon road) |
cast_bolt_capture (action) |
cast a spell, assert the bolt spawned with a LightEmitter, optionally fly it clear, then arm capture in the same step |
TIMAERT_SMOKE_SPELL |
which spell cast_bolt_capture casts; default fireball |
TIMAERT_SMOKE_BOLT_FLIGHT |
seconds to fly the bolt clear of the caster before the shot (clamped 0..0.30) |
light_probe_capture (action) |
spawn one actor (a procedural creature or a drawn-art humanoid like guard), relocate it a fixed distance straight ahead, aim the camera down at it, hold it pinned for a few settle frames, then arm capture — a deterministic actor-in-point-light frame. The settle hold is load-bearing: staging runs in tick_smoke_script, which fires after the frame's 3D scene is already recorded, so a same-tick capture would photograph the pre-staging frame (the actor and any light strip only reach the ECS next frame). Stage-then-settle-then-capture is the general rule for any harness action that mutates the ECS and then wants to photograph the result. |
TIMAERT_SMOKE_PROBE |
which actor light_probe_capture spawns; default wolf; guard stages the Inc 9 carried torch |
TIMAERT_SMOKE_PROBE_DIST |
metres ahead to place the probe actor (clamped 1..15); default 7 |
TIMAERT_SMOKE_NO_PLAYER_LIGHT=1 |
strip the player lantern before the probe shot (isolate the actor's own light from the lantern pool) |
TIMAERT_SMOKE_SOLO_PROBE_LIGHT=1 |
strip every LightEmitter except the probe actor's — the airtight isolation: the scene is lit by exactly the probe's own carried light, or by nothing. A guard frame then shows a single warm pool; a peasant at identical staging is dark. |
Example — a night frame looking back along the moon's bearing:
TIMAERT_SMOKE_HOUR=1 TIMAERT_SMOKE_YAW=180 TIMAERT_SHOT_PATH=/tmp/moon.png \
TIMAERT_SMOKE_SCRIPT="new_game,wait_boot_done,subworld_enter,capture_frame,quit" \
./build/timaert
The app smoke self-terminates on the quit token. (The separate GPU harness
gpu_smoke3d instead auto-exits after GPU_SMOKE_FRAMES
frames, default 600; GPU_SMOKE_FRAMES=0 is its unbounded interactive mode.)
gpu_smoke3d offscreen-style capture. The shipping window can stall in
CAMetalLayer nextDrawable when launched head-less / backgrounded (no compositor
drains the swapchain, so its presentable-image pool starves on the very first
present). The GPU harness renders the same billboard / NPC / mesh shaders
bound to the same set-0 light SSBO but presents in a tight self-terminating
loop that never starves, so it is the dependable path to a LOOK-able point-light
frame. It carries its own opt-in capture knobs (all default OFF ⇒ the buffer is
count = 0 and the frame is byte-identical to the pre-point-light harness):
| Env var | Effect |
|---|---|
GPU_SMOKE_LIGHT=1 |
inject one warm point light ({1.00,0.72,0.42}, r 6 m) at the NPC/tree cluster centre, straight into the set-0 SSBO exactly as gather_point_lights() would |
GPU_SMOKE_LIGHT_WATER=1 |
scan the heightmap for the deepest (submerged) cell, aim the camera to graze low across it, and — when GPU_SMOKE_LIGHT is also on — place the light over that cell, so water.frag's reflected glint (point_lights_spec()) is staged. Kept independent of GPU_SMOKE_LIGHT so LIGHT_WATER=1 LIGHT=0 gives a pixel-comparable same-camera control on the water |
GPU_SMOKE_NIGHT=1 |
pin time-of-day to deep night so the point light is the only warm source in frame |
GPU_SMOKE_FX=1 |
stage a standing additive-particle burst (warm fire cloud, 96 emissive cards) over the cluster centre, mirroring the shipping particle pass byte-for-byte (same shaders, attrs, additive/depth flags). Default OFF ⇒ particleCount = 0 and the frame is byte-identical to before. Pair with GPU_SMOKE_NIGHT=1 for an unmistakable glow |
GPU_SMOKE_FX_TRAIL=1 |
stage the Inc-B spell-bolt layout instead of a standing cloud: a fading trail line running into a 4-ring impact-burst head, warm-tinted (1.0,0.55,0.15). Same byte-for-byte particle pass. Implies the FX pass on (no need to also set GPU_SMOKE_FX) |
GPU_SMOKE_FX_VIOLET=1 |
recolour the GPU_SMOKE_FX_TRAIL layout to arcane violet (0.75,0.45,1.0) — the same geometry, so a fire/violet A/B isolates exactly the tint path |
GPU_SMOKE_SHOT=<path> |
write the frame to PPM at <path> then exit 0 |
GPU_SMOKE_SHOT_FRAME |
which frame to capture; default 90 |
Example — the Inc-6 billboard-point-light proof (warm pool on the tree/NPC
billboards + ground against a cool moonlit night; the negative control
GPU_SMOKE_LIGHT=0 shows the identical scene with no pool):
GPU_SMOKE_LIGHT=1 GPU_SMOKE_NIGHT=1 GPU_SMOKE_SHOT=/tmp/bb_light.ppm \
GPU_SMOKE_FRAMES=200 ./build/gpu_smoke3d
Example — the Inc-A additive-particle proof (warm bloom against a moonlit
night; the negative control GPU_SMOKE_FX=0 shows the identical scene with
no glow — a pure A/B that isolates exactly the additive pass):
GPU_SMOKE_FX=1 GPU_SMOKE_NIGHT=1 GPU_SMOKE_SHOT=/tmp/fx_on.ppm \
GPU_SMOKE_SHOT_FRAME=80 GPU_SMOKE_FRAMES=90 ./build/gpu_smoke3d
Example — the Inc-B spell-trail proof (warm fireball wake + impact bloom vs the violet arcane recolour; both against a moonlit night — a fire/violet A/B that isolates exactly the bolt-tint path):
GPU_SMOKE_FX_TRAIL=1 GPU_SMOKE_NIGHT=1 GPU_SMOKE_SHOT=/tmp/trail_fire.ppm \
GPU_SMOKE_SHOT_FRAME=80 GPU_SMOKE_FRAMES=90 ./build/gpu_smoke3d
GPU_SMOKE_FX_TRAIL=1 GPU_SMOKE_FX_VIOLET=1 GPU_SMOKE_NIGHT=1 \
GPU_SMOKE_SHOT=/tmp/trail_violet.ppm \
GPU_SMOKE_SHOT_FRAME=80 GPU_SMOKE_FRAMES=90 ./build/gpu_smoke3d
- 3D relief must match the 2D map. The 2D view is the map / minimap (the macro synth — shaders/macro.frag) and must stay beautiful; the first-person 3D relief has to read as the same world, especially mountains. In the game both views consume the same macro heightmap, so the correspondence is structural — keep it that way (never invent 3D relief the map does not show). Mountains are the sensitive case: tall and readable, never spiky aliasing.
- Everything is extensible. More biomes, landmarks and features are expected,
and each adds render passes + context. Keep passes data-keyed (biome id,
structure
type, feature id) so growth is new table rows / newtypevalues, not new engine branches.
Do not cite these as current visual evidence:
- Terrain surface synth — the material id is now sampled per-fragment from
the full-resolution tile grid (see §Terrain and trees), so roads/fields/
shorelines are crisp and connected. What remains is the per-material surface
texture: it still reads "fabric-like" (a woven micro-pattern) rather than
natural ground. This is a
groundColorsynth polish, not a data-routing gap. - Richer structures — walls + houses render as lit, shadowed boxes; pitched
roofs, bridges and arbitrary
Structuremeshes still map onto the same pass.
The 2D view is the map / minimap, not a separate tile renderer to port — it is already the macro synth (shaders/macro.frag); the first-person 3D view is the subworld renderer.
See vulkan.md for the backend module map.