render3d: the GPU blades grow by a painted density (a layer of the ground densities, grass_density_layer) where a map has one - a 5x5-tile window round the camera in a storage buffer the cull samples - and by today's rules where not; grass_rule_at is the rules on the CPU for the migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 21:14:22 +03:00
parent e6f4685bd0
commit 9186abbbe3
10 changed files with 219 additions and 21 deletions

View file

@ -0,0 +1,9 @@
bump: minor
type: feature
**`ludic.render3d`: the GPU blades grow where a painted density says.** `grass_density_layer(l)` names a layer of
the ground densities (`ground_density_open`: the same 2 m texel, 64 m tiles, bake and dev fallback) as the
blades'; `grass_cull.comp` then keeps a blade by that density, bilinear, where it used to apply its rules (the
slope, the water's margin, the snow line, the photograph). Without one, nothing changes. The cull reads a
window of 5 x 5 density tiles round the camera (160^2 bytes, a host-visible storage buffer, re-filled from
the CPU tile cache when the camera crosses a 64 m tile), never the whole map. `grass_rule_at(x, z)` is the
rules on the CPU, for a migration that paints them; `grass_density_texels()` the side a density PNG has.

View file

@ -856,6 +856,11 @@ export state Render3dState {
gd_slot_of: words = null
gd_hand: int = 0
gd_trample_cb: fn(float, float) -> float = null
gb_layer: int = -1 # the densities' layer the blades grow by; -1: the rules (grass_density.ludic)
gb_buf: int = 0 # its window round the camera, on the GPU
gb_win: words = null # ... and the window's bytes, four texels a word, made once
gb_tx: int = -1000000 # the window's first texel in the map, x and z (-1000000: not filled)
gb_tz: int = 0
tt_dir: string = "" # terrain_tiles_to: where a map's tiles are written ("" keeps the whole copies)
tt_key: string = ""
tt_file: pointer = null # the tiles' file, open for reading (terrain_tiles.ludic)

View file

@ -814,8 +814,8 @@ function gvk_startup_state(render3d_st: mut Render3dState) -> void {
render3d_st.gvk_pc_pass = new []int; render3d_st.gvk_pc_pipe = new []long
render3d_st.gvk_pc_last = words(4096)
for i in 0 .. 4096 { render3d_st.gvk_pc_last[i] = -1 }
render3d_st.gg_rb = words(8); render3d_st.gg_cb = words(1); render3d_st.gg_bufs = words(3)
render3d_st.gg_texs = words(7); render3d_st.gg_pr = words(72)
render3d_st.gg_rb = words(8); render3d_st.gg_cb = words(1); render3d_st.gg_bufs = words(4)
render3d_st.gg_texs = words(7); render3d_st.gg_pr = words(76)
if render3d_st.gpu_unit_2d == null {
render3d_st.gpu_unit_2d = words(32)
for i in 0 .. 32 { render3d_st.gpu_unit_2d[i] = -1 }

View file

@ -0,0 +1,100 @@
# grass_density.ludic — the blades grow where the map's painted density says, when it has one: a layer of
# the ground densities (ground_density.ludic; a PNG like any ground layer's, the same 2 m texel and 64 m
# tiles), named by grass_density_layer. Without one grass_cull.comp keeps its rules (grass_rule_at here).
#
# The cull decides every blade on the GPU, so the density must be there too - but not all of it: 4096^2
# bytes is 16 MB, and the blades stand within grass_reach (70 m at most). The GPU holds a window of
# GB_TILES x GB_TILES density tiles round the camera's (5 x 64 m: 160^2 bytes, 25 KB, twice while a re-fill
# swaps), re-filled from the CPU tile cache when the camera crosses into another tile. The camera stays in
# the middle tile, so the window reaches at least 128 m every way. A host-visible storage buffer, not a
# texture: filling a texture waits for the GPU (gpu_tex_fill), a buffer's re-fill swaps under the frame
# still reading it (gvk_buf_reserve), and the cull does its own bilinear in four reads.
const GB_TILES: int = 5
const GB_NONE: int = -1000000
# The layer of the ground densities the blades grow by (-1: none, the rules). Call it after
# ground_density_open, with the index the blades' PNG had in its list; closing the densities forgets it.
export function grass_density_layer(render3d_st: mut Render3dState, l: int) -> void {
render3d_st.gb_layer = l
render3d_st.gb_tx = GB_NONE
}
# the side, in texels, a density PNG must have for this renderer (as every ground layer's)
export function grass_density_texels() -> int { return TERRAIN_RES / TT_TEX * GD_SIDE }
# whether the blades read a density: a layer named, and the densities open with it
function gb_on(render3d_st: Render3dState) -> bool {
let l = render3d_st.gb_layer
if l < 0 or render3d_st.gd_file == null or render3d_st.gb_win == null or l >= render3d_st.gd_layers { return false }
return render3d_st.gd_ch[l] > 0
}
# the densities' texel in metres
function gb_texel(render3d_st: Render3dState) -> float { return 2.0 * float(render3d_st.TERRAIN_HALF) / float(render3d_st.gd_n * GD_SIDE) }
# once, with the cull's buffers: the window's bytes and its buffer, at its full size (zeros)
@alloc_ok("start-up: the density window's bytes and buffer, once")
function gb_init(render3d_st: mut Render3dState) -> void {
let side = GB_TILES * GD_SIDE
render3d_st.gb_win = words(side * side / 4)
for i in 0 .. side * side / 4 { render3d_st.gb_win[i] = 0 }
render3d_st.gb_buf = gpu_buffer_new(render3d_st)
gpu_buffer_upload(render3d_st, render3d_st.gb_buf, side * side, data_of(render3d_st.gb_win), GPU_DYNAMIC)
}
# before the cull: the window re-filled and sent when the camera is no longer in its middle tile
function gb_frame(render3d_st: mut Render3dState) -> void {
if not gb_on(render3d_st) { return }
let texel = gb_texel(render3d_st)
let tm = texel * float(GD_SIDE)
let half = float(render3d_st.TERRAIN_HALF)
let ctx = int(Math.floor((render3d_st.cam_pos[0] - render3d_st.ter_ox + half) / tm))
let ctz = int(Math.floor((render3d_st.cam_pos[2] - render3d_st.ter_oz + half) / tm))
let tx = (ctx - GB_TILES / 2) * GD_SIDE
let tz = (ctz - GB_TILES / 2) * GD_SIDE
if tx == render3d_st.gb_tx and tz == render3d_st.gb_tz { return }
for j in 0 .. GB_TILES {
for i in 0 .. GB_TILES { gb_fill_tile(render3d_st, i, j, ctx - GB_TILES / 2 + i, ctz - GB_TILES / 2 + j) }
}
let side = GB_TILES * GD_SIDE
gpu_buffer_upload(render3d_st, render3d_st.gb_buf, side * side, data_of(render3d_st.gb_win), GPU_DYNAMIC)
render3d_st.gb_tx = tx
render3d_st.gb_tz = tz
}
# window tile (i, j) from the map's tile (mx, mz): its density bytes, four to a word; zeros off the map
function gb_fill_tile(render3d_st: mut Render3dState, i: int, j: int, mx: int, mz: int) -> void {
let l = render3d_st.gb_layer
let n = render3d_st.gd_n
let wq = GB_TILES * GD_SIDE / 4
let inside = mx >= 0 and mz >= 0 and mx < n and mz < n
var at = 0
var ch = 1
if inside {
at = gd_slot(render3d_st, l, mz * n + mx) * GD_SIDE * GD_SIDE * 2
ch = render3d_st.gd_ch[l]
}
let pool = render3d_st.gd_pool
for r in 0 .. GD_SIDE {
for q in 0 .. GD_SIDE / 4 {
var w = 0
if inside {
let o = at + (r * GD_SIDE + q * 4) * ch
w = pool[o] | (pool[o + ch] << 8) | (pool[o + 2 * ch] << 16) | (pool[o + 3 * ch] << 24)
}
render3d_st.gb_win[(j * GD_SIDE + r) * wq + i * GD_SIDE / 4 + q] = w
}
}
}
# grass_cull.comp's Params word 72: the window's world corner, its texel and side; a side of 0 is the rules
function gb_params(render3d_st: Render3dState, pr: words) -> void {
if not gb_on(render3d_st) or render3d_st.gb_tx == GB_NONE { return }
let texel = gb_texel(render3d_st)
let half = float(render3d_st.TERRAIN_HALF)
pr[72] = float_bits(render3d_st.ter_ox - half + float(render3d_st.gb_tx) * texel)
pr[73] = float_bits(render3d_st.ter_oz - half + float(render3d_st.gb_tz) * texel)
pr[74] = float_bits(texel)
pr[75] = float_bits(float(GB_TILES * GD_SIDE))
}

View file

@ -37,7 +37,7 @@ function gg_init__t(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, 7)
render3d_st.gg_cull = gpu_compute_tex(render3d_st, "grass_cull", 4, 7)
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
@ -57,6 +57,7 @@ function gg_init__t(render3d_st: mut Render3dState) -> void {
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)
gb_init(render3d_st)
render3d_st.gg_on = true
print("r3d: grass: blades are culled on the GPU")
}
@ -132,13 +133,14 @@ function gg_cull_frame(render3d_st: mut Render3dState) -> void {
# 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)
gb_frame(render3d_st)
let pr = gg_params(render3d_st)
let bufs = render3d_st.gg_bufs
bufs[0] = tb; bufs[1] = render3d_st.gg_out; bufs[2] = render3d_st.gg_cmds
bufs[0] = tb; bufs[1] = render3d_st.gg_out; bufs[2] = render3d_st.gg_cmds; bufs[3] = render3d_st.gb_buf
let texs = render3d_st.gg_texs
texs[0] = render3d_st.ter_height_tex; texs[1] = render3d_st.ter_ortho_tex; texs[2] = render3d_st.ter_normal_tex
gg_page_texs(render3d_st, texs)
gpu_dispatch_tex(render3d_st, render3d_st.gg_cull, data_of(pr), 288, bufs, texs, (render3d_st.gg_max + 63) / 64, render3d_st.gg_n)
gpu_dispatch_tex(render3d_st, render3d_st.gg_cull, data_of(pr), 304, bufs, texs, (render3d_st.gg_max + 63) / 64, render3d_st.gg_n)
}
# the page pool's four, after the three whole-map ones (the stand-ins while paging is off)
@ -148,10 +150,10 @@ function gg_page_texs(render3d_st: mut Render3dState, texs: words) -> void {
if render3d_st.tp_on { texs[3] = render3d_st.tp_page_tex; texs[4] = render3d_st.tp_h_tex; texs[5] = render3d_st.tp_n_tex; texs[6] = render3d_st.tp_o_tex }
}
# grass_cull.comp's Params, std140: 18 vec4s, into the block made once with the device
# grass_cull.comp's Params, std140: 19 vec4s, into the block made once with the device
function gg_params(render3d_st: mut Render3dState) -> words {
let pr = render3d_st.gg_pr
for i in 0 .. 72 { pr[i] = 0 }
for i in 0 .. 76 { 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(grass_reach(render3d_st))
var ph = render3d_st.post_h
@ -171,6 +173,7 @@ function gg_params(render3d_st: mut Render3dState) -> words {
# the page pool (terrain_pages.ludic): on in lev.w, and its dims as the terrain's shaders take them
if render3d_st.tp_on { pr[31] = float_bits(1.0) }
pr[48] = float_bits(float(render3d_st.tt_n)); pr[49] = float_bits(float(TT_TEX)); pr[50] = float_bits(float(render3d_st.tt_oside)); pr[51] = float_bits(float(TERRAIN_RES))
gb_params(render3d_st, pr)
return pr
}

View file

@ -0,0 +1,53 @@
# grass_rule.ludic — grass_cull.comp's bladeRules on the CPU: whether the ground at (x, z) grows blades, 0..1,
# for the migration that paints it into a map's blades density (keep the two in step). Every term here varies
# slower than a 2 m texel - the slope, the water's margin, the snow line, the photograph - so a painted texel
# holds it; what varies within one (each blade's hashes, the tussocks, the colour field) stays in the shader.
# the ground's keep at (x, z), under the kind in use and the map's water and snow
export function grass_rule_at(render3d_st: mut Render3dState, x: float, z: float) -> float {
let k = grass_kind(render3d_st)
let e = terrain_texel(render3d_st)
let y = terrain_height(render3d_st, x, z)
let gx = (terrain_height(render3d_st, x + e, z) - terrain_height(render3d_st, x - e, z)) / (2.0 * e)
let gz = (terrain_height(render3d_st, x, z + e) - terrain_height(render3d_st, x, z - e)) / (2.0 * e)
let ny = 1.0 / Math.sqrt(1.0 + gx * gx + gz * gz)
let snow = render3d_st.ter_snow_line
var ok = (1.0 - gr_smooth(k.slope_lo, k.slope_hi, 1.0 - ny)) * gr_smooth(0.0, 0.6, y - gr_water(render3d_st, x, z) - 0.15)
ok = ok * gr_smooth(snow - k.snow_in, snow - k.snow_out, y)
if ok <= 0.0 or render3d_st.ter_ortho_tex == 0 { return ok }
# the photograph varies inside a texel: its term averaged over four points of it
var o = 0.0
for s in 0 .. 4 {
let c = terrain_ortho(render3d_st, x + (float(s % 2) - 0.5) * e * 0.5, z + (float(s / 2) - 0.5) * e * 0.5)
let gb = gr_linear((c >> 8) & 255) - gr_linear(c & 255)
o = o + k.ortho_floor + k.ortho_gain * gr_smooth(0.0, k.ortho_green, gb)
}
return ok * o * 0.25
}
# the water line there: the sea's, or the carved lake's inside its ellipse (as gg_params hands them over)
function gr_water(render3d_st: Render3dState, x: float, z: float) -> float {
var lake = -100000.0
if render3d_st.ter_lake_ex != 0.0 { lake = render3d_st.ter_lake_level }
var wl = lake
if render3d_st.ter_sea_set { wl = render3d_st.ter_sea_level }
if render3d_st.ter_lake_ex > 0.0 {
let qx = (x - render3d_st.ter_lake_cx) / render3d_st.ter_lake_ex
let qz = (z - render3d_st.ter_lake_cz) / render3d_st.ter_lake_ez
if qx * qx + qz * qz < 1.0 { wl = Math.max(wl, lake) }
}
return wl
}
# GLSL's smoothstep, edges either way round as the shader's snow line has them
function gr_smooth(e0: float, e1: float, v: float) -> float {
let t = Math.clamp((v - e0) / (e1 - e0), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
}
# an sRGB byte as the shader reads it from the photograph's sRGB texture: linear
function gr_linear(c: int) -> float {
let v = float(c) / 255.0
if v <= 0.04045 { return v / 12.92 }
return Math.pow((v + 0.055) / 1.055, 2.4)
}

View file

@ -68,6 +68,7 @@ function gd_cache_reset(render3d_st: mut Render3dState, tiles: int) -> void {
export function ground_density_close(render3d_st: mut Render3dState) -> void {
if render3d_st.gd_file != null { file_close(render3d_st.gd_file); render3d_st.gd_file = null }
render3d_st.gb_layer = -1 # a layer index means nothing in the next map's file (grass_density_layer)
}
# the slot holding layer l's tile t, read in when it is not (a clock, as the terrain's tiles)

View file

@ -60,6 +60,8 @@ import "grass_kind.ludic"
import "grass_bind.ludic"
import "grass.ludic"
import "grass_gpu.ludic"
import "grass_density.ludic"
import "grass_rule.ludic"
import "water.ludic"
import "streamline.ludic"
import "render.ludic"

View file

@ -27,20 +27,22 @@ layout(set = 0, binding = 0) uniform Params {
vec4 gk_clump; // clump_lo, clump_hi, arch, arch_var
vec4 gk_ground; // slope_lo, slope_hi, snow_in, snow_out
vec4 gk_ortho; // ortho_floor, ortho_gain, ortho_green, cell (m)
vec4 bd; // the painted density's window (grass_density.ludic): world x/z of its corner, m a texel, texels a side (0: the rules)
} 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; // the height (terrain's u_ts_height)
layout(set = 0, binding = 5) uniform sampler2D u_ortho; // the photograph
layout(set = 0, binding = 6) uniform sampler2D u_ter_normal; // the normal: x and z, y rebuilt
layout(set = 0, binding = 4) readonly buffer Dens { uint dens[]; }; // the density's window, four texels a word
layout(set = 0, binding = 5) uniform sampler2D u_height; // the height (terrain's u_ts_height)
layout(set = 0, binding = 6) uniform sampler2D u_ortho; // the photograph
layout(set = 0, binding = 7) uniform sampler2D u_ter_normal; // the normal: x and z, y rebuilt
// the page pool (terrain_pages.ludic): while it is on, the three above are the coarse whole-map level
// and the tiles round the camera are layers of these, addressed through the page table
layout(set = 0, binding = 7) uniform sampler2D u_tp_page; // slot + 1 per tile, 0 = coarse
layout(set = 0, binding = 8) uniform sampler2DArray u_tp_h; // (T + 2)^2 a layer, a texel of border
layout(set = 0, binding = 9) uniform sampler2DArray u_tp_nrm;
layout(set = 0, binding = 10) uniform sampler2DArray u_tp_ortho; // (S + 2)^2 a layer
layout(set = 0, binding = 8) uniform sampler2D u_tp_page; // slot + 1 per tile, 0 = coarse
layout(set = 0, binding = 9) uniform sampler2DArray u_tp_h; // (T + 2)^2 a layer, a texel of border
layout(set = 0, binding = 10) uniform sampler2DArray u_tp_nrm;
layout(set = 0, binding = 11) uniform sampler2DArray u_tp_ortho; // (S + 2)^2 a layer
#define CELL pr.gk_ortho.w // the kind's cell (m)
@ -91,6 +93,34 @@ float heightSmooth(vec2 uv) {
return (groundH(vec2(o0.x, o0.y)) * s0.x + groundH(vec2(o1.x, o0.y)) * s1.x) * s0.y
+ (groundH(vec2(o0.x, o1.y)) * s0.x + groundH(vec2(o1.x, o1.y)) * s1.x) * s1.y;
}
// the painted density, 0..1, bilinear over texel centres as ground_density_at reads it on the CPU
float densTexel(ivec2 t, int side) {
t = clamp(t, ivec2(0), ivec2(side - 1));
uint k = uint(t.y * side + t.x);
return float((dens[k >> 2u] >> ((k & 3u) * 8u)) & 0xFFu) * (1.0 / 255.0);
}
float bladeDensity(vec2 xz) {
int side = int(pr.bd.w + 0.5);
vec2 f = (xz - pr.bd.xy) / pr.bd.z - 0.5;
vec2 i0 = floor(f);
vec2 a = f - i0;
ivec2 i = ivec2(i0);
float lo = mix(densTexel(i, side), densTexel(i + ivec2(1, 0), side), a.x);
float hi = mix(densTexel(i + ivec2(0, 1), side), densTexel(i + ivec2(1, 1), side), a.x);
return mix(lo, hi, a.y);
}
// today's rules, where no density is painted: the slope, the water's margin, the snow line, the photograph
// (grass_rule_at in grass_density.ludic is this on the CPU, for the migration that paints them: keep in step)
float bladeRules(vec2 xz, vec2 huv, float y, vec3 gn) {
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(pr.gk_ground.x, pr.gk_ground.y, 1.0 - gn.y)) * smoothstep(0.0, 0.6, y - wl - 0.15) * smoothstep(pr.dens.w - pr.gk_ground.z, pr.dens.w - pr.gk_ground.w, y);
if (pr.lev.z > 0.5) {
vec3 oc = groundO(huv);
ok *= pr.gk_ortho.x + pr.gk_ortho.y * smoothstep(0.0, pr.gk_ortho.z, oc.g - oc.b);
}
return ok;
}
// 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;
@ -136,13 +166,8 @@ void main() {
vec2 gxz = groundN(huv);
vec3 gn = vec3(gxz.x, sqrt(max(1.0 - dot(gxz, gxz), 0.0)), gxz.y);
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(pr.gk_ground.x, pr.gk_ground.y, 1.0 - gn.y)) * smoothstep(0.0, 0.6, ht.r - wl - 0.15) * smoothstep(pr.dens.w - pr.gk_ground.z, pr.dens.w - pr.gk_ground.w, ht.r);
if (pr.lev.z > 0.5) {
vec3 oc = groundO(huv);
ok *= pr.gk_ortho.x + pr.gk_ortho.y * smoothstep(0.0, pr.gk_ortho.z, oc.g - oc.b);
}
// where it grows: the map's painted density when it has one, else the rules; the hashes stay procedural
float ok = pr.bd.w > 0.5 ? bladeDensity(xz) : bladeRules(xz, huv, ht.r, gn);
if (h4 > ok) return;
float h = heightSmooth(huv);
float seed = hv.x * 0.7 + hv.y * 0.3;