Εισαγωγή στο Direct Draw

από τον Nuclear / the Lab

Πρόλογος

Γεια και πάλι, όπως υποσχέθηκα στο προηγούμενο tutorial που ήταν αφιερωμένο στα βασικά του Win32 API αυτό το tutorial είναι μια εισαγωγή στο DirectDraw, αλλά πριν αρχίσουμε ας ρίξουμε μια ματιά στο παρελθόν.

Εδώ είχα σκεφτεί να κάνω μια μικρή αναδρομή στο παρελθόν και στην εποχή του Dos αλλά θα αφήσω να σας τα πει ο Navis / ASD που είναι και παλαιότερος, και μπορεί να σας τα πει καλύτερα...

Από την εποχή του Dos μέχρι σήμερα, που το Start button είναι το πρώτο πράγμα που θα σημαδέψουμε με το ποντίκι μας το πρωί (σχεδόν όλοι ;-)), έχουν αλλάξει αρκετά στον τρόπο με τον οποίο διαχειριζόμαστε τα γραφικά του υπολογιστή. Πριν τις κάρτες με τους επιταχυντές, ο μόνος τρόπος για να σχηματιστεί μια εικόνα ήταν μέσο της προσπέλασης στην video ram της VGA: η διαδικασία πολύ απλή. Αλλάζουμε mode γραφικών σε (για παράδειγμα) 320x200 με 256 χρώματα (mode 13h), και με την λογική του poke offset, color (αλλά όχι σε basic ε :-) σχηματίζεται ένα pixel συγκεκριμένου χρώματος στη θέση του offset που θέτουμε (x+320*y). Με αυτό τον τρόπο γράφτηκαν όλα τα demo της παλιάς καλής εποχής, όπως επίσης και παιχνίδια που όλοι αγαπήσαμε, από "Defender of the crown" έως το "Quake".

Η πρώτη έλευση των windows, ενός διαφορετικού λειτουργικού με πιο αυστηρούς κανόνες όσον αφορά την άμεση προσπέλαση στο hardware, βρήκε αδιάφορους τους προγραμματιστές που ασχολούνταν με γραφικά. Απόλυτα δικαιολογημένη η στάση τους, καθώς οι ρουτίνες που υποστήριζαν γραφικά κάτω από windows ήταν ακόμη σε νηπιακό επίπεδο, πάρα πολύ αργές σε σύγκριση με το Dos. Κάποιες κινήσεις έπρεπε να γίνουν.

Στην αρχή ήταν το GDI (Graphics Device Interface), το οποίο επέτρεπε την δημιουργία γραφικών σε διάφορες πλατφόρμες υλικού, αλλά με κόστος την ταχύτητα. Κάθε κατασκευαστής κάρτας γραφικών άρχισε να γράφει τις δικές του ρουτίνες για το GDI, το οποίο βελτιώθηκε αρκετά.

Μετά ήρθε το WinG, το οποίο είχε και επιπλέον ρουτίνες για διαχείριση bitmaps, αλλά ακόμη ήταν πολύ αργό για χρήση σε real-time full screen εφαρμογές.

Τελικά η Microsoft σχεδίασε το Direct Draw API για πραγματικά χαμηλού επιπέδου πρόσβαση στο υλικό τού υπολογιστή, κάτι το οποίο έκανε πλέον τη δημιουργία παιχνιδιών και demo (σε συνεργασία και με τα άλλα API που βγήκαν όπως το Direct3D και OpenGL) πολύ πιο ενδιαφέρουσα.

Αυτό, φαίνετε, ήταν και το τελευταίο καρφί στην κάσα του Dos.

Και μετά αυτή τη μικρή εισαγωγή - αναδρομή από τον Navis ας περάσουμε στο ζουμί, αλλά πρώτα ας δούμε μια εκκρεμότητα που άφησα από το προηγούμενο tutorial.

Message Loop για real-time applications

