Для 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
:
- преобразовать значения цветов Display P3 в их линейные (некодированные) аналоги в sRGB и разрешить
MTKView
применить гамма-кодировку для вас, выбрав формат пикселей.bgra10_xr_srgb
, или - преобразуйте значения 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 кому-то поможет. И огромное спасибо Дэвиду Гавилану за его информативные сообщения в блоге и за его невероятно полезные отзывы на этот пост.