Commit 6040eac
committed
Metal: add HDR10 / scRGB output support with tonemapped read_viewport
Ports the Vulkan driver's HDR implementation to Metal, covering both
HDR10 (ST.2084 PQ, BT.2020, 10-bit swapchain) and scRGB (BT.709 linear,
FP16 swapchain) output modes. The shader math in
gfx/common/metal/Shaders.metal is a direct MSL port of
gfx/drivers/vulkan_shaders/hdr_common.glsl, hdr.frag and
hdr_tonemap.frag so shader presets authored against the Vulkan HDR
pipeline look the same on Metal.
Pipeline overview
shader chain final pass -> HDR offscreen (RGBA16Float)
-> hdr_composite_fragment
-> CAMetalLayer drawable
(RGB10A2Unorm PQ / RGBA16Float scRGB)
-> menu / overlay / OSD layered on top
The composite fragment has five branches matching the Vulkan reference:
SDR -> HDR10 inverse-tonemap + PQ encode, SDR -> scRGB, shader-emitted
PQ passthrough on HDR10 swapchain, shader-emitted FP16 passthrough on
scRGB swapchain, and shader-emitted PQ -> scRGB conversion (HDRMode 3).
When the output resolution is large enough (y > 240 * 4) and the
scanlines flag is set, a CRT-mask branch in hdr_crt:: generates
per-subpixel scanline luminance in linear Rec.709 and applies the mask
in the target colour space — same geometry as hdr.frag.
Shader-emitted HDR detection keys on SLANG_FORMAT_A2B10G10R10_* (HDR10)
or SLANG_FORMAT_R16G16B16A16_SFLOAT (HDR16) in the final pass's slang
output format, parallel to vulkan_filter_chain::set_hdr10() /
set_hdr16(). When detected, the composite bypasses inverse-tonemap +
PQ encode and either passes through (format match) or converts
(PQ -> scRGB when output is scRGB).
Composite is sourced from FrameView.shaderOutputTexture when a preset
is active (the last shader pass's RT), or from FrameView.frameTexture
otherwise (the raw core frame). Both sources are sampled into the
video-viewport rect of the drawable, so aspect-ratio / integer-scale
settings are honoured the same way the SDR path honours them — outside
the viewport remains the clear colour, matching the SDR
letterbox / pillarbox.
read_viewport (off / HDR10 / scRGB)
Context readBackBuffer routes through _runHDRTonemapForReadbackInto:
when HDR is on. That method runs hdr_tonemap_fragment over the
current drawable into a BGRA8 landing-pad texture, then the existing
row-copy unpacks BGRA -> BGR and flips vertically into the caller's
buffer. Screenshots, recordings, and every other consumer of
video_driver_read_viewport get correct SDR pixels in all three modes.
Tonemap math lives in hdr_tonemap_fragment and matches the Vulkan
hdr_tonemap.frag two-branch logic.
OS availability
HDR is compile-gated on SDK version and runtime-gated on OS version:
macOS : SDK >= 11.0, runtime >= 11.0 (kCGColorSpaceITUR_2100_PQ)
iOS : SDK >= 16.0, runtime >= 16.0 (EDR on CAMetalLayer)
tvOS : SDK >= 16.0, runtime >= 16.0 (EDR on CAMetalLayer)
We key the compile gate on Availability.h's __MAC_11_0 /
__IPHONE_16_0 / __TVOS_16_0 tokens rather than the AvailabilityMacros.h
MAC_OS_X_VERSION_* constants — the former are defined consistently in
all modern SDKs, while the latter have been phased out for newer point
releases. RetroArch's current Apple deployment targets (macOS 10.13,
iOS 11, tvOS 12.1) are below the first HDR-capable OS release, so the
runtime @available(...) guards matter in addition to the compile gate.
On older OSes the driver quietly falls back to SDR.
Init-time mode selection
video_hdr_mode is read from settings once in initWithVideo and becomes
fixed for that driver instance; changing it requires a driver restart
(same as Vulkan). Every MTLRenderPipelineState with a colour
attachment against _layer.pixelFormat (stock blit, clear, menu, font,
slang shader passes) bakes that format into its state — switching the
drawable's format at runtime would invalidate all of them.
metal_apply_hdr_layer_config() reconfigures the CAMetalLayer's
pixelFormat / colorspace / wantsExtendedDynamicRangeContent BEFORE
_initMetal compiles any pipelines, so downstream pipelines pick up the
correct format at construction.
Per-frame HDR knobs (paper_white_nits, expand_gamut, scanlines,
subpixel_layout) are plain uniforms in the shared HDRUniforms buffer
and can change at runtime via the poke interface.
EDR capability announcement
Driver init queries display EDR headroom
(NSScreen.maximumPotentialExtendedDynamicRangeColorComponentValue on
macOS, UIScreen.potentialEDRHeadroom on iOS 16+, assumed-true on
tvOS 16+ since Apple TV 4K is the only HDR-capable tvOS hardware)
and announces VIDEO_FLAG_HDR_SUPPORT / HDR10_SUPPORT / SCRGB_SUPPORT
via video_driver_set_disp_flags() when supported. Flags are cleared
on dealloc so a subsequent driver switch doesn't see stale bits.
A single diagnostic log line is printed at init describing the result,
to make it easy to debug cases where the HDR menu entry doesn't appear
("[Metal] HDR capability: display EDR detected, advertising HDR
support YES (HDR10 + scRGB).").
Also fixed in passing
The existing metal_poke_interface had four NULL slots with comments
that didn't match the actual video_poke_interface_t layout:
NULL, /* set_hdr_menu_nits */
NULL, /* set_hdr_paper_white_nits */
NULL, /* set_hdr_contrast */ -- no longer exists in the struct
NULL /* set_hdr_expand_gamut */ -- missing 5th entry entirely
Replaced with five real entries: metal_set_hdr_menu_nits,
metal_set_hdr_paper_white_nits, metal_set_hdr_expand_gamut,
metal_set_hdr_scanlines, metal_set_hdr_subpixel_layout.
The MetalRaster font pipeline was also hardcoded to
MTLPixelFormatBGRA8Unorm, which would fail validation against an HDR
drawable (RGB10A2Unorm / RGBA16Float). Now uses a new
Context.drawableFormat property to match whatever the layer is
configured as. SDR-only builds are unaffected.
Known limitations
Menu / overlay / OSD render directly onto the HDR backbuffer through
stock SDR pipelines (compiled against the HDR drawable format so they
pass Metal validation, but emitting sRGB-encoded values that the HDR
swapchain interprets in its native colour space). Visible effect
differs by mode:
- HDR10 : sRGB bytes land in the dark region of the PQ curve —
coincidentally close enough to correct that the UI looks
approximately right.
- scRGB : sRGB values are interpreted as extended-linear — blacks
are lifted, menu background appears medium-gray instead
of dark.
Fixing this properly requires a separate SDR-offscreen menu composite
path (Vulkan has one as of Jan 2026: commit 12d188e "Fixed menu system
displaying incorrectly in HDR on D3D11, D3D12 and Vulkan") and is
deferred as follow-up work. Core video composites correctly via the
HDR composite fragment in both modes.
The CRT scanline/mask HDR branch has fixed geometry constants
(k_crt_h_size = 1.0, k_crt_pin_phase = 0.0, etc.) identical to
hdr.frag; these aren't user-configurable from the frontend yet
(neither are Vulkan's).
Files
gfx/common/metal/metal_shader_types.h : HDRUniforms (CPU <-> GPU).
gfx/common/metal/Shaders.metal : hdr:: and hdr_crt:: namespaces;
hdr_composite_vertex,
hdr_composite_fragment,
hdr_tonemap_fragment.
gfx/common/metal_common.h : HDR API on Context + FrameView;
METAL_HDR_OUTPUT_* constants.
gfx/drivers/metal.m : OS gate; Context HDR state +
pipelines + composite/tonemap;
FrameView HDR offscreen routing;
setShaderFromPath HDR-format and
emits-HDR detection;
renderFrame composite hookup;
readBackBuffer HDR-aware path;
init-time layer config;
EDR-headroom query and disp-flag
announce; five poke setter
functions.1 parent 41d4600 commit 6040eac
4 files changed
Lines changed: 1760 additions & 18 deletions
0 commit comments