git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit5eadbb3d63dfb7abb3b98b3ba8104adcae419f40
parent83a3ca03a3
authorLucas Galante <[email protected]>
date2026-08-24 12:12
fix: sample user images as sRGB, not UNORM

The swapchain is an sRGB format (renderer.rs prefers one), so the hardware
encodes shader output on write. User images were created as
R8G8B8A8_UNORM, which hands the sampler their sRGB bytes as if they were
linear — encoding them a second time and lightening every midtone. Only
pure black and white survived, so it read as "images look washed out"
rather than as a color cast.

Measured in cce-preview on a synthetic swatch, before -> after:
#101010 (71,71,71) -> (16,16,16); #404040 (137) -> (64); #808080 (188) ->
(128); #204060 (99,137,165) -> (32,64,96); #c87828 (229,182,110) ->
(200,120,40). Exact round trip on every patch, same in cce-browser on a
rendered page. sRGB formats leave alpha linear, so icon edges and any
straight-alpha artwork blend as before.

This is only about pixels that arrive already sRGB-encoded — rasterized
SVGs, decoded PNGs, Servo's page readback. The vector palette shares the
same double-encode but as a CONVENTION, not a bug: those constants are
linear floats tuned by eye in this pipeline (cce-preview's [0.13, 0.13,
0.14] backdrop measures (101,101,104), linear->sRGB of 0.13). Converting
them would darken the whole DE and invalidate every tuned color, so they
are deliberately left alone.

Takes effect per binary: an app shows the corrected images only once it
is rebuilt against this cce-ui.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/vk/image.rs | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/src/vk/image.rs b/src/vk/image.rs
index c970497..c4bf01c 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -348,7 +348,15 @@ impl ImageStage {
                 .create_image(
                     &vk::ImageCreateInfo::default()
                         .image_type(vk::ImageType::TYPE_2D)
-                        .format(vk::Format::R8G8B8A8_UNORM)
+                        // SRGB, not UNORM: uploaded pixels are sRGB-encoded
+                        // (rasterized SVGs, decoded PNGs, Servo page readback),
+                        // and the swapchain is an sRGB format, so the hardware
+                        // encodes shader output on write. Sampling as UNORM
+                        // fed those bytes through as if linear and encoded
+                        // them a second time, lightening every midtone —
+                        // a page's #101010 measured (71,71,71) on screen.
+                        // Decoding on sample makes the round trip exact.
+                        .format(vk::Format::R8G8B8A8_SRGB)
                         .extent(vk::Extent3D { width, height, depth: 1 })
                         .mip_levels(1)
                         .array_layers(1)
@@ -465,7 +473,7 @@ impl ImageStage {
                     &vk::ImageViewCreateInfo::default()
                         .image(image)
                         .view_type(vk::ImageViewType::TYPE_2D)
-                        .format(vk::Format::R8G8B8A8_UNORM)
+                        .format(vk::Format::R8G8B8A8_SRGB)
                         .subresource_range(range),
                     None,
                 )