Skip to main content
Geometry Batching         Low-level Geometry Batching using Hardware Buffers and PagedGeometry Engine

This article intent is to give a practical code demonstrating the usage of Hardware Buffers and to be able to understand how the PagedGeometry Engine works aka forests ogreaddon.
This tutorial is not meant to replace the Ogre Manual but to give a practical code to toy with those classes.

As you will notice when you study the PagedGeometry engine the code below is a rip-off of the engine. I am greatly indebted to JohnJ without his code I wouldn't have grasped the concepts.

Forum icon question2.gif Please, use this forum thread to discuss problems, suggestions, etc.

What is Geometry Batching?

Batching is the process of rendering sending massive amounts of meshes by accessing directly the graphics memory. For simple usage using Static Geometry is ideal. However sometime you need to be able to use more fundamental methods (such as if you want to create a specific scene manager, have more control on the mesh detail based on -LOD, ...). PagedGeometry is a good example.

Vertex & Buffer structure

TODO A nice UML diagram ๐Ÿ˜€

Classes Structure Explanation
VertexData
api,
manual
VertexDeclaration* vertexDeclaration;
VertexBufferBinding* vertexBufferBinding;
size_t vertexStart; size_t vertexCount;
HardwareAnimationDataList hwAnimationDataList;
size_t hwAnimationItemUsed;
Collects vertex sources information.
vertexCount is the number of vertices. hwAnimationDataList is usef for morph/pose animation.
IndexData
api,
manual
size_t indexStart;
size_t indexCount;
indexStart: buffer position to start from during an Operation
indexCount: number of indexes to use from the buffer
HardwareVertexBuffer
api,
manual
size_t mNumVertices;
size_t mVertexSize;
mVertexSize: size of a single vertex in this buffer.
HardwareIndexBuffer
api,
manual
enum IndexType;
enum Usage;
enum LockOptions;
...
VertexBufferBinding
api,
manual
std::map<ushort,
HardwareVertexBufferSharedPtr>         VertexBufferBindingMap;
VertexBufferBindingMap mBindingMap;
ushort mHightIndex;
...
VertexDeclaration
api,
manual
typdef std::list<VertexElement>
VertexElementList;
VertexElementList mElementList;
VertexElement should be added in a precise order: see below.
VertexElement
api,
manual
ushort mSource;
size_t mOffset;
VertexElementType mType;
VertexElementSemantic mSemantics;
ushort mIndex;
todo



Some important points need to be taken in consideration when implementing VertexDeclaration:
You should be aware that the ordering and structure of the VertexDeclaration can be very important on DirectX with older cards,so if you want to maintain maximum compatibility with all render systems and all cards you should be careful to follow these rules:

  1. -VertexElements should be added in the following order, and the order of the elements within a shared buffer should be as follows: position, blending weights, normals, diffuse colours, specular colours, texture coordinates (in order, with no gaps)
  2. You must not have unused gaps in your buffers which are not referenced by any VertexElement
  3. You must not cause the buffer & offset settings of 2 VertexElements to overlap

Sample Code Usage

The code below will demonstrate how to make a MovableObject which uses batched geometry. The code can be compiled as a static lib and used very simply. It will display a simple cube (no texture, no color).
Adding texture and color could be done in a second part.

Copy to clipboard
Ogre::SceneNode* sn = sceneManager->getRootSceneNode() ->createChildSceneNode(Ogre::Vector3(0,0,0)); Ogre::BatchedGeometry* object = new Ogre::BatchedGeometry(sceneManager, sn); object->addObject(Ogre::Vector3::ZERO); object->build();

To add your own geometry you need to modify the code between :

Copy to clipboard
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Note: The code is a bare minimum rip-off from the PagedGeometry Engine source also know as the forests ogreaddons.

Code Structure

The code is only composed of two classes: BatchedGeometry and Batch.

BatchedGeometry derives from a MovableObject which means you can easily manipulate it in your scenes. Its purpose is to collect the various sub-part composing the geometry you want to batch.

