Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
d0415b0
feat: Implement wp_color_management_v1 Wayland protocol
sophietheopossum Jul 5, 2026
1947eec
Add HDR output formats and color management infrastructure
sophietheopossum Jul 5, 2026
6a1ee0b
Add parametric color descriptions and dynamic output mode resolution
sophietheopossum Jul 5, 2026
29190fa
Add color primary chromaticity data
sophietheopossum Jul 5, 2026
634e0b0
Add EDID HDR probing and DRM HDR10 signaling
sophietheopossum Jul 5, 2026
56bb0d2
Add full `wp_color_management_v1` protocol implementation
sophietheopossum Jul 5, 2026
81d5fe0
make module public
sophietheopossum Jul 5, 2026
8b5b871
add public clour module
sophietheopossum Jul 5, 2026
28512ea
add colour management states
sophietheopossum Jul 5, 2026
eb367b9
Initialize output color management on connector connect
sophietheopossum Jul 5, 2026
465584b
Destroy HDR property blobs on output disconnect
sophietheopossum Jul 5, 2026
e971c9a
Refactor HDR metadata blob destruction
sophietheopossum Jul 5, 2026
49fc007
Update DRM enum property value access
sophietheopossum Jul 5, 2026
cf3d032
Add tests for HDR EDID parsing and DRM metadata structure alignment
sophietheopossum Jul 5, 2026
a108aca
Implement colorimetry calculations and PQ curves
sophietheopossum Jul 5, 2026
c871ec3
add colorimetry module
sophietheopossum Jul 5, 2026
fc841c1
add pq encode shader
sophietheopossum Jul 5, 2026
8894640
create hdr pipeline module
sophietheopossum Jul 5, 2026
7153e84
Remove unused `ModeTypeFlags` import and workaround
sophietheopossum Jul 6, 2026
a726366
Add HDR pipeline module to backend
sophietheopossum Jul 6, 2026
6c7ceb9
Add HdrEncode render element to TTY backend
sophietheopossum Jul 6, 2026
d4e0e9f
Add HdrEncode render element name to TTY backend
sophietheopossum Jul 6, 2026
aa08733
Add GPU FP16 capability flag to TTY backend
sophietheopossum Jul 6, 2026
5bb2652
Probe and store GPU FP16 render support in TTY backend
sophietheopossum Jul 6, 2026
19d545f
Render HDR10 output and gate on FP16 support
sophietheopossum Jul 6, 2026
d3b1aa6
Prevent HDR encode pipeline bypasses
sophietheopossum Jul 6, 2026
f6defdd
Store per-output HDR10 composite and PQ encode pipelines
sophietheopossum Jul 6, 2026
47267a7
Add HDR pipeline storage to main ShojiWM state
sophietheopossum Jul 6, 2026
1e52615
add the last few touches
sophietheopossum Jul 6, 2026
72e8170
update comment
sophietheopossum Jul 6, 2026
cecc2a5
Defer image description info delivery to prevent write-after-free
sophietheopossum Jul 6, 2026
db9ee51
make send_information public
sophietheopossum Jul 6, 2026
2488d69
Defer `GetInformation` response to prevent write-after-free
sophietheopossum Jul 6, 2026
c7df46c
remove suppression
sophietheopossum Jul 6, 2026
3f046b4
Defer WpImageDescriptionInfoV1 response to prevent write-after-free
sophietheopossum Jul 6, 2026
b12e12e
add hdr option to config
sophietheopossum Jul 7, 2026
47efe93
Introduce dynamic HDR configuration state
sophietheopossum Jul 7, 2026
90a1ca0
Drive HDR output color mode by explicit configuration request
sophietheopossum Jul 7, 2026
bc27bdf
Implement dynamic color mode refresh for TTY outputs
sophietheopossum Jul 7, 2026
faed9f5
Qualify `LogicalRect` with `crate::ssd`
sophietheopossum Jul 7, 2026
2633570
Configure session HDR and TTY color modes
sophietheopossum Jul 7, 2026
830ad74
Introduce HDR configuration for outputs
sophietheopossum Jul 7, 2026
29bd81e
Add test for HDR flag parsing in runtime output config
sophietheopossum Jul 7, 2026
92aa074
add ability to check if hdr is supported
sophietheopossum Jul 9, 2026
86fbd76
implement scrgb support
sophietheopossum Jul 29, 2026
e318c8b
Add per-surface color management to fragment shader
sophietheopossum Jul 30, 2026
0570499
Correctly apply color transfer to premultiplied alpha
sophietheopossum Jul 30, 2026
4142065
Add sRGB EOTF to surface color management
sophietheopossum Jul 30, 2026
9c77355
Resolve surface color tags into shader uniforms
sophietheopossum Jul 30, 2026
3a3fa20
Propagate surface color tags to rendering elements
sophietheopossum Jul 30, 2026
aa0202b
Apply color management to subsurfaces
sophietheopossum Jul 30, 2026
7a69d15
Use element's specific description for clipped elements
sophietheopossum Jul 30, 2026
21354ae
Resolve element description before `element` is consumed
sophietheopossum Jul 30, 2026
8b4fefc
Implement ITU-R BT.2390 EETF for HDR tone mapping
sophietheopossum Jul 30, 2026
510f778
Integrate BT.2390 EETF with explicit tone mapping parameters
sophietheopossum Jul 30, 2026
5d800b7
Calculate BT.2390 PQ knee points from luminance metadata
sophietheopossum Jul 30, 2026
4d94510
fix declaration order
sophietheopossum Jul 30, 2026
f02ec56
preserve rounded corners
sophietheopossum Jul 30, 2026
2164ed5
clarify comment
sophietheopossum Jul 30, 2026
c05076a
avoid deadlock during surface tree traversal
sophietheopossum Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/shoji_wm/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function cloneOutputState(
position: { ...snapshot.position },
scale: snapshot.scale,
availableModes: snapshot.availableModes.map((mode) => ({ ...mode })),
hdrSupported: snapshot.hdrSupported,
},
]),
);
Expand Down Expand Up @@ -65,6 +66,7 @@ function normalizeOutputState(
position: { ...snapshot.position },
scale: snapshot.scale,
availableModes: snapshot.availableModes.map((mode) => ({ ...mode })),
hdrSupported: snapshot.hdrSupported,
},
]),
);
Expand Down Expand Up @@ -101,6 +103,7 @@ function cloneOutputConfigEntry(config: OutputConfigEntry): OutputConfigEntry {
? { ...config.position }
: undefined,
scale: config.scale,
hdr: config.hdr,
};
}

