feat(render3d): texture filtering and shadow resolution a game can change while it runs

- r3d_set_anisotropy(level) updates every mipmapped texture already loaded (OpenGL parameter,
  Vulkan sampler record), not only later uploads; the game's setting never reached the scanned
  materials, which load before the settings are read.
- shadow_set_res(size) remakes the cascades at 1024 / 2048 / 4096 (shadow_res replaces the
  SHADOW_RES constant); lighting.glsl reads the texel size from the map, so OpenGL frames at
  2048 are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 14:25:55 +03:00
parent c1eb5f399f
commit 5b28063b52
18 changed files with 72 additions and 24 deletions

View file

@ -0,0 +1,8 @@
bump: minor
type: feat
**Texture filtering and shadow resolution as settings a game can change while it runs.**
- `r3d_set_anisotropy(level)` sets the anisotropic filtering of every mipmapped texture already
loaded, not only of later uploads, on OpenGL and Vulkan.
- `shadow_set_res(size)` remakes the cascades' depth layers at 1024, 2048 or 4096 (`shadow_res`
replaces the `SHADOW_RES` constant); the lighting reads the texel size from the map.

View file

@ -109,7 +109,7 @@ float shadowSample(int c, vec3 wpos, float biasWorld) {
vec3 p = lp.xyz / lp.w * 0.5 + 0.5;
if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) return 1.0;
float bias = biasWorld / cascadeRange(c);
float texel = 1.0 / 2048.0;
float texel = 1.0 / float(textureSize(u_shadow, 0).x); // the shadow resolution setting
float r = ign(gl_FragCoord.xy) * 6.2831853;
float cs = cos(r), sn = sin(r);
mat2 rot = mat2(cs, sn, -sn, cs);

View file