An very important point to note is that batches should be grouped together to use the hardware at its fullest. This is the reason of the BatchedGeometry::getFormatString() method in the PagedGeometry Engine. I didn't implement this (yet) to keep the code... simple.

Batch derives from a Renderable. This is the class that access the hardware buffer during the rendering phase.

Code Source

OgreBatchedGeometry.h

Copy to clipboard
#ifndef __OGE_BATCHEDGEOMETRY_H__ #define __OGE_BATCHEDGEOMETRY_H__ #include "OgreBatch.h" #include "OGRE/OgrePrerequisites.h" #include "OGRE/OgreMovableObject.h" namespace Ogre { /** */ class BatchedGeometry: public MovableObject { private: Vector3 mCenter; Real mRadius; AxisAlignedBox mBounds; bool mBoundsUndefined; Real mMinDistanceSquared; // Why not using MovableObject::mBeyondFarDistance ? bool mWithinFarDistance; /// Stores a list of Batchs, using a format string /// (generated with getGeometryFormatString()) as the key value typedef std::map<String, Batch*> BatchMap; BatchMap mBatchs; bool mIsBuilt; SceneManager* mSceneManager; SceneNode* mSceneNode; SceneNode* mParentSceneNode; public: typedef Ogre::MapIterator<BatchMap> BatchIterator; public: BatchedGeometry(SceneManager* mgr, SceneNode* parentSceneNode); virtual ~BatchedGeometry(); // ---- Abstract method that need to be implemented ---- const String& getMovableType() const { static String s = "BatchedGeometry"; return s; } const AxisAlignedBox& getBoundingBox() const { return mBounds; } Real getBoundingRadius() const { return mRadius; } const Vector3& getCenter() const { return mCenter; } const SceneNode* getSceneNode() const { return mSceneNode; } void _updateRenderQueue(RenderQueue* queue); void visitRenderables(Renderable::Visitor* visitor, bool debugRenderables) {} // ------------------------------------------------------ void clear(); void build(); bool isVisible(); /** Internal method to notify the object of the camera * to be used for the next rendering operation. */ void _notifyCurrentCamera(Camera* cam); void addSelfToRenderQueue(RenderQueue* queue, uint8 group); inline Real const getMinDistanceSquared() const { return mMinDistanceSquared; } /// Convert from the given global position to the local /// coordinate system of the parent scene node. Vector3 convertToLocal(const Vector3& globalVec) const; BatchIterator getBatchIterator() const; // The main method of the class! void addObject(const Vector3& position, const Quaternion& orientation = Quaternion::IDENTITY, const Vector3& scale = Vector3::UNIT_SCALE); ///Generate a format string that uniquely identifies /// this material & vertex/index format // TODO String getFormatString(Ogre::SubEntity* ent); }; } #endif

OgreBatchedGeometry.cpp


