[client] egl: compose native PQ output in linear light

Decode PQ/BT.2020 desktop pixels into an FP16 linear scRGB target before
compositing the color cursor, ImGui, damage overlay, and letterboxing.
Encode the damaged result to PQ once at the surface boundary. Keep logical
AND/XOR cursor masks in the encoded domain.

This preserves premultiplied cursor edges, prevents encoded-domain
blending, uses the Wayland output reference white for local overlays, and
keeps guest cursor white and display calibration independent from frame
SDR white. It also corrects HDR-to-SDR absolute luminance scaling, gamut
matrices, black handling, and buffer-age repair for the extra pass.

Suppress overlays when their conversion framebuffer is unavailable rather
than allowing unconverted sRGB draws into an HDR target.

Use named transfer constants for cursor shader state. Convert clipped
surface rectangles explicitly before using the frame-damage merge helper,
which avoids mixing its unsigned type with the EGL surface rectangle type.

Only advertise native PQ when the FP16 composition target is available.
SDR and native scRGB retain their direct single-pass paths.
This commit is contained in:
Geoffrey McRae
2026-07-19 17:34:27 +10:00
parent 29cfa58e3b
commit 79b031f061
19 changed files with 726 additions and 191 deletions

View File

@@ -2,15 +2,11 @@
precision highp float;
precision highp int;
#include "hdr.h"
in vec2 uv;
out vec4 color;
uniform sampler2D sampler1;
uniform float scale;
uniform int wireTransfer; // 0: sRGB, 1: scRGB, 2: PQ
uniform float sdrWhiteLevel;
void main()
{
@@ -30,13 +26,7 @@ void main()
if (tmp.rgb == vec3(0.0, 0.0, 0.0))
discard;
if (wireTransfer == 2)
{
vec3 linear = bt709to2020(srgb2lin(tmp.rgb));
tmp.rgb = lin2pq(linear * (sdrWhiteLevel / 10000.0));
}
else if (wireTransfer == 1)
tmp.rgb = srgb2lin(tmp.rgb) * (sdrWhiteLevel / 80.0);
// Logical cursor masks operate on the encoded destination values. Their
// binary AND/XOR operands must not be colour converted.
color = tmp;
}

View File

@@ -12,11 +12,15 @@ uniform sampler2D sampler1;
uniform sampler2D sampler2;
uniform float scale;
uniform int cbMode;
uniform int wireTransfer; // 0: sRGB, 1: scRGB, 2: PQ
uniform int sourceTransfer;
uniform float sdrWhiteLevel;
uniform bool mapHDRtoSDR;
uniform float mapHDRGain;
uniform float mapHDRContentPeak;
uniform uint colorTransformFlags;
uniform vec4 colorMatrix[3];
uniform float colorTransformScalar;
uniform bool linearComposition;
const uint COLOR_TRANSFORM_MATRIX = 1u;
const uint COLOR_TRANSFORM_LUT = 2u;
@@ -47,10 +51,10 @@ vec3 xyzToBT2020(vec3 xyz)
vec3 applyColorLUT(vec3 value)
{
vec3 pos = clamp(value, 0.0, 1.0) * 4095.0;
ivec3 lo = ivec3(floor(pos));
ivec3 hi = min(lo + ivec3(1), ivec3(4095));
vec3 f = fract(pos);
vec3 pos = clamp(value, 0.0, 1.0) * 4095.0;
ivec3 lo = ivec3(floor(pos));
ivec3 hi = min(lo + ivec3(1), ivec3(4095));
vec3 f = fract(pos);
return vec3(
mix(texelFetch(sampler2, ivec2(lo.r, 0), 0).r,
texelFetch(sampler2, ivec2(hi.r, 0), 0).r, f.r),
@@ -74,10 +78,9 @@ void main()
else
color = texture(sampler1, uv);
if (cbMode > 0)
color = cbTransform(color, cbMode);
if (color.a > 0.0 && (wireTransfer != 0 || colorTransformFlags != 0u))
if (color.a > 0.0 &&
(sourceTransfer != TRANSFER_SRGB ||
colorTransformFlags != 0u || cbMode > 0))
{
// Cursor pixels are premultiplied sRGB. Work on straight, linear BT.709
// values through the XYZ calibration stage, encode for the active wire
@@ -90,20 +93,36 @@ void main()
dot(vec4(xyz, 1.0), colorMatrix[0]),
dot(vec4(xyz, 1.0), colorMatrix[1]),
dot(vec4(xyz, 1.0), colorMatrix[2])) * colorTransformScalar;
value = wireTransfer == 2 ? xyzToBT2020(xyz) : xyzToBT709(xyz);
value = sourceTransfer == TRANSFER_PQ ?
xyzToBT2020(xyz) : xyzToBT709(xyz);
}
else if (wireTransfer == 2)
else if (sourceTransfer == TRANSFER_PQ)
value = bt709to2020(value);
if (wireTransfer == 2)
if (sourceTransfer == TRANSFER_PQ)
value = lin2pq(max(value, 0.0) * (sdrWhiteLevel / 10000.0));
else if (wireTransfer == 1)
else if (sourceTransfer == TRANSFER_SCRGB)
value *= sdrWhiteLevel / 80.0;
else
value = lin2srgb(max(value, 0.0));
if ((colorTransformFlags & COLOR_TRANSFORM_LUT) != 0u)
value = applyColorLUT(value);
if (mapHDRtoSDR)
value = mapToSDR(value, mapHDRGain,
mapHDRContentPeak, sourceTransfer == TRANSFER_PQ);
// Native PQ surfaces are composed through a linear scRGB framebuffer.
// Preserve the guest's transfer-domain LUT by decoding it only after the
// complete guest transform has been applied.
else if (linearComposition && sourceTransfer == TRANSFER_PQ)
value = bt2020to709(pq2lin(value, 1.0)) * 125.0;
// Apply SDR-only accessibility transforms after HDR-to-SDR mapping and
// to straight colour so translucent cursor edges remain premultiplied
// correctly.
if (cbMode > 0)
value = cbTransform(vec4(value, 1.0), cbMode).rgb;
color.rgb = value * color.a;
}
else if (color.a == 0.0)