Expand Down
10 changes: 10 additions & 0 deletions packages/shoji_wm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,14 @@ export interface OutputExtendConfigEntry {
resolution?: OutputResolutionPreference;
position?: OutputPositionPreference;
scale?: number;
/**
* Drive this output as HDR10 (PQ/BT.2020 signaling) when its EDID
* advertises ST 2084 support; ignored otherwise. Experimental: SDR
* content is composited in sRGB and PQ-encoded as a final pass.
* この出力の EDID が ST 2084 対応を示す場合、HDR10(PQ/BT.2020)で
* 駆動します。実験的機能です。
*/
hdr?: boolean;
}

export interface OutputDisabledConfigEntry {
Expand Down Expand Up @@ -1010,6 +1018,8 @@ export interface OutputStateSnapshot {
};
scale: number;
availableModes: OutputMode[];
/** EDID advertises HDR (CTA-861 static metadata block). */
hdrSupported?: boolean;
}

export interface OutputInfo extends OutputStateSnapshot {
Expand Down
176 changes: 172 additions & 4 deletions src/shojiwm/src/backend/clipped_surface.frag
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
precision highp float;

uniform float alpha;
varying vec2 v_coords;
// Smithay substitutes the variant defines at the marker below, by plain string
// replacement (see gles/shaders/mod.rs). Without it the replacement is a no-op
// and all three compiled variants get identical source with EXTERNAL and
// NO_ALPHA *undefined* — an external-OES buffer would then be sampled through a
// `sampler2D` declaration, and NO_ALPHA (which smithay documents as mandatory
// for custom texture shaders) would never be honoured.
//
// Do not write the marker's name anywhere else in this file: the replacement is
// global, so a second occurrence — even inside a comment — would inject a
// `#define` mid-line and break the following line out of its comment.
//_DEFINES_

// `#extension` must precede every non-preprocessor token in GLSL ES 1.00, so
// it has to come before `precision` and any declaration.
#if defined(EXTERNAL)
#extension GL_OES_EGL_image_external : require
#endif

precision highp float;

#if defined(EXTERNAL)
uniform samplerExternalOES tex;
#else
uniform sampler2D tex;
#endif

uniform float alpha;
varying vec2 v_coords;
uniform float clip_scale;
uniform vec2 slot_size;
uniform vec2 slot_origin;
Expand All @@ -28,6 +41,148 @@ uniform vec2 sample_buffer_size;
uniform vec2 sample_uv_snap_axes;
uniform float sample_uv_compensation_enabled;

// Per-surface color management. Compositing happens on sRGB-encoded BT.709
// values, so content tagged with any other transfer/primaries via
// `wp_color_management_v1` has to be converted on the way in — otherwise it is
// decoded as if it were sRGB (which it never was) and again by
// `output_encode.frag`, which is what makes PQ video look washed out.
//
// This is the SDR-correct conversion: HDR content is tone-mapped down into the
// compositing range rather than carried at HDR luminance, because the encode
// pass clamps to [0,1] before its own transfer. Carrying real HDR through
// needs linear compositing and is a separate change.
//
// 0 = passthrough (untagged, or already sRGB in sRGB primaries), 1 = ST 2084 PQ,
// 2 = extended linear (scRGB), 3 = sRGB transfer needing only a gamut change.
uniform float src_transfer;
// 0 = BT.709/sRGB primaries, 1 = BT.2020.
uniform float src_primaries;
// Content reference white in cd/m², from the surface's `Luminances` (or the
// protocol's per-transfer-function defaults when unset). Doubles as the
// compositing space's white point: 1.0 here means this many nits.
uniform float src_ref_nits;
// Tone-mapping knee parameters, already in the PQ domain because that is where
// BT.2390 operates. Precomputed on the CPU via `color::colorimetry::
// pq_inverse_eotf` — deriving them here would cost four extra pow-heavy calls
// per fragment for values that are constant across the whole surface.
uniform float src_pq_lo; // content black
uniform float src_pq_hi; // content peak
uniform float dst_pq_hi; // what compositing-space 1.0 can represent

// BT.2020 -> BT.709 linear-light gamut matrix, column-major. Inverse of the
// BT.2087 matrix in `output_encode.frag`; both are cross-checked against the
// CPU derivation in `color/colorimetry.rs`.
const mat3 BT2020_TO_BT709 = mat3(
1.660491, -0.124550, -0.018151,
-0.587641, 1.132900, -0.100579,
-0.072850, -0.008349, 1.118730
);

// SMPTE ST 2084 (PQ) EOTF: PQ signal -> absolute cd/m². Inverse of
// `pq_inv_eotf` in `output_encode.frag`.
vec3 pq_eotf(vec3 e) {
const float m1 = 0.1593017578125; // 1305/8192
const float m2 = 78.84375; // 2523/32
const float c1 = 0.8359375; // 107/128
const float c2 = 18.8515625; // 2413/128
const float c3 = 18.6875; // 2392/128
vec3 ep = pow(clamp(e, 0.0, 1.0), vec3(1.0 / m2));
vec3 num = max(ep - vec3(c1), vec3(0.0));
vec3 den = max(vec3(c2) - c3 * ep, vec3(0.000001));
return 10000.0 * pow(num / den, vec3(1.0 / m1));
}

// sRGB inverse EOTF (IEC 61966-2-1 piecewise encode).
vec3 srgb_inv_eotf(vec3 c) {
vec3 lo = c * 12.92;
vec3 hi = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
return mix(hi, lo, vec3(lessThanEqual(c, vec3(0.0031308))));
}

// SMPTE ST 2084 (PQ) inverse EOTF: absolute cd/m² -> PQ signal. Mirrors the
// one in `output_encode.frag`; needed here to lift non-PQ sources into the
// domain BT.2390 works in.
vec3 pq_inv_eotf(vec3 nits) {
const float m1 = 0.1593017578125;
const float m2 = 78.84375;
const float c1 = 0.8359375;
const float c2 = 18.8515625;
const float c3 = 18.6875;
vec3 y = clamp(nits / 10000.0, 0.0, 1.0);
vec3 ym = pow(y, vec3(m1));
return pow((vec3(c1) + c2 * ym) / (vec3(1.0) + c3 * ym), vec3(m2));
}

// ITU-R BT.2390 EETF, applied per channel on a PQ signal.
//
// Below the knee this is the *identity*, so content already inside the target
// range passes through untouched — the decisive advantage over a global curve
// like Reinhard, which pulls midtones down alongside highlights and leaves the
// whole image flat. Above the knee a Hermite spline rolls smoothly off to the
// target peak. Operating in PQ rather than linear light spreads the
// compression error evenly across *perceived* brightness.
float bt2390_channel(float e) {
float range = max(src_pq_hi - src_pq_lo, 0.000001);
float e1 = clamp((e - src_pq_lo) / range, 0.0, 1.0);
float max_lum = clamp((dst_pq_hi - src_pq_lo) / range, 0.0, 1.0);
float ks = 1.5 * max_lum - 0.5;

float e2 = e1;
if (ks < 1.0 && e1 > ks) {
float t = (e1 - ks) / (1.0 - ks);
float t2 = t * t;
float t3 = t2 * t;
e2 = (2.0 * t3 - 3.0 * t2 + 1.0) * ks
+ (t3 - 2.0 * t2 + t) * (1.0 - ks)
+ (-2.0 * t3 + 3.0 * t2) * max_lum;
}
return e2 * range + src_pq_lo;
}

vec3 bt2390_eetf(vec3 pq) {
return vec3(
bt2390_channel(pq.r),
bt2390_channel(pq.g),
bt2390_channel(pq.b)
);
}

// sRGB EOTF (IEC 61966-2-1 piecewise decode).
vec3 srgb_eotf(vec3 c) {
vec3 lo = c / 12.92;
vec3 hi = pow((max(c, vec3(0.0)) + vec3(0.055)) / 1.055, vec3(2.4));
return mix(hi, lo, vec3(lessThanEqual(c, vec3(0.04045))));
}

// Convert one sampled, *unpremultiplied* texel into the compositing space.
vec3 to_compositing_space(vec3 c) {
float ref_nits = max(src_ref_nits, 0.0001);

// Lift the source into the PQ domain, which is where the tone curve works.
vec3 pq;
if (src_transfer > 2.5) {
// sRGB transfer in wider primaries: decode, scale to absolute nits.
pq = pq_inv_eotf(srgb_eotf(c) * ref_nits);
} else if (src_transfer > 1.5) {
// Extended linear (scRGB): 1.0 is defined as 80 cd/m².
pq = pq_inv_eotf(max(c, vec3(0.0)) * 80.0);
} else {
// Already a PQ signal.
pq = clamp(c, 0.0, 1.0);
}

// Compress the content's range into what the compositing space can hold.
// SDR sources land entirely below the knee and pass through unchanged.
vec3 linear = pq_eotf(bt2390_eetf(pq));

if (src_primaries > 0.5) {
linear = BT2020_TO_BT709 * linear;
}
// Normalize against the content's own reference white so diffuse white
// lands on 1.0 rather than being scaled by an unrelated display value.
return srgb_inv_eotf(clamp(linear / ref_nits, 0.0, 1.0));
}

float rounded_alpha(vec2 coords, vec2 size) {
if (coords.x < 0.0 || coords.y < 0.0 || coords.x > size.x || coords.y > size.y) {
return 0.0;
Expand Down Expand Up @@ -80,6 +235,19 @@ void main() {
}

vec4 color = texture2D(tex, sample_coords);
#if defined(NO_ALPHA)
// Buffer carries no alpha channel. Force it here, immediately after the
// sample — *not* at the end, where it would overwrite the rounded-corner
// coverage written by `rounded_alpha` below and square off every corner.
color = vec4(color.rgb, 1.0);
#endif
if (src_transfer > 0.5) {
// Wayland buffers carry premultiplied alpha. A transfer function is
// non-linear, so it has to be applied to the unpremultiplied value or
// partially transparent edges pick up haloes; re-premultiply after.
float a = max(color.a, 0.0001);
color.rgb = to_compositing_space(color.rgb / a) * a;
}
vec2 local_coords = (input_to_local * vec3(v_coords, 1.0)).xy;
if (rect_bounds_enabled > 0.5) {
vec2 slot_coords = local_coords - slot_origin;
Expand Down
100 changes: 100 additions & 0 deletions src/shojiwm/src/backend/clipped_surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,16 @@ pub struct ClippedSurfaceElement {
sample_buffer_size: [f32; 2],
sample_uv_snap_axes: [f32; 2],
sample_uv_compensation_enabled: f32,
/// Per-surface color management, resolved once at element construction.
/// `src_transfer == 0.0` is the untagged fast path and makes the shader
/// skip the conversion entirely, so nothing changes for sRGB clients.
src_transfer: f32,
src_primaries: f32,
src_ref_nits: f32,
/// Tone-mapping knee, already PQ-encoded on the CPU.
src_pq_lo: f32,
src_pq_hi: f32,
dst_pq_hi: f32,
}

#[derive(Debug)]
Expand Down Expand Up @@ -207,6 +217,7 @@ impl ClippedSurfaceElement {
clip: ContentClip,
forced_geometry: Option<Rectangle<i32, Physical>>,
debug_label: Option<String>,
image_description: Option<crate::color::ImageDescription>,
) -> Result<Self, GlesError> {
if renderer
.egl_context()
Expand Down Expand Up @@ -273,6 +284,30 @@ impl ClippedSurfaceElement {
"sample_uv_compensation_enabled",
smithay::backend::renderer::gles::UniformType::_1f,
),
UniformName::new(
"src_transfer",
smithay::backend::renderer::gles::UniformType::_1f,
),
UniformName::new(
"src_primaries",
smithay::backend::renderer::gles::UniformType::_1f,
),
UniformName::new(
"src_ref_nits",
smithay::backend::renderer::gles::UniformType::_1f,
),
UniformName::new(
"src_pq_lo",
smithay::backend::renderer::gles::UniformType::_1f,
),
UniformName::new(
"src_pq_hi",
smithay::backend::renderer::gles::UniformType::_1f,
),
UniformName::new(
"dst_pq_hi",
smithay::backend::renderer::gles::UniformType::_1f,
),
],
)?);
renderer
Expand Down Expand Up @@ -482,6 +517,59 @@ impl ClippedSurfaceElement {
}
}

// Resolve the surface's color-management tag into shader uniforms once,
// here, rather than per-fragment. `src_transfer == 0.0` is the untagged
// path and makes the shader skip the conversion, so sRGB clients are
// byte-identical to before.
let (
src_transfer,
src_primaries,
src_ref_nits,
src_pq_lo,
src_pq_hi,
dst_pq_hi,
) = match image_description {
Some(description) => {
let primaries = match description.primaries {
crate::color::ColorPrimaries::Srgb => 0.0,
crate::color::ColorPrimaries::Bt2020 => 1.0,
};
let transfer = match description.tf {
// sRGB in sRGB primaries already *is* the compositing
// space. In a wider gamut it still needs the matrix, so it
// takes the decode-only branch instead of the fast path.
crate::color::TransferCharacteristics::Srgb if primaries == 0.0 => 0.0,
crate::color::TransferCharacteristics::Srgb => 3.0,
crate::color::TransferCharacteristics::St2084Pq => 1.0,
crate::color::TransferCharacteristics::ExtLinear => 2.0,
};
let luminances = description.effective_luminances();
// MaxCLL is the measured peak of the content; fall back to the
// description's declared maximum when the client didn't send it.
let max_nits = description
.max_cll
.map(|cll| cll as f32)
.unwrap_or(luminances.max);
// BT.2390 operates on PQ signals, so encode the knee here
// rather than paying four pow() calls per fragment for values
// that are constant across the surface. The target peak is the
// content's own reference white, which is what compositing-space
// 1.0 represents.
let to_pq = |nits: f32| {
crate::color::colorimetry::pq_inverse_eotf(nits as f64) as f32
};
(
transfer,
primaries,
luminances.reference,
to_pq(luminances.min),
to_pq(max_nits),
to_pq(luminances.reference),
)
}
None => (0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
};

Ok(Self {
inner,
geometry: render_geometry,
Expand All @@ -507,6 +595,12 @@ impl ClippedSurfaceElement {
} else {
0.0
},
src_transfer,
src_primaries,
src_ref_nits,
src_pq_lo,
src_pq_hi,
dst_pq_hi,
})
}

Expand Down Expand Up @@ -648,6 +742,12 @@ impl ClippedSurfaceElement {
"sample_uv_compensation_enabled",
self.sample_uv_compensation_enabled,
),
Uniform::new("src_transfer", self.src_transfer),
Uniform::new("src_primaries", self.src_primaries),
Uniform::new("src_ref_nits", self.src_ref_nits),
Uniform::new("src_pq_lo", self.src_pq_lo),
Uniform::new("src_pq_hi", self.src_pq_hi),
Uniform::new("dst_pq_hi", self.dst_pq_hi),
]
}
}
Expand Down
Loading