• v0.15.0 81d4344bdc

    v0.15.0
    Some checks failed
    commit-lint / conventional-commits (push) Waiting to run
    docs / build-and-deploy (push) Waiting to run
    bootstrap / cfree-fixpoint (push) Successful in 41s
    ci / build-and-test (push) Has been cancelled
    release / publish (push) Successful in 3m16s
    Pre-release

    orkun released this 2026-09-15 08:55:37 +02:00 | 920 commits to main since this release

    v0.15.0 — 2026-09-15

    Features

    • Vulkan for Ludic, and the start of a second renderer beside OpenGL.

      • Vk.*: every command of Vulkan 1.0-1.4 and of the extensions a modern renderer is
        built around (swapchain, HDR colour spaces, ray query and acceleration structures, opacity
        micromaps, mesh shaders, variable rate shading, memory budget, pipeline libraries, NVIDIA
        low latency, portability for MoltenVK), with every constant and every struct's size
        (<Struct>_sizeof) and field offsets (<Struct>_<field>). Generated from the Vulkan
        registry by ludic-dev vkgen into runtime/native/vk_api.ludic and vk_thunks.ll;
        structs are plain memory filled by name with Vk.put_i32 / put_i64 / put_ptr. Every
        size and offset was checked against the SDK's C headers (3128 facts). A C float is float
        bits in an int; 64-bit values and non-dispatchable handles are long.
      • The loader is opened at run time, never linked: vk_win.ll (vulkan-1.dll) and
        vk_mac.ll (libvulkan.1.dylib, MoltenVK). Vk.open() returning 0 means no Vulkan, and
        the program carries on. ludicc and ludic build link both files for any program that
        uses Vk.*.
      • examples/rendering/vk_probe.ludic reports what a machine's Vulkan can do;
        vk_compute.ludic runs a Slang compute shader (vk_compute.slang) and reads the picture
        back - on an RTX 3070 Ti and on an M4 Pro through MoltenVK, clean under the validation layer.
      • render3d gpu.ludic: the seam between the renderer and a graphics API. Render state
        and uniforms go through gpu_* / u_* and nowhere else (OpenGL frames unchanged);
        R3D_GFX=gl|vk or gpu_request chooses a backend, falling back to OpenGL with a reason;
        gpu_caps_probe() detects, on Windows, the Vulkan 1.3 floor, ray tracing, mesh shaders,
        the NVIDIA RTX generation, Reflex and HDR, for a settings screen to grey out what a machine
        cannot use (R3D_CAPS=rtx50|rtx40|rtx30|amd|intel|none pretends, for tests).
    Downloads
  • v0.14.2 043d8d81a2

    v0.14.2
    All checks were successful
    bootstrap / cfree-fixpoint (push) Successful in 39s
    ci / build-and-test (push) Successful in 3m23s
    commit-lint / conventional-commits (push) Successful in 4s
    docs / build-and-deploy (push) Successful in 37s
    release / publish (push) Successful in 3m11s
    Pre-release

    orkun released this 2026-09-14 14:35:40 +02:00 | 924 commits to main since this release

    v0.14.2 — 2026-09-14

    Fixes

    • Input.mouse_dx / mouse_dy no longer jump on the first frame or when the cursor mode changes.
      The delta was the position minus the previous frame's, and the previous position started at 0,0,
      so the first frame reported the cursor's whole distance from the corner as motion, and switching
      between a locked cursor's virtual reticle and the real cursor did the same. A camera that adds
      mouse_dy to its pitch came up pointing at the ground with nobody touching the mouse. Both frames
      now report no motion; every other frame is unchanged, windowed or headless, on macOS and Windows.
    Downloads
  • v0.14.1 dc9138846f

    v0.14.1
    All checks were successful
    bootstrap / cfree-fixpoint (push) Successful in 29s
    ci / build-and-test (push) Successful in 3m22s
    commit-lint / conventional-commits (push) Successful in 4s
    release / publish (push) Successful in 3m12s
    Pre-release

    orkun released this 2026-09-13 11:46:46 +02:00 | 926 commits to main since this release

    v0.14.1 — 2026-09-13

    Fixes

    • A rim from outline_model no longer stays on after the caller stops asking — the queue
      was only emptied by the next outline_model call, so a game that highlights what the
      crosshair is on left the last highlighted tree outlined for good once the player looked
      away. r3d_frame now drops a batch nobody reopened this frame (outline_frame).
    Downloads
  • v0.14.0 751532831a

    v0.14.0
    All checks were successful
    bootstrap / cfree-fixpoint (push) Successful in 30s
    ci / build-and-test (push) Successful in 3m21s
    commit-lint / conventional-commits (push) Successful in 4s
    release / publish (push) Successful in 3m11s
    Pre-release

    orkun released this 2026-09-13 04:03:41 +02:00 | 928 commits to main since this release

    v0.14.0 — 2026-09-13

    Features

    • Overlay text is UTF-8, and a font atlas can carry any script — ludic.render3d's ov_text,
      ov_text_w and ov_text_wrap decode UTF-8 instead of drawing bytes 32–126, so a game can show
      Turkish, German, Polish, Greek, Cyrillic or whatever its atlas holds.

      • font.json may list the atlas's code points in "codes" (atlas order). An atlas without it is
        read as before: consecutive code points from "first", so existing ASCII fonts keep working.
      • A code point the atlas lacks draws as ?; a control character as a space; a malformed or
        cut-off sequence as one ?, so a string sliced mid-character still draws.
      • overlay_font(dir) loads or swaps the atlas at run time, for a language that brings its own font.
    Downloads
  • v0.13.3 a90c58eb09

    v0.13.3
    All checks were successful
    bootstrap / cfree-fixpoint (push) Successful in 30s
    ci / build-and-test (push) Successful in 3m21s
    commit-lint / conventional-commits (push) Successful in 4s
    release / publish (push) Successful in 3m12s
    Pre-release

    orkun released this 2026-09-13 03:18:38 +02:00 | 930 commits to main since this release

    v0.13.3 — 2026-09-13

    Performance

    • Dense forest renders faster — a depth prepass for the near tree foliage in ludic.render3d.

      A crown of needle cards is many cut-out quads deep, and a shader that can discard turns
      early depth rejection off, so every card behind the front one ran the full lighting shader.
      In a dense stand at 3840x2160 that made the vegetation pass the largest in the frame. The near
      tree LODs now write depth first with depth.frag (the same alpha coverage test), the terrain
      beneath them is rejected before shading, and the lit pass draws them with no discard and an equal depth test. R3D_NOPREPASS=1 restores the old path for comparison.

    Downloads
  • v0.13.2 21bc611455

    v0.13.2
    All checks were successful
    bootstrap / cfree-fixpoint (push) Successful in 30s
    ci / build-and-test (push) Successful in 3m22s
    commit-lint / conventional-commits (push) Successful in 4s
    docs / build-and-deploy (push) Successful in 38s
    release / publish (push) Successful in 3m12s
    Pre-release

    orkun released this 2026-09-13 02:48:57 +02:00 | 932 commits to main since this release

    v0.13.2 — 2026-09-13

    Fixes

    • ludic build links http.ll only into a program that uses Http.*.

      The check that added it grepped the IR for @hs_, which every program's header declares,
      so the HTTP transport and Foundation were linked into everything. That cost nothing visible
      on macOS and failed the link on Linux, where ludic build, ludic run and a new project all
      broke in CI. It now looks for a call to hs_send.

    Downloads
  • v0.7.0 9d1df6ec32

    v0.7.0
    All checks were successful
    bootstrap / cfree-fixpoint (push) Successful in 24s
    ci / build-and-test (push) Successful in 3m3s
    commit-lint / conventional-commits (push) Successful in 1s
    release / publish (push) Successful in 2m52s
    Pre-release

    orkun released this 2026-09-10 02:31:41 +02:00 | 975 commits to main since this release

    v0.7.0 — 2026-09-10

    Features

    • 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 — 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.

    • 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.

    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.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.

    • An upgrade keeps the package store. The installer replaces the whole install
      root, and ludic add caches 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_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.

    • 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.

    Downloads
  • v0.6.1 6588c9d4ae

    v0.6.1
    All checks were successful
    bootstrap / cfree-fixpoint (push) Successful in 22s
    ci / build-and-test (push) Successful in 2m59s
    commit-lint / conventional-commits (push) Successful in 1s
    docs / build-and-deploy (push) Successful in 33s
    release / publish (push) Successful in 2m49s
    Pre-release

    orkun released this 2026-09-05 22:29:14 +02:00 | 978 commits to main since this release

    v0.6.1 — 2026-09-05

    Fixes

    • ludic new scaffolded a project that would not compile. The project name went
      straight into the program <Name> identifier, so ludic new my-game wrote
      program My-Game — a subtraction — and the first ludic run failed with
      expected '{', got '-'. A name is now turned into a valid identifier
      (my-game → MyGame, 2048 → Game2048), and a name that cannot be a
      directory or a package is refused with the rule rather than mangled.

      Found while auditing every command's flags and arguments, along with:

      • Unknown options are errors. ludic build --headles silently built a
        windowed binary; ludic build -o with no path silently ignored it. Both now
        say what is wrong and exit non-zero.
      • ludic fmt formats in place, as its help always claimed — it was printing
        the file to stdout and changing nothing. ludic fmt --check reports drift
        without writing, for a hook or CI.
      • ludic test nosuch.ludic says the file does not exist instead of passing it
        to the compiler.
      • ludic build-lib reported failures as a shell syntax error (an
        interpolation written in a non-interpolating string), and its no-argument
        auto-detection picked up package.lock.ludic as a module to compile.
      • Error messages that still began with x: — the CLI's old name — now say
        ludic:.
    Downloads
  • v0.6.0 b24f0dd572

    v0.6.0
    All checks were successful
    bootstrap / cfree-fixpoint (push) Successful in 22s
    ci / build-and-test (push) Successful in 2m55s
    commit-lint / conventional-commits (push) Successful in 1s
    docs / build-and-deploy (push) Successful in 33s
    release / publish (push) Successful in 2m44s
    Pre-release

    orkun released this 2026-09-05 22:15:22 +02:00 | 980 commits to main since this release

    v0.6.0 — 2026-09-05

    Refactoring

    • ludic is only the language's command line now. The toolchain's own tasks —
      building the compiler from its IR seed, the regression suites, the docs site,
      releases — moved out of it into a separate ludic-dev binary that is built from
      a checkout and is not part of an install.

      • ludic help is what a user can actually do: new, run, build, test,
        add, fmt, lsp, doctor, upgrade. No section about a repository they do
        not have. Typing ludic dev … says where those tasks went rather than failing
        as an unknown command.
      • ludic dev <task> becomes ludic-dev <task> for contributors; every task
        is otherwise unchanged. The bootstrap is now
        bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev && bin/ludic-dev build.
      • The shipped binary drops from ~880 KB to ~190 KB, since none of the release,
        docs-generation or bootstrap machinery is linked into it any more.

    CI

    • The docs deploy triggers on a change to install.sh. The site publishes the
      installer, but the workflow's path filter did not mention it — so a release that
      only fixed install.sh left the old script live at the URL the landing page
      tells people to pipe into sh.
    Downloads
  • v0.5.2 4aaf27c669

    v0.5.2
    All checks were successful
    bootstrap / cfree-fixpoint (push) Successful in 23s
    ci / build-and-test (push) Successful in 2m55s
    commit-lint / conventional-commits (push) Successful in 2s
    release / publish (push) Successful in 2m44s
    Pre-release

    orkun released this 2026-09-05 21:33:35 +02:00 | 983 commits to main since this release

    v0.5.2 — 2026-09-05

    Fixes

    • The installer also covers a non-login interactive bash — the shell most Linux
      terminal emulators start, which reads only ~/.bashrc. That file is now created
      when it is missing (its presence changes nothing else about how bash starts),
      while ~/.bash_profile is still only appended to when it already exists, since
      creating that one would stop bash reading ~/.profile.
    Downloads