Commit graph

60 commits

Author SHA1 Message Date
e72a4d0a13 Merge grass/far into main (gathering every branch; main's side kept on conflicts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:21:20 +03:00
737d6e1859 wip: uncommitted work gathered before the branch is merged into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:18:48 +03:00
9f07fc8a62 render3d: heightgen's plain and SMOOTH variants compiled (61 programs) - a smoothed or noise-only terrain had no height field on Vulkan; run-time defines as pure functions (program_defs.ludic) and manifest_test holds every program the source can ask for, literal or computed, to the manifest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:30:35 +03:00
c3fc896dea render3d: a renderer that cannot draw says so and stops - LUDIC_HOME's leading ~ expanded (an unexpanded one left a run without shaders, logging 27 missing variants and drawing on), the SPIR-V manifest ends with E <count> and a cut or miscounted one is refused, and every such failure is a fault: r3d_open / r3d_load_step fail on it, r3d_fault() / r3d_fault_exit(code) for the game; tests/manifest_test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:12:39 +03:00
9186abbbe3 render3d: the GPU blades grow by a painted density (a layer of the ground densities, grass_density_layer) where a map has one - a 5x5-tile window round the camera in a storage buffer the cull samples - and by today's rules where not; grass_rule_at is the rules on the CPU for the migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:14:22 +03:00
e908672034 render3d: a grass kind - the GPU blades' per-kind numbers are a GrassKind record the game hands over (grass_kind_set), not constants
GrassKind (grass_kind.ludic) carries what grass.ludic, grass_gpu.ludic, grass.vert, grass_cull.comp and
model.frag's BLADE path held as constants: the cell, s0 / d0 / radius, the row bands and rows, the blade
profile (neck, root, swell, tip, bend), the heights, clumps, widths and arch, where it grows (slope, snow,
the photograph's test), the root / tip / gone-to-seed colours, the far tufts, the root occlusion, the
roughness, the sheen and the wind. Its defaults are those constants exactly, so a game that sets nothing
draws as before. The shaders read them as u_gk_* (the cull as five more Params vec4s, 288 bytes);
grass_reset.comp writes each band's index count so a kind of other rows reaches the draw in order.
grass_quality holds the kind to a settings tier (never denser, never farther); R3D_GRASS_* still win.
grass_profile_w / _bend are the one profile, for the meshes and a game's preview. grass.mesh takes only
the cell: the rest of it is an older copy that already differs from grass.vert, left as it draws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:15:39 +03:00
f79390838a render3d: a glTF material's factors are drawn - base colour, roughness, metallic and emission
gltf_factors reads pbrMetallicRoughness.baseColorFactor, metallicFactor, roughnessFactor and the
emissiveFactor into the primitive (pr.fac), and prim_factors hands them to every program that draws its
textures (actors, the scatter's meshes and levels, the impostor bake) as 1 - factor, so a program never
given them - or a material that gives none (pr.fac null) - multiplies by 1 and draws as before. An
untextured material with a colour (or a metal/roughness) samples a pure white for it, so the factor is
exactly what it draws: horse_cornea is its own colour, not the 200 grey every untextured part had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:52:42 +03:00
c775e5b74e Merge branch 'r3d/tp-shd' into r3d/fog-wall 2026-09-29 17:14:08 +03:00
617ac10c5a render3d: the terrain's GPU maps paged round the camera while the tiles are on
At the cut the whole 4096 height, normal and photograph textures go, replaced by a coarse
2048 level (the CPU's tt_coarse uploaded; the normal and photograph blitted down on the GPU,
the photograph mipmapped) and a pool of fine tiles in three array textures - heights, normals,
photograph, each layer a tile with a one-texel border - addressed through a tt_n^2 page table
(u_tp_page: slot + 1, 0 = the coarse level). The pool holds the tiles within the reach (900 m,
or the fog wall's when nearer) and a ring, and is made again when the fog wall changes its size
(terrain_pages_fog). Once a frame (tp_frame, beside tt_frame) tiles past the reach and two tiles
go and the wanted ones come in nearest first, 8 a frame, written into a staging buffer kept for
the process (two halves, one per frame in flight) and copied into their layers inside the frame's
own command buffer - no submit of their own - with the page table re-uploaded only when it
changed. tp_bind binds the pool (or 1-layer stand-in arrays while paging is off, u_tp_on = 0) for
every program that reads the ground: terrain_bind_height (models, scatter, shadow_bind, grass),
terrain_bind_prog, the sun pass and the shadow bake. grass_cull.comp reads through the same page
table. R3D_VKMEM prints the pool's line. Tiles off, nothing changes. Compile-only: not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:13:11 +03:00
eee6a0906e render3d: the ground's maps read through a page table of fine tiles over a coarse map (shaders)
terpage.glsl is the reference block (tpSlot, tpUV, terHeight, terHeightSmooth, terNormalXZ,
terOrtho, tpOrthoRes, tpOrthoLod), pasted by section into terrain.vert, terrain.frag,
tersun.frag, model.vert, grass.vert and grass.mesh. u_tp_on = 0 reads the old samplers with the
old coordinates and filtering; on, a resident tile is read at level 0 from u_tp_h / u_tp_nrm /
u_tp_ortho, anything else from the coarse map now bound under the old names. The B-splines use
the FULL map's texel and take every tap through the page, so a tile edge stays one surface;
blurred photograph reads (lod 1-2.5) stay on u_ortho with the level moved down by the coarse
map's ratio. The fragment stages drop their unused u_height. SPIR-V rebuilt: terrain programs
carry 23 samplers (21 in the fragment stage), up from 19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:10:49 +03:00
329439cf61 render3d: under a fog wall the volumetric air is marched only to it, SSGI stops where it is solid, and card casters that fill the wall stop rebuilding
The volumetric pass marched 800 m in a fixed number of steps whatever the wall: it now stops at the
wall with steps in proportion (at least 8) - 0.57 -> 0.33 ms at 30 m by the pass timers. SSGI skipped
only past 900 m; it now also skips past 0.85 of a wall, where the fog is solid (u_ao_far). applyFog
samples the fog's colour only where its weight is above zero. A card layer whose casters inside the
wall are 80% or more of it (a kilometre's wall) casts from its static list and is not refiltered and
re-uploaded every 4 m of camera motion. Fog off: the frame unchanged (0 pixels over 8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:00:27 +03:00
c5e58d14ea render3d: the survey DEM is let go once the height field is made, and the terrain sun shadow is R32F + RG16F
The DEM (R16 with mips, 44 MB) was read only by terrain_generate and kept for the map's life; a
world swap loads its own again. The height-field sun shadow was one RGBA32F for three values and an
unused fourth: the lowest lit height stays 32-bit (a receiver 3000 m up compares against it to a
quarter metre), the occluder distance and the cloud mask go beside it in RG16F (64 -> 32 MB), baked
in a pass of their own (TS_AUX): a pipeline takes one colour format for all its targets, and drawn
together into R32F + RG16F the writes went nowhere and nothing was lit.

-75 MB at every fog level, off included (2008 -> 1933 MB at the overlook); the fog-off frame is
unchanged (0 pixels over 8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:48:26 +03:00
4d485a1778 render3d: the shadow array holds only the cascades a fog wall needs, and a thick fog takes the whole sky
A cascade that begins past the wall was fitted and cleared every frame for nothing: the array is now
made with the ones that begin inside it (2 at 30 m, 3 at 175 m, 5 without a fog) and made again when
the wall changes that count; shadow maps 80 -> 32 MB at 30 m, 48 MB at 175 m. A layer the array lacks
clamps to its last, read only past the wall where everything is fogged.

The sky: at walls of 110 m and nearer the whole sky is the fog's colour, overhead too, with a soft
glow at the sun or moon and no stars or clouds through it, easing out by 175 m; from 175 m on the
horizon band alone, and off as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:10:58 +03:00
f65bd1aca1 render3d: the fog wall is solid by 0.85 of it and the blades end at 0.75
Whatever is cut at the wall is now cut inside full fog: at 0.3-1.0 the last metres before the cut
were 80-95% fogged against fully fogged ground behind, which drew a line of tufts at 70 m and a
dark band of blade tips at 30 m. The blades thin out while the fog is still coming in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:41:25 +03:00
b9f9cb8495 render3d: r3d_fog_wall(dist) - fog that closes to the sky's horizon colour from 0.3 dist to dist, and nothing drawn past dist + 8 m
Every lit program blends toward the horizon colour (the prefiltered sky with the sun's inscatter,
so it carries the day's grade); the sky's horizon band is pulled to the same colour, wider the
closer the wall. The far plane, scatter and stream cull reach and the shadow cascades are clamped
to the wall; the reflection pass shares the shaders and the cull. 0 is off: every branch is gated
on u_fog_wall > 0 and r3d_reach hands back the far it was given. R3D_FOG_WALL=<m> for a shot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:07:40 +03:00
db7c21e5d8 render3d/overlay: the font atlas kept as its coverage, one byte a texel
The atlas is white glyphs on alpha and the overlay reads only the coverage,
but it was uploaded RGBA8: Maroon Lake's 3072 x 2688 atlas was 43 MB of Metal
memory with its mips, the largest single thing in the launcher window.
overlay_font now keeps the alpha alone as R8 (ov_font_tex; any other PNG
shape is uploaded as before) and overlay.frag reads .r - the white fallback
texture reads 1 there as it did from .a.

Launcher window, graphics regions: the atlas 43.0 -> 11.5 MB; process
footprint at 25 s 402 -> 378 MB. The headless launcher shot is identical to
the unchanged build's (0 pixels differ; two unchanged runs differ from each
other by 1.69%, and the new one by the same against them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:45:02 +03:00
6a82b4928b render3d/foliage: a small plant is occluded by the sward it stands in, so it no longer glows at night
The user saw kit grass, ferns and flowers lit up at night. Nothing was
emissive and every layer draw binds the scene's lighting; what differed was
occlusion. At night the sky's light is nearly all the light, and it is
scaled by ambient occlusion: the meadow's blades are heavily occluded near
the ground, while a kit plant's own AO map knows nothing of the grass around
it, so it took the full sky. By day the sun hides the difference.

model.vert hands on each vertex's height above the model's base (v_lh), and
a non-blade FOLIAGE plant under 2 m scales its AO from 0.35 at the ground to
1 at 0.9 m. Crowns and the blades are untouched.

Measured in the lab (R3D_AT=tree, same binary, old and new SPIR-V): the fern
against the meadow beside it was 2.05x at 23:00 and 1.66x at 13:00; now 1.58x
and 1.60x - the same relation by night as by day. The flowers' brightest
tenth at night 78 -> 52. The noon frame moves 8.4% of pixels past 8 (the fern
and flower bases a little darker; run-to-run noise is part of that).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:19:10 +03:00
3a2d594626 render3d/wind: trees bend a few per cent, branches move as one piece, the flutter is slower across a crown
The user's play found the aspens' branches swinging like rope. Three terms:
- the whole-tree bend reached 22% of the tree's height at a gust's top (three
  metres on a 14 m aspen); 4.5% now, and the side-to-side part at a quarter of
  that and slower;
- a branch term: nothing within 0.35 m of the trunk, growing with the distance
  out, phased by the direction the branch leaves the trunk (constant along a
  branch, so it moves as one piece and its neighbours are out of step);
- the aspen's leaf flutter took its phase from each vertex at eleven radians a
  metre, which bent every twig along its length; about three now, and half the
  amplitude.

Measured on the lab's `aspen` view (10-frame bursts, crowns only, same binary
with the old and new SPIR-V): pixels moving more than 8 over ten frames 16.8%
-> 9.7%, frame to frame 4.4% -> 2.1%. SPIR-V rebuilt with `ludic-dev shaders`
(vertex stages only; the manifest is unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:16:11 +03:00
1675eb451f perf(render3d): the terrain's height and normal in R32F + RG16F, 128 MB where one RGBA32F was 256
The first generation pass's R32F height is kept as ter_height_tex (not copied into an RGBA32F),
so every reader of the height - placement, the read-back, physics, selftest16's 9 mm - sees the
same 32 bits. ternormal.frag writes the baked normal's x and z into ter_normal_tex (RG16F), and
the six places that read it (terrain.frag twice, tersun.frag, grass.vert, grass.mesh,
grass_cull.comp, which takes a third texture at binding 6) rebuild y. SPIR-V regenerated.

Maroon Lake's play, headless Vulkan: 2793 -> 2647 MB. The camp's frame: 0.066% of pixels differ by
more than 8 (mean 0.024/255), isolated grass blades at the slope gate. ludic-dev test 307/307.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 02:31:18 +03:00
cc384efee0 feat(render3d): the meadow's blades are culled on the GPU - under 1.5 ms of a MoltenVK frame for all the grass
- grass_cull.comp decides each candidate blade once a frame (place, ground, density, water,
  slope, frustum, colour field) and writes survivors into three bands by distance, one indirect
  draw each; grass_inst.vert only bends and places the vertices. OpenGL keeps grass.vert's
  per-vertex path; R3D_GRASS_GPU=0 compares
- compute programs take sampled textures after their buffers (gpu_compute_tex / gpu_dispatch_tex),
  and every dispatch now records a compute-to-draw memory barrier
- the blades as a sward: 4 m cells, bands with five, three and one-quad blades, spacing doubling
  every 18 m to 70 m, never narrower than a pixel; lit facing the sun and leaning to the sky,
  shadow looked up above the ground (it read the terrain as its caster), a colour ramp that
  leaves only the sheath dark, clumps, dry patches and a tussock shade worked out per blade
- scatter layers flagged grass are skipped while the blades draw (and under R3D_NOGRASS);
  R3D_BLADES, R3D_NOBLADES win over the game's setting; R3D_GRASS_S0/D0 for measuring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:47:36 +03:00
38d4ea72b1 feat(render3d): Vulkan in a macOS window through MoltenVK; window built-ins take a slice's elements; the NEAR_FADE SPIR-V
- a CAMetalLayer on the view (cocoa.ll win_metal_layer), VK_EXT_metal_surface, QuartzCore linked
  with Vk.*; the drawable measured after the layer sets the backing scale
- vk_mac.ll opens MoltenVK directly after any loader: a bundle ships only libMoltenVK.dylib
- a covered window is not presented to (win_visible); one frame in flight on macOS
  (R3D_VK_INFLIGHT), with images, buffers and descriptor pools held until it is done
- win_held / win_mouse / win_pad / win_touch / win_text / win_present pass slice elements (arg_buf):
  every windowed program died on its first input poll
- variants.list and SPIR-V for the three NEAR_FADE foliage programs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:52:45 +03:00
e6f565a6c4 carry: the runtime and render3d work Maroon Lake builds against
Input.text, App.monitor_count / window_to_monitor / window_fixed,
gl_sleep_us, and the grass and collide changes, uncommitted on main and
depended on by the game; carried here so the language work starts from
what the game actually uses. main's working tree is untouched.
2026-09-23 15:55:02 +03:00
0998cb5a18 feat(compiler): a slice index is checked against its length
A slice has carried { ptr, len, cap } since it existed and nothing ever read
the len: every index emitted a bare getelementptr. Running off the end of one
wrote into whatever the allocator had put next, and the program died somewhere
else entirely - a maroon-lake crash took a day to find because the stack named
a texture upload and the write was in a telemetry buffer five frames earlier.

Now each index loads the length and compares unsigned, which rejects a negative
index in the same instruction, and a failure aborts with the location the other
located errors use:

    oob.ludic:9: index out of range: 7, len 3

`words` and the other raw buffers are unchanged - they are a bare malloc with no
length to check, which is the argument for moving off them.

The SPIR-V variants are regenerated in the same commit: shaders --check was
failing against the edited GLSL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:54:12 +03:00
18d23ca20d feat(render3d): a bole that does not quake, leaves out of the lens, a light with a reach
layer_flutter's mask was the vertex's height alone, so on a ten metre aspen every part of
the trunk above 1.2 m trembled with the leaves and the bole read as cloth. What separates
a leaf from a bole is distance from the model's centre line, not height; the mask is
radial now, and small plants keep the old one because a flower is all leaf.

NEAR_FADE dithers tree foliage out inside arm's length of the camera, in the depth
prepass, so the lit pass never sees those pixels and the equal-depth optimisation is
untouched. gl_Position.w is the view depth, so it costs one varying and no uniform.
Blades, cards and flowers are excluded - they live at the player's feet - and the shadow
pass keeps every leaf.

daylight_hand takes a reach in metres. The falloff was an inverse square windowed between
26 and 6 with both numbers hard-coded: two per cent of its own near field at ten metres,
so a torch lit your boots. It is a gentle power out to the reach now.

Measured in Maroon Lake, twice per side: the crown mask moves 1.7% of an aspen frame and
nothing at all in the meadow under it; the near fade moves 7.1% of a first-person frame
at a conifer and 0% where there is no foliage in the lens; the torch's reach lifts the lit
ground 20 to 45% and leaves the sky bit-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:06:50 +03:00
ab6f310013 feat(render3d): an aspen quakes, and a tree is no longer bent like a bow
layer_flutter(l, v) gives a scatter layer a per-leaf tremble. The vertex stage offsets
each leaf by a phase taken from its own place on the card, so neighbouring leaves are
never in step, and writes the result out as a varying the fragment stage uses to flash
the leaf's pale underside as it turns - which is the part that reads, since a still
frame of a tremble is a still frame of nothing. One uniform, one varying, no extra pass.

And the sway itself was measured in METRES: hgt * hgt * 0.35 is right for a 40 cm
flower and puts ten metres of sideways into a 14 m trunk, so every tall tree in the
valley stood bent over like a fishing rod. It is a fraction of the model's own height
now - the tip moves a few per cent of the tree whatever the tree is, and the base does
not move at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:13:42 +03:00
4975c60ac1 feat(render3d): the lake answers to a body standing in it
water.frag takes u_wade - a point and a strength - and puts spreading rings and a
patch of churn into the surface normals there, so a wader marks the water and a
swimmer works the whole of it. It is one uniform in a fragment stage that was
already running, applied after the distance flattening so a disturbance close to
the camera survives it, and it measures no frame cost at all.

Also records the two renderer features that shipped without a changeset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:23:27 +03:00
5ca14eeaec feat(render3d): contact darkening, so things sit ON the ground
The existing occlusion is tuned as AMBIENT occlusion: a wide radius answering "how open is
the sky here". That is the right question, and it leaves every object in the frame
hovering - because what says a log is ON the ground rather than in front of it is a hard,
narrow darkening in the last few centimetres where the two meet, and at a metre and a half
of radius that darkening is spread so thin it is not there.

A second, tight pass: eight taps inside 40 cm, which at any normal distance is a handful of
pixels and stays in cache, with a much tighter range check than the ambient one so a wall
across the room does not darken the floor in front of it.

Small by frame area, because contact occlusion is - 1.8% of the frame, 7% of the region
around the things it grounds. GL 7.0 -> 7.1 s, VK 7.3 s over 400 frames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:49:05 +03:00
031137a5fa feat(render3d): rain that leaves something behind
Rain fell and the ground did not change: the weather was a curtain of particles in front
of a dry valley.

u_wet, 0 dry to 1 soaked, does the three things a wet surface does. Darkens the albedo.
Drops the roughness hard - which is what makes a soaked meadow read as soaked rather than
merely dark, because the sky glances off it. And pools: water finds the low places and
flat ground holds what a slope sheds, so the puddle mask is the macro noise the materials
already use gated on slope, and a puddle takes the world's up for its normal rather than
the ground's relief.

It joins the shore's existing wet band rather than fighting it - the same term from a
different cause.

34.1% of the frame changes between dry and soaked. GL 7.1 s either way over 400 frames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:43:29 +03:00
6169cd05b9 feat(render3d): the grass knows a body is standing in it
You walked through a meadow and every blade ignored you, which is the most noticeable
thing missing from every step the game asks you to take.

u_push (x, z, radius) bends blades away from a body and DOWN - a trodden stem is shorter
as well as leaning, and leaving the height alone made them splay outward like a fan
instead of being walked through. Applied after the blade's own yaw has put it into world
axes and before it is tipped onto the ground normal, so the push is a world direction
rather than something in the blade's private frame.

Radius 0 means nobody is there, so nothing is paid for when it does not apply. Measured:
GL 6.9 s, VK 7.3 s over 400 frames, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:40:08 +03:00
9dff8f21bd feat(render3d): one wind, and everything in the valley is moved by it
The grass had a gust built from two sines; the trees had no gust term AT ALL, only a
per-instance wobble. So the meadow rippled and the canopy above it swayed to an unrelated
rhythm, and nothing ever crossed the valley.

wind.glsl is prepended to every stage (programs.ludic, and shaders.ludic for the SPIR-V
build) so grass, crowns and water read the same field at the same world position. It
declares no uniforms on purpose: u_time and u_wind already exist in several of those
files and redeclaring them is a compile error in whichever stage includes both.

The wavelength is what made it work. At 0.030 the front was 209 m crest to crest -
longer than the meadow you can see - so the whole frame sat in one phase and the gust
read as everything breathing together. At 0.085 it is 74 m and a front crosses a view in
a couple of seconds.

R3D_DEBUG_WIND=1 paints the field on the ground. It is the only honest way to show a gust
in a still image, and two shots 0.3 s apart move by 39/255.

400 frames: GL 6.9 s, VK 7.2 s. Backends agree to 0.68/255.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:24:43 +03:00
c5693db0e4 fix(render3d): the meadow is vegetation, and still water reads as water
Two changes that have been sitting uncommitted in this checkout, gathered as a recovery
point before the next run of work.

grass.vert: the blade existence gate tested green DOMINANCE - g - max(r, b) - which is a
test for lush green and nothing else. A dry alpine meadow is yellow-green, its red as high
as its green, so the meadow scored zero and was thinned to the floor - a QUARTER of the
blades - on exactly the ground that should be thickest. Measured on Maroon's own ortho,
g - max(r, b) reads +0.026 at the camp and -0.002 six hundred metres away, flipping between
full density and a quarter over continuous meadow; g - b reads +0.076 and +0.014 and
separates plant from rock and snow just as well, because rock and snow are neutral and
vegetation is not. Bare ground in the near band went 82% -> 57% looking down.

Blade height is biased short (h3 * h3) rather than spread evenly, so a meadow is a dense
mat with taller stems out of it; an even spread read as a lawn that had been cut.

water.frag: the planar reflection is returned at 0.72 of the scene's exposure rather than
all of it, the fresnel mix caps at 0.70 rather than 0.86, absorption falls so the bed is
visible through the surface at the angles a player stands at, and the ripple field is
roughly halved - so a sheltered lake is one sheet of water with a mountain in it instead
of open chop to the horizon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:07:01 +03:00
ae52e9c4da feat(render3d): anti-aliasing that exists, and a lens for the viewfinder
The shipping default had NO anti-aliasing at all: the temporal resolve was removed, the
setting's first option went on saying "Temporal", and MSAA defaults to one sample, so
every machine without DLSS drew grass and needle cards with nothing smoothing an edge.

FXAA in the sharpen pass, which already reads the neighbourhood and runs last on the LDR
image. No history, so it cannot drag or smear a reflection. 25.5% less single-pixel
staircase on edge pixels.

The trap, recorded because it inverted the result: the unsharp delta must be computed
from the RAW image and only then applied to the anti-aliased colour. Centre from FXAA and
neighbours from the raw texture measures half smoothing and half signal, so the mask
sharpens precisely what FXAA softened - 29% WORSE than no anti-aliasing at all.

Depth of field for the photo mode: a disc whose radius is the circle of confusion,
normalised by focus distance so a landscape shot is not a macro, with taps rejected
unless they are at least as out of focus as the pixel they blur into - which is what
stops a sharp foreground haloing into a blurred background. Between the scene and the
bloom, so a blurred highlight still blooms. Skipped whole when the aperture is shut.

400 frames in ordinary play: GL 7.1 s, VK 7.2 s - the lens does not draw there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 17:38:25 +03:00
182cbf3cc3 fix(render3d): a leaf is not matte
Foliage roughness was pinned to 1.0 and grazing Fresnel switched off, so nothing green
in the game had a highlight anywhere. The glint off waxy leaves and wet needles is most
of what makes a stand look alive rather than painted.

The reason it was off is real: a crown is card quads, and at a grazing angle the card's
normal is a lie, so a specular lobe frosted whole crowns white against the sky. The sheen
returns as its own term gated on exactly that - it fades as the card turns edge-on, which
is where its normal stops meaning anything. A tight lobe for the glint, a weak wide one
for the waxy rim, nothing at the angles that frosted.

Translucency reaches 260 m rather than 140, and a dense crown passes 0.45 of it rather
than 0.3, so a backlit stand glows for as far as you can see it.

400 frames: GL 7.1 s, VK 7.4 s. Backends agree to 0.14/255.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:41:33 +03:00
b22f65f598 fix(render3d): the Bells are maroon - saturation was the knob, not brightness
Correcting the previous commit, which said this was unfixed. It is fixed, and I had the
wrong knob: raising the mudstone tint's magnitude from 0.14 to 0.38 made the face LIGHTER
rather than REDDER and it came out pale tan. The shader's own DEBUG_ALB view measured
red:blue at 1.56 on the peak where maroon mudstone wants nearer 4, which says plainly that
what needed moving was green and blue DOWN, not red up.

The built-in DEBUG_MAT view is what settled it, and I should have reached for it an hour
earlier instead of inventing my own: it shows the Bells as pure red, rockW = 1, so the
face is rock and always was. That also explains why painting the snow albedo bright red
changed nothing and I wrongly read that as "this surface is not drawn by this shader" -
there is no snow on those faces to paint.

The far tier's fallback moves with it, so the cheap tier keeps the near tier's mean.

Backends agree to 0.60/255.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:36:39 +03:00
b5d13180a6 fix(render3d): the ground keeps more of its own colour, and the rock keeps all of it
55% of every ground pixel was the orthophotograph, at every distance and on every
material. A survey image records WHERE the forest and the scree are well and what a
steep rock face is coloured badly - it is shot through kilometres of air at an angle no
player stands at. The photograph keeps the meadow and the forest and gives the rock back
to the rock, by material and by elevation and never by distance: a distance fade used to
live here and was removed because the photograph has the day's own shadows baked in, so
grass grew dark patches as you backed away and they slid as you walked.

The far tier's rock fallback is the maroon mean, not a neutral grey - TFAST_3 skips the
rock sample and leaves that constant standing in for a whole mountain.

Ortho-classified snow needs altitude now. Bright and unsaturated is what snow looks like
from a satellite and also what a sunlit rock face looks like. Gully snow is gated on the
snow line rather than two absolute heights.

Distance desaturation eased 1.9 -> 1.15: tuned on a valley whose rock was grey anyway.

NOT FIXED, and I want it recorded rather than implied: the Bells themselves are still
not maroon. The near and middle rock is warmer and measurably so, but the distant peak
does not respond to ANY of this - not the rock tint, not the ortho weight, not the snow
line, not the gully gate. Painting sA bright red left it unchanged, so whatever surface
that is, it is not this shader's snow and not this shader's material blend. Somebody
should find out what draws it before tuning any of these numbers further.

400 frames: GL 7.0 s, VK 7.2 s. Backends agree to 0.62/255.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:29:17 +03:00
b12228c662 feat(render3d): light you can see - sun shafts and valley mist
Everything before this made the air a COLOUR APPLIED TO A SURFACE. Nothing put light
in the space between surfaces, so the basin had no shafts, no pooled mist and no rays
off a ridge at any hour, whatever was done to the fog.

A half-resolution march from the camera to the depth buffer, asking the same shadow
the rest of the frame asks - the cascades, the baked height-field shadow and the cloud
mask - so a shaft is cast by the actual trees and the actual ridge and a passing cloud
dims its own rays. Henyey-Greenstein scattering, because real air throws light forward.
Density and a separate ground-hugging mist layer ride the sun's elevation, so mist
forms in the cold at either end of the day and burns off by mid-morning.

Composited with the bloom pyramid's own tent upsample under ONE/ONE - what was wanted
and already there - and before bloom, so a shaft blooms. Into post_hdr, not post_scene:
post_scene is what the water refracts and shafts added there would sit under the lake.

The tuning that mattered was the sky term, which is added at every step: at 0.06 it
accumulated into a flat grey wash lifting lit and shadowed air equally, which is the
contrast a shaft is made of, and the valley came out one pale sheet. At 0.012 the sun
dominates and there is light rather than fog. I cut the density and mist three times
before the frame looked like air instead of paint.

R3D_NOVOL=1 for an A/B; Off in Settings skips the pass whole.

400 frames at 07:00: GL 7.1 -> 7.3 s, VK 7.2 -> 7.4 s. Backends agree to 0.08/255.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:06:16 +03:00
d34fb5bc63 feat(render3d): the air and the light are the hour's, not one constant apiece
Aerial perspective is a curve now. A low sun shines through far more air than a
high one and shines ALONG the ground rather than down onto it, so density, height
falloff and forward scatter all ride the sun's elevation; overcast thickens the air
and flattens the scatter, because a grey sky has no disc to scatter from. `lowsun`
falls away BELOW the horizon as well as above it, or the middle of the night gets a
dawn's haze with no dawn to justify it.

The term that was missing entirely is distance DESATURATION. Blending a saturated
green ridge toward a saturated blue noon sky leaves a saturated ridge - which is why
the same valley read as a photograph at dusk, where the fog colour happened to be a
warm grey, and as a toy at one o'clock. A surface is now pulled toward its own
luminance faster than the fog itself arrives. Measured far/near saturation at the
camp: 07:00 1.11 -> 0.89, 09:00 1.04 -> 0.93, 13:00 0.98 -> 0.89.

The grade is the hour's too - nine literals bound at the draw, written by
daylight_set now. Noon is the case worth naming: direct sun is warm-white and the
only thing filling a midday shadow is a blue sky, so noon gets a cool balance over a
blue-lifted shadow with hard contrast, and dawn and dusk the reverse. Ground R-B,
lit vs shadowed: 07:00 +42.8/+14.2 -> +48.9/+15.1, 13:00 +32.2/+14.8 -> +25.2/+2.9.
Gain is left alone deliberately: the grade is `c * gain + lift * (1 - c)`, so warming
it warms the whole frame, and warming it at noon made one o'clock yellower than seven
in the morning - the opposite of the point.

The visible sky is relit. Turning a photograph on its axis does not change what
colour it was taken at, so every sunset had a mid-morning blue overhead. An analytic
sky supplies the chroma and the photograph keeps the luminance: the cloud stays where
it is and goes orange at dusk, the zenith goes deep blue at noon, and no second sky
is shipped. It fades out under the horizon and eases off under cloud.

The ground bounce follows the ground, crossing meadow to rock at the map's treeline
instead of being one green constant everywhere including above the scree.

R3D_NOAIR=1 restores all of it, so a before-and-after comes from one binary at one
hour; it joins R3D_NOCLOUD / R3D_NOSHADOW / R3D_NOGI.

Verified on macOS OpenGL, macOS Vulkan (MoltenVK) and Windows Vulkan (RTX 3070 Ti).
Backends agree: mean difference 0.15-0.88/255 within a machine. Across machines the
ORIGINAL renderer already differed by 5.02/255 at 19:12 and this build differs by
2.80, so cross-platform variance is pre-existing and did not grow. 400 frames: GL
7.4 s before and after, VK 7.0 s before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:42:30 +03:00
fa6c422f9b feat(render3d): terrain_lake_carve - a height map may carry its own lake bed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:27:21 +03:00
6dfdee75e2 fix(render3d): a lake can be the mirrored water, with its own wet shore
A reflecting body is clipped to its ellipse like every other unless it is an
unbounded sea, so a map whose reflection belongs to its lake (Maroon Lake, once
its sea sits below it) does not draw that lake's level over every hollow in the
survey. The terrain's wet shore and its forest and scree gates read the carved
lake's line inside its outline (u_lake), the rule grass already used. SPIR-V
regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:15:28 +03:00
65c1b9ca5c feat(render3d): HDR calibration; fix the HDR toggle crash and yellow reading as red
r3d_hdr_calibrate(peak, paper, black) feeds the tonemap's and the overlay's HDR10 variants and
the display's HDR metadata; ov_hdr_nits draws a calibration patch at a number of nits.

gpu_caps_probe asks the running Vulkan renderer's instance instead of making and destroying a
second one under Streamline's interposer, which left the next swapchain rebuild calling address 0.
The overlay gets an HDR10 variant, and the tonemap brightens HDR highlights by one factor rather
than per channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:57:18 +03:00
750d13779d feat(render3d): mesh-shader grass
The chunked grass path draws each chunk as one mesh-shader dispatch when the
setting asks and the card has VK_EXT_mesh_shader: work group y is a tile, x a
batch of 16 of its blades, and a blade the placement, density, frustum, water
or slope tests reject emits nothing - the instanced path still runs its eight
vertices to a degenerate position. grass.mesh generates the same blades as
grass.vert (grass_blade_mesh(4)'s rows, the same hashes, sway and lighting
normal), capped at the instanced path's 65535 a tile.

Vulkan: VK_EXT_mesh_shader with meshShader, and maintenance4 (glslang's mesh
stages declare LocalSizeId); vkCmdDrawMeshTasksEXT looked up per device, as the
Streamline interposer exports none; a *.mesh program's pipeline takes the mesh
stage and no vertex input, its bindings the mesh stage bit. gpu_has_mesh,
gpu_draw_mesh_tasks; r3d_mesh_grass and R3D_MESH_GRASS / R3D_NO_MESH.
bin/ludic-dev rebuilt: the committed binary predated the shader tool's mesh
support and compiled grass.mesh as a vertex stage.

PC (RTX 3070 Ti): the camp matches the chunked path; validation only the
no-window present-id message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:20:42 +03:00
6525c11b3c feat(render3d): HDR10 output, and DLSS that stays still
HDR output: an HDR10 swapchain (A2B10G10R10, ST 2084 over BT.2020) when the
setting asks and the display offers it, with HDR metadata. The tonemap's HDR10
variant keeps the SDR picture up to a 200-nit paper white and rolls highlights
on to 1000 nits; the overlay's converts the interface to the same white. The
screen and LDR images go 10-bit with it; screenshots refuse while it is on.
OpenGL and the Vulkan SDR frame are unchanged. The instance asks for
VK_EXT_swapchain_colorspace. HDR metadata only where the loader has
vkSetHdrMetadataEXT: Streamline's interposer does not, and calling the thunk
crashed the game the moment the swapchain came up HDR10. PC 4K monitor: HDR10,
validation 0. R3D_HDR overrides the setting.

DLSS:
- the vertical jitter offset flips with Streamline's image (rows from the top):
  unflipped, Quality resolved the ground into concentric rings;
- preset K in every mode: the default M put Performance at 18 ms a frame at 4K
  on an RTX 3070 Ti (33 fps against 41 with DLSS off; with K, 60);
- the camera is jittered only while this frame holds a token and the last
  evaluate worked.
R3D_DLSS_PRESET, R3D_CAM_LOG (the camera and DLSS state a frame) and
R3D_NOGRAIN for measuring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:06:18 +03:00
44ecd55a86 render3d(vulkan): the ground reads the sun cascades itself - no separate terrain sun pass
tersun.frag rasterised every terrain patch a second time into a screen buffer, because on OpenGL the
cascade read inside terrain.frag fell off a driver cliff (about 6 ms a frame). Vulkan has no such
cliff: its terrain programs are the SUN_INLINE variant, which evaluates the same two tiers with the
same normal and cross-fade in the ground's own shader, and terrain_draw skips the pass - in the frame
and in the water reflection. OpenGL keeps the pass. R3D_SUN_PASS=1 keeps it on Vulkan, for comparing.

Mac Vulkan town 1759 -> 1692 draws; frames within 2/255 of the pass (15331 px, float rounding). PC camp
1984 -> 1882 draws, 3.9 s for 400 frames either way, self-tests 59/59, validation 0. OpenGL frames
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:26:39 +03:00
eb1cd3e01a render3d: HUD text, panels and images share a draw - the font stays bound and each vertex says what it reads
A panel and its label alternated the white and font textures, and every switch closed the overlay's
draw range: 77-91 ranges a frame in town. The font atlas is now bound beside the image texture for the
whole flush, and v carries 4 x the vertex's mode (0 image, 1 font, 2 flat colour) on top of its real
coordinate, so only a different image texture or a clip rectangle closes a range. Town: 24 ranges.
OpenGL frames byte-identical at all five viewpoints; MoltenVK with validation adds no message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:14:53 +03:00
cdfffa62bb render3d: grass in chunks of 256 tiles a draw, flower casters only into the cascades they reach
Grass (Vulkan with multi-draw indirect): every visible tile is a record in one buffer, uploaded once a
frame, and each band draws its records 256 at a time. A record's firstInstance is its place in the
chunk times 65536; grass.vert's TILES variant reads that place's corner and indices per cell from
u_tiles. R3D_GRASS_TILES=1 keeps a draw per tile. OpenGL is unchanged.

Casters: a LOD level with no impostor is drawn into a shadow cascade only when its distance band,
widened by six times its height, the camera's height over the ground and the frustum's corner reach,
can touch that cascade's receivers. The flowers' mesh levels (6 - 30 m) leave the three outer
cascades. R3D_CAST_ALL=1 draws every level everywhere. OpenGL frames byte-identical at all five
viewpoints; alpha-tested shadow draws at a 460 -> 244.

gpu_has_mdi() guards both this and the GPU-culled trees' multi-record draws.

Camp: Mac Vulkan 2645 -> 2191 (grass) -> 2034 draws; PC 2657 -> 2046, self-tests 59/59, validation 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:58:53 +03:00
d8300a7bf9 render3d(vulkan): GPU-driven trees - one merged mesh per material, one draw per material; on by default
Every level of a kit tree or rock carries the same materials, so a GPU-culled layer merges each
material's levels into one mesh (layer_arena_build) and scatter_cull.comp writes a record per
(material, level) naming that level's index and vertex range; one indirect draw covers them all.
A conifer's lit pass and prepass go from 8 draws to 2, its shadow LOD from 6 to 2. Layers that do
not fit (card levels, other materials or attributes, 32-bit indices) keep the CPU path.

GPU culling is now the Vulkan default (R3D_GPU_CULL=0 turns it off). Camp benchmark, 400 frames:
PC 2791 -> 2657 draws, 4.3 -> 4.1 s (both runs); Mac 2791 -> 2657, 8.0/7.4 -> 7.7/7.2 s.
Self-tests 59/59 on both machines, PC validation 0 errors; frames within run-to-run noise; OpenGL
frames unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:44:00 +03:00
5b28063b52 feat(render3d): texture filtering and shadow resolution a game can change while it runs
- r3d_set_anisotropy(level) updates every mipmapped texture already loaded (OpenGL parameter,
  Vulkan sampler record), not only later uploads; the game's setting never reached the scanned
  materials, which load before the settings are read.
- shadow_set_res(size) remakes the cascades at 1024 / 2048 / 4096 (shadow_res replaces the
  SHADOW_RES constant); lighting.glsl reads the texel size from the map, so OpenGL frames at
  2048 are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:25:55 +03:00
c1eb5f399f feat(render3d): compute and indirect draws on Vulkan, and tree layers culled on the GPU (opt-in)
- Device: multiDrawIndirect, drawIndirectFirstInstance and drawIndirectCount where present.
- Buffers carry storage and indirect usage; a GPU-owned buffer is never swapped under a draw.
- Compute programs from shaders/compute.list (binding 0 parameters, 1.. storage buffers),
  built by `ludic-dev shaders`; gpu_compute / gpu_dispatch / gpu_draw_mesh_indirect in gpu.ludic.
- R3D_VK_PROBE=1: a dispatch read back (OK on the RTX 3070 Ti).
- scatter_cull.comp: a tree layer's frustum test and LOD split on the GPU, with the lit, prepass,
  impostor and shadow-LOD draws reading its records. Behind R3D_GPU_CULL=1 and off by default:
  at the camp it is slower (43.0 fps against 53.3), because the frame's cost is per-draw
  descriptor sets and it adds empty-level draws. Validation-clean; OpenGL frames unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:19:43 +03:00
66940f9c0a fix(shaders): varyings meet by name on Vulkan, so the meadow's flowers draw
OpenGL links a vertex output to a fragment input by name; SPIR-V links them by location, and
glslang's --auto-map-locations numbered each stage in its own declaration order. The foliage
prepass's depth.frag declares v_wpos then v_uv where model.vert writes v_wpos, v_nrm, v_uv, so
the prepass read a normal as its texture coordinate, its alpha test cut every flower head and
leaf, and the lit pass (depth EQUAL) drew nothing over them. `ludic-dev shaders` now gives both
stages explicit locations: the vertex stage's out order numbers them and the fragment stage looks
each in up by name. All 45 variants checked: every fragment input sits on its vertex output.

Also:
- A clear still waiting for its pass when the framebuffer changes now runs on that framebuffer,
  instead of becoming the load op of whichever pass began next.
- R3D_DUMP_ATLAS writes every impostor and card atlas a run bakes (build/atlas_<n>_*.ppm); the
  40 baked on Vulkan match OpenGL's.

The PC's Vulkan frame now shows the flowers as OpenGL does, validation-clean. ludic-dev test 140
passed; OpenGL frames byte-identical at the five viewpoints; 59 self-tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:50:54 +03:00
a674c9b372 fix(render3d): Vulkan exposure, foliage depth and meadow alpha
Three things the first Vulkan frames on the RTX 3070 Ti showed against OpenGL on the same PC:

- Exposure: the adaptation pass reads the HDR scene's smallest mip, and a render target made
  without pixels had one level, so exposure came from a single texel. A target asked for mipmaps
  now grows a full chain (level 0 kept), and passes draw through level-0 views keyed by the
  image's generation.
- Foliage: the depth prepass and the lit pass (depth EQUAL) are different variants. Vulkan vertex
  stages now declare an invariant gl_Position so both land on the same depth.
- Alpha to coverage is enabled only on a multisampled pass. OpenGL ignores it without MSAA; Vulkan
  with one sample dropped every fragment under half alpha.

vk_resources also checks a big-endian 16-bit RGB upload (a normal map). VKRES OK; ludic-dev test
140 passed; the PC's Vulkan frame is validation-clean; OpenGL frames byte-identical at the five
viewpoints with 59 self-tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:29:24 +03:00