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>
Actor.cast_hidden separates drawn from casting. ac_visible rejected a hidden actor from
the shadow pass too, so hiding the player's own body for first person took the player's
shadow with it - and in a sunlit basin your own shadow is what tells you where you are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The runtime opens a game's window before main, titled with the program's name, and
gl_open attached to it without passing its title on (render3d's gvk_open called win_open,
which returns early on Windows and opened a second window on macOS). win_set_title in
cocoa.ll / win32.ll (setTitle: / SetWindowTextW, UTF-8 -> UTF-16; a no-op without a window,
stubbed headless) retitles it from gl_open, and win_open on an open window retitles it on
both platforms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Every R3D_* read goes through r3d_env_has / r3d_env (env.ludic): a headless
build honours them as before, a windowed build only when R3D_DEV is set to
anything but "0", so a shipped game never reaches a debug view, feature kill,
file writer or hardware fake. Documented in docs/SHIPPING.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A chunk's dispatch was sized for its largest tile, so the far tiles beside a
near one ran thousands of empty invocations: 9.8 ms of grass at 4K on an RTX
3070 Ti. One dispatch per tile, sized to that tile, brings it to 5.0 ms - still
three times the chunked path's 1.7 (36 fps against 41), so GF_MESH_GRASS is not
implemented yet and the Advanced row says a coming update.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
grass_flush uploaded u_tiles (vec4[256]) with u_fv(4n floats). On Vulkan an
array element is copied at the size given and placed at the array's stride,
so each tile got one float: nonsense corners and zero blades a cell. The
meadow had no grass on the Vulkan renderer since cdfffa6 while the draw
counts looked right. u_f4v uploads n vec4s; OpenGL was never affected.
PC camp: chunked path matches R3D_GRASS_TILES=1, validation 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
streamline.ludic: slInit before the Vulkan instance, feature support per
adapter, a frame token per frame, Reflex sleep and PCL latency markers, and
DLSS super resolution on the lit HDR frame (Halton jitter, depth + zero
motion vectors with camera motion from clipToPrevClip, matrices carrying
the Vulkan path's y flip and depth remap). Bloom, tonemap and sharpen read
the upscaled size. The device asks for privateData and present_id, which
Streamline's hooks need. R3D_DLSS / R3D_REFLEX / R3D_SL_LOG for tests.
gpu_feature_implemented: DLSS and Reflex.
ludic bundle (Windows): app native "<dir>" copies native libraries beside
the executable.
Verified on an RTX 3070 Ti: DLSS Quality evaluates 1280x720 -> 1920x1080.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The water reflection keeps the main camera's frustum planes, and ac_visible tested the actor itself
against them: the town's people, far above the lake with their images far below the frame, all drew
again. The image (2L - y) is what is tested now - town, 69 skinned reflection draws -> 38, the frame
byte-identical; PC camp reflection scene CPU 793 -> about 760 us. R3D_PROF splits the reflection's
CPU into setup, terrain, scene and sky.
Tried and not kept, with the numbers (PC, camp, Vulkan GPU timestamps):
- the ground's cost is its scanned material taps: without them the terrain pass is 555 us of 1568,
without the photograph 1029; the sun, the noise fields and the grain are 20-100 us each. Moving the
cheap tier in from 200 m to 60 m changes nothing, so it is the far tier's single taps over the
mountains. Skipping the normal-map taps past 900 m (where the detail normal is fully faded) saved
22 us and moved a few pixels - reverted.
- grass: cutting its radius to 300 m barely moves it, so the cost is the near blades' pixels; moving
their three noise fields to the vertices changed nothing (747 us) - reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Where the frame goes, before optimising it further. R3D_PROF now prints the average frame: CPU before
the swap (the game's share apart), GPU and swap, and the renderer's CPU phases in frame order - the
shadow pass split into cascade fit, scatter casters and actor casters. On Vulkan the per-pass GPU
table comes from timestamp queries (host query reset asked for where the device has it); MoltenVK's
attribution is tile-based and not to be trusted per pass, the PC's is.
What it showed on the PC (camp): 4.3 ms CPU and 5.3 ms GPU a frame; the shadow pass was the largest
CPU phase (1.8 ms) and actors half of that. Every actor within 300 m was drawn into all five cascades,
though the outer two only shade receivers from 212 and 935 m out: 160 actors and 300 draws into each.
cast_band_reaches - the flowers' reach test, now shared - skips an actor for a cascade it cannot shade
(receivers counted from 0.85 of the previous split, where sunShadow's cross-fade begins).
PC camp, two runs each: mean frame 9553/9601 -> 8664/8656 us; CPU 4.3 -> 3.7 ms; shadow GPU 1.14 ->
0.79 ms; actor-shadow CPU 1.02 -> 0.60 ms; 2039 -> 1411 draws; self-tests 59/59, validation 0.
Mac: OpenGL shot viewpoints and the camp byte-identical; town 19 px at <= 2/255 on two flower stems a
few metres from the camera - the accepted leftover-binding difference, no shadow; self-tests 59/59 on
OpenGL and Vulkan. R3D_CAST_ALL=1 draws every caster into every cascade.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reflection pass is a second copy of the terrain, the vegetation and the actors, and it ran on
every frame of a map with water - looking straight down at a meadow included. The mirrored body is cut
into rectangles where the ground lies below it (32 m over the height map in 8 x 8 blocks, the sea
beyond it coarse), and the pass runs when one meets the view frustum and its water is not all dry
where it shows, outside the frame or behind the ground. Three wrong turns on the way, each kept in a
comment: bounding spheres (a 156 m cell reached into the view from behind the camera), sight lines
that never asked whether the point was in the frame, and a walk of every rectangle every frame (0.1 s
per 400 frames on the PC until the blocks). Built once in 6.6 ms.
Mac, the five shot viewpoints: view c (the meadow) 225 reflection draws -> none; a, b, d, e unchanged;
OpenGL frames byte-identical; self-tests 59/59 on OpenGL and Vulkan; MoltenVK validation adds nothing.
PC camp (lake in view): 1882 draws either way, 3.8 s for 400 frames either way, three runs each,
self-tests 59/59, validation 0. R3D_REFL_ALWAYS=1 runs the pass every frame; R3D_REFL_DBG=1 prints the
test once.
R3D_ACTOR_CENSUS=<frame> prints the lit pass's actors grouped by model: town is 17 models and 69 draws,
and only four rigid models repeat (17 actors) - too little for instancing to be worth a shader variant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
gpu_rb_storage makes the image with the samples asked for; a pass takes its attachments' count and
every pipeline in it matches; a blit from a multisampled source resolves on the end of an empty
dynamic-rendering pass (colour averaged, depth from sample zero - vkCmdResolveImage cannot do depth).
gpu_msaa_max reads the device's colour-and-depth sample limits, and post_set_msaa clamps to it, so
the Anti-aliasing setting is live on Vulkan. The pipeline cache's pass key no longer packs samples
into three bits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gpu_feature_implemented(GF_VULKAN) is true: the Vulkan renderer draws the whole game (validation
clean, 105 fps at the camp on the RTX 3070 Ti). Ray tracing, DLSS, Reflex, HDR output and mesh-shader
grass still report false, so their rows keep saying they take effect later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- A block sub-allocator: 64 MB blocks per memory type, images and buffers kept apart, first fit with
alignment, freed ranges merged and empty blocks given back; anything over 16 MB still gets its own
allocation. The self-tests' second world holds 94 allocations instead of 8735 (the driver's limit
refused a shadow map before). Camp bench unchanged, 105.3 fps.
- shadow_set_res keeps the size that worked when the card has no memory for the new one, and records
it in shadow_refused, instead of ending with no shadow map; gpu_tex_ok says whether a texture has an
image behind it.
- Image barriers skip an image that was never made (a failed allocation used to crash there), and
R3D_VK_ERRLOG=<file> appends every Vulkan failure line by line, so a crash no longer takes the
message with it.
- R3D_VK_PROF reports draws asked for and not made, so a layer missing from a frame is never silent.
Validation on (VK_INSTANCE_LAYERS): the game's self-tests 61 OK, 0 errors, on the RTX 3070 Ti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- A draw's set carries its textures and lives in a pool of its own, keyed by each texture's handle,
generation and sampler; the uniform blocks are dynamic uniform buffers into the frame's ring,
bound with the draw's offsets, so a draw that changes only uniforms allocates and writes no set.
The sampler for an unbound slot is made once instead of looked up by string every draw.
Camp bench, RTX 3070 Ti, 400 frames, twice: 102.6 fps (53.3 before; OpenGL 114.3), sets 0.9 ms
against 8.5. MoltenVK (M4 Pro): sets 0.2 ms.
- Integer vertex attributes read by float inputs use USCALED formats (a_joints was UINT against a
vec4), and every shader input a mesh does not feed reads a shared zero buffer. NVIDIA drew
anyway; MoltenVK refused every skinned pipeline and the whole actor layer was missing from the
Mac's Vulkan frame. A draw skipped for want of a pipeline now says so, once per program.
- A failed image allocation is reported instead of bound as a null allocation.
Validation proven on (VK_INSTANCE_LAYERS): 0 errors over the game's self-tests (61 OK) and a frame.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- sky_start_yaw: a game that turns the sky at boot sets it before r3d_init and the image-based light
is baked once at that yaw; sky_set_yaw to a yaw already baked bakes nothing.
- terrain_init_step: the height field and normals, the sun shadow, the materials, the patches and
programs as separate steps; r3d_load_count is 8, so a loading bar moves through the terrain (half
the start-up) instead of jumping over it. terrain_init runs the four in a row as before.
- The per-cascade actor cull centres its sphere at half the model's height and sizes it by the
larger of height and radius: centred at the radius, it dropped a head at a cascade's edge (11 px
in town, found by the drawstats comparison). Lake was byte-identical before and after.
OpenGL frames at the five viewpoints unchanged; the game's 59 self-tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
actor_draw_casters drew every visible actor within 300 m into all five cascades. The draw-call
count (R3D_DRAWSTATS) found about 1500 skinned shadow draws a frame in town against 69 in the lit
pass. The cascade is an orthographic box, so a bounding sphere (1.5x the actor's radius) outside
its clip x/y casts nothing into that layer and is skipped. OpenGL frames at the five viewpoints
are unchanged; the game's 59 self-tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- r3d_open opens the window with nothing baked; r3d_load_count / r3d_load_step run the rest (sky
and light, terrain, shadow and screen targets, cover and actors, grass) so a game can present a
loading frame between them. r3d_init is the same calls in a row.
- sky_set_quality(width): the prefiltered sky light at 256 / 512 / 1024, baked again.
- post_set_msaa(samples): multisampling on OpenGL, remade in place (post_msaa_live is false on
Vulkan); post_free frees the multisampled framebuffer too.
- STREAM_BUDGET_US is a variable; r3d_fog_scale multiplies the fog the day sets.
- Vulkan buffers carry UNIFORM_BUFFER usage: the frame's ring is bound as uniform buffers and was
created without it (VUID-VkWriteDescriptorSet-descriptorType-00330, found on MoltenVK).
OpenGL frames at the five viewpoints unchanged; the game's 59 self-tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measurement only: a tally at each of gpu.ludic's five draw doors, program
changes and texture binds, keyed by the open profiler pass. Off unless
R3D_DRAWSTATS is set; frames byte-identical with it off and on (Mac, OpenGL).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- 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>
- 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>
R3D_VK_PROF at the camp view (about 2950 draws a frame) showed the frame spent in bookkeeping:
19.5 ms a frame finding pipelines by string key and 16 ms building descriptor sets.
- Pipelines: a mesh carries an interned vertex-layout id (dropped only when an attribute's shape
changes, not when an instance buffer is swapped), render state packs into an int and the pass
formats into another; the program's last hit is tried first. The string path only builds.
- Samplers: each texture keeps the sampler for its parameters until they change.
- Descriptor sets: a program's last set is reused within the frame while its blocks and resolved
textures are unchanged; a uniform write that repeats the value it already holds changes nothing.
Headless on the RTX 3070 Ti at 1920x1080: 21.7 -> 53.3 fps (pipelines 0.2 ms, sets 8.5 ms, inside
draws 10 ms a frame; OpenGL 114 fps). The camp frame is unchanged and validation-clean. OpenGL frames
byte-identical at the five viewpoints; 59 self-tests pass; VKRES OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- A buffer re-uploaded after a draw this frame read it gets fresh storage, as OpenGL orphans
one; storage moved off or freed while the frame still reads it is destroyed after the frame's
submit. Draws mark the buffers they bind. No flush for buffer work.
- Mipmaps asked for mid-frame (the exposure measure, every frame) are recorded into the open frame
after its pass; growing a chain the first time keeps its one-shot path.
- R3D_VK_PROF prints, every 120 frames, draws and flushes per frame and the milliseconds spent
finding pipelines, filling descriptor sets and inside draws.
The gain was small - the camp view headless at 1920x1080 on the RTX 3070 Ti went from 21.3 to
21.7 fps (OpenGL: 114 fps) - so these flushes were not what holds the frame; the profile is how
the rest is found. The frame is unchanged and validation-clean; OpenGL frames byte-identical at the
five viewpoints with 59 self-tests passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- gpu_select takes Vulkan for a window on Windows; gvk_init asks for VK_KHR_surface,
VK_KHR_win32_surface and VK_KHR_swapchain when the renderer will present. A window elsewhere
stays on OpenGL with the reason.
- gvk_open opens the window without a GL context and makes the screen images at its client area;
gvk_swap_make builds the swapchain on the Win32 surface (FIFO with vsync, mailbox or immediate
without) and rebuilds it when it is out of date, suboptimal or the window changes size.
- gvk_present blits the screen image into the acquired swapchain image, flipped (the screen keeps
OpenGL's bottom-up rows), and presents it.
- Samplers pointed at a texture unit with u_i and then fed by binding units - the actors do this -
read that unit's texture; on Vulkan they drew with the white stand-in (the tent, the log, the
chair, the workbench).
- gl.ll carries a weak win_gl_drawable for headless macOS builds.
On the RTX 3070 Ti the windowed build runs the valley through Vulkan at 3840x2160 with the HUD and
the frame-rate readout (16 fps: every upload still waits on the frame, and buffers are
host-visible). The headless Vulkan frame is unchanged; OpenGL frames byte-identical at the five
viewpoints with 59 self-tests passing. The actor fix is compiled and OpenGL-verified, not yet
seen on the PC; blades at the grass's middle distance still draw black on Vulkan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
gpu.ludic's calls branch to the Vulkan backend when it was chosen and came up; every OpenGL
statement is unchanged, only guarded. R3D_GFX=vk selects it in a headless run (the window's
swapchain is the next milestone) and falls back to OpenGL, with the reason, when the device or
the SPIR-V manifest is missing.
- Render state is cached as before and turned into pipelines at the draw; u_* and sampler binds
go into the variant's uniform blocks; meshes, buffers and textures are gvk_* objects;
framebuffer binds are dynamic-rendering passes; same-size blits are image copies; the screen,
the photograph read-back, the present and the screenshot go through the frame.
- Work that submits on its own (uploads, read-backs, new or freed images and buffers) flushes the
frame first, so it runs in OpenGL's order. A read may take fewer channels than the image has
(the height field's R from its RGBA32F bake). Pipeline keys name vertex bindings by order, not
buffer handle, so re-pointed instance buffers keep their pipeline.
The valley renders at frame 90 validation-clean on the RTX 3070 Ti and on MoltenVK. OpenGL frames
byte-identical at the five viewpoints; 59 self-tests pass with no GL error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>