render3d: the fog wall culls on the CPU - nothing past it costs a draw, a vertex or a caster

cam_sphere_visible refuses a sphere wholly past the wall (terrain patches, scatter cells, stream
chunks, grass tiles, water), actors past it are skipped for draws, casters and outlines, the grass
reach and the streams' generate-and-gather reach end at it, and the card shadows cast only from
the wall's share of a layer (fog_casters.ludic, rebuilt every 4 m). r3d_beyond_fog is exported for
the game to skip animating what will not be drawn. Render-only: no query of the ground or the world
changes, and off is the old frame (0 pixels over 8 against 160ce96, twice per side).

Draws per frame at the overlook: 2757 off, 1104 at 175 m, 484 at 30 m.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 15:38:00 +03:00
parent b9f9cb8495
commit 2ce2d997be
9 changed files with 88 additions and 11 deletions

View file

@ -175,6 +175,10 @@ function ac_visible(render3d_st: Render3dState, a: Actor, shadow: bool) -> bool
if a.model == null { return false } if a.model == null { return false }
if not a.visible and not (shadow and a.cast_hidden) { return false } if not a.visible and not (shadow and a.cast_hidden) { return false }
if shadow and not a.casts { return false } if shadow and not a.casts { return false }
# an actor with no radius given is sized generously, since a building or the dock may be one
var fr = a.radius * a.scale
if fr <= 0.0 { fr = 20.0 }
if r3d_beyond_fog(render3d_st, a.x, a.y, a.z, fr) { return false }
if a.cull != 0.0 { if a.cull != 0.0 {
let dx = a.x - render3d_st.cam_pos[0]; let dz = a.z - render3d_st.cam_pos[2] let dx = a.x - render3d_st.cam_pos[0]; let dz = a.z - render3d_st.cam_pos[2]
let d2 = dx * dx + dz * dz let d2 = dx * dx + dz * dz

View file

@ -70,6 +70,7 @@ function cam_planes_update(render3d_st: mut Render3dState) -> void {
# is a sphere (float bits) at least partly inside the side planes of the view? # is a sphere (float bits) at least partly inside the side planes of the view?
function cam_sphere_visible(render3d_st: Render3dState, x: float, y: float, z: float, r: float) -> bool { function cam_sphere_visible(render3d_st: Render3dState, x: float, y: float, z: float, r: float) -> bool {
if render3d_st.cam_planes == null { return true } if render3d_st.cam_planes == null { return true }
if r3d_beyond_fog(render3d_st, x, y, z, r) { return false }
let nr = -r let nr = -r
for p in 0 .. 4 { for p in 0 .. 4 {
let o = p * 4 let o = p * 4
@ -78,6 +79,14 @@ function cam_sphere_visible(render3d_st: Render3dState, x: float, y: float, z: f
} }
return true return true
} }
# is a sphere wholly past the fog wall? Nothing there is seen, so nothing there is drawn or animated
export function r3d_beyond_fog(render3d_st: Render3dState, x: float, y: float, z: float, r: float) -> bool {
let w = render3d_st.r3d_fog_wall
if w <= 0.0 { return false }
let dx = x - render3d_st.cam_pos[0]; let dy = y - render3d_st.cam_pos[1]; let dz = z - render3d_st.cam_pos[2]
let far = w + R3D_FOG_MARGIN + r
return dx * dx + dy * dy + dz * dz > far * far
}
# fly: forward/strafe in metres, turn in radians # fly: forward/strafe in metres, turn in radians
function cam_move(render3d_st: mut Render3dState, fwd: float, strafe: float, up: float, dyaw: float, dpitch: float) -> void { function cam_move(render3d_st: mut Render3dState, fwd: float, strafe: float, up: float, dyaw: float, dpitch: float) -> void {
render3d_st.cam_yaw = render3d_st.cam_yaw + dyaw render3d_st.cam_yaw = render3d_st.cam_yaw + dyaw

View file

@ -0,0 +1,40 @@
# fog_casters.ludic — under a fog wall, a layer's cards cast shadows only from where they can be seen.
# The impostor shadow list is every instance on the map; past the wall that is a vertex bill for
# nothing, so the casters inside the wall (and a shadow's throw past it) go into a list of their own.
const FOG_CAST_THROW: float = 30.0 # the longest shadow a tree throws back toward the camera
const FOG_CAST_STEP: float = 4.0 # the camera moves this far before the list is rebuilt
# the buffer and count the card shadows draw from: the whole list, or the wall's share of it
function fog_casters(render3d_st: mut Render3dState, l: Layer) -> int {
let w = render3d_st.r3d_fog_wall
if w <= 0.0 { return l.sh_buf }
let dx = render3d_st.cam_pos[0] - l.fog_x
let dz = render3d_st.cam_pos[2] - l.fog_z
if l.fog_wall == w and l.fog_src == l.n_sh and dx * dx + dz * dz < FOG_CAST_STEP * FOG_CAST_STEP { return l.fog_buf }
l.fog_wall = w; l.fog_src = l.n_sh
l.fog_x = render3d_st.cam_pos[0]; l.fog_z = render3d_st.cam_pos[2]
let far = w + R3D_FOG_MARGIN + FOG_CAST_THROW + FOG_CAST_STEP
let far2 = far * far
let src = l.inst
let dst = l.scratch
var n = 0
let total = Math.min(l.n_sh, l.count)
for i in 0 .. total {
let o = i * INST_FLOATS
let ex = src[o] - l.fog_x
let ez = src[o + 2] - l.fog_z
if ex * ex + ez * ez > far2 { continue }
let q = n * INST_FLOATS
for k in 0 .. INST_FLOATS { dst[q + k] = src[o + k] }
n += 1
}
l.fog_n = n
if n > 0 { gpu_buffer_upload(render3d_st, l.fog_buf, n * INST_FLOATS * 4, data_of(dst), GPU_DYNAMIC) }
return l.fog_buf
}
function fog_casters_n(render3d_st: Render3dState, l: Layer) -> int {
if render3d_st.r3d_fog_wall <= 0.0 { return l.n_sh }
return l.fog_n
}

View file

@ -127,6 +127,13 @@ function grass_init(render3d_st: mut Render3dState) -> void {
} }
# indices per 16 m cell that could exist at distance d (the count the shader computes) # indices per 16 m cell that could exist at distance d (the count the shader computes)
# no blades past the fog wall: they would be drawn only to be fogged away
function grass_reach(render3d_st: Render3dState) -> float {
let w = render3d_st.r3d_fog_wall
if w > 0.0 and w < render3d_st.grass_radius { return w }
return render3d_st.grass_radius
}
function grass_count_at(render3d_st: Render3dState, d: float) -> int { function grass_count_at(render3d_st: Render3dState, d: float) -> int {
let spacing = render3d_st.grass_s0 * (1.0 + d / render3d_st.grass_d0) let spacing = render3d_st.grass_s0 * (1.0 + d / render3d_st.grass_d0)
let n = float(GRASS_CELL * GRASS_CELL) / (spacing * spacing) let n = float(GRASS_CELL * GRASS_CELL) / (spacing * spacing)
@ -137,7 +144,7 @@ function grass_count_at(render3d_st: Render3dState, d: float) -> int {
function grass_tiles(render3d_st: mut Render3dState, size: int, d_min: float, d_max: float, blade: Mesh) -> 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 p = render3d_st.grass_prog
let cells = size / GRASS_CELL let cells = size / GRASS_CELL
if d_min >= render3d_st.grass_radius { return } if d_min >= grass_reach(render3d_st) { return }
if render3d_st.grass_merge { 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_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 render3d_st.grass_band_mesh[render3d_st.grass_band_n] = blade; render3d_st.grass_band_n += 1
@ -228,11 +235,11 @@ function grass_bind(render3d_st: mut Render3dState, p: int) -> void {
u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_tint"), render3d_st.sc_blade_tint) u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_tint"), render3d_st.sc_blade_tint)
u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_blade_base"), render3d_st.sc_blade_base) u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_blade_base"), render3d_st.sc_blade_base)
u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_blade_tip"), render3d_st.sc_blade_tip) u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_blade_tip"), render3d_st.sc_blade_tip)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_cull"), render3d_st.grass_radius) u_f(render3d_st, gpu_uniform(render3d_st, p, "u_cull"), grass_reach(render3d_st))
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_model_h"), 0.0) u_f(render3d_st, gpu_uniform(render3d_st, p, "u_model_h"), 0.0)
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_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_d0"), render3d_st.grass_d0)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_radius"), render3d_st.grass_radius) u_f(render3d_st, gpu_uniform(render3d_st, p, "u_radius"), grass_reach(render3d_st))
var ph = render3d_st.post_h var ph = render3d_st.post_h
if ph <= 0 { ph = gl_height() } 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_f(render3d_st, gpu_uniform(render3d_st, p, "u_px"), 2.0 * Math.tan(render3d_st.cam_fov * 0.5) / float(ph))
@ -268,7 +275,7 @@ function grass_bands(render3d_st: mut Render3dState) -> void {
grass_tiles(render3d_st, 4, 0.0, 6.0, render3d_st.grass_mesh) 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, 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, 8, 16.0, 40.0, render3d_st.grass_mesh2)
grass_tiles(render3d_st, 16, 40.0, render3d_st.grass_radius, render3d_st.grass_mesh2) grass_tiles(render3d_st, 16, 40.0, grass_reach(render3d_st), render3d_st.grass_mesh2)
} }
# the records gathered this frame: uploaded once, then each band GRASS_CHUNK records a draw # the records gathered this frame: uploaded once, then each band GRASS_CHUNK records a draw

