ludic/packages/ludic.render3d/post.ludic
Orkuncakilkaya ae52e9c4da feat(render3d): anti-aliasing that exists, and a lens for the viewfinder
The shipping default had NO anti-aliasing at all: the temporal resolve was removed, the
setting's first option went on saying "Temporal", and MSAA defaults to one sample, so
every machine without DLSS drew grass and needle cards with nothing smoothing an edge.

FXAA in the sharpen pass, which already reads the neighbourhood and runs last on the LDR
image. No history, so it cannot drag or smear a reflection. 25.5% less single-pixel
staircase on edge pixels.

The trap, recorded because it inverted the result: the unsharp delta must be computed
from the RAW image and only then applied to the anti-aliased colour. Centre from FXAA and
neighbours from the raw texture measures half smoothing and half signal, so the mask
sharpens precisely what FXAA softened - 29% WORSE than no anti-aliasing at all.

Depth of field for the photo mode: a disc whose radius is the circle of confusion,
normalised by focus distance so a landscape shot is not a macro, with taps rejected
unless they are at least as out of focus as the pixel they blur into - which is what
stops a sharp foreground haloing into a blurred background. Between the scene and the
bloom, so a blurred highlight still blooms. Skipped whole when the aperture is shut.

400 frames in ordinary play: GL 7.1 s, VK 7.2 s - the lens does not draw there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 17:38:25 +03:00

457 lines
21 KiB
Text

