-
v0.7.0
Pre-releasereleased this
2026-09-10 02:31:41 +02:00 | 975 commits to main since this releasev0.7.0 — 2026-09-10
Features
-
A game can use the renderer from outside this repository.
ludic.render3dreads 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.render3dand ship with it, so the renderer looks for them beside the project
first (a Ludic checkout, where they are underpackages/) and then under the install
root,$LUDIC_HOME/packages/ludic.render3d— the same place the compiler already
resolvesimport "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'sassets/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-assetsis the same command from a checkout.A URL-shaped module built its binary into directories.
project_nametook
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/nameinstead ofbuild/name. The last path segment comes first now, and
a dot inside that segment still separates namespace from package, soludic.render3d
still builds asrender3d.The camping game has moved out to its own repository —
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.ludicstays as the renderer's
example in this tree. -
OpenGL for Ludic, and a 3D renderer on it.
Gl.*binds the whole OpenGL 4.1
core API — everygl*entry point of the platformgl3.hasGl.<snake_name>(…)
with everyGL_*constant, generated byludic-dev glgenwith per-call ABI thunks
(runtime/native/gl_thunks.ll; float/double parameters takefixed). Windowed
builds get anNSOpenGLContexton the existing window at Retina resolution
(cocoa.ll); headless builds render into an offscreen CGL context, so a program
that usesGl.*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.*linksgl.ll + gl_thunks.ll + OpenGL.framework
only when used; every other build is byte-identical.The
ludic.render3dpackage (packages/ludic.render3d) is a physically based
renderer written onGl.*: 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 withludic assets.16-bit PNGs: the renderer's texture loader keeps 16-bit samples (normal /
displacement maps) and uploads them asRGB16/R16.
Fixes
-
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.mdborrows 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 itsTarget, and aTargetdeletes whatever its
depthnames when it is freed. On the first resize the sequence was:post_freedeletes
the frame's depth texture,post_initimmediately 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 oneglFramebufferTexture2Dper 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. -
An upgrade keeps the package store. The installer replaces the whole install
root, andludic addcaches packages in~/.ludic/store— so re-running the
one-liner deleted every package a project had fetched. The store is carried
across now; everything else in the root belongs to the toolchain and is replaced.
Performance
-
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_evictnow 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_NOEVICTrestores 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: astream_fillis 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. -
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_ELAPSEDqueries 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_NOTERRAINskips the ground,R3D_TNEARONLY/R3D_TFARONLYdraw every patch
with one tier's program, andR3D_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.fragholds 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 withNEAR_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, andterrain.fragfetches 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.
Downloads
-
Source code (ZIP)
1 download
-
Source code (TAR.GZ)
1 download
-
ludic-0.7.0-linux-x86_64.tar.gz
1 download · 759 KiB
-
ludic-0.7.0-linux-x86_64.tar.gz.sha256
1 download · 98 B
-
ludic-0.7.0-src.tar.gz
1 download · 2 MiB
-
ludic-0.7.0-src.tar.gz.sha256
1 download · 89 B
-