Merge branch 'lang/foundations' into lang/ecs

This commit is contained in:
Orkun ÇAKILKAYA 2026-09-27 21:37:20 +03:00
commit 4a285903e1
83 changed files with 2224 additions and 313 deletions

View file

@ -30,6 +30,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.needs](ludic.needs/README.md) | a body's warmth, food, water and energy, and the countdown to a collapse |
| [ludic.npc](ludic.npc/README.md) | the other people in a place: a routine by the hour, walking round what is in the way, facing a player who comes near, lines as data, a guest's copy |
| [ludic.physics](ludic.physics/README.md) | rigid bodies, removable still shapes, queries and buoyancy over Jolt Physics (a native library, phase 16) |
| [ludic.nav](ludic.nav/README.md) | a navmesh per kind of walker and the ways across it, over Recast & Detour (a native library, phase 17) |
| [ludic.photo](ludic.photo/README.md) | a camera's photographs: what is in the frame, a grade on size, framing, light and the moment, the roll, its worth, the best of each subject |
| [ludic.save](ludic.save/README.md) | versioned save files: a migration chain the game declares, torn writes told apart, a backup, a newer file refused and read-only |
| [ludic.settings](ludic.settings/README.md) | a game's settings as data: one store, a fact per change, ranges, a safe set |

View file

@ -64,7 +64,8 @@ export port JobsPay { reward: fn(int, int, int) -> void } # (scope, money, rep
| `jobs_morning()`, `jobs_roll(lo, hi)`, `jobs_post(b, s, kind, param, need, money, rep)`, `jobs_config_seed(seed)` | posting |
| `jobs_slots(b)`, `jobs_posted(b)`, `jobs_post_kind / _param / _need / _have / _money / _rep / _state / _day(b, s)`, `jobs_posts_done(b)`, `jobs_total(b)` | the boards |
| `jobs_post_hand_in(b, s)`, `jobs_post_set(b, s, ...)`, `jobs_set_total(b, n)` | a post's verbs |
| `jobs_facts() -> Queue<JobsFact>` | `{ what: JOBS_F_OFFERED / _PROGRESSED / _COMPLETED / _HANDED_IN, job, board, slot, kind, param, have, need, money, rep, scope }` |
| `jobs_give_up(i)`, `jobs_back_on(i)`, `jobs_post_give_up(b, s)` | letting one go: a written job is offered again from the next day (the day back is saved with it), a post comes down and the morning replaces it; an auto board's posts are never taken, so never given up |
| `jobs_facts() -> Queue<JobsFact>` | `{ what: JOBS_F_OFFERED / _PROGRESSED / _COMPLETED / _HANDED_IN / _GIVEN_UP, job, board, slot, kind, param, have, need, money, rep, scope }` |
| `jobs_system()`, `jobs_party_system()` | `"jobs"` (yours, the tick) and `"jobs.party"` (the party's), each saving its scope's jobs and boards by key |
## Tests

View file

@ -49,6 +49,7 @@ export const JOBS_F_OFFERED: int = 0
export const JOBS_F_PROGRESSED: int = 1
export const JOBS_F_COMPLETED: int = 2
export const JOBS_F_HANDED_IN: int = 3
export const JOBS_F_GIVEN_UP: int = 4 # taken and let go: what the game placed for it comes down
# `job` a written job, or -1 and `board` / `slot` a post; what it counted rides along, since a
# post handed in is replaced before anyone reads the fact

View file

@ -0,0 +1,25 @@
# giveup.ludic - letting a job go. A written job goes back on offer, but not until tomorrow, so
# giving up is never a reroll; a post comes down and the morning puts up another in its place.
# Either way the fact says so, and the game takes down what it had placed for it.
export function jobs_give_up(jobs_st: mut JobsState, i: int) -> bool {
if not jobs_ok(i) or not jobs_under_way(jobs_st, i) { return false }
jobs__fact(jobs_st, JOBS_F_GIVEN_UP, i, -1, -1, jobs_st.jobs__hv[i], Jobs[i].need)
jobs_st.jobs__st[i] = JOBS_OFFERED
jobs_st.jobs__hv[i] = 0
jobs_st.jobs__back[i] = JobsWorld.day() + 1
return true
}
# the day a written job given up may be taken again, 0 never given up
export function jobs_back_on(jobs_st: JobsState, i: int) -> int { return jobs_st.jobs__back[i] }
# a post taken and not yet paid; an auto board's posts are not taken, so they are not given up
export function jobs_post_give_up(jobs_st: mut JobsState, b: int, s: int) -> bool {
if not jobs_board_ok(b) or s < 0 or s >= JobBoards[b].slots or JobBoards[b].auto { return false }
let k = jobs_st.jobs__off[b] + s
let st = jobs_st.jobs__ps[k]
if jobs_st.jobs__pk[k] < 0 or (st != JOBS_ACTIVE and st != JOBS_READY) { return false }
jobs__fact(jobs_st, JOBS_F_GIVEN_UP, -1, b, s, jobs_st.jobs__ph[k], jobs_st.jobs__pn[k])
jobs__clear(jobs_st, k)
return true
}

View file

@ -7,6 +7,7 @@ import "defs.ludic"
import "ports.ludic"
import "state.ludic"
import "jobs.ludic"
import "giveup.ludic"
import "count.ludic"
import "lists.ludic"
import "boards.ludic"

View file

@ -20,12 +20,12 @@ export function jobs_under_way(jobs_st: JobsState, i: int) -> bool {
# on offer: not taken, the scope's standing and the story far enough, and the game allowing it
export function jobs_offered(jobs_st: JobsState, i: int) -> bool {
let d = Jobs[i]
if jobs_state(jobs_st, i) != JOBS_OFFERED { return false }
if jobs_state(jobs_st, i) != JOBS_OFFERED or JobsWorld.day() < jobs_st.jobs__back[i] { return false }
return JobsWorld.rep(d.scope) >= d.minrep and JobsWorld.stage() >= d.minstage and JobsWorld.open(i)
}
export function jobs_take(jobs_st: mut JobsState, i: int) -> bool {
if not jobs_ok(i) or jobs_state(jobs_st, i) != JOBS_OFFERED { return false }
if not jobs_ok(i) or jobs_state(jobs_st, i) != JOBS_OFFERED or JobsWorld.day() < jobs_st.jobs__back[i] { return false }
jobs_st.jobs__st[i] = JOBS_ACTIVE
jobs_st.jobs__hv[i] = 0
jobs__recheck_one(jobs_st, i)

View file

@ -5,6 +5,7 @@ function jobs__reset_scope(jobs_st: mut JobsState, sc: int) -> void {
if Jobs[i].scope != sc { continue }
jobs_st.jobs__st[i] = JOBS_OFFERED
jobs_st.jobs__hv[i] = 0
jobs_st.jobs__back[i] = 0
}
for b in 0 .. BOARD_COUNT {
if JobBoards[b].scope != sc { continue }
@ -19,8 +20,8 @@ function jobs__save_scope(jobs_st: JobsState, sc: int) -> Val {
let v = Value.object()
let js = Value.object()
for i in 0 .. JOB_COUNT {
if Jobs[i].scope != sc or jobs_st.jobs__st[i] == JOBS_OFFERED { continue }
sv_put_ints(js, Jobs[i].key, [jobs_st.jobs__st[i], jobs_st.jobs__hv[i]])
if Jobs[i].scope != sc or (jobs_st.jobs__st[i] == JOBS_OFFERED and jobs_st.jobs__back[i] == 0) { continue }
sv_put_ints(js, Jobs[i].key, [jobs_st.jobs__st[i], jobs_st.jobs__hv[i], jobs_st.jobs__back[i]])
}
Value.put(v, "jobs", js)
let bs = Value.object()
@ -48,7 +49,9 @@ function jobs__load_scope(jobs_st: mut JobsState, sc: int, v: Val) -> void {
let js = Value.get(v, "jobs")
for i in 0 .. JOB_COUNT {
let p = sv_ints(js, Jobs[i].key)
if Jobs[i].scope == sc and len(p) == 2 { jobs_set(jobs_st, i, p[0], p[1]) }
if Jobs[i].scope != sc or len(p) < 2 { continue }
jobs_set(jobs_st, i, p[0], p[1])
if len(p) > 2 { jobs_st.jobs__back[i] = p[2] }
}
}
if Value.has(v, "boards") == 0 { return }

View file

@ -4,6 +4,7 @@ export state JobsState {
jobs__st: []int = jobs__ints(JOB_COUNT, JOBS_OFFERED)
jobs__hv: []int = jobs__ints(JOB_COUNT, 0)
jobs__seen: []bool = jobs__bools(JOB_COUNT) # an offer already said
jobs__back: []int = jobs__ints(JOB_COUNT, 0) # given up: the first day it may be taken again
jobs__primed: bool = false # the first tick after a reset learns the offers quietly
jobs__done_n: []int = jobs__ints(JOBS_GROUPS, 0) # handed in, per group
jobs__off: []int = jobs__offsets()

View file

@ -0,0 +1,132 @@
# giveup_test.ludic - letting a job go: a written job back on offer tomorrow, not today; a post taken
# down and replaced in the morning; a daily never taken; the day back saved with the job
import "ludic.jobs"
import "ludic.base"
program JobsGiveUpTest {
numbers float
const K_FISH: int = 0
const K_PHOTO: int = 1
const K_WOOD: int = 2
def Jobs catch { group: 0, kind: K_FISH, need: 3, money: 30, rep: 5 }
def Jobs deer { group: 0, kind: K_PHOTO, param: 4, need: 1, money: 20, rep: 2, minrep: 10 }
def Jobs ten { group: 0, kind: K_FISH, need: 10, money: 70, rep: 12, scope: JOBS_PARTY }
def Jobs still { group: 1, need: 0, money: 60, rep: 12, minstage: 2 }
def JobBoards daily { slots: 3, keep: 1, auto: true, refill: false }
def JobBoards ada { slots: 2, keep: 7, scope: JOBS_PARTY }
state JobsTestState {
rep: int = 0
stage: int = 0
day: int = 1
guest: bool = false
fish: int = 0 # the state's count of fish
paid: int = 0
paid_rep: int = 0
paid_scope: int = -1
}
function world_rep(jobs_test_st: JobsTestState, sc: int) -> int { return jobs_test_st.rep }
function world_stage(jobs_test_st: JobsTestState) -> int { return jobs_test_st.stage }
function world_day(jobs_test_st: JobsTestState) -> int { return jobs_test_st.day }
function world_owns(jobs_test_st: JobsTestState, sc: int) -> bool { return sc == JOBS_SELF or not jobs_test_st.guest }
function world_evidence(jobs_test_st: JobsTestState, kind: int, param: int) -> int {
if kind == K_FISH { return jobs_test_st.fish }
return -1
}
function world_roll(jobs_st: mut JobsState, b: int, s: int) -> void {
let need = jobs_roll(jobs_st, 2, 4)
if b == BOARD_DAILY { jobs_post(jobs_st, b, s, K_WOOD, -1, need, 7, 2) } else { jobs_post(jobs_st, b, s, K_FISH, -1, need, 9 * need, 3) }
}
function pay(jobs_test_st: mut JobsTestState, sc: int, money: int, r: int) -> void {
jobs_test_st.paid += money
jobs_test_st.paid_rep += r
jobs_test_st.paid_scope = sc
}
bind JobsWorld { rep: fn world_rep, stage: fn world_stage, day: fn world_day, owns: fn world_owns, evidence: fn world_evidence, roll: fn world_roll }
bind JobsPay { reward: fn pay }
function fresh(jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) -> void {
jobs_test_st.rep = 0
jobs_test_st.stage = 0
jobs_test_st.day = 1
jobs_test_st.guest = false
jobs_test_st.fish = 0
jobs_test_st.paid = 0
jobs_test_st.paid_rep = 0
jobs_reset(jobs_st)
jobs_party_reset(jobs_st)
}
function count_of(jobs_st: JobsState, what: int) -> int {
let fs = q_drain(jobs_facts(jobs_st))
var n = 0
for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } }
return n
}
test "a job given up is offered again tomorrow, not today" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) {
fresh(jobs_st, jobs_test_st)
expect(jobs_take(jobs_st, JOB_CATCH))
jobs_count(jobs_st, K_FISH, 0, 2)
count_of(jobs_st, JOBS_F_GIVEN_UP)
expect(jobs_give_up(jobs_st, JOB_CATCH))
expect_eq(count_of(jobs_st, JOBS_F_GIVEN_UP), 1)
expect_eq(jobs_state(jobs_st, JOB_CATCH), JOBS_OFFERED)
expect_eq(jobs_have(jobs_st, JOB_CATCH), 0)
expect(not jobs_offered(jobs_st, JOB_CATCH))
expect(not jobs_take(jobs_st, JOB_CATCH))
expect(not jobs_give_up(jobs_st, JOB_CATCH))
jobs_test_st.day = 2
expect(jobs_offered(jobs_st, JOB_CATCH))
expect(jobs_take(jobs_st, JOB_CATCH))
expect_eq(jobs_test_st.paid, 0)
}
test "a ready job can be let go too, and nothing is paid" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) {
fresh(jobs_st, jobs_test_st)
jobs_take(jobs_st, JOB_CATCH)
jobs_count(jobs_st, K_FISH, 0, 3)
expect_eq(jobs_state(jobs_st, JOB_CATCH), JOBS_READY)
expect(jobs_give_up(jobs_st, JOB_CATCH))
expect(not jobs_hand_in(jobs_st, JOB_CATCH))
expect_eq(jobs_test_st.paid, 0)
}
test "a post given up comes down, and the morning puts up another" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) {
fresh(jobs_st, jobs_test_st)
jobs_morning(jobs_st)
count_of(jobs_st, JOBS_F_OFFERED)
expect(jobs_post_give_up(jobs_st, BOARD_ADA, 0))
expect_eq(count_of(jobs_st, JOBS_F_GIVEN_UP), 1)
expect_eq(jobs_post_kind(jobs_st, BOARD_ADA, 0), -1)
expect(not jobs_post_give_up(jobs_st, BOARD_ADA, 0))
expect(jobs_post_kind(jobs_st, BOARD_ADA, 1) >= 0)
jobs_test_st.day = 2
jobs_morning(jobs_st)
expect(jobs_post_kind(jobs_st, BOARD_ADA, 0) >= 0)
expect_eq(jobs_post_day(jobs_st, BOARD_ADA, 0), 2)
}
test "a daily is not taken, so it is not given up" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) {
fresh(jobs_st, jobs_test_st)
jobs_morning(jobs_st)
expect(not jobs_post_give_up(jobs_st, BOARD_DAILY, 0))
expect(jobs_post_kind(jobs_st, BOARD_DAILY, 0) >= 0)
}
test "the day back is saved with the job" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) {
fresh(jobs_st, jobs_test_st)
jobs_take(jobs_st, JOB_CATCH)
jobs_give_up(jobs_st, JOB_CATCH)
let body = jobs_save(jobs_st)
fresh(jobs_st, jobs_test_st)
expect(jobs_offered(jobs_st, JOB_CATCH))
jobs_load(jobs_st, body, 1)
expect_eq(jobs_back_on(jobs_st, JOB_CATCH), 2)
expect(not jobs_offered(jobs_st, JOB_CATCH))
jobs_test_st.day = 2
expect(jobs_offered(jobs_st, JOB_CATCH))
}
}

View file

