ludic/changes/terrain-perf.md
Orkuncakilkaya f25289db20
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
feat(gl): OpenGL 4.1 and the ludic.render3d renderer
`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>
2026-09-10 03:31:12 +03:00

3.7 KiB

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.