Introducing Batches

While Cinder's convenience methods are indeed convenient, they should be avoided when performance matters. Let's replicate the last example in the previous section, but upgrade it to use gl::Batch, which is a much more scalable technique.


class BasicApp : public App {
  public:
	void setup() override;
	void draw() override;
	
	CameraPersp		mCam;
	gl::BatchRef	mBox;
};

void BasicApp::setup()
{
	auto lambert = gl::ShaderDef().lambert().color();
	gl::GlslProgRef shader = gl::getStockShader( lambert );	
	mBox = gl::Batch::create( geom::Cube(), shader );
	
	mCam.lookAt( vec3( 3, 4.5, 4.5 ), vec3( 0, 1, 0 ) );
}

void BasicApp::draw()
{
	gl::clear();
	gl::enableDepthRead();
	gl::enableDepthWrite();

	gl::setMatrices( mCam );

	int numSpheres = 64;
	float maxAngle = M_PI * 7;
	float spiralRadius = 1;
	float height = 2;
	float boxSize = 0.05f;
	float anim = getElapsedFrames() / 30.0f;

	for( int s = 0; s < numSpheres; ++s ) {
		float rel = s / (float)numSpheres;
		float angle = rel * maxAngle;
		float y = fabs( cos( rel * M_PI + anim ) ) * height;
		float r = rel * spiralRadius;
		vec3 offset( r * cos( angle ), y / 2, r * sin( angle ) );
		
		gl::pushModelMatrix();
		gl::translate( offset );
		gl::scale( vec3( boxSize, y, boxSize ) );
		gl::color( Color( CM_HSV, rel, 1, 1 ) );
		mBox->draw();
		gl::popModelMatrix();
	}
}

This example contains several new concepts, so let's look at each one individually. First, we're preparing our CameraPersp in setup() now, storing it in the member variable mCam. We still call gl::setMatrices() every frame however, in order to reset our Model and Projection matrices.

The most important change though is that we're no longer calling gl::drawCube(). Instead we're relying on an instance of gl::Batch called mBox, which we initialize in setup(). We still call gl::getStockShader() using a gl::ShaderDef. However we capture the result to a gl::GlslProgRef. This class is Cinder's representation of a GLSL program (sometimes called a shader). GLSL is the language used for writing OpenGL shaders. We'll look at them in more detail in a later section, but in this case, it's the code that runs on the GPU to simulate Lambert shading.

Finally, we're instantiating our gl::Batch using this GlslProgRef, and another new class, geom::Cube. gl::Batch is a very important tool when using OpenGL with Cinder. It represents the pairing of a piece of geometry with a GLSL program. In this case, the geometry comes from geom::Cube. This class is a part of a family of geom::Source classes. It has many siblings, such as geom::Sphere, geom::Icosahedron and others. Although there are others, we're using the primary gl::Batch constructor here, supplying a geom::Source for the geometry and GlslProgRef for the GLSL program. Note that our draw() is quite similar to the previous version, the primary change being that we now call mBox->draw(). The gl::Batch::draw() method does exactly what we'd expect; note that it's still influenced by the active color and Model matrix, just as gl::drawCube() and other convenience methods are.

Let's look at geom::Sources in a bit more detail.


class BasicApp : public App {
  public:
	void setup() override;
	void draw() override;
	
	CameraPersp		mCam;
	gl::BatchRef	mShapes[3][3];
};

void BasicApp::setup()
{
	auto lambert = gl::ShaderDef().lambert().color();
	gl::GlslProgRef	shader = gl::getStockShader( lambert );
	
	auto capsule = geom::Capsule().subdivisionsAxis( 10 )
								.subdivisionsHeight( 10 );
	mShapes[0][0] = gl::Batch::create( capsule, shader );
	auto sphere = geom::Sphere().subdivisions( 30 );
	mShapes[0][1] = gl::Batch::create( sphere, shader );
	auto cylinder = geom::Cylinder().subdivisionsAxis( 40 )
								.subdivisionsHeight( 2 );
	mShapes[0][2] = gl::Batch::create( cylinder, shader );
	auto cube = geom::Cube();
	mShapes[1][0] = gl::Batch::create( cube, shader );
	auto cone = geom::Cone();
	mShapes[1][1] = gl::Batch::create( cone, shader );
	auto torus = geom::Torus();
	mShapes[1][2] = gl::Batch::create( torus, shader );
	auto helix = geom::Helix().subdivisionsAxis( 20 )
							.subdivisionsHeight( 10 );
	mShapes[2][0] = gl::Batch::create( helix, shader );
	auto icosahedron = geom::Icosahedron();
	mShapes[2][1] = gl::Batch::create( icosahedron, shader );
	auto teapot = geom::Teapot() >> geom::Scale( 1.5f );
	mShapes[2][2] = gl::Batch::create( teapot, shader );
	
	mCam.lookAt( vec3( 5, 11, 5 ), vec3( 0 ) );
}

void BasicApp::draw()
{
	gl::clear();
	gl::enableDepthRead();
	gl::enableDepthWrite();

	gl::setMatrices( mCam );

	float gridSize = 5;
	
	for( int i = 0; i < 3; ++i ) {
		for( int j = 0; j < 3; ++j ) {
			float x = ( -0.5f + i / 2.0f ) * gridSize;
			float z = ( -0.5f + j / 2.0f ) * gridSize;
			
			gl::ScopedModelMatrix scpModelMatrix;
			gl::translate( x, 1, z );
			gl::color( i / 2.0f, 1 - i * j, j / 2.0f );
			mShapes[i][j]->draw();
		}
	}
}