Copy to clipboard
#include "OgreBatchedGeometry.h" #include "OGRE/OgreCamera.h" #include "OGRE/OgreSceneNode.h" #include "OGRE/OgreSceneManager.h" namespace Ogre { //----------------------------------------------------------------------------- BatchedGeometry::BatchedGeometry(SceneManager* mgr, SceneNode* parentSceneNode) : mBoundsUndefined(true), mMinDistanceSquared(0), mWithinFarDistance(false), mSceneManager(mgr), mSceneNode(0), mParentSceneNode(parentSceneNode) { clear(); COUT("BatchedGeometry created") } //----------------------------------------------------------------------------- BatchedGeometry::~BatchedGeometry() { clear(); COUT("BatchedGeometry destroyed") } //----------------------------------------------------------------------------- void BatchedGeometry::clear() { // Remove the batch from the scene if (mSceneNode) { mSceneNode->removeAllChildren(); mSceneManager->destroySceneNode(mSceneNode->getName()); mSceneNode = 0; } // Reset bounds information mBoundsUndefined = true; //mBounds = AxisAlignedBox::BOX_NULL; why not? mCenter = Vector3::ZERO; mRadius = 0; // Delete each batch for (BatchMap::iterator i = mBatchs.begin(); i != mBatchs.end(); ++i) { delete i->second; } mBatchs.clear(); mIsBuilt = false; } //----------------------------------------------------------------------------- void BatchedGeometry::build() { if (mIsBuilt) OGRE_EXCEPT(Exception::ERR_DUPLICATE_ITEM, "Invalid call to build() - celestial object is already batched (call clear() first)", "BatchedGeometry::build()"); if (mBatchs.size() != 0) { // Finish bounds information mCenter = mBounds.getCenter(); //Center the bounding box mBounds.setMinimum(mBounds.getMinimum() - mCenter); mBounds.setMaximum(mBounds.getMaximum() - mCenter); //Calculate BB radius mRadius = mBounds.getMaximum().length(); // Create scene node mSceneNode = mParentSceneNode->createChildSceneNode(mCenter); // Build each batch for (BatchMap::iterator i = mBatchs.begin(); i != mBatchs.end(); ++i) { i->second->build(); } } // Attach the batch to the scene node mSceneNode->attachObject(this); // Debug mSceneNode->showBoundingBox(true); // TODO param mIsBuilt = true; } //----------------------------------------------------------------------------- void BatchedGeometry::_updateRenderQueue(RenderQueue* queue) { if (isVisible()) { // If appropriate ask each batch to add itself // to the render queue for (BatchMap::iterator i = mBatchs.begin(); i != mBatchs.end(); ++i) { i->second->addSelfToRenderQueue(queue, getRenderQueueGroup()); } } } //----------------------------------------------------------------------------- bool BatchedGeometry::isVisible() { // mVisible is an MovableObject attribute return mVisible && mWithinFarDistance; } //----------------------------------------------------------------------------- void BatchedGeometry::_notifyCurrentCamera(Camera* cam) { if (getRenderingDistance() == 0) { mWithinFarDistance = true; } else { //Calculate camera distance Vector3 camVec = convertToLocal( cam->getDerivedPosition()) - mCenter; Real centerDistanceSquared = camVec.squaredLength(); mMinDistanceSquared = std::max(0.0f, centerDistanceSquared - (mRadius * mRadius)); // Note: centerDistanceSquared measures the distance // between the camera and the center of the Batch, // while minDistanceSquared measures the closest distance // between the camera and the closest edge of the // geometry's bounding sphere. //Determine whether the BatchedGeometry is within / the far rendering distance mWithinFarDistance = mMinDistanceSquared <= Math::Sqr(getRenderingDistance()); } } //----------------------------------------------------------------------------- Ogre::Vector3 BatchedGeometry::convertToLocal(const Vector3& globalVec) const { assert(mParentSceneNode); //Convert from the given global position to // the local coordinate system of the parent scene node. return (mParentSceneNode->getOrientation().Inverse() * globalVec); } //----------------------------------------------------------------------------- BatchedGeometry::BatchIterator BatchedGeometry::getBatchIterator() const { return BatchIterator((BatchMap&)mBatchs); } //----------------------------------------------------------------------------- void BatchedGeometry::addObject(const Vector3& position, const Quaternion& orientation, const Vector3& scale) { // Create geometry // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ String format = "aa"; // TODO = getFormatString(...) // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Batch* batch; /* TODO BatchIterator iter = mBatchs.find(format); if (iter != mBatchs.end()) batch = iter->second; else { */ batch = new Batch(this); mBatchs.insert(std::pair<String, Batch*>(format, batch)); // } // cf. PagedGeometry addSubEntity() batch->addSub( position, orientation, scale ); // Update the bounding box Matrix4 mat(orientation); mat.setScale(scale); AxisAlignedBox entBounds; // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ // entBounds = ent->getBoundingBox(); entBounds.setMinimum(Vector3(-100,-100,-100)); entBounds.setMaximum(Vector3( 100, 100, 100)); // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ entBounds.transform(mat); if (mBoundsUndefined) { mBounds.setMinimum(entBounds.getMinimum() + position); mBounds.setMaximum(entBounds.getMaximum() + position); mBoundsUndefined = false; } else { Vector3 min = mBounds.getMinimum(); Vector3 max = mBounds.getMaximum(); min.makeFloor(entBounds.getMinimum() + position); max.makeCeil(entBounds.getMaximum() + position); mBounds.setMinimum(min); mBounds.setMaximum(max); } } //----------------------------------------------------------------------------- }

