Borderland between Rendering and Editor - Part 1
We are pushing full steam ahead to take The Machinery from its current alpha state into beta. We’ll be writing more about going into beta and what that means in the near future. Today, I will instead share the first part of a mini-series of blog posts covering a few editor features that sit somewhere in the borderland between rendering and editor code, or more precisely:
- Grid rendering
- Mouse picking
- Selection highlighting
I might add to this list if I end up working on something more in this area that feels interesting to write about.
In common for all three of the topics above is that they have been itching for attention in the back of my head for quite some time now. While we’ve had some kind of half-assed solution for them in The Machinery editor, I’ve been painfully aware that they’ve definitely been in need of some love. Yet I’ve struggled with finding the motivation and time to replace the existing solutions with something better, but as we are getting ready to ship our beta we really want the first impression when starting to work with the editor to feel nice, so the time for procrastination is over.
Ironically when I finally put aside some time to work on this, all three turned out much easier to significantly improve than I had anticipated.
In this first part, we’ll be talking rendering of grids. To be honest I hadn’t really thought much about rendering a decent looking grid before we started Our Machinery. Instead, I’ve somewhat arrogantly just consider it being the problem for a tools programmer to solve. While they usually did an excellent job implementing various types of snapping tools and grids, they never got any attention or help visualize them nicely. Typically that meant they resolved to using some basic debug line drawing API. Not only was this bad from a performance point of view, it usually also meant the grid lines lacked any kind of anti-aliasing, because who cares about the visual quality of debug lines?
Since one of our main objectives with Our Machinery is to figure out how to architect engine technology in a way that makes tools development faster and hopefully also bridge the knowledge gap between tools programmers and “runtime” programmers, at least to some extent, I figured it would make sense to share how I implemented the current grid renderer that we will roll out in the soon to be released beta of The Machinery.
First, let’s take a look at our requirements for grid rendering:
-
We want a simple API to use from the editor code that allows rendering of one or multiple grids in one or many viewports.
-
We want to be able to freely specify grid size, transform, cell size, as well and color without having to care too much about any potential performance impact.
-
We want to be able to highlight every tenth line to increase the readability of the grid.
-
We want the grid lines to be rendered with anti-aliasing.
-
We want the grid to look pleasant from any viewpoint (i.e we want as little moire as possible).
-
We want the grid to fade out as it reaches its extents.
So let’s take a look at the C-API:
typedef struct tm_visual_grid_t
{
// World transform
tm_mat44_t tm;
// Extent of grid in X,Z plane
float grid_size;
// Minimum size of one cell
float cell_size;
// sRGB color and alpha of thin lines
tm_color_srgb_t thin_lines_color;
// sRGB color and alpha of thick lines (every tenth line)
tm_color_srgb_t thick_lines_color;
} tm_visual_grid_t;
struct tm_grid_renderer_api
{
void (*render)(const tm_visual_grid_t *grid,
const struct tm_shader_system_context_o *context,
struct tm_shader_o *grid_shader,
struct tm_renderer_resource_command_buffer_o *res_buf,
struct tm_renderer_command_buffer_o *cmd_buf);
};
From an editor point of view, we only have to care about tm_visual_grid_t
which is a POD struct.
Currently, we only support specifying quadratic grid extents and cell sizes, there’s nothing
stopping us from handling nonsquare extents (or cell sizes) but I fail to see any case you ever want
that so I opted for minimalism. As we render grids after post-processing and tone mapping, it makes
the most sense to specify the colors in 8-bit sRGB color space, making the color space consistent
with the rest of the UI code.
When it’s time to render the viewport tm_grid_renderer_api``->``render()
is called for each grid
the editor wants to draw. Internally this API doesn’t do anything else than writing the contents of
tm_visual_grid_t
to a constant buffer owned by the grid_shader
and issuing a draw call with 6
vertices. The rest happens in the shader.
In the vertex shader the 6 vertices are interpreted as the corners of two triangles spanning the extents of the grid in X,Z plane (we use a right-handed Y+ up coordinate system in The Machinery). Input to the pixel shader is the interpolated 2D grid coordinates going from { -extent, -extent } to { extent, extent }.
Let’s take a look at the code for the pixel shader:
// UV is grid space coordinate of pixel.
float2 uv = input.grid_position;
// Find screen-space derivates of grid space. [1]
float2 dudv = float2(length(float2(ddx(uv.x), ddy(uv.x))),
length(float2(ddx(uv.y), ddy(uv.y))) );
// Define minimum number of pixels between cell lines before LOD switch should occur.
const float min_pixels_between_cells = 1.f;
// Load cell size from tm_visual_grid_t, minimum size of a grid cell in world units
// that will be visualized.
float cs = load_cell_size();
// Calc lod-level [2].
float lod_level = max(0, log10((length(dudv) * min_pixels_between_cells) / cs) + 1);
float lod_fade = frac(lod_level);
// Calc cell sizes for lod0, lod1 and lod2.
float lod0_cs = cs * pow(10, floor(lod_level));
float lod1_cs = lod0_cs * 10.f;
float lod2_cs = lod1_cs * 10.f;
// Allow each anti-aliased line to cover up to 2 pixels.
dudv *= 2;
// Calculate unsigned distance to cell line center for each lod [3]
float2 lod0_cross_a = 1.f - abs(saturate(fmod(uv, lod0_cs) / dudv) * 2 - 1.f);
// Pick max of x,y to get a coverage alpha value for lod
float lod0_a = max(lod0_cross_a.x, lod0_cross_a.y);
float2 lod1_cross_a = 1.f - abs(saturate(fmod(uv, lod1_cs) / dudv) * 2 - 1.f);
float lod1_a = max(lod1_cross_a.x, lod1_cross_a.y);
float2 lod2_cross_a = 1.f - abs(saturate(fmod(uv, lod2_cs) / dudv) * 2 - 1.f);
float lod2_a = max(lod2_cross_a.x, lod2_cross_a.y);
// Load sRGB colors from tm_visual_grid_t (converted into 0-1 range)
float4 thin_color = load_thin_lines_color();
float4 thick_color = load_thick_lines_color();
// Blend between falloff colors to handle LOD transition [4]
float4 c = lod2_a > 0 ? thick_color : lod1_a > 0 ? lerp(thick_color, thin_color, lod_fade) : thin_color;
// Calculate opacity falloff based on distance to grid extents and gracing angle. [5]
float3 view_dir = normalize(input.view_dir);
float op_gracing = 1.f - pow(1.f - abs(dot(view_dir, load_tm()._m10_m11_m12)), 16);
float op_distance = (1.f - saturate(length(uv) / load_grid_size()));
float op = op_gracing * op_distance;
// Blend between LOD level alphas and scale with opacity falloff. [6]
c.a *= (lod2_a > 0 ? lod2_a : lod1_a > 0 ? lod1_a : (lod0_a * (1-lod_fade))) * op;
output.color = c;
For the experienced shader programmer, this code is probably fairly self-explanatory (and I’m sure there might be better or more efficient ways to implement this, feel free to enlighten me). For the rest of you, I’ll do a walk-through:
-
We start by finding the length of the derivatives (ddx/ddy) of the current grid coordinate. This is essentially how much we move in grid space from one pixel to the next.
-
Since we had a requirement of being able to specify any cell size (in world units, which in The Machinery is meters) without having to care about the location of the viewer we need some kind of automatic Level-Of-Detail (LOD) to avoid ending up in moire hell. We define the LOD transition metric to be the minimum allowed distance in pixels between two cell lines (
min_pixels_between_cells
) before the line has completely faded out and the transition to the next LOD-step has fully occurred. Since we highlight the gridline between every tenth cell it makes sense to use that to define a LOD-step. This is where thelog10()
andpow(10, lod_level)
comes from, for each LOD level we will cover 10^lod_level x more cells of the smallest size (cell_size
intm_visual_grid_t
). -
Since we want to blend between the LOD-steps we’ll calculate grid line coverage for the three different cell sizes (
lod0_cs
,lod1_cs
,lod2_cs
) calculated in (2). The cell size is essentially the frequency at which a new line should be visible. It’s easier to show graphically exactly what’s going on when calculating the coverage value, therefor eI’ve created a small demos graph.In the graph w is the line width (
dudv
in the code above) and c is the line frequency (lod0_cs, lod1_cs, lod2_cs
). The demos graph works in 1D but we are working on the grid plane in 2D with grid lines crossing each other, so we simply take the maximum coverage of X,Y and use that as out alpha mask value for each LOD-step (lod0_a, lod1_a, lod2_a
). -
This is fairly handwavy, but essentially here we simply calculate the fade between the thin and thick line colors based on the
lod_fade
and how much we consider the current pixel belongs to a certain LOD-step using the coverage alpha values calculated in (3). -
Calculates opacity fade-out for all grid lines based on viewing angle and distance to the center of the graph. This is a bit temporary and will likely be tweaked further, some knobs might need to get exposed in
tm_visual_grid_t
. -
Lastly, we modulate the final alpha value of the pixel with another handwavy mix of the line coverage masks for the three LOD-steps and multiply the whole shebang with the opacity value calculated in (5).
That’s it. A nice looking grid. Implement one yourself or check it out in our beta release coming soon.
Note: In a future post I might follow up with some floating-point precision considerations to be aware of when using this technique, and maybe something about playing nicely with depth testing against your TAA-jittered depth buffer, but time is up and I need to get this posted and get back working on the beta. 🙂