26 января 2015 в 13:52

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

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. Если эта тема и такой формат окажутся интересными сообществу, то мы продолжим дальше. В следующей серии я планирую нарисовать модельку поинтереснее, мы используем индексный буфер и затекстурируем ее. В качестве «фишки» урока будет что-то типа инстансинга. Жду ваших откликов, спасибо за внимание.
Роман Кузнецов @rokuz
карма
47,0
рейтинг 0,0
Программист графики
Самое читаемое Разработка

Комментарии (23)

  • 0
    А вы специально не использовали gesture recognizers в пользу touchesBegan/Moved/etc? И семафор вроде можно было бы заменить dispatch_group, как мне кажется.
    __block dispatch_semaphore_t block_sema = _inflightSemaphore;
    

    тут по-моему __block лишний, потому что dispatch_semaphore_t это всё равно указатель

    Для этого я пробросил вызовы applicationDidEnterBackground и applicationWillEnterForeground из AppDelegate в RenderViewContoller.

    можно ведь использовать уведомления UIApplicationDidEnterBackgroundNotification и
    UIApplicationDidBecomeActiveNotification из NSNotificationCenter?

    • 0
      Спасибо за отклик! Да, специально не использовал жесты, для управления камерой это оказалось даже проще. Да, заменить можно, но в данном конкретном случае использование недвоичного семафора мне показалось более уместным. dispatch_group я бы скорей использовал в ситуации, когда надо синхронизировать разнородные таски.
      Несмотря на то, что dispatch_semaphore_t — указатель, работаем мы с ним как с объектом (я к тому, что указатель это его внутренняя организация). __block здесь носит информационный характер и говорит о том, что объект будет изменяться внутри блока.
      Можно использовать и нотификации, более того, так и было в одной из демок Apple)) Так как контроллер у нас всегда один, я решил, что перегружать код контроллера подпиской/отпиской в центре нотификаций — лишнее
      • 0
        объект будет изменяться внутри блока

        мне это кажется каким-то вольным трактованием назначения __block. без этого модификатора у вас получается как будто бы const void *block_sema, но это ведь не мешает менять что-то разыменовывая указатель или вызывать методы объекта
        • 0
          Ваше право считать так) В том, что __block в данном случае можно убрать без последствий, я с вами согласен.
          • 0
            Я просто хотел указать на то, что вы вроде как хотели пометить переменную так, чтобы было ясно, что ей управляют внутри блока – а получилось так, что сторонний разработчик настораживается и ищет место, где изменяется само значение этой переменной. Такого нет. Почему тогда __block? Не понятно. Вдруг это сделано специально, но я не могу понять почему? Надо разбираться. Выясняется, что __block использован просто как маркер, который в «авторском» смысле понимает только автор. На мой взгляд, тут больше подойдет комментарий, а еще лучше – вообще ничего не надо. Название у переменной и так соответствует её использованию.
            • 0
              Спасибо, за конструктивное замечание, убедили) Это обучающий материал, поэтому мнение обучаемых особенно ценно. В следующей серии, если она будет, обязательно исправлю это.
              • 0
                Ещё я бы _currentUniformBufferIndex передавал аргументом в encodeRenderCommands:commend:startIndex:endIndex и использовал NSInteger вместо int
                Вообще с этими семафорами всё как-то сложно выглядит, ну прям вообще. Я бы использовал concurrent NSOperationQueue и более человечные addOperations:waitUntilFinished: – так оно как-то человечнее выглядит и можно распараллеливать на сколько угодно частей, назначать зависимости, если вдруг такое нужно. Мне кажется, можно половину синхронизаций попросту убрать, отрефакторив взаимодействие между компонентами. Я вообще тут просто мимо проходил, графикой не интересуюсь, но может быть попробую сделать на свой лад и покажу.
              • 0
                и зум со скроллом кривые :) я сначала подумал, что у скролла специально такая «инерция» сделана, но с зумом сразу всё стало понятно. проблема в том, как вы считаете новую позицию камеры
                currentDistance += delta * ZOOM_SPEED;
                это неправильно, надо так же как и расстояние, запоминать стартовую дистанцию и прибавлять дельту именно к ней
                • 0
                  Ок, жду с нетерпением вашего форка :)
                • 0
                  по-моему, должно быть что-то типа такого, там тоже есть свои баги, но я не разобрался в деталях как там currentDistance работает
                  void ArcballCamera::updateZooming(const float d)
                  {
                      if (isZooming) {
                          const float delta = lastDistance - d;
                          currentDistance = delta * 0.1f;
                      }
                  }
                  

                  т.е. когда я начал жест на расстоянии x, раздвинул до 2x – должно увеличиться условно в 2 раза. и если я не отпуская пальцев начну зумить до 1.5x – то масштаб сразу должен начать убавляться, а не продолжать увеличиваться в сторону 3x

                  и у сколла такая же кривая «инерция»
                  • 0
                    Ладно, раз общественный резонанс есть, видимо второй серии быть, попробую там улучшить камеру. Спасибо за ревью, кстати)
  • +2
    Для Metal инженеры Apple придумали собственный язык шейдеров, который не особо сильно отличается от HLSL, GLSL и Cg.
    Почему? Фатальный недостаток?
    • 0
      Нет никакого недостатка, я бы даже назвал эту схожесть языков достоинством) Правда я не уверен, что правильно понял ваши вопросы
      • +1
        Если серьёзно, в этом и заключается вопрос: если языки настолько схожи, в чём была потребность изобретать новый язык, а не использовать имеющийся? И в чём достоинство наличия нескольких схожих языков, которые решают одну и ту же задачу?
        • 0
          О мотивации Apple я могу только догадываться, как вы понимаете. Думаю немаловажным фактором было наличие «собственного» языка, это в духе компании. Для себя я не нашел в этом языке фич, которых бы мне сильно не хватало в других языках. Да, в Metal Shading Language есть перегрузка функций и шаблоны, удобно, но не более того. Была попытка сблизить программы GPGPU и шейдеры синтаксически, удобно, но не революция.
          Если уж говорить о моем сугубо личном мнении, языку шейдеров давно нужна стандартизация, один язык, который бы использовался везде.
  • 0
    В мире, где вся разработка максимально облегчается, Эппл решила пойти по принципу — пишите тонны кода для вывода кубика.
    Да еще и с совершенно своим подходом. Преимущество перед OpenGL-то есть?
    • +1
      Те преимущества, что мне показались значимыми, я обозначил во «Вступлении». Из них я бы особенно выделил хорошую поддержку многопоточности. Мне доводилось реализовывать многопоточный рендеринг на Direct3D 11 API при помощи deferred-контекста, так что сравнивать есть с чем) Вопрос, что будет с Metal, когда выйдет Direct3D 12, в котором обещают схожую гибкость, но пока его нет, работаем с тем, что имеем.
      • 0
        Ну Metal это под мак, а Direct3D 12 всё-таки под windows. Скорее что будет с Mantle, уж :)
        • +1
          Я не к тому, что Metal загнется (Apple создали технологию, которую будут продавать еще долго). С выходом Direct3D 12 технологию Metal, возможно, будет с чем сравнивать на мобильных платформах
  • +1
    Сразу наткнулся на
    error: can't exec 'metal' (No such file or directory)
    Metal до сих пор не работает в симуляторе?(
    • +2
      К сожалению, да. Это я, кстати, упомянул в начале поста. Только под девайсом, и нужно быть подписанным на программу разработчика.
      • 0
        Для маков, произведенных начиная с 2012 года, метал появился в El Capitan. Так что с небольшими поправками можно с ним поиграться в osx-приложении.

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