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.
TartalomjegyzékAzt 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).
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:
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.
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:
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:
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.
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.
|