Στο προηγούμενο tutorial που είχε θέμα εισαγωγή στο Win32 API είχαμε δει ένα βασικό main loop που χρησιμοποιούσε τη συνάρτηση GetMessage() για να παίρνει τα windows messages από το message queue, αυτή η συνάρτηση είναι ότι πρέπει για κλασικά event driven προγράμματα αλλά δεν είναι καλή για real-time προγράμματα λόγο του ότι περιμένει να μπει κάποιο message στο queue. Αυτό σε ένα demo θα είχε ως αποτέλεσμα να ανανεώνονται τα γραφικά στην οθόνη, μόνο όταν υπάρχει κάποιο message στο queue πράγμα απαράδεκτο. Ευτυχώς το Win32 API έχει μια συνάρτηση για την περίπτωση μας, την PeekMessage().

Οι δύο αυτές συναρτήσεις έχουν κάποιες διαφοροποιήσεις μεταξύ τους:

Επίσης η PeekMessage() παίρνει μια επιπλέον παράμετρο για το αν θα αφήσει το message στο queue η θα το αφαιρέσει, η GetMessage() το αφαιρεί αυτόματα.

Ας δούμε λοιπόν πώς είναι ένα message loop σε ένα real-time πρόγραμμα.

message loop

Όλα είναι λίγο πολύ γνωστά, ελέγχουμε αν υπάρχει κάποιο message, αν υπάρχει ελέγχουμε αν είναι το WM_QUIT, αν είναι βγαίνουμε από το loop αλλιώς καλούμε τις γνωστές TranslateMessage() και DispatchMessage(). Αν δεν υπάρχει κάποιο message κάνουμε ότι έχουμε να κάνουμε σε κάθε καρέ.

Και με αυτά καλύψαμε το κενό που είχαμε αφήσει από το προηγούμενο tutorial καιρός να μπούμε στο DirectDraw.

DirectX technology

Η Microsoft βλέποντας την απουσία μιας καλής λύσης για real-time γραφικά στα windows έκανε τo DirectX.

Το DirectX είναι ένα API που αποτελείτε διάφορα κομμάτια για high-performance graphics, sound, input και networking, σχεδιασμένο με βάση το COM (Component Object Model) ένα μοντέλο object oriented σχεδιασμού.

Στο tutorial αυτό μας ενδιαφέρει μόνο το DirectDraw οπότε ας δούμε το διάγραμμα με τα interfaces του DirectDraw.

ddraw COM interfaces

Όπως ορίζει το COM όλα τα αντικείμενα του DirectX είναι derived από το IUnknown.

Το IDirectDraw7 είναι το κυρίως object του DirectDraw, αυτό είναι το πρώτο αντικείμενο που δημιουργούμε και το τελευταίο που καταστρέφουμε, από αυτό δημιουργούμε όλα τα άλλα αντικείμενα του DirectDraw.

Το IDirectDrawSurface7 είναι ένα αντικείμενο που μας δίνει κάποιο buffer και διάφορες συναρτήσεις για να το χειριστούμε, το χρησιμοποιούμε για δύο πράματα, τέτοια surfaces αποτελούν το flipping chain, δηλαδή την ορατή στην οθόνη επιφάνια και τα back buffers για page-flipping, και επιπλέον τα χρησιμοποιούμε και ως off-screen surfaces για να κρατάμε εικόνες (ή textures για το D3D) που χρειαζόμαστε.

Το IDirectDrawPalette είναι για τη διαχείριση της παλέτας σε περίπτωση που χρησιμοποιούμε κάποιο palletized mode, π.χ. 256 χρώματα.

Τέλος το IDirectDrawClipper όπως λεει και το όνομα του αναλαμβάνει το clipping στα raster operations του DirectDraw.

Initializing Direct Draw

Ήρθε η ώρα να δημιουργήσουμε το πρώτο αντικείμενο που θα χρειαστούμε.

Χρειαζόμαστε ένα IDirectDraw7 pointer το οποίο θα περάσουμε σαν παράμετρο στην συνάρτηση DirectDrawCreateEx(), αλλά ας δούμε τι παραμέτρους παίρνει αυτή η συνάρτηση.

HRESULT WINAPI DirectDrawCreateEx(GUID FAR *lpGUID, LPVOID *lplpDD, REFIID iid,
        IUnknownFAR *pUnkOuter)

