I found that Metal/Core Image doesn't process the PQ transfer function when rendering which is why it looks dark. Core Animation handles it when it renders the CVPixelBuffer tagged with that TF, which is why it looks correct.
I was able to get it to render properly by using CIRenderDestination to an IOSurface with the ITU Rec. 2100 PQ color space, and then using MPSImageConversion between the surface and the layer's drawable. The converter's source color space is set to PQ (matching the surface) and the dest space to the CAMetalLayer's.
There might be a method without using an intermediate IOSurface/CVPixelBuffer, but I'm already using a surface anyway.