View File

@@ -2,9 +2,17 @@
precision highp float;
precision highp int;
#include "hdr.h"
out vec4 color;
uniform bool linearOutput;
uniform float referenceWhiteLevel;
void main()
{
color = vec4(1.0, 1.0, 0.0, 0.5);
vec3 value = vec3(1.0, 1.0, 0.0);
if (linearOutput)
value = srgb2lin(value) * (referenceWhiteLevel / 80.0);
color = vec4(value, 0.5);
}

View File

@@ -24,15 +24,17 @@ uniform int cbMode;
uniform bool isHDR;
uniform bool mapHDRtoSDR;
uniform float mapHDRGain;
uniform float mapHDRContentPeak;
uniform bool mapHDRPQ;
uniform bool outputHDRLinear;
vec4 samplePQLinear(vec2 coord)
vec3 samplePQLinear(vec2 coord)
{
ivec2 size = textureSize(sampler1, 0);
vec2 samplePos = coord * vec2(size) - 0.5;
ivec2 base = ivec2(floor(samplePos));
vec2 f = fract(samplePos);
ivec2 limit = size - ivec2(1);
ivec2 size = textureSize(sampler1, 0);
vec2 samplePos = coord * vec2(size) - 0.5;
ivec2 base = ivec2(floor(samplePos));
vec2 f = fract(samplePos);
ivec2 limit = size - ivec2(1);
vec3 c00 = pq2lin(texelFetch(sampler1,
clamp(base, ivec2(0), limit), 0).rgb, 1.0);
@@ -44,11 +46,12 @@ vec4 samplePQLinear(vec2 coord)
clamp(base + ivec2(1, 1), ivec2(0), limit), 0).rgb, 1.0);
vec3 linear = mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y);
return vec4(lin2pq(max(linear, 0.0)), 1.0);
return max(linear, 0.0);
}
void main()
{
bool sampledPQLinear = false;
switch (scaleAlgo)
{
case EGL_SCALE_NEAREST:
@@ -60,13 +63,28 @@ void main()
case EGL_SCALE_LINEAR:
{
color = isHDR && mapHDRPQ ? samplePQLinear(uv) : texture(sampler1, uv);
if (isHDR && mapHDRPQ)
{
vec3 linear = samplePQLinear(uv);
color = vec4(
outputHDRLinear ? linear : lin2pq(linear), 1.0);
sampledPQLinear = outputHDRLinear;
}
else
color = texture(sampler1, uv);
break;
}
}
if (isHDR && mapHDRtoSDR)
color.rgb = mapToSDR(color.rgb, mapHDRGain, mapHDRPQ);
color.rgb = mapToSDR(color.rgb, mapHDRGain,
mapHDRContentPeak, mapHDRPQ);
else if (isHDR && outputHDRLinear && mapHDRPQ)
{
vec3 linear2020 = sampledPQLinear ?
color.rgb : pq2lin(color.rgb, 1.0);
color.rgb = bt2020to709(linear2020) * 125.0;
}
// The legacy effects are defined for an SDR signal. Do not apply them to a
// native HDR signal where their nonlinear operations would alter luminance

View File

@@ -29,47 +29,24 @@ const float c1 = 3424.0 / 4096.0;
const float c2 = 2413.0 / 128.0;
const float c3 = 2392.0 / 128.0;
float minGain(vec3 pixel) { return min(pixel.r, min(pixel.g, pixel.b)); }
float maxGain(vec3 pixel) { return max(pixel.r, max(pixel.g, pixel.b)); }
float midGain(vec3 pixel)
{
return pixel.r < pixel.g ?
(pixel.r < pixel.b ?
min(pixel.g, pixel.b) : // min = r
min(pixel.r, pixel.g)) : // min = b
(pixel.g < pixel.b ?
min(pixel.r, pixel.b) : // min = g
min(pixel.r, pixel.g)); // min = b
}
const int TRANSFER_SRGB = 0;
const int TRANSFER_SCRGB = 1;
const int TRANSFER_PQ = 2;
vec3 compress(vec3 pixel)
{
float maxGain = maxGain(pixel);
return pixel * (maxGain < knee ? maxGain :
knee + max(maxGain - knee, 0.0) * compressor) / maxGain;
}
vec3 fixClip(vec3 pixel)
{
// keep the (mid - min) / (max - min) ratio
float preMin = minGain(pixel);
float preMid = midGain(pixel);
float preMax = maxGain(pixel);
vec3 clip = clamp(pixel, 0.0, 1.0);
float postMin = minGain(clip);
float postMid = midGain(clip);
float postMax = maxGain(clip);
float ratio = (preMid - preMin) / (preMax - preMin);
float newMid = ratio * (postMax - postMin) + postMin;
return vec3(clip.r != postMid ? clip.r : newMid,
clip.g != postMid ? clip.g : newMid,
clip.b != postMid ? clip.b : newMid);
pixel = max(pixel, 0.0);
float peak = max(pixel.r, max(pixel.g, pixel.b));
if (peak <= 0.0)
return vec3(0.0);
return pixel * (peak < knee ? peak :
knee + (peak - knee) * compressor) / peak;
}
// Returns luminance in nits
vec3 pq2lin(vec3 pq, float gain)
{
vec3 p = pow(pq, vec3(m2inv));
vec3 p = pow(max(pq, 0.0), vec3(m2inv));
vec3 d = max(p - c1, vec3(0.0)) / (c2 - c3 * p);
return pow(d, vec3(m1inv)) * gain;
}
@@ -77,25 +54,25 @@ vec3 pq2lin(vec3 pq, float gain)
vec3 lin2pq(vec3 linear)
{
// ST.2084 uses absolute luminance normalized to 10000 cd/m².
vec3 p = pow(linear, vec3(m1));
vec3 p = pow(max(linear, 0.0), vec3(m1));
return pow((c1 + c2 * p) / (1.0 + c3 * p), vec3(m2));
}
vec3 srgb2lin(vec3 c)
{
vec3 v = c / 12.92;
vec3 v2 = pow((c + vec3(0.055)) / 1.055, vec3(2.4));
vec3 v = c / 12.92;
vec3 v2 = pow((c + vec3(0.055)) / 1.055, vec3(2.4));
vec3 threshold = vec3(0.04045);
vec3 result = mix(v, v2, greaterThanEqual(c, threshold));
vec3 result = mix(v, v2, greaterThanEqual(c, threshold));
return result;
}
vec3 lin2srgb(vec3 c)
{
vec3 v = c * 12.92;
vec3 v2 = pow(c, vec3(1.0/2.4)) * 1.055 - 0.055;
vec3 v = c * 12.92;
vec3 v2 = pow(max(c, 0.0), vec3(1.0/2.4)) * 1.055 - 0.055;
vec3 threshold = vec3(0.0031308);
vec3 result = mix(v, v2, greaterThanEqual(c, threshold));
vec3 result = mix(v, v2, greaterThanEqual(c, threshold));
return result;
}
@@ -103,29 +80,42 @@ vec3 lin2srgb(vec3 c)
vec3 bt2020to709(vec3 bt2020)
{
return vec3(
bt2020.r * 1.6605 + bt2020.g * -0.5876 + bt2020.b * -0.0728,
bt2020.r * -0.1246 + bt2020.g * 1.1329 + bt2020.b * -0.0083,
bt2020.r * -0.0182 + bt2020.g * -0.1006 + bt2020.b * 1.1187);
bt2020.r * 1.6604910 + bt2020.g * -0.5876411 + bt2020.b * -0.0728499,
bt2020.r * -0.1245505 + bt2020.g * 1.1328999 + bt2020.b * -0.0083494,
bt2020.r * -0.0181508 + bt2020.g * -0.1005789 + bt2020.b * 1.1187297);
}
// in linear space
vec3 bt709to2020(vec3 bt709)
{
return vec3(
bt709.r * 0.6274 + bt709.g * 0.3293 + bt709.b * 0.0433,
bt709.r * 0.0691 + bt709.g * 0.9195 + bt709.b * 0.0114,
bt709.r * 0.0164 + bt709.g * 0.0880 + bt709.b * 0.8956);
bt709.r * 0.6274039 + bt709.g * 0.3292830 + bt709.b * 0.0433131,
bt709.r * 0.0690973 + bt709.g * 0.9195404 + bt709.b * 0.0113623,
bt709.r * 0.0163914 + bt709.g * 0.0880133 + bt709.b * 0.8955953);
}
vec3 mapToSDR(vec3 color, float gain, bool pq)
vec3 mapToSDR(vec3 color, float gain, float contentPeak, bool pq)
{
if (pq)
{
// HDR10: PQ-encoded BT.2020. Linearise then convert the gamut to BT.709.
// HDR10: PQ-encoded BT.2020. Decode the absolute 10000-nit PQ range into
// values relative to the configured SDR display peak. MaxCLL constrains
// content luminance without changing that absolute unit conversion.
color = pq2lin(color.rgb, gain);
float luminance2020 =
dot(color, vec3(0.2627002, 0.6779981, 0.0593017));
if (contentPeak > 0.0 && luminance2020 > contentPeak)
color *= contentPeak / luminance2020;
color = bt2020to709(color);
}
// else: scRGB is already linear BT.709 (1.0 == SDR white), so no EOTF or
// gamut conversion is required - only highlight compression below.
return lin2srgb(compress(color));
else
{
// scRGB is linear BT.709 with 1.0 fixed at 80 nits. Use the same
// absolute-luminance mapping as PQ before compressing the highlights.
color *= gain;
float luminance709 = dot(color, vec3(0.2126390, 0.7151687, 0.0721923));
if (contentPeak > 0.0 && luminance709 > contentPeak)
color *= contentPeak / luminance709;
}
return lin2srgb(clamp(compress(color), 0.0, 1.0));
}

View File

@@ -0,0 +1,18 @@
#version 300 es
precision highp float;
#include "hdr.h"
in vec2 fragCoord;
out vec4 color;
uniform sampler2D sampler1;
void main()
{
vec3 scRGB = texture(sampler1, fragCoord).rgb;
// Preserve negative BT.709 components used to represent colours outside
// the BT.709 gamut. Clamp only after rotating back into BT.2020.
vec3 linear2020 = bt709to2020(scRGB * (80.0 / 10000.0));
color = vec4(lin2pq(max(linear2020, 0.0)), 1.0);
}

View File

@@ -0,0 +1,13 @@
#version 300 es
precision highp float;
layout(location = 0) in vec3 vertex;
layout(location = 1) in vec2 coord;
out vec2 fragCoord;
void main()
{
gl_Position = vec4(vertex, 1.0);
fragCoord = coord;
}