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

@ -95,6 +95,8 @@ Characters and moving objects
66. A PPM reader with box-filtered downscale (photo thumbnails) and word-wrapped overlay text — `texture.ludic` `tex_load_ppm`, `overlay.ludic` `ov_text_wrap`
67. The overlay batches a whole frame into one upload and draws per texture range with optional scissor clips (per-change uploads stalled the driver: 40 ms -> 25 ms), plus nine-slice, rotated, line, disc and arc primitives — `overlay.ludic` `ov_flush`, `ov_clip`, `ov_nine`, `ov_sub_rot`, `ov_line`, `ov_disc`, `ov_arc`
68. Weather on the daylight: an overcast factor greys and dims the sun and the sky's light, a fog multiplier thickens the air, a lightning flash — `daylight.ludic` `daylight_weather`
69. An outline pass: an actor carries a rim width and colour, drawn as an inverted hull depth-tested against the scene, and anything that is not an actor can queue one at a transform (a scatter instance, a tile) — `actor.ludic` `actor_draw_outlines`, `outline_model`, `shaders/outline.frag`
70. Two ways to make an island out of a survey: a radius from a centre, or the sea put around the survey's own edge with a noise-wobbled coast, both scaling the existing relief into the water and compressing the last few metres of height either side of the line into beach and shallows — `terrain.ludic` `terrain_island`, `terrain_coast`, `shaders/heightgen.frag`
## Against Unreal Engine 5 and RAGE (RDR2), honestly

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 {

View file

@ -10,7 +10,8 @@ uniform float u_dem_base; // the elevation that becomes y = 0
uniform vec2 u_origin; // world x/z of the map's centre
uniform vec4 u_lake; // a lake: centre x/z, half extents (zero = none)
uniform float u_lake_level; // its surface height; the model records the surface, the bed is carved below it
uniform vec4 u_isle; // an island: centre x/z, radius, falloff width (radius 0 = none)
uniform vec4 u_isle; // an island: centre x/z, radius (or rim margin), falloff width
uniform float u_isle_mode; // 0 none, 1 radial (terrain_island), 2 inward from the survey's edge (terrain_coast)
// The DEM is Copernicus GLO-30 — 30 m data resampled onto this 4 m grid — so the stored
// field is piecewise linear with a slope discontinuity every ~7 texels. Differencing it
// for a shading normal turns each kink into a ridge, and on steep ground, where the same
@ -59,16 +60,39 @@ void main() {
float bed = u_lake_level - 0.4 - (5.0 + 3.0 * (1.0 - dot(q, q)) + 1.5 * fbm(xz * 0.02, 3)) * basin * basin; // a gentle gravel ramp, then the drop
h = mix(h, bed, basin);
}
// The island. Outside the radius the terrain is scaled toward the water line and then
// taken below it, so the shoreline is not drawn on: it is wherever the survey's own
// relief, shrunk, happens to cross the water. Low ground becomes beach and shallows,
// high ground becomes cliff, and a bay stays a bay.
if (u_isle.z > 0.0) {
float d = distance(xz, u_isle.xy);
float t = smoothstep(u_isle.z, u_isle.z + u_isle.w, d); // 0 inland, 1 past the fall
float shelf = 26.0 + 34.0 * smoothstep(0.0, 1.0, (d - u_isle.z - u_isle.w) / max(u_isle.w, 1.0));
// The island. The terrain is scaled toward the water line and then taken below it, so
// the shoreline is not drawn on: it is wherever the survey's own relief, shrunk, happens
// to cross the water. Low ground becomes beach and shallows, high ground becomes cliff,
// and a bay stays a bay.
//
// Two ways of deciding where that happens. RADIAL measures out from a centre, which
// suits a made-up map. COAST measures inward from the survey's own boundary, which suits
// a real one: everything the data covers stays land and the sea starts where the data
// runs out, rather than two thirds of an expensive survey being drowned to make an
// island of the rest.
if (u_isle_mode > 0.5 && u_isle.z > 0.0) {
float t;
float shelf;
if (u_isle_mode > 1.5) {
vec2 e = abs(xz - u_isle.xy);
float inland = u_half - max(e.x, e.y); // metres in from the boundary
inland += 180.0 * (fbm(xz * 0.00085, 4) * 2.0 - 1.0); // headlands and bays
t = 1.0 - smoothstep(u_isle.z, u_isle.z + u_isle.w, inland);
shelf = 26.0 + 34.0 * smoothstep(u_isle.z, 0.0, inland);
} else {
float d = distance(xz, u_isle.xy);
t = smoothstep(u_isle.z, u_isle.z + u_isle.w, d); // 0 inland, 1 past the fall
shelf = 26.0 + 34.0 * smoothstep(0.0, 1.0, (d - u_isle.z - u_isle.w) / max(u_isle.w, 1.0));
}
float above = h - u_lake_level;
h = u_lake_level + above * (1.0 - t) - t * shelf;
// A strand. Scaling alone hands a 500 m mountainside a 40-degree plunge into the sea,
// which is a cliff coast and nothing else. Real shores are cut flat by the water they
// meet, so the last few metres of height either side of the line are compressed —
// which stretches them out horizontally into beach and shallows. Only inside the
// coastal band: an inland lake has its own bed and wants none of this.
float near = 1.0 - smoothstep(0.0, 48.0, abs(h - u_lake_level));
h = mix(h, u_lake_level + (h - u_lake_level) * 0.30, near * smoothstep(0.02, 0.30, t));
}
o = vec4(h, 0.0, 0.0, 1.0);
#elif defined(SMOOTH)

View file

@ -72,12 +72,36 @@ function terrain_lake(level: int, cx: int, cz: int, ex: int, ez: int) -> void {
# over `fall` metres and on down to a shelf. Scaling rather than blending to a fixed bed is
# what makes the coastline come out of the terrain that is already there — low ground turns
# into beach and shallows, high ground into cliff. `r = 0` leaves the survey alone.
const TER_ISLE_NONE: int = 0
const TER_ISLE_RADIAL: int = 1
const TER_ISLE_COAST: int = 2
var ter_isle_cx: int = 0
var ter_isle_cz: int = 0
var ter_isle_r: int = 0
var ter_isle_fall: int = 0
var ter_isle_mode: int = 0
function terrain_island(cx: int, cz: int, r: int, fall: int) -> void {
ter_isle_cx = cx; ter_isle_cz = cz; ter_isle_r = r; ter_isle_fall = fall
ter_isle_mode = TER_ISLE_RADIAL
if r == 0 { ter_isle_mode = TER_ISLE_NONE }
}
# The other way to make an island, and the one a real survey usually wants: put the sea
# around the survey's OWN EDGE rather than cutting a circle out of the middle of it.
# Everything the data covers stays land; the outer `margin` metres are under water and the
# `fall` metres inside that are scaled down into it, exactly as terrain_island scales.
#
# The difference matters more than it sounds. Measuring a radius from a point in the middle
# of an 8 km mountain survey drowns most of the survey to make an island of the rest —
# you paid for the data and then threw two thirds of it away. Measuring inward from the
# boundary keeps all of it and puts the coast where the data runs out, which is also where
# a surveyor would tell you it runs out.
#
# The band is wobbled by low-frequency noise so the coastline is headlands and bays rather
# than the square the data arrived in. `margin = 0` leaves the survey alone.
function terrain_coast(cx: int, cz: int, margin: int, fall: int) -> void {
ter_isle_cx = cx; ter_isle_cz = cz; ter_isle_r = margin; ter_isle_fall = fall
ter_isle_mode = TER_ISLE_COAST
if margin == 0 { ter_isle_mode = TER_ISLE_NONE }
}
# Use a real place: a 16-bit PNG height map plus its elevation range (metres). The
@ -206,6 +230,7 @@ function terrain_generate() -> void {
u_f4(gl_uniform(p, "u_lake"), ter_lake_cx, ter_lake_cz, ter_lake_ex, ter_lake_ez)
u_f(gl_uniform(p, "u_lake_level"), ter_lake_level)
u_f4(gl_uniform(p, "u_isle"), ter_isle_cx, ter_isle_cz, ter_isle_r, ter_isle_fall)
u_f(gl_uniform(p, "u_isle_mode"), fi(ter_isle_mode))
u_f(gl_uniform(p, "u_dem_blur"), ter_dem_blur)
}
mesh_draw(sky_fullscreen)