ludic/packages/ludic.render3d/grass.ludic
Orkuncakilkaya 6169cd05b9 feat(render3d): the grass knows a body is standing in it
You walked through a meadow and every blade ignored you, which is the most noticeable
thing missing from every step the game asks you to take.

u_push (x, z, radius) bends blades away from a body and DOWN - a trodden stem is shorter
as well as leaning, and leaving the height alone made them splay outward like a fan
instead of being walked through. Applied after the blade's own yaw has put it into world
axes and before it is tipped onto the ground normal, so the push is a world direction
rather than something in the blade's private frame.

Radius 0 means nobody is there, so nothing is paid for when it does not apply. Measured:
GL 6.9 s, VK 7.3 s over 400 frames, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:40:08 +03:00

263 lines
11 KiB
Text

# ============================================================================
# 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
# (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
# each blade's own distance, so density is one smooth function of distance everywhere.
# Tiles are 16 m near, 64 m in the middle distance and 256 m far, purely to keep the
# draw count down — the cells and their hashes are the same in every tile size.
# ============================================================================
const GRASS_CELL: int = 16
var grass_prog: int = 0
var grass_mesh: Mesh = null
var grass_on: bool = true
var grass_wind: int = 0
# Where a body is standing, and how wide it pushes. The grass has never known the player was
# in it: you walked through a meadow and every blade ignored you, which is the single most
# noticeable thing missing from every step the game asks you to take. The game sets this each
# frame; radius 0 means nobody is there.
var grass_push_x: int = 0
var grass_push_z: int = 0
var grass_push_r: int = 0
var grass_s0: int = 0 # float bits: blade spacing at the camera (m)
var grass_d0: int = 0 # the distance at which the spacing has doubled (m)
var grass_radius: int = 0 # no blades past this (m)
var grass_draws: int = 0
var grass_dbg: int = 0
# Vulkan with multi-draw indirect: every visible tile is a record in one buffer, uploaded once a
# frame, and each band draws its records GRASS_CHUNK at a time - one draw for up to 256 tiles. A
# record's firstInstance is its place in its chunk times 65536; grass.vert's TILES variant reads that
# place's corner and indices per cell from u_tiles. R3D_GRASS_TILES=1 keeps a draw per tile.
const GRASS_CHUNK: int = 256
const GRASS_RECS: int = 8192
var grass_merge: bool = false
var grass_rec: words = null # VkDrawIndexedIndirectCommand records, 5 words each
var grass_tv: words = null # per record: corner x, corner z, indices per cell, 0 (float bits)
var grass_chunk_tv: words = null
var grass_n: int = 0
var grass_cmds: int = 0
var grass_band_start: words = null # per band: its first record
var grass_band_cells: words = null # ... and its 16 m cells per tile side
var grass_band_n: int = 0
# Mesh-shader grass (Settings, Video, Advanced): the chunked path's records, but each chunk is one
# mesh dispatch - work group y a tile, x a batch of GRASS_MESH_BLADES of its blades - so a blade the
# tests reject emits nothing instead of eight degenerate vertices. R3D_MESH_GRASS=1 / 0 overrides.
const GRASS_MESH_BLADES: int = 16
var grass_mesh_on: bool = false
var grass_mesh_prog: int = 0
function r3d_mesh_grass(on: bool) -> void {
grass_mesh_on = on
if r3d_env_has("R3D_MESH_GRASS") { grass_mesh_on = Text.to_int(r3d_env("R3D_MESH_GRASS")) != 0 }
}
function grass_mesh_live() -> bool { return grass_mesh_on and grass_merge and grass_mesh_prog != 0 and gpu_has_mesh() }
# a blade: `rows` rows of 2 vertices (x across, y along, z bend), attribute 2 = uv
function grass_blade_mesh(rows: int) -> Mesh {
let m = gpu_mesh_new()
let v = gl_floats(rows * 2 * 5)
var k = 0
for r in 0 .. rows {
let t = fr(r, rows - 1)
let taper = f_max(f_sub(F_ONE, f_mul(t, f_mul(t, f_sqrt(t)))), fl(0.12))
let bend = f_mul(f_mul(t, t), fl(0.28))
for sd in 0 .. 2 {
var x = f_neg(F_HALF)
if sd == 1 { x = F_HALF }
gl_put_bits(v, k, f_mul(x, taper)); gl_put_bits(v, k + 1, t); gl_put_bits(v, k + 2, bend)
gl_put_bits(v, k + 3, fi(sd)); gl_put_bits(v, k + 4, t)
k += 5
}
}
gpu_mesh_vertices(m, v, gl_bytes_of(rows * 2 * 5), GPU_STATIC)
gpu_mesh_attr(m, 0, 3, GPU_F32, 20, 0, false)
gpu_mesh_attr(m, 2, 2, GPU_F32, 20, 12, false)
free(v)
let nq = rows - 1
let idx = words(nq * 6)
for q in 0 .. nq {
let b = q * 2
idx[q * 6] = b; idx[q * 6 + 1] = b + 1; idx[q * 6 + 2] = b + 2
idx[q * 6 + 3] = b + 1; idx[q * 6 + 4] = b + 3; idx[q * 6 + 5] = b + 2
}
gpu_mesh_indices(m, idx, nq * 6 * 4, 4)
free(idx)
m.count = nq * 6
gpu_mesh_done(m)
return m
}
function grass_init() -> void {
grass_merge = gpu_has_mdi() and not r3d_env_has("R3D_GRASS_TILES")
var defs = "#define FOLIAGE\n#define BLADE\n"
if grass_merge {
defs = defs + "#define TILES\n"
grass_rec = words(GRASS_RECS * 5); grass_tv = words(GRASS_RECS * 4); grass_chunk_tv = words(GRASS_CHUNK * 4)
grass_band_start = words(4); grass_band_cells = words(4)
grass_cmds = gpu_buffer_new()
}
grass_prog = r3d_program("grass.vert", "model.frag", defs)
if grass_merge and gpu_has_mesh() { grass_mesh_prog = r3d_program("grass.mesh", "model.frag", "#define FOLIAGE\n#define BLADE\n#define MESH\n") }
grass_mesh = grass_blade_mesh(4)
grass_wind = fl(2.4)
grass_s0 = fl(0.11)
grass_d0 = fi(45)
grass_radius = fi(1600)
if r3d_env_has("R3D_NOBLADES") { grass_on = false }
if r3d_env_has("R3D_GRASS_R") { grass_radius = fi(Text.to_int(r3d_env("R3D_GRASS_R"))) }
if r3d_env_has("R3D_GRASS_DBG") { grass_dbg = Text.to_int(r3d_env("R3D_GRASS_DBG")) }
}
# indices per 16 m cell that could exist at distance d (the count the shader computes)
function grass_count_at(d: int) -> int {
let spacing = f_mul(grass_s0, f_add(F_ONE, f_div(d, grass_d0)))
let n = f_div(fi(GRASS_CELL * GRASS_CELL), f_mul(spacing, spacing))
return f_to_int(n) + 1
}
# one tile size over one distance band
function grass_tiles(size: int, d_min: int, d_max: int) -> void {
let p = grass_prog
let cells = size / GRASS_CELL
if grass_merge {
grass_band_start[grass_band_n] = grass_n; grass_band_cells[grass_band_n] = cells; grass_band_n += 1
} else {
u_i(gpu_uniform(p, "u_tile_cells"), cells)
}
let sz = fi(size)
let half = f_mul(sz, F_HALF)
let reach = f_add(d_max, f_mul(half, fl(1.5)))
let tx0 = f_to_int(f_floor(f_div(f_sub(cam_pos[0], reach), sz)))
let tx1 = f_to_int(f_floor(f_div(f_add(cam_pos[0], reach), sz)))
let tz0 = f_to_int(f_floor(f_div(f_sub(cam_pos[2], reach), sz)))
let tz1 = f_to_int(f_floor(f_div(f_add(cam_pos[2], reach), sz)))
let corner_r = f_mul(half, fl(1.42))
var tz = tz0
while tz <= tz1 {
var tx = tx0
while tx <= tx1 {
let ox = f_mul(fi(tx), sz); let oz = f_mul(fi(tz), sz)
let cx = f_add(ox, half); let cz = f_add(oz, half)
let dx = f_sub(cx, cam_pos[0]); let dz = f_sub(cz, cam_pos[2])
let dc = f_sqrt(f_add(f_mul(dx, dx), f_mul(dz, dz)))
# the tile's nearest and farthest points decide which band it belongs to
let dnear = f_max(f_sub(dc, corner_r), F_ZERO)
if f_ls(dc, d_min) or not f_ls(dnear, d_max) { tx += 1; continue }
let cy = terrain_height(cx, cz)
if cam_sphere_visible(cx, cy, cz, f_add(corner_r, fi(6))) {
let per = grass_count_at(dnear)
if per > 0 and grass_merge {
if grass_n < GRASS_RECS {
var inst = per * cells * cells
if inst > 65535 { inst = 65535 }
let r = grass_n * 5
grass_rec[r] = grass_mesh.count; grass_rec[r + 1] = inst; grass_rec[r + 2] = 0; grass_rec[r + 3] = 0
grass_rec[r + 4] = ((grass_n - grass_band_start[grass_band_n - 1]) % GRASS_CHUNK) * 65536
let t = grass_n * 4
grass_tv[t] = ox; grass_tv[t + 1] = oz; grass_tv[t + 2] = fi(per); grass_tv[t + 3] = F_ZERO
grass_n += 1
}
} else if per > 0 {
u_f2(gpu_uniform(p, "u_tile"), ox, oz)
u_i(gpu_uniform(p, "u_per_cell"), per)
mesh_draw_instanced(grass_mesh, per * cells * cells)
grass_draws += 1
}
}
tx += 1
}
tz += 1
}
}
function grass_draw() -> void {
if not grass_on or ter_reflect or grass_prog == 0 { return }
var p = grass_prog
if grass_mesh_live() { p = grass_mesh_prog }
gpu_use_program(p)
u_mat4(gpu_uniform(p, "u_view"), cam_view)
u_mat4(gpu_uniform(p, "u_proj"), cam_proj)
u_mat4(gpu_uniform(p, "u_vp"), cam_vp_clean)
u_f(gpu_uniform(p, "u_wind"), grass_wind)
u_f3(gpu_uniform(p, "u_push"), grass_push_x, grass_push_z, grass_push_r)
u_f(gpu_uniform(p, "u_rough_scale"), F_ONE)
u_v3(gpu_uniform(p, "u_tint"), sc_blade_tint)
u_v3(gpu_uniform(p, "u_blade_base"), sc_blade_base)
u_v3(gpu_uniform(p, "u_blade_tip"), sc_blade_tip)
u_f(gpu_uniform(p, "u_cull"), grass_radius)
u_f(gpu_uniform(p, "u_model_h"), F_ZERO)
u_f(gpu_uniform(p, "u_s0"), grass_s0)
u_f(gpu_uniform(p, "u_d0"), grass_d0)
u_f(gpu_uniform(p, "u_radius"), grass_radius)
u_i(gpu_uniform(p, "u_dbg"), grass_dbg)
var orthotex = ter_ortho_tex
var oon = F_ONE
if orthotex == 0 { orthotex = ter_height_tex; oon = F_ZERO }
r3d_bind_2d(p, "u_ortho", 4, orthotex)
u_f(gpu_uniform(p, "u_ortho_on"), oon)
var lake = fl(-100000.0)
if ter_lake_ex != 0 { lake = ter_lake_level }
u_f(gpu_uniform(p, "u_lake_level"), lake)
var sea = lake
if ter_sea_set { sea = ter_sea_level }
u_f(gpu_uniform(p, "u_sea_level"), sea)
u_f4(gpu_uniform(p, "u_lake"), ter_lake_cx, ter_lake_cz, ter_lake_ex, ter_lake_ez)
u_f(gpu_uniform(p, "u_snow_line"), ter_snow_line)
sky_bind_lighting(p)
shadow_bind(p)
fog_bind(p)
u_f(gpu_uniform(p, "u_spec_scale"), fl(0.15))
gpu_cull(false)
grass_draws = 0
grass_n = 0
grass_band_n = 0
gpu_mesh_bind(grass_mesh)
grass_tiles(16, F_ZERO, fi(300))
grass_tiles(64, fi(300), fi(1200))
grass_tiles(256, fi(1200), grass_radius)
if grass_merge { grass_flush() }
gpu_cull(true)
}
# the records gathered this frame: uploaded once, then each band GRASS_CHUNK records a draw
function grass_flush() -> void {
if grass_n == 0 { return }
var p = grass_prog
let mesh = grass_mesh_live()
if mesh { p = grass_mesh_prog } else { gpu_buffer_upload(grass_cmds, grass_n * 20, grass_rec, GPU_DYNAMIC) }
for b in 0 .. grass_band_n {
let s = grass_band_start[b]
var e = grass_n
if b + 1 < grass_band_n { e = grass_band_start[b + 1] }
if e > s { u_i(gpu_uniform(p, "u_tile_cells"), grass_band_cells[b]) }
var k = s
while k < e {
var m = e - k
if m > GRASS_CHUNK { m = GRASS_CHUNK }
for q in 0 .. m * 4 { grass_chunk_tv[q] = grass_tv[k * 4 + q] }
if mesh {
# one dispatch per tile, as many blade batches as that tile has: sized for a chunk's largest
# tile, the far tiles beside a near one ran thousands of empty invocations (9.8 ms against the
# chunked path's 2 at 4K on an RTX 3070 Ti)
let cells = grass_band_cells[b]
for q in 0 .. m {
var total = f_to_int(grass_tv[(k + q) * 4 + 2]) * cells * cells
if total > 65535 { total = 65535 }
if total > 0 {
for c in 0 .. 4 { grass_chunk_tv[c] = grass_tv[(k + q) * 4 + c] }
u_f4v(gpu_uniform(p, "u_tiles"), 1, grass_chunk_tv)
gpu_draw_mesh_tasks((total + GRASS_MESH_BLADES - 1) / GRASS_MESH_BLADES, 1, 1)
grass_draws += 1
}
}
} else {
u_f4v(gpu_uniform(p, "u_tiles"), m, grass_chunk_tv)
gpu_draw_mesh_indirect(grass_mesh, grass_cmds, k * 20, m, 0, 0)
}
if not mesh { grass_draws += 1 }
k += m
}
}
}