This document outlines Trial's render pipeline system and details how rendering behaves internally and externally.
The system deals with objects of the following types:
Containers that are not themselves renderable, but may contain other renderables and provide positioning information.
An object to be drawn in the scene in some way. It is not specified how this object is drawn.
A rendering pass. Note that one pass may draw many objects, or none, such as for a post-processing pass.
A bundle of shader passes into a consistent pipeline that produces a set of textures or, more typically, renders to the screen.
In order to understand rendering, we have to look at two distinct cases where the renderer needs to change its state:
After the scene is set up all of the renderable objects contained within need to be registered with the shader pipelines, so that effective shader programs can be computed and allocated. This typically occurs as a bulk change within a loading context.
While the game is running, dynamic changes may add or remove renderable entities. Renderables may also come in or out of view, or for other reasons start or stop rendering. In this case the shader pipelines need to adapt appropriately to cull their draw calls.
To actually perform the rendering, each shader pipeline simply iterates through each shader pass in topological order. Each pass then conceptually proceeds as follows:
Allocate the objects that should be rendered for the current frame in a frame sequence:
1. If the frame is considered not to have changed, skip
2. For every
renderable that is tied to the pass:
If the renderable is considered visible within the pass' context (may be culled for occlusion, frustum, or other reasons):
Store the object and its
shader-program for the pass in the frame
Sort the frame to ensure objects are drawn in the correct order
Perform the render operations needed to render the frame:
Bind the pass' textures
Iterate over the
renderables in order of the frame:
Bind the object's textures and transforms
render with the associated
Important to note here are that
construct-frame is called for every frame, meaning the potential culling and ordering is called automatically once per frame to ensure consistency. Passes may elide changing the frame, but by default without optimisations they will not.
renderables that are tied to the pass will be considered for rendering within the pass' context at all. Meaning that any object first needs to be tied to the pass by
entering it. An object may also be dynamically removed by using
A pass only distinguishes between 1. Setup and 2. Runtime above by whether a
containter is passed to the pass or a single
renderable. Entering and leaving renderables potentially causes allocations to happen, as new shader programs may need to be compiled. More details on this are illustrated in §3.
The specifics of frame construction and rendering are elaborated upon in §4.
Broadly passes are distinguished in the following manner:
These passes contain all the rendering logic they need in themselves. The scene graph does not interact with them at all.
These passes share some rendering logic with objects from the scene graph, and render control usually lies with the actual objects, rather than with the pass itself.
For passes of type 1 there isn't much to discuss. The pass manages its own shader and geometry logic, and allocates that at the beginning when the pass data in general is allocated.
For type two, things are more interesting, as the pass needs to potentially allocate a shader program for the object, and needs to appropriately manage the lifetime of this program. The pass also needs to figure out the "effective shader sources" for each object tied to it.
When an object is
entered into a pass, whether on its own or through a
container, it proceeds as follows:
If the object is already in the renderable table, skip
Check if the object's class has an allocated shader program. If not:
Note that this test always fails if the object is a
Construct a shader program calling
1. For each shader type:
compute-shader with the shader type, the pass, and the object
If the list of shaders returned is not empty:
Merge the list of shaders with GLSL-toolkit and construct the
Merge the list of effective buffers for both pass and object
Construct the shader program using the list of
Check if an identical shader program exists. If not:
Generate the shaders and shader program
Associate a load trigger with the program to run
update-uniforms once loaded
Tie the object's class to the generated shader program
Increase the refcount of the shader program
Associate the object with its shader program in the renderable table
If the object was entered on its own, the staging area is now committed. Otherwise the commit is deferred until all resource construction is complete. Note that this process is very cheap for instances of classes that have already been entered once before.
When an object is entered from a collection, it also first calls
object-renderable-p on the object and pass, to preemptively cull objects that should not be rendered on the given pass.
When an object
leaves a pass, it proceeds as follows:
If an associated shader program is found in the renderable table:
1. Reduce the refcount of the associated shader program
2. Remove the object from the renderable table
Deallocation of shader programs is not done even if the refcount reaches zero, as this could lead to bad dynamic allocations in the future when the same object type is entered again, such as can often happen for effects or other dynamic object types.
Instead, deallocation is deferred to when the pass is next
staged, at which point the programs with a zero refcount are culled again, and not staged, leading to a deallocation on commit.
The rendering for post-processing passes is fairly straight-forward, simply calling
render on the pass itself with the singular
shader-program it has allocated for its operations.
Things only become interesting for per-object passes, as they first need to construct a sequence according to which the objects are rendered, and which also excludes objects that should be culled for the current frame. As described briefly in §2, this proceeds in two steps, in the first building the frame sequence, and in the second actually rendering that sequence.
In the first step,
construct-frame is called, which proceeds as follows:
If entities were added or removed, or visibility state of entities changed:
The visibility check especially is dependent on the specific rendering pass used and the camera model used. In many cases, recomputing the frame can be avoided as visibility of entities does not change frequently
map-visible on the pass'
scene, and iterate over every visible object:
If the object has an entry in the renderable table:
Store the object and its
shader-program for the pass in the
frame by the
The specifics of how this sorting occurs is up to the pass internals, but suffice to say that in most cases the cost can be held down a lot, and in cases where it's clear that order has not changed ahead of time, elided completely
Most of the magic here is in the function
map-visible. However, the precise operation of this is up to the individual camera models, which know how to properly perform frustum culling. The pass may also specialise
camera to return
NIL in order to iterate over every object in the
scene, and/or specialise
scene to return the pass itself in order to iterate over every object that was
entered into the pass. Doing so can be useful to handle rendering of objects not part of the scene graph, or objects that would by default be culled from visibility.
In the second step,
render-frame is called, which proceeds as follows:
bind-textures on the pass
This causes necessary texture units that the pass needs in its shader fragments to get bound appropriately. We only have to do this once per pass, so best to do it here
Iterate over the
renderables in order of the
This sets up the transform matrices as required for the specific object in play. The transform matrices are reset to their prior values after each object completes its render step
Same as before, but this time the object's textures are bound
render with the associated
This performs the actual draw call, as well as any potentially needed setting of uniforms and so forth. Note that the setting of many uniforms should not be done here, as the uniforms are set once per frame per object, and many of the uniform values do not change that frequently
render-frame is called as part of the pass'
render method, after which the pass may perform extra steps, such as blitting the framebuffer to the screen.