Inizializzare Direct3D12 3745 Visite) DirectX 12
In questo articolo andremo a mostrare come creare un applicazione hello world per Direct3D12 usando SharpDX.
Chi proviene dalle precedenti versioni vedrà delle piccole ma significative differenze.
Innanzitutto occorre importare i giusti namespaces, ognuno corrispondente all'omonimo Assembly.
using SharpDX.DXGI;
using SharpDX;
using SharpDX.Direct3D12;
using SharpDX.Windows;
DXGI rappresenta il ponte tra il sistema e la scheda grafica. Direct3D si appoggia ad esso per inviare a video ciò che renderizziamo. Attenzione, DXGI ha oggetti con nomi uguali a quelli Direct3D12. É quest'ultima che deve prevalere
Device and Swapchains
Creiamo il nostro device
Device device = new Device(null, SharpDX.Direct3D.FeatureLevel.Level_11_0);
Il device é l'oggetto che crea tutti gli oggetti Direct3D. Il primo parametro è l'Adapter, cioè la scheda video da usare (con null userà la scheda di default) mentre il feature Level indica la versione minima che deve avere chi usa l'applicazione.
Ogni rilascio di nuove versioni di DirectX corrisponde a nuove funzionalità per cui è richiesta una scheda video compatibile. Direct3D12 è retro compatibile con schede video fino alla versione 9. Impostando a 11 funzionerà, ad esempio, su hardware compatibili da DirectX11 in poi ma non potremo usare effetti nati nelle versioni successive.
Ora creiamo un factory, un oggetto DXGI
var factory = new Factory4();
Quindi un command queue
CommandQueueDescription queueDesc = new CommandQueueDescription(CommandListType.Direct);
CommandQueue commandQueue = device.CreateCommandQueue(queueDesc);
Questo oggetto è la prima novità. In Direct3D12 non si eseguono comandi, ma li si mettono in code in attesa che vengano lavorati.
Questo ci servirà per creare lo swapchain, l'area di memoria dove Direct3D disegna e che manda a video.
SwapChainDescription swapChainDesc = new SwapChainDescription()
{
BufferCount = 2,
ModeDescription = new ModeDescription(width, height, new Rational(60, 1), Format.R8G8B8A8_UNorm),
Usage = Usage.RenderTargetOutput,
SwapEffect = SwapEffect.FlipDiscard,
OutputHandle = Handle,
SampleDescription = new SampleDescription(1, 0),
IsWindowed = true
};
SwapChain tempSwapChain = new SwapChain(factory, commandQueue, swapChainDesc);
Render Target
A differenza delle precedenti versioni la creazione dell'oggetto è fatta dalla commandQueue. Questo permette al sistema di continuare l'esecuzione in attesa che la risorsa venga creata.
SwapChainDescription serve a descrivere le proprietà con cui creare lo swapchain. Le principali sono il ModeDescription (la risoluzione), IsWindows, che indica se l'applicazione sarà in finestra o fullscreen, e OutputHandle, l'handle della finestra che farà da schermo all'applicazione. Anche in fullscreen serve una finestra anche se verrà gestita interamente da DirectX. In fullscreen si possono usare solo risoluzioni supportate dal sistema mentre in windowed qualsiasi (meglio se uguali alla clientsize della Form). Qualsiasi controllo abbia un handle può essere utilizzato.
Infine convertiamo lo SwapChain nella sua versione 3
SwapChain swapChain = tempSwapChain.QueryInterface<SwapChain3>();
Questo ci darà accesso a molte più funzionalità.
Ora creeremo un Heap
DescriptorHeapDescription rtvHeapDesc = new DescriptorHeapDescription()
{
DescriptorCount = FrameCount,
Flags = DescriptorHeapFlags.None,
Type = DescriptorHeapType.RenderTargetView
};
DescriptorHeap renderTargetViewHeap = device.CreateDescriptorHeap(rtvHeapDesc);
L'heap é la grande novità Direct3D12. Quest'oggetto creerà delle zone di memoria con i riferimenti a tutti gli oggetti che creeremo. Si può immaginare come un indice. Non diremo mai a DirectX di usare direttamente una risorsa, ma di usare la risorsa collocata in un punto dell’heap.
Lo swap chain contiene al suo interno un certo numero di buffer (numero che abbiamo specificato con la proprietà BufferCount). Questi vengono chiamati RenderView e ne servono almeno due. Il meccanismo di funzionamento è molto semplice: mentre un RenderView si trova sul monitor, Direct3D disegna sull’altro. Appena è pronto vengono scambiati in modo da disegnare sul successivo.
Ora creeremo i RenderView associandoli all’Heap.
Int rtvDescriptorSize = device.GetDescriptorHandleIncrementSize(DescriptorHeapType.RenderTargetView);
rtvDescriptSize contiene il numero di byte che un RenderTarget occupa nell’heap.
CpuDescriptorHandle rtvHandle = renderTargetViewHeap.CPUDescriptorHandleForHeapStart;
Resource[] renderTargets = new Resource[2];
for (int n = 0; n < 2; n++)
{
renderTargets[n] = swapChain.GetBackBuffer<Resource>(n);
device.CreateRenderTargetView(renderTargets[n], null, rtvHandle);
rtvHandle += rtvDescriptorSize;
}
La proprietà CPUDescriptorHandleForHeapStart contiene la posizione iniziale dell’heap su cui associeremo i render target. Il ciclo non fa altro che creare i render target con il metodo GetBackBuffer ed associarli all’heap con il metodo CreateRenderTargetView.
Fatta l’associazione la posizione sull’Heap è ormai occupata, il successivo render target andrà caricato più avanti. Occorre quindi incrementare la posizione sul CpuDescriptorHandle e ripetere la procedura.
Command List
Ora si può creare la command list, l’esecutore dei metodi. Per creare una lista occorre creare prima un CommandAllocator che permette la creazione delle liste.
CommandAllocator commandAllocator = device.CreateCommandAllocator(CommandListType.Direct);
GraphicsCommandList commandList = device.CreateCommandList(CommandListType.Direct, commandAllocator, null);
commandList.Close();
Esistono 2 tipi di liste:
-
Direct: per renderizzare sullo swap chain ed eseguite dalla command queue
-
Bundle: per esecuzioni di blocchi di istruzioni ed eseguite dalle liste di tipo Direct
Gli ultimi oggetti che ci servono sono
Fence fence = device.CreateFence(0, FenceFlags.None);
AutoResetEvent fenceEvent = new AutoResetEvent(false);
Questi sono necessari per sincronizzare il rendering.
Rendering
Il ciclo di rendering, seppur simile alle precedenti, è in realtà profondamente diverso. Il rendering si compone di tre fasi: il popolamento di una lista, la sua esecuzione e la presentazione del render target a video.
-
La prima fase richiede il reset della lista e del command allocator, la scrittura di tutte le istruzioni e la chiusura della lista
commandAllocator.Reset();
commandList.Reset(commandAllocator, null);
//istruzioni
commandList.Close(); -
La seconda fase è l’esecuzione della lista
commandQueue.ExecuteCommandList(commandList); -
Infine la presentazione a video
swapChain.Present(1, 0);
Al termine del metodo Present i RenderView il renderview su cui ha lavorato la commandList viene mandato a video e quello che prima era a video viene messo nella condizione di poter essere usato.
La particolarità delle Direct3D12 è che l'esecuzione delle istruzioni è asincrona. Se la GPU è impegnata la CPU continua ad avanzare con il rischio di utilizzare risorse ancora in uso.
Per sincronizzare le risorse Direct3D ci fornisce il Fence (recinto).
Quando noi utilizziamo il comando
commandQueue.Signal(fence, N);
Diciamo al nostro device di impostare il suo valore ad N. In qualsiasi momento e da qualsiasi thread noi accediamo all’oggetto fence possiamo sapere quale valore è registrato al suo interno tramite la proprietà CompletedValue. Tale valore però non è registrato immediatamente, stiamo solo inviando alla GPU un comando per dirgli di registrarlo appena possibile. Se in quel momento la commandQueue non è ancora libera il valore presente nella fence sarà quello precedentemente impostato. Occorre quindi aspettare che il valore si aggiorni, segno che la commandQueue ha terminato tutto il lavoro.
Il modo migliore è utilizzare l’oggetto AutoReset che abbiamo definito
fence.SetEventOnCompletion(N, fenceEvent.SafeWaitHandle.DangerousGetHandle());
fenceEvent.WaitOne();
Queste istruzioni associano un evento all’oggetto AutoReset fenceEvent. Eseguendo il metodo WaitOne questo aspetterà il segnale dalla fence che ha aggiornato il suo valore ad N (cioè che la queue ha terminato tutte le istruzioni ed eseguito il metodo Signal). Utilizzando due frame si può semplicemente inviare un segnale subito dopo il present ed aspettare che il valore si aggiorni prima di continuare il rendering.
Utilizzando più di 2 RenderView non sarà necessario fermarsi ad ogni frame in quanto potremmo far lavorare Direct3D su un terzo buffer mentre il primo è ancora a video ed il secondo sta finendo di lavorare. Più avanti vedremo come utilizzare più RenderView.
Hello World
La prima applicazione farà una sola semplice operazione: colorerà lo schermo con un solo colore. All’interno della scrittura della command list
Per prima cosa utilizzeremo il metodo ResourceBarrierTransition. Il fence ci serve per sincronizzare la CPU e la GPU, questo per sincronizzare la GPU.
commandList.ResourceBarrierTransition(renderTargets[frameIndex], ResourceStates.Present, ResourceStates.RenderTarget);
In questo metodo stiamo dicendo a Direct3D di non proseguire con la command list finché l’oggetto che gli abbiamo passato (il renderView corrente) non ha completato il passaggio dallo stato di Present (cioè quando lo sta utilizzando il metodo Present) a quello di RenderTarget (cioè quando è libero e pronto). Siamo sicuri che non faremo nulla finché la risorsa non è libera.
CpuDescriptorHandle rtvHandle = renderTargetViewHeap.CPUDescriptorHandleForHeapStart;
rtvHandle += frameIndex * rtvDescriptorSize;
commandList.ClearRenderTargetView(rtvHandle, new Color4(0, 0.2F, 0.4f, 1), 0, null);
Qui vediamo l’utilizzo dell’heap. Ci facciamo restituire la posizione iniziale dell’Heap (dove abbiamo iniziato a registrar I nostri Render View), avanziamo fino alla risorsa che ci serve (il render view corrente) e quindi diciamo alla commandList che dovrà eseguire il metodo ClearRenderTargetView sulla risorsa che si trova in quel punto dell’Heap. Il metodo Clear non fa altro che pulire il render view con un colore.
Ora applichiamo una barriera che impedisca l’utilizzo del renderview fino allo stato present (cioè l’esecuzione del metodo Present).
commandList.ResourceBarrierTransition(renderTargets[frameIndex], ResourceStates.RenderTarget, ResourceStates.Present);
L’applicazione mostrerà una finestra con un colore unico sullo sfondo gestito da Direct3D12, la base per le nostre applicazioni. Chi proviene dalle precedenti versioni di Direct3D avrà notato che è richiesto uno sforzo maggiore nel comprendere questo nuovo ciclo di Rendering. Con le Direct3D12 abbiamo molta più flessibilità ma, allo stesso tempo, dobbiamo fare noi il lavoro che prima la libreria faceva per noi. Nell’immagine sotto c’è una demo in cui ci viene mostrato come la stessa applicazione sullo stesso Hardware possa passare da 19fps a 33fps, consumando meno CPU (che torna libera per gestire la logica delle applicazioni) ed un maggiore utilizzo della GPU.
Ovviamente utilizzare Direct3D12 in modo sbagliato al contrario ci porterà risultati peggiori.
Vi consiglio di consultare il demo HelloWindow disponibile su Github