@ -4,7 +4,8 @@
# of the camera frustum and snapped to its own texel grid (no swimming).
# ============================================================================
const SHADOW_RES: int = 2048
# the size of each cascade's depth layer; shadow_set_res changes it at run time
var shadow_res: int = 2048
const SHADOW_CASCADES: int = 5
var sh_tex: int = 0
@ -21,19 +22,7 @@ var sh_corner: words = null
var sh_cascade: int = 0 # the cascade being rendered (for casters that skip far ones)
function shadow_init() -> void {
sh_tex = gpu_tex_new()
gpu_tex_bind(GPU_TEX2D_ARRAY, sh_tex)
gpu_tex_image3d(GL_DEPTH_COMPONENT32F, SHADOW_RES, SHADOW_RES, SHADOW_CASCADES, GL_DEPTH_COMPONENT, GL_FLOAT, null)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL)
let border = gl_floats(4)
gl_put(border, 0, 1.0); gl_put(border, 1, 1.0); gl_put(border, 2, 1.0); gl_put(border, 3, 1.0)
gpu_tex_border(GPU_TEX2D_ARRAY, border)
free(border)
shadow_make_tex()
sh_fbo = gpu_fb_new()
gpu_fb_bind(sh_fbo)
gpu_fb_no_color()
@ -48,6 +37,33 @@ function shadow_init() -> void {
sh_corner = words(3)
}
# A shadow resolution setting: 1024, 2048 or 4096 per cascade. The depth layers are made again at
# the new size; the pass attaches a layer per cascade every frame, and the lighting reads the
# texel size from the map itself, so nothing else has to follow.
function shadow_set_res(r: int) -> void {
if r < 256 or r == shadow_res { return }
shadow_res = r
if sh_tex == 0 { return }
gpu_tex_free(sh_tex)
shadow_make_tex()
}
function shadow_make_tex() -> void {
sh_tex = gpu_tex_new()
gpu_tex_bind(GPU_TEX2D_ARRAY, sh_tex)
gpu_tex_image3d(GL_DEPTH_COMPONENT32F, shadow_res, shadow_res, SHADOW_CASCADES, GL_DEPTH_COMPONENT, GL_FLOAT, null)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL)
let border = gl_floats(4)
gl_put(border, 0, 1.0); gl_put(border, 1, 1.0); gl_put(border, 2, 1.0); gl_put(border, 3, 1.0)
gpu_tex_border(GPU_TEX2D_ARRAY, border)
free(border)
}
# light view-projection for the camera-frustum slice [near, far]
function shadow_fit(c: int, near: int, far: int) -> void {
# Fit the slice in VIEW space, not world space. The bounding sphere of a frustum
@ -92,7 +108,7 @@ function shadow_fit(c: int, near: int, far: int) -> void {
let up = v3_new(F_ZERO, F_ONE, F_ZERO)
m4_look_at(sh_tmp_view, eye, center, up)
# snap the ortho window to the shadow texel grid
let texel = f_div(f_mul(radius, F_TWO), fi(SHADOW_RES))
let texel = f_div(f_mul(radius, F_TWO), fi(shadow_res))
m4_xform_point(sh_corner, sh_tmp_view, center[0], center[1], center[2])
let ox = f_sub(f_mul(f_floor(f_div(sh_corner[0], texel)), texel), sh_corner[0])
let oy = f_sub(f_mul(f_floor(f_div(sh_corner[1], texel)), texel), sh_corner[1])
@ -114,7 +130,7 @@ function shadow_cascade_vp(c: int) -> words { return mem_off(sh_vp, c * 64) }
function shadow_pass() -> void {
var near = cam_near
gpu_fb_bind(sh_fbo)
gpu_viewport(0, 0, SHADOW_RES, SHADOW_RES)
gpu_viewport(0, 0, shadow_res, shadow_res)
gpu_depth_test(true)
gpu_depth_func(GL_LESS)
gpu_depth_bias(2.0, 4.0)
@ -144,7 +160,7 @@ function shadow_pass() -> void {
m4_xform_point(q, vp, sh_probe_x, sh_probe_y, sh_probe_z)
print(`probe base ndc {f_fx(q[0])} {f_fx(q[1])} {f_fx(q[2])}`)
m4_xform_point(q, vp, sh_probe_x, f_add(sh_probe_y, fi(15)), sh_probe_z)
print(`probe top ndc {f_fx(q[0])} {f_fx(q[1])} {f_fx(q[2])} -> map texel {f_to_int(f_mul(f_add(f_mul(q[0], F_HALF), F_HALF), fi(SHADOW_RES)))} {f_to_int(f_mul(f_add(f_mul(q[1], F_HALF), F_HALF), fi(SHADOW_RES)))}`)
print(`probe top ndc {f_fx(q[0])} {f_fx(q[1])} {f_fx(q[2])} -> map texel {f_to_int(f_mul(f_add(f_mul(q[0], F_HALF), F_HALF), fi(shadow_res)))} {f_to_int(f_mul(f_add(f_mul(q[1], F_HALF), F_HALF), fi(shadow_res)))}`)
# where the top's shadow lands on the ground: walk down the sun ray
let gx = f_sub(f_add(sh_probe_x, F_ZERO), f_mul(sun_dir[0], f_div(fi(15), sun_dir[1])))
let gz = f_sub(sh_probe_z, f_mul(sun_dir[2], f_div(fi(15), sun_dir[1])))
@ -172,7 +188,7 @@ var sh_probe_z: int = 0
# Debug: cascade depths as grey PPMs (build/dbg_shadow_<c>.ppm)
function shadow_dump() -> void {
let n = SHADOW_RES * SHADOW_RES
let n = shadow_res * shadow_res
let buf = words(n * SHADOW_CASCADES)
gpu_tex_bind(GPU_TEX2D_ARRAY, sh_tex)
gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_COMPARE_MODE, GL_NONE)
@ -182,13 +198,13 @@ function shadow_dump() -> void {
let vp = shadow_cascade_vp(2)
let q = words(3)
m4_xform_point(q, vp, sh_probe_x, f_add(sh_probe_y, fi(12)), sh_probe_z)
let tx = f_to_int(f_mul(f_add(f_mul(q[0], F_HALF), F_HALF), fi(SHADOW_RES)))
let ty = f_to_int(f_mul(f_add(f_mul(q[1], F_HALF), F_HALF), fi(SHADOW_RES)))
let tx = f_to_int(f_mul(f_add(f_mul(q[0], F_HALF), F_HALF), fi(shadow_res)))
let ty = f_to_int(f_mul(f_add(f_mul(q[1], F_HALF), F_HALF), fi(shadow_res)))
let want = f_add(f_mul(q[2], F_HALF), F_HALF)
print(`probe (12 m up) texel {tx} {ty} card depth {f_fx(f_mul(want, fi(1000)))}/1000`)
for dy in 0 .. 5 {
let yy = ty - 40 + dy * 20
print(` row {yy}: {f_fx(f_mul(buf[2 * n + yy * SHADOW_RES + tx - 20], fi(1000)))} {f_fx(f_mul(buf[2 * n + yy * SHADOW_RES + tx], fi(1000)))} {f_fx(f_mul(buf[2 * n + yy * SHADOW_RES + tx + 20], fi(1000)))} /1000`)
print(` row {yy}: {f_fx(f_mul(buf[2 * n + yy * shadow_res + tx - 20], fi(1000)))} {f_fx(f_mul(buf[2 * n + yy * shadow_res + tx], fi(1000)))} {f_fx(f_mul(buf[2 * n + yy * shadow_res + tx + 20], fi(1000)))} /1000`)
}
free(q)
}
@ -203,10 +219,10 @@ function shadow_dump() -> void {
let f = file_open(`build/dbg_shadow_{c}.ppm`, "wb")
let hdr = `P6\n{sm} {sm}\n255\n`
file_write(f, hdr, len(hdr))
let st = SHADOW_RES / sm
let st = shadow_res / sm
for y in 0 .. sm {
for x in 0 .. sm {
let d = buf[c * n + (y * st) * SHADOW_RES + x * st]
let d = buf[c * n + (y * st) * shadow_res + x * st]
let g = f_to_int(f_mul(f_clamp(f_div(f_sub(d, lo), f_max(f_sub(hi, lo), fl(0.0001))), F_ZERO, F_ONE), fi(255)))
row[x * 3] = g; row[x * 3 + 1] = g; row[x * 3 + 2] = g
}

View file

@ -18,6 +18,30 @@ var tex_depth: int = 0 # bits per sample (8 or 16)
var tex_file_len: int = 0
var tex_anisotropy: fixed = 16.0
# The anisotropic filtering level for every mipmapped texture, loaded or not: 1 (off), 2, 4, 8
# or 16. Textures uploaded before a change are updated in place - on OpenGL by setting the
# parameter again, on Vulkan by rewriting the record the sampler cache reads - so a settings
# screen can offer it live instead of on the next start.
function r3d_set_anisotropy(level: int) -> void {
var a: fixed = 1.0
if level >= 2 { a = 2.0 }
if level >= 4 { a = 4.0 }
if level >= 8 { a = 8.0 }
if level >= 16 { a = 16.0 }
if a == tex_anisotropy { return }
tex_anisotropy = a
if gpu_tx == null { return }
let keep = gpu_bound_2d
for t in 1 .. gpu_tx_cap {
let o = t * GPU_TX_W
# a 2D texture with mipmaps that was given a level when it was uploaded
if gpu_tx[o] != GPU_TEX2D or gpu_tx[o + 10] != 1 or gpu_tx[o + 11] == 0 { continue }
gpu_tex_bind(GPU_TEX2D, t)
gpu_tex_paramf(GPU_TEX2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, a)
}
if keep > 0 { gpu_tex_bind(GPU_TEX2D, keep) }
}
function r3d_read_file(path: pointer) -> pointer {
let f = file_open(path, "rb")
if f == null { return null }