Skip to content

Latest commit

 

History

History
1066 lines (930 loc) · 66.2 KB

File metadata and controls

1066 lines (930 loc) · 66.2 KB

Rendering — Timaert (Vulkan)

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).


Frame structure

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]
Loading

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.


Dynamic lighting

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 directionsunAng = (tod − 0.25)·2π; sunDir = (cos, sin, 0). Shaded receivers use this as L, the direction from the world toward the sun, for N·L. Do not negate it when filling MeshPush::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 smoothstep of 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 same sunDir/sunColor slot the sun uses: as the sun sinks, the moon's authored tint (cooled, × kMoonDirGain 0.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 -sunDir decree. On an all-new-moon night strength01 is 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 by point_lights() in lighting.glsl additively over lit_surface(): col += base · Σ light.color · gain · atten · N·L, with a smooth quadratic atten = clamp(1 − d/radius, 0, 1)². The sum is unshadowed (the sun shadow map does not gate it) and returns exactly vec3(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 the N·L term above. Camera-facing billboards (trees, NPCs, creatures) have no meaningful per-pixel normal, so they use point_lights_flat() — the same buffer, gain and point_light_atten() curve but distance attenuation alone (no N·L). Dropping N·L is deliberate: a chest-height torch gives N·L ≈ 0 on an upright card and would leave an actor dark while lighting the ground at its feet — visually incoherent. This mirrors how lit_surface() already feeds billboards a flat sunTerm instead 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 form point_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 share point_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's gather_point_lights() packs every view<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 terrain vWorld (tile_to_world for XZ, Position.z for 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_element on 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 in point_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 in record_main after acquire_frame reset 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 only PlayerTag, 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 a LightEmitter built by bolt_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's Position, 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_capture reports lit=1 and, 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 to 0 — 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-local maybe_emplace_carried_light helper copies those fields verbatim into an ecs::LightEmitter at every humanoid spawn site — settlement population, the player squad and macro→subworld projection (src/sub/spawn.cpp) plus the console / encounter path spawn_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 universal gather_point_lights() path as the lantern and the bolts with zero renderer code. The helper is deliberately duplicated per-TU (mirroring maybe_emplace_missile_attack), not shared. The spawn-layer wiring — opt-in data contract, verbatim copy, +Y seating, and only the lit types getting an emitter — is pinned by carried_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-locked guard probe vs a peasant probe 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 its LightEmitter.

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_heightM is 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 rides terrainParams.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 into body.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 from gpu_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.


Macro night lighting (2D map)

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's LandmarkDef.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 the kMacroGlowCeil clamp/encode.
  • Upload is surgical: vk_macro_renderer::upload_light_field rewrites 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.


Shadow mapping

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.

Light matrix — two levels, one law

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).
  • FarkShadowFarRadiusM (±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.

Casters

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.

Receivers (PCF sampling)

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.

Extending shadows

  • New caster — add a create_shadow() pipeline for the mesh and draw it inside shadowMap.begin()/end(). Give it the same lightMvp (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 as lightMvpFar), #include "shadow_common.glsl", multiply the sun term by shadowFactorHandoff.
  • Softer shadows — the PCF spread knob is TIMAERT_SHADOW_SPREAD_* in shadow_common.glsl; the cascade pair already exists (near/far above).

Sky and stars

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 and trees

  • 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}, where uv is the normalised grid position (0..1). Ground colour is a procedural per-biome synth (shaders/mesh.frag groundColor/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) in upload() — one texel per world tile, independent of the terrain tessellation — and the fragment stage looks it up at vUv.

    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_tileGrid lookup (v_uv = a_pos in 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 each upload() (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 groundColor that still needs a pass.

  • Treesinstanced procedural billboards. One vkCmdDraw(6, treeCount) draws the whole forest: the quad corners come from gl_VertexIndex, and a per-instance buffer supplies {vec3 pos, halfWidth, height, species, seed} — both extents in metres, decided once on the CPU by tree_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 in Structure::height, its crown footprint in Structure::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.

  • Bodiesone 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 == 0xFF meant "NPC pass" (the field has since been renamed ecs::Sprite::spriteRow, components.h) — and paid for it with two pipelines, two shadow pipelines, two instance buffers, two fill loops and four draw blocks.

    kind carries both halves of the law (gpu::bb_body_kind): the low 16 bits are the bank slot or kBbNoSlot, 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.

Water

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.


Particles / additive FX

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).

Inc B — spell-bolt trails + impact bursts

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.


Structures (walls, houses, towers, gate lintels)

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.


Seam crossing (incremental terrain upload)

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×Nv vertex grid heightVtxM_ is slid in place by std::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_). One vkCmdCopyImage relocates the unchanged 6/9 (axis) or 4/9 (diagonal) overlap on the GPU, vkCmdCopyBufferToImage fills only the fresh cells, then the two images std::swap and matFront_ 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 because material_new[cell] == material_old[shifted-from cell] over the overlap.

The real fence contract (Session 19, 2026-08-11)

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 in PendingGpu.
  • flush_uploads(cmd) records the GPU writes onto the frame's command buffer (first thing in prepare_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→TRANSFER execution barrier per frame covers every write-after-read against the frame in flight (including the NPC/creature/particle vkCmdUpdateBuffer paths, which previously had no guard and survived only because the blocking submits drained the queue); TRANSFER→VERTEX_INPUT|INDEX_READ covers this frame's read-after-write; images keep their queue-scope FRAGMENT_SHADER→TRANSFER layout transitions.
  • Destruction goes to the graveyard (VulkanDevice::defer_destroy): handles park for kGraveyardDelayFrames and die after their fence provably passed — never vkDestroyBuffer mid-flight, never vkDeviceWaitIdle mid-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 explicitlySubworldEngine:: 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_seam does 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.


Push-constant layouts

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 maxPushConstantsSize at 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.


Frame capture (visual self-check)

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.

  • Swapchainvk_swapchain.cpp adds VK_IMAGE_USAGE_TRANSFER_SRC_BIT to the presentable images when the surface supports it and records that in VulkanSwapchain::transferSrc; capture is a no-op when absent.
  • Renderervk_renderer.h: arm with request_capture() before end_frame(); end_frame() copies the presented image into a persistent host-visible buffer inside the same command buffer; drain with take_capture(px, w, h, fmt) after end_frame(). Pixels come back in the swapchain's native format (BGRA on MoltenVK).
  • App smokemain.cpp write_smoke_frame_png() swizzles BGRA→RGBA, forces opaque alpha, and writes via stb_image_write. Triggered from a smoke script by the capture_frame action 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

Design requirements (standing)

  • 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 / new type values, not new engine branches.

What is not implemented yet

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 groundColor synth polish, not a data-routing gap.
  • Richer structures — walls + houses render as lit, shadowed boxes; pitched roofs, bridges and arbitrary Structure meshes 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.