@ -0,0 +1,67 @@
# ludic.nav
A walkable mesh of the world and the ways across it, over
[Recast & Detour](https://github.com/recastnavigation/recastnavigation) (zlib) built here from a
pinned tag (phase 17). Uses `ludic.base` and nothing else.
```ludic
import "ludic.nav"
```
## The rules it keeps
- **One mesh per kind of walker.** `NAV_PERSON` (0.35 m wide, the hiker's 0.55 m step),
`NAV_LARGE` (an elk, a bear, a horse) and `NAV_SMALL` (a hare, a marmot) each have their own,
because what a hare slips between a bear walks round. A `NavConfig` says what a mesh is built
for: its voxels and its walker's height, radius, step and steepest slope.
- **Built from ground, or loaded from a bake.** A `NavGround` is triangles - each with an area
byte, 0 not walkable and 1..62 a kind of ground - and what nothing stands in: cylinders (a trunk,
a post) and convex footprints (a boulder). Ground steeper than the slope is cleared whatever its
area. `nav_build` makes one mesh of it; a map is square tiles (`nav_tiled`, then `nav_tile_build`
for each tile from the ground under it and a few metres round), and a tile must be a whole number
of cells across or its seams never join - the shim refuses one that is not. A game bakes its maps
once and loads the bytes (`nav_save_file` / `nav_load_file`, one format for one tile or many): a
map is never built at start-up. Polygon refs are 64-bit, so a map may have 16384 tiles.
- **A path is corners.** `nav_path` answers the way from one point to another as the corners
where it turns, the first the start and the last the end. When the end cannot be reached the
path stops as near it as the mesh allows and `nav_partial` says so - a walker goes there and
gives up, never through a river. Either end off the mesh is `-1`.
- **A wander's goal is somewhere it can go.** `nav_random_near` answers a point about r away that a
walker standing at the start can reach - never across a river without a ford - from a seed the
caller draws from its own `Rng`, so the same seed is the same point on every machine.
- **A kind of ground has a cost** (`nav_area_cost`): a trail cheaper than a meadow, scree dearer.
- **The same question gets the same answer.** Detour is deterministic for the same mesh and the
same points, so co-op's order of dice is untouched by asking it.
## API
| | |
| --- | --- |
| `NAV_PERSON`, `NAV_LARGE`, `NAV_SMALL`, `NAV_CORNERS` | the kinds of walker, and a path's most corners (256) |
| `NavConfig { cell, cell_h, height, radius, climb, slope }` | what a mesh is built for (a person by default, 0.25 m cells) |
| `NavGround { v, nv, t, nt, area, cyl, nc, foot, nf }` | what it is built from: triangles, an area byte each, cylinders (`x, y, z, r, h`) and footprints (`n`, n points `x z`, `ymin`, `ymax`) |
| `nav_build(kind, ground, config) -> bool`, `nav_polygons(kind)` | one mesh over all of it |
| `nav_tiled(kind, ox, oz, tile, max_tiles, max_polys) -> bool`, `nav_tile_build(kind, tx, tz, ground, config) -> int` | a map of square tiles, a tile at a time (its polygons, 0 none, -1 failed) |
| `nav_save_file(kind, path)`, `nav_load_file(kind, path)`, `nav_reset()` | a baked mesh written and read; every mesh let go |
| `nav_nearest(kind, x, y, z) -> bool`, `nav_near_x/y/z()` | the nearest walkable point within a couple of metres |
| `nav_path(kind, sx, sy, sz, ex, ey, ez) -> int`, `nav_corners()`, `nav_corner_x/y/z(i)`, `nav_partial(kind)` | a way as corners |
| `nav_straight(kind, sx, sy, sz, ex, ez) -> bool` | does the straight line stay walkable |
| `nav_random_near(kind, x, y, z, r, seed) -> bool`, `nav_near_x/y/z()` | a reachable point about r away, from the caller's seed |
| `nav_area_cost(kind, area, cost)` | how dear a kind of ground is to cross |
## Tests
```bash
ludic test packages/ludic.nav
```
A 40 m meadow built by hand: a path round a post and a boulder's footprint, over a river's ford,
stopping at the bank of a river with none, the nearest point, random points that never cross the
river, and a saved mesh answering as the built one did. And a 128 m meadow as four tiles: a path
across the seams, a tile under water with no polygons, and the set saved and loaded.
## The native library
`native/build.sh` fetches Recast & Detour v1.6.0, checks its SHA-256, and builds Recast, Detour
and the shim (`native/shim/nav_shim.cpp`) into `lib/<target>/` - the same script on the Mac and on
the PC (Git Bash, the LLVM installer's clang). `native/LICENSE-recastnavigation` ships with it.

View file

@ -0,0 +1,66 @@
# build.ludic - a kind's mesh: whole from its ground, or tile by tile (a map), or from a bake's file
function nv_ground_ok(g: NavGround) -> bool {
if g.v == null or g.t == null or g.area == null { return false }
if len(g.v) < g.nv * 3 or len(g.t) < g.nt * 3 or len(g.area) < g.nt { return false }
if g.nc > 0 and (g.cyl == null or len(g.cyl) < g.nc * 5) { return false }
return g.nf == 0 or (g.foot != null and len(g.foot) >= g.nf)
}
# the configuration in the state's own buffer: a map is thousands of tile builds, and nothing
# allocated per build is ever given back
function nv_cfg(nav_st: mut NavState, c: NavConfig) -> []float {
let cfg = nav_st.nv_cfg
cfg[0] = c.cell
cfg[1] = c.cell_h
cfg[2] = c.height
cfg[3] = c.radius
cfg[4] = c.climb
cfg[5] = c.slope
cfg[6] = Math.max(c.detail, 0.9)
return cfg
}
function nv_or_none(nav_st: NavState, xs: []float, n: int) -> []float {
if xs == null or n == 0 { return nav_st.nv_empty }
return xs
}
# one mesh over the whole of g
export function nav_build(nav_st: mut NavState, kind: int, g: NavGround, c: NavConfig) -> bool {
if not nv_ground_ok(g) { return false }
return nv_set(nav_st, kind, nvc_build(g.v, g.nv, g.t, g.nt, g.area, nv_or_none(nav_st, g.cyl, g.nc), g.nc, nv_or_none(nav_st, g.foot, g.nf), g.nf, nv_cfg(nav_st, c)))
}
# an empty mesh of square tiles `tile` metres across from (ox, oz), for a map built a tile at a time
export function nav_tiled(nav_st: mut NavState, kind: int, ox: float, oz: float, tile: float, max_tiles: int, max_polys: int) -> bool {
return nv_set(nav_st, kind, nvc_tiled(ox, oz, tile, max_tiles, max_polys))
}
# tile (tx, tz) from ground g covering it and a few metres round it: its polygons, 0 none, -1 failed
export function nav_tile_build(nav_st: mut NavState, kind: int, tx: int, tz: int, g: NavGround, c: NavConfig) -> int {
let h = nv_mesh(nav_st, kind)
if h == null or not nv_ground_ok(g) { return -1 }
return nvc_tile_build(h, tx, tz, g.v, g.nv, g.t, g.nt, g.area, nv_or_none(nav_st, g.cyl, g.nc), g.nc, nv_or_none(nav_st, g.foot, g.nf), g.nf, nv_cfg(nav_st, c))
}
# the polygons a kind's mesh has (0: none)
export function nav_polygons(nav_st: NavState, kind: int) -> int {
let h = nv_mesh(nav_st, kind)
if h == null { return 0 }
return nvc_polys(h)
}
# a kind's mesh written to a file, and read back. Reading holds the file's bytes for good (a slice
# is never given back), so a mesh is loaded once per map, never per frame
export function nav_save_file(nav_st: NavState, kind: int, path: string) -> bool {
let h = nv_mesh(nav_st, kind)
if h == null { return false }
let n = nvc_save(h, null, 0)
if n <= 0 { return false }
let buf = buffer(n)
if nvc_save(h, buf, n) != n { return false }
return Fs.write_bytes(path, buf, n)
}
export function nav_load_file(nav_st: mut NavState, kind: int, path: string) -> bool {
if not Fs.exists(path) { return false }
let buf = Fs.read_bytes(path)
if buf == null or len(buf) == 0 { return false }
return nv_set(nav_st, kind, nvc_load(buf, len(buf)))
}

View file

@ -0,0 +1,10 @@
# ludic.nav - a navmesh per kind of walker (a person, a large animal, a small one): built from
# triangles and the cylinders nothing stands in, or loaded from the bytes a bake saved; asked for
# the nearest walkable point, a path as corners, and whether a straight line stays walkable.
module ludic_nav uses ludic_base
numbers float
import "ludic.base"
import "native.ludic"
import "state.ludic"
import "build.ludic"
import "query.ludic"

BIN
packages/ludic.nav/lib/macos-arm64/libludicnav.dylib (Stored with Git LFS) Executable file

Binary file not shown.

BIN
packages/ludic.nav/lib/windows-x64/ludicnav.dll (Stored with Git LFS) Normal file

Binary file not shown.

BIN
packages/ludic.nav/lib/windows-x64/ludicnav.lib (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,15 @@
# native.ludic - the shim's symbols (native/shim/nav_shim.cpp). A navmesh is a handle the package
# keeps; points come back through a float buffer it owns. None of this is exported.
extern function nvc_build(v: pointer, nv: int, t: pointer, nt: int, area: pointer, cyl: pointer, nc: int, foot: pointer, nf: int, c: pointer) -> pointer = "nav_build"
extern function nvc_tiled(ox: float, oz: float, tile: float, max_tiles: int, max_polys: int) -> pointer = "nav_tiled"
extern function nvc_tile_build(h: pointer, tx: int, tz: int, v: pointer, nv: int, t: pointer, nt: int, area: pointer, cyl: pointer, nc: int, foot: pointer, nf: int, c: pointer) -> int = "nav_tile_build"
extern function nvc_save(h: pointer, buf: pointer, cap: int) -> int = "nav_save"
extern function nvc_load(buf: pointer, size: int) -> pointer = "nav_load"
extern function nvc_free(h: pointer) -> void = "nav_free"
extern function nvc_polys(h: pointer) -> int = "nav_polys"
extern function nvc_nearest(h: pointer, x: float, y: float, z: float, out: pointer) -> int = "nav_nearest"
extern function nvc_area_cost(h: pointer, area: int, cost: float) -> void = "nav_area_cost"
extern function nvc_path(h: pointer, sx: float, sy: float, sz: float, ex: float, ey: float, ez: float, out: pointer, max: int) -> int = "nav_path"
extern function nvc_partial(h: pointer) -> int = "nav_partial"
extern function nvc_raycast(h: pointer, sx: float, sy: float, sz: float, ex: float, ez: float) -> int = "nav_raycast"
extern function nvc_random_near(h: pointer, x: float, y: float, z: float, r: float, seed: int, out: pointer) -> int = "nav_random_near"

View file

@ -0,0 +1,18 @@
Copyright (c) 2009 Mikko Mononen memon@inside.org
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.

View file

@ -0,0 +1,28 @@
#!/bin/sh
# builds lib/<target>/ for ludic.nav: Recast & Detour at a pinned tag, and the shim over them.
# Recast builds the mesh, Detour answers it; DetourTileCache and DetourCrowd come with phases 17.8
# and 18. Polygon refs are 64-bit (DT_POLYREF64): an 8 km map in 64 m tiles is 16384 tiles, which
# 32-bit refs would leave 256 polygons each. Objects go to build/native; a few seconds on eight cores.
set -eu
PKG="$(cd "$(dirname "$0")/.." && pwd)"
. "$PKG/../../tools/native/lib.sh"
RC_TAG=v1.6.0
RC_SHA=d48ca0121962fa0639502c0f56c4e3ae72f98e55d88727225444f500775c0074
SRC="$PKG/build/src/recast-$RC_TAG"
native_fetch "$SRC" "https://github.com/recastnavigation/recastnavigation/archive/refs/tags/$RC_TAG.tar.gz" "$RC_SHA"
OBJ="$PKG/build/native/$(native_target)"
mkdir -p "$OBJ"
CXX="$(native_cxx)"
INC="-I$SRC/Recast/Include -I$SRC/Detour/Include"
FLAGS="$(native_cflags) -std=c++17 -ffp-contract=off -fno-exceptions -fno-rtti -DNDEBUG -DDT_POLYREF64 $INC"
case "$(native_target)" in windows-*) FLAGS="$FLAGS -D_CRT_SECURE_NO_WARNINGS" ;; esac
JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
( cd "$SRC" && ls Recast/Source/*.cpp Detour/Source/*.cpp ) | while read -r f; do
o="$OBJ/$(basename "$f" .cpp).o"
[ "$o" -nt "$SRC/$f" ] || echo "$f $o"
done | xargs -P "$JOBS" -n 2 sh -c '"$0" '"$FLAGS"' -c "'"$SRC"'/$1" -o "$2"' "$CXX"
"$CXX" $FLAGS -I"$PKG/native/shim" -c "$PKG/native/shim/nav_shim.cpp" -o "$OBJ/nav_shim.o"
native_link "$PKG" ludicnav "$OBJ"/*.o

View file

@ -0,0 +1,129 @@
// nav_build.inl - Recast's pipeline, shared by a whole mesh and a tile: rasterize the triangles,
// filter, mark what nothing stands in, erode, regions, contours, polygons, detail, Detour's data.
// c[] is the configuration: cell size, cell height, agent height, radius, max climb, max slope, and
// the detail mesh's sample spacing (at least 0.9 m, Recast's own limit; 0 grew without bound)
// cylinders (x, y, z, r, h each) no agent may stand in: trunks, posts, tents
static void nav_mark_cylinders(rcContext &ctx, rcCompactHeightfield &chf, const float *cyl, int nc) {
for (int i = 0; i < nc; ++i) {
float pos[3] = {cyl[i * 5], cyl[i * 5 + 1], cyl[i * 5 + 2]};
rcMarkCylinderArea(&ctx, pos, cyl[i * 5 + 3], cyl[i * 5 + 4], RC_NULL_AREA, chf);
}
}
// convex footprints no agent may stand in (a boulder): each is n, n points (x, z), ymin, ymax
static void nav_mark_footprints(rcContext &ctx, rcCompactHeightfield &chf, const float *f, int nf) {
float pts[3 * 64];
int i = 0;
while (i < nf) {
int n = (int)f[i];
if (n < 3 || n > 64 || i + 1 + n * 2 + 2 > nf) return;
float y0 = f[i + 1 + n * 2], y1 = f[i + 2 + n * 2];
for (int k = 0; k < n; ++k) { pts[k * 3] = f[i + 1 + k * 2]; pts[k * 3 + 1] = y0; pts[k * 3 + 2] = f[i + 2 + k * 2]; }
rcMarkConvexPolyArea(&ctx, pts, n, y0, y1, RC_NULL_AREA, chf);
i += 3 + n * 2;
}
}
static void nav_config(rcConfig &cfg, const float *c) {
memset(&cfg, 0, sizeof(cfg));
cfg.cs = c[0];
cfg.ch = c[1];
cfg.walkableSlopeAngle = c[5];
cfg.walkableHeight = (int)ceilf(c[2] / cfg.ch);
cfg.walkableClimb = (int)floorf(c[4] / cfg.ch);
cfg.walkableRadius = (int)ceilf(c[3] / cfg.cs);
cfg.maxEdgeLen = (int)(12.0f / cfg.cs);
cfg.maxSimplificationError = 1.3f;
cfg.minRegionArea = 8 * 8;
cfg.mergeRegionArea = 20 * 20;
cfg.maxVertsPerPoly = 6;
cfg.detailSampleDist = c[6] > 0.9f ? c[6] : 0.9f;
cfg.detailSampleMaxError = cfg.ch;
}
struct NavIn {
const float *v; int nv; const int *t; int nt; const unsigned char *area;
const float *cyl; int nc; const float *foot; int nf; const float *c;
};
// the pipeline over cfg's box; the tile's Detour data into data / size. 0 polygons is not an error
static bool nav_pipeline(rcConfig &cfg, const NavIn &in, int tx, int tz, unsigned char **data, int *size, int *polys) {
rcContext ctx(false);
std::vector<unsigned char> areas(in.area, in.area + in.nt);
rcClearUnwalkableTriangles(&ctx, cfg.walkableSlopeAngle, in.v, in.nv, in.t, in.nt, areas.data());
rcHeightfield *hf = rcAllocHeightfield();
rcCompactHeightfield *chf = rcAllocCompactHeightfield();
rcContourSet *cs = rcAllocContourSet();
rcPolyMesh *pm = rcAllocPolyMesh();
rcPolyMeshDetail *dm = rcAllocPolyMeshDetail();
bool ok = hf && chf && cs && pm && dm &&
rcCreateHeightfield(&ctx, *hf, cfg.width, cfg.height, cfg.bmin, cfg.bmax, cfg.cs, cfg.ch) &&
rcRasterizeTriangles(&ctx, in.v, in.nv, in.t, areas.data(), in.nt, *hf, cfg.walkableClimb);
if (ok) {
rcFilterLowHangingWalkableObstacles(&ctx, cfg.walkableClimb, *hf);
rcFilterLedgeSpans(&ctx, cfg.walkableHeight, cfg.walkableClimb, *hf);
rcFilterWalkableLowHeightSpans(&ctx, cfg.walkableHeight, *hf);
ok = rcBuildCompactHeightfield(&ctx, cfg.walkableHeight, cfg.walkableClimb, *hf, *chf);
}
if (ok) {
nav_mark_cylinders(ctx, *chf, in.cyl, in.nc);
nav_mark_footprints(ctx, *chf, in.foot, in.nf);
ok = rcErodeWalkableArea(&ctx, cfg.walkableRadius, *chf) && rcBuildDistanceField(&ctx, *chf) &&
rcBuildRegions(&ctx, *chf, cfg.borderSize, cfg.minRegionArea, cfg.mergeRegionArea) &&
rcBuildContours(&ctx, *chf, cfg.maxSimplificationError, cfg.maxEdgeLen, *cs) &&
rcBuildPolyMesh(&ctx, *cs, cfg.maxVertsPerPoly, *pm) &&
rcBuildPolyMeshDetail(&ctx, *pm, *chf, cfg.detailSampleDist, cfg.detailSampleMaxError, *dm);
}
*polys = ok ? pm->npolys : 0;
if (ok && pm->npolys > 0) {
for (int i = 0; i < pm->npolys; ++i) pm->flags[i] = pm->areas[i] ? 1 : 0;
dtNavMeshCreateParams p;
memset(&p, 0, sizeof(p));
p.verts = pm->verts; p.vertCount = pm->nverts; p.polys = pm->polys; p.polyAreas = pm->areas;
p.polyFlags = pm->flags; p.polyCount = pm->npolys; p.nvp = pm->nvp;
p.detailMeshes = dm->meshes; p.detailVerts = dm->verts; p.detailVertsCount = dm->nverts;
p.detailTris = dm->tris; p.detailTriCount = dm->ntris;
p.walkableHeight = in.c[2]; p.walkableRadius = in.c[3]; p.walkableClimb = in.c[4];
p.tileX = tx; p.tileY = tz;
rcVcopy(p.bmin, pm->bmin); rcVcopy(p.bmax, pm->bmax);
p.cs = cfg.cs; p.ch = cfg.ch; p.buildBvTree = true;
ok = dtCreateNavMeshData(&p, data, size);
}
rcFreeHeightField(hf); rcFreeCompactHeightfield(chf); rcFreeContourSet(cs); rcFreePolyMesh(pm); rcFreePolyMeshDetail(dm);
return ok;
}
// one mesh over all the triangles given: v nv points (x, y, z); t nt triangles; area a byte each,
// 0 not walkable and 1..62 a kind of ground; the slope clears what is too steep whatever its area
NAV_SHIM void *nav_build(const float *v, int nv, const int *t, int nt, const unsigned char *area,
const float *cyl, int nc, const float *foot, int nf, const float *c) {
rcConfig cfg;
nav_config(cfg, c);
rcCalcBounds(v, nv, cfg.bmin, cfg.bmax);
rcCalcGridSize(cfg.bmin, cfg.bmax, cfg.cs, &cfg.width, &cfg.height);
NavIn in = {v, nv, t, nt, area, cyl, nc, foot, nf, c};
unsigned char *data = nullptr;
int size = 0, polys = 0;
if (!nav_pipeline(cfg, in, 0, 0, &data, &size, &polys) || polys == 0) return nullptr;
return nav_from(data, size);
}
NAV_SHIM void nav_free(void *h) {
Nav *n = static_cast<Nav *>(h);
if (!n) return;
dtFreeNavMeshQuery(n->query);
dtFreeNavMesh(n->mesh);
delete n;
}
// every polygon in every tile
NAV_SHIM int nav_polys(void *h) {
const dtNavMesh *m = static_cast<Nav *>(h)->mesh;
int total = 0;
for (int i = 0; i < m->getMaxTiles(); ++i) {
const dtMeshTile *t = m->getTile(i);
if (t && t->header) total += t->header->polyCount;
}
return total;
}

View file

@ -0,0 +1,72 @@
// nav_query.inl - what a navmesh answers: the nearest point on it, a path as corner points, and
// whether a straight line stays on it. Points go out through out[] (x, y, z each).
static dtPolyRef nav_poly_at(Nav *n, const float *p, float *on) {
dtPolyRef r = 0;
if (dtStatusFailed(n->query->findNearestPoly(p, n->ext, &n->filter, &r, on))) return 0;
return r;
}
// the nearest point on the mesh to (x, y, z) into out; 0 when none is within reach
NAV_SHIM int nav_nearest(void *h, float x, float y, float z, float *out) {
float p[3] = {x, y, z};
return nav_poly_at(static_cast<Nav *>(h), p, out) ? 1 : 0;
}
// a cost per kind of ground (area 1..62): the filter every query uses
NAV_SHIM void nav_area_cost(void *h, int area, float cost) {
Nav *n = static_cast<Nav *>(h);
if (area > 0 && area < DT_MAX_AREAS) n->filter.setAreaCost(area, cost);
}
// the corners of a path from one point to another, at most max of them, into out: the count, or
// -1 when either end is off the mesh. One that cannot reach the end stops as near it as the mesh
// allows, and nav_partial says so
NAV_SHIM int nav_path(void *h, float sx, float sy, float sz, float ex, float ey, float ez, float *out, int max) {
Nav *n = static_cast<Nav *>(h);
float s[3] = {sx, sy, sz}, e[3] = {ex, ey, ez}, so[3], eo[3];
dtPolyRef a = nav_poly_at(n, s, so), b = nav_poly_at(n, e, eo);
n->partial = 0;
if (!a || !b) return -1;
int np = 0;
dtStatus st = n->query->findPath(a, b, so, eo, &n->filter, n->polys, &np, NAV_MAX_POLYS);
if (dtStatusFailed(st) || np == 0) return -1;
if (dtStatusDetail(st, DT_PARTIAL_RESULT) || n->polys[np - 1] != b) n->partial = 1;
float end[3];
dtVcopy(end, eo);
if (n->polys[np - 1] != b) n->query->closestPointOnPoly(n->polys[np - 1], eo, end, nullptr);
int count = 0;
if (dtStatusFailed(n->query->findStraightPath(so, end, n->polys, np, out, nullptr, nullptr, &count, max, 0))) return -1;
return count;
}
NAV_SHIM int nav_partial(void *h) { return static_cast<Nav *>(h)->partial; }
// does the straight line from one point to another stay on the mesh? 1 yes, 0 no, -1 off it
NAV_SHIM int nav_raycast(void *h, float sx, float sy, float sz, float ex, float ez) {
Nav *n = static_cast<Nav *>(h);
float s[3] = {sx, sy, sz}, so[3], e[3] = {ex, sy, ez}, t = 0, nrm[3];
dtPolyRef a = nav_poly_at(n, s, so);
if (!a) return -1;
int np = 0;
if (dtStatusFailed(n->query->raycast(a, so, e, &n->filter, &t, nrm, n->polys, &np, NAV_MAX_POLYS))) return -1;
return t >= 1.0f ? 1 : 0;
}
// a point about r from (x, y, z) that a walker there can reach, into out; 0 when there is none.
// Detour asks a function for its dice: this one is seeded by the caller, so the same seed from the
// package's Rng gives the same point on every machine
static unsigned int nav_rs = 1;
static float nav_frand() {
nav_rs = nav_rs * 1664525u + 1013904223u;
return (float)(nav_rs >> 8) * (1.0f / 16777216.0f);
}
NAV_SHIM int nav_random_near(void *h, float x, float y, float z, float r, int seed, float *out) {
Nav *n = static_cast<Nav *>(h);
float p[3] = {x, y, z}, on[3];
dtPolyRef start = nav_poly_at(n, p, on);
if (!start) return 0;
nav_rs = (unsigned int)seed * 2654435761u + 1u;
dtPolyRef ref = 0;
if (dtStatusFailed(n->query->findRandomPointAroundCircle(start, on, r, &n->filter, nav_frand, &ref, out))) return 0;
return ref ? 1 : 0;
}

View file

@ -0,0 +1,52 @@
// nav_shim.cpp - ludic.nav's door into Recast & Detour (phase 17). The shim rules of
// packages/README.md: int, float and opaque handles cross; no struct by value, no callback into
// Ludic; what a query finds is written into a buffer the package owns; errors are return codes.
#include <Recast.h>
#include <DetourNavMesh.h>
#include <DetourNavMeshBuilder.h>
#include <DetourNavMeshQuery.h>
#include <DetourCommon.h>
#include <cstring>
#include <cmath>
#include <vector>
#if defined(_WIN32)
#define NAV_SHIM extern "C" __declspec(dllexport)
#else
#define NAV_SHIM extern "C" __attribute__((visibility("default")))
#endif
namespace {
const int NAV_MAX_POLYS = 512; // polygons a path may cross; a longer one is cut there
const int NAV_MAX_NODES = 4096; // the search's open list
// a navmesh and the one query that walks it, with the scratch a path needs
struct Nav {
dtNavMesh *mesh = nullptr;
dtNavMeshQuery *query = nullptr;
dtQueryFilter filter;
float ext[3] = {2.0f, 4.0f, 2.0f}; // how far to look for the mesh around a point
dtPolyRef polys[NAV_MAX_POLYS];
int partial = 0; // the last path stopped short of its end
};
// a navmesh with its query, from params (a set of tiles) or from one tile's data
Nav *nav_open(dtNavMesh *mesh) {
Nav *n = new Nav();
n->mesh = mesh;
n->query = dtAllocNavMeshQuery();
if (!n->query || dtStatusFailed(n->query->init(n->mesh, NAV_MAX_NODES))) { dtFreeNavMeshQuery(n->query); dtFreeNavMesh(n->mesh); delete n; return nullptr; }
n->filter.setIncludeFlags(0xffff);
n->filter.setExcludeFlags(0);
return n;
}
Nav *nav_from(unsigned char *data, int size) {
dtNavMesh *mesh = dtAllocNavMesh();
if (!mesh || dtStatusFailed(mesh->init(data, size, DT_TILE_FREE_DATA))) { dtFree(data); dtFreeNavMesh(mesh); return nullptr; }
return nav_open(mesh);
}
} // namespace
#include "nav_build.inl"
#include "nav_tiles.inl"
#include "nav_query.inl"

View file

@ -0,0 +1,102 @@
// nav_tiles.inl - a map as square tiles: a tiled mesh, one tile built from the triangles under it
// and a border round it, and every tile written and read back as one set of bytes
// an empty mesh of tiles `tile` metres across, the first at (ox, oz)
NAV_SHIM void *nav_tiled(float ox, float oz, float tile, int max_tiles, int max_polys) {
dtNavMeshParams p;
memset(&p, 0, sizeof(p));
p.orig[0] = ox; p.orig[1] = 0.0f; p.orig[2] = oz;
p.tileWidth = tile; p.tileHeight = tile;
p.maxTiles = max_tiles; p.maxPolys = max_polys;
dtNavMesh *mesh = dtAllocNavMesh();
if (!mesh || dtStatusFailed(mesh->init(&p))) { dtFreeNavMesh(mesh); return nullptr; }
return nav_open(mesh);
}
// tile (tx, tz) built from triangles that cover it and a border of the walker's radius and three
// cells round it, replacing whatever was there; its polygons, 0 for none, -1 when it failed
NAV_SHIM int nav_tile_build(void *h, int tx, int tz, const float *v, int nv, const int *t, int nt,
const unsigned char *area, const float *cyl, int nc, const float *foot, int nf, const float *c) {
Nav *n = static_cast<Nav *>(h);
const dtNavMeshParams *mp = n->mesh->getParams();
rcConfig cfg;
nav_config(cfg, c);
cfg.tileSize = (int)(mp->tileWidth / cfg.cs + 0.5f);
// a tile a cell short of its width leaves a seam its neighbour's polygons never meet
if (fabsf(cfg.tileSize * cfg.cs - mp->tileWidth) > 0.001f) return -1;
cfg.borderSize = cfg.walkableRadius + 3;
cfg.width = cfg.height = cfg.tileSize + cfg.borderSize * 2;
float lo[3], hi[3];
rcCalcBounds(v, nv, lo, hi);
cfg.bmin[0] = mp->orig[0] + tx * mp->tileWidth - cfg.borderSize * cfg.cs;
cfg.bmin[2] = mp->orig[2] + tz * mp->tileHeight - cfg.borderSize * cfg.cs;
cfg.bmax[0] = mp->orig[0] + (tx + 1) * mp->tileWidth + cfg.borderSize * cfg.cs;
cfg.bmax[2] = mp->orig[2] + (tz + 1) * mp->tileHeight + cfg.borderSize * cfg.cs;
cfg.bmin[1] = lo[1] - 1.0f;
cfg.bmax[1] = hi[1] + c[2] + 1.0f;
NavIn in = {v, nv, t, nt, area, cyl, nc, foot, nf, c};
unsigned char *data = nullptr;
int size = 0, polys = 0;
n->mesh->removeTile(n->mesh->getTileRefAt(tx, tz, 0), nullptr, nullptr);
if (!nav_pipeline(cfg, in, tx, tz, &data, &size, &polys)) return -1;
if (polys == 0) return 0;
if (dtStatusFailed(n->mesh->addTile(data, size, DT_TILE_FREE_DATA, 0, nullptr))) { dtFree(data); return -1; }
return polys;
}
// the set's bytes: "NAVT", a version, the mesh's params, then each tile's ref, size and data.
// First call with buf null for the size, then with a buffer that big
static const int NAV_MAGIC = 'N' << 24 | 'A' << 16 | 'V' << 8 | 'T';
static const int NAV_VERSION = 1;
static void nav_put(unsigned char *buf, int &at, const void *p, int n) {
if (buf) memcpy(buf + at, p, n);
at += n;
}
NAV_SHIM int nav_save(void *h, unsigned char *buf, int cap) {
const dtNavMesh *m = static_cast<Nav *>(h)->mesh;
const dtNavMeshParams *p = m->getParams();
int at = 0, count = 0;
for (int i = 0; i < m->getMaxTiles(); ++i) {
const dtMeshTile *t = m->getTile(i);
if (t && t->header && t->dataSize) count++;
}
nav_put(buf, at, &NAV_MAGIC, 4); nav_put(buf, at, &NAV_VERSION, 4); nav_put(buf, at, &count, 4);
nav_put(buf, at, p->orig, 12); nav_put(buf, at, &p->tileWidth, 4); nav_put(buf, at, &p->tileHeight, 4);
nav_put(buf, at, &p->maxTiles, 4); nav_put(buf, at, &p->maxPolys, 4);
for (int i = 0; i < m->getMaxTiles(); ++i) {
const dtMeshTile *t = m->getTile(i);
if (!t || !t->header || !t->dataSize) continue;
unsigned long long ref = (unsigned long long)m->getTileRef(t);
if (buf && at + 12 + t->dataSize > cap) return -1;
nav_put(buf, at, &ref, 8); nav_put(buf, at, &t->dataSize, 4); nav_put(buf, at, t->data, t->dataSize);
}
return at;
}
// a mesh from bytes nav_save wrote; null for anything else
NAV_SHIM void *nav_load(const unsigned char *buf, int size) {
int at = 0, magic = 0, version = 0, count = 0;
if (size < 40) return nullptr;
memcpy(&magic, buf, 4); memcpy(&version, buf + 4, 4); memcpy(&count, buf + 8, 4);
if (magic != NAV_MAGIC || version != NAV_VERSION) return nullptr;
dtNavMeshParams p;
memset(&p, 0, sizeof(p));
memcpy(p.orig, buf + 12, 12); memcpy(&p.tileWidth, buf + 24, 4); memcpy(&p.tileHeight, buf + 28, 4);
memcpy(&p.maxTiles, buf + 32, 4); memcpy(&p.maxPolys, buf + 36, 4);
at = 40;
dtNavMesh *mesh = dtAllocNavMesh();
if (!mesh || dtStatusFailed(mesh->init(&p))) { dtFreeNavMesh(mesh); return nullptr; }
for (int i = 0; i < count; ++i) {
unsigned long long ref = 0;
int n = 0;
if (at + 12 > size) break;
memcpy(&ref, buf + at, 8); memcpy(&n, buf + at + 8, 4);
at += 12;
if (n <= 0 || at + n > size) break;
unsigned char *data = static_cast<unsigned char *>(dtAlloc(n, DT_ALLOC_PERM));
memcpy(data, buf + at, n);
at += n;
if (dtStatusFailed(mesh->addTile(data, n, DT_TILE_FREE_DATA, (dtTileRef)ref, nullptr))) dtFree(data);
}
return nav_open(mesh);
}

View file

@ -0,0 +1,7 @@
# ludic.nav - a walkable mesh of the world and the ways across it, over Recast & Detour (phase 17),
# built here from a pinned tag (native/build.sh). Uses ludic.base and nothing else.
package "ludic.nav"
version "0.1.0"
kind source
native "macos-arm64" "lib/macos-arm64/libludicnav.dylib"
native "windows-x64" "lib/windows-x64/ludicnav.dll"

View file

@ -0,0 +1,51 @@
# query.ludic - what a kind's mesh answers. A path is its corners, read back one at a time
# (nav_corner_x / _y / _z); the nearest point the same (nav_near_x / _y / _z)
# the nearest walkable point to (x, y, z) within a couple of metres; false when there is none
export function nav_nearest(nav_st: NavState, kind: int, x: float, y: float, z: float) -> bool {
let h = nv_mesh(nav_st, kind)
if h == null { return false }
return nvc_nearest(h, x, y, z, nav_st.nv_near) == 1
}
export function nav_near_x(nav_st: NavState) -> float { return nav_st.nv_near[0] }
export function nav_near_y(nav_st: NavState) -> float { return nav_st.nv_near[1] }
export function nav_near_z(nav_st: NavState) -> float { return nav_st.nv_near[2] }
# a point about r from (x, y, z) that a walker standing there can reach (a wander's goal), read back
# as the nearest is; the seed is the caller's dice, so the same seed is the same point everywhere
export function nav_random_near(nav_st: NavState, kind: int, x: float, y: float, z: float, r: float, seed: int) -> bool {
let h = nv_mesh(nav_st, kind)
if h == null { return false }
return nvc_random_near(h, x, y, z, r, seed, nav_st.nv_near) == 1
}
# a way from one point to another: its corners, first the start and last the end (or as near the end
# as the mesh reaches - nav_partial says which); -1 when either end is off the mesh
export function nav_path(nav_st: mut NavState, kind: int, sx: float, sy: float, sz: float, ex: float, ey: float, ez: float) -> int {
let h = nv_mesh(nav_st, kind)
nav_st.nv_count = 0
if h == null { return -1 }
let n = nvc_path(h, sx, sy, sz, ex, ey, ez, nav_st.nv_out, NAV_CORNERS)
if n > 0 { nav_st.nv_count = n }
return n
}
export function nav_partial(nav_st: NavState, kind: int) -> bool {
let h = nv_mesh(nav_st, kind)
return h != null and nvc_partial(h) == 1
}
export function nav_corners(nav_st: NavState) -> int { return nav_st.nv_count }
export function nav_corner_x(nav_st: NavState, i: int) -> float { return nav_st.nv_out[i * 3] }
export function nav_corner_y(nav_st: NavState, i: int) -> float { return nav_st.nv_out[i * 3 + 1] }
export function nav_corner_z(nav_st: NavState, i: int) -> float { return nav_st.nv_out[i * 3 + 2] }
# does the straight line from (sx, sy, sz) to (ex, ez) stay walkable?
export function nav_straight(nav_st: NavState, kind: int, sx: float, sy: float, sz: float, ex: float, ez: float) -> bool {
let h = nv_mesh(nav_st, kind)
return h != null and nvc_raycast(h, sx, sy, sz, ex, ez) == 1
}
# how dear a kind of ground is to cross (1 is plain; a trail cheaper, scree dearer)
export function nav_area_cost(nav_st: NavState, kind: int, area: int, cost: float) -> void {
let h = nv_mesh(nav_st, kind)
if h != null { nvc_area_cost(h, area, cost) }
}

View file

@ -0,0 +1,68 @@
# state.ludic - the kinds of walker, what a mesh is built for, and what the package holds: a mesh
# per kind, and the last path's corners and nearest point, read back through the verbs
export const NAV_PERSON: int = 0 # 0.35 m wide, steps what a hiker steps (CHAR_STEP)
export const NAV_LARGE: int = 1 # an elk, a bear, a horse
export const NAV_SMALL: int = 2 # a hare, a marmot
export const NAV_KINDS: int = 3
export const NAV_CORNERS: int = 256 # a path's corners at most; a longer one is cut there
# what a mesh is built for: its voxels (cell across, cell high) and its walker (height, radius,
# the step it takes whatever its gradient, the steepest ground it walks, degrees). A tile must be a
# whole number of cells across, or its seams never join: 0.25 m is 256 to a 64 m tile
export property NavConfig {
cell: float = 0.25
cell_h: float = 0.2
height: float = 1.8
radius: float = 0.35
climb: float = 0.55
slope: float = 45.0
detail: float = 1.5 # the detail mesh's samples, metres apart (at least 0.9: 0 took one small test to 3.5 GB)
}
# what a mesh is built from: v holds nv points (x, y, z) and t nt triangles (three indices each);
# area a byte per triangle, 0 not walkable and 1..62 a kind of ground; cyl nc cylinders (x, y, z,
# r, h) and foot nf floats of convex footprints (n, n points x z, ymin, ymax) nothing stands in
export property NavGround {
v: []float = null
nv: int = 0
t: []int = null
nt: int = 0
area: []byte = null
cyl: []float = null
nc: int = 0
foot: []float = null
nf: int = 0
}
export state NavState {
nv_meshes: []pointer = nv_none()
nv_out: []float = floats(NAV_CORNERS * 3)
nv_count: int = 0
nv_near: []float = floats(3)
nv_cfg: []float = floats(7) # the configuration handed to the shim, filled in place per build
nv_empty: []float = floats(1) # what the shim is handed for no cylinders or no footprints
}
function nv_none() -> []pointer {
let m = new []pointer
for k in 0 .. NAV_KINDS { push(m, null) }
return m
}
function nv_mesh(nav_st: NavState, kind: int) -> pointer {
if kind < 0 or kind >= NAV_KINDS { return null }
return nav_st.nv_meshes[kind]
}
function nv_set(nav_st: mut NavState, kind: int, h: pointer) -> bool {
if kind < 0 or kind >= NAV_KINDS { return false }
if nav_st.nv_meshes[kind] != null { nvc_free(nav_st.nv_meshes[kind]) }
nav_st.nv_meshes[kind] = h
return h != null
}
# one kind's mesh let go (a bake writes each kind and drops it before the next)
export function nav_drop(nav_st: mut NavState, kind: int) -> void { nv_set(nav_st, kind, null) }
# every mesh let go: a new map, or the world closed
export function nav_reset(nav_st: mut NavState) -> void {
for k in 0 .. NAV_KINDS { nv_set(nav_st, k, null) }
nav_st.nv_count = 0
}

View file

@ -0,0 +1,61 @@
# fakes/meadow.ludic - ground built by hand for ludic.nav's tests: n metre squares a side, flat at
# y = 0; river 1 crosses x 18..22 with a ford at z 30..34, river 2 without one, river 0 is none
function ground(n: int, river: int) -> NavGround {
let g = new NavGround
g.nv = (n + 1) * (n + 1)
g.v = floats(g.nv * 3)
for j in 0 .. n + 1 {
for i in 0 .. n + 1 {
let o = (j * (n + 1) + i) * 3
g.v[o] = float(i)
g.v[o + 2] = float(j)
}
}
g.nt = n * n * 2
g.t = new []int
g.area = buffer(g.nt)
for j in 0 .. n {
for i in 0 .. n {
let a = j * (n + 1) + i
push(g.t, a)
push(g.t, a + n + 1)
push(g.t, a + 1)
push(g.t, a + 1)
push(g.t, a + n + 1)
push(g.t, a + n + 2)
var k = 1
if river > 0 and i >= 18 and i < 22 and not (river == 1 and j >= 30 and j < 34) { k = 0 }
g.area[(j * n + i) * 2] = k
g.area[(j * n + i) * 2 + 1] = k
}
}
return g
}
# a post r wide at (x, z), from under the ground to over a walker's head
function post_at(x: float, z: float, r: float) -> []float {
let c = floats(5)
c[0] = x
c[1] = -1.0
c[2] = z
c[3] = r
c[4] = 4.0
return c
}
# a square footprint 2h across at (x, z), as a boulder's is written: n, the corners, ymin, ymax
function square_at(x: float, z: float, h: float) -> []float {
let f = floats(11)
f[0] = 4.0
f[1] = x - h
f[2] = z - h
f[3] = x + h
f[4] = z - h
f[5] = x + h
f[6] = z + h
f[7] = x - h
f[8] = z + h
f[9] = -1.0
f[10] = 3.0
return f
}

View file

@ -0,0 +1,103 @@
# nav_test.ludic - ludic.nav on ground built by hand: a 40 m meadow of metre squares, a post in its
# middle, a river across it (area 0) with a ford or without one. A path goes round the post and over
# the ford, stops at the bank when there is none, and a saved mesh answers as the built one did.
import "ludic.nav"
import "ludic.base"
import "fakes/meadow.ludic"
program NavTest {
numbers float
function meadow(nav_st: mut NavState, kind: int, river: int, post: bool) -> bool {
let g = ground(40, river)
if post { g.cyl = post_at(20.0, 20.0, 2.0) }
if post { g.nc = 1 }
return nav_build(nav_st, kind, g, new NavConfig)
}
function last_x(nav_st: NavState) -> float { return nav_corner_x(nav_st, nav_corners(nav_st) - 1) }
function last_z(nav_st: NavState) -> float { return nav_corner_z(nav_st, nav_corners(nav_st) - 1) }
test "a path goes round a post, and the straight line through it is not walkable" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 0, true))
expect(nav_polygons(nav_st, NAV_PERSON) > 0)
let n = nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 20.0, 35.0, 0.0, 20.0)
expect(n >= 3)
expect(not nav_partial(nav_st, NAV_PERSON))
expect(Math.abs(last_x(nav_st) - 35.0) < 0.1)
for i in 0 .. n {
let dx = nav_corner_x(nav_st, i) - 20.0
let dz = nav_corner_z(nav_st, i) - 20.0
expect(dx * dx + dz * dz > 2.0 * 2.0)
}
expect(not nav_straight(nav_st, NAV_PERSON, 5.0, 0.0, 20.0, 35.0, 20.0))
expect(nav_straight(nav_st, NAV_PERSON, 5.0, 0.0, 5.0, 35.0, 5.0))
}
test "a river is crossed at its ford" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 1, false))
let n = nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 35.0, 0.0, 10.0)
expect(n >= 3)
expect(not nav_partial(nav_st, NAV_PERSON))
var ford = false
for i in 0 .. n { if nav_corner_z(nav_st, i) > 29.0 { ford = true } }
expect(ford)
}
test "without a ford the path stops at the near bank and says so" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 2, false))
let n = nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 35.0, 0.0, 10.0)
expect(n >= 1)
expect(nav_partial(nav_st, NAV_PERSON))
expect(last_x(nav_st) < 18.0)
}
test "the nearest walkable point, and nothing off the mesh" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 0, false))
expect(nav_nearest(nav_st, NAV_PERSON, 10.0, 1.0, 10.0))
expect(Math.abs(nav_near_y(nav_st)) < 0.3)
expect(Math.abs(nav_near_x(nav_st) - 10.0) < 0.1)
expect(not nav_nearest(nav_st, NAV_PERSON, -50.0, 0.0, -50.0))
expect_eq(nav_path(nav_st, NAV_PERSON, -50.0, 0.0, -50.0, 10.0, 0.0, 10.0), -1)
expect_eq(nav_path(nav_st, NAV_LARGE, 5.0, 0.0, 5.0, 10.0, 0.0, 10.0), -1)
}
test "a random point near is always one a walker there can reach, and a seed is always the same point" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 2, false))
for seed in 0 .. 50 {
expect(nav_random_near(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 30.0, seed))
expect(nav_near_x(nav_st) < 18.0)
}
nav_random_near(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 30.0, 7)
let x7 = nav_near_x(nav_st)
let z7 = nav_near_z(nav_st)
nav_random_near(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 30.0, 8)
expect(Math.abs(nav_near_x(nav_st) - x7) + Math.abs(nav_near_z(nav_st) - z7) > 0.01)
nav_random_near(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 30.0, 7)
expect_eq(nav_near_x(nav_st), x7)
expect_eq(nav_near_z(nav_st), z7)
}
test "a boulder's footprint is walked round" (nav_st: mut NavState) {
let g = ground(40, 0)
g.foot = square_at(20.0, 20.0, 3.0)
g.nf = len(g.foot)
expect(nav_build(nav_st, NAV_PERSON, g, new NavConfig))
expect(not nav_straight(nav_st, NAV_PERSON, 5.0, 0.0, 20.0, 35.0, 20.0))
expect(nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 20.0, 35.0, 0.0, 20.0) >= 3)
}
test "a mesh saved and loaded answers as the built one did" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 1, true))
let path = Os.temp_dir() + "/ludic_nav_test.navmesh"
expect(nav_save_file(nav_st, NAV_PERSON, path))
expect(nav_load_file(nav_st, NAV_SMALL, path))
let a = nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 35.0, 0.0, 10.0)
let ax = last_x(nav_st)
let az = last_z(nav_st)
let b = nav_path(nav_st, NAV_SMALL, 5.0, 0.0, 10.0, 35.0, 0.0, 10.0)
expect_eq(a, b)
expect_near(last_x(nav_st), ax, 0.0001)
expect_near(last_z(nav_st), az, 0.0001)
nav_reset(nav_st)
expect_eq(nav_polygons(nav_st, NAV_SMALL), 0)
}
}

View file

@ -0,0 +1,66 @@
# tiles_test.ludic - ludic.nav's map in tiles: a 128 m meadow as four 64 m tiles, one of them under
# water. A path crosses the tiles' seams, the water's tile has no polygons and nothing inside it is
# a way's end, and the set saved and loaded answers as the built one did.
import "ludic.nav"
import "ludic.base"
import "fakes/meadow.ludic"
program TilesTest {
numbers float
# four tiles over ground(128), the far corner's squares (x and z past 64) under water when wet
function map(nav_st: mut NavState, wet: bool) -> int {
let g = ground(128, 0)
if wet {
for j in 64 .. 128 { for i in 64 .. 128 { g.area[(j * 128 + i) * 2] = 0 } }
for j in 64 .. 128 { for i in 64 .. 128 { g.area[(j * 128 + i) * 2 + 1] = 0 } }
}
g.cyl = post_at(96.0, 20.0, 2.0)
g.nc = 1
expect(nav_tiled(nav_st, NAV_PERSON, 0.0, 0.0, 64.0, 16, 4096))
var empty = 0
for tz in 0 .. 2 {
for tx in 0 .. 2 {
let p = nav_tile_build(nav_st, NAV_PERSON, tx, tz, g, new NavConfig)
expect(p >= 0)
if p == 0 { empty += 1 }
}
}
return empty
}
function last_x(nav_st: NavState) -> float { return nav_corner_x(nav_st, nav_corners(nav_st) - 1) }
function last_z(nav_st: NavState) -> float { return nav_corner_z(nav_st, nav_corners(nav_st) - 1) }
test "a path crosses the tiles' seams and goes round the post in the far tile" (nav_st: mut NavState) {
expect_eq(map(nav_st, false), 0)
expect(nav_polygons(nav_st, NAV_PERSON) > 0)
let n = nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 20.0, 120.0, 0.0, 20.0)
expect(n >= 3)
expect(not nav_partial(nav_st, NAV_PERSON))
expect(Math.abs(last_x(nav_st) - 120.0) < 0.1)
let d = nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 10.0, 120.0, 0.0, 120.0)
expect(d >= 2)
expect(not nav_partial(nav_st, NAV_PERSON))
}
test "a tile under water has no polygons: a way into it is none, the shore beside it is reached" (nav_st: mut NavState) {
expect_eq(map(nav_st, true), 1)
expect(not nav_nearest(nav_st, NAV_PERSON, 100.0, 0.0, 100.0))
expect_eq(nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 10.0, 70.0, 0.0, 70.0), -1)
expect(nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 10.0, 62.0, 0.0, 70.0) >= 2)
expect(not nav_partial(nav_st, NAV_PERSON))
expect(Math.abs(last_z(nav_st) - 70.0) < 0.1)
}
test "the tiles saved and loaded answer as the built ones did" (nav_st: mut NavState) {
map(nav_st, true)
let path = Os.temp_dir() + "/ludic_nav_tiles.navmesh"
expect(nav_save_file(nav_st, NAV_PERSON, path))
expect(nav_load_file(nav_st, NAV_LARGE, path))
expect_eq(nav_polygons(nav_st, NAV_LARGE), nav_polygons(nav_st, NAV_PERSON))
let a = nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 20.0, 120.0, 0.0, 20.0)
let ax = last_x(nav_st)
let b = nav_path(nav_st, NAV_LARGE, 10.0, 0.0, 20.0, 120.0, 0.0, 20.0)
expect_eq(a, b)
expect_near(last_x(nav_st), ax, 0.0001)
}
}

View file

@ -29,7 +29,7 @@ import "ludic.physics"
| | |
| --- | --- |
| `phys_open(st, max_bodies, threads) -> bool`, `phys_close(st)`, `phys_is_open(st)` | a world, and everything in it let go |
| `phys_box`, `phys_sphere`, `phys_capsule`, `phys_cylinder`, `phys_dome(r, h)`, `phys_offset(shape, x, y, z, yaw)` | shapes, each an int (`-1` refused); a dome is a boulder standing on its origin |
| `phys_box`, `phys_sphere`, `phys_capsule`, `phys_cylinder`, `phys_dome(r, h)`, `phys_convex(points, n)`, `phys_scaled(shape, k)`, `phys_offset(shape, x, y, z, yaw)` | shapes, each an int (`-1` refused); a dome is a boulder standing on its origin; a convex hull of n points (x, y, z) is a rock's own shape, made once and scaled for every rock of that shape; a body's yaw turns it as a renderer turns an instance (x' = cos x + sin z, z' = -sin x + cos z) |
| `phys_heightfield(st, h, n, ox, oz, cell)`, `phys_mesh(st, v, nv, tri, nt)` | the ground (`PHYS_HOLE` is none) and a dock or cabin |
| `phys_ground_add`, `phys_static_add`, `phys_kinematic_add`, `phys_body_add(..., mass)` | bodies: the ground, still things, what the game moves (the player), what falls and floats |
| `phys_settle(st)` | after adding many still things, before the first query |

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -14,6 +14,8 @@ extern function jph_shape_capsule(half_h: float, r: float) -> pointer = "jph_sha
extern function jph_shape_cylinder(half_h: float, r: float) -> pointer = "jph_shape_cylinder"
extern function jph_shape_dome(r: float, h: float) -> pointer = "jph_shape_dome"
extern function jph_shape_offset(s: pointer, x: float, y: float, z: float, yaw: float) -> pointer = "jph_shape_offset"
extern function jph_shape_convex(v: pointer, n: int, radius: float) -> pointer = "jph_shape_convex"
extern function jph_shape_scaled(s: pointer, k: float) -> pointer = "jph_shape_scaled"
extern function jph_shape_heightfield(h: pointer, n: int, ox: float, oz: float, cell: float) -> pointer = "jph_shape_heightfield"
extern function jph_shape_mesh(v: pointer, nv: int, tri: pointer, nt: int) -> pointer = "jph_shape_mesh"
extern function jph_shape_free(s: pointer) -> void = "jph_shape_free"

View file

@ -17,6 +17,19 @@ JPH_SHIM void *jph_shape_dome(float r, float h) {
return keep(RotatedTranslatedShapeSettings(Vec3(0.0f, h - big, 0.0f), Quat::sIdentity(), s).Create());
}
// a rock: the convex hull of n points (x, y, z) about its origin, its edges rounded by radius
JPH_SHIM void *jph_shape_convex(const float *v, int n, float radius) {
Array<Vec3> pts;
pts.reserve(n);
for (int i = 0; i < n; ++i) pts.push_back(Vec3(v[3 * i], v[3 * i + 1], v[3 * i + 2]));
return keep(ConvexHullShapeSettings(pts, radius).Create());
}
// another shape, k times its size on every axis (one hull, every boulder of that shape)
JPH_SHIM void *jph_shape_scaled(void *s, float k) {
return keep(ScaledShapeSettings(static_cast<Shape *>(s), Vec3::sReplicate(k)).Create());
}
// a shape moved and turned about y, inside its body
JPH_SHIM void *jph_shape_offset(void *s, float x, float y, float z, float yaw) {
return keep(RotatedTranslatedShapeSettings(Vec3(x, y, z), yaw_q(yaw), static_cast<Shape *>(s)).Create());

View file

@ -16,6 +16,8 @@
#include <Jolt/Physics/Collision/Shape/HeightFieldShape.h>
#include <Jolt/Physics/Collision/Shape/MeshShape.h>
#include <Jolt/Physics/Collision/Shape/RotatedTranslatedShape.h>
#include <Jolt/Physics/Collision/Shape/ConvexHullShape.h>
#include <Jolt/Physics/Collision/Shape/ScaledShape.h>
#include <Jolt/Physics/Collision/RayCast.h>
#include <Jolt/Physics/Collision/CastResult.h>
#include <Jolt/Physics/Collision/ShapeCast.h>

View file

@ -59,6 +59,18 @@ export function phys_cylinder(physics_st: mut PhysicsState, half_h: float, r: fl
# a boulder: a dome r across and h tall, standing on its origin
export function phys_dome(physics_st: mut PhysicsState, r: float, h: float) -> int { return ph_keep(physics_st, jph_shape_dome(r, h)) }
# a rock: the convex hull of n points (x, y, z) about its origin, its edges rounded by 2 cm
export function phys_convex(physics_st: mut PhysicsState, v: []float, n: int) -> int {
if n < 4 or len(v) < n * 3 { return -1 }
return ph_keep(physics_st, jph_shape_convex(v, n, 0.02))
}
# another shape k times its size, so one hull serves every boulder of its shape
export function phys_scaled(physics_st: mut PhysicsState, shape: int, k: float) -> int {
let s = ph_shape(physics_st, shape)
if s == null { return -1 }
return ph_keep(physics_st, jph_shape_scaled(s, k))
}
# another shape moved and turned about y inside its body (a post whose foot is its origin)
export function phys_offset(physics_st: mut PhysicsState, shape: int, x: float, y: float, z: float, yaw: float) -> int {
let s = ph_shape(physics_st, shape)

View file

@ -0,0 +1,44 @@
# hull_shape_test.ludic - a rock's shape: a convex hull of points, scaled for one boulder and
# turned by its yaw the way a renderer turns an instance (x' = cos x + sin z, z' = -sin x + cos z)
import "ludic.physics"
import "ludic.base"
program HullShapeTest {
numbers float
# a cube two metres across, its centre two metres along x from its origin
function cube(physics_st: mut PhysicsState) -> int {
let v = floats(8 * 3)
for i in 0 .. 8 {
v[i * 3] = 2.0 + float(i % 2) * 2.0 - 1.0
v[i * 3 + 1] = float((i / 2) % 2) * 2.0 - 1.0
v[i * 3 + 2] = float(i / 4) * 2.0 - 1.0
}
return phys_convex(physics_st, v, 8)
}
function down(physics_st: mut PhysicsState, x: float, z: float) -> PhysHit { return phys_ray(physics_st, x, 10.0, z, 0.0, -20.0, 0.0, PHYS_M_ALL) }
test "a hull stands where its points are, and too few points are no shape" (physics_st: mut PhysicsState) {
expect(phys_open(physics_st, 16, 1))
phys_static_add(physics_st, cube(physics_st), 0.0, 0.0, 0.0, 0.0)
phys_settle(physics_st)
let h = down(physics_st, 2.0, 0.0)
expect(h.body >= 0)
expect(Math.abs(h.y - 1.0) < 0.01)
expect(down(physics_st, 0.0, 0.0).body < 0)
expect_eq(phys_convex(physics_st, floats(9), 3), -1)
phys_close(physics_st)
}
test "scaled twice as big, and turned a quarter the renderer's way" (physics_st: mut PhysicsState) {
expect(phys_open(physics_st, 16, 1))
phys_static_add(physics_st, phys_scaled(physics_st, cube(physics_st), 2.0), 0.0, 0.0, 0.0, 1.5707963)
phys_settle(physics_st)
# (4, 0, 0) turned by +90 degrees about y is (0, 0, -4): x' = cos x + sin z, z' = -sin x + cos z
let h = down(physics_st, 0.0, -4.0)
expect(h.body >= 0)
expect(Math.abs(h.y - 2.0) < 0.01)
expect(down(physics_st, 4.0, 0.0).body < 0)
expect(down(physics_st, 0.0, 4.0).body < 0)
phys_close(physics_st)
}
}

View file

@ -191,7 +191,13 @@ export state Render3dState {
gvk_frame_fence: bytes = null # the presented frame still on the GPU (one frame in flight)
gvk_frame_pending: bool = false
gvk_frame_pending_cb: pointer = null
gvk_inflight: int = -1 # -1 until asked: 1 leaves the presented frame in flight
gvk_frame_pending_no: int = -1 # which frame it is (gvk_frame_no when it was submitted)
gvk_dpools: []long = null # a descriptor pool per frame slot (frame_no & 1)
gvk_ring_end: int = 0 # the end of this frame's half of the ring
gvk_retired_frame: []int = null # the last frame that read each retired buffer
gvk_labels: bool = false # R3D_VK_LABELS: each profiled pass is a debug label (Metal System Trace)
gvk_inflight: int = -1
gvk_label_depth: int = 0 # -1 until asked: 1 leaves the presented frame in flight
gvk_has_hqr: bool = false
gvk_ts_period: float = 0.0 # float bits: nanoseconds per timestamp tick
gvk_qpool: long = 0
@ -208,6 +214,7 @@ export state Render3dState {
gvk_cp_layout: []long = null
gvk_cp_dsl: []long = null
gvk_cp_nbuf: []int = null
gvk_cp_ntex: []int = null
gvk_pipe_keys: []string = null
gvk_pipe: []long = null
gvk_zero_vbuf: int = 0
@ -357,8 +364,22 @@ export state Render3dState {
grass_n: int = 0
grass_cmds: int = 0
grass_band_start: words = null # per band: its first record
grass_band_cells: words = null # ... and its 16 m cells per tile side
grass_band_cells: words = null # ... and its cells per tile side
grass_band_n: int = 0
gg_on: bool = false # the blades are culled on the GPU (grass_gpu.ludic)
gg_reset: int = 0
gg_cull: int = 0
gg_prog: int = 0
gg_tiles_buf: []int = null
gg_tv: floats = null
gg_out: int = 0
gg_cmds: int = 0
gg_mesh: []Mesh = null
gg_n: int = 0
gg_max: int = 0
grass_band_mesh: []Mesh = null # per band: the blade it draws (rows fall with distance)
grass_mesh3: Mesh = null # three rows: the middle distance
grass_mesh2: Mesh = null # two rows, one quad: far enough that the arch is under a pixel
grass_mesh_on: bool = false
grass_mesh_prog: int = 0
r3d_draw_cb: fn() = null
@ -582,6 +603,9 @@ export state Render3dState {
sc_cast_dy: float = 0.0
sc_cast_k: float = 0.0
sc_skip_blade: bool = false
sc_skip_grass: bool = false
grass_force: bool = false # R3D_BLADES: the blades on whatever the game set
grass_env_off: bool = false # R3D_NOBLADES: the blades off whatever the game set
sc_skip_card: bool = false
cb_state: int = 12345
shadow_res: int = 2048

View file

@ -513,6 +513,14 @@ function gpu_compute(render3d_st: mut Render3dState, name: string, n_bufs: int)
function gpu_dispatch(render3d_st: mut Render3dState, c: int, params: pointer, n_params: int, bufs: words, groups: int) -> void {
if render3d_st.gpu_kind == GPU_VK and c > 0 { gvk_dispatch(render3d_st, c, params, n_params, bufs, groups, 1, 1) }
}
# a compute program that also samples n_tex textures (bound after its buffers), and its 2-D dispatch
function gpu_compute_tex(render3d_st: mut Render3dState, name: string, n_bufs: int, n_tex: int) -> int {
if render3d_st.gpu_kind != GPU_VK { return 0 }
return gvk_compute_new_tex(render3d_st, name, n_bufs, n_tex)
}
function gpu_dispatch_tex(render3d_st: mut Render3dState, c: int, params: pointer, n_params: int, bufs: words, texs: words, gx: int, gy: int) -> void {
if render3d_st.gpu_kind == GPU_VK and c > 0 { gvk_dispatch_tex(render3d_st, c, params, n_params, bufs, texs, gx, gy, 1) }
}
# a buffer a compute pass writes (never reallocated under a draw that reads it)
function gpu_buffer_gpu_owned(render3d_st: mut Render3dState, buf: int) -> void { if render3d_st.gpu_kind == GPU_VK { gvk_buf_gpu_owned(render3d_st, buf) } }
# the host-visible contents of a buffer, for a readback after gpu_finish; null on OpenGL

View file

@ -85,6 +85,9 @@ function gvk_init(render3d_st: mut Render3dState) -> bool {
Vk.put_ptr(iext_names, n_iext * 8, VK_KHR_SURFACE_EXTENSION_NAME); n_iext += 1
Vk.put_ptr(iext_names, n_iext * 8, surf_ext); n_iext += 1
}
# R3D_VK_LABELS: name each pass for a GPU capture (MoltenVK makes them Metal debug groups)
render3d_st.gvk_labels = r3d_env_has(render3d_st, "R3D_VK_LABELS") and gvk_ext_in(iexts, nie, VK_EXT_DEBUG_UTILS_EXTENSION_NAME) and Vk.has("vkCmdBeginDebugUtilsLabelEXT") == 1
if render3d_st.gvk_labels { Vk.put_ptr(iext_names, n_iext * 8, VK_EXT_DEBUG_UTILS_EXTENSION_NAME); n_iext += 1 }
# HDR output: an instance only lists the HDR colour spaces when it asks for them
render3d_st.gvk_has_colorspace = render3d_st.gvk_has_surface and gvk_ext_in(iexts, nie, VK_EXT_SWAPCHAIN_COLOR_SPACE_EXTENSION_NAME)
if render3d_st.gvk_has_colorspace { Vk.put_ptr(iext_names, n_iext * 8, VK_EXT_SWAPCHAIN_COLOR_SPACE_EXTENSION_NAME); n_iext += 1 }
@ -544,8 +547,11 @@ function gvk_once_end(render3d_st: mut Render3dState, cb: pointer) -> bool {
# CPU while the GPU draws: waiting at the present serialised the two, which cost MoltenVK a third
# of its frame rate against OpenGL. Anything that would reuse what that frame reads - the next
# frame's command buffer and ring, a one-off submit, a descriptor pool reset, a swapchain rebuild -
# waits for it first (gvk_frame_wait), and a buffer it reads is busy (gvk_buf_busy). On by default
# on macOS; R3D_VK_INFLIGHT=0/1 says otherwise.
# waits for it first (gvk_frame_wait), and a buffer it reads is busy (gvk_buf_busy). The frame's
# uniform ring and descriptor pool are double-buffered by frame_no & 1, so the next frame records
# while this one draws: waiting at the next frame's start left the GPU idle for the whole of its
# recording and MoltenVK's encoding (a third of a frame). On by default on macOS; R3D_VK_INFLIGHT=0/1
# says otherwise.
function gvk_inflight_on(render3d_st: mut Render3dState) -> bool {
if render3d_st.gvk_inflight < 0 {
render3d_st.gvk_inflight = 0
@ -571,6 +577,7 @@ function gvk_frame_submit(render3d_st: mut Render3dState, cb: pointer) -> bool {
if r != VK_SUCCESS { return gvk_fail(render3d_st, "vkQueueSubmit", r) }
render3d_st.gvk_frame_pending = true
render3d_st.gvk_frame_pending_cb = cb
render3d_st.gvk_frame_pending_no = render3d_st.gvk_frame_no
return true
}
@ -583,7 +590,7 @@ function gvk_frame_wait(render3d_st: mut Render3dState) -> void {
Vk.free_command_buffers(render3d_st.gvk_dev, render3d_st.gvk_pool, 1, cbs)
render3d_st.gvk_frame_pending = false
render3d_st.gvk_frame_pending_cb = null
gvk_retire_flush(render3d_st)
gvk_retire_upto(render3d_st, render3d_st.gvk_frame_pending_no)
}
# ---- teardown -----------------------------------------------------------------------------
@ -640,3 +647,30 @@ function gvk_query_result(render3d_st: Render3dState, id: int, out: words) -> bo
out[0] = int(float(ticks) * render3d_st.gvk_ts_period)
return true
}
# ---- debug labels (R3D_VK_LABELS) ------------------------------------------------------------
function gvk_label_begin(render3d_st: mut Render3dState, name: pointer) -> void {
# only inside a frame: a pass the load profiles comes before the frame's pools exist
if render3d_st.gpu_kind != GPU_VK or render3d_st.gvk_cb == null { return }
let cb = render3d_st.gvk_cb
let li = bytes(VkDebugUtilsLabelEXT_sizeof)
Vk.zero(li, VkDebugUtilsLabelEXT_sizeof)
Vk.put_i32(li, VkDebugUtilsLabelEXT_sType, VK_STRUCTURE_TYPE_DEBUG_UTILS_LABEL_EXT)
Vk.put_ptr(li, VkDebugUtilsLabelEXT_pLabelName, name)
Vk.cmd_begin_debug_utils_label_ext(cb, li)
render3d_st.gvk_label_depth += 1
}
function gvk_label_end(render3d_st: mut Render3dState) -> void {
if render3d_st.gpu_kind != GPU_VK or render3d_st.gvk_label_depth <= 0 or render3d_st.gvk_cb == null { return }
Vk.cmd_end_debug_utils_label_ext(render3d_st.gvk_cb)
render3d_st.gvk_label_depth -= 1
}
# the frame's command buffer ends with every label it opened closed (called where it ends: a
# pointer compares by what it points at, so once_end cannot ask which buffer it was handed)
function gvk_labels_close(render3d_st: mut Render3dState) -> void {
if render3d_st.gvk_cb == null { render3d_st.gvk_label_depth = 0; return }
while render3d_st.gvk_label_depth > 0 {
Vk.cmd_end_debug_utils_label_ext(render3d_st.gvk_cb)
render3d_st.gvk_label_depth -= 1
}
}

View file

@ -120,24 +120,28 @@ function gvk_program(render3d_st: mut Render3dState, p: int, key: string, spv_di
# ---- compute ------------------------------------------------------------------------------
# A compute program is <name>.comp.spv beside the manifest, with bindings fixed by convention:
# 0 the parameter block (a uniform buffer, copied into the frame's ring at the dispatch) and
# 1 .. n storage buffers. Handles start at 1.
# 0 the parameter block (a uniform buffer, copied into the frame's ring at the dispatch),
# 1 .. n storage buffers, then n+1 .. n+m sampled textures (linear, clamped). Handles start at 1.
function gvk_compute_new(render3d_st: mut Render3dState, name: string, n_bufs: int) -> int {
function gvk_compute_new(render3d_st: mut Render3dState, name: string, n_bufs: int) -> int { return gvk_compute_new_tex(render3d_st, name, n_bufs, 0) }
function gvk_compute_new_tex(render3d_st: mut Render3dState, name: string, n_bufs: int, n_tex: int) -> int {
let zero: long = 0
if render3d_st.gvk_cp_pipe == null {
render3d_st.gvk_cp_pipe = new []long; render3d_st.gvk_cp_layout = new []long; render3d_st.gvk_cp_dsl = new []long; render3d_st.gvk_cp_nbuf = new []int
push(render3d_st.gvk_cp_pipe, zero); push(render3d_st.gvk_cp_layout, zero); push(render3d_st.gvk_cp_dsl, zero); push(render3d_st.gvk_cp_nbuf, 0)
render3d_st.gvk_cp_ntex = new []int
push(render3d_st.gvk_cp_pipe, zero); push(render3d_st.gvk_cp_layout, zero); push(render3d_st.gvk_cp_dsl, zero); push(render3d_st.gvk_cp_nbuf, 0); push(render3d_st.gvk_cp_ntex, 0)
}
let module = gvk_module(render3d_st, `{render3d_st.gvk_spv_dir}/{name}.comp.spv`)
if module == 0 { return 0 }
let nb = n_bufs + 1
let nb = n_bufs + 1 + n_tex
let bw = VkDescriptorSetLayoutBinding_sizeof
let binds = bytes(bw * nb)
Vk.zero(binds, bw * nb)
for k in 0 .. nb {
var kind = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER
if k == 0 { kind = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER }
if k > n_bufs { kind = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER }
Vk.put_i32(binds, k * bw + VkDescriptorSetLayoutBinding_binding, k)
Vk.put_i32(binds, k * bw + VkDescriptorSetLayoutBinding_descriptorType, kind)
Vk.put_i32(binds, k * bw + VkDescriptorSetLayoutBinding_descriptorCount, 1)
@ -172,17 +176,25 @@ function gvk_compute_new(render3d_st: mut Render3dState, name: string, n_bufs: i
r = Vk.create_compute_pipelines(render3d_st.gvk_dev, zero, 1, cpci, null, out)
if r != VK_SUCCESS { gvk_fail(render3d_st, `vkCreateComputePipelines {name}`, r); return 0 }
push(render3d_st.gvk_cp_pipe, gvk_handle(out)); push(render3d_st.gvk_cp_layout, layout); push(render3d_st.gvk_cp_dsl, Vk.get_i64(dsl, 0)); push(render3d_st.gvk_cp_nbuf, n_bufs)
push(render3d_st.gvk_cp_ntex, n_tex)
return len(render3d_st.gvk_cp_pipe) - 1
}
# Record a dispatch of compute program c into the frame, between passes: params (n_params
# bytes, std140) as binding 0 and bufs[0 .. n) as bindings 1 .. n.
function gvk_dispatch(render3d_st: mut Render3dState, c: int, params: pointer, n_params: int, bufs: words, gx: int, gy: int, gz: int) -> void {
function gvk_dispatch(render3d_st: mut Render3dState, c: int, params: pointer, n_params: int, bufs: words, gx: int, gy: int, gz: int) -> void { gvk_dispatch_tex(render3d_st, c, params, n_params, bufs, null, gx, gy, gz) }
# ... and texs[0 .. m) as the sampled textures after the buffers. What it writes is made visible to
# the indirect draws, vertex attributes and shaders that follow (Metal's own hazard tracking was all
# that ordered the tree culling's output before this)
function gvk_dispatch_tex(render3d_st: mut Render3dState, c: int, params: pointer, n_params: int, bufs: words, texs: words, gx: int, gy: int, gz: int) -> void {
let zero: long = 0
if render3d_st.gvk_cp_pipe == null or c <= 0 or c >= len(render3d_st.gvk_cp_pipe) { return }
gvk_pass_end(render3d_st)
let cb = gvk_frame_cb(render3d_st)
let nb = render3d_st.gvk_cp_nbuf[c]
var nt = 0
if render3d_st.gvk_cp_ntex != null and c < len(render3d_st.gvk_cp_ntex) { nt = render3d_st.gvk_cp_ntex[c] }
let dsai = bytes(VkDescriptorSetAllocateInfo_sizeof)
Vk.zero(dsai, VkDescriptorSetAllocateInfo_sizeof)
Vk.put_i32(dsai, VkDescriptorSetAllocateInfo_sType, VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO)
@ -199,10 +211,13 @@ function gvk_dispatch(render3d_st: mut Render3dState, c: int, params: pointer, n
if at < 0 { print("r3d: vulkan: the frame's uniform ring is full"); return }
let ww = VkWriteDescriptorSet_sizeof
let bw = VkDescriptorBufferInfo_sizeof
let writes = bytes(ww * (nb + 1))
Vk.zero(writes, ww * (nb + 1))
let iw = VkDescriptorImageInfo_sizeof
let writes = bytes(ww * (nb + 1 + nt))
Vk.zero(writes, ww * (nb + 1 + nt))
let infos = bytes(bw * (nb + 1))
Vk.zero(infos, bw * (nb + 1))
let iis = bytes(iw * (nt + 1))
Vk.zero(iis, iw * (nt + 1))
for k in 0 .. nb + 1 {
var buf = render3d_st.gvk_ring_buf
var off: long = 0
@ -220,10 +235,31 @@ function gvk_dispatch(render3d_st: mut Render3dState, c: int, params: pointer, n
Vk.put_i32(writes, k * ww + VkWriteDescriptorSet_descriptorType, kind)
Vk.put_ptr(writes, k * ww + VkWriteDescriptorSet_pBufferInfo, mem_off(infos, k * bw))
}
Vk.update_descriptor_sets(render3d_st.gvk_dev, nb + 1, writes, 0, null)
let smp = gvk_sampler(render3d_st, GL_LINEAR, GL_LINEAR, GL_CLAMP_TO_EDGE, GL_CLAMP_TO_EDGE, 0, 0)
for t in 0 .. nt {
var tex = render3d_st.gvk_white
if texs != null and t < len(texs) and texs[t] > 0 { tex = texs[t] }
Vk.put_i64(iis, t * iw + VkDescriptorImageInfo_sampler, smp)
Vk.put_i64(iis, t * iw + VkDescriptorImageInfo_imageView, render3d_st.gvk_tex_view[tex])
Vk.put_i32(iis, t * iw + VkDescriptorImageInfo_imageLayout, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
let k = nb + 1 + t
Vk.put_i32(writes, k * ww + VkWriteDescriptorSet_sType, VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET)
Vk.put_i64(writes, k * ww + VkWriteDescriptorSet_dstSet, set)
Vk.put_i32(writes, k * ww + VkWriteDescriptorSet_dstBinding, k)
Vk.put_i32(writes, k * ww + VkWriteDescriptorSet_descriptorCount, 1)
Vk.put_i32(writes, k * ww + VkWriteDescriptorSet_descriptorType, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
Vk.put_ptr(writes, k * ww + VkWriteDescriptorSet_pImageInfo, mem_off(iis, t * iw))
}
Vk.update_descriptor_sets(render3d_st.gvk_dev, nb + 1 + nt, writes, 0, null)
Vk.cmd_bind_pipeline(cb, VK_PIPELINE_BIND_POINT_COMPUTE, render3d_st.gvk_cp_pipe[c])
Vk.cmd_bind_descriptor_sets(cb, VK_PIPELINE_BIND_POINT_COMPUTE, render3d_st.gvk_cp_layout[c], 0, 1, sets, 0, null)
Vk.cmd_dispatch(cb, gx, gy, gz)
let mb = bytes(VkMemoryBarrier_sizeof)
Vk.zero(mb, VkMemoryBarrier_sizeof)
Vk.put_i32(mb, VkMemoryBarrier_sType, VK_STRUCTURE_TYPE_MEMORY_BARRIER)
Vk.put_i32(mb, VkMemoryBarrier_srcAccessMask, VK_ACCESS_SHADER_WRITE_BIT)
Vk.put_i32(mb, VkMemoryBarrier_dstAccessMask, VK_ACCESS_INDIRECT_COMMAND_READ_BIT | VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT | VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT)
Vk.cmd_pipeline_barrier(cb, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT | VK_PIPELINE_STAGE_VERTEX_INPUT_BIT | VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 1, mb, 0, null, 0, null)
}
# R3D_VK_PROBE=1: one dispatch over a GPU-owned buffer, read back - the compute path end to end
@ -650,7 +686,8 @@ function gvk_frame_init(render3d_st: mut Render3dState) -> bool {
if (counts & VK_SAMPLE_COUNT_2_BIT) != 0 { render3d_st.gvk_msaa_max = 2 }
if (counts & VK_SAMPLE_COUNT_4_BIT) != 0 { render3d_st.gvk_msaa_max = 4 }
render3d_st.gvk_ring_buf = gvk_buf_new(render3d_st)
if not gvk_buf_reserve(render3d_st, render3d_st.gvk_ring_buf, GVK_RING_BYTES) { return false }
# one half per frame slot: the cached sets bind this one buffer, and each draw's offset says the half
if not gvk_buf_reserve(render3d_st, render3d_st.gvk_ring_buf, GVK_RING_BYTES * 2) { return false }
let sizes = bytes(VkDescriptorPoolSize_sizeof * 3)
Vk.put_i32(sizes, VkDescriptorPoolSize_type, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER)
Vk.put_i32(sizes, VkDescriptorPoolSize_descriptorCount, 32768)
@ -665,9 +702,13 @@ function gvk_frame_init(render3d_st: mut Render3dState) -> bool {
Vk.put_i32(dpci, VkDescriptorPoolCreateInfo_poolSizeCount, 3)
Vk.put_ptr(dpci, VkDescriptorPoolCreateInfo_pPoolSizes, sizes)
let out = bytes(8)
let r = Vk.create_descriptor_pool(render3d_st.gvk_dev, dpci, null, out)
if r != VK_SUCCESS { return gvk_fail(render3d_st, "vkCreateDescriptorPool", r) }
render3d_st.gvk_dpool = gvk_handle(out)
render3d_st.gvk_dpools = new []long
for k in 0 .. 2 {
let r = Vk.create_descriptor_pool(render3d_st.gvk_dev, dpci, null, out)
if r != VK_SUCCESS { return gvk_fail(render3d_st, "vkCreateDescriptorPool", r) }
push(render3d_st.gvk_dpools, gvk_handle(out))
}
render3d_st.gvk_dpool = render3d_st.gvk_dpools[0]
if not gvk_kpool_make(render3d_st) { return false }
render3d_st.gvk_white = gvk_tex_new(render3d_st)
let px = bytes(4)
@ -676,14 +717,17 @@ function gvk_frame_init(render3d_st: mut Render3dState) -> bool {
}
function gvk_frame_reset(render3d_st: mut Render3dState) -> void {
render3d_st.gvk_ring_off = 0
let slot = render3d_st.gvk_frame_no & 1
render3d_st.gvk_ring_off = slot * GVK_RING_BYTES
render3d_st.gvk_ring_end = (slot + 1) * GVK_RING_BYTES
render3d_st.gvk_dpool = render3d_st.gvk_dpools[slot]
Vk.reset_descriptor_pool(render3d_st.gvk_dev, render3d_st.gvk_dpool, 0)
}
# a block into the ring; its offset, or -1 when the frame has used the whole ring
function gvk_ring_put(render3d_st: mut Render3dState, blk: pointer, n: int) -> int {
let at = (render3d_st.gvk_ring_off + render3d_st.gvk_ring_align - 1) / render3d_st.gvk_ring_align * render3d_st.gvk_ring_align
if at + n > GVK_RING_BYTES { return -1 }
if at + n > render3d_st.gvk_ring_end { return -1 }
mem_copy(mem_off(render3d_st.gvk_buf_map[render3d_st.gvk_ring_buf], at), blk, n)
render3d_st.gvk_ring_off = at + n
return at
@ -954,7 +998,8 @@ function gvk_screen_make(render3d_st: mut Render3dState, w: int, h: int) -> bool
function gvk_frame_cb(render3d_st: mut Render3dState) -> pointer {
if render3d_st.gvk_cb == null {
gvk_frame_wait(render3d_st)
# the frame in flight uses the other half of the ring and the other pool, unless it has this slot
if render3d_st.gvk_frame_pending and (render3d_st.gvk_frame_pending_no & 1) == (render3d_st.gvk_frame_no & 1) { gvk_frame_wait(render3d_st) }
gvk_frame_reset(render3d_st)
render3d_st.gvk_cb = gvk_once_begin(render3d_st)
}
@ -1270,6 +1315,7 @@ function gvk_flush(render3d_st: mut Render3dState) -> void {
if render3d_st.gvk_cb == null { return }
render3d_st.gvk_n_flush += 1
gvk_pass_end(render3d_st)
gvk_labels_close(render3d_st)
gvk_once_end(render3d_st, render3d_st.gvk_cb)
render3d_st.gvk_cb = null
render3d_st.gvk_frame_no += 1
@ -1290,6 +1336,7 @@ function gvk_present(render3d_st: mut Render3dState) -> void {
}
if render3d_st.gvk_cb == null { return }
gvk_pass_end(render3d_st)
gvk_labels_close(render3d_st)
gvk_once_end(render3d_st, render3d_st.gvk_cb)
render3d_st.gvk_cb = null
render3d_st.gvk_frame_no += 1
@ -1669,6 +1716,7 @@ function gvk_swap_make(render3d_st: mut Render3dState, w: int, h: int) -> bool {
function gvk_present_window(render3d_st: mut Render3dState) -> void {
let cb = gvk_frame_cb(render3d_st)
gvk_pass_end(render3d_st)
gvk_labels_close(render3d_st)
# a covered window is not drawn to: its layer would hold the frame for a drawable the compositor
# hands back once a second. The frame still runs, paced at about 60 Hz, so the game keeps time.
if win_visible() == 0 {

View file

@ -560,6 +560,9 @@ function gvk_buf_release(render3d_st: mut Render3dState, b: int) -> void {
if gvk_buf_busy(render3d_st, b) {
# a draw recorded this frame (or the frame in flight) still reads it: destroy it once the frame has been submitted
push(render3d_st.gvk_retired_buf, render3d_st.gvk_buf[b]); push(render3d_st.gvk_retired_mem, render3d_st.gvk_buf_mem[b])
if render3d_st.gvk_retired_frame == null { render3d_st.gvk_retired_frame = new []int }
while len(render3d_st.gvk_retired_frame) < len(render3d_st.gvk_retired_buf) - 1 { push(render3d_st.gvk_retired_frame, 0) }
push(render3d_st.gvk_retired_frame, render3d_st.gvk_buf_used[b])
} else {
Vk.destroy_buffer(render3d_st.gvk_dev, render3d_st.gvk_buf[b], null)
gvk_mem_free(render3d_st, gvk_mem_id(render3d_st.gvk_buf_mem[b]))
@ -569,18 +572,32 @@ function gvk_buf_release(render3d_st: mut Render3dState, b: int) -> void {
# read by a draw recorded this frame, or by the presented frame still on the GPU
function gvk_buf_busy(render3d_st: Render3dState, b: int) -> bool {
let u = render3d_st.gvk_buf_used[b]
return u == render3d_st.gvk_frame_no or (render3d_st.gvk_frame_pending and u == render3d_st.gvk_frame_no - 1)
return u == render3d_st.gvk_frame_no or (render3d_st.gvk_frame_pending and u == render3d_st.gvk_frame_pending_no)
}
# the frame's submitted work is done: storage retired during it can go
function gvk_retire_flush(render3d_st: mut Render3dState) -> void {
# everything submitted is done: every retired buffer can go
function gvk_retire_flush(render3d_st: mut Render3dState) -> void { gvk_retire_upto(render3d_st, render3d_st.gvk_frame_no) }
# frame `done` and every frame before it are finished: storage last read by them can go, and what
# the frame being recorded read stays
function gvk_retire_upto(render3d_st: mut Render3dState, done: int) -> void {
if render3d_st.gvk_retired_buf == null { return }
let keep_buf = new []long
let keep_mem = new []long
let keep_frame = new []int
for i in 0 .. len(render3d_st.gvk_retired_buf) {
Vk.destroy_buffer(render3d_st.gvk_dev, render3d_st.gvk_retired_buf[i], null)
gvk_mem_free(render3d_st, gvk_mem_id(render3d_st.gvk_retired_mem[i]))
var f = 0
if render3d_st.gvk_retired_frame != null and i < len(render3d_st.gvk_retired_frame) { f = render3d_st.gvk_retired_frame[i] }
if f > done {
push(keep_buf, render3d_st.gvk_retired_buf[i]); push(keep_mem, render3d_st.gvk_retired_mem[i]); push(keep_frame, f)
} else {
Vk.destroy_buffer(render3d_st.gvk_dev, render3d_st.gvk_retired_buf[i], null)
gvk_mem_free(render3d_st, gvk_mem_id(render3d_st.gvk_retired_mem[i]))
}
}
render3d_st.gvk_retired_buf = new []long
render3d_st.gvk_retired_mem = new []long
render3d_st.gvk_retired_buf = keep_buf
render3d_st.gvk_retired_mem = keep_mem
render3d_st.gvk_retired_frame = keep_frame
}
# A buffer a compute pass writes: a draw later in the same frame reads what the GPU put there,

View file

@ -1,7 +1,7 @@
# ============================================================================
# grass.ludic — procedural GPU ground cover with continuous density (no rings).
#
# The world is cut into 16 m cells; blade j of a cell always stands in the same place
# The world is cut into 4 m cells; blade j of a cell always stands in the same place
# (shaders/grass.vert). Draws are per tile: the CPU walks tiles around the camera,
# frustum-culls them, and feeds each visible tile as many blade indices per cell as its
# NEAREST point could need; the vertex stage then keeps only the indices that exist at
@ -10,7 +10,7 @@
# draw count down — the cells and their hashes are the same in every tile size.
# ============================================================================
const GRASS_CELL: int = 16
const GRASS_CELL: int = 4
# A photographed blade, as an atlas of straightened blades side by side (the game sets
# this; the renderer does not name a game asset). 0 = the procedural gradient, which is
# what this was for a year: a two-tone ramp with a hard edge, and every blade in the
@ -89,11 +89,13 @@ function grass_blade_mesh(render3d_st: mut Render3dState, rows: int) -> Mesh {
function grass_init(render3d_st: mut Render3dState) -> void {
render3d_st.grass_merge = gpu_has_mdi(render3d_st) and not r3d_env_has(render3d_st, "R3D_GRASS_TILES")
var defs = "#define FOLIAGE\n#define BLADE\n"
var defs = "#define FOLIAGE\n#define BLADE\n#define GBLADE\n"
if render3d_st.grass_merge {
defs = defs + "#define TILES\n"
render3d_st.grass_rec = words(GRASS_RECS * 5); render3d_st.grass_tv = floats(GRASS_RECS * 4); render3d_st.grass_chunk_tv = floats(GRASS_CHUNK * 4)
render3d_st.grass_band_start = words(4); render3d_st.grass_band_cells = words(4)
render3d_st.grass_band_mesh = new []Mesh
for b in 0 .. 4 { push(render3d_st.grass_band_mesh, null) }
render3d_st.grass_cmds = gpu_buffer_new(render3d_st)
}
render3d_st.grass_prog = r3d_program(render3d_st, "grass.vert", "model.frag", defs)
@ -101,6 +103,8 @@ function grass_init(render3d_st: mut Render3dState) -> void {
# five rows, four quads: the arch above needs somewhere to bend, and at four rows a
# blade that leans over is three straight segments and shows every join
render3d_st.grass_mesh = grass_blade_mesh(render3d_st, 5)
render3d_st.grass_mesh3 = grass_blade_mesh(render3d_st, 3)
render3d_st.grass_mesh2 = grass_blade_mesh(render3d_st, 2)
render3d_st.grass_wind = 2.4
# Matched to the blade's width: a 1 cm blade at 0.11 m spacing covers a third of what a
# 2.8 cm blade did, and the meadow goes bare. The game's graphics settings override this
@ -108,12 +112,17 @@ function grass_init(render3d_st: mut Render3dState) -> void {
# gets there, so the two have to agree or a shot shows something no player will see.
# That is exactly how the last change measured as "no effect": the render was identical
# because this line, not the settings, was deciding.
# The spacing doubles every 18 m and the blades stop at 70 m: at 45 m and 1600 m a 1 cm blade
# was under a pixel from 20 m on, costing a full blade's vertices to shimmer (5 ms of 9.7 on GL).
render3d_st.grass_s0 = 0.066
render3d_st.grass_d0 = 45.0
render3d_st.grass_radius = 1600.0
render3d_st.grass_d0 = 18.0
render3d_st.grass_radius = 70.0
if r3d_env_has(render3d_st, "R3D_NOBLADES") { render3d_st.grass_on = false }
if r3d_env_has(render3d_st, "R3D_GRASS_R") { render3d_st.grass_radius = float(Text.to_int(r3d_env(render3d_st, "R3D_GRASS_R"))) }
if r3d_env_has(render3d_st, "R3D_GRASS_S0") { render3d_st.grass_s0 = float(Text.to_int(r3d_env(render3d_st, "R3D_GRASS_S0"))) / 1000.0 }
if r3d_env_has(render3d_st, "R3D_GRASS_D0") { render3d_st.grass_d0 = float(Text.to_int(r3d_env(render3d_st, "R3D_GRASS_D0"))) }
if r3d_env_has(render3d_st, "R3D_GRASS_DBG") { render3d_st.grass_dbg = Text.to_int(r3d_env(render3d_st, "R3D_GRASS_DBG")) }
gg_init(render3d_st)
}
# indices per 16 m cell that could exist at distance d (the count the shader computes)
@ -124,11 +133,13 @@ function grass_count_at(render3d_st: Render3dState, d: float) -> int {
}
# one tile size over one distance band
function grass_tiles(render3d_st: mut Render3dState, size: int, d_min: float, d_max: float) -> void {
function grass_tiles(render3d_st: mut Render3dState, size: int, d_min: float, d_max: float, blade: Mesh) -> void {
let p = render3d_st.grass_prog
let cells = size / GRASS_CELL
if d_min >= render3d_st.grass_radius { return }
if render3d_st.grass_merge {
render3d_st.grass_band_start[render3d_st.grass_band_n] = render3d_st.grass_n; render3d_st.grass_band_cells[render3d_st.grass_band_n] = cells; render3d_st.grass_band_n += 1
render3d_st.grass_band_start[render3d_st.grass_band_n] = render3d_st.grass_n; render3d_st.grass_band_cells[render3d_st.grass_band_n] = cells
render3d_st.grass_band_mesh[render3d_st.grass_band_n] = blade; render3d_st.grass_band_n += 1
} else {
u_i(render3d_st, gpu_uniform(render3d_st, p, "u_tile_cells"), cells)
}
@ -159,7 +170,7 @@ function grass_tiles(render3d_st: mut Render3dState, size: int, d_min: float, d_
var inst = per * cells * cells
if inst > 65535 { inst = 65535 }
let r = render3d_st.grass_n * 5
render3d_st.grass_rec[r] = render3d_st.grass_mesh.count; render3d_st.grass_rec[r + 1] = inst; render3d_st.grass_rec[r + 2] = 0; render3d_st.grass_rec[r + 3] = 0
render3d_st.grass_rec[r] = blade.count; render3d_st.grass_rec[r + 1] = inst; render3d_st.grass_rec[r + 2] = 0; render3d_st.grass_rec[r + 3] = 0
render3d_st.grass_rec[r + 4] = ((render3d_st.grass_n - render3d_st.grass_band_start[render3d_st.grass_band_n - 1]) % GRASS_CHUNK) * 65536
let t = render3d_st.grass_n * 4
render3d_st.grass_tv[t] = ox; render3d_st.grass_tv[t + 1] = oz; render3d_st.grass_tv[t + 2] = float(per); render3d_st.grass_tv[t + 3] = 0.0
@ -168,7 +179,7 @@ function grass_tiles(render3d_st: mut Render3dState, size: int, d_min: float, d_
} else if per > 0 {
u_f2(render3d_st, gpu_uniform(render3d_st, p, "u_tile"), ox, oz)
u_i(render3d_st, gpu_uniform(render3d_st, p, "u_per_cell"), per)
mesh_draw_instanced(render3d_st, render3d_st.grass_mesh, per * cells * cells)
mesh_draw_instanced(render3d_st, blade, per * cells * cells)
render3d_st.grass_draws += 1
}
}
@ -178,11 +189,27 @@ function grass_tiles(render3d_st: mut Render3dState, size: int, d_min: float, d_
}
}
function grass_live(render3d_st: Render3dState) -> bool { return not render3d_st.grass_env_off and (render3d_st.grass_on or render3d_st.grass_force) and render3d_st.grass_prog != 0 }
function grass_draw(render3d_st: mut Render3dState) -> void {
if not render3d_st.grass_on or render3d_st.ter_reflect or render3d_st.grass_prog == 0 { return }
if not grass_live(render3d_st) or render3d_st.ter_reflect { return }
if render3d_st.gg_on { gg_draw(render3d_st); return }
var p = render3d_st.grass_prog
if grass_mesh_live(render3d_st) { p = render3d_st.grass_mesh_prog }
gpu_use_program(render3d_st, p)
grass_bind(render3d_st, p)
gpu_cull(render3d_st, false)
render3d_st.grass_draws = 0
render3d_st.grass_n = 0
render3d_st.grass_band_n = 0
gpu_mesh_bind(render3d_st, render3d_st.grass_mesh)
grass_bands(render3d_st)
if render3d_st.grass_merge { grass_flush(render3d_st) }
gpu_cull(render3d_st, true)
}
# the blades' uniforms, shared by the per-vertex path and the culled one (grass_gpu.ludic)
function grass_bind(render3d_st: mut Render3dState, p: int) -> void {
u_mat4(render3d_st, gpu_uniform(render3d_st, p, "u_view"), render3d_st.cam_view)
u_mat4(render3d_st, gpu_uniform(render3d_st, p, "u_proj"), render3d_st.cam_proj)
u_mat4(render3d_st, gpu_uniform(render3d_st, p, "u_vp"), render3d_st.cam_vp_clean)
@ -205,6 +232,9 @@ function grass_draw(render3d_st: mut Render3dState) -> void {
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_s0"), render3d_st.grass_s0)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_d0"), render3d_st.grass_d0)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_radius"), render3d_st.grass_radius)
var ph = render3d_st.post_h
if ph <= 0 { ph = gl_height() }
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_px"), 2.0 * Math.tan(render3d_st.cam_fov * 0.5) / float(ph))
u_i(render3d_st, gpu_uniform(render3d_st, p, "u_dbg"), render3d_st.grass_dbg)
var orthotex = render3d_st.ter_ortho_tex
var oon = 1.0
@ -228,16 +258,16 @@ function grass_draw(render3d_st: mut Render3dState) -> void {
# does have a faint sheen, so this is small rather than nothing - the foliage layers have
# used 0.05 all along and never showed the fault.
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_spec_scale"), 0.008)
gpu_cull(render3d_st, false)
render3d_st.grass_draws = 0
render3d_st.grass_n = 0
render3d_st.grass_band_n = 0
gpu_mesh_bind(render3d_st, render3d_st.grass_mesh)
grass_tiles(render3d_st, 16, 0.0, 300.0)
grass_tiles(render3d_st, 64, 300.0, 1200.0)
grass_tiles(render3d_st, 256, 1200.0, render3d_st.grass_radius)
if render3d_st.grass_merge { grass_flush(render3d_st) }
gpu_cull(render3d_st, true)
}
# the per-vertex path's tiles, in four bands
function grass_bands(render3d_st: mut Render3dState) -> void {
# four bands: the tile grows and the blade loses rows with distance (a band past the radius is
# skipped); a tile asks for what its NEAREST point needs, so a wide tile far out wastes the rest
grass_tiles(render3d_st, 4, 0.0, 6.0, render3d_st.grass_mesh)
grass_tiles(render3d_st, 4, 6.0, 16.0, render3d_st.grass_mesh3)
grass_tiles(render3d_st, 8, 16.0, 40.0, render3d_st.grass_mesh2)
grass_tiles(render3d_st, 16, 40.0, render3d_st.grass_radius, render3d_st.grass_mesh2)
}
# the records gathered this frame: uploaded once, then each band GRASS_CHUNK records a draw
@ -251,6 +281,8 @@ function grass_flush(render3d_st: mut Render3dState) -> void {
var e = render3d_st.grass_n
if b + 1 < render3d_st.grass_band_n { e = render3d_st.grass_band_start[b + 1] }
if e > s { u_i(render3d_st, gpu_uniform(render3d_st, p, "u_tile_cells"), render3d_st.grass_band_cells[b]) }
let blade = render3d_st.grass_band_mesh[b]
if e > s and not mesh { gpu_mesh_bind(render3d_st, blade) }
var k = s
while k < e {
var m = e - k
@ -273,7 +305,7 @@ function grass_flush(render3d_st: mut Render3dState) -> void {
}
} else {
u_f4v(render3d_st, gpu_uniform(render3d_st, p, "u_tiles"), m, render3d_st.grass_chunk_tv)
gpu_draw_mesh_indirect(render3d_st, render3d_st.grass_mesh, render3d_st.grass_cmds, k * 20, m, 0, 0)
gpu_draw_mesh_indirect(render3d_st, blade, render3d_st.grass_cmds, k * 20, m, 0, 0)
}
if not mesh { render3d_st.grass_draws += 1 }
k += m

View file

@ -0,0 +1,154 @@
# ============================================================================
# grass_gpu.ludic — the meadow's blades culled on the GPU (Vulkan), drawn from what survived.
#
# grass.vert decided every blade per VERTEX: its hashes, the ground under it, whether anything
# grows there, its colour field - ten times for a five-row blade, and in full for every index a
# tile asked for and threw away. That was 3.5 ms of a MoltenVK frame. Here the CPU still walks the
# visible tiles (as grass_tiles does), but grass_cull.comp decides each candidate once and writes
# the survivors into three bands by distance, one indirect draw each; grass_inst.vert only bends
# and places the vertices. OpenGL keeps the per-vertex path. R3D_GRASS_GPU=0 does too, to compare.
# ============================================================================
const GG_TILES: int = 4096
const GG_BANDS: int = 3
const GG_NEAR: float = 6.0 # band 0, five rows
const GG_MID: float = 16.0 # band 1, three rows; band 2 is one quad
function gg_cap(b: int) -> int {
if b == 0 { return 16384 }
if b == 1 { return 49152 }
return 163840
}
function gg_base(b: int) -> int {
if b == 0 { return 0 }
if b == 1 { return 16384 }
return 65536
}
# once, after grass_init: the programs, the buffers and the three instanced blades
function gg_init(render3d_st: mut Render3dState) -> void {
if not gpu_has_compute(render3d_st) or render3d_st.grass_prog == 0 { return }
if r3d_env_has(render3d_st, "R3D_GRASS_GPU") and r3d_env(render3d_st, "R3D_GRASS_GPU") == "0" { return }
render3d_st.gg_reset = gpu_compute(render3d_st, "grass_reset", 1)
render3d_st.gg_cull = gpu_compute_tex(render3d_st, "grass_cull", 3, 2)
render3d_st.gg_prog = r3d_program(render3d_st, "grass_inst.vert", "model.frag", "#define FOLIAGE\n#define BLADE\n#define GBLADE\n#define GINST\n")
if render3d_st.gg_reset == 0 or render3d_st.gg_cull == 0 or render3d_st.gg_prog == 0 { return }
render3d_st.gg_tiles_buf = new []int
for k in 0 .. 2 { push(render3d_st.gg_tiles_buf, gpu_buffer_new(render3d_st)) }
render3d_st.gg_tv = floats(GG_TILES * 4)
render3d_st.gg_out = gpu_buffer_new(render3d_st)
gpu_buffer_upload(render3d_st, render3d_st.gg_out, (gg_base(2) + gg_cap(2)) * 64, null, GPU_DYNAMIC)
gpu_buffer_gpu_owned(render3d_st, render3d_st.gg_out)
render3d_st.gg_mesh = new []Mesh
push(render3d_st.gg_mesh, gg_blade(render3d_st, 5))
push(render3d_st.gg_mesh, gg_blade(render3d_st, 3))
push(render3d_st.gg_mesh, gg_blade(render3d_st, 2))
let rec = words(GG_BANDS * 5)
for b in 0 .. GG_BANDS {
rec[b * 5] = render3d_st.gg_mesh[b].count; rec[b * 5 + 1] = 0; rec[b * 5 + 2] = 0; rec[b * 5 + 3] = 0; rec[b * 5 + 4] = gg_base(b)
}
render3d_st.gg_cmds = gpu_buffer_new(render3d_st)
gpu_buffer_upload(render3d_st, render3d_st.gg_cmds, GG_BANDS * 20, data_of(rec), GPU_DYNAMIC)
gpu_buffer_gpu_owned(render3d_st, render3d_st.gg_cmds)
render3d_st.gg_on = true
print("r3d: grass: blades are culled on the GPU")
}
# a blade mesh reading its instance record (four vec4s) from the cull's output
function gg_blade(render3d_st: mut Render3dState, rows: int) -> Mesh {
let m = grass_blade_mesh(render3d_st, rows)
gpu_mesh_bind_instances(render3d_st, m, render3d_st.gg_out)
for k in 0 .. 4 { gpu_mesh_attr_inst(render3d_st, m, 3 + k, 4, GPU_F32, 64, k * 16) }
gpu_mesh_done(render3d_st, m)
return m
}
# one tile size over one distance band: its visible tiles into gg_tv (corner, indices per cell, cells)
function gg_tiles(render3d_st: mut Render3dState, size: int, d_min: float, d_max: float) -> void {
if d_min >= render3d_st.grass_radius { return }
let cells = size / GRASS_CELL
let sz = float(size)
let half = sz * 0.5
let reach = d_max + half * 1.5
let corner_r = half * 1.42
let tx0 = int(Math.floor((render3d_st.cam_pos[0] - reach) / sz))
let tx1 = int(Math.floor((render3d_st.cam_pos[0] + reach) / sz))
let tz0 = int(Math.floor((render3d_st.cam_pos[2] - reach) / sz))
let tz1 = int(Math.floor((render3d_st.cam_pos[2] + reach) / sz))
for tz in tz0 .. tz1 + 1 {
for tx in tx0 .. tx1 + 1 {
let ox = float(tx) * sz; let oz = float(tz) * sz
let dx = ox + half - render3d_st.cam_pos[0]; let dz = oz + half - render3d_st.cam_pos[2]
let dc = Math.sqrt(dx * dx + dz * dz)
let dnear = Math.max(dc - corner_r, 0.0)
if dc < d_min or not (dnear < d_max) or render3d_st.gg_n >= GG_TILES { continue }
if not cam_sphere_visible(render3d_st, ox + half, terrain_height(render3d_st, ox + half, oz + half), oz + half, corner_r + 6.0) { continue }
let per = grass_count_at(render3d_st, dnear)
var total = per * cells * cells
if total > 65535 { total = 65535 }
let t = render3d_st.gg_n * 4
render3d_st.gg_tv[t] = ox; render3d_st.gg_tv[t + 1] = oz; render3d_st.gg_tv[t + 2] = float(total / (cells * cells)); render3d_st.gg_tv[t + 3] = float(cells)
if total > render3d_st.gg_max { render3d_st.gg_max = total }
render3d_st.gg_n += 1
}
}
}
# before the frame's first pass: the tiles, then the reset and the cull
function gg_cull_frame(render3d_st: mut Render3dState) -> void {
if not render3d_st.gg_on or not grass_live(render3d_st) { return }
render3d_st.gg_n = 0
render3d_st.gg_max = 0
gg_tiles(render3d_st, 4, 0.0, 16.0)
gg_tiles(render3d_st, 8, 16.0, 40.0)
gg_tiles(render3d_st, 16, 40.0, render3d_st.grass_radius)
let rb = words(4)
rb[0] = GG_BANDS; rb[1] = 0; rb[2] = 0; rb[3] = 0
let cb = words(1)
cb[0] = render3d_st.gg_cmds
gpu_dispatch(render3d_st, render3d_st.gg_reset, data_of(rb), 16, cb, 1)
if render3d_st.gg_n == 0 { return }
# the tile list alternates buffers by frame, so the frame still on the GPU keeps its own
let tb = render3d_st.gg_tiles_buf[render3d_st.r3d_test_frame % 2]
gpu_buffer_upload(render3d_st, tb, render3d_st.gg_n * 16, data_of(render3d_st.gg_tv), GPU_DYNAMIC)
let pr = gg_params(render3d_st)
let bufs = words(3)
bufs[0] = tb; bufs[1] = render3d_st.gg_out; bufs[2] = render3d_st.gg_cmds
let texs = words(2)
texs[0] = render3d_st.ter_height_tex; texs[1] = render3d_st.ter_ortho_tex
gpu_dispatch_tex(render3d_st, render3d_st.gg_cull, data_of(pr), 192, bufs, texs, (render3d_st.gg_max + 63) / 64, render3d_st.gg_n)
}
# grass_cull.comp's Params, std140: 12 vec4s
function gg_params(render3d_st: Render3dState) -> words {
let pr = words(48)
for i in 0 .. 48 { pr[i] = 0 }
if render3d_st.cam_planes != null { for i in 0 .. 16 { pr[i] = float_bits(render3d_st.cam_planes[i]) } }
pr[16] = float_bits(render3d_st.cam_pos[0]); pr[17] = float_bits(render3d_st.cam_pos[1]); pr[18] = float_bits(render3d_st.cam_pos[2]); pr[19] = float_bits(render3d_st.grass_radius)
var ph = render3d_st.post_h
if ph <= 0 { ph = gl_height() }
pr[20] = float_bits(render3d_st.grass_s0); pr[21] = float_bits(render3d_st.grass_d0); pr[22] = float_bits(2.0 * Math.tan(render3d_st.cam_fov * 0.5) / float(ph)); pr[23] = float_bits(render3d_st.ter_snow_line)
pr[24] = float_bits(render3d_st.ter_lake_cx); pr[25] = float_bits(render3d_st.ter_lake_cz); pr[26] = float_bits(render3d_st.ter_lake_ex); pr[27] = float_bits(render3d_st.ter_lake_ez)
var lake = -100000.0
if render3d_st.ter_lake_ex != 0.0 { lake = render3d_st.ter_lake_level }
var sea = lake
if render3d_st.ter_sea_set { sea = render3d_st.ter_sea_level }
var oon = 0.0
if render3d_st.ter_ortho_tex != 0 { oon = 1.0 }
pr[28] = float_bits(lake); pr[29] = float_bits(sea); pr[30] = float_bits(oon)
pr[32] = float_bits(render3d_st.ter_ox); pr[33] = float_bits(render3d_st.ter_oz); pr[34] = float_bits(float(render3d_st.TERRAIN_HALF))
pr[36] = float_bits(GG_NEAR); pr[37] = float_bits(GG_MID)
for b in 0 .. GG_BANDS { pr[40 + b] = gg_base(b); pr[44 + b] = gg_cap(b) }
return pr
}
# the survivors: one indirect draw per band, from the records grass_cull.comp counted into
function gg_draw(render3d_st: mut Render3dState) -> void {
let p = render3d_st.gg_prog
gpu_use_program(render3d_st, p)
grass_bind(render3d_st, p)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_time"), render3d_st.r3d_time)
gpu_cull(render3d_st, false)
for b in 0 .. GG_BANDS { gpu_draw_mesh_indirect(render3d_st, render3d_st.gg_mesh[b], render3d_st.gg_cmds, b * 20, 1, 0, 0) }
gpu_cull(render3d_st, true)
}

View file

@ -46,6 +46,7 @@ function prof_slot(render3d_st: mut Render3dState, name: pointer) -> int {
function prof_begin(render3d_st: mut Render3dState, name: pointer) -> void {
ds_set_pass(render3d_st, name)
if render3d_st.gvk_labels { gvk_label_begin(render3d_st, name) }
if not render3d_st.prof_on { return }
if render3d_st.prof_active >= 0 { return } # GL_TIME_ELAPSED queries cannot nest
let s = prof_slot(render3d_st, name)
@ -56,6 +57,7 @@ function prof_begin(render3d_st: mut Render3dState, name: pointer) -> void {
function prof_end(render3d_st: mut Render3dState) -> void {
ds_set_pass(render3d_st, render3d_st.ds_outside)
if render3d_st.gvk_labels { gvk_label_end(render3d_st) }
if not render3d_st.prof_on { return }
if render3d_st.prof_active < 0 { return }
gpu_query_end(render3d_st)

View file

@ -51,6 +51,8 @@ function r3d_shader_src(render3d_st: mut Render3dState, name: string, defines: s
if render3d_st.r3d_wind_src == null { render3d_st.r3d_wind_src = r3d_shader_file(render3d_st, "wind.glsl") }
var s = "#version 410 core\n" + render3d_st.r3d_global_defs + defines + render3d_st.r3d_wind_src
if is_frag { s = s + render3d_st.r3d_noise_src + render3d_st.r3d_lighting_src }
# a blade's colour field is worked out once per vertex, not once per pixel (grass.vert)
else if Text.contains(defines, "#define GBLADE") { s = s + render3d_st.r3d_noise_src }
return s + r3d_shader_file(render3d_st, name)
}

View file

@ -31,6 +31,7 @@ import "scatter.ludic"
import "actor.ludic"
import "stream.ludic"
import "grass.ludic"
import "grass_gpu.ludic"
import "water.ludic"
import "streamline.ludic"
import "render.ludic"

View file

@ -56,6 +56,9 @@ function r3d_env_flags(render3d_st: mut Render3dState) -> void {
if r3d_env_has(render3d_st, "R3D_NOGI") { render3d_st.post_gi_strength = 0.0; render3d_st.post_ao_strength = 0.0; render3d_st.post_no_gi = true }
if r3d_env_has(render3d_st, "R3D_MSAA") { render3d_st.post_ms_samples = Text.to_int(r3d_env(render3d_st, "R3D_MSAA")) }
render3d_st.sc_skip_blade = r3d_env_has(render3d_st, "R3D_NOBLADES")
render3d_st.sc_skip_grass = r3d_env_has(render3d_st, "R3D_NOGRASS")
render3d_st.grass_force = r3d_env_has(render3d_st, "R3D_BLADES")
render3d_st.grass_env_off = r3d_env_has(render3d_st, "R3D_NOBLADES")
render3d_st.sc_skip_card = r3d_env_has(render3d_st, "R3D_NOCARDS")
render3d_st.sc_dbg_lod = r3d_env_has(render3d_st, "R3D_LODDBG")
if r3d_env_has(render3d_st, "R3D_ANISO") {
@ -206,6 +209,7 @@ function r3d_frame(render3d_st: mut Render3dState, time: float) -> void {
prof_cpu_mark(render3d_st, "shadow rebake")
stream_update_all(render3d_st)
prof_cpu_mark(render3d_st, "streaming")
gg_cull_frame(render3d_st) # before any pass: a dispatch inside one would split it
if not render3d_st.r3d_no_shadow { prof_begin(render3d_st, "shadow"); shadow_pass(render3d_st); prof_end(render3d_st) }
prof_cpu_mark(render3d_st, "shadow pass")
if render3d_st.water_on and not render3d_st.r3d_no_refl and water_reflect_visible(render3d_st) { prof_begin(render3d_st, "water reflection"); water_reflection_pass(render3d_st); prof_end(render3d_st) }

View file

@ -36,6 +36,7 @@ property Layer {
last_cam: floats,
rough: float = 0.0,
blade: bool = false,
grass: bool = false, # ground cover the GPU blades replace: skipped while they draw (and under R3D_NOGRASS)
flower: bool = false,
card: bool = false,
cheap: bool = false, # distant cover: no shadows, no wind, flat lighting
@ -1134,6 +1135,7 @@ function scatter_draw_depth(render3d_st: mut Render3dState) -> void {
for i in 0 .. len(render3d_st.sc_layers) {
let l = render3d_st.sc_layers[i]
if not l.foliage or l.blade or l.flower { continue }
if l.grass and sc_grass_replaced(render3d_st) { continue }
if render3d_st.r3d_no_trees and l.imp != null { continue }
layer_update(render3d_st, l)
if l.n_lods > 1 and l.g_on {
@ -1149,12 +1151,15 @@ function scatter_draw_depth(render3d_st: mut Render3dState) -> void {
}
gpu_cull(render3d_st, true)
}
# a layer the game flagged `grass` is ground cover the GPU blades stand in for: off while they draw
function sc_grass_replaced(render3d_st: Render3dState) -> bool { return render3d_st.sc_skip_grass or ((render3d_st.grass_on or render3d_st.grass_force) and not render3d_st.grass_env_off and render3d_st.grass_prog != 0) }
function scatter_draw(render3d_st: mut Render3dState) -> void {
for i in 0 .. len(render3d_st.sc_layers) {
let l = render3d_st.sc_layers[i]
if render3d_st.sc_skip_blade and l.blade { continue }
if render3d_st.sc_skip_flower and l.flower { continue }
if render3d_st.sc_skip_card and l.card { continue }
if l.grass and sc_grass_replaced(render3d_st) { continue }
if render3d_st.r3d_no_trees and l.imp != null and not l.card { continue }
layer_update(render3d_st, l)
layer_draw_near(render3d_st, l, false, null, false)
@ -1172,6 +1177,7 @@ function scatter_draw_casters(render3d_st: mut Render3dState, light_vp: floats)
let l = render3d_st.sc_layers[i]
if render3d_st.sc_skip_blade and l.blade { continue }
if render3d_st.sc_skip_card and l.card { continue }
if l.grass and sc_grass_replaced(render3d_st) { continue }
if render3d_st.r3d_no_trees and l.imp != null and not l.card { continue }
layer_update(render3d_st, l)
if l.imp != null {

View file

@ -2,3 +2,5 @@
# spv/<name>.comp.spv. Binding 0 is the uniform block of parameters, 1.. storage buffers.
probe|probe.comp
scatter_cull|scatter_cull.comp
grass_reset|grass_reset.comp
grass_cull|grass_cull.comp

View file

@ -31,6 +31,7 @@ uniform vec4 u_tiles[256]; // per tile of the dispatch: corner x, corner z, in
uniform float u_s0;
uniform float u_d0;
uniform float u_radius;
uniform float u_px; // one pixel in radians (grass.vert)
uniform int u_dbg;
out vec3 v_wpos[];
out vec3 v_nrm[];
@ -40,7 +41,7 @@ out vec2 v_rot[];
out float v_hull[];
out float v_quake[]; // grass does not quake; written so model.frag can read it
const float CELL = 16.0;
const float CELL = 4.0; // grass.ludic GRASS_CELL: 4 m, so a near tile asks for what its blades need
float hash1(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
uint pcg(uint v) { uint s = v * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; return (w >> 22u) ^ w; }
float bladeHash(ivec2 cell, int j, int k) {
@ -115,6 +116,7 @@ void main() {
float tall = mix(0.18, 0.42, h3) * mix(0.8, 1.2, hash1(cid * 0.1)) * (1.0 + 0.35 * smoothstep(1.0, 12.0, grow)) * life;
float bw = 0.028 * mix(1.0, 0.45 * grow, smoothstep(1.0, 4.0, grow));
if (far) { bw = max(bw, spacing * 0.35); tall = min(tall, spacing * 0.3); }
bw = max(bw, dist * u_px * 1.1);
float gust = sin(xz.x * 0.09 + u_time * 1.1) * 0.5 + sin(xz.y * 0.13 - u_time * 0.8 + xz.x * 0.05) * 0.5;
float ph = u_time * 1.7 + seed * 6.2831 + xz.x * 0.05 + xz.y * 0.07;
float sway = (sin(ph) * 0.6 + sin(ph * 2.3 + 1.0) * 0.4 + gust) * u_wind;

View file

@ -34,6 +34,7 @@ uniform vec4 u_tiles[256]; // per tile of the draw: corner x, corner z, indice
uniform float u_s0; // blade spacing at the camera (m)
uniform float u_d0; // distance at which the spacing has doubled (m)
uniform float u_radius; // no blades past this
uniform float u_px; // one pixel's height in radians: no blade is drawn narrower than a pixel
uniform int u_dbg; // R3D_GRASS_DBG: 1 lift blades 0.3 m, 2 light as ground everywhere, 3 both
out vec3 v_wpos;
out vec3 v_nrm;
@ -42,9 +43,10 @@ out float v_seed;
out vec2 v_rot;
out float v_hull;
out float v_quake; // only aspens quake; written so model.frag can read it
out vec3 v_tint; // the blade's colour field at its root (model.frag's regionTint and patchiness, once a blade)
const float CELL = 16.0;
float hash1(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
const float CELL = 4.0; // grass.ludic GRASS_CELL: 4 m, so a near tile asks for what its blades need
// hash1 and fbm come from noise.glsl, which a GBLADE vertex stage is given (programs.ludic)
// An integer hash (PCG) for the per-blade values. The sine hash advanced linearly with
// the blade index, so a cell's blades fell into diagonal rows, and the rows read as
// streaks across the meadow with an edge wherever they thinned out.
@ -68,7 +70,22 @@ float heightSmooth(sampler2D tex, vec2 uv) {
return (texture(tex, vec2(o0.x, o0.y)).r * s0.x + texture(tex, vec2(o1.x, o0.y)).r * s1.x) * s0.y
+ (texture(tex, vec2(o0.x, o1.y)).r * s0.x + texture(tex, vec2(o1.x, o1.y)).r * s1.x) * s1.y;
}
void cull() { gl_Position = vec4(0.0, 0.0, 2.0, 1.0); v_wpos = vec3(0.0); v_nrm = vec3(0.0, 1.0, 0.0); v_uv = vec2(0.0); v_seed = 0.0; v_rot = vec2(0.0, 1.0); v_hull = 1.0; v_quake = 0.0; }
void cull() { gl_Position = vec4(0.0, 0.0, 2.0, 1.0); v_wpos = vec3(0.0); v_nrm = vec3(0.0, 1.0, 0.0); v_uv = vec2(0.0); v_seed = 0.0; v_rot = vec2(0.0, 1.0); v_hull = 1.0; v_quake = 0.0; v_tint = vec3(1.0); }
// lighting.glsl's regionTint(wpos, 0.35) times model.frag's patchiness, at the root: three fbm
// fields a blade pixel used to pay for, when the whole blade stands on one spot
vec3 bladeField(vec2 xz, float y) {
float n = fbm(xz * 0.0018 + 4.0, 3) * 0.5 + 0.5;
float aspen = smoothstep(0.52, 0.7, n) * smoothstep(520.0, 250.0, y);
float dry = smoothstep(0.35, 0.15, fbm(xz * 0.004 + 9.0, 3) * 0.5 + 0.5);
vec3 t = mix(vec3(1.0), vec3(1.25, 1.3, 0.85), aspen * 0.35);
t = mix(t, vec3(1.15, 1.05, 0.7), dry * 0.35 * 0.6);
float patchy = fbm(xz * 0.045, 3) * 0.5 + 0.5;
t *= mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy);
// dry and green patches a few metres across, and the tussocks' own shade of the same green
float dryp = smoothstep(0.2, 0.8, gnoise(xz * 0.33 + 17.0) * 0.5 + 0.5);
t *= mix(vec3(0.92, 1.0, 0.95), vec3(1.30, 1.10, 0.62), dryp);
return t * (0.72 + 0.56 * (gnoise(xz * 1.9 + 41.0) * 0.5 + 0.5));
}
void main() {
#ifdef TILES
@ -158,6 +175,10 @@ void main() {
// that had been cut, which is the one thing an alpine meadow is not.
float hh = h3 * h3;
float tall = mix(0.09, 0.60, hh) * mix(0.8, 1.2, hash1(cid * 0.1)) * (1.0 + 0.35 * smoothstep(1.0, 12.0, grow)) * life;
// CLUMPS: grass grows in tussocks, taller and shorter patches a metre or so across, and that
// relief is the texture a meadow has from above - an even height reads as felt
float clump = gnoise(xz * 0.85 + 3.7) * 0.5 + 0.5;
tall *= mix(0.45, 1.40, clump * clump * (3.0 - 2.0 * clump));
// 2.8 cm was FIVE TIMES a blade of meadow grass, which is 3-6 mm. At 2 m from the
// camera that is a broad dark scimitar lying along the ground rather than a blade
// standing in a sward, and no amount of profile or colour work fixes a blade that is
@ -165,6 +186,9 @@ void main() {
// and too flat" note in this stage.
float bw = 0.010 * mix(1.0, 0.45 * grow, smoothstep(1.0, 4.0, grow));
if (far) { bw = max(bw, spacing * 0.35); tall = min(tall, spacing * 0.3); }
// a blade under a pixel is a vertex bill that shimmers: widen it to one and a bit, and let the
// thinning (spacing) keep the coverage
bw = max(bw, dist * u_px * 1.1);
vec3 p = vec3(a_pos.x * bw, a_pos.y * tall, a_pos.z * tall * (0.6 + 0.8 * h4));
vec3 n = vec3(0.0, 0.3, 1.0);
// The shared field (wind.glsl), so the gust that crosses this meadow is the same gust that
@ -217,6 +241,7 @@ void main() {
v_seed = seed;
v_rot = vec2(s, c_);
v_quake = 0.0;
v_tint = bladeField(xz, h);
v_hull = (dist > 2.0 || far || (u_dbg & 2) != 0) ? -1.0 : 1.0; // no back-face flip, no rounding past arm's reach
gl_Position = u_proj * u_view * vec4(w, 1.0);
}

View file

@ -0,0 +1,123 @@
// grass_cull.comp - the meadow's blades, decided once a frame on the GPU (Vulkan).
//
// grass.vert used to do all of this per VERTEX: every blade's hashes, its place, the ground under
// it (a four-tap bicubic height), the photograph's say on whether anything grows there, the water,
// the slope, the snow line and its colour field - ten times over for a five-row blade, and in full
// for every index a tile asked for and then threw away. Here each candidate is one invocation: a
// workgroup row per visible tile (grass.ludic walks them as before), a thread per candidate blade.
// A blade that survives is written once, as four vec4s, into its band's region, and the band's
// indirect draw count goes up by one; grass_inst.vert only bends and places the vertices.
// The rules are grass.vert's, kept in step with it: the same hashes give the same meadow.
layout(local_size_x = 64) in;
layout(set = 0, binding = 0) uniform Params {
vec4 planes[4]; // the side frustum planes: xyz in, w distance
vec4 cam; // xyz the camera, w no blades past this (u_radius)
vec4 dens; // x s0, y d0, z one pixel in radians, w the snow line
vec4 lake; // the carved lake: centre x/z, half extents (z = 0: none)
vec4 lev; // x the lake's level, y the sea's, z the photograph on (1), w unused
vec4 ts; // the height texture: origin x/z, half extent, unused
vec4 bands; // outer distance of bands 0 and 1 (x, y); band 2 is the rest
uvec4 base; // each band's first record in Out
uvec4 cap; // ... and how many it holds
} pr;
layout(set = 0, binding = 1) readonly buffer Tiles { vec4 tiles[]; }; // corner x/z, indices per cell, cells per side
layout(set = 0, binding = 2) buffer Out { vec4 outv[]; }; // 4 per blade
layout(set = 0, binding = 3) buffer Cmds { uint cmds[]; }; // VkDrawIndexedIndirectCommand per band
layout(set = 0, binding = 4) uniform sampler2D u_height; // height, normal (terrain's u_ts_height)
layout(set = 0, binding = 5) uniform sampler2D u_ortho; // the photograph
const float CELL = 4.0; // grass.ludic GRASS_CELL
uint pcg(uint v) { uint s = v * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; return (w >> 22u) ^ w; }
float bladeHash(ivec2 cell, int j, int k) {
uint h = pcg(uint(cell.x + 32768) * 73856093u ^ uint(cell.y + 32768) * 19349663u ^ uint(j) * 83492791u ^ uint(k) * 2654435761u);
return float(h) * (1.0 / 4294967295.0);
}
float heightSmooth(vec2 uv) {
vec2 res = vec2(textureSize(u_height, 0));
vec2 t = uv * res - 0.5;
vec2 f = fract(t);
vec2 i = floor(t);
vec2 w0 = (1.0 - f) * (1.0 - f) * (1.0 - f) / 6.0;
vec2 w1 = (4.0 - 6.0 * f * f + 3.0 * f * f * f) / 6.0;
vec2 w3 = f * f * f / 6.0;
vec2 w2 = 1.0 - w0 - w1 - w3;
vec2 s0 = w0 + w1, s1 = w2 + w3;
vec2 o0 = (i - 1.0 + w1 / s0 + 0.5) / res;
vec2 o1 = (i + 1.0 + w3 / s1 + 0.5) / res;
return (textureLod(u_height, vec2(o0.x, o0.y), 0.0).r * s0.x + textureLod(u_height, vec2(o1.x, o0.y), 0.0).r * s1.x) * s0.y
+ (textureLod(u_height, vec2(o0.x, o1.y), 0.0).r * s0.x + textureLod(u_height, vec2(o1.x, o1.y), 0.0).r * s1.x) * s1.y;
}
// grass.vert's bladeField: the region, the patchiness, the dry patches and the tussocks' shade
vec3 bladeField(vec2 xz, float y) {
float n = fbm(xz * 0.0018 + 4.0, 3) * 0.5 + 0.5;
float aspen = smoothstep(0.52, 0.7, n) * smoothstep(520.0, 250.0, y);
float dry = smoothstep(0.35, 0.15, fbm(xz * 0.004 + 9.0, 3) * 0.5 + 0.5);
vec3 t = mix(vec3(1.0), vec3(1.25, 1.3, 0.85), aspen * 0.35);
t = mix(t, vec3(1.15, 1.05, 0.7), dry * 0.35 * 0.6);
float patchy = fbm(xz * 0.045, 3) * 0.5 + 0.5;
t *= mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy);
float dryp = smoothstep(0.2, 0.8, gnoise(xz * 0.33 + 17.0) * 0.5 + 0.5);
t *= mix(vec3(0.92, 1.0, 0.95), vec3(1.30, 1.10, 0.62), dryp);
return t * (0.72 + 0.56 * (gnoise(xz * 1.9 + 41.0) * 0.5 + 0.5));
}
void main() {
vec4 tile = tiles[gl_WorkGroupID.y];
int per_cell = int(tile.z + 0.5);
int cells = int(tile.w + 0.5);
int i = int(gl_GlobalInvocationID.x);
if (per_cell <= 0 || i >= per_cell * cells * cells) return;
int c = i / per_cell;
int j = i - c * per_cell;
vec2 cell = tile.xy + vec2(float(c % cells), float(c / cells)) * CELL;
vec2 cid = floor(cell / CELL + 0.5);
ivec2 ci = ivec2(cid);
vec2 hv = vec2(bladeHash(ci, j, 0), bladeHash(ci, j, 1));
vec2 xz = cell + hv * CELL;
float dist = length(xz - pr.cam.xz);
float radius = pr.cam.w;
if (dist >= radius) return;
float spacing = pr.dens.x * (1.0 + dist / pr.dens.y);
float count = CELL * CELL / (spacing * spacing) * (1.0 - smoothstep(radius * 0.7, radius, dist));
float keep = count / float(max(per_cell, 1));
float r = bladeHash(ci, j, 5);
if (r > keep) return;
float life = 1.0 - smoothstep(keep * 0.75, keep, r);
vec2 huv = (xz - pr.ts.xy) / (2.0 * pr.ts.z) + 0.5;
if (huv.x < 0.0 || huv.x > 1.0 || huv.y < 0.0 || huv.y > 1.0) return;
vec4 ht = textureLod(u_height, huv, 0.0);
// the frustum, on a sphere round the blade (it stands at most 0.9 m tall)
vec3 root = vec3(xz.x, ht.r, xz.y);
for (int k = 0; k < 4; k++) { if (dot(pr.planes[k].xyz, root) + pr.planes[k].w < -1.0) return; }
vec3 gn = normalize(ht.gba);
float h3 = bladeHash(ci, j, 2), h4 = bladeHash(ci, j, 3);
float wl = pr.lev.y;
if (pr.lake.z > 0.0) { vec2 q = (xz - pr.lake.xy) / pr.lake.zw; if (dot(q, q) < 1.0) wl = max(wl, pr.lev.x); }
float ok = (1.0 - smoothstep(0.30, 0.55, 1.0 - gn.y)) * smoothstep(0.0, 0.6, ht.r - wl - 0.15) * smoothstep(pr.dens.w - 80.0, pr.dens.w - 200.0, ht.r);
if (pr.lev.z > 0.5) {
vec3 oc = textureLod(u_ortho, huv, 1.5).rgb;
ok *= 0.40 + 0.60 * smoothstep(0.0, 0.025, oc.g - oc.b);
}
if (h4 > ok) return;
float h = heightSmooth(huv);
float seed = hv.x * 0.7 + hv.y * 0.3;
float ang = bladeHash(ci, j, 6) * 6.2831853;
float grow = spacing / pr.dens.x;
float hh = h3 * h3;
float tall = mix(0.09, 0.60, hh) * mix(0.8, 1.2, hash1(cid * 0.1)) * (1.0 + 0.35 * smoothstep(1.0, 12.0, grow)) * life;
float clump = gnoise(xz * 0.85 + 3.7) * 0.5 + 0.5;
tall *= mix(0.45, 1.40, clump * clump * (3.0 - 2.0 * clump));
float bw = 0.010 * mix(1.0, 0.45 * grow, smoothstep(1.0, 4.0, grow));
bw = max(bw, dist * pr.dens.z * 1.1);
uint b = dist < pr.bands.x ? 0u : (dist < pr.bands.y ? 1u : 2u);
uint slot = atomicAdd(cmds[b * 5u + 1u], 1u);
if (slot >= pr.cap[b]) { atomicAdd(cmds[b * 5u + 1u], 0xFFFFFFFFu); return; }
uint o = (pr.base[b] + slot) * 4u;
outv[o] = vec4(xz.x, h - 0.02, xz.y, seed);
outv[o + 1u] = vec4(sin(ang), cos(ang), tall, bw);
outv[o + 2u] = vec4(bladeField(xz, h), 0.6 + 0.8 * h4);
outv[o + 3u] = vec4(gn, dist);
}

View file

@ -0,0 +1,71 @@
// grass_inst.vert - a blade grass_cull.comp has already decided on (Vulkan). Everything about the
// blade - where it stands, its facing, height, width, colour field and the ground under it - is its
// instance record; this only bends, sways, pushes and stands the vertices, as grass.vert's second
// half does, so the two draw the same meadow.
layout(location = 0) in vec3 a_pos; // x: -0.5..0.5 across, y: 0..1 along the blade, z: bend
layout(location = 2) in vec2 a_uv;
layout(location = 3) in vec4 i_root; // xyz the root, w the seed
layout(location = 4) in vec4 i_shape; // sin and cos of the yaw, height, width
layout(location = 5) in vec4 i_tint; // the colour field, and how far the tip arches
layout(location = 6) in vec4 i_ground; // the ground's normal, and the distance it was decided at
uniform mat4 u_view;
uniform mat4 u_proj;
uniform float u_time;
uniform float u_wind;
uniform vec3 u_push; // x, z, radius: a body standing in the grass
uniform int u_dbg; // R3D_GRASS_DBG (2: light as ground everywhere)
out vec3 v_wpos;
out vec3 v_nrm;
out vec2 v_uv;
out float v_seed;
out vec2 v_rot;
out float v_hull;
out float v_quake;
out vec3 v_tint;
void main() {
vec2 xz = i_root.xz;
float seed = i_root.w;
float s = i_shape.x, c_ = i_shape.y, tall = i_shape.z, bw = i_shape.w;
vec3 gn = i_ground.xyz;
float dist = i_ground.w;
vec3 p = vec3(a_pos.x * bw, a_pos.y * tall, a_pos.z * tall * i_tint.w);
vec3 n = vec3(0.0, 0.3, 1.0);
float ph = u_time * 1.7 + seed * 6.2831 + xz.x * 0.05 + xz.y * 0.07;
float sway = windSway(xz, u_time, ph, 0.0) * u_wind;
float hgt = max(p.y, 0.0);
p.x += sway * hgt * hgt * 0.35;
p.z += sway * hgt * hgt * 0.15 * cos(ph * 0.7);
p = vec3(c_ * p.x + s * p.z, p.y, -s * p.x + c_ * p.z);
if (u_push.z > 0.0) {
vec2 away = xz - u_push.xy;
float pd = length(away);
float push = smoothstep(u_push.z, u_push.z * 0.2, pd);
if (push > 0.0) {
vec2 pdir = (pd > 1e-3) ? away / pd : vec2(1.0, 0.0);
float bh = max(p.y, 0.0);
p.x += pdir.x * push * bh * 1.05;
p.z += pdir.y * push * bh * 1.05;
p.y -= push * bh * 0.40;
}
}
n = normalize(vec3(c_ * n.x + s * n.z, n.y, -s * n.x + c_ * n.z));
vec3 k = cross(vec3(0.0, 1.0, 0.0), gn);
float sk = length(k), ck = gn.y;
if (sk > 1e-4) {
k /= sk;
p = p * ck + cross(k, p) * sk + k * dot(k, p) * (1.0 - ck);
n = normalize(n * ck + cross(k, n) * sk + k * dot(k, n) * (1.0 - ck));
}
n = normalize(mix(n, gn, smoothstep(2.0, 12.0, dist)));
vec3 w = i_root.xyz + p;
v_wpos = w;
v_nrm = n;
v_uv = a_uv;
v_seed = seed;
v_rot = vec2(s, c_);
v_quake = 0.0;
v_tint = i_tint.rgb;
v_hull = (dist > 2.0 || (u_dbg & 2) != 0) ? -1.0 : 1.0;
gl_Position = u_proj * u_view * vec4(w, 1.0);
}

View file

@ -0,0 +1,8 @@
// grass_reset.comp - zero each band's instance count before grass_cull.comp counts into it. On the
// GPU, in order, because the frame before may still be drawing from the same records.
layout(local_size_x = 1) in;
layout(set = 0, binding = 0) uniform Params { uvec4 n; } pr;
layout(set = 0, binding = 1) buffer Cmds { uint cmds[]; };
void main() {
for (uint b = 0u; b < pr.n.x; b++) { cmds[b * 5u + 1u] = 0u; }
}

View file

@ -15,6 +15,9 @@ uniform mat4 u_view;
uniform vec3 u_tint;
uniform float u_rough_scale;
uniform float u_emissive; // self-lit (a flame): albedo added back after shading
#ifdef GBLADE
in vec3 v_tint; // grass.vert's colour field (the mesh-shader and scatter blades work it out here)
#endif
#ifdef BLADE
uniform vec3 u_blade_base;
uniform vec3 u_blade_tip;
@ -49,6 +52,11 @@ void main() {
#endif
vec3 N = normalize(v_nrm);
if (!gl_FrontFacing && v_hull >= 0.0) N = -N;
#ifdef BLADE
// A blade is thin and two-sided: the sun lights whichever face it reaches (the other glows
// through, below). Faced away from the sun, half the meadow was lit by the sky alone.
if (dot(N, u_sun_dir) < 0.0) N = -N;
#endif
#ifdef CARD
// a baked card: albedo with coverage (premultiplied mips), normal in the card's own frame
vec4 ca = texture(u_diff, v_uv);
@ -80,7 +88,16 @@ void main() {
#ifdef BLADE
// a procedural blade: dark at the root, lighter at the tip; some blades gone to seed
float t = clamp(v_uv.y, 0.0, 1.0);
alb = mix(u_blade_base, u_blade_tip, t * t) * regionTint(v_wpos, 0.35);
// most of a blade is its own green, only the sheath darker: t*t held the lower two thirds at the
// root colour (0.14, darker than the ground), and a meadow of those reads as dark wire
#ifndef GBLADE
vec3 field = regionTint(v_wpos, 0.35);
float patchy = fbm(v_wpos.xz * 0.045, 3) * 0.5 + 0.5;
field *= mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy);
#else
vec3 field = v_tint;
#endif
alb = mix(u_blade_base, u_blade_tip, smoothstep(0.0, 0.55, t)) * field;
float dry = smoothstep(0.7, 0.8, fract(v_seed * 3.17));
alb = mix(alb, vec3(0.40, 0.36, 0.13) * (0.45 + 0.55 * t), dry * 0.75);
// A REAL BLADE, if the game gave us one. Each blade picks a column of the atlas from
@ -110,13 +127,13 @@ void main() {
vec3 g = photo * (1.0 / 0.3736);
alb *= mix(vec3(1.0), min(g, vec3(1.15)), 0.85);
}
float patchy = fbm(v_wpos.xz * 0.045, 3) * 0.5 + 0.5;
alb *= mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy);
// a far tuft is a patch of the meadow, darker than a lit blade tip and never straw
if (v_hull < 0.0) alb = mix(u_blade_base, u_blade_tip, 0.45) * regionTint(v_wpos, 0.35) * mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy) * 0.72;
if (v_hull < 0.0) alb = mix(u_blade_base, u_blade_tip, 0.45) * field * 0.72;
// a rounded cross-section reads softer than a flat card
vec3 side = normalize(cross(N, vec3(0.0, 1.0, 0.0)) + vec3(1e-4));
n = (v_hull < 0.0) ? N : normalize(N + side * (v_uv.x * 2.0 - 1.0) * 0.6);
// and a sward is lit as a mass leaning to the sky: a level normal took the light at a graze
n = normalize(n + vec3(0.0, 0.45, 0.0));
// A blade's own ambient occlusion. This was mix(0.2, 1.0, t*t): 80% occluded at the
// sheath and still 60% at half height, because t*t holds the curve down. Measured in a
// walking-distance shot the blades came out at 24-31 of 255 against a ground of 143 -
@ -227,6 +244,13 @@ void main() {
#else
float shadow = mix(1.0, sunShadow(v_wpos + N * 0.1 + vec3(0.0, 0.2, 0.0), N, viewDepth), 0.55);
#endif
#elif defined(BLADE)
// A blade stands within a shadow texel of the ground under it, and biased along its own
// (level) normal it read the terrain as its caster: the whole meadow in shadow, dark wire.
// Looked up a hand's width up, along the ground's up, trees and rocks still shade it.
// past arm's length a blade is a pixel or two wide: one tap is what a soft filter averages to
float shadow = (dist < 8.0) ? sunShadow(v_wpos + vec3(0.0, 0.25, 0.0), vec3(0.0, 1.0, 0.0), viewDepth)
: sunShadowCheap(v_wpos + vec3(0.0, 0.25, 0.0), vec3(0.0, 1.0, 0.0), viewDepth);
#else
float shadow = sunShadow(v_wpos, N, viewDepth);
#endif
@ -271,6 +295,9 @@ void main() {
float NoV = max(dot(n, vv), 0.0);
float sheen = pow(NoH, 26.0) * 0.55 + pow(1.0 - NoV, 4.0) * 0.05;
sheen *= smoothstep(0.10, 0.42, NoV);
#ifdef BLADE
sheen *= 0.2; // a sward seen from above is tips edge-on to the sun: the leaf's glint frosted them white
#endif
col += u_sun_color * sheen * shadow * cloudShadow(v_wpos) * 0.4 * (0.45 + 0.55 * arm.r);
}
#endif

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -843,7 +843,7 @@ T 74258189 u_shadow 6
T 74258189 u_tershadow 7
T 74258189 u_ts_height 8
P d48d6a48 grass.mesh model.frag #define FOLIAGE;#define BLADE;#define MESH;
B d48d6a48 vert 0 4384
B d48d6a48 vert 0 4388
U d48d6a48 vert u_view 0 mat4 1 0
U d48d6a48 vert u_proj 64 mat4 1 0
U d48d6a48 vert u_vp 128 mat4 1 0
@ -862,7 +862,8 @@ U d48d6a48 vert u_tiles 272 vec4 256 16
U d48d6a48 vert u_s0 4368 float 1 0
U d48d6a48 vert u_d0 4372 float 1 0
U d48d6a48 vert u_radius 4376 float 1 0
U d48d6a48 vert u_dbg 4380 int 1 0
U d48d6a48 vert u_px 4380 float 1 0
U d48d6a48 vert u_dbg 4384 int 1 0
B d48d6a48 frag 1 964
U d48d6a48 frag u_cascade_vp 0 mat4 5 64
U d48d6a48 frag u_cascade_split 320 float 5 16
@ -919,169 +920,171 @@ T d48d6a48 u_prefilter 9
T d48d6a48 u_shadow 10
T d48d6a48 u_tershadow 11
T d48d6a48 u_ts_height 12
P 7ed7de52 grass.vert model.frag #define FOLIAGE;#define BLADE;
B 7ed7de52 vert 0 320
U 7ed7de52 vert u_view 0 mat4 1 0
U 7ed7de52 vert u_proj 64 mat4 1 0
U 7ed7de52 vert u_vp 128 mat4 1 0
U 7ed7de52 vert u_cam_pos 192 vec3 1 0
U 7ed7de52 vert u_time 204 float 1 0
U 7ed7de52 vert u_ts_origin 208 vec2 1 0
U 7ed7de52 vert u_ts_half 216 float 1 0
U 7ed7de52 vert u_ortho_on 220 float 1 0
U 7ed7de52 vert u_lake_level 224 float 1 0
U 7ed7de52 vert u_sea_level 228 float 1 0
U 7ed7de52 vert u_lake 240 vec4 1 0
U 7ed7de52 vert u_snow_line 256 float 1 0
U 7ed7de52 vert u_wind 260 float 1 0
U 7ed7de52 vert u_push 272 vec3 1 0
U 7ed7de52 vert u_tile 288 vec2 1 0
U 7ed7de52 vert u_tile_cells 296 int 1 0
U 7ed7de52 vert u_per_cell 300 int 1 0
U 7ed7de52 vert u_s0 304 float 1 0
U 7ed7de52 vert u_d0 308 float 1 0
U 7ed7de52 vert u_radius 312 float 1 0
U 7ed7de52 vert u_dbg 316 int 1 0
I 7ed7de52 a_pos 0 vec3
I 7ed7de52 a_uv 2 vec2
B 7ed7de52 frag 1 964
U 7ed7de52 frag u_cascade_vp 0 mat4 5 64
U 7ed7de52 frag u_cascade_split 320 float 5 16
U 7ed7de52 frag u_cascade_range 400 float 5 16
U 7ed7de52 frag u_cascade_texel 480 float 5 16
U 7ed7de52 frag u_sun_dir 560 vec3 1 0
U 7ed7de52 frag u_sun_color 576 vec3 1 0
U 7ed7de52 frag u_cam_pos 592 vec3 1 0
U 7ed7de52 frag u_prefilter_levels 604 float 1 0
U 7ed7de52 frag u_fog_density 608 float 1 0
U 7ed7de52 frag u_fog_height_falloff 612 float 1 0
U 7ed7de52 frag u_fog_base 616 float 1 0
U 7ed7de52 frag u_clip_y 620 float 1 0
U 7ed7de52 frag u_spec_scale 624 float 1 0
U 7ed7de52 frag u_sky_rot 632 vec2 1 0
U 7ed7de52 frag u_ground_alb 640 vec3 1 0
U 7ed7de52 frag u_ground_alb_hi 656 vec3 1 0
U 7ed7de52 frag u_ground_hi_y 668 float 1 0
U 7ed7de52 frag u_ground_hi_w 672 float 1 0
U 7ed7de52 frag u_ibl_scale 688 vec3 1 0
U 7ed7de52 frag u_daylight 700 float 1 0
U 7ed7de52 frag u_fire_pos 704 vec3 1 0
U 7ed7de52 frag u_fire_color 720 vec3 1 0
U 7ed7de52 frag u_hand_pos 736 vec3 1 0
U 7ed7de52 frag u_hand_color 752 vec3 1 0
U 7ed7de52 frag u_hand_dir 768 vec3 1 0
U 7ed7de52 frag u_hand_cone 780 float 1 0
U 7ed7de52 frag u_hand_reach 784 float 1 0
U 7ed7de52 frag u_ts_origin 792 vec2 1 0
U 7ed7de52 frag u_ts_half 800 float 1 0
U 7ed7de52 frag u_ts_on 804 float 1 0
U 7ed7de52 frag u_force_cascade 808 int 1 0
U 7ed7de52 frag u_cloud_shadow 812 float 1 0
U 7ed7de52 frag u_time 816 float 1 0
U 7ed7de52 frag u_fog_inscatter 820 float 1 0
U 7ed7de52 frag u_fog_desat 824 float 1 0
U 7ed7de52 frag u_model_h 828 float 1 0
U 7ed7de52 frag u_view 832 mat4 1 0
U 7ed7de52 frag u_tint 896 vec3 1 0
U 7ed7de52 frag u_rough_scale 908 float 1 0
U 7ed7de52 frag u_emissive 912 float 1 0
U 7ed7de52 frag u_blade_base 928 vec3 1 0
U 7ed7de52 frag u_blade_tip 944 vec3 1 0
U 7ed7de52 frag u_blade_cols 956 float 1 0
U 7ed7de52 frag u_blade_tex_on 960 float 1 0
T 7ed7de52 u_arm 2
T 7ed7de52 u_blade_tex 3
T 7ed7de52 u_brdf 4
T 7ed7de52 u_diff 5
T 7ed7de52 u_irradiance 6
T 7ed7de52 u_nrm 7
T 7ed7de52 u_ortho 8
T 7ed7de52 u_prefilter 9
T 7ed7de52 u_shadow 10
T 7ed7de52 u_tershadow 11
T 7ed7de52 u_ts_height 12
P 7f096322 grass.vert model.frag #define FOLIAGE;#define BLADE;#define TILES;
B 7f096322 vert 0 4416
U 7f096322 vert u_view 0 mat4 1 0
U 7f096322 vert u_proj 64 mat4 1 0
U 7f096322 vert u_vp 128 mat4 1 0
U 7f096322 vert u_cam_pos 192 vec3 1 0
U 7f096322 vert u_time 204 float 1 0
U 7f096322 vert u_ts_origin 208 vec2 1 0
U 7f096322 vert u_ts_half 216 float 1 0
U 7f096322 vert u_ortho_on 220 float 1 0
U 7f096322 vert u_lake_level 224 float 1 0
U 7f096322 vert u_sea_level 228 float 1 0
U 7f096322 vert u_lake 240 vec4 1 0
U 7f096322 vert u_snow_line 256 float 1 0
U 7f096322 vert u_wind 260 float 1 0
U 7f096322 vert u_push 272 vec3 1 0
U 7f096322 vert u_tile 288 vec2 1 0
U 7f096322 vert u_tile_cells 296 int 1 0
U 7f096322 vert u_per_cell 300 int 1 0
U 7f096322 vert u_tiles 304 vec4 256 16
U 7f096322 vert u_s0 4400 float 1 0
U 7f096322 vert u_d0 4404 float 1 0
U 7f096322 vert u_radius 4408 float 1 0
U 7f096322 vert u_dbg 4412 int 1 0
I 7f096322 a_pos 0 vec3
I 7f096322 a_uv 2 vec2
B 7f096322 frag 1 964
U 7f096322 frag u_cascade_vp 0 mat4 5 64
U 7f096322 frag u_cascade_split 320 float 5 16
U 7f096322 frag u_cascade_range 400 float 5 16
U 7f096322 frag u_cascade_texel 480 float 5 16
U 7f096322 frag u_sun_dir 560 vec3 1 0
U 7f096322 frag u_sun_color 576 vec3 1 0
U 7f096322 frag u_cam_pos 592 vec3 1 0
U 7f096322 frag u_prefilter_levels 604 float 1 0
U 7f096322 frag u_fog_density 608 float 1 0
U 7f096322 frag u_fog_height_falloff 612 float 1 0
U 7f096322 frag u_fog_base 616 float 1 0
U 7f096322 frag u_clip_y 620 float 1 0
U 7f096322 frag u_spec_scale 624 float 1 0
U 7f096322 frag u_sky_rot 632 vec2 1 0
U 7f096322 frag u_ground_alb 640 vec3 1 0
U 7f096322 frag u_ground_alb_hi 656 vec3 1 0
U 7f096322 frag u_ground_hi_y 668 float 1 0
U 7f096322 frag u_ground_hi_w 672 float 1 0
U 7f096322 frag u_ibl_scale 688 vec3 1 0
U 7f096322 frag u_daylight 700 float 1 0
U 7f096322 frag u_fire_pos 704 vec3 1 0
U 7f096322 frag u_fire_color 720 vec3 1 0
U 7f096322 frag u_hand_pos 736 vec3 1 0
U 7f096322 frag u_hand_color 752 vec3 1 0
U 7f096322 frag u_hand_dir 768 vec3 1 0
U 7f096322 frag u_hand_cone 780 float 1 0
U 7f096322 frag u_hand_reach 784 float 1 0
U 7f096322 frag u_ts_origin 792 vec2 1 0
U 7f096322 frag u_ts_half 800 float 1 0
U 7f096322 frag u_ts_on 804 float 1 0
U 7f096322 frag u_force_cascade 808 int 1 0
U 7f096322 frag u_cloud_shadow 812 float 1 0
U 7f096322 frag u_time 816 float 1 0
U 7f096322 frag u_fog_inscatter 820 float 1 0
U 7f096322 frag u_fog_desat 824 float 1 0
U 7f096322 frag u_model_h 828 float 1 0
U 7f096322 frag u_view 832 mat4 1 0
U 7f096322 frag u_tint 896 vec3 1 0
U 7f096322 frag u_rough_scale 908 float 1 0
U 7f096322 frag u_emissive 912 float 1 0
U 7f096322 frag u_blade_base 928 vec3 1 0
U 7f096322 frag u_blade_tip 944 vec3 1 0
U 7f096322 frag u_blade_cols 956 float 1 0
U 7f096322 frag u_blade_tex_on 960 float 1 0
T 7f096322 u_arm 2
T 7f096322 u_blade_tex 3
T 7f096322 u_brdf 4
T 7f096322 u_diff 5
T 7f096322 u_irradiance 6
T 7f096322 u_nrm 7
T 7f096322 u_ortho 8
T 7f096322 u_prefilter 9
T 7f096322 u_shadow 10
T 7f096322 u_tershadow 11
T 7f096322 u_ts_height 12
P cb13f3c6 grass.vert model.frag #define FOLIAGE;#define BLADE;#define GBLADE;
B cb13f3c6 vert 0 324
U cb13f3c6 vert u_view 0 mat4 1 0
U cb13f3c6 vert u_proj 64 mat4 1 0
U cb13f3c6 vert u_vp 128 mat4 1 0
U cb13f3c6 vert u_cam_pos 192 vec3 1 0
U cb13f3c6 vert u_time 204 float 1 0
U cb13f3c6 vert u_ts_origin 208 vec2 1 0
U cb13f3c6 vert u_ts_half 216 float 1 0
U cb13f3c6 vert u_ortho_on 220 float 1 0
U cb13f3c6 vert u_lake_level 224 float 1 0
U cb13f3c6 vert u_sea_level 228 float 1 0
U cb13f3c6 vert u_lake 240 vec4 1 0
U cb13f3c6 vert u_snow_line 256 float 1 0
U cb13f3c6 vert u_wind 260 float 1 0
U cb13f3c6 vert u_push 272 vec3 1 0
U cb13f3c6 vert u_tile 288 vec2 1 0
U cb13f3c6 vert u_tile_cells 296 int 1 0
U cb13f3c6 vert u_per_cell 300 int 1 0
U cb13f3c6 vert u_s0 304 float 1 0
U cb13f3c6 vert u_d0 308 float 1 0
U cb13f3c6 vert u_radius 312 float 1 0
U cb13f3c6 vert u_px 316 float 1 0
U cb13f3c6 vert u_dbg 320 int 1 0
I cb13f3c6 a_pos 0 vec3
I cb13f3c6 a_uv 2 vec2
B cb13f3c6 frag 1 964
U cb13f3c6 frag u_cascade_vp 0 mat4 5 64
U cb13f3c6 frag u_cascade_split 320 float 5 16
U cb13f3c6 frag u_cascade_range 400 float 5 16
U cb13f3c6 frag u_cascade_texel 480 float 5 16
U cb13f3c6 frag u_sun_dir 560 vec3 1 0
U cb13f3c6 frag u_sun_color 576 vec3 1 0
U cb13f3c6 frag u_cam_pos 592 vec3 1 0
U cb13f3c6 frag u_prefilter_levels 604 float 1 0
U cb13f3c6 frag u_fog_density 608 float 1 0
U cb13f3c6 frag u_fog_height_falloff 612 float 1 0
U cb13f3c6 frag u_fog_base 616 float 1 0
U cb13f3c6 frag u_clip_y 620 float 1 0
U cb13f3c6 frag u_spec_scale 624 float 1 0
U cb13f3c6 frag u_sky_rot 632 vec2 1 0
U cb13f3c6 frag u_ground_alb 640 vec3 1 0
U cb13f3c6 frag u_ground_alb_hi 656 vec3 1 0
U cb13f3c6 frag u_ground_hi_y 668 float 1 0
U cb13f3c6 frag u_ground_hi_w 672 float 1 0
U cb13f3c6 frag u_ibl_scale 688 vec3 1 0
U cb13f3c6 frag u_daylight 700 float 1 0
U cb13f3c6 frag u_fire_pos 704 vec3 1 0
U cb13f3c6 frag u_fire_color 720 vec3 1 0
U cb13f3c6 frag u_hand_pos 736 vec3 1 0
U cb13f3c6 frag u_hand_color 752 vec3 1 0
U cb13f3c6 frag u_hand_dir 768 vec3 1 0
U cb13f3c6 frag u_hand_cone 780 float 1 0
U cb13f3c6 frag u_hand_reach 784 float 1 0
U cb13f3c6 frag u_ts_origin 792 vec2 1 0
U cb13f3c6 frag u_ts_half 800 float 1 0
U cb13f3c6 frag u_ts_on 804 float 1 0
U cb13f3c6 frag u_force_cascade 808 int 1 0
U cb13f3c6 frag u_cloud_shadow 812 float 1 0
U cb13f3c6 frag u_time 816 float 1 0
U cb13f3c6 frag u_fog_inscatter 820 float 1 0
U cb13f3c6 frag u_fog_desat 824 float 1 0
U cb13f3c6 frag u_model_h 828 float 1 0
U cb13f3c6 frag u_view 832 mat4 1 0
U cb13f3c6 frag u_tint 896 vec3 1 0
U cb13f3c6 frag u_rough_scale 908 float 1 0
U cb13f3c6 frag u_emissive 912 float 1 0
U cb13f3c6 frag u_blade_base 928 vec3 1 0
U cb13f3c6 frag u_blade_tip 944 vec3 1 0
U cb13f3c6 frag u_blade_cols 956 float 1 0
U cb13f3c6 frag u_blade_tex_on 960 float 1 0
T cb13f3c6 u_arm 2
T cb13f3c6 u_blade_tex 3
T cb13f3c6 u_brdf 4
T cb13f3c6 u_diff 5
T cb13f3c6 u_irradiance 6
T cb13f3c6 u_nrm 7
T cb13f3c6 u_ortho 8
T cb13f3c6 u_prefilter 9
T cb13f3c6 u_shadow 10
T cb13f3c6 u_tershadow 11
T cb13f3c6 u_ts_height 12
P b8cff226 grass.vert model.frag #define FOLIAGE;#define BLADE;#define GBLADE;#define TILES;
B b8cff226 vert 0 4420
U b8cff226 vert u_view 0 mat4 1 0
U b8cff226 vert u_proj 64 mat4 1 0
U b8cff226 vert u_vp 128 mat4 1 0
U b8cff226 vert u_cam_pos 192 vec3 1 0
U b8cff226 vert u_time 204 float 1 0
U b8cff226 vert u_ts_origin 208 vec2 1 0
U b8cff226 vert u_ts_half 216 float 1 0
U b8cff226 vert u_ortho_on 220 float 1 0
U b8cff226 vert u_lake_level 224 float 1 0
U b8cff226 vert u_sea_level 228 float 1 0
U b8cff226 vert u_lake 240 vec4 1 0
U b8cff226 vert u_snow_line 256 float 1 0
U b8cff226 vert u_wind 260 float 1 0
U b8cff226 vert u_push 272 vec3 1 0
U b8cff226 vert u_tile 288 vec2 1 0
U b8cff226 vert u_tile_cells 296 int 1 0
U b8cff226 vert u_per_cell 300 int 1 0
U b8cff226 vert u_tiles 304 vec4 256 16
U b8cff226 vert u_s0 4400 float 1 0
U b8cff226 vert u_d0 4404 float 1 0
U b8cff226 vert u_radius 4408 float 1 0
U b8cff226 vert u_px 4412 float 1 0
U b8cff226 vert u_dbg 4416 int 1 0
I b8cff226 a_pos 0 vec3
I b8cff226 a_uv 2 vec2
B b8cff226 frag 1 964
U b8cff226 frag u_cascade_vp 0 mat4 5 64
U b8cff226 frag u_cascade_split 320 float 5 16
U b8cff226 frag u_cascade_range 400 float 5 16
U b8cff226 frag u_cascade_texel 480 float 5 16
U b8cff226 frag u_sun_dir 560 vec3 1 0
U b8cff226 frag u_sun_color 576 vec3 1 0
U b8cff226 frag u_cam_pos 592 vec3 1 0
U b8cff226 frag u_prefilter_levels 604 float 1 0
U b8cff226 frag u_fog_density 608 float 1 0
U b8cff226 frag u_fog_height_falloff 612 float 1 0
U b8cff226 frag u_fog_base 616 float 1 0
U b8cff226 frag u_clip_y 620 float 1 0
U b8cff226 frag u_spec_scale 624 float 1 0
U b8cff226 frag u_sky_rot 632 vec2 1 0
U b8cff226 frag u_ground_alb 640 vec3 1 0
U b8cff226 frag u_ground_alb_hi 656 vec3 1 0
U b8cff226 frag u_ground_hi_y 668 float 1 0
U b8cff226 frag u_ground_hi_w 672 float 1 0
U b8cff226 frag u_ibl_scale 688 vec3 1 0
U b8cff226 frag u_daylight 700 float 1 0
U b8cff226 frag u_fire_pos 704 vec3 1 0
U b8cff226 frag u_fire_color 720 vec3 1 0
U b8cff226 frag u_hand_pos 736 vec3 1 0
U b8cff226 frag u_hand_color 752 vec3 1 0
U b8cff226 frag u_hand_dir 768 vec3 1 0
U b8cff226 frag u_hand_cone 780 float 1 0
U b8cff226 frag u_hand_reach 784 float 1 0
U b8cff226 frag u_ts_origin 792 vec2 1 0
U b8cff226 frag u_ts_half 800 float 1 0
U b8cff226 frag u_ts_on 804 float 1 0
U b8cff226 frag u_force_cascade 808 int 1 0
U b8cff226 frag u_cloud_shadow 812 float 1 0
U b8cff226 frag u_time 816 float 1 0
U b8cff226 frag u_fog_inscatter 820 float 1 0
U b8cff226 frag u_fog_desat 824 float 1 0
U b8cff226 frag u_model_h 828 float 1 0
U b8cff226 frag u_view 832 mat4 1 0
U b8cff226 frag u_tint 896 vec3 1 0
U b8cff226 frag u_rough_scale 908 float 1 0
U b8cff226 frag u_emissive 912 float 1 0
U b8cff226 frag u_blade_base 928 vec3 1 0
U b8cff226 frag u_blade_tip 944 vec3 1 0
U b8cff226 frag u_blade_cols 956 float 1 0
U b8cff226 frag u_blade_tex_on 960 float 1 0
T b8cff226 u_arm 2
T b8cff226 u_blade_tex 3
T b8cff226 u_brdf 4
T b8cff226 u_diff 5
T b8cff226 u_irradiance 6
T b8cff226 u_nrm 7
T b8cff226 u_ortho 8
T b8cff226 u_prefilter 9
T b8cff226 u_shadow 10
T b8cff226 u_tershadow 11
T b8cff226 u_ts_height 12
P 3733fc38 impostor.vert impostor.frag
B 3733fc38 vert 0 232
U 3733fc38 vert u_view 0 mat4 1 0
@ -3522,3 +3525,72 @@ T 46e2325c u_scene 7
T 46e2325c u_shadow 8
T 46e2325c u_tershadow 9
T 46e2325c u_ts_height 10
P b20fdcb5 grass_inst.vert model.frag #define FOLIAGE;#define BLADE;#define GBLADE;#define GINST;
B b20fdcb5 vert 0 160
U b20fdcb5 vert u_view 0 mat4 1 0
U b20fdcb5 vert u_proj 64 mat4 1 0
U b20fdcb5 vert u_time 128 float 1 0
U b20fdcb5 vert u_wind 132 float 1 0
U b20fdcb5 vert u_push 144 vec3 1 0
U b20fdcb5 vert u_dbg 156 int 1 0
I b20fdcb5 i_root 3 vec4
I b20fdcb5 i_shape 4 vec4
I b20fdcb5 i_ground 6 vec4
I b20fdcb5 a_pos 0 vec3
I b20fdcb5 i_tint 5 vec4
I b20fdcb5 a_uv 2 vec2
B b20fdcb5 frag 1 964
U b20fdcb5 frag u_cascade_vp 0 mat4 5 64
U b20fdcb5 frag u_cascade_split 320 float 5 16
U b20fdcb5 frag u_cascade_range 400 float 5 16
U b20fdcb5 frag u_cascade_texel 480 float 5 16
U b20fdcb5 frag u_sun_dir 560 vec3 1 0
U b20fdcb5 frag u_sun_color 576 vec3 1 0
U b20fdcb5 frag u_cam_pos 592 vec3 1 0
U b20fdcb5 frag u_prefilter_levels 604 float 1 0
U b20fdcb5 frag u_fog_density 608 float 1 0
U b20fdcb5 frag u_fog_height_falloff 612 float 1 0
U b20fdcb5 frag u_fog_base 616 float 1 0
U b20fdcb5 frag u_clip_y 620 float 1 0
U b20fdcb5 frag u_spec_scale 624 float 1 0
U b20fdcb5 frag u_sky_rot 632 vec2 1 0
U b20fdcb5 frag u_ground_alb 640 vec3 1 0
U b20fdcb5 frag u_ground_alb_hi 656 vec3 1 0
U b20fdcb5 frag u_ground_hi_y 668 float 1 0
U b20fdcb5 frag u_ground_hi_w 672 float 1 0
U b20fdcb5 frag u_ibl_scale 688 vec3 1 0
U b20fdcb5 frag u_daylight 700 float 1 0
U b20fdcb5 frag u_fire_pos 704 vec3 1 0
U b20fdcb5 frag u_fire_color 720 vec3 1 0
U b20fdcb5 frag u_hand_pos 736 vec3 1 0
U b20fdcb5 frag u_hand_color 752 vec3 1 0
U b20fdcb5 frag u_hand_dir 768 vec3 1 0
U b20fdcb5 frag u_hand_cone 780 float 1 0
U b20fdcb5 frag u_hand_reach 784 float 1 0
U b20fdcb5 frag u_ts_origin 792 vec2 1 0
U b20fdcb5 frag u_ts_half 800 float 1 0
U b20fdcb5 frag u_ts_on 804 float 1 0
U b20fdcb5 frag u_force_cascade 808 int 1 0
U b20fdcb5 frag u_cloud_shadow 812 float 1 0
U b20fdcb5 frag u_time 816 float 1 0
U b20fdcb5 frag u_fog_inscatter 820 float 1 0
U b20fdcb5 frag u_fog_desat 824 float 1 0
U b20fdcb5 frag u_model_h 828 float 1 0
U b20fdcb5 frag u_view 832 mat4 1 0
U b20fdcb5 frag u_tint 896 vec3 1 0
U b20fdcb5 frag u_rough_scale 908 float 1 0
U b20fdcb5 frag u_emissive 912 float 1 0
U b20fdcb5 frag u_blade_base 928 vec3 1 0
U b20fdcb5 frag u_blade_tip 944 vec3 1 0
U b20fdcb5 frag u_blade_cols 956 float 1 0
U b20fdcb5 frag u_blade_tex_on 960 float 1 0
T b20fdcb5 u_arm 2
T b20fdcb5 u_blade_tex 3
T b20fdcb5 u_brdf 4
T b20fdcb5 u_diff 5
T b20fdcb5 u_irradiance 6
T b20fdcb5 u_nrm 7
T b20fdcb5 u_prefilter 8
T b20fdcb5 u_shadow 9
T b20fdcb5 u_tershadow 10
T b20fdcb5 u_ts_height 11

View file

@ -20,8 +20,8 @@ fullscreen.vert|tonemap.frag|
fullscreen.vert|tonemap.frag|#define HDR10;
fullscreen.vert|volumetric.frag|
grass.mesh|model.frag|#define FOLIAGE;#define BLADE;#define MESH;
grass.vert|model.frag|#define FOLIAGE;#define BLADE;
grass.vert|model.frag|#define FOLIAGE;#define BLADE;#define TILES;
grass.vert|model.frag|#define FOLIAGE;#define BLADE;#define GBLADE;
grass.vert|model.frag|#define FOLIAGE;#define BLADE;#define GBLADE;#define TILES;
impostor.vert|impostor.frag|
impostor.vert|impostor.frag|#define SHADOW_PASS;
model.vert|bake.frag|
@ -59,3 +59,4 @@ terrain.vert|terrain.frag|#define SUN_INLINE;#define FAR_ONLY;
terrain.vert|terrain.frag|#define SUN_INLINE;#define NEAR_ONLY;
terrain.vert|tersun.frag|
water.vert|water.frag|
grass_inst.vert|model.frag|#define FOLIAGE;#define BLADE;#define GBLADE;#define GINST;

View file

@ -28,6 +28,11 @@ through the game).
the oars pulled against each other - and reads back where the hull is and how fast it goes. The
water floats it, the wind (`VehicleWorld.wind`) drifts every boat, ridden or not, and the lake bed
stops it at the shore; the swell at the sail limit takes the outward share of a stroke.
- **A horse walks on legs in the game's physics** (`VehicleLegs`; a Jolt walker in Maroon Lake). The
package decides its pace - a walk, a canter on stamina, hay for every metre - and never steps it
into water; the legs meet the ground, its steps and slopes, and whatever stands in its way. What
they cover along its heading is its speed, so a fence brings it to a stand and a trunk it glances
pushes it aside. A horse called to its bay or led round where it is tied is stood there (`stand`).
- **A horse's steering follows signed speed**: backing up with the stick left swings the nose right.
- A stale rider locks a vehicle for ever, so getting off is `vehicle_dismount` or `vehicle_drop`.
@ -35,12 +40,13 @@ through the game).
`VehicleHull { put(nid, x, y, z, yaw), row(nid, forward, turn), drift(nid, fx, fz), read(nid) -> bool,
x, y, z, yaw, speed, gone(nid) }` is required: the boat's body, named by the vehicle's nid.
`VehicleLegs { walk(nid, x, y, z, vx, vz, dt) -> bool, x, y, z, stand(nid, x, y, z), gone(nid) }` is the
horse's: unbound, the ground is walked as it is and only a rise steeper than it can take stops it.
```ludic
export port VehicleWorld {
ground: fn(float, float) -> float # required
water: fn(float, float) -> float # the surface (unbound: never wet)
blocked: fn(float, float, float) -> bool # (x, z, r): a collider in a horse's way
swell: fn(float, float, float, float) -> float # (x, z, fx, fz): a stroke's share lost
wind: fn() -> float # 0..1
wind_yaw: fn() -> float # the yaw the wind pushes toward

View file

@ -20,12 +20,14 @@ function ve_add(vehicles_st: VehiclesState, kind: int, x: float, z: float, yaw:
return v
}
# a boat's hull put where the vehicle now is, at rest
function ve_settle(v: Vehicle) -> void { if v.kind == VEHICLE_BOAT { VehicleHull.put(v.nid, v.x, v.y, v.z, v.yaw) } }
# a boat's hull or a horse's legs put where the vehicle now is, at rest
function ve_settle(v: Vehicle) -> void {
if v.kind == VEHICLE_BOAT { VehicleHull.put(v.nid, v.x, v.y, v.z, v.yaw) } else { VehicleLegs.stand(v.nid, v.x, v.y, v.z) }
}
# gone from the world: its model and, for a boat, its hull
# gone from the world: its model and its hull or legs
function ve_gone(v: Vehicle) -> void {
if v.kind == VEHICLE_BOAT { VehicleHull.gone(v.nid) }
if v.kind == VEHICLE_BOAT { VehicleHull.gone(v.nid) } else { VehicleLegs.gone(v.nid) }
VehicleWorld.gone(v)
}
@ -73,40 +75,3 @@ export function vehicles_release(vehicles_st: mut VehiclesState, pid: int) -> vo
}
vehicles_st.ve_list = keep
}
# hired or rented for the trip
export function vehicle_own(v: Vehicle) -> void { if v != null { v.owned = true } }
# hay for a horse, up to a full belly
export function vehicle_feed(vehicles_st: VehiclesState, v: Vehicle, amount: float) -> void {
if v == null { return }
v.food = Math.min(v.food + amount, 100.0)
ve_say(vehicles_st, VEHICLE_FED, v, amount)
}
# a horse nobody rides goes where its animal goes, round the home it is tied at (home_x, home_z)
export function vehicle_follow(v: Vehicle, x: float, y: float, z: float, yaw: float) -> void {
if v == null or v.rider >= 0 { return }
v.x = x
v.y = y
v.z = z
v.yaw = yaw
}
# a saved trip's word on this player's own: hired, and how fed
export function vehicle_restore(vehicles_st: mut VehiclesState, kind: int, owned: bool, food: float) -> void {
if kind == VEHICLE_HORSE {
vehicles_st.ve_kept_horse = owned
vehicles_st.ve_kept_food = food
} else { vehicles_st.ve_kept_boat = owned }
let v = vehicle_find(vehicles_st, kind)
if v != null { ve_apply_kept(vehicles_st, v) }
}
function ve_apply_kept(vehicles_st: VehiclesState, v: Vehicle) -> void {
if v.kind == VEHICLE_HORSE {
v.owned = vehicles_st.ve_kept_horse
v.food = vehicles_st.ve_kept_food
} else { v.owned = vehicles_st.ve_kept_boat }
}

View file

@ -1,5 +1,5 @@
# horse.ludic - a horse under the stick: a walk, a canter on stamina, hay for every metre; it stops at
# water, at a rise it cannot take and at a still thing in its way
# horse.ludic - a horse under the stick: a walk, a canter on stamina, hay for every metre; it will
# not step into water, and its legs (VehicleLegs) meet the ground and whatever stands in its way
const VE_HORSE_WALK: float = 4.5
const VE_HORSE_GALLOP: float = 11.0
const VE_HORSE_ACC: float = 4.0
@ -15,26 +15,31 @@ function ve_ride_horse(vehicles_st: VehiclesState, v: Vehicle, ix: float, iz: fl
if v.speed < 0.0 { rate = -rate }
v.yaw = v.yaw - ix * VE_HORSE_TURN * (dt * rate)
}
let fx = -Math.sin(v.yaw)
let fz = -Math.cos(v.yaw)
let d = v.speed * dt
ve_travel(vehicles_st, v, v.x - Math.sin(v.yaw) * d, v.z - Math.cos(v.yaw) * d, d)
ve_stamina(v, d, dt)
}
# a horse only on dry ground, up a walkable rise, clear of what is in its way
function ve_travel(vehicles_st: VehiclesState, v: Vehicle, nx: float, nz: float, d: float) -> void {
let nh = VehicleWorld.ground(nx, nz)
var ok = true
if nh < VehicleWorld.water(nx, nz) + 0.4 { ok = false }
if nh - v.y > Math.abs(d) * 0.9 { ok = false }
if VehicleWorld.blocked(nx, nz, 0.8) { ok = false }
if not ok {
let nh = VehicleWorld.ground(v.x + fx * d, v.z + fz * d)
if nh < VehicleWorld.water(v.x + fx * d, v.z + fz * d) + 0.4 {
v.speed = 0.0
return
}
v.x = nx
v.z = nz
v.y = nh
if Math.abs(d) > 0.0 { ve_say(vehicles_st, VEHICLE_MOVED, v, Math.abs(d)) }
ve_legs_walk(vehicles_st, v, fx * v.speed, fz * v.speed, dt)
ve_stamina(v, d, dt)
}
# the legs carry it from where it is; what they covered along its heading is its speed now, never
# more than was asked, so a trunk or a rise it cannot take brings it to a stand
function ve_legs_walk(vehicles_st: VehiclesState, v: Vehicle, vx: float, vz: float, dt: float) -> void {
if not VehicleLegs.walk(v.nid, v.x, v.y, v.z, vx, vz, dt) { return }
let dx = VehicleLegs.x() - v.x
let dz = VehicleLegs.z() - v.z
v.x = VehicleLegs.x()
v.y = VehicleLegs.y()
v.z = VehicleLegs.z()
let got = (dx * -Math.sin(v.yaw) + dz * -Math.cos(v.yaw)) / dt
if v.speed > 0.0 { v.speed = Math.clamp(got, 0.0, v.speed) } else { v.speed = Math.clamp(got, v.speed, 0.0) }
let moved = Math.sqrt(dx * dx + dz * dz)
if moved > 0.0 { ve_say(vehicles_st, VEHICLE_MOVED, v, moved) }
}
# a gallop spends stamina and a walk gives it back; every metre costs hay

View file

@ -9,7 +9,9 @@ import "ports.ludic"
import "state.ludic"
import "places.ludic"
import "call.ludic"
import "keep.ludic"
import "mount.ludic"
import "ride.ludic"
import "horse.ludic"
import "legs.ludic"
import "system.ludic"

View file

@ -0,0 +1,39 @@
# keep.ludic - what a player does with their own between rides: hires it, feeds the horse, lets it
# wander where it is tied, and what a saved trip says of them
# hired or rented for the trip
export function vehicle_own(v: Vehicle) -> void { if v != null { v.owned = true } }
# hay for a horse, up to a full belly
export function vehicle_feed(vehicles_st: VehiclesState, v: Vehicle, amount: float) -> void {
if v == null { return }
v.food = Math.min(v.food + amount, 100.0)
ve_say(vehicles_st, VEHICLE_FED, v, amount)
}
# a horse nobody rides goes where its animal goes, round the home it is tied at (home_x, home_z)
export function vehicle_follow(v: Vehicle, x: float, y: float, z: float, yaw: float) -> void {
if v == null or v.rider >= 0 { return }
v.x = x
v.y = y
v.z = z
v.yaw = yaw
VehicleLegs.stand(v.nid, x, y, z)
}
# a saved trip's word on this player's own: hired, and how fed
export function vehicle_restore(vehicles_st: mut VehiclesState, kind: int, owned: bool, food: float) -> void {
if kind == VEHICLE_HORSE {
vehicles_st.ve_kept_horse = owned
vehicles_st.ve_kept_food = food
} else { vehicles_st.ve_kept_boat = owned }
let v = vehicle_find(vehicles_st, kind)
if v != null { ve_apply_kept(vehicles_st, v) }
}
function ve_apply_kept(vehicles_st: VehiclesState, v: Vehicle) -> void {
if v.kind == VEHICLE_HORSE {
v.owned = vehicles_st.ve_kept_horse
v.food = vehicles_st.ve_kept_food
} else { v.owned = vehicles_st.ve_kept_boat }
}

View file

@ -0,0 +1,30 @@
# legs.ludic - a horse's legs in the world's physics (Maroon Lake: a Jolt walker), named by the
# vehicle's nid. Gravity, steps, slopes and whatever stands in its way are theirs; unbound, the
# ground is walked as it is, a rise steeper than it can take stops it and nothing else is in the way.
export port VehicleLegs {
walk: fn(int, float, float, float, float, float, float) -> bool = fn ve__walk # (nid, x, y, z, vx, vz, dt): its pose into x, y, z
x: fn() -> float = fn ve__lx
y: fn() -> float = fn ve__ly
z: fn() -> float = fn ve__lz
stand: fn(int, float, float, float) -> void = fn ve__stand # (nid, x, y, z): tied or led there
gone: fn(int) -> void = fn ve__legs_gone
}
function ve__walk(vehicles_st: mut VehiclesState, nid: int, x: float, y: float, z: float, vx: float, vz: float, dt: float) -> bool {
vehicles_st.ve_lx = x
vehicles_st.ve_ly = y
vehicles_st.ve_lz = z
let nx = x + vx * dt
let nz = z + vz * dt
let nh = VehicleWorld.ground(nx, nz)
if nh - y > Math.sqrt(vx * vx + vz * vz) * dt * 0.9 { return true }
vehicles_st.ve_lx = nx
vehicles_st.ve_ly = nh
vehicles_st.ve_lz = nz
return true
}
function ve__lx(vehicles_st: VehiclesState) -> float { return vehicles_st.ve_lx }
function ve__ly(vehicles_st: VehiclesState) -> float { return vehicles_st.ve_ly }
function ve__lz(vehicles_st: VehiclesState) -> float { return vehicles_st.ve_lz }
function ve__stand(nid: int, x: float, y: float, z: float) -> void { }
function ve__legs_gone(nid: int) -> void { }

View file

@ -4,7 +4,6 @@
export port VehicleWorld {
ground: fn(float, float) -> float
water: fn(float, float) -> float = fn ve__dry # the surface over a point
blocked: fn(float, float, float) -> bool = fn ve__open # (x, z, r): a collider in a horse's way
swell: fn(float, float, float, float) -> float = fn ve__calm # (x, z, fx, fz): 0..1 of a stroke lost
wind: fn() -> float = fn ve__zero # 0..1
wind_yaw: fn() -> float = fn ve__zero # the yaw the wind pushes toward
@ -56,10 +55,12 @@ export state VehiclesState {
ve_rail_yaw: float = 0.0
ve_have_dock: bool = false
ve_swell_said: float = 0.0
ve_lx: float = 0.0 # the unbound legs' pose
ve_ly: float = 0.0
ve_lz: float = 0.0
ve_fact_q: Queue<VehicleFact> = ve_fact_q__new()
}
function ve__dry(x: float, z: float) -> float { return -10000.0 }
function ve__open(x: float, z: float, r: float) -> bool { return false }
function ve__calm(x: float, z: float, fx: float, fz: float) -> float { return 0.0 }
function ve__zero() -> float { return 0.0 }
function ve__next(vehicles_st: mut VehiclesState) -> int {

View file

@ -3,7 +3,10 @@
# read back and pushed by the wind in vehicles_tick, which the game calls where the world is run
export function vehicles_reset(vehicles_st: mut VehiclesState) -> void {
if vehicles_st.ve_list != null {
for i in 0 .. len(vehicles_st.ve_list) { if vehicles_st.ve_list[i].kind == VEHICLE_BOAT { VehicleHull.gone(vehicles_st.ve_list[i].nid) } }
for i in 0 .. len(vehicles_st.ve_list) {
let v = vehicles_st.ve_list[i]
if v.kind == VEHICLE_BOAT { VehicleHull.gone(v.nid) } else { VehicleLegs.gone(v.nid) }
}
}
vehicles_st.ve_list = new []Vehicle
vehicles_st.ve_cur = null

View file

@ -1,8 +1,8 @@
# vehicles_test.ludic - a fake shore: land at x < 10, a lake past it deepening a metre a metre, a
# pier head at the shore facing out and a trough on the meadow, a trunk in a horse's way. Calling to
# berths and bays, the owner and the rider, mounting a hungry horse, riding both, the swell, the wind,
# getting off near a shore and far from one, a player leaving, and the save. The boat's hull is a
# real ludic.physics body on the same lake.
# getting off near a shore and far from one, a player leaving, and the save. The boat's hull and the
# horse's legs are real ludic.physics bodies on the same ground, the trunk a post in it.
import "ludic.vehicles"
import "ludic.base"
import "ludic.physics"
@ -17,7 +17,6 @@ program VehiclesTest {
if x > 9.5 { return 0.0 }
return -10000.0
}
function fk_blocked(x: float, z: float, r: float) -> bool { return Math.abs(x) < 1.0 + r and Math.abs(z + 60.0) < 1.0 + r }
state VehiclesTestState {
swell: float = 0.0
wind: float = 0.0
@ -38,6 +37,11 @@ program VehiclesTest {
hyaw: float = 0.0
hspeed: float = 0.0
hulls: int = 0
legs: int = -1
lx: float = 0.0
ly: float = 0.0
lz: float = 0.0
stood: int = 0
}
function fk_swell(vehicles_test_st: VehiclesTestState, x: float, z: float, fx: float, fz: float) -> float {
if fx > 0.0 { return vehicles_test_st.swell }
@ -79,11 +83,28 @@ program VehiclesTest {
function fk_lake(physics_st: mut PhysicsState) -> void {
if phys_is_open(physics_st) { return }
phys_open(physics_st, 64, 1)
let n = 96
let n = 160
let h = floats(n * n)
for j in 0 .. n { for i in 0 .. n { h[j * n + i] = fk_ground(float(i) - 20.0, float(j) - 48.0) } }
phys_ground_add(physics_st, phys_heightfield(physics_st, h, n, -20.0, -48.0, 1.0))
for j in 0 .. n { for i in 0 .. n { h[j * n + i] = fk_ground(float(i) - 20.0, float(j) - 100.0) } }
phys_ground_add(physics_st, phys_heightfield(physics_st, h, n, -20.0, -100.0, 1.0))
phys_static_add(physics_st, phys_box(physics_st, 15.0, 5.0, 1.0), 0.0, 0.0, -60.0, 0.0)
}
# the legs: the horse's own walker on that ground, made on its first step
function fk_walk(physics_st: mut PhysicsState, vehicles_test_st: mut VehiclesTestState, nid: int, x: float, y: float, z: float, vx: float, vz: float, dt: float) -> bool {
fk_lake(physics_st)
if vehicles_test_st.legs < 0 { vehicles_test_st.legs = phys_walker_add(physics_st, 0.8, 1.7, x, y, z, 40.0, 450.0) }
phys_walker_move(physics_st, vehicles_test_st.legs, x, y, z, vx, vz, 0.0, 0.45, 40.0, dt)
let p = phys_walker_pose(physics_st, vehicles_test_st.legs)
vehicles_test_st.lx = p.x
vehicles_test_st.ly = p.y
vehicles_test_st.lz = p.z
return true
}
function fk_lx(vehicles_test_st: VehiclesTestState) -> float { return vehicles_test_st.lx }
function fk_ly(vehicles_test_st: VehiclesTestState) -> float { return vehicles_test_st.ly }
function fk_lz(vehicles_test_st: VehiclesTestState) -> float { return vehicles_test_st.lz }
function fk_stand(vehicles_test_st: mut VehiclesTestState, nid: int, x: float, y: float, z: float) -> void { vehicles_test_st.stood += 1 }
function fk_put(physics_st: mut PhysicsState, vehicles_test_st: mut VehiclesTestState, nid: int, x: float, y: float, z: float, yaw: float) -> void {
fk_lake(physics_st)
if vehicles_test_st.hull >= 0 and vehicles_test_st.hull_nid == nid {
@ -120,8 +141,9 @@ program VehiclesTest {
function fk_hspeed(vehicles_test_st: VehiclesTestState) -> float { return vehicles_test_st.hspeed }
bind VehicleHull { put: fn fk_put, row: fn fk_row, drift: fn fk_drift, read: fn fk_read, x: fn fk_hx, y: fn fk_hy, z: fn fk_hz, yaw: fn fk_hyaw, speed: fn fk_hspeed, gone: fn fk_hull_gone }
bind PhysWater { height: fn fk_water }
bind VehicleLegs { walk: fn fk_walk, x: fn fk_lx, y: fn fk_ly, z: fn fk_lz, stand: fn fk_stand }
bind VehicleWorld { ground: fn fk_ground, water: fn fk_water, blocked: fn fk_blocked, swell: fn fk_swell, wind: fn fk_wind, made: fn fk_made, gone: fn fk_gone, placed: fn fk_placed }
bind VehicleWorld { ground: fn fk_ground, water: fn fk_water, swell: fn fk_swell, wind: fn fk_wind, made: fn fk_made, gone: fn fk_gone, placed: fn fk_placed }
bind VehicleRider { board: fn fk_board, ride_at: fn fk_ride, step_off: fn fk_off, safe_spot: fn fk_safe, safe_x: fn fk_sx, safe_z: fn fk_sz }
const EAST: float = -1.5707963
@ -207,7 +229,7 @@ program VehiclesTest {
expect(vehicle_in_place(vehicles_st, VEHICLE_HORSE, 0) == null)
}
test "a horse walks, gallops on stamina, stops at the water and at a trunk" (physics_st: mut PhysicsState, vehicles_st: mut VehiclesState, vehicles_test_st: mut VehiclesTestState) {
test "a horse walks, gallops on stamina, is pushed aside by a trunk it glances, stops at a fence and the water; called, it stands at its bay" (physics_st: mut PhysicsState, vehicles_st: mut VehiclesState, vehicles_test_st: mut VehiclesTestState) {
fresh(vehicles_st, vehicles_test_st)
vehicle_call_for(vehicles_st, 0, VEHICLE_HORSE, 0)
let h = vehicle_find(vehicles_st, VEHICLE_HORSE)
@ -221,10 +243,14 @@ program VehiclesTest {
expect(h.z < -15.0)
expect_near(vehicles_test_st.rz, h.z, 0.001)
expect_near(vehicles_test_st.ry, 1.35, 0.001)
# the trunk at z = -60 stops it
# a trunk half a metre off its line pushes it aside and lets it by; the fence at z = -60 stops it
let x0 = h.x
phys_static_add(physics_st, phys_cylinder(physics_st, 3.0, 0.3), x0 + 0.5, 0.0, -35.0, 0.0)
ride(physics_st, vehicles_st, 0.0, 1.0, true, 6.0)
expect(h.z > -60.0 + 1.7)
expect_near(h.speed, 0.0, 0.001)
expect(h.z > -59.0 - 0.85)
expect(h.z < -50.0)
expect(Math.abs(h.x - x0) > 0.2)
expect_near(h.speed, 0.0, 0.05)
# and the lake to the east does too
vehicle_drop(vehicles_st)
h.x = 7.0
@ -233,6 +259,11 @@ program VehiclesTest {
vehicle_mount(vehicles_st, h)
ride(physics_st, vehicles_st, 0.0, 1.0, false, 4.0)
expect(h.x < 9.6)
# a horse brought to its bay is stood there
vehicle_drop(vehicles_st)
let before = vehicles_test_st.stood
expect(vehicle_call_for(vehicles_st, 0, VEHICLE_HORSE, 1))
expect_eq(vehicles_test_st.stood, before + 1)
}
test "a boat is rowed out, loses its stroke to the swell, turns, is stopped by the shore's bed, drifts on the wind" (physics_st: mut PhysicsState, vehicles_st: mut VehiclesState, vehicles_test_st: mut VehiclesTestState) {