Extending the GUI system

Even though Banshee provides fully skinnable and very customizable GUI elements, sometimes the built-in ones are just not enough if you need some very specialized functionality or look. Banshee allows you to create brand new elements and fully customize the way how they are rendered and how the user interacts with such elements.

You are expected to have read the user-facing GUI manuals before proceeding, and as such familiarized yourself with the basics.

All GUI elements derive from the base GUIElementBase type. The elements can be categorized into two major groups:

  • Layouts - They derive from GUILayout and do not have any graphics, but instead they control the placement of all elements attached to them.
  • Elements - They derive from GUIElement, and they're standard GUI elements like buttons, input boxes, sliders and such.

It's unlikely you will have the need to create GUILayout types, so we will instead focus on creating custom GUI elements.

GUIElement implementation

When creating a custom GUI element you will need to override the GUIElement class. In this section we'll go over the minimum set of functionality every GUI element must implement, split into relevant sections. In later sections we'll focus on optional functionality.

// GUI element that displays a single texture
class GUITexture : public GUIElement
{
// ... implementation
};

Construction

In its constructor GUIElement expects two parameters:

  • styleName - Name of the style to use for rendering the element. This will be looked up in the currently active GUISkin and relevant GUIElementStyle objects will be used for determining object's textures, fonts, colors, borders, margins and similar. Each GUI element type should have a unique default style name, but you also might want to allow the user to override the default style name if he desires.
  • dimensions - And initial set of dimensions determining GUI element's position and size, represented by GUIDimensions. You can create an empty set of dimensions by calling GUIDimensions::create(), in which case the system will use whichever dimensions are provided by the GUIElementStyle. Other overload of GUIDimensions::create() accepts a GUIOptions object, in which you can specify an arbitrary sized array of various dimension related options.

