Pull to refresh

Основы программирования графики на Apple Metal: Начало

Reading time 18 min
Views 26K
imageПривет, Хабр! Мой сегодняшний пост — это руководство для начинающих программировать графику на Apple Metal API. Когда я начал разбираться с этой темой, то обнаружилось, что помимо документации от Apple и примеров от них же и смотреть особо нечего. Сегодня я расскажу о том, как создать простое приложение на Metal, которое отображает трехмерный куб с освещением. Затем мы нарисуем несколько кубов с использованием одной из главных фишек Metal — рендеринга в нескольких потоках. Заинтересовавшихся прошу под кат.


Демонстрационное приложение


Для того чтобы запускать демку, нам потребуется мак, Xcode 6, а также устройство с процессором A7 (начиная с iPad Air 2013 и iPhone 5S). К сожалению, запуск приложения для Metal невозможен на эмуляторе. Из последнего ограничения вытекает необходимость иметь действующую подписку на программу разработчика для iOS. Я понимаю, что это не малые требования для простого любопытствующего, и, разумеется, не призываю покупать что-либо из перечисленного. Однако, если звезды сложились так, что у вас есть все необходимое, буду рад узнать о форках с моего репозитория и ваших собственных экспериментах с Metal.
Кроме того, при чтении данного руководства настоятельно рекомендую параллельно смотреть в код демки, это сильно улучшит понимание происходящего.

Вступление


Я не сторонник добавления в посты переводов официальной документации, поэтому поговорим о природе Metal простыми словами. Apple много рассказывала о том, почему Metal круче, чем OpenGL ES (немного об этом было и на хабре). Из всего этого я бы выделил лишь 2 ключевых преимущества:
  1. В Metal существенно уменьшили объем runtime-валидаций команд для графического процессора, перенеся валидацию на момент загрузки приложения или вообще на момент компиляции. Так появились кэшируемые объекты-состояния. Идея, откровенно говоря, не нова, объекты-состояния мы видели еще в Direct3D 10. Таким образом, в Metal API можно предварительно подготовить и закэшировать практически любые состояния графического конвейера.
  2. Возможность параллельного расчета и заполнения буферов команд. Идея здесь заключается в том, чтобы переложить на разработчика приложения процесс заполнения очереди команд для графического процессора, поскольку никто лучше разработчика не знает, как рендерится его сцена, что можно выполнить параллельно, а что нельзя. При этом при работе с Metal API в нескольких потоках не следует бояться погрязнуть в процессах синхронизации потоков, API спроектировано так, чтобы максимально упростить жизнь разработчику (или, по крайней мере, не вызвать мгновенный приступ паники).

Для того чтобы начать работать с Metal, можно в Xcode 6 создать новый проект типа «Game», затем в мастере создания проекта выбрать Metal в качестве способа рендеринга и… все. Xcode сгенерирует шаблонный проект, который будет рисовать куб. Именно так я и начал создавать свою демку, так как стандартный шаблонный проект меня не устроил.

Шаг 1. Рисуем куб с освещением.


Результатом этого шага станет приложение, в котором будет отображаться одноцветный куб, освещенный при помощи модели Блина. В приложении также будет arcball-камера, которая позволит нам вращаться вокруг объекта при помощи жеста Swipe и приближать/удалять объект при помощи жеста Zoom.
В стандартном шаблоне от Apple вся логика приложения сосредоточена в кастомном ViewController'е. Я выделил 2 класса: RenderView и RenderViewContoller. Первый класс является наследником от UIView и отвечает за инициализацию Metal и его связку с Core Animation. Во втором классе содержится сама графическая демка и некоторое количество инфраструктурного кода для обработки ситуаций сворачивания/разворачивания приложения и пользовательского ввода. Более правильно было бы создать класс RenderModel и вынести логику графической демки туда. Возможно, мы так и поступим, когда сложность программы возрастет.
Здесь уместно будет упомянуть, на каком языке мы будет создавать приложение. Я выбрал Objective-C++, который позволил мне включать в классы, написанные на чистом C++ в проект. Существует также возможность использовать Swift (неплохую статью на английском об этом можно почитать здесь).

