OpenGL-SDL2-Window-Skia-Wrapper

Achtung! dieser Post ist Work in Progress ;D

Eine Instanz der Window Klasse ist ein Wrapper um ein SDL Fenster mit OpenGL-Kontext und Schnittstelle für die Skia Grafikbibliothek. Im Wesentlichen besteht die öffentliche Schnittstelle für Window Objekte aus nur drei Methoden.

class Window {
public:
  Window(std::string title, int x, int y, int width, int height);
  SkCanvas* getCurrentCanvas();
  void display();
};

Nach dem Konstruieren eines Window objekts, erscheint unserem Nutzer ein betriebssystemnatives Fenster. Mit getCurrentCanvas() erhalten wir das SkCanvas-Objekt auf das wir die Geometrie zeichnen, welche wir dem Benutzer zeigen wollen. Die Methode display() rufen wir auf, um das Gezeichnete dem Benutzer anzuzeigen.

Der Konstruktor kümmert sich um die Erstellung des Fensters, des OpenGL-Kontexts und der Schnittstelle für Skia.

Window::Window(std::string title, int x, int y, int width, int height) :
  sdlWindow(nullptr),
  glContext(nullptr),
  surfaceProps(SkSurfaceProps::kLegacyFontHost_InitType) {
  configureGl();
  initializeSdl();
  initializeSdlWindow(title.c_str(), x, y, width, height);
  initializeGlContext();
  initializeSurface();
}

Um bei der Programmierung nicht die Übersicht zu verlieren, ist das Initialisierungsprogramm in mehrere Funktionen aufgeteilt. Der Reihe nach werden Initialisierungsparameter für OpenGL via SDL konfiguriert, SDL initialisert, das SDL_Window objekt und darin der OpenGL-Kontext erstellt und dieser mit dem Skia Interface verknüpft.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void Window::configureGl() {
  // set OpenGL version to 3.0
  SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
  SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
 
  SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
 
  // skia requires 8 bit stencils
  SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
  SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
  SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
  SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
 
  // we are using double buffer for swaping graphic content to avoid flickering when clearing
  SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
 
  SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0);
 
  SDL_GL_SetAttribute(SDL_GL_ACCELERATED_VISUAL, 1);
 
  // uncomment the following to disable multisampling
  SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, 1);
  SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, 4);
}

Die Einstellungen für den OpenGL-Kontext können theoretisch angepasst und optimiert werden. Auf meinem System liefern die festgeschriebenen Einstellungen gute Resultate. Mir ist kein Grund bekannt, warum auf anderen Systemen diese Konfiguration zu schlechten Ergebnissen führen sollte. Deshalb habe ich mich entschieden die Einstellungen in den Quelltext der Methode zu schreiben anstatt die Werte als Paramter zu übergeben. Fällt dir ein Grund ein, warum eine Parameterübergabe doch sinnvoller sein sollte?

void Window::initializeSdl() {
  const bool sdlInitSuccess = SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) == 0;
  if (!sdlInitSuccess) {
    throw domain_error("Cannot initialize SDL");
  }
}

Auf lesbaren Quelltext lege ich besonders großen Wert. Das erleichtert anderen und mir selbst den Quelltext zu verstehen, selbst dann, wenn ich den Quelltext schon Monate lang nicht mehr angesehen habe. Deshalb habe ich mir angewöhnt bei if-Verzweigungen meistens die Prüfung in einer Variablen zu erfassen. Die Variable gibt an, was genau geprüft wurde. Damit ist das if-Statement ziemlich hübsch zu lesen, ganz so als ob wir uns tatsächlich mit unserem Computer unterhalten und beraten könnten <(^_^)>

Bei einem Fehler während der Initialisierung soll eine Ausnahme geworfen werden, sodass dies vom Aufrufenden Programm verarbeitet werden kann. Denkbar wäre zum Beispiel recht einfach ein Benachrichtungssystem einzubauen, mit Hilfe dessen der Kunde die Entwickler benachrichtigen kann. Systeminformationen über den Rechner unseres Kunden könnten mit Hilfe anderer Softwarebausteine automatisch erfasst und dem Fehlerbericht beigefügt werden…