OgreBatch.h


Copy to clipboard
#ifndef __OGRE_BATCH_H__ #define __OGRE_BATCH_H__ #include "OGRE/OgrePrerequisites.h" #include "OGRE/OgreMovableObject.h" #include "OGRE/OgreMaterialManager.h" #define COUT(x) std::cout << x << std::endl; namespace Ogre { // Forward definition class BatchedGeometry; /* * */ class Batch : public Renderable { private: bool mIsBuilt; VertexData* mVertexData; IndexData* mIndexData; BatchedGeometry* mParent; MaterialPtr mMaterial; // NOTE : This should be recalculated every frame Technique* mBestTechnique; // Should those be part of a struct? Vector3 mPosition; Quaternion mOrientation; Vector3 mScale; ColourValue mColour; public: Batch(BatchedGeometry* parent); //, SubEntity* ent); ~Batch(); void build(); void clear(); void setMaterial(MaterialPtr& mat) { mMaterial = mat; } void setMaterialName(const String& mat) { mMaterial = MaterialManager::getSingleton().getByName(mat); } inline String getMaterialName() const { return mMaterial->getName(); } const MaterialPtr& getMaterial() const { return mMaterial; } Technique* getTechnique() const { return mBestTechnique; } void getWorldTransforms(Matrix4* xform) const; const Quaternion& getWorldOrientation() const; const Vector3& getWorldPosition() const; bool castsShadows() const; const LightList& getLights() const; void addSelfToRenderQueue(RenderQueue* queue, uint8 group); void getRenderOperation(RenderOperation& operation); Real getSquaredViewDepth(const Camera* cam) const; /** * This function is used to make a unique clone of materials, * since the materials will be modified by the batch system * (and it wouldn't be good to modify the original materials * that the user may be using somewhere else). */ Material* getMaterialClone(Material* material); // cf. PagedGeometry method: // void BatchedGeometry::SubBatch::addSubEntity( // SubEntity *ent, ....); void addSub(const Vector3& position, const Quaternion& orientation, const Vector3& scale, const ColourValue& color = ColourValue::White); }; } #endif

OgreBatch.cpp


