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

View file

@ -0,0 +1,226 @@
# ============================================================================
# skin.ludic — skeletal skinning for glTF models. A Skin is the file's node
# hierarchy (rest translation / rotation / scale per node) plus the skin's joint
# list and inverse bind matrices. The game poses it by giving any node an extra
# rotation and offset IN THE MODEL'S FRAME (X right, Y up, -Z forward, whatever
# the bone's own axes happen to be), skin_pose() folds those into the hierarchy
# and produces the joint matrices, and skin.vert blends four of them per vertex.
#
# Posing in the model frame is what makes a procedural gait writable: "swing the
# thigh forward" is a rotation about the model's X axis, not about whichever axis
# the exporter gave the thigh bone. Per node the delta D is brought into the
# parent's rest frame G_p (the parent's global rest rotation): local rotation =
# (G_p^-1 D G_p) * R_rest.
# ============================================================================
const SKIN_MAX_JOINTS: int = 48
property Skin {
n_nodes: int = 0,
par: words, # parent node per node, -1 at a root
walk: words, # the nodes ordered parents-first
rest_t: words, # 3 per node
rest_r: words, # 4 per node (x, y, z, w)
rest_s: words, # 3 per node
rest_g: words, # 4 per node: the global rest rotation
names: []string,
pose_r: words, # 4 per node: the pose rotation, model frame
pose_t: words, # 3 per node: the pose offset, model frame (metres)
gmat: words, # 16 per node: global matrix this pose
n_joints: int = 0,
joints: words, # node index per joint
inv_bind: words, # 16 per joint
bones: words, # 16 per joint: what the vertex shader skins with
tmp_l: words,
tmp_q: words,
tmp_a: words,
tmp_b: words,
tmp_c: words,
tmp_v: words
}
# a 3-vector of a JSON array (float bits), or a default
function skin_jv3(o: words, at: int, nd: Val, key: pointer, dx: int, dy: int, dz: int) -> void {
if value_has(nd, key) == 0 { o[at] = dx; o[at + 1] = dy; o[at + 2] = dz; return }
let arr = value_get(nd, key)
for i in 0 .. 3 { o[at + i] = jnum(value_at(arr, i)) }
}
# JOINTS_0 / WEIGHTS_0 onto attributes 5 and 6 of the VAO being built (gltf_prim)
function skin_attribs(m: Mesh, attrs: Val) -> bool {
if value_has(attrs, "JOINTS_0") == 0 or value_has(attrs, "WEIGHTS_0") == 0 { return false }
let jd = gltf_accessor(value_as_int(value_get(attrs, "JOINTS_0")))
var jsz = 1
var jtype = GL_UNSIGNED_BYTE
if gltf_ctype == 5123 { jsz = 2; jtype = GL_UNSIGNED_SHORT }
let jb = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, jb)
gl_buffer_data(GL_ARRAY_BUFFER, gltf_count * gltf_comps * jsz, jd, GL_STATIC_DRAW)
gl_enable_vertex_attrib_array(5)
gl_vertex_attrib_pointer(5, gltf_comps, jtype, 0, 0, null) # integers, read as floats
free(jd)
let wd = gltf_accessor(value_as_int(value_get(attrs, "WEIGHTS_0")))
var wsz = 4
var wtype = GL_FLOAT
var norm = 0
if gltf_ctype == 5123 { wsz = 2; wtype = GL_UNSIGNED_SHORT; norm = 1 }
if gltf_ctype == 5121 { wsz = 1; wtype = GL_UNSIGNED_BYTE; norm = 1 }
let wb = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, wb)
gl_buffer_data(GL_ARRAY_BUFFER, gltf_count * gltf_comps * wsz, wd, GL_STATIC_DRAW)
gl_enable_vertex_attrib_array(6)
gl_vertex_attrib_pointer(6, gltf_comps, wtype, norm, 0, null)
free(wd)
return true
}
# the skin `idx` of the document being loaded (gltf_load holds gltf_doc / gltf_bin open)
function skin_load(idx: int) -> Skin {
let sk = new Skin
let nodes = value_get(gltf_doc, "nodes")
let n = value_count(nodes)
sk.n_nodes = n
sk.par = words(n); sk.walk = words(n)
sk.rest_t = words(n * 3); sk.rest_r = words(n * 4); sk.rest_s = words(n * 3); sk.rest_g = words(n * 4)
sk.pose_r = words(n * 4); sk.pose_t = words(n * 3); sk.gmat = words(n * 16)
sk.names = new []string
sk.tmp_l = m4_new(); sk.tmp_q = q_new(); sk.tmp_a = q_new(); sk.tmp_b = q_new(); sk.tmp_c = q_new(); sk.tmp_v = words(3)
for i in 0 .. n { sk.par[i] = -1 }
for i in 0 .. n {
let nd = value_at(nodes, i)
var nm: string = ""
if value_has(nd, "name") != 0 { nm = value_as_str(value_get(nd, "name")) }
push(sk.names, nm)
skin_jv3(sk.rest_t, i * 3, nd, "translation", F_ZERO, F_ZERO, F_ZERO)
skin_jv3(sk.rest_s, i * 3, nd, "scale", F_ONE, F_ONE, F_ONE)
if value_has(nd, "rotation") != 0 {
let r = value_get(nd, "rotation")
for k in 0 .. 4 { sk.rest_r[i * 4 + k] = jnum(value_at(r, k)) }
} else { sk.rest_r[i * 4] = F_ZERO; sk.rest_r[i * 4 + 1] = F_ZERO; sk.rest_r[i * 4 + 2] = F_ZERO; sk.rest_r[i * 4 + 3] = F_ONE }
if value_has(nd, "matrix") != 0 { print(`skin: node {nm} uses a matrix transform (unsupported, treated as identity)`) }
if value_has(nd, "children") != 0 {
let ch = value_get(nd, "children")
for k in 0 .. value_count(ch) { sk.par[value_as_int(value_at(ch, k))] = i }
}
}
# parents first: order the nodes by depth
let depth = words(n)
for i in 0 .. n {
var d = 0
var p = sk.par[i]
while p >= 0 and d < n { d += 1; p = sk.par[p] }
depth[i] = d
}
var k = 0
for d in 0 .. n { for i in 0 .. n { if depth[i] == d { sk.walk[k] = i; k += 1 } } }
free(depth)
# the global rest rotation of every node
for w in 0 .. n {
let i = sk.walk[w]
let p = sk.par[i]
q_load(sk.tmp_a, sk.rest_r, i)
if p >= 0 { q_load(sk.tmp_b, sk.rest_g, p); q_mul(sk.tmp_q, sk.tmp_b, sk.tmp_a); q_store(sk.rest_g, i, sk.tmp_q) }
else { q_store(sk.rest_g, i, sk.tmp_a) }
}
# the skin: joints and inverse bind matrices
let skv = value_at(value_get(gltf_doc, "skins"), idx)
let jl = value_get(skv, "joints")
var nj = value_count(jl)
if nj > SKIN_MAX_JOINTS { print(`skin: {nj} joints, only the first {SKIN_MAX_JOINTS} are used`); nj = SKIN_MAX_JOINTS }
sk.n_joints = nj
sk.joints = words(nj)
sk.inv_bind = words(nj * 16)
sk.bones = words(nj * 16)
for j in 0 .. nj { sk.joints[j] = value_as_int(value_at(jl, j)) }
if value_has(skv, "inverseBindMatrices") != 0 {
let ib = gltf_accessor(value_as_int(value_get(skv, "inverseBindMatrices")))
for i in 0 .. nj * 16 { sk.inv_bind[i] = mem_get_f32_bits(ib, i) }
free(ib)
} else {
for j in 0 .. nj { m4_identity(mem_off(sk.inv_bind, j * 64)) }
}
skin_reset(sk)
skin_pose(sk)
print(`skin: {nj} joints over {n} nodes`)
return sk
}
function skin_find(sk: Skin, name: string) -> int {
for i in 0 .. sk.n_nodes { if sk.names[i] == name { return i } }
print(`skin: no node {name}`)
return -1
}
function skin_mat(sk: Skin, node: int) -> words { return mem_off(sk.gmat, node * 64) }
# back to the rest pose
function skin_reset(sk: Skin) -> void {
for i in 0 .. sk.n_nodes {
sk.pose_r[i * 4] = F_ZERO; sk.pose_r[i * 4 + 1] = F_ZERO; sk.pose_r[i * 4 + 2] = F_ZERO; sk.pose_r[i * 4 + 3] = F_ONE
sk.pose_t[i * 3] = F_ZERO; sk.pose_t[i * 3 + 1] = F_ZERO; sk.pose_t[i * 3 + 2] = F_ZERO
}
}
# a node's pose rotation in the model frame: pitch about X, yaw about Y, roll about Z (radians)
function skin_set_rot(sk: Skin, node: int, pitch: int, yaw: int, roll: int) -> void {
if node < 0 { return }
q_euler(sk.tmp_q, pitch, yaw, roll)
q_store(sk.pose_r, node, sk.tmp_q)
}
function skin_set_quat(sk: Skin, node: int, q: words) -> void { if node >= 0 { q_store(sk.pose_r, node, q) } }
# a node's pose offset in the model frame (metres)
function skin_set_offset(sk: Skin, node: int, x: int, y: int, z: int) -> void {
if node < 0 { return }
sk.pose_t[node * 3] = x; sk.pose_t[node * 3 + 1] = y; sk.pose_t[node * 3 + 2] = z
}
# fold the pose into the hierarchy: global matrices, then the joint matrices
function skin_pose(sk: Skin) -> void {
for w in 0 .. sk.n_nodes {
let i = sk.walk[w]
let p = sk.par[i]
q_load(sk.tmp_a, sk.pose_r, i) # D, model frame
var tx = sk.rest_t[i * 3]; var ty = sk.rest_t[i * 3 + 1]; var tz = sk.rest_t[i * 3 + 2]
let ox = sk.pose_t[i * 3]; let oy = sk.pose_t[i * 3 + 1]; let oz = sk.pose_t[i * 3 + 2]
if p >= 0 {
q_load(sk.tmp_b, sk.rest_g, p) # G_p
q_conj(sk.tmp_c, sk.tmp_b) # G_p^-1
q_mul(sk.tmp_q, sk.tmp_c, sk.tmp_a)
q_mul(sk.tmp_a, sk.tmp_q, sk.tmp_b) # G_p^-1 D G_p
if ox != 0 or oy != 0 or oz != 0 {
v3_set(sk.tmp_v, ox, oy, oz)
q_rotate(sk.tmp_v, sk.tmp_c, sk.tmp_v) # the offset in the parent's frame
tx = f_add(tx, sk.tmp_v[0]); ty = f_add(ty, sk.tmp_v[1]); tz = f_add(tz, sk.tmp_v[2])
}
} else { tx = f_add(tx, ox); ty = f_add(ty, oy); tz = f_add(tz, oz) }
q_load(sk.tmp_b, sk.rest_r, i)
q_mul(sk.tmp_q, sk.tmp_a, sk.tmp_b) # local rotation
m4_trs_q(sk.tmp_l, tx, ty, tz, sk.tmp_q, sk.rest_s[i * 3], sk.rest_s[i * 3 + 1], sk.rest_s[i * 3 + 2])
if p >= 0 { m4_mul(skin_mat(sk, i), skin_mat(sk, p), sk.tmp_l) }
else { m4_copy(skin_mat(sk, i), sk.tmp_l) }
}
for j in 0 .. sk.n_joints {
m4_mul(mem_off(sk.bones, j * 64), skin_mat(sk, sk.joints[j]), mem_off(sk.inv_bind, j * 64))
}
}
# the joint matrices onto a program's u_bones[]
function skin_bind(sk: Skin, prog: int) -> void {
var loc = gl_uniform(prog, "u_bones[0]")
if loc < 0 { loc = gl_uniform(prog, "u_bones") }
gl_uniform_matrix4fv(loc, sk.n_joints, 0, sk.bones)
}
# the same skeleton posed on its own: shares the rest data, owns the pose and the matrices
function skin_clone(src: Skin) -> Skin {
let sk = new Skin
sk.n_nodes = src.n_nodes; sk.par = src.par; sk.walk = src.walk
sk.rest_t = src.rest_t; sk.rest_r = src.rest_r; sk.rest_s = src.rest_s; sk.rest_g = src.rest_g
sk.names = src.names
sk.n_joints = src.n_joints; sk.joints = src.joints; sk.inv_bind = src.inv_bind
let n = src.n_nodes
sk.pose_r = words(n * 4); sk.pose_t = words(n * 3); sk.gmat = words(n * 16)
sk.bones = words(src.n_joints * 16)
sk.tmp_l = m4_new(); sk.tmp_q = q_new(); sk.tmp_a = q_new(); sk.tmp_b = q_new(); sk.tmp_c = q_new(); sk.tmp_v = words(3)
skin_reset(sk)
skin_pose(sk)
return sk
}