Реализация RenderView

Вряд ли кто-то удивится, узнав, что Metal тесно связан с Core Animation, системой управляющей графикой и анимацией в iOS. Для встраивания Metal в приложения для iOS Apple приготовила специальный слой CAMetalLayer. Именно этой слой будет использовать наш RenderView. Инициализоваться RenderView будет следующим образом:

+ (Class)layerClass
{
    return [CAMetalLayer class];
}

- (void)initCommon
{
    self.opaque = YES;
    self.backgroundColor = nil;
    
    _metalLayer = (CAMetalLayer *)self.layer;
    _device = MTLCreateSystemDefaultDevice();
    _metalLayer.device = _device;
    _metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm;
    _metalLayer.framebufferOnly = YES;
    
    _sampleCount = 1;
    _depthPixelFormat = MTLPixelFormatDepth32Float;
    _stencilPixelFormat = MTLPixelFormatInvalid;
}

В этом коде нетрудно найти общее с другими графическими API: создаем корневой класс API (MTLDevice в данном случае), выбираем форматы заднего буфера и буфера глубины, выбираем число выборок для multisampling. Непосредственно создание текстур заднего буфера и буфера глубины осуществляется по требованию. Это обусловлено особенностью связки Metal и Core Animation. Когда Core Animation разрешает рисовать на экране устройства, то она возвращает ненулевой CAMetalDrawable, который связан с экраном устройства. Если пользователь свернет приложение, то мы обязаны позаботиться о прекращении всякого рендеринга, поскольку в этой случае CAMetalDrawable для данного приложения будет нулевым (привет, Direct3D 9 и D3DERR_DEVICELOST). Кроме этого, при переходе устройства из Portrait в Landscape и наоборот, необходимо переинициализировать текстуры для заднего буфера, буфера глубины и трафарета.
На каждом кадре происходит переформирование объекта MTLRenderPassDescriptor. Данный объект связывает текстуру заднего буфера, полученную из текущего CAMetalDrawable с желаемыми параметрами рендеринга. Также в данном объекте задаются действия, которые можно дополнительно осуществить до и после рендеринга. Например, MTLStoreActionMultisampleResolve говорит о том, что после рендеринга в текстуру с multisampling необходимо осуществить преобразование этой текстуры (resolve) к обычному виду. MTLLoadActionClear позволяет осуществить очистку заднего буфера/буфера глубины/буфера трафарета перед рисованием нового кадра.
Код для создания и переинициализации текстур заднего буфера, буфера глубины и буфера трафарета можно найти под катом.