As a rule of thumb in order to aid construction GUI elements should provide a set of static create() methods (but aren't required to). You usually want to provide a few overloads of create() that allow the user to use custom style/dimensions or stick with the default one.

class GUITexture : public GUIElement
{
public:
static const String& getGUITypeName()
{
// Returns the name of this GUI element, used for looking up the default style
static String name = "Texture";
return name;
}
GUITexture(const String& styleName, const GUIDimensions& dimensions)
:GUIElement(styleName, dimensions)
{ }
// create() overload accepting custom dimensions in the form of GUIOptions
static GUITexture* create(const GUIOptions& options, const String& styleName = StringUtil::BLANK)
{
// If user didn't provide a style name, use the default
String finalStyleName = styleName;
if(styleName == StringUtil::BLANK)
finalStyleName = getGUITypeName();
// Pass style name, and construct dimensions from the provided list of options
return bs_new<GUITexture>(finalStyleName, GUIDimensions::create(options));
}
// create() overload using default dimensions
static GUITexture* create(const String& styleName = StringUtil::BLANK)
{
// Same as above
String finalStyleName = styleName;
if(styleName == StringUtil::BLANK)
finalStyleName = getGUITypeName();
// Pass style name, and construct default dimensions object
return bs_new<GUITexture>(finalStyleName, GUIDimensions::create());
}
// ... remaining GUITexture implementation
};

Graphics

In order for your GUI element to be visible you need to define its graphics. This involves:

  • Creating a set of vertices and indices that represent your GUI element
  • Create the material to render the GUI element with, and bind required textures and other properties to it

This is done by implementing the following methods:

GUIElement::_getNumRenderElements()

Returns the number of separate elements that your GUI element consists of. Each element corresponds to a mesh and a material to render the mesh with. For most GUI elements there is only one element, but you will need multiple elements in case your GUI element uses multiple textures or materials.

class GUITexture : public GUIElement
{
// ... remaining GUITexture implementation
UINT32 _getNumRenderElements() const override
{
// Our simple GUI element has just a single render element (a single quad to be exact)
return 1;
}
};

GUIElement::_getMeshInfo()

Takes a render element index as input and returns the number of vertices and indices for the mesh at the specified index. This allows external systems to know how big of a buffer to allocate for the element's mesh. It also returns the type of the mesh which lets external systems know how to render the provided vertices.

class GUITexture : public GUIElement
{
// ... remaining GUITexture implementation
void _getMeshInfo(UINT32 renderElementIdx, UINT32& numVertices, UINT32& numIndices, GUIMeshType& type) const override
{
// Since we're rendering a single quad, we use 4 vertices, rendered as 2 triangles (6 indices)
numVertices = 4;
numIndices = 6;
type = GUIMeshType::Triangle;
}
};

GUIElement::_fillBuffer()

This is the primary method when it comes to GUIElement graphics. It allows you to provide the actual vertex and index data that will be used for rendering the GUI element. Note that number of vertices and indices provided must match the number returned by GUIElement::_getMeshInfo().

The method receives pointers to the index and vertex memory that must be populated by the function, offsets at which memory location should the function output its data at, and external buffer sizes (used for overflow checking if needed).

The vertices are always in a specific format:

  • Each vertex contains a 2D position, followed by 2D texture coordinates
  • Vertex size if sizeof(Vector2) * 2
class GUITexture : public GUIElement
{
// ... remaining GUITexture implementation
void _fillBuffer(UINT8* vertices, UINT32* indices, UINT32 vertexOffset, UINT32 indexOffset,
UINT32 maxNumVerts, UINT32 maxNumIndices, UINT32 renderElementIdx) const
{
UINT32 vertexStride = sizeof(Vector2) * 2;
UINT32 indexStride = sizeof(UINT32);
// Get location of this element in the provided buffers
vertices += vertexStride * vertexOffset;
UINT8* uvs = vertices + sizeof(Vector2);
indices += indexStride * indexOffset;
// Populate the vertices, uv and indices buffer
// ... generate vertices, uvs and indices as you would for any mesh
}
};

Banshee also provides a set of helper classes for generating required geometry in the form of ImageSprite and TextSprite classes. ImageSprite can easily generate image geometry of specified size, whether a simple quad or a scale-9-grid image (scalable image with fixed borders). And TextSprite will take a text string, font and additional options as input, and output a set of quads required for text rendering.

GUIElement::_getMaterial()

Takes a render element index as input and returns a material to render the element with, as well material parameters.

GUI materials are represented by the SpriteMaterial type and you can use SpriteManager to retrieve the built-in materials. You can also create your own sprite materials by implementing the SpriteMaterial class and registering them with the SpriteManager by calling SpriteManager::registerMaterial().

Material parameters are represented by the SpriteMaterialInfo structure, which contains the texture and color to render the element with. It also contains a groupId identifier which tells the external systems with which other GUI elements is this element allowed to be grouped with. Grouped elements are rendered together (batched) for better performance, but sometimes this is not wanted. Generally you want to provide the memory address of the parent GUIWidget for the groupId field, and don't need to worry about it further. You can retrieve the element's GUIWidget by calling GUIElement::_getParentWidget().

Textures used by the material parameter can be retrieved from the current GUIElementStyle, or provided directly to the GUI element by user-specified means. It is preferred to use the GUIElementStyle approach as this allows the user to customize the element look through GUISkins, or by providing a custom style name. To retrieve the currently active style call GUIElement::_getStyle().

class GUITexture : public GUIElement
{
// ... remaining GUITexture implementation
const SpriteMaterialInfo& _getMaterial(UINT32 renderElementIdx, SpriteMaterial** material) const
{
// Get material for rendering opaque images
*material = SpriteManager::instance().getImageOpaqueMaterial();
// Populate the material parameters (normally you want to populate this when properties change and just return it here)
mMatParams.texture = _getStyle().normal.texture;
mMatParams.tint = Color::White;
mMatParams.groupId = (UINT64)_getParentWidget();
return mMatParams;
}
SpriteMaterialInfo mMatParams;
};

GUIElement::updateRenderElementsInternal()

Called whenever the element's size or style changes. This is the method where you should rebuild the GUI element's mesh. While you could do it directly in GUIElement::_fillBuffer() it is better performance-wise to do it here, and instead just return the cached mesh when GUIElement::_fillBuffer() is called. This is because GUIElement::_fillBuffer() will get called even when mesh geometry didn't change, if the external systems deem it required.

class GUITexture : public GUIElement
{
// ... remaining GUITexture implementation
void updateRenderElementsInternal()
{
// Move the code for vertex/index generation here, cache the generate data and then just return the cached data when _fillBuffer() is called.
GUIElement::updateRenderElementsInternal();
}
};

Layout

In order for the GUI element to work well with the automatic positioning performed by GUI layouts, you must override the GUIElement::_getOptimalSize() method. As the name implies the method should return the optimal size of your GUI element. For example if your element was displaying a texture 64x64 in size, then the optimal size should probably return 64x64. If you element is displaying text you can use GUIHelper to help you calculate the bounds of the text. If displaying something else then it's up to you to determine what constitutes an optimal size.

class GUITexture : public GUIElement
{
// ... remaining GUITexture implementation
Vector2I _getOptimalSize() const
{
Vector2I optimalSize;
// Retrieve the size of the main texture, if one is provided
HSpriteTexture tex = _getStyle().normal.texture;
if(tex != nullptr)
{
optimalSize.x = tex->getWidth();
optimalSize.y = tex->getHeight();
}
return optimalSize;
}
};

This concludes the minimal set of functionality required for implementing a GUIElement. At this point you will have a static GUI element that allows no user interaction. In next sections we expand on this by adding support for user input and dynamic graphics.

Redrawing

If your element allows the user to update certain contents of it after creation (i.e. it's not static), you need to notify the GUI system so it knows when to rebuild the element. For example if your GUI element displays a text string you might provide a setText() method that allows the user to change what is displayed. When text is changed the GUI system will need to update the batched GUI mesh, update the GUI element's mesh and/or update the layout position/size of the element.

There are three ways to notify the GUI system the element is dirty, each more expensive in performance than the previous:

  • GUIElement::_markMeshAsDirty() - Causes the batched GUI mesh to be re-assembled. Will cause a call to GUIElement::_fillBuffer(). Call this if element's contents and size didn't change (e.g. when its position, or depth changes). Batched GUI mesh is a mesh managed by the GUI system that consists of multiple GUI elements sharing the same material properties (used to improve rendering performance).
  • GUIElement::_markContentAsDirty() - Has the same effect as GUIElement::_markMeshDirty(), but will also trigger a GUIElement::updateRenderElementsInternal() call so the GUI element mesh will be fully rebuilt. Call this when contents of the GUI element change, but not its size (e.g. changing a texture but it is the same dimensions as the old one.)
  • GUIElement::_markLayoutAsDirty() - Has the same effect as GUIElement::_markContentDirty(), but will also cause the layout system to recalculate the element's position and size. This can be expensive as this will generally trigger layout updates for all siblings and children, potentially even parent GUI elements. Call this when element changes and no other dirty calls are appropriate. Normally you have to call this whenever the element's size changes.
class GUITexture : public GUIElement
{
// ... remaining GUITexture implementation
// Changes the look of the GUI element by fading it out when enabled
void setFadeOut(bool fadeOut)
{
if(fadeOut)
mMatParams.tint = Color(1.0f, 1.0f, 1.0f, 0.5f);
else
mMatParams.tint = Color::White;
// Ensure the system redraws the mesh. Since neither the size or contents changed we can call the cheapest redraw method, '_markMeshAsDirty()'
_markMeshAsDirty();
}
// Updated version of _getMaterial()
const SpriteMaterialInfo& _getMaterial(UINT32 renderElementIdx, SpriteMaterial** material) const
{
// Get material for rendering opaque images
*material = SpriteManager::instance().getImageOpaqueMaterial();
// Assuming remaining mMatParams contents are populated elsewhere
return mMatParams;
}
SpriteMaterialInfo mMatParams;
};

Input

In order for the GUI element to accept input from the user you can implement any or all of the following methods:

Each of the methods should return false if other GUI elements are allowed to receive the same event. If true is returned the GUI element will consume the event and it wont be visible to others.

class GUITexture : public GUIElement
{
// ... remaining GUITexture implementation
bool _mouseEvent(const GUIMouseEvent& ev)
{
// Fade out the texture when user mouses over the element
if(ev.getType() == GUIMouseEventType::MouseOver)
setFadeOut(true);
else if(ev.getType() == GUIMouseEventType::MouseOut)
setFadeOut(false);
return false;
}
};