void Window::initializeSdlWindow(char const*const title,
                                 int const& x, int const& y,
                                 int const& w, int const& h) {
  int const windowOptions = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE;
  sdlWindow = SDL_CreateWindow(title, x, y, w, h, windowOptions);
 
  bool const createWindowFailed = (sdlWindow == nullptr);
  if (createWindowFailed) {
    throw domain_error("Cannot create window");
  }
}

Natürlich wollen wir ein hardwarebeschleunigte Anzeige für unser Fenster. Außerdem gehe ich davon aus, dass die Größe des Fensters anpassbar sein soll. Also setzen wir die Optionen unseres Fensters auf das Bitmuster SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE.

TODO: bitmuster / bitflags / bitmasken erklären und verlinken

Erst jetzt initialisieren wir den OpenGL-Kontext, da wir ab jetzt sicher sein können, dass das Fenster erfolgreich erstellt wurde.

void Window::initializeGlContext() {
  glContext = SDL_GL_CreateContext(sdlWindow);
 
  bool const createGlContextFailed = (glContext == nullptr);
  if (createGlContextFailed) {
    throw domain_error("Cannot create GLContext");
  }
}

Mit Hilfe der Funktion GrGLCreateNativeInterface() erzeugen wir das Skia-OpenGL-Interface. Die Funktion liefert uns ein Pointerprimitv zurück, welches wir in einem Shared-Pointer-Konstrukt der Skia Bibliothek unterbringen. Um die passenden Einstellungen für das Skia-OpenGL-Interface zu finden, können wir SDL nach den jeweiligen Konfigurationsdaten fragen. Was für dich vielleicht ungewohnt ist: die Abfragen werden mit output-Parametern realisiert, satt Werte per return-Statement zurück zu geben. So übergeben wir einen Pointer auf renderTargetDesc.fStencilBits, damit die SDL Funktion auf den den mit dem Pointer referenzierten Speicher zugreifen kann und wir den Wert in renderTargetDesc.fStencilBits erhalten, den wir uns in der Variablen wünschen.

TODO: shared pointer erklären und verlinken

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void Window::initializeSurface() {
  sk_sp<const GrGLInterface> grGlInterface(GrGLCreateNativeInterface());
  SkASSERT(grGlInterface.get() != nullptr);
 
  // this cast looks awkard, but it is intended to do so by the skia library
  grContext = sk_sp<GrContext>(GrContext::Create(kOpenGL_GrBackend, (GrBackendContext)(grGlInterface.get())));
  SkASSERT(grContext.get() != nullptr);
 
  // initialize renderTargetDesc by using queries
  SDL_GL_GetAttribute(SDL_GL_STENCIL_SIZE, &renderTargetDesc.fStencilBits);
  GrGLint frameBufferObjectId;
  grGlInterface->fFunctions.fGetIntegerv(GR_GL_FRAMEBUFFER_BINDING, &frameBufferObjectId);
  renderTargetDesc.fRenderTargetHandle = frameBufferObjectId;
  SDL_GL_GetAttribute(SDL_GL_MULTISAMPLESAMPLES, &renderTargetDesc.fSampleCnt);
  // static initialization of renderTargetDesc
  renderTargetDesc.fConfig = kSkia8888_GrPixelConfig;
 
  // create surface
  synchronizeSurfaceSizeByRecreation();
}

Das SkSurface-Objekt, welches SkCanvas zur Verfügung stellt muss nach einer Änderung der Fenstergröße neu konstruiert werden. Die Funktion synchronizeSurfaceSizeIfNecessary() prüft ob sich die Fenstergröße geändert hat. Sollte dies der Fall sein, wird das bisher verwendete SkCanvas-Objekt über synchronizeSurfaceSizeByRecreation() durch ein neu erstelltes an die Fenstergröße angepasstes SkCanvas-Objekt ersetzt.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
SkCanvas* Window::getCurrentCanvas() {
  synchronizeSurfaceSizeIfNecessary();
  return surface->getCanvas();
}
 