Οι παράμετροι που μας ενδιαφέρουν εδώ είναι η δεύτερη και η τρίτη, στη δεύτερη παράμετρο περνάμε τη διεύθυνση του pointer του IDirectDraw7 το οποίο από εδώ και μπρος θα χρησιμοποιούμε για να δημιουργήσουμε ότι άλλα αντικείμενα χρειαστούμε.

Στην τρίτη παράμετρο περνάμε το interface id του interface που θέλουμε να χρησιμοποιήσουμε, αυτό είναι ένα GUID (Global Unique Identifier), επειδή η συνάρτηση αυτή είναι για να δημιουργήσουμε την τελευταία έκδοση του DirectDraw (κανονικά χρησιμοποιούμε την DirectDrawCreate()) αν περάσουμε οτιδήποτε εκτός από IID_IDirectDraw7 θα πάρουμε error.

Ας δημιουργήσουμε λοιπόν το πρώτο αντικείμενο του DirectDraw.

code: create direct draw object

Μέχρι εδώ δεν νομίζω να υπάρχει κάποιο πρόβλημα, όλα είναι κατανοητά.

Σημείωση: από εδώ και μπρος δεν θα συμπεριλαμβάνω error handling στον κώδικα για να τον κρατήσω σε λογικό μέγεθος, παρόλα αυτά πάντα πρέπει να ελέγχουμε την τιμή που μας επιστρέφουν οι συναρτήσεις, η οποία κατά κανόνα στην επιτυχία είναι DD_OK ενώ σε πιθανή αποτυχία είναι ένα συγκεκριμένο error code που μας επιτρέπει να καταλάβουμε τι πήγε στραβά.

Αφού δημιουργήσαμε το βασικό αντικείμενο του DirectDraw η επόμενη κίνηση είναι να δηλώσουμε την συμπεριφορά του προγράμματος μας, αυτό γίνετε με την συνάρτηση IDirectDraw7::SetCooperativeLevel() ας δούμε τι παραμέτρους παίρνει αυτή η συνάρτηση.

HRESULT SetCooperativeLevel(HWND hWnd, DWORD dwFlags)
code: set cooperative level

Αφού θέσουμε το cooperation level καιρός είναι να μπούμε στο video mode που θέλουμε, αυτό βέβαια ισχύει μόνο για τα fullscreen προγράμματα, η συνάρτηση που μας επιτρέπει να βάλουμε το mode που θέλουμε είναι η IDirectDraw7::SetDisplayMode() ας την δούμε.

HRESULT SetDisplayMode(DWORD dwWidth, DWORD dwHeight, DWORD dwBPP, DWORD dwRefreshRate,
        DWORD dwFlags)

Αφού μπούμε στο mode που θέλουμε, το επόμενο βήμα είναι να δημιουργήσουμε το primary surface και τα back buffers δηλαδή το flipping chain. (σημ. αν φτιάχναμε 8bpp πρόγραμμα εδώ θα έπρεπε να δημιουργήσουμε την παλέτα).

Ας δούμε λοιπόν για παράδειγμα πως δημιουργούμε ένα flipping chain με δύο buffers (double buffering): το πρώτο πράγμα που έχουμε να κάνουμε είναι να συμπληρώσουμε ένα DDSURFACEDESC2 structμε τα απαραίτητα στοιχεία.

create primary surface part 1

Αφού συμπληρώσουμε το DDSURFACEDESC2 structure με τα απαραίτητα στοιχεία το περνάμε σαν πρώτη παράμετρο στην IDirectDraw7::CreateSurface(), στην δεύτερη παράμετρο δίνουμε τη διεύθυνση του pointer από το οποίο από εδώ και εμπρός θα χειριζόμαστε το Primary Surface. Η τρίτη παράμετρος είναι πάντα NULL.

create primary surface part 2

Τώρα πρέπει να δημιουργήσουμε και το 2o Surface στο flipping chain (Back Buffer), απλά θέτουμε σε ένα DDSCAPS2 structure το member dwCaps = DDSCAPS_BACKBUFFER και το περνάμε στην IDirectDrawSurface7::GetAttachedSurface() στην πρώτη παράμετρο, στη δεύτερη περνάμε τη διεύθυνση του pointer από το οποίο πλέων θα έχουμε το back buffer μας ...

get back buffer

