Skip to content
60 changes: 48 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,30 +185,66 @@ CPU is the consumer's job; `examples/common` shows one way.

### Color Conversion (RGB → YUV)

PixelForge includes a GPU compute shader for converting RGB input to YUV output, supporting multiple color spaces:

| Color Space | Description |
|-------------|-------------|
| `Bt709` | Standard SDR (BT.709 coefficients) |
| `Bt2020` | HDR passthrough (BT.2020 coefficients, PQ-encoded input) |
| `SrgbToBt2020Pq` | SDR-in-HDR (sRGB → linear → BT.2020 gamut → PQ OETF) |
| `Bt709LinearToBt2020Pq` | scRGB HDR (linear BT.709 → BT.2020 gamut → PQ OETF). `sdr_reference_white_nits` sets the interpretation of 1.0; per the scRGB spec (IEC 61966-2-2), 80 nits. |
PixelForge includes a GPU compute shader for converting RGB input to YUV
output. The source color describes the input. The target color describes the stream.
Both are a [`ColorSpec`].

| `ColorSpec` | | Can be a target |
|-------------|-|-----------------|
| `Srgb` | Ordinary SDR content | yes |
| `Bt709Linear` | scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain | no |
| `Bt2020Linear` | Linear light, wide gamut | no |
| `Bt2020Pq` | HDR10, from an `HDR10_ST2084_EXT` swapchain | yes |

The linear spaces cannot be a target, because a video file has no way to
record that it holds linear light. Any source can be converted to
`Bt2020Pq`. Only `Srgb` can be converted to `Srgb`; the others would need
tone mapping or a gamma curve applied, which the shader does not do.
[`ColorConverter::new`] rejects the combinations it cannot do.

Encoding to HDR needs to know how bright the source's white is, since HDR
carries real brightness values and SDR does not. Each space has a sensible
default, see [`ColorSpec::reference_white_nits`], overridable with
[`ColorConverterConfig::with_reference_white_nits`].

Supported input formats: BGRx, RGBx, BGRA, RGBA, ABGR2101010 (10-bit packed), RGBA16F (FP16).
Supported output formats: NV12 (8-bit), I420 (8-bit), YUV444 (8-bit), P010 (10-bit), YUV444P10 (10-bit).

Pass [`ColorConverter::color_description`] to the encoder rather than
describing the colours a second time by hand. The two have to agree: if the
converter writes full-range pixels and the stream says limited, players
stretch the range again and the picture comes out wrong.

```rust
use pixelforge::{ColorConverter, ColorConverterConfig, ColorSpace, InputFormat, OutputFormat, VideoContextBuilder};
use pixelforge::{
Codec, ColorConverter, ColorConverterConfig, ColorRange, ColorSpec, EncodeConfig,
Encoder, InputFormat, OutputFormat, VideoContextBuilder,
};

let context = VideoContextBuilder::new()
.app_name("Color Converter")
.require_encode(Codec::H265)
.build()?;

let mut config = ColorConverterConfig::new(1920, 1080, InputFormat::BGRx, OutputFormat::NV12);
config.color_space = ColorSpace::SrgbToBt2020Pq;
// SDR desktop content, encoded as HDR10 for an HDR streaming session.
let config = ColorConverterConfig::new(
1920,
1080,
InputFormat::BGRx,
OutputFormat::P010,
ColorSpec::Srgb,
ColorSpec::Bt2020Pq,
ColorRange::Full,
);

let mut converter = ColorConverter::new(context.clone(), config)?;
// converter.convert(input_image, output_buffer)?;

// The encoder declares exactly what the shader wrote.
let encode_config = EncodeConfig::h265(1920, 1080)
.with_color_description(converter.color_description());

let mut encoder = Encoder::new(context, encode_config)?;
// converter.convert(input_image, layout, encoder.input_image())?;
```

## Benchmarking
Expand Down
59 changes: 34 additions & 25 deletions shader/color_convert.comp
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ layout(push_constant) uniform PushConstants {
uint height;
uint input_format; // 0=BGRx, 1=RGBx, 2=BGRA, 3=RGBA, 4=ABGR2101010, 5=RGBA16F
uint output_format; // 0=NV12, 1=I420, 2=YUV444, 3=P010, 4=YUV444P10
uint color_space; // 0=BT.709, 1=BT.2020, 2=sRGB→BT.2020+PQ, 3=scRGB linear→BT.2020+PQ
uint full_range; // 0=limited/studio range, 1=full range
float sdr_white_nits; // SDR reference white (nits), used for sRGB and scRGB → BT.2020+PQ
uint source; // what the input is, as a ColorSpec discriminant
uint target; // what to produce, as a ColorSpec discriminant
uint range; // 0=limited/studio range, 1=full range
float reference_white_nits; // luminance of source value 1.0; unused when the source is PQ
} params;

// ColorSpec discriminants. One set: `source` and `target` are the same type,
// and which end a value describes comes from which push constant it is in.
#define SPEC_SRGB 0u
#define SPEC_BT709_LINEAR 1u
#define SPEC_BT2020_LINEAR 2u
#define SPEC_BT2020_PQ 3u

// Source image sampled directly — eliminates the image-to-buffer copy.
layout(binding = 0) uniform sampler2D inputImage;

