Commit graph

19 commits

Author SHA1 Message Date
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
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
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
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
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
3fb1b7cd87 feat(render3d): every shader variant as Vulkan SPIR-V, built ahead of time
Vulkan cannot compile GLSL when the game starts, so the programs render3d builds are listed
(shaders/variants.list, 45 of them, collected with R3D_PROGRAMS_LOG across the self-tests, the
screens, the viewpoints and the debug switches) and `ludic-dev shaders` compiles each into
shaders/spv/<id>.vert.spv and .frag.spv with a manifest of what the backend needs: each
stage's uniform block and member offsets, the samplers' bindings, the vertex inputs.

The GLSL is the renderer's own, assembled as programs.ludic assembles it, through glslang's
relaxed Vulkan mode, so gpu_uniform / u_* can write the same uniforms into a block on Vulkan.
Bindings are assigned by the tool (glslang's own numbering put several samplers of one stage
on binding 0): the vertex block is 0, the fragment block 1, samplers from 2 in name order,
shared across both stages. Every stage passes spirv-val; `ludic-dev test` rebuilds and compares
wherever the Vulkan SDK is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:11:31 +03:00