From 65c1b9ca5c2330f3104c8d37fdbddc22dbf8e3f0 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 15 Sep 2026 22:57:18 +0300 Subject: [PATCH] feat(render3d): HDR calibration; fix the HDR toggle crash and yellow reading as red 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 --- changes/hdr-calibration.md | 16 ++++++++ packages/ludic.render3d/gpu.ludic | 17 ++++++-- packages/ludic.render3d/gpu_vk_draw.ludic | 29 ++++++++++++-- packages/ludic.render3d/overlay.ludic | 37 ++++++++++++++++++ packages/ludic.render3d/post.ludic | 4 ++ packages/ludic.render3d/shaders/overlay.frag | 10 +++-- .../shaders/spv/a4683767.frag.spv | Bin 9940 -> 10932 bytes .../shaders/spv/e86f78e8.frag.spv | Bin 6060 -> 6412 bytes .../ludic.render3d/shaders/spv/manifest.txt | 9 ++++- packages/ludic.render3d/shaders/tonemap.frag | 21 +++++++--- 10 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 changes/hdr-calibration.md 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 00a755faa70e2acbd36cb45ae4374be6ef45d913..2ecfde12e61e7c82f14037f5ab03229ed6659775 100644 GIT binary patch delta 2609 zcmY+FUvE@J7{+Jm?ous;l2#kNAe2=BY5o6i#cpXq3nEaE3TknCy1S)oOSf*fMXY#4 z+JwZ!gq*~P-dJPwg2Z6f8}IN7n3$M&L1OeP_z8S|XXjlGlQTKb^SsZzXXc%m-9L~0 zGO@K|Q*N_!Dc9vX-TL*dM>D0AbG4t(4QIx(F6+K?&h1uTF3y(g#l>K;QkUA&_uSPh z!JKRBd)xZ-d{CN;zRBDS({7jYw#GY`acQ>)9*=ABC;0A)(yI~QFFs$LX&8S^e4*M{ zYVi&?DPHm3bHa;3J-Fcc)IB`wO40RfU%q^gnsznuTCr4{uhsR%tXmdeE>`RHpj-_W zN+g$_ahIZhvpxBb;^=g}JkwG?iTToOP_A7x=hawiRO()4^jdRp^PP_TVL>ZqPX5y! z&Y4Cb(at6`iikEDWxCFXY)Y5dHuW(ThpF!nroowdzp%El&36%0BYw%pI}lOR_S>m& zLynx>oVtB-X7zqd+_`T@;l%ffQ#AOOdOt0W#rSg~n>^pp`wejlx>NpXVLk41_5Gex zUNns#jXzk0-3|{iU_n?L(;fh){%9mv*PHs&9#Vl;qM#=?bWzHRIvGCnQ8Y%t*%6v$ zpyD(#tVX}}jv^7)VZGh>@H^Jt_R3-hZfDvZth>o97wt;Y^$7 zSz&bZJRf^P=Q!wQdO?_gt@uS@J@KSSK-^+4C1Okf?^R8bO67%#9~3tK%i?8&JR`y?d3(&LaEOV3&HSJ+ z0g|u#n7RYlQJ-Yaeu#038Pu3 z+|2$>JnF>Fds>*})=i&CMStfW?0-vPJ^HC>4aYvAZniVgkRHZ{DG7*X2!tP&0i6W=)}WD` zzkrHxN&SST(k(tCj7F#NVq95(73L@r~7?zbpBFoMRUST z#OgOMjL*7N=7Kc5#BK6umS<6zJSUmdq(1tA2qG;O^{#sj*|a6E@h3U1l~aj^G{)m6 Q&Mo4jHQ`2cu>Z%*|0`MdX8-^I delta 1655 zcmY+E+e=hY6vp>Bb4E?Y#IeC3$y;gK%`Rr%(^50@Qf9YFCNDT(j*Ui9!6Xp`K@jK3 z-g=4(f|&jQ|AL-^9wg|&zaZ$n?{_>~j*Hps^?l#kYp=cb+4Ft!=SWF7uQuP9kSQ_+ zX8K*pu+tGTX7F30+i|y?&%0;~KblTuGRfIga%qO-;vF;L@ynUH`PAa- z9I+8|&ExK8X6LtKrrfps@`}42tC*FApvgl)o8vH;QJn9Ir@8OEm_RgaWGA zMFysTYWZr)4z59zp;HKYK>!DJib8jM9URan%#~-47nW={pk;8Z2GdT=9^|I*X6evQBW?-5UtjOC`fcH7v9Ddt+J`&8BTNi@ zx3H~>Ymim%5ZnW16W#Oh(|-3!#h*#O4jv8}$pGf@1Tl{%hS6n?51GLc_+KlusPI zHt=$_$fsB|8+TfMODMbjrE@yk4QZ=yl@EtTtHEu4!gKQD+8iF8*27u-y!`#r;nBG| zICArfy5#eMMmhgp5#0ii42+3|{QaSknA7&ik72tmE;eUvyd&Kn#!}Bcdf%@fB zh`o-B^0NfmVcT^{9Ki(G>fvmnA^9X4_cyyN9}ctJu+KqT?uyUli5t_w)rl%IB9a6w SAb^j9P5HwZ$(7W9bp8Vp*pm4G diff --git a/packages/ludic.render3d/shaders/spv/e86f78e8.frag.spv b/packages/ludic.render3d/shaders/spv/e86f78e8.frag.spv index 24e28a7140fe4648105b99d65368d5ad6107e881..91f127f87e0e25576b71714a4c95dd2bb85ec880 100644 GIT binary patch literal 6412 zcmZveYnWA48OQg`FeAvwNa7u701HgT5k*99hlvJA%pp^}P@6sH>@#y>&pF3)&Wub} zp2#3gpzkSxb&w^S!@2vO#e=lpT zcfETZx;mGh69gT>++cR_hoCj)2c2R9aXsn0W&0i5SJWGY6*sOLz++y}l{(_nr?^>~ zk@2Wh#;{qEm&B6u<%>;=Nb6GOAULJKZn1t$w>;*`HquxFE#J*Uk66PyOrIuoXCc&DqH}DOMm9iA}))a=rHl5lU@m`0Y(cIqC?BF@!(Ing) z7o&-!ac8+ytklK_lS+P1pp%`G+U27B`KX?c3UN586SQsAyltyjlTss#qqc2ZEh>-3 znT;E3+qAzpk*z0Pk9(L1>l5X$P^w8czB}VvwJ^TN|C=`d$BSa7UFWf)R;5lFXao- zRKo7s{^zHBu~ds&#jvx^Z}Y9<30tnWq_(lBTsSl4f;Rs@F$>zZ^pY&sg>61xDRbYg zJH0678}+bKD~;;2H2z}a$D)Md_NZ&QyGx9(;M?lMOH)n_nvbm;GuvgUEuT~-3SoUL zDpVe5=eoR=t2FN2(ZZA)E7ZbjRE=xdey+&)))nzR9TLajdz#T)jcU=j!$;4+J;6_f zbzikwR47H|e0s;-iaeh3Sudu!PNdvOt)PSBb27!Y`xbXP<|3m-TG zccpjSDQ>HLxo<|dWUKxf=KCd|&n*>Z7{__wa#D;VFWf$l*I1_qxaN{lopqP)9N#?L zUp$U)9`3Im$F~mmH;>~RhkMiG_{QP><#Bx9a5O%B%D25(qPEEJ66p-=e9zeF6~n&$ zxUi_dBNEGh3R29WU(-L(cv!SQhJbEIAnMkSi|u06g9JbUjy!6y1~}F zRuIm_;;zFlxGRer0KUF|li62+o6lx=EqLb8pygdB2xr25=7gy^_r!e&uNz6VioxykYSAWio#qQUP z4;uot7&YL?2R;d*VRiVm^gs4(%NhQS@WBHIviJA5 z!VfMulHuP8U$v_*d&f=*6N4W%+xKbd48!~;F>X>fQ$Ko~L4Ds8LyzN|)4WMSKY@8G zhCR$T)bwyp`ZFHpH)`fAeAMAhT4js$t&+`>A<25JK~1+w;HTvSzSVKOV~;xSHplap z^~wvs_eiKe>tf%a6S04ITkpNnS&ui)*1JPGy!pIO`q_KBQyjd->`eXJ`?*_~cM|?# zx5s;=6NhbB+@R)rC9H>y{ZUUi%X^=6_WY{+d0Rgqf#c2nyZm=+X8cpN!WK$KCEOc2 zc_Ph4Ni1QFyu<&H&u;1X5@TzQN%z+*3Byru*4(3+!J61UDEX*_Jm|J&BAqpF*A37! z;}ST8(IKJVtj-nb_?IQNm#TDlFmLySq~S32o0L$qovIJ#d_aN^{0>+5y^g1!tzV|3 zlN+1)eMmZcWEl6LbU5~E+=m^<-iLKE*8PZtc>J-G-)fDGT2nV_M6K*RACo@qFz0z# z;-BXcVSM1p*VGJWHGWjOU*pGw;SdJ8$DB)2D>t@yTg^LeiA@K2JK{?%*k5;^3{epO(&=4CD4mhhuNXJ?%KoW_R!z>BQrY zo&0tO*!(--F8u!AEzI6u(0o{ONbk+Sp1iz6aSLL z;=dvt-r~P1-Quw=lUQ9|mQH?r*gtP5oZaEqq~|4ehhLWthcL)XU091d{D$M$E55Ht z%=Rs3yG%2FMMD?@6C0fu~md zhW|jqS$3q-df$I29bX1{@PV_KA4w->NMil)s&ryFJ2CjcS7-~ff zK5+k5mYXH~9&&ch&6p!uEx*~C&z0~k_iCmd_$_k$(=P6bsmP*HOSe*#}Hp!KqJnq(iyEuHYo9|W9mw96RJH*i+CcW;>zFHhL zWhNi-_*(z0kdB@H!RJ~DwPerOY|qzAhqGQ;DIIP=Vtcqj`gNX6yK~$q4jVC+?zt`=5asU7T delta 2166 zcmZ8iO>7%Q6rSC+9h=5+E7#c?LuqOuQBeWqR-r_RpwJ!=ia2noWVW`GSh#VzPDCpX z7OHxvh!E9KAcdBM-xk_HFc1tB>Tp7$QaNxyaG>JC0i+%|@_n=OqAlY*&wSta-kZNS z@#4dmAIy4gc|a*gZBc3U!&>%fXXKnxYAy(aqv(?=p)TBpQ|hAbC;a`F!7fzh!unLB zIbRQr=M}>p32Re~OsX}5+cQC}^*n}P(yFJAIrlg{ea0!_`-=Jg(J9^CgYFssYS69* zwR&Z~e!SjPDSUd`as47alE$OGFk7j%nyt{P`&=Kn4!O+RaGCd%TQ>f882_o(bmd6U zt}E-`H~zo6CF7Hgaz1yW61L_sj%Lhy(Tq8x)`Anw#`Fy2NFDWjv*K-0{zh203)+l7 z=5bA@y=ac@#&0H=tyOA`P_kULa||1GzFldBjp@d$o#P+RFL2L0HdQJEIE`YWG)m=8 zqg0!%g<3^UIfXN#R*Iq;N3rW9PWBsMHr`8LZ%gK#4SjF&WalEd5m*BD1ip!P!`O&< z&(wMHg5x^xoVsgu;<(Nlpq{ciaZK;1&#gFa9lo%5tHodb53k>bJ+oO(eh`c0#@V>o zgZLJV3Rlb*=gPq5n1plyNseP+I6aZdn{#GeQu#v^jCo$<9>Uy`fzz4{!s#y@;h;W-m+bSE-hFSFM?ETzusCx`s{>kzO1G&nBo z8^Gkm?nfZY`7IbO|IuB*%NThu^3)HYe;E2Lz^dq24FlP9e z5*j&15cBb|Nd`VHHfC7W#ay}b3D7Ex89t?iMvf80d~R$zfImYYz*xGn#xBMz+q=N> zCDMl+vAjgV@)8BFEA@ccFo^;JzCynX=D>F1!~0@VCSvfqUd!h7V)n5PJ$Y0#F8yot zJSs+T>BPKQa&p|?V*Fd~(752Mz~`2h#sq%{{KEFtP5eFZp2N8@xyEb2jDg=yMNv+* zk1?Cz4gE{*nF60De+pSRRERHhC%3+o7#Rf&05z-%dkP}mUbURo=L>%4H6WhEeO5V( zeh_~2T!K;Ke{lM-#KurSoRHdnTnhc(gz@E*f7x>9Ezj~}41b@=NzB_A5Alz}1cH{) zujt9)eCHjED6=ti1wA?OdKY7E=yBL2B6&UU!5G8kGKN0nB<2H*8N+Iqm=7^#46lzd M^a0oXb9j&YA2|jw82|tP 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);