feat(lang): functions are values (L2), and render3d takes its scene as callbacks

fn(int, float) -> bool is a type, fn name is any top-level function's value, and a call through
a local, a global, a record field, a slice element, a parameter or a result of a function type
is an indirect call; two function types mix only when equal, a call checks its argument count,
and a value may be null (examples/functions/values.ludic). Job.parallel_for keeps its worker
check.

render3d's scene is registered rather than required by name: r3d_on_draw, r3d_on_casters and
r3d_on_stream_fill (hooks.ludic). The two rendering examples register theirs - and had defined
scene_draw_casters with no parameter while the renderer passed one, which nothing checked.
render3d declares numbers float itself; smooth.ludic is converted to floats and returns when
r3d_init fails instead of running on into a segfault. Noise.* check their argument count (a call
one short crashed the compiler). selfhost-build says why it failed. The migration tool reads a
declared float as evidence. Seed regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 00:27:26 +03:00
parent dc75a5a5ab
commit 0c73287e35
23 changed files with 71430 additions and 68575 deletions

View file

@ -815,6 +815,20 @@ fields: `toks[i].kind = T_ID` is a single address computation.
## Functions & FFI ## Functions & FFI
**Functions are values.** `fn(int, float) -> bool` is a type (no `->` means it returns
nothing), `fn name` is any top-level function as a value of its own type, and a call through a
local, a global, a record field, a slice element, a parameter or a result of a function type
calls whatever it holds. Two function types mix only when they are the same, and a value may be
`null`. A registry holds behaviour this way, and a package takes a callback:
```ludic
# doc-check: skip — illustrative
property Kind { name: string = "", use: fn(Thing) -> bool = null }
function kind_def(name: string, use: fn(Thing) -> bool) -> void { ... }
kind_def("bush", fn bush_use)
if kinds[k].use(t) { sound_pickup() }
```
```ludic ```ludic
function heal(amount: int) -> int { return amount * 2 } function heal(amount: int) -> int { return amount * 2 }
``` ```

View file

@ -0,0 +1,9 @@
bump: minor
type: feat
**Functions are values (L2).** `fn(int, float) -> bool` is a type, `fn name` is any top-level
function's value (it used to be only a thread worker's address), and a call through a local, a
global, a record field, a slice element, a parameter or a result of a function type is an
indirect call. Two different function types do not mix, a call through one checks its argument
count, and a value may be `null`. A registry can hold behaviour and a package can take
callbacks. `Job.parallel_for` still checks that its worker takes (int, pointer) and returns
nothing. `ludic-dev selfhost-build` now says why it failed instead of exiting 1 silently.

10
changes/render3d-hooks.md Normal file
View file

@ -0,0 +1,10 @@
bump: minor
type: feat
**render3d takes what a frame draws as callbacks.** A program used to have to define
`scene_draw`, `scene_draw_casters` and `stream_fill` by name - a contract only the linker
enforced, whose shape nothing checked (two examples defined `scene_draw_casters` with no
parameter while the renderer passed one), and one scene per program. It registers them now:
`r3d_on_draw(fn ...)`, `r3d_on_casters(fn ...)`, `r3d_on_stream_fill(fn ...)` (hooks.ludic).
render3d also declares `numbers float` itself, so a program that imports it without the line
still gets a working renderer. The Noise functions check their argument count: a call one
short crashed the compiler, or - where it happened to fit - put fbm2's octaves in its seed.

View file

@ -0,0 +1,31 @@
# values.ludic — L2: a function is a value. `fn(int) -> int` is a type; `fn name` is any
# top-level function's value; a call through a local, a global, a field or an element of a
# function type is an indirect call. A registry can hold behaviour, and a package can take one.
#
# Running it prints: 42 10 25 49 81 1.5 1
program FunctionValues {
numbers float
property Kind { name: string = "", use: fn(int) -> int = null }
function double(x: int) -> int { return x * 2 }
function square(x: int) -> int { return x * x }
function half(x: float) -> float { return x / 2.0 }
var op: fn(int) -> int = null
function run_with(f: fn(int) -> int, v: int) -> int { return f(v) }
function pass_on(f: fn(float) -> float) -> fn(float) -> float { return f }
entry {
op = fn double
print(op(21))
let ops = new []fn(int) -> int
push(ops, fn double)
push(ops, fn square)
for i in 0 .. len(ops) { print(ops[i](5)) }
let k = new Kind
k.name = "square"
k.use = fn square
print(k.use(7))
print(run_with(fn square, 9))
let h = pass_on(fn half)
print(h(3.0))
if op != null { print(1) }
}
}

View file

@ -0,0 +1,6 @@
# fn_type_mismatch.ludic - a function of one type where another is wanted is refused, by name
program FnTypeMismatch {
function half(x: float) -> float { return x / 2.0 }
var op: fn(int) -> int = null
entry { op = fn half }
}

View file

