Commit graph

25 commits

Author SHA1 Message Date
e634177ca4 feat(render3d): 23.4 - model_release, shared textures counted, and scatter layers sized to what they hold
- model_release(model) frees each primitive's mesh, and each texture once no other model uses it:
  the glTF texture cache counts the primitives using each texture (a path loaded again, and a LOD
  chain borrowing its LOD0's material, each take a reference); the last one frees the texture and
  forgets it along with any remembered material naming it, so a later load is a fresh one
- a scatter layer's instance and sort arrays start at 256 and double as layer_add or a stream
  fills them, to the layer's cap, instead of the whole cap up front (0.4 GB in Maroon Lake)
- gvk_tex_read's copy struct comes from the scratch ring
- examples/rendering/release.ludic holds it (RELEASE OK on Vulkan and OpenGL), in the suite

smooth renders pixel-identical before and after (max |d| 0). ludic-dev test 306/306.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:34:14 +03:00
cc384efee0 feat(render3d): the meadow's blades are culled on the GPU - under 1.5 ms of a MoltenVK frame for all the grass
- grass_cull.comp decides each candidate blade once a frame (place, ground, density, water,
  slope, frustum, colour field) and writes survivors into three bands by distance, one indirect
  draw each; grass_inst.vert only bends and places the vertices. OpenGL keeps grass.vert's
  per-vertex path; R3D_GRASS_GPU=0 compares
- compute programs take sampled textures after their buffers (gpu_compute_tex / gpu_dispatch_tex),
  and every dispatch now records a compute-to-draw memory barrier
- the blades as a sward: 4 m cells, bands with five, three and one-quad blades, spacing doubling
  every 18 m to 70 m, never narrower than a pixel; lit facing the sun and leaning to the sky,
  shadow looked up above the ground (it read the terrain as its caster), a colour ramp that
  leaves only the sheath dark, clumps, dry patches and a tussock shade worked out per blade
- scatter layers flagged grass are skipped while the blades draw (and under R3D_NOGRASS);
  R3D_BLADES, R3D_NOBLADES win over the game's setting; R3D_GRASS_S0/D0 for measuring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:47:36 +03:00
f815f3e3cf wip(render3d): one frame recording while one draws on Vulkan (double-buffered ring and pools), R3D_VK_LABELS, grass measuring switches (R3D_NOGRASS, R3D_BLADES, R3D_GRASS_S0/D0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:47:36 +03:00
3a87d02696 packages: ludic.ui's render3d backend threads Render3dState; render3d's settable vars (r3d_dem_path, post_*, grass_*, wt_wade_*, ...) are Render3dState's fields again, not lets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:11:15 +03:00
19fcf60599 wip(0.S3): packages and examples migrated again from their pre-0.S sources in one run
ludic migrate state packages <every example program> packages/ludic.lab/example/plate.ludic
  1804 vars into 126 states, 64 into lets; 23498 edits in 460 files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:31:34 +03:00
5ffe50ed02 wip(0.S2): migrate - a var nothing writes becomes a let; a state is keyed by its module, directory or program, so every program's plan agrees; paths normalized; program states named SceneDemoState / scene_demo_st; the LSP reads state, mut and entry/handler parameters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:31:34 +03:00
07505e7ef2 wip(0.S3): the packages migrated by ludic migrate state packages - every package test green
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:02:21 +03:00
b0b0b62bce feat(lang): L7 memory is safe unless it says unsafe
The typed buffers are slices: words/floats/fixeds/doubles/pointers(n) make
zeroed, bounds-checked []int/[]float/... and the type names mean them. buffer(n)
is a []byte, with text_of, Fs.read_bytes/write_bytes and view(xs, start, n).
bytes(), indexing a raw pointer or bytes, free, resize, Memory.*, raw file calls,
data_of and C externs are refused outside unsafe { } / unsafe function, and a
project's own files may write unsafe only with --unsafe; the runtime and packages
are the platform. A slice passed to an extern goes as its data.

What the change found: Sync's atomics on a slice header, words(n) uninitialised,
input's fixed axes in ints, truetype's fixed outlines as ints, skin matrices
typed int, gl_shader's source table made from raw bytes. render3d gets safe
entry points (safe_api.ludic). Rendering is byte-identical; a frame costs the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:53:27 +03:00
cc89fc37a4 feat(lang): strict numbers in float files; render3d on float
A numbers float file adapts decimal literals to a fixed operand or slot, and refuses to
promote a computed int to a float implicitly: there it is almost always float bits. Explicit
float(x) is always allowed.

render3d's numbers are float, converted by tools/migrate/floatbits.py - a whole-program
inference of which ints carried IEEE bits (union-find over flows, calls, returns, buffers,
nested buffers and lexical scopes) and a rewriter to operators, Math.* and float literals,
with float_bits / float_from_bits left only where bits really cross (runtime scratch
buffers, mixed buffers). Seed regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:13:25 +03:00
18d23ca20d feat(render3d): a bole that does not quake, leaves out of the lens, a light with a reach
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>
2026-09-20 00:06:50 +03:00
ab6f310013 feat(render3d): an aspen quakes, and a tree is no longer bent like a bow
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>
2026-09-19 21:13:42 +03:00
2980f050ed feat(render3d): gate R3D_* switches behind R3D_DEV in windowed builds
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>
2026-09-16 13:21:51 +03:00
5b9403c343 render3d: actors cast only into the cascades they can shade; Vulkan GPU timings and a mean frame split in R3D_PROF
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>
2026-09-15 18:03:31 +03:00
cdfffa62bb render3d: grass in chunks of 256 tiles a draw, flower casters only into the cascades they reach
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>
2026-09-15 16:58:53 +03:00
d8300a7bf9 render3d(vulkan): GPU-driven trees - one merged mesh per material, one draw per material; on by default
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>
2026-09-15 16:44:00 +03:00
c1eb5f399f feat(render3d): compute and indirect draws on Vulkan, and tree layers culled on the GPU (opt-in)
- 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>
2026-09-15 14:19:43 +03:00
66940f9c0a fix(shaders): varyings meet by name on Vulkan, so the meadow's flowers draw
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>
2026-09-15 12:50:54 +03:00
ce3986bb02 refactor(render3d): programs, GPU timers and the context behind gpu.ludic
The last of milestone 1: no OpenGL call is left in render3d outside gpu.ludic but the clock
and the CPU-side buffer helpers.

- gpu_program builds a program and remembers the variant it came from (vertex, fragment and
  defines as one line - the SPIR-V manifest's key), so a backend that cannot compile at run
  time finds the pipeline for the same handle. gpu_use_program and gpu_program_free replace
  32 uses and 2 frees; the overlay's program is recorded under overlay.vert|overlay.frag.
- gpu_query_new / _begin / _end / _result carry R3D_PROF's timers.
- gpu_open, gpu_vsync, gpu_renderer_name and gpu_resize_check carry the context.
- r3d_program_tess is gone: nothing called it and no tessellation shader exists.
- R3D_GLCHECK also checks after framebuffer and renderbuffer changes, viewport, draw buffers,
  program use, texture parameters, the resize check and between frames.

OpenGL frames are byte-identical at the five viewpoints; 59 self-tests pass with and without
R3D_GLCHECK, with no GL error; ludic-dev test 140 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:33:01 +03:00
78c1f4f9ca refactor(render3d): render targets and passes behind gpu.ludic
Framebuffers and their attachments, renderbuffers (multisampled too), draw and read buffers,
completeness checks, blits, viewports, clears, the screen framebuffer, the multisample enable,
the wireframe switch and GL error checks now go through gpu_fb_* / gpu_rb_* / gpu_viewport /
gpu_clear / gpu_blit / gpu_check, and no other file names them. Each call is the one GL call it
replaces, in the same order: the fixed viewpoints render bit-identically and the game's
self-tests report exactly what they did.

What is attached to each framebuffer - colour slots, a depth texture or one layer of an array,
renderbuffers and their samples - is recorded as it is attached, for a backend that builds
render passes and image views. gpu_read_screen is the frame read-back a photograph takes.

R3D_GLCHECK=1 checks, around every draw, clear and blit, that the bound framebuffer is complete
and that no error is left behind, naming the framebuffer. It has already narrowed the old
"gl error 1286 at terrain shadow bake": the error is pending before a draw after the map self-
test, so it comes from a call that is not a draw, clear or blit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:41:43 +03:00
4d3479d431 refactor(render3d): textures behind gpu.ludic, their sampler state recorded
Texture creation, 2D and array uploads, pixel packing, filters, wraps, comparison, border,
anisotropy, mipmaps, texture units, read-backs and freeing now go through gpu_tex_* and
gpu_bind_sampler, and no other file names them. Each call is the one GL call it replaces,
in the same order, so OpenGL renders bit-identically at the fixed viewpoints and the game's
self-tests report what they did before.

What those calls say about a texture - size, format, layers, min and mag filter, wraps,
comparison, mipmaps, anisotropy - is recorded per handle as it is set: a backend with
immutable images and separate sampler objects creates both from exactly that.
r3d_bind_tex takes a texture kind (GPU_TEX2D / GPU_TEX2D_ARRAY) instead of a GL target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:29:28 +03:00
43c379aa0f refactor(render3d): vertex data and draws behind gpu.ludic
Every vertex array, vertex and index buffer, attribute pointer, instance divisor, stream
upload and draw call now goes through gpu_mesh_* / gpu_buffer_* / gpu_draw_*, and no other
file in the package names them. A Mesh records its layout as it is built - which buffer
feeds which attribute at what stride and offset, per vertex or per instance - so a backend
that bakes vertex input into a pipeline can read it back. On OpenGL each call is the GL it
replaces, in the same order: the five fixed viewpoints render bit-identically and the game's
self-tests report exactly what they did before.

scatter_attach takes the mesh rather than its vertex array; a mesh now frees every vertex
buffer it owns (glTF meshes used to keep all but the first); the two helpers nothing called,
mesh_grid_patches and mesh_instance_buffer, are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:21:55 +03:00
54eeef5f8d feat(render3d): gpu.ludic - the seam for a second graphics API, and what the GPU can do
Render state (depth, blending, culling, colour writes, alpha-to-coverage, depth bias,
scissor) and uniforms go through gpu_* / u_* and nowhere else; OpenGL state is cached and
only changes reach the driver. Frames are bit-identical to before at the fixed viewpoints.
R3D_GFX=gl|vk or gpu_request chooses a backend, falling back to OpenGL with a reason.

gpu_caps_probe() asks Vulkan, on Windows, for the 1.3 floor, ray tracing, mesh shaders, the
NVIDIA RTX generation, Reflex and HDR colour spaces, for a game's settings to grey out what
a machine cannot use; R3D_CAPS pretends to be a given card for tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:52:12 +03:00
d6ffdfa638 perf(render3d): a depth prepass for the near tree foliage
Needle-card crowns are many cut-out quads deep and a discarding shader turns early depth
rejection off, so every card behind the front one ran the full lighting shader. The near
tree LODs now write depth first (depth.frag, the same alpha coverage test), terrain under
them is rejected before shading, and the lit pass draws them with no discard and an equal
depth test (invariant gl_Position). R3D_NOPREPASS=1 restores the old path.

Dense forest on a Windows PC: 61 -> 78 fps at 3840x2160, 116 -> 164 fps at 1920x1080.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 04:18:38 +03:00
a4a3d75cd9 feat(render3d): replace the world at run time, water bodies, terrain_sea
terrain_reload/terrain_unload release one map's height field, survey, photograph
and patch bounds and generate another at any TERRAIN_HALF; scatter_clear_all
(with streams), actor_clear_all, col_reset and mesh_free empty the scene;
water_body_add/water_bodies_clear draw several still-water planes with one
reflecting; terrain_sea separates the coast's sea level from the carved lake's,
and grass keeps off both. Fixes CDLOD patch bounds for TERRAIN_HALF != 4096 and
water_init leaking a mesh and program per call. examples/rendering/reload.ludic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:03:40 +03:00
f25289db20 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>
2026-09-10 03:31:12 +03:00