diff --git a/changes/hdr-calibration.md b/changes/hdr-calibration.md new file mode 100644 index 00000000..c733a31a --- /dev/null +++ b/changes/hdr-calibration.md @@ -0,0 +1,16 @@ +bump: patch +type: feat +**HDR calibration, and two HDR fixes** — the HDR10 picture follows the player's display instead of one fixed curve. + +- **`r3d_hdr_calibrate(peak, paper, black)`** — nits as float bits: the brightest the display shows (100-10000), + where the picture's and the interface's white sit (80-1000, never above the peak) and how far the darkest shade is + lifted (0-5, fading out by paper white). The tonemap's and the overlay's HDR10 variants read them, and the display's + HDR metadata is sent again with the new peak. The defaults are the old constants: 1000, 200 and 0. +- **`ov_hdr_nits(nits)` / `ov_hdr_paper()`** — what the overlay draws next is at that many nits on an HDR10 frame, + for a calibration screen's test patches; it closes the batch so far. No effect on an SDR frame. +- **Fixed: a crash toggling HDR on the Vulkan renderer.** `gpu_caps_probe()` made and destroyed a second Vulkan + instance under NVIDIA Streamline's interposer; the next swapchain rebuild then called through a pointer that + instance had left behind, at address 0. With the Vulkan renderer running, the probe asks its instance instead. +- **Fixed: yellow read as red in HDR.** The overlay drew sRGB values straight into the HDR10 swapchain (it now has an + HDR10 variant, chosen while `gpu_hdr_active()`), and the tonemap extended highlights per channel, which boosted a + bright yellow's red far more than its green; it now brightens the graded colour by one factor. diff --git a/packages/ludic.render3d/gpu.ludic b/packages/ludic.render3d/gpu.ludic index 0fc54f3f..9db0acc8 100644 --- a/packages/ludic.render3d/gpu.ludic +++ b/packages/ludic.render3d/gpu.ludic @@ -337,9 +337,18 @@ function gpu_caps_probe() -> void { Vk.zero(ici, VkInstanceCreateInfo_sizeof) Vk.put_i32(ici, VkInstanceCreateInfo_sType, VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO) Vk.put_ptr(ici, VkInstanceCreateInfo_pApplicationInfo, app) - let out = bytes(8) - if Vk.create_instance(ici, null, out) != VK_SUCCESS { return } - let inst = Vk.get_ptr(out, 0) + # With the Vulkan renderer running, ask its own instance: the renderer's loader is NVIDIA Streamline's + # interposer, and a second instance made and destroyed under it is what this avoids. + var inst: pointer = null + var own = false + if gpu_kind == GPU_VK and gvk_ready { + inst = gvk_inst + } else { + let out = bytes(8) + if Vk.create_instance(ici, null, out) != VK_SUCCESS { return } + inst = Vk.get_ptr(out, 0) + own = true + } Vk.put_i32(cnt, 0, 0) Vk.enumerate_physical_devices(inst, cnt, null) let nd = Vk.get_i32(cnt, 0) @@ -385,7 +394,7 @@ function gpu_caps_probe() -> void { gpu_cap_mesh = gpu_ext_in(dexts, ne, VK_EXT_MESH_SHADER_EXTENSION_NAME) gpu_cap_reflex = gpu_ext_in(dexts, ne, VK_NV_LOW_LATENCY_2_EXTENSION_NAME) } - Vk.destroy_instance(inst, null) + if own { Vk.destroy_instance(inst, null) } print(`r3d: gpu caps: {gpu_cap_device} vulkan {gpu_cap_vulkan} floor {gpu_cap_floor} rt {gpu_cap_rt} mesh {gpu_cap_mesh} rtx {gpu_cap_rtx} reflex {gpu_cap_reflex} hdr {gpu_cap_hdr}`) } diff --git a/packages/ludic.render3d/gpu_vk_draw.ludic b/packages/ludic.render3d/gpu_vk_draw.ludic index fdbe74dc..1abe2cda 100644 --- a/packages/ludic.render3d/gpu_vk_draw.ludic +++ b/packages/ludic.render3d/gpu_vk_draw.ludic @@ -973,7 +973,28 @@ function r3d_hdr(on: bool) -> void { function gpu_hdr_active() -> bool { return gpu_kind == GPU_VK and gvk_hdr_on } function gvk_screen_fmt() -> int { if gvk_hdr_on { return GL_RGB10_A2 }; return GL_RGBA8 } -# what the picture is: graded in BT.709 around D65, highlights to 1000 nits, a 200-nit average +# The player's calibration of their display, in nits (float bits): the brightest it shows, where the +# picture's and the interface's white sit, and how far the darkest shade is lifted. Displays differ by +# an order of magnitude - a 400-nit monitor and a 2000-nit television - and one fixed curve either +# clips the first's highlights flat or leaves the second dim. +var r3d_hdr_peak: int = 0 # 0 until r3d_hdr_calibrate: 1000 +var r3d_hdr_paper: int = 0 # 200 +var r3d_hdr_black: int = 0 # 0 +function r3d_hdr_peak_nits() -> int { if r3d_hdr_peak == 0 { return fi(1000) }; return r3d_hdr_peak } +function r3d_hdr_paper_nits() -> int { if r3d_hdr_paper == 0 { return fi(200) }; return r3d_hdr_paper } +function r3d_hdr_black_nits() -> int { return r3d_hdr_black } +function r3d_hdr_calibrate(peak: int, paper: int, black: int) -> void { + var pk = f_clamp(peak, fi(100), fi(10000)) + let pp = f_clamp(paper, fi(80), fi(1000)) + if f_ls(pk, pp) { pk = pp } + let bl = f_clamp(black, F_ZERO, fi(5)) + if pk == r3d_hdr_peak and pp == r3d_hdr_paper and bl == r3d_hdr_black { return } + r3d_hdr_peak = pk; r3d_hdr_paper = pp; r3d_hdr_black = bl + # the display is told what the picture now reaches + if gvk_hdr_on and gvk_has_hdr_meta and gvk_swap != 0 { gvk_hdr_metadata() } +} + +# what the picture is: graded in BT.709 around D65, highlights to the calibrated peak, paper white average function gvk_hdr_metadata() -> void { let md = bytes(VkHdrMetadataEXT_sizeof) Vk.zero(md, VkHdrMetadataEXT_sizeof) @@ -982,10 +1003,10 @@ function gvk_hdr_metadata() -> void { Vk.put_i32(md, VkHdrMetadataEXT_displayPrimaryGreen + VkXYColorEXT_x, fl(0.30)); Vk.put_i32(md, VkHdrMetadataEXT_displayPrimaryGreen + VkXYColorEXT_y, fl(0.60)) Vk.put_i32(md, VkHdrMetadataEXT_displayPrimaryBlue + VkXYColorEXT_x, fl(0.15)); Vk.put_i32(md, VkHdrMetadataEXT_displayPrimaryBlue + VkXYColorEXT_y, fl(0.06)) Vk.put_i32(md, VkHdrMetadataEXT_whitePoint + VkXYColorEXT_x, fl(0.3127)); Vk.put_i32(md, VkHdrMetadataEXT_whitePoint + VkXYColorEXT_y, fl(0.3290)) - Vk.put_i32(md, VkHdrMetadataEXT_maxLuminance, fi(1000)) + Vk.put_i32(md, VkHdrMetadataEXT_maxLuminance, r3d_hdr_peak_nits()) Vk.put_i32(md, VkHdrMetadataEXT_minLuminance, fl(0.001)) - Vk.put_i32(md, VkHdrMetadataEXT_maxContentLightLevel, fi(1000)) - Vk.put_i32(md, VkHdrMetadataEXT_maxFrameAverageLightLevel, fi(200)) + Vk.put_i32(md, VkHdrMetadataEXT_maxContentLightLevel, r3d_hdr_peak_nits()) + Vk.put_i32(md, VkHdrMetadataEXT_maxFrameAverageLightLevel, r3d_hdr_paper_nits()) let chains = bytes(8) Vk.put_i64(chains, 0, gvk_swap) Vk.set_hdr_metadata_ext(gvk_dev, 1, chains, md) diff --git a/packages/ludic.render3d/overlay.ludic b/packages/ludic.render3d/overlay.ludic index a76ccf71..f5e4d9a3 100644 --- a/packages/ludic.render3d/overlay.ludic +++ b/packages/ludic.render3d/overlay.ludic @@ -53,10 +53,40 @@ var ov_clip_y: int = 0 var ov_clip_w: int = 0 var ov_clip_h: int = 0 +# The interface is sRGB. While the output is HDR10 the overlay draws with its HDR10 variant, which puts +# the interface at the picture's paper white; the SDR program there sent sRGB values as PQ, and the +# menu's yellow read as red on an HDR display. +var ov_prog_sdr: int = 0 +var ov_prog_hdr: int = 0 +# How bright the interface's white is while HDR10 is on, as a multiple of paper white. It is 1 for the +# interface; a calibration screen draws its test patches at a number of nits with ov_hdr_nits, which +# closes the batch so far - the multiple is one uniform per flush. On an SDR frame it does nothing. +var ov_hdr_scale: int = 0 # float bits; 0 until ov_begin sets 1 +function ov_hdr_nits(nits: int) -> void { + var s = F_ONE + if nits != 0 { s = f_div(nits, r3d_hdr_paper_nits()) } + if s == ov_hdr_scale { return } + if ov_open { ov_flush() } + ov_hdr_scale = s +} +# back to the interface's own white +function ov_hdr_paper() -> void { ov_hdr_nits(0) } +function ov_pick_prog() -> void { + if gpu_hdr_active() { + if ov_prog_hdr == 0 { + r3d_program_log("overlay.vert", "overlay.frag", "#define HDR10\n") + ov_prog_hdr = gpu_program("#version 410 core\n#define HDR10\n" + r3d_shader_file("overlay.vert"), "#version 410 core\n#define HDR10\n" + r3d_shader_file("overlay.frag"), "overlay.vert", "overlay.frag", "#define HDR10\n") + } + if ov_prog_hdr != 0 { ov_prog = ov_prog_hdr; return } + } + ov_prog = ov_prog_sdr +} + function overlay_init(font_dir: string) -> bool { r3d_program_log("overlay.vert", "overlay.frag", "") ov_prog = gpu_program("#version 410 core\n" + r3d_shader_file("overlay.vert"), "#version 410 core\n" + r3d_shader_file("overlay.frag"), "overlay.vert", "overlay.frag", "") if ov_prog == 0 { print("overlay: program failed"); return false } + ov_prog_sdr = ov_prog ov_mesh = gpu_mesh_new() ov_vbo = gpu_mesh_vertices(ov_mesh, null, gl_bytes_of(OV_MAX_QUADS * 6 * OV_FLOATS), GPU_DYNAMIC) gpu_mesh_attr(ov_mesh, 0, 2, GPU_F32, OV_FLOATS * 4, 0, false) @@ -163,12 +193,14 @@ function ov_begin() -> void { gpu_cull(false) gpu_blend(true) gpu_blend_func(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + ov_pick_prog() gpu_use_program(ov_prog) u_f2(gpu_uniform(ov_prog, "u_screen"), fi(gl_w), fi(gl_h)) ov_n = 0; ov_nr = 0; ov_range_start = 0 ov_tex = ov_white ov_mode = 2; ov_voff = fi(8) ov_clip_w = 0 + ov_hdr_scale = F_ONE ov_open = true } # everything drawn until ov_unclip stays inside this rectangle (a scrolling list) @@ -198,12 +230,17 @@ function ov_flush() -> void { # framebuffer operation. Saying the target at each flush is what a render pass says anyway. gpu_fb_bind(gpu_screen_fb()) gpu_viewport(0, 0, gl_w, gl_h) + ov_pick_prog() gpu_use_program(ov_prog) gpu_mesh_bind(ov_mesh) gpu_buffer_upload(ov_vbo, gl_bytes_of(ov_n * 6 * OV_FLOATS), ov_buf, GPU_STREAM) var font = ov_font if font == 0 { font = ov_white } r3d_bind_2d(ov_prog, "u_font", 1, font) + var hs = ov_hdr_scale + if hs == 0 { hs = F_ONE } + u_f(gpu_uniform(ov_prog, "u_hdr_paper"), r3d_hdr_paper_nits()) + u_f(gpu_uniform(ov_prog, "u_hdr_scale"), hs) var last = -1 var clipped = false for i in 0 .. ov_nr { diff --git a/packages/ludic.render3d/post.ludic b/packages/ludic.render3d/post.ludic index e32c925d..8b6aa6d1 100644 --- a/packages/ludic.render3d/post.ludic +++ b/packages/ludic.render3d/post.ludic @@ -327,6 +327,10 @@ function post_tonemap(color_tex: int) -> void { 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()) diff --git a/packages/ludic.render3d/shaders/overlay.frag b/packages/ludic.render3d/shaders/overlay.frag index 070dabce..1b3d08f9 100644 --- a/packages/ludic.render3d/shaders/overlay.frag +++ b/packages/ludic.render3d/shaders/overlay.frag @@ -7,8 +7,11 @@ uniform sampler2D u_tex; uniform sampler2D u_font; out vec4 o_color; #ifdef HDR10 -// the interface is sRGB: drawn into an HDR10 frame it sits at paper white, as the picture's white does -const float HDR_PAPER = 200.0; +// the interface is sRGB: drawn into an HDR10 frame its white sits at the player's paper white, as the +// picture's white does. u_hdr_scale multiplies that for one batch (ov_hdr_nits): a calibration +// screen's test patches, which have to reach past paper white to the display's peak. +uniform float u_hdr_paper; +uniform float u_hdr_scale; vec3 bt709_to_2020(vec3 c) { return mat3(0.6274, 0.0691, 0.0164, 0.3293, 0.9195, 0.0880, 0.0433, 0.0114, 0.8956) * c; } @@ -30,6 +33,7 @@ void main() { o_color = vec4(v_col.rgb * t.rgb, v_col.a * t.a); } #ifdef HDR10 - o_color.rgb = pq_encode(bt709_to_2020(pow(max(o_color.rgb, vec3(0.0)), vec3(2.2))) * HDR_PAPER); + float nits = max(u_hdr_paper, 80.0) * max(u_hdr_scale, 0.0); + o_color.rgb = pq_encode(bt709_to_2020(pow(max(o_color.rgb, vec3(0.0)), vec3(2.2))) * nits); #endif } diff --git a/packages/ludic.render3d/shaders/spv/a4683767.frag.spv b/packages/ludic.render3d/shaders/spv/a4683767.frag.spv index 00a755fa..2ecfde12 100644 Binary files a/packages/ludic.render3d/shaders/spv/a4683767.frag.spv and b/packages/ludic.render3d/shaders/spv/a4683767.frag.spv differ diff --git a/packages/ludic.render3d/shaders/spv/e86f78e8.frag.spv b/packages/ludic.render3d/shaders/spv/e86f78e8.frag.spv index 24e28a71..91f127f8 100644 Binary files a/packages/ludic.render3d/shaders/spv/e86f78e8.frag.spv and b/packages/ludic.render3d/shaders/spv/e86f78e8.frag.spv differ diff --git a/packages/ludic.render3d/shaders/spv/manifest.txt b/packages/ludic.render3d/shaders/spv/manifest.txt index fcc70708..a76f578e 100644 --- a/packages/ludic.render3d/shaders/spv/manifest.txt +++ b/packages/ludic.render3d/shaders/spv/manifest.txt @@ -578,7 +578,7 @@ T 725523d9 u_shadow 9 T 725523d9 u_tershadow 10 T 725523d9 u_ts_height 11 P a4683767 fullscreen.vert tonemap.frag #define HDR10; -B a4683767 frag 1 852 +B a4683767 frag 1 864 U a4683767 frag u_cascade_vp 0 mat4 5 64 U a4683767 frag u_cascade_split 320 float 5 16 U a4683767 frag u_cascade_range 400 float 5 16 @@ -618,6 +618,9 @@ U a4683767 frag u_bloom_strength 836 float 1 0 U a4683767 frag u_vignette 840 float 1 0 U a4683767 frag u_saturation 844 float 1 0 U a4683767 frag u_contrast 848 float 1 0 +U a4683767 frag u_hdr_paper 852 float 1 0 +U a4683767 frag u_hdr_peak 856 float 1 0 +U a4683767 frag u_hdr_black 860 float 1 0 T a4683767 u_adapt 2 T a4683767 u_ao 3 T a4683767 u_bloom 4 @@ -1932,7 +1935,7 @@ U e86f78e8 vert u_screen 0 vec2 1 0 I e86f78e8 a_pos 0 vec2 I e86f78e8 a_uv 1 vec2 I e86f78e8 a_col 2 vec4 -B e86f78e8 frag 0 764 +B e86f78e8 frag 1 772 U e86f78e8 frag u_cascade_vp 0 mat4 5 64 U e86f78e8 frag u_cascade_split 320 float 5 16 U e86f78e8 frag u_cascade_range 400 float 5 16 @@ -1961,6 +1964,8 @@ U e86f78e8 frag u_ts_on 748 float 1 0 U e86f78e8 frag u_force_cascade 752 int 1 0 U e86f78e8 frag u_cloud_shadow 756 float 1 0 U e86f78e8 frag u_time 760 float 1 0 +U e86f78e8 frag u_hdr_paper 764 float 1 0 +U e86f78e8 frag u_hdr_scale 768 float 1 0 T e86f78e8 u_brdf 2 T e86f78e8 u_font 3 T e86f78e8 u_irradiance 4 diff --git a/packages/ludic.render3d/shaders/tonemap.frag b/packages/ludic.render3d/shaders/tonemap.frag index dac0127a..cf11f5ca 100644 --- a/packages/ludic.render3d/shaders/tonemap.frag +++ b/packages/ludic.render3d/shaders/tonemap.frag @@ -22,9 +22,11 @@ vec3 aces(vec3 x) { } float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } #ifdef HDR10 -// HDR10 output: nits in BT.2020 primaries, encoded with the SMPTE ST 2084 (PQ) curve -const float HDR_PAPER = 200.0; // nits the SDR picture's white sits at -const float HDR_PEAK = 1000.0; // nits the brightest highlight rolls on to +// HDR10 output: nits in BT.2020 primaries, encoded with the SMPTE ST 2084 (PQ) curve. The three +// numbers are the player's calibration of their display (r3d_hdr_calibrate). +uniform float u_hdr_paper; // nits the SDR picture's white sits at +uniform float u_hdr_peak; // nits the brightest highlight rolls on to: what the display reaches +uniform float u_hdr_black; // nits the darkest shade is lifted to, fading out toward paper white vec3 bt709_to_2020(vec3 c) { return mat3(0.6274, 0.0691, 0.0164, 0.3293, 0.9195, 0.0880, 0.0433, 0.0114, 0.8956) * c; } @@ -57,9 +59,16 @@ void main() { float vig = 1.0 - u_vignette * dot(q, q) * 0.5; c *= vig; #ifdef HDR10 - // up to paper white the SDR picture as it is; past it, what ACES clipped runs on to the peak - vec3 hi = max(pre - 1.0, vec3(0.0)); - vec3 nits = (c + hi / (hi + 1.0) * (HDR_PEAK / HDR_PAPER - 1.0) * vig) * HDR_PAPER; + // up to paper white the SDR picture as it is; past it, the whole graded colour is brightened by one + // factor from its brightest channel. Extending each channel on its own boosted a bright yellow's red + // far more than its green, and yellow read as red on an HDR display. + float paper = max(u_hdr_paper, 80.0); + float peak = max(u_hdr_peak, paper); + float over = max(max(pre.r, max(pre.g, pre.b)) - 1.0, 0.0); + float lift = 1.0 + over / (over + 1.0) * (peak / paper - 1.0); + vec3 nits = c * lift * paper; + // the black level: the shadows' floor raised for a display that crushes them, gone by paper white + nits += max(u_hdr_black, 0.0) * (1.0 - clamp(nits / paper, 0.0, 1.0)); vec3 e = pq_encode(bt709_to_2020(nits)); e += (hash(gl_FragCoord.xy) - 0.5) / 1023.0; o_color = vec4(e, 1.0);