feat(gl): OpenGL 4.1 and the ludic.render3d renderer
Some checks failed
ci / build-and-test (push) Waiting to run
commit-lint / conventional-commits (push) Waiting to run
bootstrap / cfree-fixpoint (push) Has been cancelled
docs / build-and-deploy (push) Successful in 34s

`Gl.*` binds the whole OpenGL 4.1 core API — every entry point of the
platform gl3.h with every GL_* constant, generated by `ludic-dev glgen`
with per-call ABI thunks. Windowed builds get an NSOpenGLContext on the
existing window at Retina resolution; headless builds render into an
offscreen CGL context, so a program that uses Gl.* renders and
screenshots identically under the test harness. It links gl.ll, the
thunks and OpenGL.framework only when used; every other build stays
byte-identical.

packages/ludic.render3d is a physically based renderer written on that
surface: HDRI image-based lighting, GPU-generated terrain with scanned
PBR materials, CDLOD, cascaded shadows, glTF with skinning, instanced
vegetation with impostors, procedural grass, water, SSAO, and an HDR
pipeline with bloom, auto-exposure and ACES.

It also carries this session's work on it: the terrain at half its cost
(10.3 -> 5.4 ms of frame), the streaming hitch that got worse the longer
you played, a resize that emptied the world, and the packaging that lets
a game use the renderer from its own repository — `ludic assets`, the
material manifest shipping with the package, and shader lookup falling
back to the install root. See changes/ for each, with its numbers.

The camping game that drove all of it has moved out to its own
repository, Maroon Lake; examples/rendering/smooth.ludic stays as the
renderer's example here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-10 03:31:12 +03:00
parent 470971bf70
commit f25289db20
90 changed files with 35316 additions and 19853 deletions

28
changes/gl-render3d.md Normal file
View file

@ -0,0 +1,28 @@
bump: minor
type: feat
**OpenGL for Ludic, and a 3D renderer on it.** `Gl.*` binds the whole OpenGL 4.1
core API — every `gl*` entry point of the platform `gl3.h` as `Gl.<snake_name>(…)`
with every `GL_*` constant, generated by `ludic-dev glgen` with per-call ABI thunks
(`runtime/native/gl_thunks.ll`; float/double parameters take `fixed`). Windowed
builds get an `NSOpenGLContext` on the existing window at Retina resolution
(`cocoa.ll`); headless builds render into an offscreen CGL context, so a program
that uses `Gl.*` renders and screenshots identically under the test harness.
`Gl.open / swap / screenshot / program / vao / floats …` cover the glue, and the
IEEE-float helpers (`f_add`, `mem_put_f32`, …) let Q16.16 programs fill real
float vertex and uniform data. `Gl.*` links `gl.ll + gl_thunks.ll + OpenGL.framework`
only when used; every other build is byte-identical.
The `ludic.render3d` package (`packages/ludic.render3d`) is a physically based
renderer written on `Gl.*`: HDRI sky with image-based lighting (irradiance, GGX
prefiltered, split-sum BRDF, sun extracted from the map), GPU-generated terrain
with scanned PBR materials (stochastic anti-tiling, triplanar rock, slope/altitude
splatting), cascaded shadow maps with PCF and world-unit biasing, a glTF loader
for scanned models, instanced vegetation with baked impostors, procedural grass
and lupines with wind and translucency, 4x MSAA with alpha-to-coverage, SSAO,
still water, cloud shadows, aerial perspective, an HDR pipeline with bloom,
auto-exposure, ACES tonemapping, grading, sharpening and grain. See
`examples/rendering/smooth.ludic`, and the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake) for a
game built on it. The renderer's CC0 materials are fetched with `ludic assets`.
**16-bit PNGs**: the renderer's texture loader keeps 16-bit samples (normal /
displacement maps) and uploads them as `RGB16` / `R16`.

View file