Copy to clipboard
#include "OgreBatch.h" #include "OgreBatchedGeometry.h" #include "OGRE/OgreCamera.h" #include "OGRE/OgreSceneNode.h" #include "OGRE/OgreHardwareBufferManager.h" #include "OGRE/OgreTechnique.h" #include "OGRE/OgreRoot.h" #include "OGRE/OgreRenderSystem.h" #include "OGRE/OgreColourValue.h" namespace Ogre { //----------------------------------------------------------------------------- Batch::Batch(BatchedGeometry* parent) : mParent(parent) { mIsBuilt = false; // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ // See PagedGeometry BatchedGeometry::SubBatch::SubBatch() // Material *origMat = // ((MaterialPtr)MaterialManager::getSingleton().getByName( // ent->getMaterialName())).getPointer(); // mMaterial = // MaterialManager::getSingleton().getByName( // getMaterialClone(origMat)->getName()); // mMaterial = MaterialManager::getSingleton().create( // "Test/ColourTest", // ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); mMaterial->getTechnique(0)->getPass(0) ->setVertexColourTracking(TVC_AMBIENT); mMaterial->getTechnique(0)->getPass(0)->setSpecular(0,0,0,1); // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ // See PagedGeometry to see how to clone PARTS of the entity data mVertexData = new VertexData(); mIndexData = new IndexData(); // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ // Reset vertex/index count mVertexData->vertexStart = 0; mVertexData->vertexCount = 0; mIndexData->indexStart = 0; mIndexData->indexCount = 0; } //----------------------------------------------------------------------------- Batch::~Batch() { clear(); delete mVertexData; delete mIndexData; } //----------------------------------------------------------------------------- void Batch::addSelfToRenderQueue(RenderQueue* queue, uint8 group) { if (mIsBuilt) { // Update material technique based on camera distance assert(!mMaterial.isNull()); mBestTechnique = mMaterial->getBestTechnique( mMaterial->getLodIndexSquaredDepth( mParent->getMinDistanceSquared())); queue->addRenderable(this, group); } } //----------------------------------------------------------------------------- bool Batch::castsShadows() const { return mParent->getCastShadows(); } //----------------------------------------------------------------------------- void Batch::clear() { // If built, delete it if (mIsBuilt) { //Delete buffers mIndexData->indexBuffer.setNull(); mVertexData->vertexBufferBinding->unsetAllBindings(); // NOTE: If you are adding the elements each time // you must remove the previous one before // mVertexData->vertexDeclaration->removeAllElements(); //Reset vertex/index count mVertexData->vertexStart = 0; mVertexData->vertexCount = 0; mIndexData->indexStart = 0; mIndexData->indexCount = 0; } mIsBuilt = false; } //----------------------------------------------------------------------------- Material* Batch::getMaterialClone(Material* material) { String name = material->getName() + "_Batch"; MaterialPtr clone = MaterialManager::getSingleton().getByName( name ); if (clone.isNull()) clone = material->clone(name); return clone.getPointer(); } //----------------------------------------------------------------------------- void Batch::getRenderOperation(RenderOperation& operation) { operation.operationType = RenderOperation::OT_TRIANGLE_LIST; operation.srcRenderable = this; operation.useIndexes = true; operation.vertexData = mVertexData; operation.indexData = mIndexData; /* debug COUT(" 1 " << mVertexData->vertexCount) COUT(" 1 " << mVertexData->vertexCount) COUT(" 2 " << mVertexData->vertexDeclaration->getElementCount()) COUT(" 3 " << mVertexData->vertexDeclaration->getVertexSize(0)) COUT(" 4 " << mVertexData->vertexDeclaration->getElement(1)->getIndex()) COUT(" 5 " << mVertexData->vertexDeclaration->getElement(1)->getSource()) COUT(" 5 " << mIndexData->indexCount) COUT(" 6 " << mIndexData->indexBuffer->getIndexSize()) COUT(" 7 " << mIndexData->indexBuffer->getNumIndexes()) */ } //----------------------------------------------------------------------------- Real Batch::getSquaredViewDepth(const Camera *camera) const { Vector3 pos = mParent->convertToLocal( camera->getDerivedPosition()) - mParent->getCenter(); return pos.squaredLength(); } //----------------------------------------------------------------------------- const LightList& Batch::getLights() const { return mParent->queryLights(); } //----------------------------------------------------------------------------- void Batch::getWorldTransforms(Matrix4* xform) const { *xform = mParent->_getParentNodeFullTransform(); } //----------------------------------------------------------------------------- const Quaternion& Batch::getWorldOrientation() const { return mParent->getSceneNode()->_getDerivedOrientation(); } //----------------------------------------------------------------------------- const Vector3& Batch::getWorldPosition() const { return mParent->getSceneNode()->_getDerivedPosition(); } //----------------------------------------------------------------------------- void Batch::addSub(const Vector3& position, const Quaternion& orientation, const Vector3& scale, const ColourValue& color) { assert(!mIsBuilt); // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ // Use a geometry data struct to pass the data mVertexData->vertexCount = 8; mIndexData->indexCount = 36; // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ mPosition = position; mOrientation = orientation; mScale = scale; mColour = color; VertexElementType format = Root::getSingleton().getRenderSystem() ->getColourVertexElementType(); switch (format) { case VET_COLOUR_ARGB: std::swap(mColour.r, mColour.b); break; case VET_COLOUR_ABGR: break; default: OGRE_EXCEPT(0, "Unknown RenderSystem color format", "Batch::addSub()"); break; } } //----------------------------------------------------------------------------- void Batch::build() { assert(!mIsBuilt); Vector3 batchCenter = mParent->getCenter(); // See PagedGeometry::BatchedGeometry for how to set IT_32BIT // If I understand it correctly this is dependent // on the number of vertices you want to batch HardwareIndexBuffer::IndexType destIndexType; destIndexType = HardwareIndexBuffer::IT_16BIT; // The vertex data at last // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ VertexBufferBinding *vertBinding = mVertexData->vertexBufferBinding; VertexDeclaration *vertDecl = mVertexData->vertexDeclaration; //Allocate & lock the vertex buffers HardwareVertexBufferSharedPtr buffer = HardwareBufferManager::getSingleton() .createVertexBuffer(24, 8, HardwareBuffer::HBU_STATIC_WRITE_ONLY); vertBinding->setBinding(0, buffer); float* destPtr = static_cast<float*> (buffer->lock(HardwareBuffer::HBL_DISCARD)); // See Ogre Samples/CubeMapping/include/CubeMapping.h ! VertexDeclaration* dec = mVertexData->vertexDeclaration; size_t offset = 0; dec->addElement(0, offset, VET_FLOAT3, VES_POSITION); offset += VertexElement::getTypeSize(VET_FLOAT3); dec->addElement(0, offset, VET_FLOAT3, VES_NORMAL); const float sqrt13 = 0.577350269f; *destPtr++ = -100; *destPtr++ = 100; *destPtr++ = -100; *destPtr++ = -sqrt13; *destPtr++ = sqrt13; *destPtr++ = -sqrt13; *destPtr++ = 100; *destPtr++ = 100; *destPtr++ = -100; *destPtr++ = sqrt13; *destPtr++ = sqrt13; *destPtr++ = -sqrt13; *destPtr++ = 100; *destPtr++ = -100; *destPtr++ = -100; *destPtr++ = sqrt13; *destPtr++ = -sqrt13; *destPtr++ = -sqrt13; *destPtr++ = -100; *destPtr++ = -100; *destPtr++ = -100; *destPtr++ = -sqrt13; *destPtr++ = -sqrt13; *destPtr++ = -sqrt13; *destPtr++ = -100; *destPtr++ = 100; *destPtr++ = 100; *destPtr++ = -sqrt13; *destPtr++ = sqrt13; *destPtr++ = sqrt13; *destPtr++ = 100; *destPtr++ = 100; *destPtr++ = 100; *destPtr++ = sqrt13; *destPtr++ = sqrt13; *destPtr++ = sqrt13; *destPtr++ = 100; *destPtr++ = -100; *destPtr++ = 100; *destPtr++ = sqrt13; *destPtr++ = -sqrt13; *destPtr++ = sqrt13; *destPtr++ = -100; *destPtr++ = -100; *destPtr++ = 100; *destPtr++ = -sqrt13; *destPtr++ = -sqrt13; *destPtr++ = sqrt13; //Allocate the index buffer mIndexData->indexBuffer = HardwareBufferManager::getSingleton() .createIndexBuffer(HardwareIndexBuffer::IT_16BIT, 36, HardwareBuffer::HBU_STATIC_WRITE_ONLY); // As an alternative you can lock the buffer your self // and copy the data "manually" //uint16* indexBuffer16 = static_cast<uint16*> // (mIndexData->indexBuffer->lock(HardwareBuffer::HBL_DISCARD)); uint16 faces[36] = { 0,2,3, 0,1,2, 1,6,2, 1,5,6, 4,6,5, 4,7,6, 0,7,4, 0,3,7, 0,5,1, 0,4,5, 2,7,3, 2,6,7 }; //for (int i=0; i<36; ++i) //{ // *indexBuffer16++ = static_cast<uint16>(faces[i]); //} COUT(mIndexData->indexBuffer->getSizeInBytes()); COUT(sizeof(faces)); mIndexData->indexBuffer->writeData( 0, mIndexData->indexBuffer->getSizeInBytes(), faces, true); // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ //mIndexData->indexBuffer->unlock(); buffer->unlock(); mMaterial->load(); // TODO Not sure about this mIsBuilt = true; } //----------------------------------------------------------------------------- }

That's all folks.

--Steven 04:32, 2 April 2009 (UTC)


Alias: Geometry_Batching