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
c5b38c6
feat: Implement wp_color_management_v1 Wayland protocol
sophietheopossum Jul 5, 2026
c35283e
Add HDR output formats and color management infrastructure
sophietheopossum Jul 5, 2026
5a20672
Add parametric color descriptions and dynamic output mode resolution
sophietheopossum Jul 5, 2026
3505a0c
Add color primary chromaticity data
sophietheopossum Jul 5, 2026
2b98ffd
Add EDID HDR probing and DRM HDR10 signaling
sophietheopossum Jul 5, 2026
b025539
Add full `wp_color_management_v1` protocol implementation
sophietheopossum Jul 5, 2026
ca3c2cd
make module public
sophietheopossum Jul 5, 2026
2b52b00
add public clour module
sophietheopossum Jul 5, 2026
f3676a8
add colour management states
sophietheopossum Jul 5, 2026
2de7d14
Initialize output color management on connector connect
sophietheopossum Jul 5, 2026
6cce697
Destroy HDR property blobs on output disconnect
sophietheopossum Jul 5, 2026
2f885fc
Refactor HDR metadata blob destruction
sophietheopossum Jul 5, 2026
d66a828
Update DRM enum property value access
sophietheopossum Jul 5, 2026
c1a16a1
Add tests for HDR EDID parsing and DRM metadata structure alignment
sophietheopossum Jul 5, 2026
c76d144
Implement colorimetry calculations and PQ curves
sophietheopossum Jul 5, 2026
1d11e62
add colorimetry module
sophietheopossum Jul 5, 2026
f2e2e54
add pq encode shader
sophietheopossum Jul 5, 2026
824a91c
create hdr pipeline module
sophietheopossum Jul 5, 2026
e67dfd5
Remove unused `ModeTypeFlags` import and workaround
sophietheopossum Jul 6, 2026
363514d
Add HDR pipeline module to backend
sophietheopossum Jul 6, 2026
c418271
Add HdrEncode render element to TTY backend
sophietheopossum Jul 6, 2026
8b7e579
Add HdrEncode render element name to TTY backend
sophietheopossum Jul 6, 2026
d49174d
Add GPU FP16 capability flag to TTY backend
sophietheopossum Jul 6, 2026
7f78a73
Probe and store GPU FP16 render support in TTY backend
sophietheopossum Jul 6, 2026
bd9aacf
Render HDR10 output and gate on FP16 support
sophietheopossum Jul 6, 2026
a8ee3c8
Prevent HDR encode pipeline bypasses
sophietheopossum Jul 6, 2026
67c0cf6
Store per-output HDR10 composite and PQ encode pipelines
sophietheopossum Jul 6, 2026
adccabe
Add HDR pipeline storage to main ShojiWM state
sophietheopossum Jul 6, 2026
f75d704
add the last few touches
sophietheopossum Jul 6, 2026
e1fac98
update comment
sophietheopossum Jul 6, 2026
eeb4441
Defer image description info delivery to prevent write-after-free
sophietheopossum Jul 6, 2026
579ddc1
make send_information public
sophietheopossum Jul 6, 2026
e9b564e
Defer `GetInformation` response to prevent write-after-free
sophietheopossum Jul 6, 2026
7ee5c39
remove suppression
sophietheopossum Jul 6, 2026
73259b5
Defer WpImageDescriptionInfoV1 response to prevent write-after-free
sophietheopossum Jul 6, 2026
eed7e3b
add hdr option to config
sophietheopossum Jul 7, 2026
9ee05f1
Introduce dynamic HDR configuration state
sophietheopossum Jul 7, 2026
e081f3f
Drive HDR output color mode by explicit configuration request
sophietheopossum Jul 7, 2026
49fbb6e
Implement dynamic color mode refresh for TTY outputs
sophietheopossum Jul 7, 2026
c56f44f
Qualify `LogicalRect` with `crate::ssd`
sophietheopossum Jul 7, 2026
207a5f0
Configure session HDR and TTY color modes
sophietheopossum Jul 7, 2026
87af74f
Introduce HDR configuration for outputs
sophietheopossum Jul 7, 2026
48a1992
Add test for HDR flag parsing in runtime output config
sophietheopossum Jul 7, 2026
a536444
add ability to check if hdr is supported
sophietheopossum Jul 9, 2026
1a3073a
implement scrgb support
sophietheopossum Jul 29, 2026
cc12223
Add per-surface color management to fragment shader
sophietheopossum Jul 30, 2026
38eb506
Correctly apply color transfer to premultiplied alpha
sophietheopossum Jul 30, 2026
1e78e93
Add sRGB EOTF to surface color management
sophietheopossum Jul 30, 2026
71791f3
Resolve surface color tags into shader uniforms
sophietheopossum Jul 30, 2026
c8d63c1
Propagate surface color tags to rendering elements
sophietheopossum Jul 30, 2026
04d7a90
Apply color management to subsurfaces
sophietheopossum Jul 30, 2026
9e6651a
Use element's specific description for clipped elements
sophietheopossum Jul 30, 2026
c89b586
Resolve element description before `element` is consumed
sophietheopossum Jul 30, 2026
8dd22f7
Implement ITU-R BT.2390 EETF for HDR tone mapping
sophietheopossum Jul 30, 2026
0af1c40
Integrate BT.2390 EETF with explicit tone mapping parameters
sophietheopossum Jul 30, 2026
bc44442
Calculate BT.2390 PQ knee points from luminance metadata
sophietheopossum Jul 30, 2026
b86747f
fix declaration order
sophietheopossum Jul 30, 2026
52fa674
preserve rounded corners
sophietheopossum Jul 30, 2026
1ece4be
clarify comment
sophietheopossum Jul 30, 2026
6714c13
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 @@ -919,6 +919,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 @@ -950,6 +958,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