Unity Terrain is a good Terrain renderer, but the API’s behind it are famously badly documented and rather clunky (most of the documentation still hasn’t been written, almost 10 years after it was launched). At Unite this year they were showing-off some of the “new Terrain” features/tools, all of which were aimed at artists, and look great.
But what about the Terrain itself? Unity 2019.2 and 2019.3 still have the ponderous old API and it seems we’re stuck with it for at least another few years. Today I found and fixed an issue in my custom Terrain-tools that took our Editor rendering from < 4 FPS back to normal realtime speeds.
The secret is to use Unity’s required float[,,] arrays (which Microsoft only partially supports in C#) but plug the gap in C# which causes them to be slow when interacting with Unity’s Serializer (which fires 6 times per frame in the Editor, magnifiying any slowdown considerably!)
NB: As far as I can tell, you cannot fix the Unity “serialize 6 times even if it’s not needed, where only 1 would have been fine” issue, because the methods to do that only exist on Custom EditorWindows, and not on Custom Inspectors. But it’s bad practice to be slowing-down the serializer anyway, so I’m happy with fixing MY code to run fast, and then stop worrying about the Unity Serialization layers being inefficient.
The problem: float[,,] isn’t supported by Unity
Unity requires you to use float[,,] for textures/splats/alphamaps on their terrain.
However, Unity has never supported multi-dimensional arrays in their engine (this is finally getting fixed sometime in 2020, I believe, with the new Serializer). So your data gets wiped every frame. Thats a pain when making Terrain-editing scripts.
The workaround is to implement Unity’s ISerializationCallbackReceiver interface, and provide the missing code that Unity doesn’t (i.e. serialize a float[,,]). The standard way of doing this is something like:
NB: I’m only showing half of the serialize/deserialize here, just to illustrate the point
[code language=”csharp”]
void ISerializationCallbackReceiver.OnAfterDeserialize()
{
deltas = new float[_Serialize_2D_Length0, _Serialize_2D_Length1, _Serialize_2D_Length2];
/** NB: iterate in C#’s internal storage order for [,,] */
for (int i0 = 0; i0 < _Serialize_2D_Length0; i0++)
for (int i1 = 0; i1 < _Serialize_2D_Length1; i1++)
for( int i2 = 0; i2 < _Serialize_2D_Length2; i2++ )
deltas[i0,i1,i2] = _Serialize_1DArray[i0 * _Serialize_2D_Length1 * _Serialize_2D_Length2
+ i1 * _Serialize_2D_Length2
+ i2];
}
}
[/code]
…which retrieves every cell in the float[,,] from a cell in a private float[] (which Unity DOES support and will auto-serialize for you).
The problem is that C# for-loops are extremely slow when used like this, simply because of the scale of the operation. For a typical Unity terrain, you’re copying up to 4096 x 4096 samples (your splatmap) with anywhere from 5 to 10 values for each. Each value is a 4-byte 32-bit float.
i.e. 4k x 4k x 10 x 4 == 640 MB of data
…which destroys your 100+ FPS frame-time, taking it to 1 FPS or worse.
You need to copy this data in a single call, not in 640,000,000 separate method calls.
But … how?
Array.Copy() to the rescue!
It doesn’t work. You can compile your C# class, and then the C# runtime will cry when you try to execute it:
RankException: Only single dimension arrays are supported here.
Bummer. In theory, Array.Copy() would have solved the problem – this is literally what it was designed for: bulk copying of large arrays without the overhead of doing millions of tiny copy-calls.
Try again … Buffer.BlockCopy()
Fortunately there’s another method in C# core that steps-in and saves us. I often find that when C# ties your hands behind your back, the reason it hasn’t been changed/updated/improved is that there’s a lesser-known behind-the-scenes low-level method that you can (ab)use to achieve what you need, and the language maintainers recommend you do that instead of them updating the mainstream stuff. Fair enough!
The one caveat with BlockCopy is that you need to tell it the size in bytes that you’re copying NOT the number of array items.
i.e.: [code language=”csharp”]Array.Copy( from, 0, to, 0, length )[/code]
becomes: [code language=”csharp”]Buffer.BlockCopy( from, 0, to, 0, 4 * length ) // if copying float, or int, or any of the other 32bit primitives[/code]
20x faster Terrain data handling
The modified serialization callback becomes:
[code language=”csharp”]
void ISerializationCallbackReceiver.OnAfterDeserialize()
{
deltas = new float[_Serialize_2D_Length0, _Serialize_2D_Length1, _Serialize_2D_Length2];
int bytesPerFloat = 4;
Buffer.BlockCopy( _Serialize_2D, 0, deltas, 0, bytesPerFloat * deltas.GetLength( 0 ) * deltas.GetLength( 1 )*deltas.GetLength( 2 ) );
}
}
[/code]