Код для создания и переинициализации текстур
- (void)setupRenderPassDescriptorForTexture:(id <MTLTexture>)texture
{
    if (_renderPassDescriptor == nil)
        _renderPassDescriptor = [MTLRenderPassDescriptor renderPassDescriptor];
    
    // init/update default render target
    MTLRenderPassColorAttachmentDescriptor* colorAttachment = _renderPassDescriptor.colorAttachments[0];
    colorAttachment.texture = texture;
    colorAttachment.loadAction = MTLLoadActionClear;
    colorAttachment.clearColor = MTLClearColorMake(0.0f, 0.0f, 0.0f, 1.0f);
    if(_sampleCount > 1)
    {
        BOOL doUpdate = (_msaaTexture.width != texture.width) || ( _msaaTexture.height != texture.height) || ( _msaaTexture.sampleCount != _sampleCount);
        if(!_msaaTexture || (_msaaTexture && doUpdate))
        {
            MTLTextureDescriptor* desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat: MTLPixelFormatBGRA8Unorm
                                                           width: texture.width
                                                           height: texture.height
                                                           mipmapped: NO];
            desc.textureType = MTLTextureType2DMultisample;
            desc.sampleCount = _sampleCount;
            _msaaTexture = [_device newTextureWithDescriptor: desc];
            _msaaTexture.label = @"Default MSAA render target";
        }
        
        colorAttachment.texture = _msaaTexture;
        colorAttachment.resolveTexture = texture;
        colorAttachment.storeAction = MTLStoreActionMultisampleResolve;
    }
    else
    {
        colorAttachment.storeAction = MTLStoreActionStore;
    }
    
    // init/update default depth buffer
    if(_depthPixelFormat != MTLPixelFormatInvalid)
    {
        BOOL doUpdate = (_depthTexture.width != texture.width) || (_depthTexture.height != texture.height) || (_depthTexture.sampleCount != _sampleCount);
        if(!_depthTexture || doUpdate)
        {
            MTLTextureDescriptor* desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat: _depthPixelFormat
                                                             width: texture.width
                                                             height: texture.height
                                                             mipmapped: NO];
            desc.textureType = (_sampleCount > 1) ? MTLTextureType2DMultisample : MTLTextureType2D;
            desc.sampleCount = _sampleCount;
            
            _depthTexture = [_device newTextureWithDescriptor: desc];
            _depthTexture.label = @"Default depth buffer";
            
            MTLRenderPassDepthAttachmentDescriptor* depthAttachment = _renderPassDescriptor.depthAttachment;
            depthAttachment.texture = _depthTexture;
            depthAttachment.loadAction = MTLLoadActionClear;
            depthAttachment.storeAction = MTLStoreActionDontCare;
            depthAttachment.clearDepth = 1.0;
        }
    }
    
    // init/update default stencil buffer
    if(_stencilPixelFormat != MTLPixelFormatInvalid)
    {
        BOOL doUpdate = (_stencilTexture.width != texture.width) || (_stencilTexture.height != texture.height) || (_stencilTexture.sampleCount != _sampleCount);
        if (!_stencilTexture || doUpdate)
        {
            MTLTextureDescriptor* desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat: _stencilPixelFormat
                                                               width: texture.width
                                                               height: texture.height
                                                               mipmapped: NO];
            
            desc.textureType = (_sampleCount > 1) ? MTLTextureType2DMultisample : MTLTextureType2D;
            desc.sampleCount = _sampleCount;
            
            _stencilTexture = [_device newTextureWithDescriptor: desc];
            _stencilTexture.label = @"Default stencil buffer";
            
            MTLRenderPassStencilAttachmentDescriptor* stencilAttachment = _renderPassDescriptor.stencilAttachment;
            stencilAttachment.texture = _stencilTexture;
            stencilAttachment.loadAction = MTLLoadActionClear;
            stencilAttachment.storeAction = MTLStoreActionDontCare;
            stencilAttachment.clearStencil = 0;
        }
    }
}

Метод render класса RenderView будет вызываться на каждый кадр из RenderViewController.

Реализация RenderViewController

Описание реализации данного класса начнем с инфраструктурной части. Для того чтобы вызывать метод для рендеринга кадра из RenderView, нам потребуется таймер, объект класса CADisplayLink, который мы инициализируем следующим образом:

- (void)startTimer
{
    _timer = [CADisplayLink displayLinkWithTarget:self selector:@selector(_renderloop)];
    [_timer addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
}

Важно отметить, что мы будем останавливать таймер при сворачивании приложения и возобновлять при разворачивании. Для этого я пробросил вызовы applicationDidEnterBackground и applicationWillEnterForeground из AppDelegate в RenderViewContoller. Это гарантирует, что наше приложение не будет пытаться ничего рендерить, будучи свернутым, и не упадет по этой причине.
Кроме того, мы инициализируем специальный семафор (dispatch_semaphore_t _inflightSemaphore). Это позволит нам избежать так называемой GPU Bound, то есть ситуации когда центральный процессор ждет графический процессор для того чтобы сформировать следующий кадр. Мы позволим нашему CPU подготавливать несколько кадров заранее (до 3 кадров в нашем случае), чтобы минимизировать свой простой при ожидании GPU. Техника использования семафора будет рассматриваться далее.
Пользовательский ввод будем перехватывать при помощи реализации методов touchesBegan, touchesMoved и touchesEnded. Движения одного или нескольких пальцев по экрану будут передаваться классу ArcballCamera, который будет преобразовывать эти движения в повороты и перемещения камеры.
Код реакции на пользовательский ввод под катом.

Реакция на пользовательский ввод
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    NSArray* touchesArray = [touches allObjects];
    if (touches.count == 1)
    {
        if (!camera.isRotatingNow())
        {
            CGPoint pos = [touchesArray[0] locationInView: self.view];
            camera.startRotation(pos.x, pos.y);
        }
        else
        {
            // here we put second finger
            simd::float2 lastPos = camera.getLastFingerPosition();
            camera.stopRotation();
            CGPoint pos = [touchesArray[0] locationInView: self.view];
            float d = vector_distance(simd::float2 { (float)pos.x, (float)pos.y }, lastPos);
            camera.startZooming(d);
        }
    }
    else if (touches.count == 2)
    {
        CGPoint pos1 = [touchesArray[0] locationInView: self.view];
        CGPoint pos2 = [touchesArray[1] locationInView: self.view];
        float d = vector_distance(simd::float2 { (float)pos1.x, (float)pos1.y },
                                  simd::float2 { (float)pos2.x, (float)pos2.y });
        camera.startZooming(d);
    }
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    NSArray* touchesArray = [touches allObjects];
    if (touches.count != 0 && camera.isRotatingNow())
    {
        CGPoint pos = [touchesArray[0] locationInView: self.view];
        camera.updateRotation(pos.x, pos.y);
    }
    else if (touches.count == 2 && camera.isZoomingNow())
    {
        CGPoint pos1 = [touchesArray[0] locationInView: self.view];
        CGPoint pos2 = [touchesArray[1] locationInView: self.view];
        float d = vector_distance(simd::float2 { (float)pos1.x, (float)pos1.y },
                                  simd::float2 { (float)pos2.x, (float)pos2.y });
        camera.updateZooming(d);
    }
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    camera.stopRotation();
    camera.stopZooming();
}

Про теорию реализации arcball-камеры можно почитать здесь.
Наконец, перейдем к логике самого графического приложения, которая содержится в 5 основных методах:

- (void)configure:(RenderView*)renderView

Здесь мы конфигурируем представление, задавая, например, количество выборок для multisampling, форматы заднего буфера, буфера глубины и трафарета.

- (void)setupMetal:(id<MTLDevice>)device

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

- (void)update

Здесь происходит обновление кадра, вычисление матриц и других параметров для шейдеров.

- (void)render:(RenderView*)renderView

Здесь, очевидно, происходит сам рендеринг кадра.

- (void)resize:(RenderView*)renderView

Данный метод вызывается когда произошло изменение размеров экрана, например, при повороте устройства, когда длина и ширина меняются местами. Здесь удобно вычислять, например, матрицу проекции.

Какие есть особенности при инициализации ресурсов и объектов-состояний в Metal? Для меня, привыкшего к Direct3D 11 API, нашлась лишь одна серьезная. Так как CPU может успеть отправить на рендеринг до 3 кадров до синхронизации с GPU, размер буфера для констант должен быть в трое больше обычного. Каждый из трех кадров работает со своим кусочком константного буфера, чтобы исключить возможность перетирания данных. На практике это выглядит следующим образом:

// Заполнение
uint8_t* bufferPointer = (uint8_t*)[_dynamicUniformBuffer contents] + 
                                      (sizeof(uniforms_t) * _currentUniformBufferIndex);
memcpy(bufferPointer, &_uniform_buffer, sizeof(uniforms_t));

// Использование
[renderEncoder setVertexBuffer:_dynamicUniformBuffer 
                          offset:(sizeof(uniforms_t) * _currentUniformBufferIndex) atIndex:1 ];

