Custom Renderers
This article will introduce you to writing custom renderer Components.
What is a “Renderer Component”? Back ↑
When you run your game, the things you see on screen are a visual representation of the objects you defined and created both in the editor and code. While Duality provides the facilities to talk to the render pipeline and ultimately draw things on the screen, it is the job of a renderer Component to use those facilities and actually draw something.
Depending on how you implement a renderer Component, it can display just about anything: A sprite, geometric shapes, a particle system, a whole tilemap, and so on. It may render in world space or screen space and it is not restricted to the concept of a single GameObject, or object at all.
Defining a Renderer Component Back ↑
The bottom line of creating a custom renderer is implementing the ICmpRenderer
interface. Alternatively, you can derive from Renderer
, which is an abstract Component type that implements some of the boilerplate code you need for your usual world-space object rendering.
In both cases, you will have to talk to the IDrawDevice
interface - it’s the API that Duality provides to let you draw things.
Option A: Implementing ICmpRenderer
Back ↑
Choosing the bottom line approach, implementing the ICmpRenderer
interface means you will have to provide the code for two members:
// A getter method that populates a CullingInfo struct with the objects
// position, radius and visibility group flags. Once retrieved, this
// information is used by Duality to determine which renderers are visible
// to any particular camera or drawing device.
void GetCullingInfo(out CullingInfo info);
// A method that uses the drawing device to actually draw the object.
void Draw(IDrawDevice device);
Let’s say you’re implementing a very simple renderer that draws a circle object with a fixed radius. It would look something like this:
public class SimpleCircleRenderer : Component, ICmpRenderer
{
private const float SampleCircleRadius = 50.0f;
void ICmpRenderer.GetCullingInfo(out CullingInfo info)
{
info.Position = this.GameObj.Transform.Pos;
info.Radius = this.SampleCircleRadius;
info.Visibility = VisibilityFlag.Group0;
}
void ICmpRenderer.Draw(IDrawDevice device)
{
// Draw things!
}
}
Now, some background on visibility group flags: Every Camera and rendering pass has a bitmask that specifies which groups of objects it can see. These groups are for the most part user-defined and by default, all the objects belong to the first group and all Cameras and passes can see all groups. Each group has its own VisibilityFlag
item that is set or not set in the device.VisibilityMask
bitmask, depending on whether a Camera or rendering pass can see it.
There are up to 31 visibility groups and for our sample above, our renderer just assumes to be part of the default group zero. It will be visible to all cameras and drawing devices that can see this group, which, by default, is all of them.
One of the available visibility flags is not a group flag and has a special role though. In Duality, a Camera can render an object either in world space, or in screen space. A world space rendering will not follow the Camera and remain in the same spot on- or off screen while the Camera moves around. On the other hand, a screen space rendering is like drawing directly on the Camera lens - it doesn’t matter where the Camera is or what it is looking at, that drawing will always be in the same spot on the screen.
The default rendering setup has two rendering passes: One that draws all world space objects and one that draws all screen space objects on top of them. Since Duality doesn’t know which object belongs to which, it use each renderers culling info to determine whether they are visible in each pass. The VisiblityFlag.ScreenOverlay
is dedicated to identifying a rendering pass that renders in screen space: Your renderer can simply set the flag when it belongs to screen space, or not set it when it’s bound to world space.
Option B: Deriving From Renderer
Back ↑
When writing a custom renderer that does the usual and simply renders a world space object, we can avoid duplicating some of the code that you saw in the ICmpRenderer
version above. Instead of implementing the interface directly, derive your Component class from the abstract Renderer
Component that does some of the boilerplate things for you. Our simplified implementation of the circle renderer would look like this:
public class SimpleCircleRenderer : Renderer
{
private const float SampleCircleRadius = 50.0f;
public override float BoundRadius
{
get { return SampleCircleRadius; }
}
public override void Draw(IDrawDevice device)
{
// Draw things!
}
}
As a bonus, the base class also allows to assign your custom renderer to any of the visibility groups in the editor. Simple, isn’t it?
Implementing Drawing Code Back ↑
In a somewhat similar way, we have two approaches for implementing our drawing code: A very high-level one using the Canvas
helper class, and the bottom-line by talking to IDrawDevice
directly.
Using the Canvas
Class Back ↑
The Canvas
class allows us to draw geometric shapes easily without worrying too much about the details. Our circle renderer could look like this:
[DontSerialize]
private Canvas canvas = new Canvas();
public override void Draw(IDrawDevice device)
{
// Prepare the Canvas for rendering to the target device
this.canvas.Begin(device);
// Where are we in world space?
Vector3 pos = this.GameObj.Transform.Pos;
// Draw things!
canvas.State.ColorTint = ColorRgba.Green;
canvas.FillCircle(pos.X, pos.Y, pos.Z, SampleCircleRadius);
// Finalize rendering with our Canvas
this.canvas.End();
}
If you want to slap a texture on that circle, render it additively or with a special shader, use canvas.State.SetMaterial
. You’ll also find some other properties that will allow you to specify additional parameters, like depth offset, the applied region of the texture, Font to use for text rendering and so on. Other parameters allow you to transform the rendered shapes, so you can rotate or scale what is rendered easily.
Using the IDrawDevice
Interface Back ↑
If you have a great deal of things to render (like particles in a particle effect, or tiles in a tile map) using the high-level Canvas
helper can get in the way of maximum performance. In other cases, Canvas
might not provide the functionality you need. Fortunately you can also talk directly to the drawing device and submit so-called “drawing batches”.
Each drawing batch consists of a set of vertices that define which geometry to draw, as well as a Material that represents how that geometry will be rendered. The vertices you submit need to be expressed in world space, as they are automatically transformed as needed by the vertex shader later on.
public override void Draw(IDrawDevice device)
{
// Retrieve position and scale of the object from its Transform component.
// We do not take into account rotation here, but we could, if needed.
Vector3 pos = this.GameObj.Transform.Pos;
float scale = this.GameObj.Transform.Scale;
// Display a centered [50, 50] rectangle
Rect rectTemp = new Rect(-25, -25, 50, 50);
// Create an array containing the four vertices of our single quad
VertexC1P3T2[] vertices = new VertexC1P3T2[4];
// Define the first vertex
vertices[0].Pos.X = pos.X + rectTemp.TopLeft.X * scale;
vertices[0].Pos.Y = pos.Y + rectTemp.TopLeft.Y * scale;
vertices[0].Pos.Z = pos.Z;
vertices[0].Color = ColorRgba.Red;
// Define the second vertex
vertices[1].Pos.X = pos.X + rectTemp.TopRight.X * scale;
vertices[1].Pos.Y = pos.Y + rectTemp.TopRight.Y * scale;
vertices[1].Pos.Z = pos.Z;
vertices[1].Color = ColorRgba.Red;
// ... define the other two vertices ...
// Submit a drawing batch
device.AddVertices(this.sharedMaterial, VertexMode.Quads, vertices);
}
The power of this approach is in how pure it is: At its core, you only write data into an array. This allows you to perform a lot of optimizations when it comes to efficiently rendering thousands of tiles or particles - at the cost of convenience. In a lot of cases, the Canvas
approach above is sufficient and easier to handle though and most of the time you don’t need to go that extra mile.
For a more complex and versatile implementation sample of a renderer Component that uses IDrawDevice
directly, take a look at the SpriteRenderer implementation.
Example: A Custom HUD Renderer Back ↑
The following example simply renders the mouse cursor and its position as part of a HUD. Note that, unlike the previous renderer samples, it operates in screen space and uses screen coordinates.
public class HudRenderer : Component, ICmpRenderer
{
private ContentRef<Font> font = null;
[DontSerialize] private Canvas canvas = new Canvas();
public ContentRef<Font> Font
{
get { return this.font; }
set { this.font = value; }
}
void ICmpRenderer.GetCullingInfo(out CullingInfo info)
{
info.Position = Vector3.Zero;
info.Radius = float.MaxValue;
info.Visibility = VisibilityFlag.AllGroups | VisibilityFlag.ScreenOverlay;
}
void ICmpRenderer.Draw(IDrawDevice device)
{
this.canvas.Begin(device);
// Set up the canvas as needed for our HUD
canvas.State.SetMaterial(DrawTechnique.Alpha);
canvas.State.ColorTint = ColorRgba.Green.WithAlpha(0.5f);
canvas.State.TextFont = this.font;
// Display a mouse cursor as a simple filled circle
canvas.FillCircle(DualityApp.Mouse.Pos.X, DualityApp.Mouse.Pos.Y, 5.0f);
// Draw some text next to the cursor
string cursorText = string.Format("{0}, {1}", (int)DualityApp.Mouse.Pos.X, (int)DualityApp.Mouse.Pos.Y);
canvas.DrawText(cursorText, DualityApp.Mouse.Pos.X, DualityApp.Mouse.Pos.Y);
this.canvas.End();
}
}
For a real-world example, take a look at the HUD renderer from the space shooter, which you can find among the sample packages.