70. fejezet - Metal


Az OpenGL lassan 25 éves csillaga leáldozni látszik a bábeli zűrzavarként terjedő low level (ejtsd: ló level) API-k által, amelyek a totális káosz elérése érdekében még platformonként különböznek is. Így történt hát, hogy míg a Microsoft a DirectX 12-t fejlesztgette és a Khronos alkoholterápiás csoport a józanságról ábrándozott, addig az Apple egy meglepetésszerű tökönrúgással bejelentette a Metal-t. A szó kiejtése közben a mutató- és kisujjaddal szamárfület kell mutatni, különben Elvis a gyorsvonat.

Ez a cikk időhiány miatt rövid, tehát nem fogok forradalmian új programozási technikákról írni; azt majd a később elkészülő Vulkan-os cikkben fogom megtenni (amint a Khronos kijózanodott a szilveszteri buliból).



Azt rögtön borítékolnám, hogy a fejlesztéshez kell legalább egy Mac, illetve ha kütyüre akarsz fejleszteni, akkor kütyü is. Hogy milyen Mac-ek illetve kütyük támogatják a metált, arról itt egy lista. Én most egy iPad Pro-t fogok használni. Ezen kívül szükséges az Objective-C vagy a Swift ismerete (én előbbit fogom használni), illetve fejlesztés közben heavy metal-t kell hallgatni, különben nem tud kibontakozni az élmény (tippek lent).

Bár azt gondolnánk, hogy a hardverközeliség miatt sokkal nehezebb programozni, az eddig megszokott fogalmak mellé csak néhány új, eddig driver szintű fogalom jön be; éppen emiatt a Metal az új API-k közül talán a legkönnyebb. Persze itt is igaz, hogy ha optimálisan akarod kihasználni a vasat, akkor az eddig tipikusan driver oldali módszereket neked kell leimplementálni (ezek közül a frame queueing-ot egy későbbi bekezdésben bemutatom).

Kezdésképpen hozzunk létre XCode-ban egy új projektet az iOS/Application/Single View Application sablonnal. Az alábbi fájlokban fogok dolgozni (ezeket létrehozással/átnevezéssel kell hozzáadni a projekthez és a storyboard-hoz):

  • MetalView.h/m
  • MetalViewController.h/m
  • Renderer.m
Kezdjük először a view controller-el, mert ahhoz nem kell még semmi spéci dolog. Ez az osztály kezeli le az applikációval történő eseményeket (pl. háttérbe kerül), illetve a rendereléshez regisztrál egy időzítőt (bármiféle más megoldás nem számít tisztességesnek). A háttérbe kerüléskor illendő eldobni a nagy memóriát foglaló dolgokat (pl. rendertarget-ek), így ezt jelzi majd a renderernek.

A MetalView osztályban megmutatkozik az első metálos fogalom, ami MTLDevice névre hallgat és nem meglepő módon a GPU-val való interakciót biztosító interfészt jelenti (hasonló, mint a Direct3D device). A másik fontos osztály a CAMetalLayer, merthogy ha metált akarsz használni, akkor a view-od mögött ilyen kell legyen. A kódból a sallangot kihagyva:

