Log the luminance of HDR10 shots and keep their raw pixels, and show an HDR shot as a 2.2 display would.

A shot taken from an HDR10 swapchain (the menu's test hook, NX2NAVI_SHOT_EVERY) now writes the picture's luminance in nits to the log, the darkest pixel, four percentiles and the brightest, and its pixels as the swapchain holds them beside the PNG. The PNG of such a shot shows the HDR display's light at the HUD's white through the power 2.2, not the sRGB curve, so a dark tone the HDR display lifts looks lifted in it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-11 14:10:31 -05:00
parent 3752b8d157
commit 358ad54105
3 changed files with 73 additions and 8 deletions

View File

@@ -637,7 +637,13 @@ int vk_present_captured(const uint8_t **px, int *w, int *h, VkFormat *fmt) {
return ok;
}
// The captured swapchain image as a PNG (the menu's test hook), RGBA with opaque alpha.
static float g_pic[4]; // gfx_picture_rect
static volatile int32_t g_pic_ok;
// The captured swapchain image as a PNG (the menu's test hook), RGBA with opaque alpha. From an HDR10 swapchain the
// log also gets the luminance in nits of the picture's rectangle (vk_hdr_luminance_stats: the black floor of a dark
// scene, for one), and <path>.pq10 keeps the raw pixels: "PQ10", width, height, 1 when A2R10G10B10 (u32 each), then
// the pixels as the swapchain holds them.
static void save_capture(const char *path) {
size_t n = (size_t)g_cap_w * (size_t)g_cap_h * 4;
uint8_t *px = malloc(n);
@@ -653,15 +659,36 @@ static void save_capture(const char *path) {
rt_write_png(path, px, (uint32_t)g_cap_w, (uint32_t)g_cap_h);
free(px);
rt_log("gfx: menu test shot %s (%dx%d)", path, g_cap_w, g_cap_h);
if (!g_sc_hdr) return;
int a2r = g_sc_fmt == VK_FORMAT_A2R10G10B10_UNORM_PACK32;
int rect[4] = {0, 0, g_cap_w, g_cap_h};
if (__atomic_load_n(&g_pic_ok, __ATOMIC_ACQUIRE)) {
rect[0] = (int)(g_pic[0] * (float)g_cap_w + 0.5f), rect[1] = (int)(g_pic[1] * (float)g_cap_h + 0.5f);
rect[2] = (int)(g_pic[2] * (float)g_cap_w + 0.5f), rect[3] = (int)(g_pic[3] * (float)g_cap_h + 0.5f);
if (rect[2] <= 0 || rect[3] <= 0 || rect[0] + rect[2] > g_cap_w || rect[1] + rect[3] > g_cap_h)
rect[0] = rect[1] = 0, rect[2] = g_cap_w, rect[3] = g_cap_h;
}
float st[7];
vk_hdr_luminance_stats(g_cap_ptr, g_cap_w, rect, a2r, st);
rt_log("gfx: shot %s, HDR10 luminance of the picture (%dx%d at %d,%d) in nits: darkest %.5f, 0.1%% %.5f, 1%% %.5f, "
"median %.3f, 99%% %.1f, brightest %.1f; %.2f%% of pixels under 0.01", path, rect[2], rect[3], rect[0], rect[1],
st[0], st[1], st[2], st[3], st[4], st[5], 100.0f * st[6]);
char raw[1200];
snprintf(raw, sizeof raw, "%s.pq10", path);
FILE *f = fopen(raw, "wb");
if (f) {
uint32_t head[4] = {0x30315150u /* "PQ10" */, (uint32_t)g_cap_w, (uint32_t)g_cap_h, (uint32_t)a2r};
fwrite(head, sizeof head, 1, f);
fwrite(g_cap_ptr, 4, (size_t)g_cap_w * (size_t)g_cap_h, f);
fclose(f);
}
}
// The rectangle {x, y, w, h} of a dw x dh swapchain image the cw x ch picture goes into: the whole image with
// NX2NAVI_ASPECT=stretch, else (fit, the default) the largest one of the picture's own aspect ratio, centred (a 16:9 picture
// on the Steam Deck's 1280x800 screen gets bars of 40 rows above and below; a 16:9 picture in a 21:9 window, bars at the
// sides).
// Where the last present put the picture, as fractions of the swapchain (x, y, w, h): menu_cursor.c's pointer
static float g_pic[4];
static volatile int32_t g_pic_ok;
// Where the last present put the picture, as fractions of the swapchain (x, y, w, h; g_pic): menu_cursor.c's pointer
int gfx_picture_rect(float out[4]) {
if (!__atomic_load_n(&g_pic_ok, __ATOMIC_ACQUIRE)) return 0;
memcpy(out, g_pic, sizeof g_pic);

View File

@@ -207,6 +207,9 @@ int vk_hdr_native_picture(VkFormat f); // vk_hdr_native, and f is the format o
VkImageView vk_hdr_ui_target(VkCommandBuffer cb, uint32_t w, uint32_t h);
void vk_hdr_ui_compose(VkCommandBuffer cb, VkImageView dst_view, VkFormat fmt, uint32_t w, uint32_t h);
void vk_hdr_to_srgb8(const uint8_t *in, uint8_t *out, size_t px, int a2r);
// Luminance statistics of an HDR10 shot inside rect {x, y, w, h}: the darkest pixel, the 0.1st, 1st, 50th and 99th
// percentiles and the brightest pixel in nits, and the share of pixels under 0.01 nits (vk_hdr.c).
void vk_hdr_luminance_stats(const uint8_t *in, int stride, const int rect[4], int a2r, float out[7]);
void vk_hdr_release(void); // before the device goes away
// The support buffer's HDR settings, five vec4 at 1440 (vk_exec_draw.c support_fill; read by the game's tonemap with
// shaders/hdr_tonemap.frag spliced in, vk_hdr.c tonemap_splice).

View File

@@ -1176,14 +1176,15 @@ void vk_hdr_release(void) {
memset(&H, 0, sizeof H);
}
// PQ code (0..1023) of a 10-bit channel to the sRGB byte of the same colour at the HUD's white (menu test shots and
// flight thumbnails of an HDR10 swapchain).
// Luminance of a PQ code (0..1023 over 1) in nits.
static float pq_nits(float e) {
const float m1 = 0.1593017578125f, m2 = 78.84375f, c1 = 0.8359375f, c2 = 18.8515625f, c3 = 18.6875f;
float p = powf(e, 1.0f / m2);
return 10000.0f * powf(fmaxf(p - c1, 0.0f) / (c2 - c3 * p), 1.0f / m1);
}
// 10-bit PQ pixels of an HDR10 swapchain to bytes that show on a display of gamma 2.2 what the HDR display shows, the
// HUD's white at 255 (menu test shots and flight thumbnails): a dark tone the HDR display lifts looks lifted here too.
void vk_hdr_to_srgb8(const uint8_t *in, uint8_t *out, size_t px, int a2r) {
float ui = nits().ui;
// Rec. 2020 to Rec. 709 (the inverse of ITU-R BT.2087's matrix)
@@ -1196,9 +1197,43 @@ void vk_hdr_to_srgb8(const uint8_t *in, uint8_t *out, size_t px, int a2r) {
for (int k = 0; k < 3; k++) c[k] = pq_nits(c[k] / 1023.0f) / ui;
for (int k = 0; k < 3; k++) {
float l = fminf(fmaxf(m[3 * k] * c[0] + m[3 * k + 1] * c[1] + m[3 * k + 2] * c[2], 0.0f), 1.0f);
float s = l <= 0.0031308f ? 12.92f * l : 1.055f * powf(l, 1.0f / 2.4f) - 0.055f;
out[4 * i + k] = (uint8_t)(s * 255.0f + 0.5f);
out[4 * i + k] = (uint8_t)(powf(l, 1.0f / 2.2f) * 255.0f + 0.5f);
}
out[4 * i + 3] = 255;
}
}
// The luminance (Rec. 2020 Y, in nits) of the 10-bit PQ pixels inside the rectangle {x, y, w, h} of a stride-wide
// image: out = the darkest pixel's, the 0.1st, 1st, 50th and 99th percentiles, the brightest's, and the share of pixels
// under 0.01 nits. The percentiles come from a histogram of 64 bins a stop, from 2^-24 to 2^14 nits; a value is a bin's
// upper edge.
void vk_hdr_luminance_stats(const uint8_t *in, int stride, const int rect[4], int a2r, float out[7]) {
enum { LO = -24, HI = 14, PER = 64, BINS = (HI - LO) * PER };
static uint32_t hist[BINS + 1];
static float lut[1024];
for (int i = 0; i < 1024; i++) lut[i] = pq_nits((float)i / 1023.0f);
memset(hist, 0, sizeof hist);
float lo = 1e30f, hi = 0.0f;
uint64_t total = 0, dark = 0;
for (int y = rect[1]; y < rect[1] + rect[3]; y++)
for (int x = rect[0]; x < rect[0] + rect[2]; x++) {
uint32_t v;
memcpy(&v, in + 4 * ((size_t)y * (size_t)stride + (size_t)x), 4);
float r = lut[v & 1023], g = lut[v >> 10 & 1023], b = lut[v >> 20 & 1023];
if (a2r) { float t = r; r = b; b = t; }
float l = 0.2627002120f * r + 0.6779980715f * g + 0.0593017165f * b;
lo = fminf(lo, l), hi = fmaxf(hi, l);
dark += l < 0.01f;
int bin = l > 0.0f ? (int)floorf((log2f(l) - LO) * PER) + 1 : 0; // bin 0: exactly black
hist[bin < 0 ? 0 : bin > BINS ? BINS : bin]++;
total++;
}
static const double q[4] = {0.001, 0.01, 0.5, 0.99};
for (int k = 0; k < 4; k++) {
uint64_t want = (uint64_t)(q[k] * (double)total), seen = 0;
int bin = 0;
while (bin < BINS && seen + hist[bin] <= want) seen += hist[bin++];
out[1 + k] = bin == 0 ? 0.0f : exp2f((float)LO + (float)bin / PER);
}
out[0] = total ? lo : 0.0f, out[5] = hi, out[6] = total ? (float)((double)dark / (double)total) : 0.0f;
}