View file

@ -66,7 +66,7 @@ function gg_blade(render3d_st: mut Render3dState, rows: int) -> Mesh {
# one tile size over one distance band: its visible tiles into gg_tv (corner, indices per cell, cells) # 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 { function gg_tiles(render3d_st: mut Render3dState, size: int, d_min: float, d_max: float) -> void {
if d_min >= render3d_st.grass_radius { return } if d_min >= grass_reach(render3d_st) { return }
let cells = size / GRASS_CELL let cells = size / GRASS_CELL
let sz = float(size) let sz = float(size)
let half = sz * 0.5 let half = sz * 0.5
@ -102,7 +102,7 @@ function gg_cull_frame(render3d_st: mut Render3dState) -> void {
render3d_st.gg_max = 0 render3d_st.gg_max = 0
gg_tiles(render3d_st, 4, 0.0, 16.0) gg_tiles(render3d_st, 4, 0.0, 16.0)
gg_tiles(render3d_st, 8, 16.0, 40.0) gg_tiles(render3d_st, 8, 16.0, 40.0)
gg_tiles(render3d_st, 16, 40.0, render3d_st.grass_radius) gg_tiles(render3d_st, 16, 40.0, grass_reach(render3d_st))
# made once, with the device (gvk_startup_state) # made once, with the device (gvk_startup_state)
let rb = render3d_st.gg_rb let rb = render3d_st.gg_rb
rb[0] = GG_BANDS; rb[1] = 0; rb[2] = 0; rb[3] = 0 rb[0] = GG_BANDS; rb[1] = 0; rb[2] = 0; rb[3] = 0
@ -126,7 +126,7 @@ function gg_params(render3d_st: mut Render3dState) -> words {
let pr = render3d_st.gg_pr let pr = render3d_st.gg_pr
for i in 0 .. 48 { pr[i] = 0 } 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]) } } 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) 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 var ph = render3d_st.post_h
if ph <= 0 { ph = gl_height() } 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[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)