CODE
@implementation MetalView { @private __weak CAMetalLayer* _metalLayer; id<CAMetalDrawable> _currentDrawable; id<MTLDevice> _device; } + (Class)layerClass { // Metal legyen mögötte return [CAMetalLayer class]; } - (id)initWithCoder:(NSCoder*)coder { self = [super initWithCoder:coder]; _metalLayer = (CAMetalLayer*)self.layer; _device = MTLCreateSystemDefaultDevice(); _metalLayer.device = _device; return self; }
CODE
- (id<CAMetalDrawable>)currentDrawable { // ildomos elfedni if (_currentDrawable == nil) _currentDrawable = [_metalLayer nextDrawable]; return _currentDrawable; } - (void)display:(float)frametime { @autoreleasepool { Render(self, 0, frametime); // rajzolás után új surface kell _currentDrawable = nil; } } @end

A CAMetalDrawable egy rajzolható felületet jelent, de nem tudod hogy a dupla/tripla bufferelés által ez éppen melyik, úgyhogy illik letárolni. A rajzolás csak ezen a currentDrawable üzeneten keresztül érheti el. Felmerül a kérdés, hogy mi biztosítja, hogy a következő rajzoláskor már egy újat fogunk kapni? Akár egy brazil szappanoperában, kiderül a folytatásból.

A Renderer.m-be szedtem össze a szokásos C függvényeket, amiket az eddigi cikkekben is implementáltam, nyilván most metállal. Erről egyelőre nem mondok semmit, mert előbb szükséges elmagyarázni az alapfogalmakat.


Sok újdonság nincsen, hiszen ezekről a fogalmakról eddig is volt tudomásunk, csak nem fértünk hozzá. Programozás oldalról szintén nem érhet meglepetés, mivel már a DirectX 10 is bevezette azt a módszert, hogy a GPU állapotait logikailag összecsoportosítja és egyben küldi le a kártyának.

device ez jelképezi a program felé a GPU-t. Le tud kérdezni bizonyos hardvertulajdonságokat, illetve egy rakás egyéb metál objektumot is ezzel lehet létrehozni (textúra, buffer, állapotleírók, stb.).
command queue command buffer-ek rendezett listája. Több szálból is használható.
command buffer a GPU számára érthető parancsok halmaza. Nem újrafelhasználható (szemben pl. a Vulkan-al).
command encoder az eddig ismert fogalmainkat (pl. "rajzolj pónilovat") lefordítja a GPU-nak és bepakolja a command buffer-be. Egyszerre csak egy lehet aktív egy adott command buffer-hez, de persze egymás után tetszőlegesen sok írhat bele. Három fajtája van (render, compute, blit).
render pass gyakorlatilag ez jelenti a frame buffert.
állapotleírók eszközkonfigurációk, mint pl. depth-stencil state, render pipeline state stb.
buffer tetszőleges adatot képes tárolni, sőt majdnem mindent ebben tárolsz (vertex, index, uniformok).
textúra ehhez nem kell különösebb magyarázat.
shader library shaderek halmaza; olyasmi mint DirectX-ben az effekt.

A metál megkülönböztet átmeneti (transient) és ...nem átmeneti (non-transient) objektumokat. Például a command buffer és egy hozzá tartozó command encoder átmeneti, mert minden frame-ben újat hozol létre belőlük. De mondjuk a render pipeline állapotleírója újrafelhasználható (milyen shader legyen beállítva).

A feladat legyen a következő: töröljük le a képernyőt kékre! Persze mivel ló szinten vagyunk, ezért sokkal több kódolással lehet csak ezt elérni. Először is kell egy command queue és egy render pass, amik értelemszerűen maradandó objektumok. Előbbit az inicializáló részben létre lehet hozni a newCommandQueue üzenettel. A buli további része már a rajzoláshoz kötődik:

CODE
id<MTLCommandQueue> commandqueue; // = [device newCommandQueue]; MTLRenderPassDescriptor* renderpassdesc; // = [MTLRenderPassDescriptor renderPassDescriptor]; void Render(MetalView* view, float alpha, float elapsedtime) { id<CAMetalDrawable> drawable = [view currentDrawable]; id<MTLCommandBuffer> commandbuffer = [commandqueue commandBuffer]; id<MTLRenderCommandEncoder> renderencoder; // framebuffer összerakása MTLRenderPassColorAttachmentDescriptor* colorattachment0 = renderpassdesc.colorAttachments[0]; colorattachment0.texture = drawable.texture; colorattachment0.loadAction = MTLLoadActionClear; // mi történjen a pass elején (törölje le) colorattachment0.storeAction = MTLStoreActionStore; // mi történjen a pass végén (tárolja le) colorattachment0.clearColor = MTLClearColorMake(0.0f, 0.125f, 0.3f, 1.0f); // kódolás és leküldés renderencoder = [commandbuffer renderCommandEncoderWithDescriptor:renderpassdesc]; [renderencoder endEncoding]; // más nem kell most [commandbuffer presentDrawable:drawable]; // a végén rakja ki a képernyőre [commandbuffer commit]; // mehet le a GPU-nak }

Azt nem részletezném, hogy milyen egyéb akciók vannak, de például mondhatod azt, hogy ne tárolja le, mert úgyis hülyeséget rajzoltál. De azt is mondhatod, hogy nagyon tetszett amit az előbb rajzoltál, ezért töltse vissza azt. A kódból nem nyilvánvaló, de a rendszer alapból nem csinál depth buffer-t (míg ezt OpenGL-ben eléggé megszoktuk), szóval ahhoz külön textúrát kell létrehozni. Azt például nem kell letárolni, mert úgyis hülyeséget rajzolsz.


Bár nagyon jól el lehet szórakozni a képernyő kékre törlésével, azért nem ártana valami geometriát is kipakolni; lehetőleg olyat, aminek köze van a metálhoz. Legyen hát az egy teáskanna, amit te ástál kanra (hétfő reggel mégis milyen viccet akartok...?):

CODE
@implementation MyMetalMesh @synthesize vertexBuffer = _vertexBuffer; @synthesize indexBuffer = _indexBuffer; @synthesize numIndices = _numIndices; + (MyMetalMesh*)loadFromQM:(NSString*)file { // ... id<MTLBuffer> vbo = [device newBufferWithBytes:vdata length:numvertices * vstride options:MTLResourceOptionCPUCacheModeDefault]; id<MTLBuffer> ibo = [device newBufferWithBytes:idata length:numindices * istride options:MTLResourceOptionCPUCacheModeDefault]; return [[MyMetalMesh alloc] initWithBuffers:vbo indexBuffer:ibo indexCount:numindices indexStride:istride]; } @end MyMetalMesh* mesh; // = [MyMetalMesh loadFromQM:@"teapot.qm"]; // rajzoláskor [renderencoder setVertexBuffer:mesh.vertexBuffer offset:0 atIndex:0]; [renderencoder drawIndexedPrimitives:MTLPrimitiveTypeTriangle indexCount:mesh.numIndices indexType:MTLIndexTypeUInt16 indexBuffer:mesh.indexBuffer indexBufferOffset:0];

A buffereknek két olyan tulajdonsága van, amit az opciók között lehet megadni. Az egyik a storage mode, ami lehet:
  • private: csak a GPU-n allokál
  • shared: a CPU és a GPU ugyanazt a memóriaterületet látja
  • managed: csak ha van dedikált memória; külön foglal a CPU-n és a GPU-n is
A másik pedig a CPU cache mode:
  • default: az írások/olvasások az elvárt sorrendben történnek
  • write combined: a CPU csak írja a buffert, sosem olvassa
Fontos kiemelni, hogy a metál nem foglalkozik azzal, hogy te felülírtad a buffert (kivéve ha még a commit előtt csináltad), tehát neked kell garantálni, hogy a következő frame nem írja felül a buffert mielőtt kirajzolódna.

Ez a kód még így nem fog működni, sőt talán errort is dob, merthogy kell egy pipeline state objektum, amihez meg kellene shader is. Bonyolódik a helyzet...


Igazi megváltás a metál shader nyelve a szutyok GLSL után, ugyanis gyakorlatilag C++11 (kiegészítve/elhagyva belőle). Azért is jó ez, mert ténylegesen forráskódként van kezelve, így nem kell a betöltésével küszködni. Használatban leginkább a HLSL .fx fájljaira emlékeztet, de a kiterjesztése .metal (mily meglepő). Lássuk:

CODE
#include <metal_stdlib> using namespace metal; struct CommonVertex { packed_float3 pos; packed_float2 tex; }; struct ConstantBuffer { float4x4 matWorld; float4x4 matViewProj; }; struct VS_OUTPUT { float4 pos [[position]]; float2 tex; };
CODE
vertex VS_OUTPUT vs_main( device CommonVertex* vertices [[buffer(0)]], constant ConstantBuffer& uniforms [[buffer(1)]], unsigned int vid [[vertex_id]]) { VS_OUTPUT out; float4 pos = float4(float3(vertices[vid].pos), 1.0); out.pos = uniforms.matViewProj * (uniforms.matWorld * pos); out.tex = vertices[vid].tex; return out; } fragment half4 ps_main( VS_OUTPUT in [[stage_in]], texture2d<half> tex0 [[texture(0)]]) { constexpr sampler sampler0( coord::normalized, address::repeat, filter::linear); return tex0.sample(sampler0, in.tex); }

Tényleg teljesen hasonló a HLSL-hez, csak máshogy jelöli a szemantikákat. Vegyük észre, hogy a bejövő vertex adatnál nincs megadva szemantika, ami ha belegondolunk világos is, hiszen mit érdekli a GPU-t, hogy mi van a bufferben? Csak az érdekli, hogy a vertex shader adjon neki pozíciót. Ennek megfelelően metálban vertex layout-ot sem szükséges megadni (de lehet, ha szeretnéd).

Az továbbra is igaz, hogy a GPU az alignolt adatot szereti, szóval ha nem mondod meg neki explicit, hogy az packed_float3, akkor ő float4-et fog olvasni. Sőt, a metálos példaprogikból látható, hogy a shader és a programkód is tudja használni ugyanazokat az SSE típusokat (pl. simd::float4). Én azt most nem használom, mert körülményes megírni a matek függvényeket.

Na de ha már így fel vagyunk vértezve, akkor rajzoljuk már ki azt a szegény teáskannát. Jöjjön tehát a szerelőszalag állapotleírója:

CODE
id<MTLRenderPipelineState> renderstate; id<MTLLibrary> defaultlibrary; id<MTLBuffer> uniforms; // nem kell magyarázni id<MTLTexture> texture; // = [device newTextureWithDescriptor: ...]; // inicializáló részben defaultlibrary = [device newDefaultLibrary]; MTLRenderPipelineDescriptor* pipelinedesc = [[MTLRenderPipelineDescriptor alloc] init]; NSError* error = nil; pipelinedesc.vertexFunction = [defaultlibrary newFunctionWithName:@"vs_main"]; pipelinedesc.fragmentFunction = [defaultlibrary newFunctionWithName:@"ps_main"]; pipelinedesc.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm; renderstate = [device newRenderPipelineStateWithDescriptor:pipelinedesc error:&error]; // rajzoláskor [renderencoder setRenderPipelineState:renderstate]; [renderencoder setVertexBuffer:mesh.vertexBuffer offset:0 atIndex:0]; [renderencoder setVertexBuffer:uniforms offset:0 atIndex:1]; [renderencoder setFragmentTexture:texture atIndex:0]; [renderencoder drawIndexedPrimitives:MTLPrimitiveTypeTriangle indexCount:mesh.numIndices indexType:MTLIndexTypeUInt16 indexBuffer:mesh.indexBuffer indexBufferOffset:0];

Ennyi. Megjegyzem, hogy metálban vannak ún. performance shader-ek, amik bizonyos gyakran használt árnyalási módszerek végletekig kioptimalizált változatai (pl. Gauss filter), de nem próbáltam ki.


Nem szeretem, amikor egy cikkben túl sok a forráskód, dehát egy API esetén nem lehet máshogy elmagyarázni a dolgokat. A fenti shaderben használt textúrát egy compute shader állítja elő. Ahogy előbb is, ennek is két oldala van: az egyik maga a shader (ez kerülhet nyugodtan ugyanabba a fájlba, mint az előbbiek), illetve a program oldal. Nézzük előbb a shadert:

CODE
kernel void coloredgrid( texture2d<half, access::write> tex0 [[texture(0)]], uint2 loc [[thread_position_in_grid]], constant float& time [[buffer(0)]]) { half4 color = { 0.0, 0.0, 0.0, 1.0 }; if ((loc.x / 16 + loc.y / 16) % 2 == 1) { color.r = sin(time) * 0.5 + 0.5; color.g = cos(time) * 0.5 + 0.5; color.b = sin(time) * cos(time) * 0.5 + 0.5; } tex0.write(color, loc); }

Különösen tetszik, hogy pont azt csinálja a shader, amit elvárok tőle. Nem úgy mint a szutyok OpenCL-ben, ahol a szabvány szerint érvényes dolgok sem hajlandóak működni. Például tudom, hogy a bufferben egy darab float van, tehát joggal írom le azt. Metálban ez gond nélkül megy is, OpenCL-ben meg vagy elszáll a driver vagy nem azt csinálja amit kell. Szóval az évtizedek óta tartó shader nyelv verseny győztese egyelőre a Metal. Nézzük akkor most a program oldalt:

CODE
id<MTLComputePipelineState> computestate; // = [device newComputePipelineStateWithFunction: ...]; id<MTLBuffer> computeuniforms; // = [device newBufferWithLength:sizeof(float) options:0]; // még a render pass előtt id<MTLComputeCommandEncoder> computeencoder = [commandbuffer computeCommandEncoder]; *((float*)[computeuniforms contents]) = time; [computeencoder setTexture:texture atIndex:0]; [computeencoder setBuffer:computeuniforms offset:0 atIndex:0]; [computeencoder setComputePipelineState:computestate]; [computeencoder dispatchThreadgroups:MTLSizeMake(8, 8, 1) threadsPerThreadgroup:MTLSizeMake(16, 16, 1)]; [computeencoder endEncoding];

Vessétek össze OpenGL-el. Majdhogynem ugyanolyan rövidke kód, mégis sokkal kifejezőbb.


Egyetlen dolog hiányzik még, amit már említettem, de magától valszeg senki nem jönne rá: a metál nem garantálja, hogy a bufferek tartalma konzisztens (szemben a régebbi API-kkal), tehát ha a GPU még dolgozik az uniform bufferrel, te viszont már a következő frame-ben felülírod, akkor rábasztál.

A frame queueing tehát nem csak új, hanem - a szemafor erejéig - kötelező módszer. Röviden: n darab frame-et leküldünk a GPU-nak, majd blokkoltatjuk a rajzoló szálat, amíg legalább egy be nem fejeződött. Ez még azért is jó, mert arra az időre egy másik szálon például számolhatod a pónilósimogató AI-t (és ezt fix hardveren nagyon egyszerű jól belőni).

CODE
#define NUM_QUEUED_FRAMES 3 dispatch_semaphore_t framesema; // = dispatch_semaphore_create(NUM_QUEUED_FRAMES); id<MTLBuffer> computeuniforms[NUM_QUEUED_FRAMES]; // nem piszkáljuk az előző frame adatait id<MTLBuffer> renderuniforms[NUM_QUEUED_FRAMES]; // render előtt csökkentjük (ha 0 akkor blokkol) dispatch_semaphore_wait(framesema, DISPATCH_TIME_FOREVER); // TODO: rajzolás a k-adik uniform bufferrel __block dispatch_semaphore_t block_sema = framesema; [commandbuffer addCompletedHandler:^(id<MTLCommandBuffer> buffer) { // render végén növeljük dispatch_semaphore_signal(block_sema); }]; [commandbuffer commit]; k = (k + 1) % NUM_QUEUED_FRAMES;

A példaprogramokban egyébként nehéz látni ennek a hozadékát, mert olyan gyorsan történik a kirajzolás, hogy esélye nincs elrontani a programot (pláne úgy, hogy időzítőről megy). Egy komolyabb programban viszont lehet rá építeni és ezért újabb pirospont jár az Apple-nek, ugyanis az Instruments-ben nanomásodpercre pontosan meg lehet nézni, hogy mi mennyi ideig tartott.


Első találkozásunkat éltük túl az új, ló level API-kkal. Megszerettük a heavy metal zenei műfajt. Gyönyörködtünk az XCode szivárványosan buzi syntax highlight-jában (már akinek az gyönyör...). Bár a metál CPU overhead-je kisebb, mint a korábbi API-knak, azért ez nem jelenti azt, hogy a régi jól bevált módszereket kidobhatjuk. Bizony a static/dynamic batching itt is rengeteget hoz, de ez valószínűleg mindig is így lesz.

Kód a szokott helyen. A példaprogi alatt a Breaking the Law című dal soul.


Höfö:

  • Meghallgatod a következő albumokat: Painkiller (Judas Priest), Imaginaerum (Nightwish), Inhuman Rampage (Dragonforce)
  • Akinek nem jön be a metál, annak álljon a hátába a hajnali GÖRCS!!!!

back to homepage

Valid HTML 4.01 Transitional Valid CSS!