Αυτό ήταν τελειώσαμε με το initialization του direct draw, καλό θα είναι να φτιάξουμε μια συνάρτηση που θα της περνάμε τα στοιχεία (X, Y resolution, BPP, Back Buffers κλπ) και θα κάνει όλη τη δουλειά, για να μην κάνουμε όλη αυτή τη διαδικασία συνεχώς κάθε φορά.

Clearing a Surface

Αρχικά ας δούμε πώς καθαρίζουμε ένα surface: ο καλύτερος τρόπος είναι να χρησιμοποιήσουμε ένα color fill blit, για να το κάνουμε αυτό πρέπει να συμπληρώσουμε ένα DDBLTFX structure με τα απαραίτητα στοιχεία και μετά να χρησιμοποιήσουμε τα Blitters του DirectDraw. Ας δούμε πως γίνετε αυτό.

Πρώτον όπως σε όλα τα structs του DirectDraw που χρησιμοποιούμε καθαρίζουμε με memset() και θέτουμε το dwSize στο μέγεθος του struct (με το sizeof operator), για να κάνουμε το colorfill πρέπει να θέσουμε στο dwFillColor το χρώμα που θέλουμε να χρησιμοποιηθεί στο colorfill, αν είμαστε σε 32bpp mode αυτό θα είναι ένα packed dword (AARRGGBB) σε 16bpp θα είναι ένα word (6R5G6B) ενώ σε 8bpp ένα byte με index στην παλέτα.

Αφού το κάνουμε αυτό καλούμε την συνάρτηση IDirectDrawSurface7::Blt() στην τελευταία παράμετρο της οποίας περνάμε τη διεύθυνση του DDBLTFX struct που δημιουργήσαμε, ενώ στα flags περνάμε DDBLT_WAIT και DDBLT_COLORFILL (για περισσότερες πληροφορίες για τα διάφορα flags που υπάρχουν κοιτάξτε το documentation που υπάρχει μαζί με το DirectX SDK). Στις υπόλοιπες παραμέτρους περνάμε NULL

color fill blit

Presenting the Back Buffer

Ωραία, γεμίσαμε το Back Buffer με κάποιο χρώμα, αλλά τώρα πρέπει να το περάσουμε στην ορατή επιφάνεια (Primary Surface) είναι πολύ απλό, μόλις τελειώσουμε ότι έχουμε να γράψουμε στο Back Buffer απλά καλούμε τη συνάρτηση IDirectDrawSurface7::Flip() και αυτόματα ότι είναι στο Back Buffer περνάει στην ορατή επιφάνεια, το μόνο που πρέπει να προσέξουμε εδώ είναι ότι καλούμε αυτή τη συνάρτηση από το primary surface, όχι από το back buffer.

swap buffers

Putting Pixels on the surface

Και τώρα ας πάμε σε κάτι πιο ενδιαφέρον, ωραία μέχρι εδώ αλλά πώς βάζουμε pixels;

Τα πράματα είναι και εδώ απλά, απλώς κάνουμε lock ένα surface με την IDirectDrawSurface7::Lock(), γράφουμε ότι έχουμε να γράψουμε και καλούμε την IDirectDrawSurface7::UnLock() για να δηλώσουμε ότι τελειώσαμε. Αφού καλέσουμε την Lock() έχουμε στη διάθεσή μας το pointer στην μνήμη του buffer, το οποίο και χρησιμοποιούμε για να γράψουμε σε αυτό.

Ας θυμηθούμε όμως πρώτα πως γράφαμε σε ένα buffer σε 13h ... έχωντας το pointer unsigned char *buffer κάναμε το εξής: buffer[y*320+x] = color; (όπου x και y οι συντεταγμένες του pixel που θέλουμε να γράψουμε) Δηλαδή βλέπουμε το buffer σαν ένα μεγάλο μονοδιάστατο array από bytes πηγαίνουμε στο σωστό σημείο και θέτουμε το συγκεκριμένο byte το index της παλέτας που αντιστοιχεί στο χρώμα που θέλουμε.