View file

@ -33,6 +33,7 @@ import "gltf_release.ludic"
import "gltf_cached.ludic" import "gltf_cached.ludic"
import "skin.ludic" import "skin.ludic"
import "scatter.ludic" import "scatter.ludic"
import "fog_casters.ludic"
import "actor.ludic" import "actor.ludic"
import "stream.ludic" import "stream.ludic"
import "grass.ludic" import "grass.ludic"

View file

@ -54,7 +54,10 @@ const R3D_FOG_MARGIN: float = 8.0
export function r3d_fog_wall(render3d_st: mut Render3dState, dist: float) -> void { export function r3d_fog_wall(render3d_st: mut Render3dState, dist: float) -> void {
var d = dist var d = dist
if d < 0.0 { d = 0.0 } if d < 0.0 { d = 0.0 }
if d == render3d_st.r3d_fog_wall { return }
render3d_st.r3d_fog_wall = d render3d_st.r3d_fog_wall = d
# every layer and stream re-gathers for the new reach
render3d_st.sc_view_gen += 1
cam_update(render3d_st) cam_update(render3d_st)
} }
# how far anything is drawn: the fog wall and a margin, or `far` (0: no limit of its own) without one # how far anything is drawn: the fog wall and a margin, or `far` (0: no limit of its own) without one

View file