# ============================================================================
# post.ludic — the HDR frame and what happens to it: a 16-bit float scene
# target, a mip-chain bloom (13-tap down, tent up), and the tonemap composite
# (exposure, ACES, vignette, saturation, contrast, dither) to the screen.
# ============================================================================
const BLOOM_LEVELS: int = 6
var post_hdr: Target = null
var post_ms_fbo: int = 0 # 4x multisampled scene target, resolved into post_hdr
var post_ms_samples: int = 1 # temporal AA carries the edges; R3D_MSAA=n to compare
var post_bloom: []Target = null
var post_p_down: int = 0
var post_p_up: int = 0
var post_p_tone: int = 0
var post_fs: Mesh = null
var post_exposure: int = 0
var post_bloom_strength: int = 0
var post_vignette: int = 0
var post_saturation: int = 0
var post_contrast: int = 0
var post_w: int = 0
var post_h: int = 0
var post_auto: bool = true
var post_key: int = 0 # target mean luminance after exposure (float bits)
var post_lum: words = null
var post_mips: int = 0
var post_adapt: int = 0 # smoothed exposure (float bits)
var post_exposure_max: int = 0x41A00000 # 20: the ceiling auto-exposure may reach (night lowers it)
# ---- the grade ------------------------------------------------------------------------------
# White balance, the shadows' floor and the highlights' gain. These were nine literals bound at
# the draw, so the game had the same colour at seven in the morning as at one in the afternoon -
# every knob a grade needs, and no hand on any of them. daylight.ludic owns them now and writes
# them from the sun's own elevation; the values here are what they used to be hard-coded to, so a
# program that never starts a day looks exactly as it did.
# They are plain ints rather than a v3 on purpose: a grade is written by daylight_set, which can
# run before post_init has allocated anything, and nine ints cannot be null.
var post_wb_r: int = 0x3F828F5C # 1.02
var post_wb_g: int = 0x3F800000 # 1.0
var post_wb_b: int = 0x3F7851EC # 0.97
var post_lift_r: int = 0x3B83126F # 0.004
var post_lift_g: int = 0x3B83126F # 0.004
var post_lift_b: int = 0x3C449BA6 # 0.012
var post_gain_r: int = 0x3F7D70A4 # 0.99
var post_gain_g: int = 0x3F7EB852 # 0.995
var post_gain_b: int = 0x3F800000 # 1.0
var post_ao: Target = null
var post_ao_blur: Target = null
var post_p_ao: int = 0
var post_p_ao_blur: int = 0
var post_ao_radius: int = 0
var post_ao_intensity: int = 0
var post_ao_strength: int = 0
var post_gi_strength: int = 0x3ECCCCCD # 0.4
var post_no_gi: bool = false
var post_ldr: Target = null
var post_depth_copy: Target = null
# ---- volumetric light ----------------------------------------------------------------------
# Half resolution on purpose: in-scattered light is smooth, a shaft has no sharp edge, and the
# march is the whole cost of the pass. post_vol_steps is the quality dial; 0 switches it off
# and the pass is skipped entirely rather than run at one step.
var post_vol: Target = null
var post_p_vol: int = 0
var post_vol_steps: int = 0x41C00000 # 24
var post_vol_density: int = 0x3B03126F # 0.002
var post_vol_falloff: int = 0x3BA3D70A # 0.005
var post_vol_far: int = 0x44480000 # 800 m
var post_vol_g: int = 0x3F19999A # 0.6: air throws light forward
var post_vol_mist: int = 0 # the day sets these two
var post_vol_mist_h: int = 0x42200000 # 40 m
var post_prev: Target = null # last frame's scene colour, for the SSGI bounce only
var post_scene: Target = null # this frame's scene colour before the water, for refraction
var post_frame: int = 0
var post_color_w: int = 0 # its size: the display's when DLSS upscaled it
var post_color_h: int = 0
var post_color: int = 0 # the HDR colour the rest of post reads # the resolved depth, copied so passes can read it while drawing into the frame
var post_p_sharp: int = 0
# Spatial anti-aliasing, in the sharpen pass because that pass already reads this pixel's
# neighbourhood and runs last on the LDR image. 1 on, 0 off; the game's setting drives it.
var post_fxaa: int = 0x3F800000
# ---- depth of field ------------------------------------------------------------------------
# Off in ordinary play - the pass is skipped whole, not run at zero radius. The game turns it on
# behind the viewfinder and says what to focus on.
var post_p_dof: int = 0
var post_dof: Target = null
var post_dof_focus: int = 0x41200000 # 10 m
var post_dof_aperture: int = 0 # 0 = no lens, and no pass
var post_dof_max: int = 0x41400000 # 12 px
var post_p_tone_hdr: int = 0 # the tonemap's HDR10 variant, made the first time HDR is on
var post_ldr_hdr: bool = false # post_ldr was made for HDR10 output (10-bit)
# the LDR image is 10-bit while the output is HDR10: PQ in 8 bits bands
function post_ldr_fmt() -> int { if gpu_hdr_active() { return GL_RGB10_A2 }; return GL_RGBA8 }
var post_sharpen: int = 0
var post_grain: int = 0
# the screen-sized targets go away before post_init makes them at a new size
function post_free() -> void {
if post_hdr == null { return }
if post_ms_fbo != 0 { gpu_fb_free(post_ms_fbo); post_ms_fbo = 0 }
target_free(post_hdr); target_free(post_ao); target_free(post_ao_blur); target_free(post_ldr)
target_free(post_depth_copy); target_free(post_prev); target_free(post_scene); target_free(post_vol); target_free(post_dof)
for i in 0 .. len(post_bloom) { target_free(post_bloom[i]) }
post_hdr = null
}
# Multisampled scene: 1 (temporal AA alone), 2 or 4, remade at once. Vulkan draws it into
# multisampled renderbuffers and resolves them in a pass; a device that cannot take the count asked
# for gets the most it can (gpu_msaa_max).
function post_msaa_live() -> bool { return gpu_is_gl() or gpu_msaa_max() > 1 }
function post_set_msaa(n: int) -> void {
var want = n
if want < 1 { want = 1 }
if not post_msaa_live() { want = 1 }
if want > gpu_msaa_max() and gpu_msaa_max() >= 1 { want = gpu_msaa_max() }
if want == post_ms_samples { return }
post_ms_samples = want
if post_hdr != null {
let w = post_w; let h = post_h
post_free()
post_init(w, h)
}
}
function post_init(w: int, h: int) -> void {
post_w = w; post_h = h
post_hdr = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, true, GL_LINEAR)
if post_ms_samples > 1 {
post_ms_fbo = gpu_fb_new()
gpu_fb_bind(post_ms_fbo)
let rbc = gpu_rb_new()
gpu_rb_storage(rbc, GL_RGBA16F, w, h, post_ms_samples)
gpu_fb_color_rb(0, rbc)
let rbd = gpu_rb_new()
gpu_rb_storage(rbd, GL_DEPTH_COMPONENT32F, w, h, post_ms_samples)
gpu_fb_depth_rb(rbd)
let st = gpu_fb_status()
if st != GL_FRAMEBUFFER_COMPLETE { print(`r3d: msaa framebuffer incomplete {st}`); post_ms_fbo = 0 }
gpu_fb_bind(0)
}
post_bloom = new []Target
var bw = w / 2; var bh = h / 2
for i in 0 .. BLOOM_LEVELS {
push(post_bloom, target_new(max(bw, 1), max(bh, 1), GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR))
bw = bw / 2; bh = bh / 2
}
if post_p_down == 0 {
post_p_down = r3d_program("fullscreen.vert", "bloom_down.frag", "")
post_p_up = r3d_program("fullscreen.vert", "bloom_up.frag", "")
post_p_tone = r3d_program("fullscreen.vert", "tonemap.frag", "")
}
# Full resolution, not half. The occlusion is reconstructed from depth differences,
# so on a surface seen at a grazing angle its gradient is steep in screen space; at
# half resolution that aliased into wide, screen-crossing bands which the bilinear
# upsample in the tonemapper then stretched over the whole ground. They read as thin
# transparent black bars, appear only where there is depth (never on the sky), and
# are nothing to do with the shadow map or the reflection.
post_ao = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
post_ao_blur = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
if post_p_ao == 0 { post_p_ao = r3d_program("fullscreen.vert", "ssgi.frag", ""); post_p_ao_blur = r3d_program("fullscreen.vert", "ssao_blur.frag", "") }
post_ldr = target_new(w, h, post_ldr_fmt(), GL_RGBA, GL_UNSIGNED_BYTE, false, GL_LINEAR)
post_ldr_hdr = gpu_hdr_active()
post_depth_copy = target_new(w, h, GL_R8, GL_RED, GL_UNSIGNED_BYTE, true, GL_NEAREST)
post_dof = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
if post_p_dof == 0 { post_p_dof = r3d_program("fullscreen.vert", "dof.frag", "") }
post_vol = target_new(max(w / 2, 1), max(h / 2, 1), GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
if post_p_vol == 0 { post_p_vol = r3d_program("fullscreen.vert", "volumetric.frag", "") }
post_prev = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
post_scene = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
if post_p_sharp == 0 { post_p_sharp = r3d_program("fullscreen.vert", "sharpen.frag", "") }
post_sharpen = fl(1.2)
post_grain = fl(0.025)
post_ao_radius = fl(0.7)
post_ao_intensity = fl(1.4)
post_ao_strength = fl(0.8)
post_fs = mesh_fullscreen()
post_exposure = fl(0.36)
post_bloom_strength = fl(0.06)
post_vignette = fl(0.35)
post_saturation = fl(1.04)
post_contrast = fl(1.12)
post_key = fl(0.19)
post_lum = words(4)
var m = 1; var sz = max(w, h)
while sz > 1 { sz = sz / 2; m += 1 }
post_mips = m
post_adapt = F_ZERO
}
# Mean scene luminance from the HDR mip chain -> exposure = key / mean, eased over
# frames. The value comes back through a pixel buffer one frame late: a direct
# glGetTexImage waits for the GPU to finish the whole frame, which serialised the
# CPU and the GPU. With the fly-camera demo that cost little (the CPU had nothing
# else to do); with the game's animals, HUD and rules on the CPU it doubled the frame
# (60 ms -> 28 ms when the read went asynchronous, measured 2026-09-09).
# ... and even that asynchronous read blocked on Apple's GL (glGetTexImage into a pixel
# buffer still synchronised the texture: 50% of the CPU's frame waiting, sampled), so
# the adaptation now stays on the GPU: a 1x1 pass (adapt.frag) eases last frame's value
# toward key / mean and the tonemapper samples it. The CPU never waits for the picture.
var post_adapt_t: []Target = null
var post_adapt_i: int = 0
var post_p_adapt: int = 0
var post_adapt_reset: bool = true
function post_measure() -> void {
gpu_tex_bind(GPU_TEX2D, post_hdr.color)
gpu_tex_param(GPU_TEX2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
gpu_tex_mips(GPU_TEX2D)
if post_adapt_t == null {
post_adapt_t = new []Target
for k in 0 .. 2 { push(post_adapt_t, target_new(1, 1, GL_R32F, GL_RED, GL_FLOAT, false, GL_NEAREST)) }
post_adapt_reset = true
}
if post_p_adapt == 0 { post_p_adapt = r3d_program("fullscreen.vert", "adapt.frag", "") }
let next = 1 - post_adapt_i
target_bind(post_adapt_t[next])
gpu_depth_test(false)
gpu_use_program(post_p_adapt)
r3d_bind_2d(post_p_adapt, "u_scene", 0, post_hdr.color)
r3d_bind_2d(post_p_adapt, "u_prev", 1, post_adapt_t[post_adapt_i].color)
u_f(gpu_uniform(post_p_adapt, "u_lod"), fi(post_mips - 1))
u_f(gpu_uniform(post_p_adapt, "u_key"), post_key)
u_f(gpu_uniform(post_p_adapt, "u_max"), post_exposure_max)
u_f(gpu_uniform(post_p_adapt, "u_rate"), fl(0.08))
var reset = F_ZERO
if post_adapt_reset { reset = F_ONE; post_adapt_reset = false }
u_f(gpu_uniform(post_p_adapt, "u_reset"), reset)
mesh_draw(post_fs)
post_adapt_i = next
gpu_tex_bind(GPU_TEX2D, post_hdr.color)
gpu_tex_param(GPU_TEX2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
}
function post_begin_scene() -> void {
target_bind(post_hdr)
if post_ms_fbo != 0 { gpu_fb_bind(post_ms_fbo); gpu_multisample(true) }
gpu_depth_test(true)
gpu_depth_func(GL_LESS)
gpu_depth_write(true)
gpu_cull(true)
gpu_cull_face(GL_BACK)
gpu_clear_color(0.0, 0.0, 0.0, 1.0)
gpu_clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
}
# resolve the multisampled scene into the plain HDR target (colour + depth)
function post_resolve() -> void {
if post_ms_fbo != 0 {
gpu_fb_bind_read(post_ms_fbo)
gpu_fb_bind_draw(post_hdr.fbo)
gpu_blit(post_w, post_h, GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
}
# the depth copy every pass after this may read while the frame is still being drawn into
gpu_fb_bind_read(post_hdr.fbo)
gpu_fb_bind_draw(post_depth_copy.fbo)
gpu_blit(post_w, post_h, GL_DEPTH_BUFFER_BIT)
gpu_fb_bind(0)
}
# There is no temporal anti-aliasing. It was reprojecting every pixel through the
# scene depth, which on water is the surface plane while the pixel's content is the
# reflection behind it — so the mirror image was fetched from the wrong place and, at
# 0.92 history, dragged several frames behind the camera as it turned. Geometry edges
# and the alpha-tested vegetation are covered by the 4x MSAA + alpha-to-coverage the
# scene already renders with, and the projection is no longer jittered, so nothing is
# left needing a temporal resolve.
# The lake bed, as drawn, before any water goes over it. Water reads this to refract and
# then absorb it, which is what makes the surface read as a body of water rather than a
# sheet laid over the ground: the bottom is seen THROUGH the water, tinted and dimmed by
# how far the light travelled, instead of being the dry terrain showing through an alpha.
function post_capture_scene() -> void {
gpu_fb_bind_read(post_hdr.fbo)
gpu_fb_bind_draw(post_scene.fbo)
gpu_blit(post_w, post_h, GL_COLOR_BUFFER_BIT)
gpu_fb_bind(post_hdr.fbo)
gpu_viewport(0, 0, post_w, post_h)
}
# Keep a copy of the finished scene colour: the SSGI bounce reads last frame's colour.
function post_capture_prev() -> void {
gpu_fb_bind_read(post_hdr.fbo)
gpu_fb_bind_draw(post_prev.fbo)
gpu_blit(post_w, post_h, GL_COLOR_BUFFER_BIT)
gpu_fb_bind(0)
post_frame += 1
}
function post_ssao_pass() -> void {
gpu_depth_test(false)
gpu_blend(false)
target_bind(post_ao)
gpu_use_program(post_p_ao)
r3d_bind_2d(post_p_ao, "u_depth", 0, post_hdr.depth)
r3d_bind_2d(post_p_ao, "u_prev_color", 1, post_prev.color)
u_f(gpu_uniform(post_p_ao, "u_frame"), fi(post_frame % 64))
u_mat4(gpu_uniform(post_p_ao, "u_inv_proj"), cam_inv_proj)
u_mat4(gpu_uniform(post_p_ao, "u_proj"), cam_proj)
u_f2(gpu_uniform(post_p_ao, "u_texel"), fr(1, post_w), fr(1, post_h))
u_f(gpu_uniform(post_p_ao, "u_radius"), post_ao_radius)
u_f(gpu_uniform(post_p_ao, "u_intensity"), post_ao_intensity)
mesh_draw(post_fs)
target_bind(post_ao_blur)
gpu_use_program(post_p_ao_blur)
r3d_bind_2d(post_p_ao_blur, "u_ao", 0, post_ao.color)
r3d_bind_2d(post_p_ao_blur, "u_depth", 1, post_hdr.depth)
u_f2(gpu_uniform(post_p_ao_blur, "u_texel"), fr(1, post_ao.w), fr(1, post_ao.h))
mesh_draw(post_fs)
}
# The march, then the composite. It is added to the scene BEFORE bloom on purpose: a shaft of
# light is a bright thing in the air and should bloom like one, and compositing it after the
# bloom pyramid would give hard-edged rays with no glow at all.
function post_volumetric_pass() -> void {
if post_vol_steps <= 0 { return }
if r3d_env_has("R3D_NOVOL") { return }
gpu_depth_test(false)
gpu_blend(false)
target_bind(post_vol)
gpu_use_program(post_p_vol)
r3d_bind_2d(post_p_vol, "u_depth", 0, post_hdr.depth)
shadow_bind(post_p_vol)
sky_bind_lighting(post_p_vol)
sky_bind_rot(post_p_vol)
fog_bind(post_p_vol)
u_mat4(gpu_uniform(post_p_vol, "u_inv_vp"), cam_inv_vp)
u_v3(gpu_uniform(post_p_vol, "u_cam_pos"), cam_pos)
u_v3(gpu_uniform(post_p_vol, "u_sun_dir"), sun_dir)
u_v3(gpu_uniform(post_p_vol, "u_sun_color"), sun_color)
u_f(gpu_uniform(post_p_vol, "u_vol_steps"), post_vol_steps)
u_f(gpu_uniform(post_p_vol, "u_vol_density"), post_vol_density)
u_f(gpu_uniform(post_p_vol, "u_vol_falloff"), post_vol_falloff)
u_f(gpu_uniform(post_p_vol, "u_vol_far"), post_vol_far)
u_f(gpu_uniform(post_p_vol, "u_vol_g"), post_vol_g)
u_f(gpu_uniform(post_p_vol, "u_vol_mist"), post_vol_mist)
u_f(gpu_uniform(post_p_vol, "u_vol_mist_h"), post_vol_mist_h)
mesh_draw(post_fs)
# Composite into the HDR scene with the bloom pyramid's own upsample - a 3x3 tent under
# ONE/ONE blending, which is exactly what is wanted here and already exists, rather than a
# blit shader written for one caller. Into post_hdr, not post_scene: post_scene is the copy
# the water refracts, so adding shafts there would put them UNDER the lake.
target_bind(post_hdr)
gpu_blend(true)
gpu_blend_func(GL_ONE, GL_ONE)
gpu_use_program(post_p_up)
r3d_bind_2d(post_p_up, "u_src", 0, post_vol.color)
u_f2(gpu_uniform(post_p_up, "u_texel"), fr(1, post_vol.w), fr(1, post_vol.h))
u_f(gpu_uniform(post_p_up, "u_radius"), F_ONE)
mesh_draw(post_fs)
gpu_blend(false)
}
# The lens, between the scene and the bloom: a blurred highlight should still bloom, and a
# bloom smeared by the lens afterwards would be a halo round nothing.
function post_dof_pass() -> void {
if post_dof_aperture == 0 { return }
gpu_depth_test(false)
gpu_blend(false)
target_bind(post_dof)
gpu_use_program(post_p_dof)
r3d_bind_2d(post_p_dof, "u_src", 0, post_color)
r3d_bind_2d(post_p_dof, "u_depth", 1, post_hdr.depth)
u_mat4(gpu_uniform(post_p_dof, "u_inv_proj"), cam_inv_proj)
u_f2(gpu_uniform(post_p_dof, "u_texel"), fr(1, post_w), fr(1, post_h))
u_f(gpu_uniform(post_p_dof, "u_focus"), post_dof_focus)
u_f(gpu_uniform(post_p_dof, "u_aperture"), post_dof_aperture)
u_f(gpu_uniform(post_p_dof, "u_max_coc"), post_dof_max)
mesh_draw(post_fs)
post_color = post_dof.color
}
function post_bloom_pass() -> void {
gpu_depth_test(false)
gpu_blend(false)
var src = post_color
var sw = post_color_w; var sh = post_color_h
gpu_use_program(post_p_down)
for i in 0 .. BLOOM_LEVELS {
let t = post_bloom[i]
target_bind(t)
r3d_bind_2d(post_p_down, "u_src", 0, src)
u_f2(gpu_uniform(post_p_down, "u_texel"), fr(1, sw), fr(1, sh))
var th = f_neg1()
if i == 0 { th = fl(1.2) }
u_f(gpu_uniform(post_p_down, "u_threshold"), th)
mesh_draw(post_fs)
src = t.color; sw = t.w; sh = t.h
}
gpu_use_program(post_p_up)
gpu_blend(true)
gpu_blend_func(GL_ONE, GL_ONE)
var i = BLOOM_LEVELS - 1
while i > 0 {
let from = post_bloom[i]
let to = post_bloom[i - 1]
target_bind(to)
r3d_bind_2d(post_p_up, "u_src", 0, from.color)
u_f2(gpu_uniform(post_p_up, "u_texel"), fr(1, from.w), fr(1, from.h))
u_f(gpu_uniform(post_p_up, "u_radius"), F_ONE)
mesh_draw(post_fs)
i -= 1
}
gpu_blend(false)
}
function post_tonemap(color_tex: int) -> void {
var prog = post_p_tone
if gpu_hdr_active() {
if post_p_tone_hdr == 0 { post_p_tone_hdr = r3d_program("fullscreen.vert", "tonemap.frag", "#define HDR10\n") }
prog = post_p_tone_hdr
}
if post_ldr_hdr != gpu_hdr_active() {
let w = post_ldr.w; let h = post_ldr.h
target_free(post_ldr)
post_ldr = target_new(w, h, post_ldr_fmt(), GL_RGBA, GL_UNSIGNED_BYTE, false, GL_LINEAR)
post_ldr_hdr = gpu_hdr_active()
}
if post_auto { post_measure() }
target_bind(post_ldr)
gpu_depth_test(false)
gpu_use_program(prog)
r3d_bind_2d(prog, "u_hdr", 0, color_tex)
r3d_bind_2d(prog, "u_bloom", 1, post_bloom[0].color)
r3d_bind_2d(prog, "u_ao", 2, post_ao_blur.color)
u_f(gpu_uniform(prog, "u_ao_strength"), post_ao_strength)
u_f(gpu_uniform(prog, "u_gi_strength"), post_gi_strength)
u_f(gpu_uniform(prog, "u_exposure"), post_exposure)
var auto = F_ZERO
if post_auto and post_adapt_t != null { auto = F_ONE; r3d_bind_2d(prog, "u_adapt", 3, post_adapt_t[post_adapt_i].color) }
u_f(gpu_uniform(prog, "u_auto"), auto)
u_f(gpu_uniform(prog, "u_bloom_strength"), post_bloom_strength)
u_f(gpu_uniform(prog, "u_vignette"), post_vignette)
u_f(gpu_uniform(prog, "u_saturation"), post_saturation)
u_f(gpu_uniform(prog, "u_contrast"), post_contrast)
u_f3(gpu_uniform(prog, "u_wb"), post_wb_r, post_wb_g, post_wb_b)
u_f3(gpu_uniform(prog, "u_lift"), post_lift_r, post_lift_g, post_lift_b)
u_f3(gpu_uniform(prog, "u_gain"), post_gain_r, post_gain_g, post_gain_b)
# the HDR10 variant's display calibration (the SDR program has none of these, and -1 sets nothing)
u_f(gpu_uniform(prog, "u_hdr_peak"), r3d_hdr_peak_nits())
u_f(gpu_uniform(prog, "u_hdr_paper"), r3d_hdr_paper_nits())
u_f(gpu_uniform(prog, "u_hdr_black"), r3d_hdr_black_nits())
mesh_draw(post_fs)
# sharpen + grain onto the screen
gpu_fb_bind(gpu_screen_fb())
gpu_viewport(0, 0, gl_w, gl_h)
gpu_use_program(post_p_sharp)
r3d_bind_2d(post_p_sharp, "u_src", 0, post_ldr.color)
u_f2(gpu_uniform(post_p_sharp, "u_texel"), fr(1, post_ldr.w), fr(1, post_ldr.h))
u_f(gpu_uniform(post_p_sharp, "u_amount"), post_sharpen)
var fx = post_fxaa
if r3d_env_has("R3D_NOFXAA") { fx = F_ZERO }
u_f(gpu_uniform(post_p_sharp, "u_fxaa"), fx)
# R3D_NOGRAIN=1: no film grain, so two frames of a still camera can be compared for what else moves
var grain = post_grain
if r3d_env_has("R3D_NOGRAIN") { grain = F_ZERO }
u_f(gpu_uniform(post_p_sharp, "u_grain"), grain)
u_f(gpu_uniform(post_p_sharp, "u_time"), r3d_time)
mesh_draw(post_fs)
}