@ -15,7 +15,7 @@ program Reload {
# (the parser's g_uses_gl), so even a check that draws nothing presents its frame. # (the parser's g_uses_gl), so even a check that draws nothing presents its frame.
# render3d calls back into the program for what stands on the terrain # render3d calls back into the program for what stands on the terrain
function scene_draw() -> void { scatter_draw() } function scene_draw() -> void { scatter_draw() }
function scene_draw_casters() -> void { scatter_draw_casters(shadow_cascade_vp(sh_cascade)) } function scene_draw_casters(light_vp: floats) -> void { scatter_draw_casters(light_vp) }
# no streamed cover here, but stream.ludic names the generator, so every program supplies one # no streamed cover here, but stream.ludic names the generator, so every program supplies one
function stream_fill(s: Stream, cx: int, cz: int, band: int) -> void { } function stream_fill(s: Stream, cx: int, cz: int, band: int) -> void { }
@ -39,9 +39,15 @@ program Reload {
handler Boot phase Start { handler Boot phase Start {
spawn Anchor {} spawn Anchor {}
r3d_on_draw(fn scene_draw)
r3d_on_casters(fn scene_draw_casters)
r3d_on_stream_fill(fn stream_fill)
TERRAIN_HALF = 1000 TERRAIN_HALF = 1000
ter_smooth = true ter_smooth = true
if not r3d_init(640, 360, "Reload") { quit() } if not r3d_init(640, 360, "Reload") {
quit()
return
}
cam_set(F_ZERO, fi(80), fi(300), fi(180), f_neg(fi(10))) cam_set(F_ZERO, fi(80), fi(300), fi(180), f_neg(fi(10)))
water_init(fl(4.0), F_ZERO, F_ZERO, fi(200), fi(200)) water_init(fl(4.0), F_ZERO, F_ZERO, fi(200), fi(200))
populate() populate()

View file

@ -7,6 +7,7 @@
# #
# bin/ludic build examples/rendering/smooth.ludic && ./build/smooth # bin/ludic build examples/rendering/smooth.ludic && ./build/smooth
program Smooth { program Smooth {
numbers float
import "ludic.render3d/r3d.ludic" import "ludic.render3d/r3d.ludic"
property Marker { on: int = 1 } property Marker { on: int = 1 }
@ -24,88 +25,94 @@ program Smooth {
var s_cards_b: Stream = null var s_cards_b: Stream = null
var l_trees: Layer = null var l_trees: Layer = null
function rnd() -> int { return fr(rng_range(0, 9999), 10000) } function rnd() -> float { return float(rng_range(0, 9999)) / 10000.0 }
function rnd_range(a: int, b: int) -> int { return f_lerp(a, b, rnd()) } function rnd_range(a: float, b: float) -> float { return Math.lerp(a, b, rnd()) }
function smooth(a: int, b: int, x: int) -> int { function smooth(a: float, b: float, x: float) -> float {
let t = f_clamp(f_div(f_sub(x, a), f_sub(b, a)), F_ZERO, F_ONE) let t = Math.clamp((x - a) / (b - a), 0.0, 1.0)
return f_mul(f_mul(t, t), f_sub(fi(3), f_mul(F_TWO, t))) return t * t * (3.0 - 2.0 * t)
} }
function slope_at(x: int, z: int) -> int { function slope_at(x: float, z: float) -> float {
let e = F_TWO let e = 2.0
let dx = f_sub(terrain_height(f_add(x, e), z), terrain_height(f_sub(x, e), z)) let dx = terrain_height(x + e, z) - terrain_height(x - e, z)
let dz = f_sub(terrain_height(x, f_add(z, e)), terrain_height(x, f_sub(z, e))) let dz = terrain_height(x, z + e) - terrain_height(x, z - e)
let ny = f_div(fi(4), f_sqrt(f_add(f_add(f_mul(dx, dx), f_mul(dz, dz)), fi(16)))) let ny = 4.0 / Math.sqrt(dx * dx + dz * dz + 16.0)
return f_sub(F_ONE, ny) return 1.0 - ny
} }
function noise01(x: int, z: int, scale: fixed) -> int { function noise01(x: float, z: float, scale: fixed) -> float {
let n = Noise.fbm2(f_fx(x) * scale, f_fx(z) * scale, 4) let n = Noise.fbm2(fixed(x) * scale, fixed(z) * scale, 0, 4)
return fl(n * 0.5 + 0.5) return float(n * 0.5 + 0.5)
} }
# grass only where the ground is grassy: gentle, above the water, below the mountain # grass only where the ground is grassy: gentle, above the water, below the mountain
function stream_fill(s: Stream, cx: int, cz: int, band: int) -> void { function stream_fill(s: Stream, cx: int, cz: int, band: int) -> void {
seed((cx * 73856093) ^ (cz * 19349663) ^ (band * 83492791) ^ (s.kind * 2654435761)) seed((cx * 73856093) ^ (cz * 19349663) ^ (band * 83492791) ^ (s.kind * 2654435761))
let size = s.size let size = s.size
let x0 = f_mul(fi(cx), size); let z0 = f_mul(fi(cz), size) let x0 = float(cx) * size; let z0 = float(cz) * size
# Candidate spacing. These are metres between attempts, so halving one quadruples the # Candidate spacing. These are metres between attempts, so halving one quadruples the
# work and the instance count: the first pass here was dense enough to bury the scene # work and the instance count: the first pass here was dense enough to bury the scene
# and cost most of the frame. Blades are only placed close to the eye, where they read # and cost most of the frame. Blades are only placed close to the eye, where they read
# as individual grass; past that the cards carry the cover. # as individual grass; past that the cards carry the cover.
var step = fl(1.0) var step = 1.0
if s.kind == 0 { if band == 0 { step = fl(0.22) } else if band == 1 { step = fl(0.45) } else { step = fl(1.1) } } if s.kind == 0 { if band == 0 { step = 0.22 } else if band == 1 { step = 0.45 } else { step = 1.1 } }
else { if band == 0 { step = fl(0.9) } else if band == 1 { step = fl(1.8) } else { step = fl(4.0) } } else { if band == 0 { step = 0.9 } else if band == 1 { step = 1.8 } else { step = 4.0 } }
var z = z0 var z = z0
while f_ls(z, f_add(z0, size)) { while z < z0 + size {
var x = x0 var x = x0
while f_ls(x, f_add(x0, size)) { while x < x0 + size {
let px = f_add(x, f_mul(rnd(), step)) let px = x + rnd() * step
let pz = f_add(z, f_mul(rnd(), step)) let pz = z + rnd() * step
let h = terrain_height(px, pz) let h = terrain_height(px, pz)
# Grassy ground only: above the water, off the steep parts, below the rim. Every # Grassy ground only: above the water, off the steep parts, below the rim. Every
# test is a smooth ramp — a hard height cut carves the cover into contour rings, # test is a smooth ramp — a hard height cut carves the cover into contour rings,
# because the cut lands on a line of constant elevation. # because the cut lands on a line of constant elevation.
var keep = smooth(fl(0.2), fl(1.6), h) # out of the water var keep = smooth(0.2, 1.6, h) # out of the water
keep = f_mul(keep, smooth(fi(90), fi(45), h)) # below the mountain keep = keep * smooth(90.0, 45.0, h) # below the mountain
keep = f_mul(keep, smooth(fl(0.42), fl(0.16), slope_at(px, pz))) keep = keep * smooth(0.42, 0.16, slope_at(px, pz))
keep = f_mul(keep, f_add(fl(0.25), f_mul(fl(0.9), noise01(px, pz, 0.02)))) keep = keep * (0.25 + 0.9 * noise01(px, pz, 0.02))
keep = f_mul(keep, fl(0.75)) keep = keep * 0.75
var sc = rnd_range(fl(0.16), fl(0.4)) var sc = rnd_range(0.16, 0.4)
if s.kind != 0 { sc = f_mul(rnd_range(fl(1.5), fl(2.5)), f_add(F_ONE, f_mul(fl(0.5), fi(band)))) } if s.kind != 0 { sc = rnd_range(1.5, 2.5) * (1.0 + 0.5 * float(band)) }
if f_ls(rnd(), keep) { if rnd() < keep {
stream_emit(s, px, f_sub(h, fl(0.03)), pz, sc, f_mul(rnd(), f_mul(F_TWO, F_PI)), rnd(), rnd_range(fl(0.6), F_ONE)) stream_emit(s, px, h - 0.03, pz, sc, rnd() * (2.0 * PI), rnd(), rnd_range(0.6, 1.0))
} }
x = f_add(x, step) x = x + step
} }
z = f_add(z, step) z = z + step
} }
} }
function scene_draw() -> void { scatter_draw() } function scene_draw() -> void { scatter_draw() }
function scene_draw_casters() -> void { scatter_draw_casters(shadow_cascade_vp(sh_cascade)) } function scene_draw_casters(light_vp: floats) -> void { scatter_draw_casters(light_vp) }
handler Boot phase Start { handler Boot phase Start {
spawn Anchor {} spawn Anchor {}
r3d_on_draw(fn scene_draw)
r3d_on_casters(fn scene_draw_casters)
r3d_on_stream_fill(fn stream_fill)
# 2 km square, generated analytically # 2 km square, generated analytically
TERRAIN_HALF = 1000 TERRAIN_HALF = 1000
ter_smooth = true ter_smooth = true
if not r3d_init(1920, 1080, "Smooth") { quit() } if not r3d_init(1920, 1080, "Smooth") {
quit()
return
}
walk = Os.has_env("R3D_WALK") walk = Os.has_env("R3D_WALK")
spin = Os.has_env("R3D_SPIN") spin = Os.has_env("R3D_SPIN")
if Os.has_env("R3D_SHOT") { shot_at = Text.to_int(Os.env("R3D_SHOT")) } if Os.has_env("R3D_SHOT") { shot_at = Text.to_int(Os.env("R3D_SHOT")) }
var cx = fi(0); var cz = fi(260) var cx = 0.0; var cz = 260.0
if Os.has_env("R3D_CAM_X") { cx = fi(Text.to_int(Os.env("R3D_CAM_X"))) } if Os.has_env("R3D_CAM_X") { cx = float(Text.to_int(Os.env("R3D_CAM_X"))) }
if Os.has_env("R3D_CAM_Z") { cz = fi(Text.to_int(Os.env("R3D_CAM_Z"))) } if Os.has_env("R3D_CAM_Z") { cz = float(Text.to_int(Os.env("R3D_CAM_Z"))) }
var ch = fl(1.8); var cp = f_neg(fi(3)); var cy = fi(180) var ch = 1.8; var cp = -3.0; var cy = 180.0
if Os.has_env("R3D_CAM_H") { ch = fi(Text.to_int(Os.env("R3D_CAM_H"))) } if Os.has_env("R3D_CAM_H") { ch = float(Text.to_int(Os.env("R3D_CAM_H"))) }
if Os.has_env("R3D_CAM_PITCH") { cp = fi(Text.to_int(Os.env("R3D_CAM_PITCH"))) } if Os.has_env("R3D_CAM_PITCH") { cp = float(Text.to_int(Os.env("R3D_CAM_PITCH"))) }
if Os.has_env("R3D_CAM_YAW") { cy = fi(Text.to_int(Os.env("R3D_CAM_YAW"))) } if Os.has_env("R3D_CAM_YAW") { cy = float(Text.to_int(Os.env("R3D_CAM_YAW"))) }
cam_set(cx, f_add(terrain_height(cx, cz), ch), cz, cy, cp) cam_set(cx, terrain_height(cx, cz) + ch, cz, cy, cp)
# a small pond in the middle of the meadow — the plane self-clips to the basin, so # a small pond in the middle of the meadow — the plane self-clips to the basin, so
# its extent only has to cover the hollow, not the map # its extent only has to cover the hollow, not the map
water_init(fl(4.0), F_ZERO, F_ZERO, fi(200), fi(200)) water_init(4.0, 0.0, 0.0, 200.0, 200.0)
sky_set_yaw(f_rad(fi(120))) sky_set_yaw(Math.deg_to_rad(120.0))
# grass: blades underfoot, cards beyond # grass: blades underfoot, cards beyond
let dir = r3d_assets + "/models/grass_medium_01" let dir = r3d_assets + "/models/grass_medium_01"
@ -113,16 +120,16 @@ program Smooth {
let gb = gltf_load(dir, "grass_medium_01_1k.gltf", "grass_medium_01_mid_a_LOD0") let gb = gltf_load(dir, "grass_medium_01_1k.gltf", "grass_medium_01_mid_a_LOD0")
if ga == null or gb == null { print("smooth: no grass models"); return } if ga == null or gb == null { print("smooth: no grass models"); return }
# blades are the expensive layer: keep them near, and cap them low # blades are the expensive layer: keep them near, and cap them low
l_blades = layer_new(model_blade(), 400000, true, fl(3.0), F_ZERO, fi(70)) l_blades = layer_new(model_blade(), 400000, true, 3.0, 0.0, 70.0)
l_blades.blade = true l_blades.blade = true
l_grass_a = layer_cards(ga, 200000, fl(0.3), fi(260)) l_grass_a = layer_cards(ga, 200000, 0.3, 260.0)
l_grass_b = layer_cards(gb, 200000, fl(0.3), fi(260)) l_grass_b = layer_cards(gb, 200000, 0.3, 260.0)
v3_set(l_grass_a.tint, fl(0.95), F_ONE, fl(0.85)) v3_set(l_grass_a.tint, 0.95, 1.0, 0.85)
v3_set(l_grass_b.tint, fl(0.9), fl(0.98), fl(0.8)) v3_set(l_grass_b.tint, 0.9, 0.98, 0.8)
l_grass_a.rough = fl(1.6); l_grass_b.rough = fl(1.6) l_grass_a.rough = 1.6; l_grass_b.rough = 1.6
s_blades = stream_new(l_blades, fi(8), fi(60), fi(12), fi(28), fi(60), fi(60)) s_blades = stream_new(l_blades, 8.0, 60.0, 12.0, 28.0, 60.0, 60.0)
s_cards_a = stream_new(l_grass_a, fi(32), fi(240), fi(45), fi(110), fi(240), fi(240)) s_cards_a = stream_new(l_grass_a, 32.0, 240.0, 45.0, 110.0, 240.0, 240.0)
s_cards_b = stream_new(l_grass_b, fi(32), fi(240), fi(45), fi(110), fi(240), fi(240)) s_cards_b = stream_new(l_grass_b, 32.0, 240.0, 45.0, 110.0, 240.0, 240.0)
s_blades.kind = 0; s_cards_a.kind = 1; s_cards_b.kind = 2 s_blades.kind = 0; s_cards_a.kind = 1; s_cards_b.kind = 2
place_trees() place_trees()
} }
@ -131,57 +138,57 @@ program Smooth {
function place_trees() -> void { function place_trees() -> void {
let model = gltf_load(r3d_assets + "/models/fir_tree_01", "fir_tree_01_1k.gltf", "fir_tree_01_c_LOD0") let model = gltf_load(r3d_assets + "/models/fir_tree_01", "fir_tree_01_1k.gltf", "fir_tree_01_c_LOD0")
if model == null { print("smooth: no fir model"); return } if model == null { print("smooth: no fir model"); return }
l_trees = layer_new(model, 20000, false, F_ZERO, fi(90), F_ZERO) l_trees = layer_new(model, 20000, false, 0.0, 90.0, 0.0)
layer_set_impostor(l_trees, impostor_bake(model, 12, 512, 1024)) layer_set_impostor(l_trees, impostor_bake(model, 12, 512, 1024))
v3_set(l_trees.tint, fl(0.9), F_ONE, fl(0.85)) v3_set(l_trees.tint, 0.9, 1.0, 0.85)
seed(4242) seed(4242)
var z = f_neg(fi(900)) var z = -900.0
while f_ls(z, fi(900)) { while z < 900.0 {
var x = f_neg(fi(900)) var x = -900.0
while f_ls(x, fi(900)) { while x < 900.0 {
let px = f_add(x, f_mul(rnd(), fi(14))); let pz = f_add(z, f_mul(rnd(), fi(14))) let px = x + rnd() * 14.0; let pz = z + rnd() * 14.0
let h = terrain_height(px, pz) let h = terrain_height(px, pz)
var keep = smooth(fi(7), fi(14), h) # above the pond shore var keep = smooth(7.0, 14.0, h) # above the pond shore
keep = f_mul(keep, smooth(fi(120), fi(60), h)) # below the rim keep = keep * smooth(120.0, 60.0, h) # below the rim
keep = f_mul(keep, smooth(fl(0.45), fl(0.2), slope_at(px, pz))) keep = keep * smooth(0.45, 0.2, slope_at(px, pz))
keep = f_mul(keep, smooth(fl(0.45), fl(0.75), noise01(px, pz, 0.004))) keep = keep * smooth(0.45, 0.75, noise01(px, pz, 0.004))
if f_ls(rnd(), f_mul(keep, fl(0.5))) { if rnd() < keep * 0.5 {
let sc = rnd_range(fl(0.7), fl(1.35)) let sc = rnd_range(0.7, 1.35)
layer_add(l_trees, px, f_sub(h, fl(0.2)), pz, sc, f_mul(rnd(), f_mul(F_TWO, F_PI)), rnd(), F_ZERO) layer_add(l_trees, px, h - 0.2, pz, sc, rnd() * (2.0 * PI), rnd(), 0.0)
} }
x = f_add(x, fi(14)) x = x + 14.0
} }
z = f_add(z, fi(14)) z = z + 14.0
} }
} }
handler Fly phase Input { handler Fly phase Input {
if walk { cam_move(fl(1.0), F_ZERO, F_ZERO, fl(0.003), F_ZERO); return } if walk { cam_move(1.0, 0.0, 0.0, 0.003, 0.0); return }
if spin { cam_move(F_ZERO, F_ZERO, F_ZERO, fl(0.02), F_ZERO); return } if spin { cam_move(0.0, 0.0, 0.0, 0.02, 0.0); return }
if not is_windowed() { return } if not is_windowed() { return }
Input.poll() Input.poll()
var fwd = F_ZERO; var side = F_ZERO; var up = F_ZERO var fwd = 0.0; var side = 0.0; var up = 0.0
let speed = fl(0.6) let speed = 0.6
if Input.key_down(key: 'w') { fwd = speed } if Input.key_down(key: 'w') { fwd = speed }
if Input.key_down(key: 's') { fwd = f_neg(speed) } if Input.key_down(key: 's') { fwd = -speed }
if Input.key_down(key: 'd') { side = speed } if Input.key_down(key: 'd') { side = speed }
if Input.key_down(key: 'a') { side = f_neg(speed) } if Input.key_down(key: 'a') { side = -speed }
if Input.key_down(key: 'e') { up = speed } if Input.key_down(key: 'e') { up = speed }
if Input.key_down(key: 'q') { up = f_neg(speed) } if Input.key_down(key: 'q') { up = -speed }
var dyaw = F_ZERO; var dpitch = F_ZERO var dyaw = 0.0; var dpitch = 0.0
if Input.mouse_down(button: 0) { if Input.mouse_down(button: 0) {
dyaw = f_mul(fi(Input.mouse_dx()), fl(-0.004)) dyaw = float(Input.mouse_dx()) * -0.004
dpitch = f_mul(fi(Input.mouse_dy()), fl(-0.004)) dpitch = float(Input.mouse_dy()) * -0.004
} }
if fwd != 0 or side != 0 or up != 0 or dyaw != 0 or dpitch != 0 { cam_move(fwd, side, up, dyaw, dpitch) } if fwd != 0 or side != 0 or up != 0 or dyaw != 0 or dpitch != 0 { cam_move(fwd, side, up, dyaw, dpitch) }
if Input.key_pressed(key: 'p') { if Input.key_pressed(key: 'p') {
let gy = terrain_height(cam_pos[0], cam_pos[2]) let gy = terrain_height(cam_pos[0], cam_pos[2])
print(`R3D_CAM_X={string(f_to_int(cam_pos[0]))} R3D_CAM_Z={string(f_to_int(cam_pos[2]))} R3D_CAM_H={string(f_to_int(f_sub(cam_pos[1], gy)))} R3D_CAM_YAW={string(f_to_int(f_mul(cam_yaw, f_div(fi(180), F_PI))))} R3D_CAM_PITCH={string(f_to_int(f_mul(cam_pitch, f_div(fi(180), F_PI))))}`) print(`R3D_CAM_X={string(int(cam_pos[0]))} R3D_CAM_Z={string(int(cam_pos[2]))} R3D_CAM_H={string(int(cam_pos[1] - gy))} R3D_CAM_YAW={string(int(cam_yaw * (180.0 / PI)))} R3D_CAM_PITCH={string(int(cam_pitch * (180.0 / PI)))}`)
} }
} }
handler Draw phase Render { handler Draw phase Render {
r3d_frame(fl(Time.elapsed())) r3d_frame(float(Time.elapsed()))
frame += 1 frame += 1
if frame == shot_at { Gl.screenshot(path: "build/smooth.ppm") } if frame == shot_at { Gl.screenshot(path: "build/smooth.ppm") }
Gl.swap() Gl.swap()

View file

@ -0,0 +1,17 @@
# hooks.ludic - the scene a frame draws, as callbacks the program registers. They used to be
# functions every program had to define by name (scene_draw, scene_draw_casters, stream_fill),
# a contract only the linker enforced and that allowed one scene per program.
var r3d_draw_cb: fn() = null
var r3d_casters_cb: fn(floats) = null
var r3d_fill_cb: fn(Stream, int, int, int) = null
# what a frame draws, for every pass that draws the scene (the view, the reflection)
function r3d_on_draw(f: fn()) -> void { r3d_draw_cb = f }
# what casts a shadow, drawn into the light's view `light_vp`
function r3d_on_casters(f: fn(floats)) -> void { r3d_casters_cb = f }
# a streamed layer's chunk (cx, cz) at detail `band`, filled with its instances
function r3d_on_stream_fill(f: fn(Stream, int, int, int)) -> void { r3d_fill_cb = f }
function r3d_scene_draw() -> void { if r3d_draw_cb != null { r3d_draw_cb() } }
function r3d_scene_casters(light_vp: floats) -> void { if r3d_casters_cb != null { r3d_casters_cb(light_vp) } }
function r3d_stream_fill(s: Stream, cx: int, cz: int, band: int) -> void { if r3d_fill_cb != null { r3d_fill_cb(s, cx, cz, band) } }

View file

@ -1,8 +1,11 @@
# ============================================================================ # ============================================================================
# ludic.render3d — a physically based 3D renderer on Gl.* (OpenGL 4.1 core). # ludic.render3d — a physically based 3D renderer on Gl.* (OpenGL 4.1 core).
# Import this one file; the game supplies scene_draw() / scene_draw_casters(). # Import this one file, and register what a frame draws: r3d_on_draw, r3d_on_casters and, for a
# streamed layer, r3d_on_stream_fill (hooks.ludic).
# ============================================================================ # ============================================================================
numbers float # the renderer's numbers are floats whoever imports it
import "env.ludic" import "env.ludic"
import "hooks.ludic"
import "fmath.ludic" import "fmath.ludic"
import "gpu.ludic" import "gpu.ludic"
import "gpu_manifest.ludic" import "gpu_manifest.ludic"

View file

@ -257,7 +257,7 @@ function r3d_frame(time: float) -> void {
prof_end() prof_end()
prof_cpu_mark("terrain") prof_cpu_mark("terrain")
prof_begin("scene (vegetation)") prof_begin("scene (vegetation)")
scene_draw() r3d_scene_draw()
prof_end() prof_end()
sc_prepass = false sc_prepass = false
prof_cpu_mark("vegetation") prof_cpu_mark("vegetation")

View file

@ -165,7 +165,7 @@ function shadow_pass() -> void {
# shadows off (a video setting): the cascades stay cleared, so everything reads lit # shadows off (a video setting): the cascades stay cleared, so everything reads lit
if sh_enabled { if sh_enabled {
if not sh_skip_terrain { terrain_draw_shadow(vp) } if not sh_skip_terrain { terrain_draw_shadow(vp) }
scene_draw_casters(vp) r3d_scene_casters(vp)
} }
} }
gpu_depth_bias(0.0, 0.0) gpu_depth_bias(0.0, 0.0)

View file

@ -233,7 +233,7 @@ function stream_update(s: Stream, cam_x: float, cam_z: float) -> void {
c.key = key c.key = key
s.cur = c s.cur = c
let t0 = gl_now_us() let t0 = gl_now_us()
stream_fill(s, cx, cz, band) r3d_stream_fill(s, cx, cz, band)
let dt = gl_now_us() - t0 let dt = gl_now_us() - t0
stream_us_gen = stream_us_gen + dt stream_us_gen = stream_us_gen + dt
prof_chunk(s.kind, band, c.count, dt) prof_chunk(s.kind, band, c.count, dt)

View file

@ -86,7 +86,7 @@ function water_reflection_pass() -> void {
prof_cpu_mark("reflection setup") prof_cpu_mark("reflection setup")
terrain_draw() terrain_draw()
prof_cpu_mark("reflection terrain") prof_cpu_mark("reflection terrain")
scene_draw() r3d_scene_draw()
prof_cpu_mark("reflection scene") prof_cpu_mark("reflection scene")
r3d_draw_sky() r3d_draw_sky()
prof_cpu_mark("reflection sky") prof_cpu_mark("reflection sky")

View file

@ -722,7 +722,7 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "error") { bare = "job_error"; push(labels, "handle") } if (meth == "error") { bare = "job_error"; push(labels, "handle") }
if (meth == "pending") { bare = "job_pending" } if (meth == "pending") { bare = "job_pending" }
if (meth == "free") { bare = "job_free"; push(labels, "handle") } if (meth == "free") { bare = "job_free"; push(labels, "handle") }
if (meth == "parallel_for") { bare = "job_parallel_for"; push(labels, "count"); push(labels, "work"); push(labels, "ctx") } if (meth == "parallel_for") { bare = "job_parallel_for"; push(labels, "count"); push(labels, "work"); push(labels, "ctx"); if len(e.kids) > 1 { check_worker_ref(e.kids[1]) } }
if (meth == "is_worker") { bare = "job_is_worker" } if (meth == "is_worker") { bare = "job_is_worker" }
} }
if (ns == "Promise") { if (ns == "Promise") {
@ -1035,6 +1035,9 @@ function emit_variant_new(en: Node, ord: int, args: []Node) -> Val {
} }
function emit_call(e: Node) -> Val { function emit_call(e: Node) -> Val {
# a call through a value of a function type (L2): a local, a global, a field, an element
let fv = callee_value(e)
if fv != null { return emit_indirect_call(fv, e) }
# `Subject.action(...)` — a namespaced builtin (Screen/Random/Input). # `Subject.action(...)` — a namespaced builtin (Screen/Random/Input).
if e.a.kind == E_MEMBER { if e.a.kind == E_MEMBER {
if e.a.a.kind == E_ID { return emit_ns_call(e.a.a.s, e.a.s, e) } if e.a.a.kind == E_ID { return emit_ns_call(e.a.a.s, e.a.s, e) }
@ -1462,14 +1465,7 @@ function emit_expr(e: Node) -> Val {
if e.kind == E_LIST { return emit_list(e) } # [a, b, c] -> a fresh slice if e.kind == E_LIST { return emit_list(e) } # [a, b, c] -> a fresh slice
# fn name -> the function's address, for a worker entry point. The OS-thread runtime calls it # fn name -> the function's address, for a worker entry point. The OS-thread runtime calls it
# as void(i32, ptr), so that is the only signature a reference may have. # as void(i32, ptr), so that is the only signature a reference may have.
if e.kind == E_FNREF { if e.kind == E_FNREF { return emit_fnref(e) }
let d = find_fn(e.s)
if d == null { perr(`fn {e.s}: no function called {e.s}`) }
var ok = len(d.kids) == 2 and llty(d.ty) == "void"
if ok { ok = llty(d.kids[0].ty) == "i32" and llty(d.kids[1].ty) == "ptr" }
if not ok { perr(`fn {e.s}: a worker function takes (i: int, ctx: pointer) and returns nothing`) }
return val(`@fn_{e.s}`, "pointer")
}
if e.kind == E_ID { if e.kind == E_ID {
let li = loc_find(e.s) let li = loc_find(e.s)
if li >= 0 { return emit_load_at(loc_reg[li], loc_ty[li]) } if li >= 0 { return emit_load_at(loc_reg[li], loc_ty[li]) }

View file

@ -109,6 +109,7 @@ function to_fixed(v: Val) -> pointer {
# narrows with trunc; everything else (same width, or ptr) passes through. # narrows with trunc; everything else (same width, or ptr) passes through.
function coerce_code(v: Val, target: pointer) -> pointer { function coerce_code(v: Val, target: pointer) -> pointer {
if is_fp(target) { return to_fp(v, target, `a {target} slot`) } if is_fp(target) { return to_fp(v, target, `a {target} slot`) }
if is_fn_type(target) and is_fn_type(v.ty) and not (v.ty == target) { perr(`a {v.ty} is not a {target}`) }
if (target == "fixed") and not (fixed_lit_code(v) == "") { return fixed_lit_code(v) } if (target == "fixed") and not (fixed_lit_code(v) == "") { return fixed_lit_code(v) }
if is_fp(v.ty) and not is_fp(target) and (llty(target) != "ptr") { if is_fp(v.ty) and not is_fp(target) and (llty(target) != "ptr") {
perr(`a {v.ty} does not convert to {target} implicitly — write int(x) or fixed(x)`) perr(`a {v.ty} does not convert to {target} implicitly — write int(x) or fixed(x)`)

View file

@ -0,0 +1,131 @@
# emit_fnval.ludic — L2: functions as values. A function type is written `fn(int, float) -> bool`
# and carried as the text "fn(int,float)->bool" (ptype normalises it); its value is the function's
# address, `fn name` takes one, and a call through anything of a function type is an indirect call.
function is_fn_type(t: pointer) -> bool {
if t == null { return false }
return len(t) > 3 and t[0] == 'f' and t[1] == 'n' and t[2] == '('
}
# the function type of a declared function, in ptype's own spelling
function fn_sig_of(d: Node) -> pointer {
var out = "fn("
var i = 0
while i < len(d.kids) {
if i > 0 { out = out + "," }
out = out + d.kids[i].ty
i += 1
}
var r = d.ty
if r == null { r = "void" }
return out + ")->" + r
}
# the parameter types of a function type: split at its top-level commas (a parameter may be a
# function type itself, whose commas are inside its own parentheses)
function fn_ty_params(t: pointer) -> []pointer {
let out = new []pointer
var depth = 0
var start = 3
var i = 3
while t[i] != 0 {
let c = t[i]
if c == '(' { depth += 1 }
if c == ')' {
if depth == 0 {
if i > start { push(out, t[start..i]) }
return out
}
depth -= 1
}
if c == ',' and depth == 0 {
push(out, t[start..i])
start = i + 1
}
i += 1
}
return out
}
# the result type: everything after the ")->" that closes the parameters
function fn_ty_ret(t: pointer) -> pointer {
var depth = 0
var i = 3
while t[i] != 0 {
if t[i] == '(' { depth += 1 }
if t[i] == ')' {
if depth == 0 { return t[i + 3..len(t)] }
depth -= 1
}
i += 1
}
return "void"
}
# `fn name`: the function's address, typed by its signature
function emit_fnref(e: Node) -> Val {
let d = find_fn(e.s)
if d == null { perr(`fn {e.s}: no function called {e.s}`) }
return val(`@fn_{e.s}`, fn_sig_of(d))
}
# a call through a value of a function type
function emit_indirect_call(fv: Val, e: Node) -> Val {
let ptys = fn_ty_params(fv.ty)
let ret = fn_ty_ret(fv.ty)
if len(e.kids) != len(ptys) { perr(`a {fv.ty} takes {itoa(len(ptys))} argument(s), and this call gives {itoa(len(e.kids))}`) }
let args = new []pointer
var i = 0
while i < len(e.kids) {
let v = emit_expr(e.kids[i])
push(args, coerce_code(v, ptys[i]))
i += 1
}
let rl = llty(ret)
emit(" ")
var rreg = "0"
if not (rl == "void") {
rreg = nreg()
emit(rreg)
emit(" = ")
}
emit(`call {rl} {fv.code}(`)
i = 0
while i < len(args) {
if i > 0 { emit(", ") }
emit(`{llty(ptys[i])} {args[i]}`)
i += 1
}
emit(")\n")
return val(rreg, ret)
}
# a callee that is a value rather than a name: a local, a global, a field or an element of a
# function type. null when the callee names a function (or a namespace) the ordinary way.
function callee_value(e: Node) -> Val {
let c = e.a
if c.kind == E_ID {
let li = loc_find(c.s)
if li >= 0 and is_fn_type(loc_ty[li]) { return emit_expr(c) }
if li < 0 {
let g = find_global(c.s)
if g != null and is_fn_type(g.ty) { return emit_expr(c) }
}
return null
}
if c.kind == E_MEMBER {
# `obj.field(...)`: obj is a local or a global, never a namespace; a field that is not a
# function leaves the call to the ordinary path (loading it had no side effect)
if c.a.kind == E_ID and loc_find(c.a.s) < 0 and find_global(c.a.s) == null { return null }
let mv = emit_expr(c)
if is_fn_type(mv.ty) { return mv }
return null
}
let v = emit_expr(c)
if not is_fn_type(v.ty) { perr(`a {v.ty} is not a function and cannot be called`) }
return v
}
# Job.parallel_for runs its worker on OS threads that call it as void(i32, ptr)
function check_worker_ref(e: Node) -> void {
if e.kind != E_FNREF { return }
let d = find_fn(e.s)
if d == null { return }
# the thread calls it as void(i32, ptr): any pointer-sized context will do (words, a record)
var ok = len(d.kids) == 2 and llty(d.ty) == "void"
if ok { ok = llty(d.kids[0].ty) == "i32" and llty(d.kids[1].ty) == "ptr" }
if not ok { perr(`fn {e.s}: a worker function takes (i: int, ctx: pointer) and returns nothing`) }
}

View file

@ -35,23 +35,28 @@ function emit_noise_ns(meth: pointer, e: Node) -> Val {
} }
g_uses_noisert = true g_uses_noisert = true
if (meth == "value2") { if (meth == "value2") {
noise_arity(e, "value2", 3)
let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2]) let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2])
return val(emit_bind(`call i32 @lp_noise_value2(i32 {x.code}, i32 {y.code}, i32 {s.code})`), "fixed") return val(emit_bind(`call i32 @lp_noise_value2(i32 {x.code}, i32 {y.code}, i32 {s.code})`), "fixed")
} }
if (meth == "perlin2") { if (meth == "perlin2") {
noise_arity(e, "perlin2", 3)
let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2]) let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2])
return val(emit_bind(`call i32 @lp_noise_perlin2(i32 {x.code}, i32 {y.code}, i32 {s.code})`), "fixed") return val(emit_bind(`call i32 @lp_noise_perlin2(i32 {x.code}, i32 {y.code}, i32 {s.code})`), "fixed")
} }
if (meth == "simplex2") { if (meth == "simplex2") {
noise_arity(e, "simplex2", 3)
let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2]) let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2])
return val(emit_bind(`call i32 @lp_noise_simplex2(i32 {x.code}, i32 {y.code}, i32 {s.code})`), "fixed") return val(emit_bind(`call i32 @lp_noise_simplex2(i32 {x.code}, i32 {y.code}, i32 {s.code})`), "fixed")
} }
if (meth == "fbm2") { if (meth == "fbm2") {
noise_arity(e, "fbm2", 4)
g_uses_mathrt = true # simplex path is standalone; fbm needs fx_div only (local) g_uses_mathrt = true # simplex path is standalone; fbm needs fx_div only (local)
let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2]); let o = emit_expr(e.kids[3]) let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2]); let o = emit_expr(e.kids[3])
return val(emit_bind(`call i32 @lp_noise_fbm2(i32 {x.code}, i32 {y.code}, i32 {s.code}, i32 {o.code})`), "fixed") return val(emit_bind(`call i32 @lp_noise_fbm2(i32 {x.code}, i32 {y.code}, i32 {s.code}, i32 {o.code})`), "fixed")
} }
if (meth == "cellular2") { if (meth == "cellular2") {
noise_arity(e, "cellular2", 3)
g_uses_mathrt = true # F1 distance needs @lp_fx_sqrt g_uses_mathrt = true # F1 distance needs @lp_fx_sqrt
let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2]) let x = emit_expr(e.kids[0]); let y = emit_expr(e.kids[1]); let s = emit_expr(e.kids[2])
return val(emit_bind(`call i32 @lp_noise_cellular2(i32 {x.code}, i32 {y.code}, i32 {s.code})`), "fixed") return val(emit_bind(`call i32 @lp_noise_cellular2(i32 {x.code}, i32 {y.code}, i32 {s.code})`), "fixed")
@ -215,3 +220,13 @@ function emit_noise_cellular() -> void {
emith(" %d2 = alloca i32\n %id = alloca i32\n call void @lp_noise_cell_scan(i32 %x, i32 %y, i32 %seed, ptr %d2, ptr %id)\n") emith(" %d2 = alloca i32\n %id = alloca i32\n call void @lp_noise_cell_scan(i32 %x, i32 %y, i32 %seed, ptr %d2, ptr %id)\n")
emith(" %r = load i32, ptr %id\n ret i32 %r\n}\n") emith(" %r = load i32, ptr %id\n ret i32 %r\n}\n")
} }
# Noise.* take their arguments by position, and fbm2's fourth is the octave count: a call one short
# used to read past its arguments and crash the compiler - or, where the call happened to fit,
# put the octaves in the seed and give a flat 0.5 everywhere
function noise_arity(e: Node, meth: pointer, n: int) -> void {
if len(e.kids) != n { perr(`Noise.{meth} takes {itoa(n)} arguments (x, y, seed{noise_arity_more(n)}), and this call gives {itoa(len(e.kids))}`) }
}
function noise_arity_more(n: int) -> pointer {
if n == 4 { return ", octaves" }
return ""
}

View file

@ -65,8 +65,27 @@ function eat_id() -> pointer {
} }
function skipnl() -> void { while toks[pi].kind == TK_NL { pi += 1 } } function skipnl() -> void { while toks[pi].kind == TK_NL { pi += 1 } }
# a type: `[]T` slice, or a plain name (int/ptr/str/bool/struct) # a type: `[]T` slice, `fn(T, U) -> R` function, or a plain name (int/ptr/str/bool/struct)
function ptype() -> pointer { function ptype() -> pointer {
if (toks[pi].text == "fn") and (toks[pi + 1].text == "(") {
pi += 1
eat_op("(")
var out = "fn("
var first = true
while not is_op(")") {
if not first { out = out + "," }
out = out + ptype()
first = false
if is_op(",") { pi += 1 }
}
eat_op(")")
var r = "void"
if is_op("->") {
pi += 1
r = ptype()
}
return out + ")->" + r
}
if is_op("[") { if is_op("[") {
pi += 1 pi += 1
eat_op("]") eat_op("]")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -53,6 +53,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/backend/emit_new.ludic") push(f, "selfhost/backend/emit_new.ludic")
push(f, "selfhost/backend/emit_expr.ludic") push(f, "selfhost/backend/emit_expr.ludic")
push(f, "selfhost/backend/emit_call.ludic") push(f, "selfhost/backend/emit_call.ludic")
push(f, "selfhost/backend/emit_fnval.ludic")
push(f, "selfhost/backend/emit_stmt.ludic") push(f, "selfhost/backend/emit_stmt.ludic")
push(f, "selfhost/backend/game/emit_ecs.ludic") push(f, "selfhost/backend/game/emit_ecs.ludic")
push(f, "selfhost/backend/game/emit_query.ludic") push(f, "selfhost/backend/game/emit_query.ludic")
@ -102,8 +103,14 @@ function cmd_selfhost_build(lc: pointer, outbin: pointer) -> int {
let src = "build/selfhost.ludic" let src = "build/selfhost.ludic"
if not write_selfhost_src(src) { err("ludic-dev: cannot write build/selfhost.ludic\n"); return 1 } if not write_selfhost_src(src) { err("ludic-dev: cannot write build/selfhost.ludic\n"); return 1 }
let ll = `{outbin}.ll` let ll = `{outbin}.ll`
if not shq(`{lc} {src} > {ll} 2>/dev/null`) { shell(`rm -f {ll}`); return 1 } # say why when it fails: it used to exit 1 with nothing on the screen
let rc = shq(`{cc()} {ll} -o {outbin} 2>/dev/null`) if not shq(`{lc} {src} > {ll} 2>{tmp_dir()}/shb.err`) {
shell(`rm -f {ll}`)
err(capture(`grep -i error {tmp_dir()}/shb.err | head -5`))
return 1
}
let rc = shq(`{cc()} {ll} -o {outbin} 2>{tmp_dir()}/shb.err`)
if not rc { err(capture(`grep -i error {tmp_dir()}/shb.err | head -5`)) }
shell(`rm -f {ll}`) shell(`rm -f {ll}`)
if rc { return 0 } if rc { return 0 }
return 1 return 1

View file

@ -704,6 +704,8 @@ function cmd_dev_test() -> int {
reject_case("rejected/duplicate_property", "'Look' is defined twice", "a property defined twice is refused") reject_case("rejected/duplicate_property", "'Look' is defined twice", "a property defined twice is refused")
reject_case("rejected/redeclared_local", "'v0' is declared twice in this block", "a local declared twice in one block is refused") reject_case("rejected/redeclared_local", "'v0' is declared twice in this block", "a local declared twice in one block is refused")
reject_case("rejected/missing_return", "can reach its end without returning", "a function that can run off its end without its result is refused") reject_case("rejected/missing_return", "can reach its end without returning", "a function that can run off its end without its result is refused")
feat_case("functions/values", "", "42 10 25 49 81 1.5 1", "values.ludic (L2: fn types, fn name, calls through a local, global, field, element, parameter, result)")
reject_case("rejected/fn_type_mismatch", "is not a fn(int)->int", "a function of the wrong type is refused")
# EV2 the world table: the mod reflection ABI, callable from Ludic by name. # EV2 the world table: the mod reflection ABI, callable from Ludic by name.
net_case("ecs/world_get", "50 1 7") net_case("ecs/world_get", "50 1 7")

View file

@ -87,6 +87,11 @@ class Program:
self.uf = UF() self.uf = UF()
self.types = {} # key -> declared type text self.types = {} # key -> declared type text
for d in self.all_decls(): self.types[d.key] = d.ty for d in self.all_decls(): self.types[d.key] = d.ty
# a declaration that already says float is evidence of its own: code converted earlier
# (render3d, once the game had moved) is read as it is written
for d in self.all_decls():
if d.ty == 'float': self.uf.mark(d.key, 'f', 'declared float')
elif d.ty in ('floats', '[]float'): self.uf.mark(('E', d.key), 'f', 'declared floats')
self.calls = [] # (callee Func, arg index, arg key, node) self.calls = [] # (callee Func, arg index, arg key, node)
self.bound_args = [] # (call node, arg node, arg key): a float going into a runtime call self.bound_args = [] # (call node, arg node, arg key): a float going into a runtime call
self.bound_rets = {} # call node id -> key of a runtime result self.bound_rets = {} # call node id -> key of a runtime result