Metal instance rendering – a few questions

Viewed 476

I'm currently getting into Apple's Metal API, and I can't wrap my head around how rendering a high amount of instances would be implemented, since every instance needs to have its own (portion of) a buffer.

Let's say I would like to draw about 50.000 instances of a model (which would be quite ridiculous, I admit.) Every instance stores its positioning and skeletal animation data in a buffer, let's say 50 joints per instance. Now considering that up to three frames can be rendered concurrently, I would need these instance buffers times the number of frames.

This would result in a rather ridiculous buffer size, just to draw instances. Also, how should a varying number of instances be implemented, allocating a larger buffer if the current one can't hold data for all objects to be rendered? Just decide on a set number of maximum instances per frame?

All articles and tutorials I've researched seem to suggest just that: there's a maximum number of objects in a scene.

1 Answers

Let's do the math. For the sake of this discussion, we'll ignore the size of static mesh geometry and other data that isn't updated every frame (things like inverse bind matrices, if you're doing matrix palette skinning). How big is each one of those skinning instance data buffers?

Instance count * joint count * elements per skinning matrix * bytes per matrix element

50,000 * 50 * 12 * 4 = 120 MB

(I'm discounting other things that might vary per frame, so if it helps, replace these numbers with the orders of magnitude of your real data. The rest of the analysis still applies.)

So yes, an appreciable amount of space, but not necessarily infeasible. We might choose to drop back to double buffering instead of triple-buffering everything, cutting the total space required from 360 MB to 240 MB.

We've also assumed that we're doing matrix palette skinning using 3x4 matrices. We could cut the required space in half again by using dual quaternion skinning.

One concern here is that transferring 60 MB of skinning data per frame to the GPU will consume a large amount of bandwidth if we're running on a discrete GPU. We might therefore consider moving our joint calculations to the GPU. On iOS, with a shared memory architecture, this is less of a concern. Either way, we'll want to be savvy about how we compute our transform hierarchy, since we're going to be doing a lot of multiplications if every joint moves every frame.

As for varying instance counts, the one thing we definitely don't want to do is reallocate new buffers every frame. Instead, we can reallocate the buffer, increasing its size geometrically, when we notice that the number of instances exceeds the current maximum capacity of our buffers.

Alternatively, we could take a "paged" approach and split the instances across multiple buffers, each of which can hold some large-ish number of instances, and which get recycled by adding them to a reusable buffer pool once the GPU is done rendering from them. This does require splitting the rendering into multiple draw calls (unless perhaps we're using argument buffers), but the overhead of issuing, say, 10 instanced draw calls, each with 1/10th of the instances, will be negligible compared to the time required to actually render.

We could also consider using MTLHeap to create transient buffers more cheaply than allocating buffers directly from our device, but this still requires selecting a sensible initial upper bound and growing/reallocating as the maximum number of instances increases.

At the end of the day, theory is trumped by benchmarks. Naturally, you should start by analyzing your data, as we're trying to do here, but building a test app and putting these ideas into practice, noticing where problems actually arise, is the only real way to be sure how much optimization you need to do to run smoothly on your target platform.

Related