@ -0,0 +1,39 @@
bump: minor
type: feat
**A game can use the renderer from outside this repository.** `ludic.render3d` reads two
things from disk at run time — its GLSL, and the scanned CC0 materials — and both were
found only by a path relative to the working directory, so the renderer worked in a
Ludic checkout and nowhere else. A game living in its own repository now needs to copy
neither.
**The shaders come from the package**, wherever the package is. They belong to
`ludic.render3d` and ship with it, so the renderer looks for them beside the project
first (a Ludic checkout, where they are under `packages/`) and then under the install
root, `$LUDIC_HOME/packages/ludic.render3d` — the same place the compiler already
resolves `import "ludic.render3d/r3d.ludic"` from. Nothing to vendor, and no version of
the shaders that can drift from the version of the code that compiles them.
**`ludic assets [--force]`** fetches the scanned materials and the HDRI sky into
`assets/polyhaven/` of whatever project you run it in. The list of what to fetch is the
renderer's own — the renderer decides which materials it wants — so it moved out of the
repository's `assets/` and into the package as
`packages/ludic.render3d/assets.manifest`, where it ships with the toolchain. A game
does not keep its own copy of that list and so cannot fall out of step with the
renderer's material set. `ludic dev fetch-assets` is the same command from a checkout.
**A URL-shaped module built its binary into directories.** `project_name` took
everything after the last dot of the manifest's module path, which for a package
identified the way the package manager identifies them —
`git.host.io/user/name` — is inside the *host*: the build wrote
`build/io/user/name` instead of `build/name`. The last path segment comes first now, and
a dot inside that segment still separates namespace from package, so `ludic.render3d`
still builds as `render3d`.
**The camping game has moved out** to its own repository —
[Maroon Lake](https://git.workshopsoft.io/workshopsoft/maroon-lake) — taking
`examples/rendering/valley.ludic`, `hiker.ludic`, `camp/`, and 175 MB of survey data and
scanned kit with it. It was here as a demo of the renderer and became a game, and an
engine repository should not be carrying a game's assets. It is now the first consumer
of everything above, which is the point: what the renderer needs a game to be able to do,
it can now do from outside. `examples/rendering/smooth.ludic` stays as the renderer's
example in this tree.

View file

@ -0,0 +1,27 @@
bump: patch
type: fix
**Resizing the window (or entering fullscreen) no longer empties the world.** It left
`gl error 1286` — `GL_INVALID_FRAMEBUFFER_OPERATION` — on every frame from there on, with
the terrain, the trees, the grass and the water gone and only the sky drawn.
The sun-visibility pass added in `changes/terrain-perf.md` borrows the depth buffer the
frame is about to be drawn with, so that rasterising it doubles as a depth prepass. It
was storing that borrowed texture in its `Target`, and a `Target` deletes whatever its
`depth` names when it is freed. On the first resize the sequence was: `post_free` deletes
the frame's depth texture, `post_init` immediately makes the replacement — and GL hands
back the name that was just freed — and then the visibility target, rebuilt for the new
size, deleted that name believing it was its own. The scene framebuffer lost its depth
attachment. What is left is a colour-only framebuffer, which is *complete*, so drawing
carried on with no depth test at all: the sky is a fullscreen quad drawn last, and with
nothing left to fail against it painted over the entire valley. The 1286s came from the
passes whose own attachment now named a texture that no longer existed.
A borrowed attachment is never written into the target now, and the frame's depth is
attached afresh at the start of each pass — it is a different texture every time the
screen-sized buffers are rebuilt, and one `glFramebufferTexture2D` per pass is cheaper
than any scheme for noticing that it changed.
`R3D_RESIZE_AT=<frame>` rebuilds every screen-sized buffer from that frame on, cycling
through four drawable sizes every few frames. A window cannot be resized in a headless
run, so this is the only way to reach the path; it reproduced the fault in one frame and
now runs twenty resizes, with the fly camera and with the game, without an error.

36
changes/stream-hitch.md Normal file
View file

@ -0,0 +1,36 @@
bump: patch
type: perf
**Ground cover stops re-growing itself.** Walking a streamed world hitched, and the
hitch got worse the longer you played. Measured in the Maroon Lake game, with a new hitch
report rather than guessed at.
**The chunk cache had a cliff, not a slope.** A stream cached 4096 chunks and then
stopped remembering: past that the chunk was generated, used for one frame and thrown
away, so every ring walk regenerated it, for the rest of the session. It arrives after
enough of the map has been walked — six evictions' worth over seven kilometres, so an
ordinary session reaches it — and it is the point where cover starts visibly re-growing
as you turn. `stream_evict` now drops the half of the cache nobody has asked for in the
longest time (chunks carry the walk that last wanted them) and rebuilds the index over
what is left. Over a 7 km traversal: generation total **9073 ms → 2230 ms**, the worst
single frame's generation **11.4 ms → 3.1 ms**, median frame 10.8 → 9.0 ms. With a cache
deliberately sized to saturate early, the same run goes from 2748 frames generating to
1548, and from a 13.3 ms median to 9.2. `R3D_NOEVICT` restores the old behaviour for
comparison, `R3D_STREAM_CAP=<n>` sets the cache size.
The other half of that hitch was in the game's own cover generator, and went with it to
the Maroon Lake game's repository: its candidates were paying for a second noise field, four
height samples and a path distance before the drift field that rules out most of the
meadow — 2341 µs → 518 µs per chunk, bit-identical output. Worth repeating in any
generator: a `stream_fill` is called for tens of thousands of candidates per chunk, so
the order of its tests is most of its cost.
**The hitch report** (`R3D_PROF=1`) is what found both. It prints the slowest frames of
the run with what was in each: CPU versus GPU wait, cover generated, instance bytes
uploaded, the game's own tick, and the renderer phase that took longest. Alongside it,
per-chunk generation cost by stream and band, a census of what the caches hold, and a
stutter figure — the frame time a run spent beyond 1.2x its own median — because a mean
cannot show a hitch and a maximum is one unlucky frame.
It also found two content bugs in the game it was measured on, which is the report doing
its job: a cover stream whose placement rule never fires still pays full generation cost,
and the census makes that visible — 3364 cached chunks holding zero instances.

54
changes/terrain-perf.md Normal file
View file

@ -0,0 +1,54 @@
bump: patch
type: perf
**The ground costs half what it did.** Measured in the Maroon Lake game, the terrain was 10.3 ms of
a 22.2 ms frame; it is now 5.4 ms of 16.4 ms — 45 fps to 61 fps at 1080p, with the
frame otherwise unchanged (every viewpoint tested stays above 54 dB PSNR against the
old renderer, with no channel differing by more than 7/255).
**Measure by frame time, not by the pass timers.** `R3D_PROF`'s per-pass
`GL_TIME_ELAPSED` queries cannot be trusted on this driver: with the ground's shading
work removed the terrain query fell from 10.5 ms to 1.3 ms while the frame time did
not move at all. Every number above and below is a median real frame time, taken by
switching one thing off (`prof_ft_report`); the pass timers are still printed, and are
still useful for spotting a pass that appears out of nowhere, but they cannot size one.
`R3D_NOTERRAIN` skips the ground, `R3D_TNEARONLY` / `R3D_TFARONLY` draw every patch
with one tier's program, and `R3D_RES=<w>x<h>` renders at another size — the three
switches that say whether a cost is the ground, which tier it is in, and whether it is
pixels at all.
**Each detail tier is its own program.** `terrain.frag` holds a detailed near tier and
a cheap far one and chose between them per pixel, so every pixel of the valley walls
was compiled — and scheduled — for a near path it never ran. CDLOD selection now knows
which tiers a patch can contain: one that never comes within the split draws with
`FAR_ONLY`, one wholly inside it with `NEAR_ONLY`, and only the ring of patches that
straddle the band needs the program that holds both and cross-fades. Pixel-identical,
and it makes the tiers separately measurable: the near tier costs 7.5 ms over a whole
frame, the far tier 2.3 ms.
**The sun visibility is its own pass** (`tersun.frag`). The same CDLOD patches are
rasterised once into a screen-sized R8 buffer that holds nothing but each ground
pixel's sun visibility, and `terrain.frag` fetches it by fragment coordinate. The pass
costs 0.27 ms, shares the frame's depth buffer so it doubles as a depth prepass, and
takes the cascade read out of the shader that covers the screen. It picks its tier —
filtered PCF near, a single tap far — over the same cross-faded band the ground uses,
so the boundary is not a contour you can find on the hillside.
**Nothing is sampled for a weight of zero.** The ground sampled all four of its
materials for every pixel and then blended three of them at zero: a meadow pixel took
nine taps of triplanar rock, a cliff pixel nine taps of stochastic grass, and every
pixel in the valley took the snow tile and the four noise fields behind the lake's
shore wash — a wash that is a hairline along one shore within 120 m of the camera. The
survey photograph's classification now runs first, because it is what decides which
materials are present; each material block sits behind its own weight; the ridge field
that ragged the snow line is skipped 160 m below it, where it cannot change anything;
and inside the stochastic blend a cell's rotation, offset and rotated gradients are
computed inside its own test, so a cell whose sharpened weight rounds away costs
nothing. All of it exact where the weight is zero, and it is most of the win.
Material sampling is what remains (2.8 ms of the 5.4): scanned 2K tiles taken at 16x
anisotropy on ground seen at a grazing angle. `R3D_ANISO=<n>` sets the filter (the
default is unchanged at 16; 4 is worth 1.0 ms and 1 is worth 1.7 ms).
The shadow pass is 1.2 ms of the frame and has nothing to give: re-using the far
cascades between frames — their windows are snapped to a 14 m and a 64 m grid — is
worth 0.15 ms standing still and nothing while walking, so it is not in the tree.