OpenGL in Cinder
Getting Started
Let's begin by looking at a minimal OpenGL application written with Cinder. This barebones app simply draws a blank, black window:
#include "cinder/app/App.h"
#include "cinder/app/RendererGl.h"
#include "cinder/gl/gl.h"
using namespace ci;
using namespace ci::app;
class BasicApp : public App {
public:
void draw() override;
};
void BasicApp::draw()
{
gl::clear();
}
CINDER_APP( BasicApp, RendererGl )
In order to use OpenGL, a Cinder app instantiates app::RendererGl UNSTYLE using the CINDER_APP
macro. The most straightforward way to start to use Cinder's OpenGL API is to #include "cinder/gl/gl.h"
. While this does not include all of Cinder's OpenGL headers, it does include some of the commonly used ones.
Convenience Functions
Often you're more interested in ease of implementation than performance. Cinder's ci::gl convenience functions are for this purpose. Let's look at how to use them to draw a filled circle. A modified version of the previous example's draw()
method follows:
void BasicApp::draw()
{
gl::clear();
gl::drawSolidCircle( getWindowCenter(), 200 );
}
We're making use of the gl::drawSolidCircle() function, which takes a vec2 offset and a radius. This short example also highlights the fact that by default app::RendererGl sets up a coordinate system that matches the app::Window 1:1 in pixels. Later we'll look at how to manipulate the coordinate system.
Let's make another modification to the draw()
method:
void BasicApp::draw()
{
gl::clear();
vec2 center = getWindowCenter();
float r = 100;
gl::color( Color( 1, 0, 0 ) ); // red
gl::drawSolidCircle( center + vec2( -r, r ), r );
gl::color( Color( 0, 1, 0 ) ); // green
gl::drawSolidCircle( center + vec2( r, r ), r );
gl::color( Color( 0, 0, 1 ) ); // blue
gl::drawSolidCircle( center + vec2( 0, -0.73 * r ), r );
}
The snippet above highlights the gl::color() method. This sets a global current color, which the convenience methods make use of.
Transformations
Traditionally in computer graphics, transformations (operations like translation, scale and rotation) are implemented using 4x4 matrices. Cinder's Model
matrix is a global transformation which is applied to any draw call. Routines like gl::translate(), gl::scale() and gl::rotate() manipulate the active Model
matrix.
void BasicApp::draw()
{
gl::clear();
// reset the matrices
gl::setMatricesWindow( getWindowSize() );
// move to the horizontal window center, down 75
gl::translate( getWindowCenter().x, 75 );
gl::color( Color( 1, 0, 0 ) );
gl::drawSolidCircle( vec2( 0 ), 70 );
// move down 150 pixels
gl::translate( 0, 150 );
gl::color( Color( 1, 1, 0 ) );
gl::drawSolidCircle( vec2( 0 ), 70 );
// move down another 150 pixels
gl::translate( 0, 150 );
gl::color( Color( 0, 1, 0 ) );
gl::drawSolidCircle( vec2( 0 ), 70 );
}
There's a couple of things to notice in this code, the first being the call to gl::setMatricesWindow(). This sets the Model
matrix to Cinder's default, window-aligned configuration when passed the Window's current size (via getWindowSize()). If we did not call this, we would keep translating the Model
matrix indefinitely, and our circles would quickly be too far down to see. This is because the effects of gl::translate() et al. are cumulative, meaning the operation is appended to the current Model
matrix rather than replacing it. In the case above, a call to gl::translate( 0, 150 )
translates the current Model
matrix 150 units vertically; note it does not set the translation to ( 0, 150 )
.
Also key is to note that we draw the circles with an offset of (0,0)
now, since we are relying on the Model
matrix manipulated by gl::translate() to position them.
For many use cases, it can be convenient to preserve, manipulate and then restore the Model
matrix. Cinder makes this simple by using a stack of matrices, coupled with the routines gl::pushModelMatrix() to push and preserve the Model
matrix, and gl::popModelMatrix() to restore it. Let's take a look at an example of how this can be useful.
void BasicApp::draw()
{
gl::clear();
// preserve the default Model matrix
gl::pushModelMatrix();
// move to the window center
gl::translate( getWindowCenter() );
int numCircles = 16;
float radius = getWindowHeight() / 2 - 30;
for( int c = 0; c < numCircles; ++c ) {
float rel = c / (float)numCircles;
float angle = rel * M_PI * 2;
vec2 offset( cos( angle ), sin( angle ) );
// preserve the Model matrix
gl::pushModelMatrix();
// move to the correct position
gl::translate( offset * radius );
// set the color using HSV color
gl::color( Color( CM_HSV, rel, 1, 1 ) );
// draw a circle relative to Model matrix
gl::drawStrokedCircle( vec2(), 30 );
// restore the Model matrix
gl::popModelMatrix();
}
// draw a white circle at window center
gl::color( Color( 1, 1, 1 ) );
gl::drawSolidCircle( vec2(), 15 );
// restore the default Model matrix
gl::popModelMatrix();
}
In this example, we preserve the default Model
matrix by calling gl::pushModelMatrix() at the start of draw()
, and calling gl::popModelMatrix() at the end. Note that we no longer need the call to gl::setMatricesWindow() as a result. Next, we translate to getWindowCenter(), so all subsequent draw calls are relative to the window's center. Then inside of a for-loop, we draw a series of circular outlines arranged in a circle whose radius is based on the height of the window. For each, we first do some basic trigonometry to determine the offset. Then we save a copy of the current Model
matrix and translate by this offset. Recall that because these transformations are cumulative, this translation is relative to the window center, due to our earlier call to gl::translate(). Next we set the color using HSV color and draw a circle relative to the current Model
matrix. We then restore the matrix (setting it to be the window center once again) using gl::popModelMatrix(). Finally, we draw a white circle at the window center and restore the matrix to its value when first entered draw()
.
If you're wondering if we could achieve the same thing simply by passing offset * radius
as the first parameter to gl::drawStrokedCircle(), you're correct. Let's look at an example which could not be achieved without using transformations. Here we'll use the gl::scale() and gl::rotate() functions.
void BasicApp::draw()
{
gl::clear();
// preserve the default Model matrix
gl::pushModelMatrix();
// move to the window center
gl::translate( getWindowCenter() );
int numCircles = 32;
float radius = getWindowHeight() / 2 - 30;
for( int c = 0; c < numCircles; ++c ) {
float rel = c / (float)numCircles;
float angle = rel * M_PI * 2;
vec2 offset( cos( angle ), sin( angle ) );
// preserve the Model matrix
gl::pushModelMatrix();
// move to the correct position
gl::translate( offset * radius );
// rotate by current angle
gl::rotate( angle );
// non-uniform scale
gl::scale( 8, 0.25f );
// set the color using HSV color
gl::color( Color( CM_HSV, rel, 1, 1 ) );
// draw a circle based on the current Model matrix
gl::drawSolidCircle( vec2(), 20 );
// restore the Model matrix
gl::popModelMatrix();
}
// restore the default Model matrix
gl::popModelMatrix();
}
In this example, we demonstrate a couple of new things. For each circle, we start by scaling it non-uniformly, making it 8 times as wide and ¼ as tall, by passing ( 8, 0.25f )
to gl::scale(). We also gl::rotate() each circle (now oval) by an angle that matches its position in the circular arrangement. Last, we move each oval to its position in the circle using gl::translate(). Note however that we make these calls in the reverse order to how they occur conceptually. Specifically, we call gl::translate(), followed by gl::rotate(), followed by gl::scale(), but the effect we achieve is the reverse - scaling, then rotation, then translation. This reversed order of operations is typical for graphics APIs and will grow comfortable for you if it's not already. And of course all of this points to the fact that the order of operations does indeed matter:
void BasicApp::draw()
{
…
for( int c = 0; c < numCircles; ++c ) {
…
gl::translate( offset * radius );
// reversed scale & rotate
gl::scale( 8, 0.25f );
gl::rotate( angle );
gl::drawSolidCircle( vec2(), 20 );
…
}
…
}
Here we've only swapped the order of the calls to gl::scale() and gl::rotate(), and it's obvious the order matters quite a bit. Consider what we're doing conceptually, keeping in mind that the operations are applied "in reverse" - they should be read from bottom-to-top relative to the draw call. First, we rotate by angle. However rotating a circle results in the same circle, so this achieves no effect. Next, we apply our non-uniform scale, creating an oval, and then finally we position the oval in the circular arrangement.
In the next section we'll look at how to take these concepts into 3D.