Еще, пожалуй, стоит упомянуть про классы MTLRenderPipelineDescriptor и MTLRenderPipelineState, которые определяет дескриптор состояния графического конвейера и сам объект-состояние. Данный объект включает в себя ссылки на вершинный и пиксельный шейдеры, количество multisample-выборок, формат заднего буфера и буфера глубины. Стоп, кажется, мы уже это где-то задавали. Все именно так, как и кажется. Данное состояние заточено под совершенно конкретные параметры рендеринга, и при других обстоятельствах использовано быть не может. Создав такой объект заранее (и проведя валидацию) мы избавляем графический конвейер от необходимости проверять ошибки совместимости параметров во время рендеринга, конвейер или принимает состояние целиком, или целиком отвергает.
Код инициализации Metal приведен ниже.

- (void)setupMetal:(id<MTLDevice>)device
{
    _commandQueue = [device newCommandQueue];
    _defaultLibrary = [device newDefaultLibrary];
    
    [self loadAssets: device];
}

- (void)loadAssets:(id<MTLDevice>)device
{
    _dynamicUniformBuffer = [device newBufferWithLength:MAX_UNIFORM_BUFFER_SIZE options:0];
    _dynamicUniformBuffer.label = @"Uniform buffer";

    id <MTLFunction> fragmentProgram = [_defaultLibrary newFunctionWithName:@"psLighting"];
    id <MTLFunction> vertexProgram = [_defaultLibrary newFunctionWithName:@"vsLighting"];
    
    _vertexBuffer = [device newBufferWithBytes:(Primitives::cube())
                                        length:(Primitives::cubeSizeInBytes())
                                       options:MTLResourceOptionCPUCacheModeDefault];
    _vertexBuffer.label = @"Cube vertex buffer";
    
    // pipeline state
    MTLRenderPipelineDescriptor *pipelineStateDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
    pipelineStateDescriptor.label = @"Simple pipeline";
    [pipelineStateDescriptor setSampleCount: ((RenderView*)self.view).sampleCount];
    [pipelineStateDescriptor setVertexFunction:vertexProgram];
    [pipelineStateDescriptor setFragmentFunction:fragmentProgram];
    pipelineStateDescriptor.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;
    pipelineStateDescriptor.depthAttachmentPixelFormat = MTLPixelFormatDepth32Float;
    
    NSError* error = NULL;
    _pipelineState = [device newRenderPipelineStateWithDescriptor:pipelineStateDescriptor error:&error];
    if (!_pipelineState) {
        NSLog(@"Failed to created pipeline state, error %@", error);
    }
    
    MTLDepthStencilDescriptor *depthStateDesc = [[MTLDepthStencilDescriptor alloc] init];
    depthStateDesc.depthCompareFunction = MTLCompareFunctionLess;
    depthStateDesc.depthWriteEnabled = YES;
    _depthState = [device newDepthStencilStateWithDescriptor:depthStateDesc];
}

Наконец, рассмотрим наиболее интригующий участок кода, рендеринг кадра.

- (void)render:(RenderView*)renderView
{
    dispatch_semaphore_wait(_inflightSemaphore, DISPATCH_TIME_FOREVER);
    
    [self update];
    
    MTLRenderPassDescriptor* renderPassDescriptor = renderView.renderPassDescriptor;
    id <CAMetalDrawable> drawable = renderView.currentDrawable;
    
    // new command buffer
    id <MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer];
    commandBuffer.label = @"Simple command buffer";
    
    // simple render encoder
    id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor: renderPassDescriptor];
    renderEncoder.label = @"Simple render encoder";
    [renderEncoder setDepthStencilState:_depthState];
    [renderEncoder pushDebugGroup:@"Draw cube"];
    [renderEncoder setRenderPipelineState:_pipelineState];
    [renderEncoder setVertexBuffer:_vertexBuffer offset:0 atIndex:0 ];
    [renderEncoder setVertexBuffer:_dynamicUniformBuffer offset:(sizeof(uniforms_t) * _currentUniformBufferIndex) atIndex:1 ];
    [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:36 instanceCount:1];
    [renderEncoder popDebugGroup];
    [renderEncoder endEncoding];
    
    __block dispatch_semaphore_t block_sema = _inflightSemaphore;
    [commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> buffer) {
        dispatch_semaphore_signal(block_sema);
    }];
    
    _currentUniformBufferIndex = (_currentUniformBufferIndex + 1) % MAX_INFLIGHT_BUFFERS;
    [commandBuffer presentDrawable:drawable];
    [commandBuffer commit];
}