Expand Down Expand Up @@ -89,34 +97,35 @@ vec3 bt709_to_bt2020(vec3 rgb709) {
// - PQ surfaces → PQ-encoded values (even in FP16)
// - sRGB surfaces → sRGB gamma-encoded values (even in FP16)
//
// color_space 0 (BT.709) and 1 (BT.2020): passthrough — data is already encoded.
// color_space 2 (sRGB→BT.2020+PQ): decode sRGB, convert gamut, apply PQ.
// color_space 3 (scRGB→BT.2020+PQ): convert gamut, apply PQ. No EOTF — input is linear.
// which is what `source` describes.
vec3 read_rgb(ivec2 coord) {
vec4 rgba = texelFetch(inputImage, coord, 0);
if (params.color_space == 2u) {
// sRGB→BT.2020+PQ: decode sRGB gamma → linear BT.709 → BT.2020 gamut → PQ.
vec3 linear_709 = srgb_to_linear(rgba.rgb);
vec3 linear_2020 = bt709_to_bt2020(linear_709);
// SDR reference white (configurable, default 203 nits per ITU-R BT.2408)
// normalized to PQ's 10000 nit scale.
return linear_to_pq(linear_2020 * (params.sdr_white_nits / 10000.0));
}
if (params.color_space == 3u) {
// scRGB→BT.2020+PQ: BT.709 linear → BT.2020 gamut → PQ (no EOTF, input is already linear).
vec3 linear_2020 = bt709_to_bt2020(rgba.rgb);
return linear_to_pq(linear_2020 * (params.sdr_white_nits / 10000.0));

if (params.target == SPEC_BT2020_PQ) {
// Already PQ in the right gamut: nothing to do.
if (params.source == SPEC_BT2020_PQ) return rgba.rgb;

// Get to linear light, then into BT.2020, then encode PQ. The
// reference white says what luminance source 1.0 stands for, which is
// what makes the PQ encode absolute.
vec3 linear = rgba.rgb;
if (params.source == SPEC_SRGB) linear = srgb_to_linear(linear);
if (params.source != SPEC_BT2020_LINEAR) linear = bt709_to_bt2020(linear);
return linear_to_pq(linear * (params.reference_white_nits / 10000.0));
}
// BT.709 or BT.2020 passthrough: values are already properly encoded.

// An SDR target: the only source that reaches it is already SDR-encoded in
// the right gamut, so this is a passthrough. Every other source is
// rejected on the CPU side before the shader ever runs.
return rgba.rgb;
}

// Convert normalized RGB [0,1] to YUV.
// Returns Y in [0, 1], U and V in [0, 1] centered at 0.5.
vec3 rgb_to_yuv(vec3 rgb) {
float yr, yg, yb, ur, ug, ub, vr, vg, vb;
if (params.color_space >= 1u) {
// BT.2020 (also used for sRGB→BT.2020+PQ, since read_rgb already converted).
if (params.target == SPEC_BT2020_PQ) {
// BT.2020 NCL. read_rgb has already put the samples in that gamut.
yr = BT2020_Y_R; yg = BT2020_Y_G; yb = BT2020_Y_B;
ur = BT2020_U_R; ug = BT2020_U_G; ub = BT2020_U_B;
vr = BT2020_V_R; vg = BT2020_V_G; vb = BT2020_V_B;
Expand All @@ -135,27 +144,27 @@ vec3 rgb_to_yuv(vec3 rgb) {
// --- 8-bit quantization ---

uint q8_y(float y) {
if (params.full_range == 0u) return uint(round(clamp(y * 219.0 + 16.0, 0.0, 255.0)));
if (params.range == 0u) return uint(round(clamp(y * 219.0 + 16.0, 0.0, 255.0)));
return uint(round(clamp(y * 255.0, 0.0, 255.0)));
}

uint q8_c(float c) {
if (params.full_range == 0u) return uint(round(clamp((c - 0.5) * 224.0 + 128.0, 0.0, 255.0)));
if (params.range == 0u) return uint(round(clamp((c - 0.5) * 224.0 + 128.0, 0.0, 255.0)));
return uint(round(clamp(c * 255.0, 0.0, 255.0)));
}

// --- 10-bit quantization (P010 layout: value in upper 10 bits of 16-bit word) ---

uint q10_y(float y) {
uint val;
if (params.full_range == 0u) val = uint(round(clamp(y * 876.0 + 64.0, 0.0, 1023.0)));
if (params.range == 0u) val = uint(round(clamp(y * 876.0 + 64.0, 0.0, 1023.0)));
else val = uint(round(clamp(y * 1023.0, 0.0, 1023.0)));
return (val << 6u) & 0xFFC0u;
}

uint q10_c(float c) {
uint val;
if (params.full_range == 0u) val = uint(round(clamp((c - 0.5) * 896.0 + 512.0, 0.0, 1023.0)));
if (params.range == 0u) val = uint(round(clamp((c - 0.5) * 896.0 + 512.0, 0.0, 1023.0)));
else val = uint(round(clamp(c * 1023.0, 0.0, 1023.0)));
return (val << 6u) & 0xFFC0u;
}
Expand Down
Binary file modified shader/color_convert.spv
Binary file not shown.
Loading
Loading