This example explores a number of the built-in geom::Sources. Note several examples of Cinder's usage of the named-parameter idiom to supply parameters to the geom::Sources. For example, the calls to subdivisionsAxis() and subdivisionsHeight() allow us to easily adjust how smooth the geom::Capsule is. Another key concept is demonstrated in the geom::Teapot() >> geom::Scale( 1.5f ) line. This example uses a geom::Modifier to modify the geom::Teapot, specifically the geom::Scale modifier, which in this case grows the Teapot by 50%. There are a number of geom::Modifiers built-in to Cinder, and they can be chained together using the >> operator.

The instance of gl::ScopedModelMatrix is significant as well. This class is a member of a large family of gl::Scoped* classes, such as gl::ScopedColor and gl::ScopedDepth. These classes are associated with a given piece of OpenGL state, which they preserve on construction, and restore on destruction. In our example, scpModelMatrix will save the current value of the Model matrix initially, and then will restore this value when it goes out of scope at the end of the inner for-loop. This is equivalent to separate calls to gl::pushModelMatrix() and gl::popModelMatrix(). However the gl::Scoped* classes more convenient and generally less error-prone.

Let's continue looking at another example of combining geom::Sources with gl::Batches.


#include "cinder/Easing.h"
…

class BasicApp : public App {
  public:
	void setup() override;
	void draw() override;
	
	static const int NUM_SLICES = 12;
	
	CameraPersp		mCam;
	gl::BatchRef	mSlices[NUM_SLICES];
};

void BasicApp::setup()
{
	auto lambert = gl::ShaderDef().lambert().color();
	gl::GlslProgRef	shader = gl::getStockShader( lambert );

	for( int i = 0; i < NUM_SLICES; ++i ) {
		float rel = i / (float)NUM_SLICES;
		float sliceHeight = 1.0f / NUM_SLICES;
		auto slice = geom::Cube().size( 1, sliceHeight, 1 );
		auto trans = geom::Translate( 0, rel, 0 );
		auto color = geom::Constant( geom::COLOR,
								Color( CM_HSV, rel, 1, 1 ) );
		mSlices[i] = gl::Batch::create( slice >> trans >> color,
														shader );
	}
	
	mCam.lookAt( vec3( 2, 3, 2 ), vec3( 0, 0.5f, 0 ) );
}

void BasicApp::draw()
{
	gl::clear();
	gl::enableDepthRead();
	gl::enableDepthWrite();

	gl::setMatrices( mCam );

	const float delay = 0.25f;
	// time in seconds for one slice to rotate
	const float rotationTime = 1.5f;
	// time in seconds to delay each slice's rotation
	const float rotationOffset = 0.1f; // seconds
	// total time for entire animation
	const float totalTime = delay + rotationTime +
									NUM_SLICES * rotationOffset;

	// loop every 'totalTime' seconds
	float time = fmod( getElapsedFrames() / 30.0f, totalTime );

	for( int i = 0; i < NUM_SLICES; ++i ) {
		// animates from 0->1
		float rotation = 0;
		// when does the slice begin rotating
		float startTime = i * rotationOffset;
		// when does it complete
		float endTime = startTime + rotationTime;
		// are we in the middle of our time section?
		if( time > startTime && time < endTime )
			rotation = ( time - startTime ) / rotationTime;
		// ease fn on rotation, then convert to radians
		float angle = easeInOutQuint( rotation ) * M_PI / 2.0f;

		gl::ScopedModelMatrix scpModelMtx;
		gl::rotate( angleAxis( angle, vec3( 0, 1, 0 ) ) );
		mSlices[i]->draw();
	}
}

In this example we're creating a cube out of 12 slices stacked on top of each other vertically. Each slice rotates around the Y-axis in 1.5 seconds, and each rotation is delayed 0.1 seconds to create a cascading animation.

One thing to notice in this example is how we're constructing our gl::Batches. Specifically, we're passing slice >> trans >> color as the geometry portion of the constructor. Another way to read this is that we're piping the result of a geom::Cube construction into a geom::Translate modifier, and then piping that into a geom::Constant modifier. The geom::Translate instance trans is used to offset each slice vertically. The geom::Constant modifier can be used to supply a constant value for an attribute. Here we're using it to provide a geom::Attrib::COLOR. The color value itself is determined using some simple HSV color math. Note that one implication of "baking" this value into the gl::Batch is that we no longer call gl::color() when we draw. It's worth noting that this property is distinctive to color values; when the geometry in a gl::Batch supplies a value for geom::Attrib::COLOR the shader uses that, otherwise the global color is used.

This example also represents our first usage of quaternions, though they're not mentioned by name. To rotate the slices we're using the angleAxis() function, which returns a ci::quat. angleAxis() takes an angle in radians and an axis (expressed as a vec3) to rotate around. In our case we're animating an angle from 0 to π / 2 radians, and we're rotating around the Y-axis, vec3( 0, 1, 0 ). The result of angleAxis() is passed to gl::rotate(), which modifies the Model matrix just as gl::translate() and gl::scale() do.

Finally, we're using our first easing function. You may be familiar with this concept from other tools, classically Flash in particular. Ease functions are useful for adding character and interest to animation. They generally remap the domain 0-1 into the range 0-1 nonlinearly. If you watch the our animation carefully, you'll see that each slice first accelerates and then decelerates into place, rather than simply rotating linearly. This is because we call easeInOutQuint() on our rotation variable before converting it into radians. This function is one of many that Cinder ships with for this purpose, and they're defined in the header "cinder/Easing.h". A graph of this specific example looks like the image to the right, which is taken from the EaseGallery sample.