void Window::synchronizeSurfaceSizeIfNecessary() {
  int width, height;
  SDL_GetWindowSize(sdlWindow, &width, &height);
  bool const needsSynchronization = (width != renderTargetDesc.fWidth) || (height != renderTargetDesc.fHeight);
  if (needsSynchronization) {
    renderTargetDesc.fWidth = width;
    renderTargetDesc.fHeight = height;
    surface = SkSurface::MakeFromBackendRenderTarget(grContext.get(), renderTargetDesc, &surfaceProps);
  }
}
 
void Window::synchronizeSurfaceSizeByRecreation() {
  SDL_GetWindowSize(sdlWindow, &renderTargetDesc.fWidth, &renderTargetDesc.fHeight);
  surface = SkSurface::MakeFromBackendRenderTarget(grContext.get(), renderTargetDesc, &surfaceProps);
}

Weil von Frame zu Frame die Größe des Fensters geändert werden kann und folglich das SkSurface-Objekt nach einer Größenänderung neu erzeugt werden muss, muss entsprechend auch das SkCanvas in jedem Frame mit getCurrentCanvas() erneut geholt werden. Dies ist eine Konvention, die vom Nutzer der Klasse eingehalten werden muss. Kannst du erklären, warum es so ungeheuer wichtig ist diese Konvention einzuhalten?

Im Detail wird durch die Zuweisung des Attributs surface dessen Referenzzähler um eins dekrementiert. Der Shared-Pointer surface ist im Window-Objekt gekapselt. Die Implementierung lässt keinen direkten Zugriff auf den Shared-Pointer zu. Deshalb kann es maximal eine aktive Referenz auf das SkSurface-Objekt geben, welches über den Shared-Pointer surface referenziert wird. Der Referenzzähler des Shared-Pointers surface kann also nie größer als 1 werden. Sobald surface ein anderer Shared-Pointer zugewiesen wird, wird der Referenzzähler um eins erniedrigt. Dies führt in diesem Fall unweigerlich dazu, dass der Referenzzähler den Wert 0 erreicht und damit das referenzierte SkSurface-Objekt gelöscht wird.

Über getCurrentCanvas() bekommen wir allerdings mit dem zurück gelieferten Pointer Zugriff auf Innereien der SkSurface. Folglich könnten wir selbst nach dem Löschen der SkSurface weiterhin mit dem Pointer auf bereits freigegebenen Speicherplatz zugreifen. Dies führt allerdings zu undefiniertem Verhalten, da der Speicherplatz in der Zwischenzeit neu vergeben und anders partitioniert werden kann. Wir lesen praktisch Datenmüll und falls wir schreibend auf das SkCanvas mit einem draw(***)-Aufruf zugreifen, könnten wir so ausversehen Speicher mit Datenbits überschreiben, welcher eigentlich bereits für andere Objekte reserviert wurde. Im schlimmsten Fall, wenn wir lesend und schreibend auf das SkCanvas zugreifen, dessen Speicherplatz bereits freigegeben und anschließend für andere Objekte reserviert wurde, schreddern wir also unsere Daten im RAM. Hoffentlich stürzt das Programm sofort ab, sodass wir die Fehlerquelle leichter einschränken können.

Wenn wir je Frame ein einziges mal getCurrentCanvas() aufrufen, sind wir vor dieser Fehlerquelle auf jeden Fall geschützt. (Mir fällt auch kein guter Grund ein, warum man die Funktion öfter als ein mal je Frame aufrufen sollte. Fällt dir ein Grund ein?)

One Reply to “OpenGL-SDL2-Window-Skia-Wrapper”

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.