В начале метода вызывается dispatch_semaphore_wait, который останавливает расчет кадра на CPU до тех пор, пока GPU не закончит с одним из текущих кадров. Как я уже говорил, в нашей демке CPU разрешается расчитывать до 3 кадров, пока GPU занят. Семафор отпускается в методе addCompletedHandler буфера команд commandBuffer. Буфер команд спроектирован как легковесный (transient) объект, т. е. его необходимо создавать каждый кадр и нельзя переиспользовать.
Каждый кадр для конкретного буфера создается так называемый кодировщик команд рендеринга (в данном случае объект класса MTLRenderCommandEncoder). При его создании используется объект класса MTLRenderPassDescriptor, который мы обсуждали выше. Данный объект позволяет наполнять буфер командами разного рода (установка состояний, вершинных буферов, вызовы методов рисования примитивов, т. е. все то, что знакомо и по другим графическим API). По завершению заполнения для буфера команд вызывается метод commit, что отправляет этот буфер в очередь.
В коде шейдеров нет ничего необычного, элементарная реализация освещения по Блинну. Для Metal инженеры Apple придумали собственный язык шейдеров, который не особо сильно отличается от HLSL, GLSL и Cg. Те, кто хоть раз писал шейдеры на одном из перечисленных языков, без всякого труда начнут пользоваться и этим языком, для остальных рекомендую гайд по языку от Apple.

Код шейдеров
#include <metal_stdlib>
#include <simd/simd.h>

using namespace metal;

constant float3 lightDirection = float3(0.5, -0.7, -1.0);
constant float3 ambientColor = float3(0.18, 0.24, 0.8);
constant float3 diffuseColor = float3(0.4, 0.4, 1.0);
constant float3 specularColor = float3(0.3, 0.3, 0.3);
constant float specularPower = 30.0;

typedef struct
{
    float4x4 modelViewProjection;
    float4x4 model;
    float3 viewPosition;
} uniforms_t;

typedef struct
{
    packed_float3 position;
    packed_float3 normal;
    packed_float3 tangent;
} vertex_t;

typedef struct
{
    float4 position [[position]];
    float3 tangent;
    float3 normal;
    float3 viewDirection;
} ColorInOut;

// Vertex shader function
vertex ColorInOut vsLighting(device vertex_t* vertex_array [[ buffer(0) ]],
                             constant uniforms_t& uniforms [[ buffer(1) ]],
                             unsigned int vid [[ vertex_id ]])
{
    ColorInOut out;
    
    float4 in_position = float4(float3(vertex_array[vid].position), 1.0);
    out.position = uniforms.modelViewProjection * in_position;
    
    float4x4 m = uniforms.model;
    m[3][0] = m[3][1] = m[3][2] = 0.0f; // suppress translation component
    out.normal = (m * float4(normalize(vertex_array[vid].normal), 1.0)).xyz;
    out.tangent = (m * float4(normalize(vertex_array[vid].tangent), 1.0)).xyz;
    
    float3 worldPos = (uniforms.model * in_position).xyz;
    out.viewDirection = normalize(worldPos - uniforms.viewPosition);
    
    return out;
}

