Для iOS-разработчика, желающего освоить Metal, естественным местом для начала является демонстрация Apple’s Hello Triangle.

Это действительно «Привет, мир» металла. Все, что он делает, - это визуализирует двухмерный треугольник с красными, зелеными и синими углами в MTKView. Вершинные и фрагментные шейдеры настолько просты, насколько это возможно. Тем не менее, это отличный способ понять, как части конвейера сочетаются друг с другом.

Единственное, что написано на Objective C.

Как Swift-разработчик, мне захотелось увидеть версию Hello Triangle на этом языке. Поэтому я решил преобразовать его в Swift. (Само преобразование было довольно простым: вы можете увидеть код в этом репо.)

Чтобы немного оживить, я также обновил демоверсию для поддержки широкого диапазона цветов, что в экосистеме Apple означает использование цветового пространства Display P3. (Широкий цвет относится к способности отображать цвета за пределами традиционной гаммы, известной как sRGB; это то, что я исследовал в этой предыдущей публикации.)

Поддержка широкого цвета в Hello Triangle концептуально проста: вместо того, чтобы устанавливать для вершин чисто красный, зеленый и синий, как определено в sRGB, установите их на чисто красный, зеленый и синий, как определено в Display P3. На устройствах, которые его поддерживают, углы треугольника будут казаться ярче и ярче.

Но, как новичку в металле, мне это показалось немного сложным. В MacOS класс MTKView имеет свойство settable colorspace, которое, по-видимому, упрощает работу, но в iOS это свойство недоступно.

По этой причине мне не сразу стало понятно, где в конвейере Metal нужно вносить корректировки для широкой поддержки цвета.

Я нашел ответ в этом отличном ответе на переполнение стека и соответствующем сообщении в блоге. Автор объясняет, как преобразовать значения цвета Display P3 (которые варьируются от 0,0 до 1,0, но на самом деле относятся к более широкому, чем обычно, цветовому пространству) в расширенные значения sRGB (которые сопоставимы с обычным sRGB, за исключением того, что они могут быть отрицательными или больше, чем 1.0) с помощью матричного преобразования. Точная математика зависит от colorPixelFormat из MTKView, который определяет, где применяется гамма.

Итак, о гамме: суть гамма-коррекции заключается в том, что интенсивность цвета часто перед сохранением изображения передается через нелинейную функцию. Поскольку большинство изображений имеют только 256 уровней яркости, а человеческий глаз очень чувствителен к изменениям темных цветов, гамма-функция помогает сохранять больше темных цветов, жертвуя яркостью. Затем значения передаются через обратную функцию при отображении на дисплее.

Поскольку гамма-кодирование не является линейным, значения, которые равномерно разнесены перед кодированием (также называемым сжатием), не будут равномерно разнесены после кодирования. (В этом сообщении блога есть превосходное объяснение гамма-коррекции.)

В конвейере Metal может происходить много неявного гамма-кодирования и декодирования, и если вы манипулируете значениями, не зная, в каком состоянии находитесь, все может быстро испортиться.

Как я узнал из тех предыдущих сообщений в блоге, есть несколько вариантов обработки гаммы при рендеринге широкого цвета в MTKView:

  1. преобразовать значения цветов Display P3 в их линейные (некодированные) аналоги в sRGB и разрешить MTKView применить гамма-кодировку для вас, выбрав формат пикселей .bgra10_xr_srgb, или
  2. преобразуйте значения P3 в линейный sRGB, а затем самостоятельно математически примените гамма-кодирование, выбрав формат пикселей .bgra10_xr.

В этой демонстрации это разница между преобразованием «расширенного» цвета левого угла в 1.2249, -0.04203, -0.0196 (который является самым красным красным цветом P3, преобразованным в линейный sRGB) и преобразованием его в 1.0930, -0.2267, -0.1501 (самый красный красный цвет P3 как sRGB с примененным гамма-кодированием ; это числа, которые вы получили бы, если бы использовали утилиту Apple ColorSync для преобразования в sRGB).

