render3d: grass in chunks of 256 tiles a draw, flower casters only into the cascades they reach

Grass (Vulkan with multi-draw indirect): every visible tile is a record in one buffer, uploaded once a
frame, and each band draws its records 256 at a time. A record's firstInstance is its place in the
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. OpenGL is unchanged.

Casters: a LOD level with no impostor is drawn into a shadow cascade only when its distance band,
widened by six times its height, the camera's height over the ground and the frustum's corner reach,
can touch that cascade's receivers. The flowers' mesh levels (6 - 30 m) leave the three outer
cascades. R3D_CAST_ALL=1 draws every level everywhere. OpenGL frames byte-identical at all five
viewpoints; alpha-tested shadow draws at a 460 -> 244.

gpu_has_mdi() guards both this and the GPU-culled trees' multi-record draws.

Camp: Mac Vulkan 2645 -> 2191 (grass) -> 2034 draws; PC 2657 -> 2046, self-tests 59/59, validation 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 16:58:53 +03:00
parent d8300a7bf9
commit cdfffa62bb
9 changed files with 202 additions and 9 deletions

View file

@ -516,6 +516,8 @@ function gpu_buffer_free(buf: int) -> void {
# a caller keeps its CPU path. A compute program's binding 0 is its parameter block (params, # a caller keeps its CPU path. A compute program's binding 0 is its parameter block (params,
# copied at the dispatch); bindings 1.. are `bufs`, gpu buffers. # copied at the dispatch); bindings 1.. are `bufs`, gpu buffers.
function gpu_has_compute() -> bool { return gpu_kind == GPU_VK } function gpu_has_compute() -> bool { return gpu_kind == GPU_VK }
# several records in one indirect draw, each with its own firstInstance
function gpu_has_mdi() -> bool { return gpu_kind == GPU_VK and gvk_has_mdi }
function gpu_compute(name: string, n_bufs: int) -> int { function gpu_compute(name: string, n_bufs: int) -> int {
if gpu_kind != GPU_VK { return 0 } if gpu_kind != GPU_VK { return 0 }
return gvk_compute_new(name, n_bufs) return gvk_compute_new(name, n_bufs)

View file

@ -20,6 +20,21 @@ var grass_d0: int = 0 # the distance at which the spacing has doubled
var grass_radius: int = 0 # no blades past this (m) var grass_radius: int = 0 # no blades past this (m)
var grass_draws: int = 0 var grass_draws: int = 0
var grass_dbg: 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
# a blade: `rows` rows of 2 vertices (x across, y along, z bend), attribute 2 = uv # a blade: `rows` rows of 2 vertices (x across, y along, z bend), attribute 2 = uv
function grass_blade_mesh(rows: int) -> Mesh { function grass_blade_mesh(rows: int) -> Mesh {
@ -57,7 +72,15 @@ function grass_blade_mesh(rows: int) -> Mesh {
} }
function grass_init() -> void { function grass_init() -> void {
grass_prog = r3d_program("grass.vert", "model.frag", "#define FOLIAGE\n#define BLADE\n") grass_merge = gpu_has_mdi() and not Os.has_env("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)
grass_mesh = grass_blade_mesh(4) grass_mesh = grass_blade_mesh(4)
grass_wind = fl(2.4) grass_wind = fl(2.4)
grass_s0 = fl(0.11) grass_s0 = fl(0.11)
@ -79,7 +102,11 @@ function grass_count_at(d: int) -> int {
function grass_tiles(size: int, d_min: int, d_max: int) -> void { function grass_tiles(size: int, d_min: int, d_max: int) -> void {
let p = grass_prog let p = grass_prog
let cells = size / GRASS_CELL 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) u_i(gpu_uniform(p, "u_tile_cells"), cells)
}
let sz = fi(size) let sz = fi(size)
let half = f_mul(sz, F_HALF) let half = f_mul(sz, F_HALF)
let reach = f_add(d_max, f_mul(half, fl(1.5))) let reach = f_add(d_max, f_mul(half, fl(1.5)))
@ -102,7 +129,18 @@ function grass_tiles(size: int, d_min: int, d_max: int) -> void {
let cy = terrain_height(cx, cz) let cy = terrain_height(cx, cz)
if cam_sphere_visible(cx, cy, cz, f_add(corner_r, fi(6))) { if cam_sphere_visible(cx, cy, cz, f_add(corner_r, fi(6))) {
let per = grass_count_at(dnear) let per = grass_count_at(dnear)
if per > 0 { 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_f2(gpu_uniform(p, "u_tile"), ox, oz)
u_i(gpu_uniform(p, "u_per_cell"), per) u_i(gpu_uniform(p, "u_per_cell"), per)
mesh_draw_instanced(grass_mesh, per * cells * cells) mesh_draw_instanced(grass_mesh, per * cells * cells)
@ -152,9 +190,35 @@ function grass_draw() -> void {
u_f(gpu_uniform(p, "u_spec_scale"), fl(0.15)) u_f(gpu_uniform(p, "u_spec_scale"), fl(0.15))
gpu_cull(false) gpu_cull(false)
grass_draws = 0 grass_draws = 0
grass_n = 0
grass_band_n = 0
gpu_mesh_bind(grass_mesh) gpu_mesh_bind(grass_mesh)
grass_tiles(16, F_ZERO, fi(300)) grass_tiles(16, F_ZERO, fi(300))
grass_tiles(64, fi(300), fi(1200)) grass_tiles(64, fi(300), fi(1200))
grass_tiles(256, fi(1200), grass_radius) grass_tiles(256, fi(1200), grass_radius)
if grass_merge { grass_flush() }
gpu_cull(true) 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 }
let p = grass_prog
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] }
u_fv(gpu_uniform(p, "u_tiles"), m * 4, grass_chunk_tv)
gpu_draw_mesh_indirect(grass_mesh, grass_cmds, k * 20, m, 0, 0)
grass_draws += 1
k += m
}
}
}

View file

@ -647,7 +647,7 @@ function layer_gpu_prepare(l: Layer) -> bool {
# on by default wherever there is compute; R3D_GPU_CULL=0 keeps the CPU partition (for comparing) # on by default wherever there is compute; R3D_GPU_CULL=0 keeps the CPU partition (for comparing)
var off = false var off = false
if Os.has_env("R3D_GPU_CULL") { off = Os.env("R3D_GPU_CULL") == "0" } if Os.has_env("R3D_GPU_CULL") { off = Os.env("R3D_GPU_CULL") == "0" }
if gpu_has_compute() and not off { if gpu_has_compute() and gpu_has_mdi() and not off {
sc_cull_prog = gpu_compute("scatter_cull", 4) sc_cull_prog = gpu_compute("scatter_cull", 4)
if sc_cull_prog > 0 { print("r3d: scatter: tree layers are culled on the GPU") } if sc_cull_prog > 0 { print("r3d: scatter: tree layers are culled on the GPU") }
} }
@ -945,6 +945,43 @@ var sc_dbg_blade: int = 0
var sc_dbg_level: int = -1 var sc_dbg_level: int = -1
var sc_dbg_lod: bool = false var sc_dbg_lod: bool = false
var sc_dbg_tint: words = null var sc_dbg_tint: words = null
# Can level k's casters (its instances lie between the previous level's distance and its own) put a
# shadow on anything the cascade being rendered covers? A receiver in that slice of view depth
# [near, far] stands between near - dy and far * K metres away on the ground: dy is the camera's height
# over the ground, K how far the frustum's corners reach past its depth. A prop's shadow falls at most
# about six times its height past it (the sun near ten degrees). A level outside that range cannot
# touch a pixel of the cascade, so leaving it out changes no shadow - unlike the old per-class skips
# at fixed distances, which dropped casters that did cast (see scatter_draw_casters). The flowers'
# mesh levels (6 - 30 m) stop being drawn into the three outer cascades. R3D_CAST_ALL=1 draws every
# level into every cascade, for comparing.
var sc_cast_all: int = -1
var sc_cast_gen: int = -1
var sc_cast_dy: int = 0
var sc_cast_k: int = 0
function layer_level_casts_here(l: Layer, k: int) -> bool {
if sc_cast_all < 0 { sc_cast_all = 0; if Os.has_env("R3D_CAST_ALL") { sc_cast_all = 1 } }
if sc_cast_all == 1 or sh_split == null or l.lod_dist == null { return true }
if sc_cast_gen != sc_view_gen {
sc_cast_gen = sc_view_gen
sc_cast_dy = f_add(f_abs(f_sub(cam_pos[1], terrain_height(cam_pos[0], cam_pos[2]))), fi(5))
let half = f_mul(cam_fov, F_HALF)
let t = f_div(f_sin(half), f_cos(half))
let ta = f_mul(t, cam_aspect)
sc_cast_k = f_mul(f_sqrt(f_add(F_ONE, f_add(f_mul(t, t), f_mul(ta, ta)))), fl(1.1))
}
let c = sh_cascade
var near = cam_near
if c > 0 { near = sh_split[c - 1] }
let far = sh_split[c]
var dmin = F_ZERO
if k > 0 { dmin = l.lod_dist[k - 1] }
let dmax = l.lod_dist[k] # 0: open, out to the layer's cull distance
let reach = f_max(f_min(f_mul(l.lods[k].height, fi(6)), fi(40)), fi(8))
if dmax != 0 and f_ls(f_add(f_add(dmax, reach), sc_cast_dy), near) { return false }
if f_gt(f_sub(dmin, reach), f_mul(far, sc_cast_k)) { return false }
return true
}
# `full` casts the layer's entire instance list out of sh_buf instead of the near # `full` casts the layer's entire instance list out of sh_buf instead of the near
# partition out of l.buf. A layer with no impostor (the tree crowns' branch cards) has # partition out of l.buf. A layer with no impostor (the tree crowns' branch cards) has
# no cheap stand-in to cast from, so without this its shadow simply began at the near # no cheap stand-in to cast from, so without this its shadow simply began at the near
@ -958,8 +995,12 @@ function layer_draw_near(l: Layer, shadow: bool, light_vp: words, full: bool) ->
return return
} }
if l.n_lods > 1 { if l.n_lods > 1 {
# a LOD chain: every level from its own bucket (casters are what is drawn) # a LOD chain: every level from its own bucket (casters are what is drawn), and a caster level
for k in 0 .. l.n_lods { sc_dbg_level = k; layer_draw_model(l, l.lods[k], l.lod_buf[k], l.n_lod[k], l.lod_card[k] == 1, shadow, light_vp) } # only into the cascades it can put a shadow in
for k in 0 .. l.n_lods {
if shadow and not layer_level_casts_here(l, k) { continue }
sc_dbg_level = k; layer_draw_model(l, l.lods[k], l.lod_buf[k], l.n_lod[k], l.lod_card[k] == 1, shadow, light_vp)
}
sc_dbg_level = -1 sc_dbg_level = -1
return return
} }

View file

@ -27,6 +27,9 @@ uniform float u_wind;
uniform vec2 u_tile; // world xz of this tile's corner uniform vec2 u_tile; // world xz of this tile's corner
uniform int u_tile_cells; // 16 m cells per tile side uniform int u_tile_cells; // 16 m cells per tile side
uniform int u_per_cell; // indices drawn per cell in this tile uniform int u_per_cell; // indices drawn per cell in this tile
#ifdef TILES
uniform vec4 u_tiles[256]; // per tile of the draw: corner x, corner z, indices per cell
#endif
uniform float u_s0; // blade spacing at the camera (m) 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_d0; // distance at which the spacing has doubled (m)
uniform float u_radius; // no blades past this uniform float u_radius; // no blades past this
@ -66,10 +69,21 @@ float heightSmooth(sampler2D tex, vec2 uv) {
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; } 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; }
void main() { void main() {
#ifdef TILES
// Vulkan: one draw covers a chunk of tiles. Each record's firstInstance is its place in the chunk
// times 65536 (grass.ludic), and u_tiles holds that place's corner and indices per cell.
int ti = gl_InstanceIndex / 65536;
int i = gl_InstanceIndex - ti * 65536;
vec2 tile = u_tiles[ti].xy;
int per_cell = int(u_tiles[ti].z + 0.5);
#else
int i = gl_InstanceID; int i = gl_InstanceID;
int c = i / u_per_cell; vec2 tile = u_tile;
int j = i - c * u_per_cell; int per_cell = u_per_cell;
vec2 cell = u_tile + vec2(float(c % u_tile_cells), float(c / u_tile_cells)) * CELL; #endif
int c = i / per_cell;
int j = i - c * per_cell;
vec2 cell = tile + vec2(float(c % u_tile_cells), float(c / u_tile_cells)) * CELL;
vec2 cid = floor(cell / CELL + 0.5); vec2 cid = floor(cell / CELL + 0.5);
ivec2 ci = ivec2(cid); ivec2 ci = ivec2(cid);
float fj = float(j); float fj = float(j);

Binary file not shown.

Binary file not shown.

View file

@ -647,6 +647,77 @@ T 7ed7de52 u_prefilter 8
T 7ed7de52 u_shadow 9 T 7ed7de52 u_shadow 9
T 7ed7de52 u_tershadow 10 T 7ed7de52 u_tershadow 10
T 7ed7de52 u_ts_height 11 T 7ed7de52 u_ts_height 11
P 7f096322 grass.vert model.frag #define FOLIAGE;#define BLADE;#define TILES;
B 7f096322 vert 0 4400
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_tile 264 vec2 1 0
U 7f096322 vert u_tile_cells 272 int 1 0
U 7f096322 vert u_per_cell 276 int 1 0
U 7f096322 vert u_tiles 288 vec4 256 16
U 7f096322 vert u_s0 4384 float 1 0
U 7f096322 vert u_d0 4388 float 1 0
U 7f096322 vert u_radius 4392 float 1 0
U 7f096322 vert u_dbg 4396 int 1 0
I 7f096322 a_pos 0 vec3
I 7f096322 a_uv 2 vec2
B 7f096322 frag 1 892
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_ibl_scale 640 vec3 1 0
U 7f096322 frag u_daylight 652 float 1 0
U 7f096322 frag u_fire_pos 656 vec3 1 0
U 7f096322 frag u_fire_color 672 vec3 1 0
U 7f096322 frag u_hand_pos 688 vec3 1 0
U 7f096322 frag u_hand_color 704 vec3 1 0
U 7f096322 frag u_hand_dir 720 vec3 1 0
U 7f096322 frag u_hand_cone 732 float 1 0
U 7f096322 frag u_ts_origin 736 vec2 1 0
U 7f096322 frag u_ts_half 744 float 1 0
U 7f096322 frag u_ts_on 748 float 1 0
U 7f096322 frag u_force_cascade 752 int 1 0
U 7f096322 frag u_cloud_shadow 756 float 1 0
U 7f096322 frag u_time 760 float 1 0
U 7f096322 frag u_model_h 764 float 1 0
U 7f096322 frag u_view 768 mat4 1 0
U 7f096322 frag u_tint 832 vec3 1 0
U 7f096322 frag u_rough_scale 844 float 1 0
U 7f096322 frag u_emissive 848 float 1 0
U 7f096322 frag u_blade_base 864 vec3 1 0
U 7f096322 frag u_blade_tip 880 vec3 1 0
T 7f096322 u_arm 2
T 7f096322 u_brdf 3
T 7f096322 u_diff 4
T 7f096322 u_irradiance 5
T 7f096322 u_nrm 6
T 7f096322 u_ortho 7
T 7f096322 u_prefilter 8
T 7f096322 u_shadow 9
T 7f096322 u_tershadow 10
T 7f096322 u_ts_height 11
P 3733fc38 impostor.vert impostor.frag P 3733fc38 impostor.vert impostor.frag
B 3733fc38 vert 0 232 B 3733fc38 vert 0 232
U 3733fc38 vert u_view 0 mat4 1 0 U 3733fc38 vert u_view 0 mat4 1 0

View file

@ -17,6 +17,7 @@ fullscreen.vert|ternormal.frag|
fullscreen.vert|tershadow.frag|#define NOISE_ONLY; fullscreen.vert|tershadow.frag|#define NOISE_ONLY;
fullscreen.vert|tonemap.frag| fullscreen.vert|tonemap.frag|
grass.vert|model.frag|#define FOLIAGE;#define BLADE; grass.vert|model.frag|#define FOLIAGE;#define BLADE;
grass.vert|model.frag|#define FOLIAGE;#define BLADE;#define TILES;
impostor.vert|impostor.frag| impostor.vert|impostor.frag|
impostor.vert|impostor.frag|#define SHADOW_PASS; impostor.vert|impostor.frag|#define SHADOW_PASS;
model.vert|bake.frag| model.vert|bake.frag|