Στο DirectDraw τα πράγματα είναι πάνω κάτω τα ίδια, το μόνο που πρέπει να προσέξουμε είναι ότι πλέον δεν χρησιμοποιούμε την X διάσταση της ανάλυσης στην οποία είμαστε, αλλά μια τιμή που μας δίνει το direct draw (pitch) όταν κάνουμε Lock(), αυτό γίνετε διότι σε αντίθεση με το κλασικό 13h μπορεί να υπάρχουν κάποια επιπλέον bytes μετά το τέλος του κάθε scanline.

framebuffer organization

έτσι πλέον αν έχουμε ένα pointer buffer γράφουμε το pixel ως εξής: buffer[y*pitch+x] = color;

Ένα άλλο που πρέπει να προσέξουμε είναι ότι αν είμαστε σε κάποιο high color ή true color mode δεν είναι το κάθε pixel ένα byte, σε 15 και 16 bpp το κάθε pixel είναι ένα WORD (unsigned short) ενώ σε 32 bpp το κάθε pixel είναι ένα DWORD (unsigned long) οπότε το παραπάνω παράδειγμα ισχύει μόνο για 8 bpp όπου το κάθε pixel είναι ένα ΒΥΤΕ (unsigned char)

Ακόμα να τονίσω το γεγονός ότι πριν γράψουμε pixels σε ένα surface του DirectDraw, πρέπει να το κάνουμε Lock(), εδώ να πω επίσης ότι τα Lock() πρέπει να περιορίζονται στο ελάχιστο μιας και είναι ακριβή διαδικασία (σε χρόνο) οπότε δεν θα ήταν καλό να κάνουμε για παράδειγμα μια PutPixel συνάρτηση που να κλειδώνει την επιφάνεια και να γράφει το pixel, πρέπει να κάνουμε ένα lock να γράψουμε ότι pixels έχουμε να γράψουμε.

Επίσης να πούμε ότι για να κάνουμε lock περνάμε στις παραμέτρους της συνάρτησης Lock() τη διεύθυνση ενός DDSURFACEDESC struct στο οποίο μας δίνει τις πληροφορίες που χρειαζόμαστε, το pointer για το buffer του surface στο member variable lpSurface και το pitch στο lPitch.

Παράδειγμα στο παρακάτω κομμάτι κώδικα κλειδώνουμε το back buffer και παίρνουμε ένα byte pointer στο buffer της surface και το pitch

lock surface

Όταν τελειώσουμε με ότι έχουμε να κάνουμε στο buffer, καλούμε την IDirectDrawSurface7::UnLock() για να δηλώσουμε στο direct draw ότι τελειώσαμε με την επιφάνεια και μπορεί να την χρησιμοποιήσει.

unlock surface

Ας δούμε τις διάφορες PutPixel ανάλογα με το color mode που χρησιμοποιούμε.

8 bpp

Απλά πράματα, γράφουμε το byte με το index της παλέτας που αντιστοιχεί στο χρώμα που θέλουμε, όπως ακριβός και στο 13h

putpixel 8bpp

16 bpp

Σε 16bpp mode γράφουμε ένα WORD για κάθε pixel, που περιέχει τις τιμές R G B που θέλουμε για το συγκεκριμένο pixel (το χρώμα δηλαδή) ως γνωστόν ένα WORD είναι 16 bits πώς λοιπόν αποθηκεύουμε τις τρεις τιμές που αποτελούν το χρώμα σε μια 16bit μεταβλητή;

Υπάρχουν δύο τρόποι, ο ένας και πιο συνηθισμένος είναι να χρησιμοποιήσουμε τα 5 πρώτα bits για το κόκκινο, τα επόμενα 6 για το πράσινο και τα 5 τελευταία για το μπλε, αυτό έχει ως αποτέλεσμα να έχουμε πιο ομαλό color ramp στο πράσινο (στο οποίο είναι και πιο ευαίσθητο το μάτι), ο άλλος τρόπος είναι αφήσουμε αχρησιμοποίητο το πρώτο bit και να χρησιμοποιήσουμε 5 για το κόκκινο, 5 για το πράσινο και 5 για το μπλε (βλ. Σχεδιάγραμμα)

16bit pixel formats

Για να δούμε όμως και πώς κάνουμε pack σε ένα WORD τα 3 χρώματα, θα χρησιμοποιήσουμε δύο macros.

16bit pack macros

Αφού είδαμε και τα pixel formats ας δούμε πώς κάνουμε την PutPixel. Πρέπει να προσέξουμε δύο πράματα:

Πρώτον γράφουμε WORDS, το pitch όμως που μας δίνει το DirectDraw είναι σε BYTES, αλλά εμείς θέλουμε να ξέρουμε το μέγεθος του pitch σε WORDS για να γίνει ο υπολογισμός του offset σωστά, άρα απλά το διαιρούμε δια δύο (pitch / 2 ή καλύτερα pitch >> 1).

Το δεύτερο που πρέπει να προσέξουμε είναι ότι το pixel format δεν το καθορίζουμε εμείς αλλά πάει ανάλογα με την κάθε κάρτα γραφικών, οπότε πρέπει να καλύψουμε και τις δύο περιπτώσεις (565 ή 555).

Τέλος πάντων ας δούμε την putpixel μας:

16bit put pixel

24 bpp

Αυτό το mode είναι πολύ απλό, κάθε pixel είναι μια τριάδα από bytes, ένα για κάθε χρώμα (R G B), το μόνο που πρέπει να προσέξουμε είναι ότι τα γράφουμε ανάποδα, δηλαδή πρώτα το μπλε, μετά το πράσινο, και τέλος το κόκκινο, (βλ. Σχεδιάγραμμα).

24 bpp

Ας δούμε και τον κώδικα της PutPixel για 24 bpp modes.

putpixel 24bpp

Επίσης να πούμε εδώ ότι οι περισσότερες καινούριες κάρτες γραφικών δεν υποστηρίζουν 24bpp οπότε είναι καλύτερο να χρησιμοποιήσουμε 32 bpp αν θέλουμε true color.

32 bpp

Σε 32bpp mode γράφουμε ένα DWORD στο οποίο έχουμε κάνει pack τα τρία χρώματα (R G B), όπως και στο 16 bpp mode.

Σε χρωματική απόδοση δεν υπάρχει καμία διαφορά από το 24 bpp mode, και στα δύο έχουμε ένα πολύ ομαλό color ramp με 256 αποχρώσεις για το κάθε χρώμα, αυτό σημαίνει ότι αν και γράφουμε 32 bits (ένα DWORD) για κάθε pixel, στην πραγματικότητα χρησιμοποιούμε μόνο τα 24 bits από αυτά, και το ένα που περισσεύει μπορούμε ή να το αφήσουμε αχρησιμοποίητο (το πιο σύνηθες), ή να το χρησιμοποιήσουμε για να αποθηκεύσουμε κάποιες άλλες πληροφορίες για το pixel (π.χ. Alpha value), για να δούμε και ένα σχεδιάγραμμα για να γίνει πιο κατανοητό.

32bpp

Και ο κώδικας της PutPixel για 32 bpp.

putpixel 32bpp

Το macro που χρησιμοποιούμε για να κάνουμε pack τα 3 bytes (R G B) σε ένα DWORD είναι ως εξής: (επάνω: το πρώτο byte αχρησιμοποίητο, κάτω: το πρώτο byte χρησιμοποιείτε για Alpha value)

32bpp packing macros

Τελειώνοντας, να πούμε ότι πριν βγούμε από το πρόγραμμα μας πρέπει να κάνουμε Release() όλα τα interfaces του direct draw που δημιουργήσαμε, και με την αντίστροφη σειρά από αυτή που τα δημιουργήσαμε, η συνάρτηση Release() υπάρχει σε όλα τα interfaces του direct draw αφού είναι από τις απαιτούμενες συναρτήσεις του COM.

Επίλογος

Ήρθε η ώρα να κλείσει αυτό το πρώτο εισαγωγικό tutorial γιατο direct draw, σε αυτό το tutorial καλύψαμε τα απολύτως βασικά του direct draw, αν υπάρξει ενδιαφέρον θα ακολουθήσει επόμενο tutorial που θα αναφέρετε και σε άλλα κομμάτια του direct draw (blitter, clipper κλπ).

Αν υπάρχουν απορίες, πάνω στο direct draw ή στο tutorial αυτό, αν υπάρχουν ασάφειες, ή για οποιονδήποτε άλλο λόγο επικοινωνήστε μαζί μου στο nuclear@siggraph.org

Nuclear / the Lab