Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
4a008d1
feat: Implement wp_color_management_v1 Wayland protocol
sophietheopossum Jul 5, 2026
ce33d32
Add HDR output formats and color management infrastructure
sophietheopossum Jul 5, 2026
588bd5a
Add parametric color descriptions and dynamic output mode resolution
sophietheopossum Jul 5, 2026
4c24f63
Add color primary chromaticity data
sophietheopossum Jul 5, 2026
58caa5c
Add EDID HDR probing and DRM HDR10 signaling
sophietheopossum Jul 5, 2026
afc6472
Add full `wp_color_management_v1` protocol implementation
sophietheopossum Jul 5, 2026
dbf0158
make module public
sophietheopossum Jul 5, 2026
c4f1b7c
add public clour module
sophietheopossum Jul 5, 2026
9983921
add colour management states
sophietheopossum Jul 5, 2026
7166ef1
Initialize output color management on connector connect
sophietheopossum Jul 5, 2026
30f9a55
Destroy HDR property blobs on output disconnect
sophietheopossum Jul 5, 2026
b4766da
Refactor HDR metadata blob destruction
sophietheopossum Jul 5, 2026
1f7c34d
Update DRM enum property value access
sophietheopossum Jul 5, 2026
ec82e0f
Add tests for HDR EDID parsing and DRM metadata structure alignment
sophietheopossum Jul 5, 2026
29343a0
Implement colorimetry calculations and PQ curves
sophietheopossum Jul 5, 2026
367d5d6
add colorimetry module
sophietheopossum Jul 5, 2026
36a30c1
add pq encode shader
sophietheopossum Jul 5, 2026
d5f431a
create hdr pipeline module
sophietheopossum Jul 5, 2026
925de8f
Remove unused `ModeTypeFlags` import and workaround
sophietheopossum Jul 6, 2026
dcb049f
Add HDR pipeline module to backend
sophietheopossum Jul 6, 2026
ef82320
Add HdrEncode render element to TTY backend
sophietheopossum Jul 6, 2026
1759988
Add HdrEncode render element name to TTY backend
sophietheopossum Jul 6, 2026
4116017
Add GPU FP16 capability flag to TTY backend
sophietheopossum Jul 6, 2026
b5b6413
Probe and store GPU FP16 render support in TTY backend
sophietheopossum Jul 6, 2026
4de9a19
Render HDR10 output and gate on FP16 support
sophietheopossum Jul 6, 2026
7e6e00c
Prevent HDR encode pipeline bypasses
sophietheopossum Jul 6, 2026
f184fad
Store per-output HDR10 composite and PQ encode pipelines
sophietheopossum Jul 6, 2026
425eda1
Add HDR pipeline storage to main ShojiWM state
sophietheopossum Jul 6, 2026
7d52e52
add the last few touches
sophietheopossum Jul 6, 2026
c795ba8
update comment
sophietheopossum Jul 6, 2026
c93d059
Defer image description info delivery to prevent write-after-free
sophietheopossum Jul 6, 2026
3140544
make send_information public
sophietheopossum Jul 6, 2026
f23affb
Defer `GetInformation` response to prevent write-after-free
sophietheopossum Jul 6, 2026
38f8d3c
remove suppression
sophietheopossum Jul 6, 2026
a87ee93
Defer WpImageDescriptionInfoV1 response to prevent write-after-free
sophietheopossum Jul 6, 2026
24869c6
add hdr option to config
sophietheopossum Jul 7, 2026
efd49c1
Introduce dynamic HDR configuration state
sophietheopossum Jul 7, 2026
f3ba332
Drive HDR output color mode by explicit configuration request
sophietheopossum Jul 7, 2026
ef6f130
Implement dynamic color mode refresh for TTY outputs
sophietheopossum Jul 7, 2026
5dbef47
Qualify `LogicalRect` with `crate::ssd`
sophietheopossum Jul 7, 2026
bdcfd39
Configure session HDR and TTY color modes
sophietheopossum Jul 7, 2026
109e998
Introduce HDR configuration for outputs
sophietheopossum Jul 7, 2026
195fb1d
Add test for HDR flag parsing in runtime output config
sophietheopossum Jul 7, 2026
ff8e209
add ability to check if hdr is supported
sophietheopossum Jul 9, 2026
93d7efb
implement scrgb support
sophietheopossum Jul 29, 2026
9ef149f
Add per-surface color management to fragment shader
sophietheopossum Jul 30, 2026
3b3f586
Correctly apply color transfer to premultiplied alpha
sophietheopossum Jul 30, 2026
ff938ab
Add sRGB EOTF to surface color management
sophietheopossum Jul 30, 2026
5918f46
Resolve surface color tags into shader uniforms
sophietheopossum Jul 30, 2026
8d0fcb6
Propagate surface color tags to rendering elements
sophietheopossum Jul 30, 2026
62add04
Apply color management to subsurfaces
sophietheopossum Jul 30, 2026
35e3311
Use element's specific description for clipped elements
sophietheopossum Jul 30, 2026
b063fae
Resolve element description before `element` is consumed
sophietheopossum Jul 30, 2026
9ba59ef
Implement ITU-R BT.2390 EETF for HDR tone mapping
sophietheopossum Jul 30, 2026
50cd013
Integrate BT.2390 EETF with explicit tone mapping parameters
sophietheopossum Jul 30, 2026
9f57af4
Calculate BT.2390 PQ knee points from luminance metadata
sophietheopossum Jul 30, 2026
fa9187f
fix declaration order
sophietheopossum Jul 30, 2026
b199ecb
preserve rounded corners
sophietheopossum Jul 30, 2026
ec03292
clarify comment
sophietheopossum Jul 30, 2026
e0a873d
avoid deadlock during surface tree traversal
sophietheopossum Aug 1, 2026
1bca659
add hdr field to test
sophietheopossum Aug 18, 2026
9da1761
this variable is always declared
sophietheopossum Aug 18, 2026
3974eec
collapse if statements
sophietheopossum Aug 18, 2026
5e46fc7
Revert "fix prev merge"
sophietheopossum Aug 30, 2026
77f3f59
fix syntax error in config tests
sophietheopossum Aug 30, 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 @@ -37,6 +37,7 @@ function cloneOutputState(
scale: snapshot.scale,
transform: snapshot.transform,
availableModes: snapshot.availableModes.map((mode) => ({ ...mode })),
hdrSupported: snapshot.hdrSupported,
},
]),
);
Expand Down Expand Up @@ -67,6 +68,7 @@ function normalizeOutputState(
scale: snapshot.scale,
transform: snapshot.transform ?? "normal",
availableModes: snapshot.availableModes.map((mode) => ({ ...mode })),
hdrSupported: snapshot.hdrSupported,
},
]),
);
Expand Down Expand Up @@ -104,6 +106,7 @@ function cloneOutputConfigEntry(config: OutputConfigEntry): OutputConfigEntry {
: undefined,
scale: config.scale,
transform: config.transform,
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 @@ -1029,6 +1029,14 @@ export interface OutputExtendConfigEntry {
* なので、設定を消すと回転なしに戻ります)。
*/
transform?: OutputTransform;
/**
* 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 @@ -1071,6 +1079,8 @@ export interface OutputStateSnapshot {
/** Currently applied transform. / 現在適用されている transform。 */
transform?: OutputTransform;
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 @@ -517,6 +552,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 @@ -542,6 +630,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 @@ -690,6 +784,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