r3d_hdr_calibrate(peak, paper, black) feeds the tonemap's and the overlay's HDR10 variants and the display's HDR metadata; ov_hdr_nits draws a calibration patch at a number of nits. gpu_caps_probe asks the running Vulkan renderer's instance instead of making and destroying a second one under Streamline's interposer, which left the next swapchain rebuild calling address 0. The overlay gets an HDR10 variant, and the tonemap brightens HDR highlights by one factor rather than per channel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
15 KiB
Text
348 lines
15 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)
|
|
|
|
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
|
|
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
|
|
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)
|
|
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_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)
|
|
}
|
|
|
|
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"), fl(1.02), F_ONE, fl(0.97))
|
|
u_f3(gpu_uniform(prog, "u_lift"), fl(0.004), fl(0.004), fl(0.012))
|
|
u_f3(gpu_uniform(prog, "u_gain"), fl(0.99), fl(0.995), fl(1.0))
|
|
# 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)
|
|
# 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 Os.has_env("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)
|
|
}
|