I am rendering sprites in 2D and am finding a lot of difficulties in batching them together.
Texture switches can be avoided by texture atlasing (at the cost of figuring out potential bleed/mip map issues), but as I add more complex effects, there are shader switches (sometimes with different vertex layouts/data), and notably a lot of switches between alpha and additive blending (since alpha-A, additive-B, alpha-C is a different result to say alpha-A, alpha-C, additive-B).
I know in 2D I could probably avoid some of these issues by moving things to common layers, e.g. all alpha-blended game objects with atlas, all additive particles, all alpha (e.g. smoke/dust) particles on top. But I wonder if it can be solved for arbitrary z/depth/order as in 3D rendering doing so doesn't seem an option.
I couldn't find any noticeable artifacts having played around in a number of 3D games so I feel they must be sorting all the transparent stuff in some efficient way (e.g. positioning the camera so that sprites both additive and alpha, and other transparent objects in the scene have different order to see if I got something odd like an additive explosion/spark/weapon effect/etc. drawn on top of dust/smoke/glass window/etc. that should be in front). Even with smoke trails from fire/damage/missiles/etc. that are individual sprites rather than say a line strip, and explosions clearly made from many smaller sprites.
My original design was to render this stuff back to front (easy in 2D), filling up a vertex buffer and then rendering with a draw call per state change in the original order, no extra sorting. But switching between alpha/additive and similar is breaking the batches into thousands of draw calls.
I then thought to my self that most of the time sprites do not overlap (especially when zoomed out, which is when the total on-screen object count can get really high). So I thought put all the items into a buffer then batch as good as possible. Batching seems to be much improved as overlaps are rare when zoomed out, but putting together the non-overlapping batches is extremely expensive.
Is there a better way to make way to make the batches? Of course the CPU has multiple cores, but splitting this aspect of rendering up seems tricky and probably the wrong way to go, as does just generally searching for optimisations of the loops, memory layout, etc.
// _items is an array populated with all the scene sprites to render
// Currently each item is just 4 vertices for a sprite plus its texture. Potentially will need to become more complex for arbitrary polygons (e.g. line strips), and where I need different vertex data (some effects), multiple textures, etc.
// _items[x].rendered is made true by add_to_batch
// Populating _items with the scene contents takes 3.43% total CPU time according to VS2019 profiler
(on a release build).
// material is a combination of texture, shaders, blend mode, etc.
void flush() // 67% total CPU
{
bool not_rendered;
size_t start = 0;
do
{
not_rendered = false;
for (size_t i = start; i < _items.size(); ++i) // 0.01% CPU
{
if (!_items[i].rendered)
{
auto &this_mat = _items[i].material;
start_batch(this_mat); // Setting shaders, textures, etc. 0.84% CPU
add_to_batch(_items[i]); // Adding sprite vertices to buffer, 0.10% CPU
for (size_t j = i + 1; j < _items.size(); ++j) // 5.80%
{
if (!_items[j].rendered) // 1.37%
{
if (_items[j].material != this_mat)
{
if (!not_rendered)
{
not_rendered = true;
start = i + 1;
}
} // 1.21%
else
{
bool can_batch = true;
for (size_t k = start; k < j; ++k) // 21.53%
{
if (!_items[k].rendered) // 26.81%
{
if (_items[k].material != this_mat) // 0.91%
{
if (overlaps(_items[j].bbox, _items[k].bbox)) // 1.69%
{
can_batch = false;
break;
}
}
}
}
if (can_batch)
{
add_to_batch(_items[j]); // 1.38%
}
else if (!not_rendered) // 0.67%
{
not_rendered = true;
start = i + 1;
}
}
}
}
flush_batch(); // Actual draw call, 0.20%
break;
}
}
} while (not_rendered);
_items.clear();
}
Being an RTS like thing there are a lot of objects, and adding damage, debris, trail, etc. particle effects is inflating the number of things to draw greatly. This can vary greatly by just tweaking a few things, but certainly can easily be in the thousands when zoomed out a bit, as distant particles can be just a few dozen pixels across. In a test scene I made is some 5,000 sprites, many of them only 16x16 or so at the zoom level (including particles. Possibly a bit extreme? This was intended to stress the system as lots of computers are somewhat slower). Takes my 2700X about 20ms to process on a core (50fps).