Хотя эти преобразования, вероятно, лучше всего выполнять в шейдере, у меня было только три вершины для обработки, поэтому я сделал это в коде Swift с использованием математической матрицы (см. Ниже).

Попробовав варианты 1 и 2 выше, я заметил интересную разницу в визуальных результатах: когда я позволил MTKView применить гамма-сжатие к моим цветам вершин (вариант 1, изображенный выше слева), внутренняя часть треугольника была намного светлее, чем когда Я использовал технику из варианта 2 (справа).

Проблема заключалась в следующем: в варианте 1 не только углам моего треугольника были присвоены гамма-сжатые значения, но и всем пикселям между ними.

Принцип работы графических процессоров заключается в том, что значения между определенными вершинами вычисляются автоматически с использованием линейной интерполяции (или, строго говоря, барицентрической интерполяции) перед передачей во фрагментный шейдер.

В моей ситуации после того, как графический процессор выполнил интерполяцию (которая произошла в линейном пространстве), последующее гамма-кодирование переместило все значения в сторону более светлых значений интенсивности (более высокие числа, ближе к 1,0).

Но когда я применил гамма-кодирование к преобразованным цветам вершин «вручную» (вариант 2) и установил MTKView на colorPixelFormat из .bgra10_xr, углы уже были гамма-кодированы, и интерполяция графического процессора была эффективно выполнена в гамма-пространстве. В результате получился треугольник, углы которого были того же цвета, что и в варианте 1, но внутренние значения которого были смещены в сторону темного конца из-за характера гамма-функции, описанной выше.

Хотя ни один из вариантов не обязательно является неправильным, вы можете возразить, что вариант 1 (интерполяция в линейном пространстве) кажется более естественным, потому что свет аддитивен в линейном пространстве.

Некоторые особенности ниже:

Использование этой матрицы и функций преобразования от endavid

private static let linearP3ToLinearSRGBMatrix: matrix_float3x3 = {
    let col1 = float3([1.2249,  -0.2247,  0])
    let col2 = float3([-0.0420,   1.0419,  0])
    let col3 = float3([-0.0197,  -0.0786,  1.0979])
    return matrix_float3x3([col1, col2, col3])
}()
extension float3 {
    var gammaDecoded: float3 {
        let f = {(c: Float) -> Float in
            if abs(c) <= 0.04045 {
                return c / 12.92
            }
            return sign(c) * powf((abs(c) + 0.055) / 1.055, 2.4)
        }
       return float3(f(x), f(y), f(z))
    }
    var gammaEncoded: float3 {
        let f = {(c: Float) -> Float in
            if abs(c) <= 0.0031308 {
                return c * 12.92
            }
            return sign(c) * (powf(abs(c), 1/2.4) * 1.055 - 0.055)
        }
        return float3 (f(x), f(y), f(z))
     }
}

… И такая функция преобразования…

func toSRGB(_ p3: float3) -> float4 {
    // Note: gamma decoding not strictly necessary in this demo
    // because 0 and 1 always decode to 0 and 1
    let linearSrgb = p3.gammaDecoded * linearP3ToLinearSRGBMatrix
    let srgb = linearSrgb.gammaEncoded
    return float4(x: srbg.x, y: srbg.y, z: srbg.z, w: 1.0)
}

… Настройка цвета была такой:

let p3red = float3([1.0, 0.0, 0.0])
let p3green = float3([0.0, 1.0, 0.0])
let p3blue = float3([0.0, 0.0, 1.0])
let vertex1 = Vertex(position: leftCorner, color: toSRGB(p3red))
let vertex2 = Vertex(position: top, color: toSRGB(p3green))
let vertex3 = Vertex(position: rightCorner, color: toSRGB(p3blue))
let myWideColorVertices = [vertex1, vertex2, vertex3]

Я надеюсь, что этот порт Swift кому-то поможет. И огромное спасибо Дэвиду Гавилану за его информативные сообщения в блоге и за его невероятно полезные отзывы на этот пост.

Привет, Треугольник Свифт