// Fragment shader function
fragment half4 psLighting(ColorInOut in [[stage_in]])
{
    float3 normalTS = float3(0, 0, 1);
    float3 lightDir = normalize(lightDirection);
    
    float3x3 ts = float3x3(in.tangent, cross(in.normal, in.tangent), in.normal);
    float3 normal = -normalize(ts * normalTS);
    float ndotl = fmax(0.0, dot(lightDir, normal));
    float3 diffuse = diffuseColor * ndotl;
    
    float3 h = normalize(in.viewDirection + lightDir);
    float3 specular = specularColor * pow (fmax(dot(normal, h), 0.0), specularPower);
    
    float3 finalColor = saturate(ambientColor + diffuse + specular);
    
    return half4(float4(finalColor, 1.0));
}

В результате на экране нашего устройства можно будет увидеть следующее.



На этом мы завершаем первый шаг руководства. Код для данного шага доступен в git-репозитории под тэгом tutorial_1_1.

Шаг 2. Рисуем несколько кубов.


Для того чтобы нарисовать несколько кубов, необходимо изменить наш константный буфер. Ранее в нем хранились параметры (матрица мира-вида-проекции, матрица мира и положение камеры) только для одного объекта, теперь эти данные необходимо задать для всех объектов. Очевидно, что положение камеры достаточно передать один раз, для этого нужен будет дополнительный константный буфер для параметров, которые вычисляются 1 раз в кадр. Однако, заводить отдельный буфер для одного вектора я пока не стал, мы сделаем это в следующий раз, когда количество параметров увеличится. Можете попробовать сделать это сами уже сейчас. Таким образом, для 5 кубов у нас будет 5 наборов параметров для каждого из 3 кадров, которые CPU может успеть рассчитать, пока не синхронизируется с GPU.
Метод рендеринга мы изменим следующим образом:

    id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor: renderPassDescriptor];
    renderEncoder.label = @"Simple render encoder";
    [renderEncoder setDepthStencilState:_depthState];
    [renderEncoder pushDebugGroup:@"Draw cubes"];
    [renderEncoder setRenderPipelineState:_pipelineState];
    [renderEncoder setVertexBuffer:_vertexBuffer offset:0 atIndex:0 ];
    for (int i = 0; i < CUBE_COUNTS; i++)
    {
        [renderEncoder setVertexBuffer:_dynamicUniformBuffer
                                offset:(sizeof(_uniform_buffer) * _currentUniformBufferIndex + i * sizeof(uniforms_t))
                               atIndex:1 ];
        [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:36 instanceCount:1];
    }
    [renderEncoder popDebugGroup];
    [renderEncoder endEncoding];


Хочу обратить ваше внимание на вычисление смещения в константном буфере (sizeof(_uniform_buffer) * _currentUniformBufferIndex + i * sizeof(uniforms_t)). Переменная _currentUniformBufferIndex определяет блок, соответствующий текущему кадру, а счетчик i определяет, где находятся данные для конкретного куба.
В результате мы получим примерно такую картинку.



Код для данного шага доступен в git-репозитории под тэгом tutorial_1_2.

Шаг 3. Рисуем несколько кубов в нескольких потоках.


Рисовать кубики в одном потоке мы можем и на OpenGL ES, теперь добавим в демку заполнение буфера команд в нескольких потоках. Пусть половина кубиков будет рендериться в одном потоке, а другая половина в другом. Пример, разумеется, сугубо учебный, никакого выигрыша производительности от этого мы в данном случае не получим.
Для многопоточного заполнения буфера команд в Metal API есть специальный класс MTLParallelRenderCommandEncoder. Этот класс позволяет создавать сколь угодно много объектов класса MTLRenderCommandEncoder, который нам уже знаком по предыдущим шагам. Каждый из таких объектов позволяет выполнять код по заполнению буфера командами в отдельном потоке.
Используя dispatch_async, мы запустим рендеринг половины кубиков в отдельном потоке, вторая половина будет рендериться в основном потоке. В результате мы получим следующий код:

- (void)render:(RenderView*)renderView
{
    dispatch_semaphore_wait(_inflightSemaphore, DISPATCH_TIME_FOREVER);
    
    [self update];
    
    MTLRenderPassDescriptor* renderPassDescriptor = renderView.renderPassDescriptor;
    id <CAMetalDrawable> drawable = renderView.currentDrawable;
    
    // new command buffer
    id <MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer];
    commandBuffer.label = @"Simple command buffer";
    
    // parallel render encoder
    id <MTLParallelRenderCommandEncoder> parallelRCE = [commandBuffer parallelRenderCommandEncoderWithDescriptor:renderPassDescriptor];
    parallelRCE.label = @"Parallel render encoder";
    id <MTLRenderCommandEncoder> rCE1 = [parallelRCE renderCommandEncoder];
    id <MTLRenderCommandEncoder> rCE2 = [parallelRCE renderCommandEncoder];
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^
    {
        @autoreleasepool
        {
            [self encodeRenderCommands: rCE2
                               Comment: @"Draw cubes in additional thread"
                            StartIndex: CUBE_COUNTS / 2
                              EndIndex: CUBE_COUNTS];
        }
        dispatch_semaphore_signal(_renderThreadSemaphore);
    });
    
    [self encodeRenderCommands: rCE1
                       Comment: @"Draw cubes"
                    StartIndex: 0
                      EndIndex: CUBE_COUNTS / 2];

    // wait additional thread and finish encoding
    dispatch_semaphore_wait(_renderThreadSemaphore, DISPATCH_TIME_FOREVER);
    [parallelRCE endEncoding];
    
    __block dispatch_semaphore_t block_sema = _inflightSemaphore;
    [commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> buffer) {
        dispatch_semaphore_signal(block_sema);
    }];
    
    _currentUniformBufferIndex = (_currentUniformBufferIndex + 1) % MAX_INFLIGHT_BUFFERS;
    [commandBuffer presentDrawable:drawable];
    [commandBuffer commit];
}

- (void)encodeRenderCommands:(id <MTLRenderCommandEncoder>)renderEncoder
                     Comment:(NSString*)comment
                  StartIndex:(int)startIndex
                    EndIndex:(int)endIndex
{
    [renderEncoder setDepthStencilState:_depthState];
    [renderEncoder pushDebugGroup:comment];
    [renderEncoder setRenderPipelineState:_pipelineState];
    [renderEncoder setVertexBuffer:_vertexBuffer offset:0 atIndex:0 ];
    for (int i = startIndex; i < endIndex; i++)
    {
        [renderEncoder setVertexBuffer:_dynamicUniformBuffer
                                offset:(sizeof(_uniform_buffer) * _currentUniformBufferIndex + i * sizeof(uniforms_t))
                               atIndex:1 ];
        [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:36 instanceCount:1];
    }
    [renderEncoder popDebugGroup];
    [renderEncoder endEncoding];
}

Для синхронизации основного и дополнительного потока я использовал семафор _renderThreadSemaphore, который синхронизирует эти два потока непосредственно перед вызовом endEncoding у объекта класса MTLParallelRenderCommandEncoder. MTLParallelRenderCommandEncoder требует, чтобы метод endEncoding был вызван гарантированно после вызовов endEncoding у порожденных им объектов класса MTLRenderCommandEncoder.
Если все было сделано верно, то в результате на экране устройства будет то же самое, что и на предыдущем шаге.

Код для данного шага доступен в git-репозитории под тэгом tutorial_1_3.

Заключение


Сегодня мы рассмотрели самые начальные шаги в программировании графики с использованием Apple Metal API. Если эта тема и такой формат окажутся интересными сообществу, то мы продолжим дальше. В следующей серии я планирую нарисовать модельку поинтереснее, мы используем индексный буфер и затекстурируем ее. В качестве «фишки» урока будет что-то типа инстансинга. Жду ваших откликов, спасибо за внимание.
Tags:
Hubs:
+15
Comments 23
Comments Comments 23

Articles