@ -32,6 +32,12 @@ property Layer {
imp_buf: int = 0, imp_buf: int = 0,
sh_buf: int = 0, # every instance, for shadow casting (no cull, no LOD split) sh_buf: int = 0, # every instance, for shadow casting (no cull, no LOD split)
n_sh: int = 0, n_sh: int = 0,
fog_buf: int = 0, # the casters inside the fog wall (fog_casters.ludic)
fog_n: int = 0,
fog_src: int = -1,
fog_wall: float = 0.0,
fog_x: float = 0.0,
fog_z: float = 0.0,
n_far: int = 0, n_far: int = 0,
scratch: floats, scratch: floats,
last_cam: floats, last_cam: floats,
@ -261,6 +267,7 @@ function layer_new(render3d_st: mut Render3dState, model: Model, cap: int, folia
l.buf = gpu_buffer_new(render3d_st) l.buf = gpu_buffer_new(render3d_st)
l.imp_buf = gpu_buffer_new(render3d_st) l.imp_buf = gpu_buffer_new(render3d_st)
l.sh_buf = gpu_buffer_new(render3d_st) l.sh_buf = gpu_buffer_new(render3d_st)
l.fog_buf = gpu_buffer_new(render3d_st)
l.rough = 1.0 l.rough = 1.0
for i in 0 .. len(model.prims) { scatter_attach(render3d_st, model.prims[i].mesh, l.buf) } for i in 0 .. len(model.prims) { scatter_attach(render3d_st, model.prims[i].mesh, l.buf) }
push(render3d_st.sc_layers, l) push(render3d_st.sc_layers, l)
@ -983,8 +990,11 @@ function layer_draw_shadow(render3d_st: mut Render3dState, l: Layer, light_vp: f
u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_face_dir"), render3d_st.sun_dir) u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_face_dir"), render3d_st.sun_dir)
u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_cam_pos"), render3d_st.cam_pos) u_v3(render3d_st, gpu_uniform(render3d_st, p, "u_cam_pos"), render3d_st.cam_pos)
gpu_cull(render3d_st, false) gpu_cull(render3d_st, false)
scatter_attach(render3d_st, render3d_st.sc_card, l.sh_buf) let buf = fog_casters(render3d_st, l)
mesh_draw_instanced(render3d_st, render3d_st.sc_card, l.n_sh) let n = fog_casters_n(render3d_st, l)
if n == 0 { return }
scatter_attach(render3d_st, render3d_st.sc_card, buf)
mesh_draw_instanced(render3d_st, render3d_st.sc_card, n)
} }
@ -1196,6 +1206,7 @@ function scatter_clear_all(render3d_st: mut Render3dState) -> void {
if l.buf != 0 { gpu_buffer_free(render3d_st, l.buf) } if l.buf != 0 { gpu_buffer_free(render3d_st, l.buf) }
if l.imp_buf != 0 { gpu_buffer_free(render3d_st, l.imp_buf) } if l.imp_buf != 0 { gpu_buffer_free(render3d_st, l.imp_buf) }
if l.sh_buf != 0 { gpu_buffer_free(render3d_st, l.sh_buf) } if l.sh_buf != 0 { gpu_buffer_free(render3d_st, l.sh_buf) }
if l.fog_buf != 0 { gpu_buffer_free(render3d_st, l.fog_buf) }
if l.lod_buf != null { for k in 0 .. l.n_lods { gpu_buffer_free(render3d_st, l.lod_buf[k]) } } if l.lod_buf != null { for k in 0 .. l.n_lods { gpu_buffer_free(render3d_st, l.lod_buf[k]) } }
if l.inst != null { free(l.inst) } if l.inst != null { free(l.inst) }
if l.scratch != null { free(l.scratch) } if l.scratch != null { free(l.scratch) }

View file

@ -221,7 +221,9 @@ function stream_update(render3d_st: mut Render3dState, s: Stream, cam_x: float,
render3d_st.stream_walks += 1 render3d_st.stream_walks += 1
render3d_st.stream_walk_no += 1 render3d_st.stream_walk_no += 1
let tw = gl_now_us() let tw = gl_now_us()
let r = int(s.reach / s.size) + 1 # past the fog wall nothing is generated, gathered or kept wanted
let reach = r3d_reach(render3d_st, s.reach)
let r = int(reach / s.size) + 1
# rings outward from the camera's cell: the nearest chunks are generated first # rings outward from the camera's cell: the nearest chunks are generated first
var ring = 0 var ring = 0
while ring <= r { while ring <= r {
@ -236,7 +238,7 @@ function stream_update(render3d_st: mut Render3dState, s: Stream, cam_x: float,
let dx = wx - cam_x; let dz = wz - cam_z let dx = wx - cam_x; let dz = wz - cam_z
let d = Math.sqrt(dx * dx + dz * dz) let d = Math.sqrt(dx * dx + dz * dz)
let band = stream_band(s, d) let band = stream_band(s, d)
if band < 4 and band >= s.min_band and d < s.reach + s.size { if band < 4 and band >= s.min_band and d < reach + s.size {
let key = stream_key(cx, cz, band) let key = stream_key(cx, cz, band)
var c = stream_find(s, key) var c = stream_find(s, key)
if c != null { c.used = render3d_st.stream_walk_no } if c != null { c.used = render3d_st.stream_walk_no }