feat(gl): OpenGL 4.1 and the ludic.render3d renderer
`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:
parent
470971bf70
commit
f25289db20
90 changed files with 35316 additions and 19853 deletions
49
examples/rendering/gl_triangle.ludic
Normal file
49
examples/rendering/gl_triangle.ludic
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# gl_triangle.ludic — the smallest Gl.* program: one shaded triangle through a
|
||||
# real OpenGL 4.1 core context. Windowed it draws to the screen each frame;
|
||||
# headless it renders into an offscreen framebuffer and screenshots it, so the
|
||||
# test harness can look at the result without a display.
|
||||
# bin/ludic build examples/rendering/gl_triangle.ludic --headless && echo q | build/gl_triangle_headless
|
||||
program GlTriangle {
|
||||
property Marker { on: int = 1 }
|
||||
model Anchor { Marker }
|
||||
|
||||
var prog: int = 0
|
||||
var vao: int = 0
|
||||
var frame: int = 0
|
||||
|
||||
const VS: string = "#version 410 core\nlayout(location=0) in vec2 p; layout(location=1) in vec3 c; out vec3 vc;\nvoid main(){ vc = c; gl_Position = vec4(p, 0.0, 1.0); }\n"
|
||||
const FS: string = "#version 410 core\nin vec3 vc; out vec4 o;\nvoid main(){ o = vec4(vc, 1.0); }\n"
|
||||
|
||||
handler Boot phase Start {
|
||||
spawn Anchor {}
|
||||
if not Gl.open(width: 640, height: 360, title: "Ludic GL") { print("no GL context"); quit() }
|
||||
print(Gl.get_string(name: GL_VERSION))
|
||||
prog = Gl.program(vs: VS, fs: FS)
|
||||
vao = Gl.vao()
|
||||
let vbo = Gl.buffer()
|
||||
Gl.bind_buffer(target: GL_ARRAY_BUFFER, buffer: vbo)
|
||||
let v = Gl.floats(15)
|
||||
Gl.put(v, 0, -0.8); Gl.put(v, 1, -0.8); Gl.put(v, 2, 1.0); Gl.put(v, 3, 0.2); Gl.put(v, 4, 0.1)
|
||||
Gl.put(v, 5, 0.8); Gl.put(v, 6, -0.8); Gl.put(v, 7, 0.1); Gl.put(v, 8, 1.0); Gl.put(v, 9, 0.2)
|
||||
Gl.put(v, 10, 0.0); Gl.put(v, 11, 0.8); Gl.put(v, 12, 0.2); Gl.put(v, 13, 0.3); Gl.put(v, 14, 1.0)
|
||||
Gl.buffer_data(target: GL_ARRAY_BUFFER, size: Gl.bytes_of(15), data: v, usage: GL_STATIC_DRAW)
|
||||
Gl.enable_vertex_attrib_array(index: 0)
|
||||
Gl.vertex_attrib_pointer(index: 0, size: 2, type: GL_FLOAT, normalized: 0, stride: 20, offset: null)
|
||||
Gl.enable_vertex_attrib_array(index: 1)
|
||||
Gl.vertex_attrib_pointer(index: 1, size: 3, type: GL_FLOAT, normalized: 0, stride: 20, offset: Gl.ptr(null, 8))
|
||||
Gl.check(tag: "boot")
|
||||
}
|
||||
|
||||
handler Draw phase Render {
|
||||
Gl.bind_framebuffer(target: GL_FRAMEBUFFER, framebuffer: Gl.screen_fbo())
|
||||
Gl.viewport(x: 0, y: 0, width: Gl.width(), height: Gl.height())
|
||||
Gl.clear_color(red: 0.1, green: 0.12, blue: 0.18, alpha: 1.0)
|
||||
Gl.clear(mask: GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
||||
Gl.use_program(prog: prog)
|
||||
Gl.bind_vertex_array(array: vao)
|
||||
Gl.draw_arrays(mode: GL_TRIANGLES, first: 0, count: 3)
|
||||
frame += 1
|
||||
if frame == 1 { Gl.screenshot(path: "build/gl_triangle.ppm") }
|
||||
Gl.swap()
|
||||
}
|
||||
}
|
||||
189
examples/rendering/smooth.ludic
Normal file
189
examples/rendering/smooth.ludic
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# smooth.ludic — a synthetic 2 km test ground for the terrain renderer.
|
||||
#
|
||||
# The valley scene stands on a real survey (Copernicus GLO-30 resampled to a 4 m grid),
|
||||
# which carries its own resampling lattice and quantisation. This scene carries none:
|
||||
# the height field is an analytic function (see SMOOTH in heightgen.frag), so anything
|
||||
# that still looks like a grid here belongs to the renderer, not the data.
|
||||
#
|
||||
# bin/ludic build examples/rendering/smooth.ludic && ./build/smooth
|
||||
program Smooth {
|
||||
import "ludic.render3d/r3d.ludic"
|
||||
|
||||
property Marker { on: int = 1 }
|
||||
model Anchor { Marker }
|
||||
|
||||
var frame: int = 0
|
||||
var shot_at: int = 40
|
||||
var walk: bool = false
|
||||
var spin: bool = false
|
||||
var l_blades: Layer = null
|
||||
var l_grass_a: Layer = null
|
||||
var l_grass_b: Layer = null
|
||||
var s_blades: Stream = null
|
||||
var s_cards_a: Stream = null
|
||||
var s_cards_b: Stream = null
|
||||
var l_trees: Layer = null
|
||||
|
||||
function rnd() -> int { return fr(rng_range(0, 9999), 10000) }
|
||||
function rnd_range(a: int, b: int) -> int { return f_lerp(a, b, rnd()) }
|
||||
function smooth(a: int, b: int, x: int) -> int {
|
||||
let t = f_clamp(f_div(f_sub(x, a), f_sub(b, a)), F_ZERO, F_ONE)
|
||||
return f_mul(f_mul(t, t), f_sub(fi(3), f_mul(F_TWO, t)))
|
||||
}
|
||||
function slope_at(x: int, z: int) -> int {
|
||||
let e = F_TWO
|
||||
let dx = f_sub(terrain_height(f_add(x, e), z), terrain_height(f_sub(x, e), z))
|
||||
let dz = f_sub(terrain_height(x, f_add(z, e)), terrain_height(x, f_sub(z, e)))
|
||||
let ny = f_div(fi(4), f_sqrt(f_add(f_add(f_mul(dx, dx), f_mul(dz, dz)), fi(16))))
|
||||
return f_sub(F_ONE, ny)
|
||||
}
|
||||
function noise01(x: int, z: int, scale: fixed) -> int {
|
||||
let n = Noise.fbm2(f_fx(x) * scale, f_fx(z) * scale, 4)
|
||||
return fl(n * 0.5 + 0.5)
|
||||
}
|
||||
|
||||
# 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 {
|
||||
seed((cx * 73856093) ^ (cz * 19349663) ^ (band * 83492791) ^ (s.kind * 2654435761))
|
||||
let size = s.size
|
||||
let x0 = f_mul(fi(cx), size); let z0 = f_mul(fi(cz), size)
|
||||
# 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
|
||||
# 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.
|
||||
var step = fl(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) } }
|
||||
else { if band == 0 { step = fl(0.9) } else if band == 1 { step = fl(1.8) } else { step = fl(4.0) } }
|
||||
var z = z0
|
||||
while f_ls(z, f_add(z0, size)) {
|
||||
var x = x0
|
||||
while f_ls(x, f_add(x0, size)) {
|
||||
let px = f_add(x, f_mul(rnd(), step))
|
||||
let pz = f_add(z, f_mul(rnd(), step))
|
||||
let h = terrain_height(px, pz)
|
||||
# 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,
|
||||
# because the cut lands on a line of constant elevation.
|
||||
var keep = smooth(fl(0.2), fl(1.6), h) # out of the water
|
||||
keep = f_mul(keep, smooth(fi(90), fi(45), h)) # below the mountain
|
||||
keep = f_mul(keep, smooth(fl(0.42), fl(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 = f_mul(keep, fl(0.75))
|
||||
var sc = rnd_range(fl(0.16), fl(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 f_ls(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))
|
||||
}
|
||||
x = f_add(x, step)
|
||||
}
|
||||
z = f_add(z, step)
|
||||
}
|
||||
}
|
||||
|
||||
function scene_draw() -> void { scatter_draw() }
|
||||
function scene_draw_casters() -> void { scatter_draw_casters(shadow_cascade_vp(sh_cascade)) }
|
||||
|
||||
handler Boot phase Start {
|
||||
spawn Anchor {}
|
||||
# 2 km square, generated analytically
|
||||
TERRAIN_HALF = 1000
|
||||
ter_smooth = true
|
||||
if not r3d_init(1920, 1080, "Smooth") { quit() }
|
||||
walk = Os.has_env("R3D_WALK")
|
||||
spin = Os.has_env("R3D_SPIN")
|
||||
if Os.has_env("R3D_SHOT") { shot_at = Text.to_int(Os.env("R3D_SHOT")) }
|
||||
|
||||
var cx = fi(0); var cz = fi(260)
|
||||
if Os.has_env("R3D_CAM_X") { cx = fi(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"))) }
|
||||
var ch = fl(1.8); var cp = f_neg(fi(3)); var cy = fi(180)
|
||||
if Os.has_env("R3D_CAM_H") { ch = fi(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_YAW") { cy = fi(Text.to_int(Os.env("R3D_CAM_YAW"))) }
|
||||
cam_set(cx, f_add(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
|
||||
# its extent only has to cover the hollow, not the map
|
||||
water_init(fl(4.0), F_ZERO, F_ZERO, fi(200), fi(200))
|
||||
sky_set_yaw(f_rad(fi(120)))
|
||||
|
||||
# grass: blades underfoot, cards beyond
|
||||
let dir = r3d_assets + "/models/grass_medium_01"
|
||||
let ga = gltf_load(dir, "grass_medium_01_1k.gltf", "grass_medium_01_tall_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 }
|
||||
# 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.blade = true
|
||||
l_grass_a = layer_cards(ga, 200000, fl(0.3), fi(260))
|
||||
l_grass_b = layer_cards(gb, 200000, fl(0.3), fi(260))
|
||||
v3_set(l_grass_a.tint, fl(0.95), F_ONE, fl(0.85))
|
||||
v3_set(l_grass_b.tint, fl(0.9), fl(0.98), fl(0.8))
|
||||
l_grass_a.rough = fl(1.6); l_grass_b.rough = fl(1.6)
|
||||
s_blades = stream_new(l_blades, fi(8), fi(60), fi(12), fi(28), fi(60), fi(60))
|
||||
s_cards_a = stream_new(l_grass_a, fi(32), fi(240), fi(45), fi(110), fi(240), fi(240))
|
||||
s_cards_b = stream_new(l_grass_b, fi(32), fi(240), fi(45), fi(110), fi(240), fi(240))
|
||||
s_blades.kind = 0; s_cards_a.kind = 1; s_cards_b.kind = 2
|
||||
place_trees()
|
||||
}
|
||||
|
||||
# scattered firs on the gentle ground, thinning toward the pond and the rim
|
||||
function place_trees() -> void {
|
||||
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 }
|
||||
l_trees = layer_new(model, 20000, false, F_ZERO, fi(90), F_ZERO)
|
||||
layer_set_impostor(l_trees, impostor_bake(model, 12, 512, 1024))
|
||||
v3_set(l_trees.tint, fl(0.9), F_ONE, fl(0.85))
|
||||
seed(4242)
|
||||
var z = f_neg(fi(900))
|
||||
while f_ls(z, fi(900)) {
|
||||
var x = f_neg(fi(900))
|
||||
while f_ls(x, fi(900)) {
|
||||
let px = f_add(x, f_mul(rnd(), fi(14))); let pz = f_add(z, f_mul(rnd(), fi(14)))
|
||||
let h = terrain_height(px, pz)
|
||||
var keep = smooth(fi(7), fi(14), h) # above the pond shore
|
||||
keep = f_mul(keep, smooth(fi(120), fi(60), h)) # below the rim
|
||||
keep = f_mul(keep, smooth(fl(0.45), fl(0.2), slope_at(px, pz)))
|
||||
keep = f_mul(keep, smooth(fl(0.45), fl(0.75), noise01(px, pz, 0.004)))
|
||||
if f_ls(rnd(), f_mul(keep, fl(0.5))) {
|
||||
let sc = rnd_range(fl(0.7), fl(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)
|
||||
}
|
||||
x = f_add(x, fi(14))
|
||||
}
|
||||
z = f_add(z, fi(14))
|
||||
}
|
||||
}
|
||||
|
||||
handler Fly phase Input {
|
||||
if walk { cam_move(fl(1.0), F_ZERO, F_ZERO, fl(0.003), F_ZERO); return }
|
||||
if spin { cam_move(F_ZERO, F_ZERO, F_ZERO, fl(0.02), F_ZERO); return }
|
||||
if not is_windowed() { return }
|
||||
Input.poll()
|
||||
var fwd = F_ZERO; var side = F_ZERO; var up = F_ZERO
|
||||
let speed = fl(0.6)
|
||||
if Input.key_down(key: 'w') { fwd = speed }
|
||||
if Input.key_down(key: 's') { fwd = f_neg(speed) }
|
||||
if Input.key_down(key: 'd') { side = speed }
|
||||
if Input.key_down(key: 'a') { side = f_neg(speed) }
|
||||
if Input.key_down(key: 'e') { up = speed }
|
||||
if Input.key_down(key: 'q') { up = f_neg(speed) }
|
||||
var dyaw = F_ZERO; var dpitch = F_ZERO
|
||||
if Input.mouse_down(button: 0) {
|
||||
dyaw = f_mul(fi(Input.mouse_dx()), fl(-0.004))
|
||||
dpitch = f_mul(fi(Input.mouse_dy()), fl(-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 Input.key_pressed(key: 'p') {
|
||||
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))))}`)
|
||||
}
|
||||
}
|
||||
|
||||
handler Draw phase Render {
|
||||
r3d_frame(fl(Time.elapsed()))
|
||||
frame += 1
|
||||
if frame == shot_at { Gl.screenshot(path: "build/smooth.ppm") }
|
||||
Gl.swap()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue