feat: per-voice audio, outlines for what is not an actor, and a coast

Three things a game could not say, each of which had been worked around.

Audio.play_at(id, gain:, pitch:, pan:) fires a one-shot with its own gain,
pitch and stereo position. Audio.volume and Audio.pitch are global - they are
the options screen - so a game placing a sound in the world was fighting them,
and distance attenuation was simply not expressible. The backend already took
volume and rate per call; this adds setPan: alongside them and stops routing
through the master state.

ludic.render3d gains outline_model(model, mat, width, r, g, b): a rim around
something that is not an Actor. The outline pass walked the actor list and
stopped, so instanced scatter - a forest - could not be highlighted at all. It
is a queue flushed by the same pass, which is what gets the depth test right
when the caller does not control pass order.

And terrain_coast(cx, cz, margin, fall), the other way to make an island:
the sea around the survey's own edge rather than cut out of the middle of it.
terrain_island measures a radius from a centre, which drowns two thirds of a
real survey to make an island of the rest; this measures inward from the
boundary, so everything the data covers stays land and the coast is where the
data runs out. Both modes gained a strand - the last few metres of height
either side of the water line compressed, which stretches a cliff plunge out
into beach and shallows.

132 regression tests and the self-host fixpoints pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-11 15:25:46 +03:00
parent 39ad8e85d9
commit 3c27606747
15 changed files with 30113 additions and 29690 deletions

View file

@ -186,6 +186,44 @@ function actor_draw_one(a: Actor, ap: AcProg, shadow: bool) -> void {
}
}
# ---- outlining something that is not an actor ---------------------------------------------
# An Actor gets its rim from its own `outline` field. Everything else in a scene - a
# scatter instance, a tile, anything drawn by a system that owns its own transforms - had
# no way to ask for one, because the outline pass walked the actor list and nothing else.
# A tree is the case that matters: instanced scatter has no actor to hull, so a game that
# highlights whatever the crosshair is on could highlight every object in the world except
# the twenty thousand most common ones.
#
# So: a queue. `outline_model` says "put a rim round this model at this transform" from
# anywhere in the frame, and the outline pass flushes it alongside the actors' - which is
# what gets the depth test right, since the rim has to be drawn after the scene it is
# tested against and the caller does not control pass order.
#
# The rim is the model's own geometry swollen along its normals. A layer whose vertex
# shader moves its instances - wind, or standing them on the drawn terrain - will differ
# from the rim by however much that shader moved them, so pass the transform the shader
# would have arrived at (for a grounded layer, the terrain height at that instance).
property OutlineReq {
model: Model,
mat: words,
width: int = 0,
r: int = 0,
g: int = 0,
b: int = 0
}
var ac_oq: []OutlineReq = null
var ac_oq_n: int = 0 # live entries; the array is kept and reused
function outline_model(m: Model, mat: words, width: int, r: int, g: int, b: int) -> void {
if m == null or mat == null or width == 0 { return }
if ac_oq == null { ac_oq = new []OutlineReq }
var q: OutlineReq = null
if ac_oq_n < len(ac_oq) { q = ac_oq[ac_oq_n] } else { q = new OutlineReq; q.mat = m4_new(); push(ac_oq, q) }
q.model = m; q.width = width; q.r = r; q.g = g; q.b = b
m4_copy(q.mat, mat)
ac_oq_n += 1
}
function outline_clear() -> void { ac_oq_n = 0 }
function actor_draw() -> void {
if ac_actors == null { return }
ac_frame += 1
@ -205,7 +243,7 @@ function actor_draw() -> void {
# swollen shell survives — a silhouette exactly `outline` metres wide. Depth-tested
# against the scene, so anything in front of the actor hides its rim too.
function actor_draw_outlines() -> void {
var any = false
var any = ac_oq_n > 0
for i in 0 .. len(ac_actors) { if ac_actors[i].outline != 0 and ac_visible(ac_actors[i], false) { any = true; break } }
if not any { return }
gl_enable(GL_CULL_FACE)
@ -221,6 +259,19 @@ function actor_draw_outlines() -> void {
if a.ocol != null { u_v3(ap.l_ocol, a.ocol) } else { u_f3(ap.l_ocol, F_ONE, F_ONE, F_ONE) }
actor_draw_outline_one(a, ap)
}
# and whatever asked for a rim without being an actor
for i in 0 .. ac_oq_n {
let q = ac_oq[i]
let ap = ac_out
gl_use_program(ap.prog)
u_mat4(ap.l_view, cam_view); u_mat4(ap.l_proj, cam_proj)
u_f(ap.l_out, q.width)
u_f3(ap.l_ocol, q.r, q.g, q.b)
u_mat4(ap.l_model, q.mat)
u_f(ap.l_skin, F_ZERO)
for k in 0 .. len(q.model.prims) { mesh_draw(q.model.prims[k].mesh) }
}
ac_oq_n = 0
gl_cull_face(GL_BACK)
}
function actor_draw_outline_one(a: Actor, ap: AcProg) -> void {