Shader Instancing on WP7 using the SkinnedEffect trick
17:42
Posted by Jonathan Yong
Ok so, WP7 doesn't allow custom shader code at the moment so you might think that getting something like instancing working would be impossible. Luckily it's pretty easy to append BoneIndices and BoneWeights to an existing vertex buffer for use with a SkinnedEffect shader.
Shader-instancing uses the same shader code as skinning. By setting the number of bones to one and setting the bone weight to 1.0 (100%), you can use the bone as the transformation matrix for each of your instances.
The basic idea is as follows:
- Get the original model's vertex buffer
- Calculate the new vertex stride with an extra Byte4 for the BoneIndices and a Vector4 for the BoneWeights
- Create a new vertex buffer large enough for the maximum number of instances per draw call, using the original model's vertex count and the new stride size
- Populate this new vertex buffer by replicating the vertex data for each instance, setting the correct bone index for each instance and setting the bone weight to 1.0
- Do the same for the IndexBuffer - create an index buffer large enough for all instances and replicate the data for each instance
To draw the instances, pass an array of transformation matrices as the bones of a SkinnedEffect shader while setting the number of bones to one.
You can see the code below. Most of the code has been taken from the instancing sample on creators.xna.com, with modifications for XNA4 and SkinnedEffect. I've also moved the code away from the Content Pipeline into a class that you use by simply passing an existing model and GraphicsDevice. This might not be perfect but it should be pretty easy to adapt it to your needs. Also, the code is extremely simple and naive because it expects your mesh to have a single meshpart with one texture only. This is fine for my simple models but if you need something more complex you'll have to tweak the code a bit.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework;
namespace DataTypes
{
public class InstancedModel
{
const int maxShaderMatrices = 60;
VertexBuffer instancedVertexBuffer;
IndexBuffer instancedIndexBuffer;
VertexDeclaration instancedVertexDeclaration;
int originalVertexCount = 0;
int originalIndexCount = 0;
int vertexStride = 0;
int maxInstances = 0;
Matrix[] tempMatrices = new Matrix[maxShaderMatrices];
Model originalModel;
GraphicsDevice graphicsDevice;
public InstancedModel(GraphicsDevice graphics, Model model)
{
graphicsDevice = graphics;
originalModel = model;
SetupInstancedVertexData();
}
void SetupInstancedVertexData()
{
// Read the existing vertex data, then destroy the existing vertex buffer.
ModelMesh mesh = originalModel.Meshes[0];
ModelMeshPart part = mesh.MeshParts[0];
originalVertexCount = part.VertexBuffer.VertexCount;
VertexDeclaration originalVertexDeclaration = part.VertexBuffer.VertexDeclaration;
int indexOverflowLimit = ushort.MaxValue / originalVertexCount;
maxInstances = Math.Min(indexOverflowLimit, maxShaderMatrices);
byte[] oldVertexData = new byte[originalVertexCount * originalVertexDeclaration.VertexStride];
part.VertexBuffer.GetData(oldVertexData);
// Adjust the vertex stride to include our additional index channel.
int oldVertexStride = part.VertexBuffer.VertexDeclaration.VertexStride;
vertexStride = oldVertexStride + (sizeof(byte) * 4) + (sizeof(float) * 4); //add Byte4 for BoneIndices and Vector4 for BoneWeights
// Allocate a temporary array to hold the replicated vertex data.
byte[] newVertexData = new byte[originalVertexCount * vertexStride * maxInstances];
int outputPosition = 0;
// Replicate one copy of the original vertex buffer for each instance.
for (int instanceIndex = 0; instanceIndex < maxInstances; instanceIndex++)
{
int sourcePosition = 0;
// Convert the instance index from float into an array of raw bits.
byte[] blendIndices = new byte[4];
blendIndices[0] = (byte)instanceIndex;
blendIndices[1] = (byte)instanceIndex;
blendIndices[2] = (byte)instanceIndex;
blendIndices[3] = (byte)instanceIndex;
byte[] blendWeight = BitConverter.GetBytes(1.0f);
for (int i = 0; i < originalVertexCount; i++)
{
// Copy over the existing data for this vertex.
Array.Copy(oldVertexData, sourcePosition,
newVertexData, outputPosition, oldVertexStride);
outputPosition += oldVertexStride;
sourcePosition += oldVertexStride;
// Set the value of our new index channel.
blendIndices.CopyTo(newVertexData, outputPosition);
outputPosition += blendIndices.Length;
//copy blend weights
blendWeight.CopyTo(newVertexData, outputPosition);
outputPosition += blendWeight.Length;
blendWeight.CopyTo(newVertexData, outputPosition);
outputPosition += blendWeight.Length;
blendWeight.CopyTo(newVertexData, outputPosition);
outputPosition += blendWeight.Length;
blendWeight.CopyTo(newVertexData, outputPosition);
outputPosition += blendWeight.Length;
}
}
int instanceIndexOffset = oldVertexStride;
VertexElement[] extraElements =
{
new VertexElement((short)oldVertexStride, VertexElementFormat.Byte4, VertexElementUsage.BlendIndices, 0),
new VertexElement((short)oldVertexStride + (sizeof(byte) * 4), VertexElementFormat.Vector4, VertexElementUsage.BlendWeight, 0)
};
int length = originalVertexDeclaration.GetVertexElements().Length + extraElements.Length;
VertexElement[] elements = new VertexElement[length];
originalVertexDeclaration.GetVertexElements().CopyTo(elements, 0);
extraElements.CopyTo(elements, originalVertexDeclaration.GetVertexElements().Length);
// Create a new vertex declaration.
instancedVertexDeclaration = new VertexDeclaration(elements);
// Create a new vertex buffer, and set the replicated data into it.
instancedVertexBuffer = new VertexBuffer(graphicsDevice, instancedVertexDeclaration, newVertexData.Length, BufferUsage.None);
instancedVertexBuffer.SetData(newVertexData);
//handle vertex indices
originalIndexCount = part.IndexBuffer.IndexCount;
ushort[] oldIndices = new ushort[originalIndexCount];
part.IndexBuffer.GetData(oldIndices);
// Allocate a temporary array to hold the replicated index data.
ushort[] newIndices = new ushort[originalIndexCount * maxInstances];
outputPosition = 0;
// Replicate one copy of the original index buffer for each instance.
for (int instanceIndex = 0; instanceIndex < maxInstances; instanceIndex++)
{
int instanceOffset = instanceIndex * originalVertexCount;
for (int i = 0; i < part.IndexBuffer.IndexCount; i++)
{
newIndices[outputPosition] = (ushort)(oldIndices[i] +
instanceOffset);
outputPosition++;
}
}
// Create a new index buffer, and set the replicated data into it.
instancedIndexBuffer = new IndexBuffer(graphicsDevice, IndexElementSize.SixteenBits, newIndices.Length, BufferUsage.None);
instancedIndexBuffer.SetData(newIndices);
}
public void Draw(Matrix[] transformMatrices, int totalInstances, SkinnedEffect skinnedEffect)
{
BasicEffect effect = (BasicEffect)originalModel.Meshes[0].MeshParts[0].Effect;
skinnedEffect.Texture = effect.Texture;
graphicsDevice.SetVertexBuffer(instancedVertexBuffer);
graphicsDevice.Indices = instancedIndexBuffer;
for (int i = 0; i < totalInstances; i += maxInstances)
{
// How many instances can we fit into this batch?
int instanceCount = totalInstances - i;
if (instanceCount > maxInstances)
instanceCount = maxInstances;
// Upload transform matrices as shader constants.
Array.Copy(transformMatrices, i, tempMatrices, 0, instanceCount);
skinnedEffect.SetBoneTransforms(tempMatrices);
foreach (EffectPass pass in skinnedEffect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, instanceCount * originalVertexCount, 0, instanceCount * originalIndexCount / 3);
}
}
}
}
}
1 November 2010 at 16:26
Do you think you could combine your particle example in your previous post with shader instancing to improve performance?
2 November 2010 at 01:24
Instancing isn't really required for particles on Windows Phone because the CPU is actually faster than the GPU for transform operations. That's why it's faster to perform skinning on the CPU too.
Also, with a large number of simple particles, it's faster to use vertex batching instead of instancing because shader instancing only allows a maximum of 80 or so objects per draw call. Vertex batching allows you to draw many more particles within a single call using a large vertex buffer.
4 January 2011 at 23:07
Hi, i've given this a try but i'm stuck a the Draw method call. What's the skinned effect that i'm supposed to pass as an argument? I though for a second that maybe i should choose SkinnedEffect as my content processor, but then you cast BasicEffect in the first line of the Draw method so i though maybe it's something else entirely that i'm missing.
8 February 2011 at 16:20
@Luis
The code dont casts BasicEffect... it only gets the texture of BasicEffect and sets it on the SkinnedEffect...
So process your model with BasicEffect, so that the code can get the texture of it
Create a SkinnedEffect.. set defaultlightning and pass it through the Draw Method
27 May 2011 at 13:52
Love the article :) , some question..
1) I think SkinnedEffect supports 72 bones thus maxShaderMatrices can be the same?
2) Do you think this could run into a problem where the VertexBuffer is too big? I'm not sure, but probably WP supports maximum of 64MB ?
3 June 2011 at 19:57
(continued)
3. This is a memory intensive way to do hardware instancing and you will quickly bump-up against the max vertex/index buffer size of 64MB. But if you get anywhere near this, you probably shouldn’t be using this technique for instancing. So we need a guard, like this, and could consider guarding the index buffer size, as well…
// Adjust the vertex stride to include our additional index channel.
int oldVertexStride = part.VertexBuffer.VertexDeclaration.VertexStride;
int newVertexStride = oldVertexStride + (sizeof(byte) * 4) + (sizeof(float) * 4); //add Byte4 for BoneIndices and Vector4 for BoneWeights
int newVertexCount = originalVertexCount * SkinnedEffect.MaxBones;
int newVertexBufferSize = newVertexCount * newVertexStride;
// Vertex and Index buffers are cap'ed at 64MB
if (newVertexBufferSize > 0x3FFFFFF)
throw new InvalidOperationException(String.Format("Attempted to create a vertex buffer of size {0} bytes. Maximum vertex or index buffer size is 67108863 bytes (64MB)", newVertexBufferSize / 0x1000000));
4. The 3rd parameter is the vertex count, not the new buffer size. This is the primary cause of your perf problem. This…
// Create a new vertex buffer, and set the replicated data into it.
VertexBuffer instancedVertexBuffer = new VertexBuffer(this.Game1.GraphicsDevice, instancedVertexDeclaration, newVertexData.Length, BufferUsage.None);
should be this…
// Create a new vertex buffer, and set the replicated data into it.
VertexBuffer instancedVertexBuffer = new VertexBuffer(this.Game1.GraphicsDevice, instancedVertexDeclaration, newVertexCount, BufferUsage.None);
5. [TODO] There is a problem with last triangle bleed-over across instances. Could be off-by-one issue or my model – either way, the cause needs to be handled. Investigating.
2 June 2012 at 21:36
Hmm. I used the code As is and plunged into my roller coaster project.
I have a pretty simple model
With 200 Tracks before I would get 25 fps
And with this it drops to 17 fps
It also seemed to lock up after attempting to render 370 tracks. (in chucks of 60)
Perhaps i am not doing it correct so ill mess around a bit more.
But Pretty Cool Never the less.