How is Terrain.SampleHeight implemented?

Hi, for our game we’d like to have an authoritative server (not written in C#) for handling our player movement. To accomplish this goal we want to load a heightmap server-side so we can use the x- and z-coordinates (which are calculated on the server) to sample a y-coordinate using the heightmap. We don’t want to do this client-side as this means we’re trusting the client to determine it’s own y-coordinate which we should never do.

I was hoping to find in Unity’s source code the implementation of the Terrain.SampleHeight function but I am unable to find it. Does someone know how this function works so we can implement it on our server? I would’ve just referenced the UnityEngine.dll if our server was written in C# but unfortunately it’s not.

Here is a complete solution to copy a Terrain’s heightmap in order to SampleHeight inside burstable Jobs for projects that use DOTS.

    using Unity.Collections;
    using Unity.Collections.LowLevel.Unsafe;
    using Unity.Mathematics;
    using UnityEngine;
    
    public struct TerrainCopy
    {
        UnsafeList<float> heightMap;
        int resolution;
        float2 sampleSize;
        public AABB AABB { get; private set; }
        public bool IsValid => heightMap.IsCreated;
        int QuadCount => resolution - 1;
    
    
        public TerrainCopy(Terrain terrain, Allocator alloc)
        {
            resolution = terrain.terrainData.heightmapResolution;
            sampleSize = new float2(terrain.terrainData.heightmapScale.x, terrain.terrainData.heightmapScale.z);
            AABB = GetTerrrainAABB(terrain);
            heightMap = GetHeightMap(terrain, alloc);
        }
    
        /// <summary>
        /// Returns world height of terrain at x and z position values.
        /// </summary>
        public float SampleHeight(float3 worldPosition)
        {
            GetTriAtPosition(worldPosition, out Triangle tri);
            return tri.SampleHeight(worldPosition);
        }
    
        /// <summary>
        /// Returns world height of terrain at x and z position values. Also outputs normalized normal vector of terrain at position.
        /// </summary>
        public float SampleHeight(float3 worldPosition, out float3 normal)
        {
            GetTriAtPosition(worldPosition, out Triangle tri);
            normal = tri.Normal;
            return tri.SampleHeight(worldPosition);
        }
    
        void GetTriAtPosition(float3 worldPosition, out Triangle tri)
        {
            if (!IsWithinBounds(worldPosition))
            {
                throw new System.ArgumentException("Position given is outside of terrain x or z bounds.");
            }
            float2 localPos = new float2(
                worldPosition.x - AABB.Min.x,
                worldPosition.z - AABB.Min.z);
            float2 samplePos = localPos / sampleSize;
            int2 sampleFloor = (int2)math.floor(samplePos);
            float2 sampleDecimal = samplePos - sampleFloor;
            bool upperLeftTri = sampleDecimal.y > sampleDecimal.x;
            int2 v1Offset = upperLeftTri ? new int2(0, 1) : new int2(1, 1);
            int2 v2Offset = upperLeftTri ? new int2(1, 1) : new int2(1, 0); 
            float3 v0 = GetWorldVertex(sampleFloor);
            float3 v1 = GetWorldVertex(sampleFloor + v1Offset);
            float3 v2 = GetWorldVertex(sampleFloor + v2Offset);
            tri = new Triangle(v0, v1, v2);
        }
    
        public void Dispose()
        {
            heightMap.Dispose();
        }
    
        bool IsWithinBounds(float3 worldPos)
        {
            return
                worldPos.x >= AABB.Min.x &&
                worldPos.z >= AABB.Min.z &&
                worldPos.x <= AABB.Max.x &&
                worldPos.z <= AABB.Max.z;
        }
    
        float3 GetWorldVertex(int2 heightMapCrds)
        {
            int i = heightMapCrds.x + heightMapCrds.y * resolution;
            float3 vertexPercentages = new float3(
                (float)heightMapCrds.x / QuadCount,
                heightMap*,*

(float)heightMapCrds.y / QuadCount);
return AABB.Min + AABB.Size * vertexPercentages;
}

static AABB GetTerrrainAABB(Terrain terrain)
{
float3 min = terrain.transform.position;
float3 max = min + (float3)terrain.terrainData.size;
float3 extents = (max - min) / 2;
return new AABB() { Center = min + extents, Extents = extents };
}

static UnsafeList GetHeightMap(Terrain terrain, Allocator alloc)
{
int resolution = terrain.terrainData.heightmapResolution;
var heightList = new UnsafeList(resolution * resolution, alloc);
var map = terrain.terrainData.GetHeights(0, 0, resolution, resolution);
for (int y = 0; y < resolution; y++)
{
for (int x = 0; x < resolution; x++)
{
int i = y * resolution + x;
heightList = map[y, x];
}
}
return heightList;
}
}

public readonly struct Triangle
{
public float3 V0 { get; }
public float3 V1 { get; }
public float3 V2 { get; }
///


/// This is already normalized.
///

public float3 Normal { get; }

public Triangle(float3 v0, float3 v1, float3 v2)
{
V0 = v0;
V1 = v1;
V2 = v2;
Normal = math.normalize(math.cross(V1 - V0, V2 - V0));
}

public float SampleHeight(float3 position)
{
// plane formula: a(x - x0) + b(y - y0) + c(z - z0) = 0
// <a,b,c> is a normal vector for the plane
// (x,y,z) and (x0,y0,z0) are any points on the plane
return (-Normal.x * (position.x - V0.x) - Normal.z * (position.z - V0.z)) / Normal.y + V0.y;
}
}

Well, the Terrain system in Unity is a native code component. Most built-in components are actually implemented in native code and not in managed code. The only exceptions are the more recent additions like the UI system or UNet.

So we don’t know how exactly Unity does implement SampleHeight. However it should be something like that:

  • get the given worldspace coordinate in local space of the terrain object by using Transform.InverseTransformPoint
  • use the local x and z coordinates and multiply them by terrainData.heightmapScale.x and .z accordingly to get values between 0 and the terrainResolution.
  • Split the coordinates into the integer part and the fractional part.
  • Use the floored integer parts as well as the ceiled integer parts to read the 4 adjacent heightmap values
  • Use the fractional parts (0-1) to do a bilinear interpolation using 3 lerps. So use the fractional x value to lerp between (x, y) and (x+1,y) to get v0 and lerp between (x,y+1) to (x+1,y+1) to get v1. Now use the fractional y value to lerp between v0 and v1 to get the final value in between.
  • Now multiply the result by “terrainData.heightmapScale.y” to get the actual local space height at the given point. So to determine the local space position you use the x and z of the initial point and the just obtained height as y.
  • If you need the position in worldspace you would finally use transform.TransformPoint() on the just created localspace position

Note that the 4 heightmap values I talked about are considered in the range of 0 to 1. Since you want to read that data outside of Unity you have to find a way how to store the heightmap for your server to read. The raw format is probably the easiest one. You just have to know what bit count per heightmap sample is used and make sure you convert the values to the range 0 to 1.

I was able to get a perfect (as far as I could tell by looking at object spawned on the terrain) implementation of sample height in a job by finding the terrain’s mesh triangle at the given position and then finding the y value for the corresponding x, z values on that tri’s plane. “heightMap” is an array of floats of values between 0 - 1. You get that by calling myTerrain.terrainData.GetHeights(0, 0, resolution, resolution). Note that GetHeights returns a 2d array indexed as [y,x] not [x,y]. Also note that “terrainAABB” is the axis aligned bounding box of the terrain and the Size.y is the terrain’s max possible height, not whatever the tallest mountain happens to be. Also not that Unity’s terrains appear to be made of a grid of squares each made of 2 tris where the seam between those 2 tris goes from back left to front right. So you have to find which of the 2 tris your position is on. Also you can get heightMapResolution by calling myTerrain.terrainData.heightMapResolution.

 public void SampleHeight(ref float3 worldPos)
        {
            float3 localPos = worldPos - terrainAABB.Min;
            float2 sampleValue = new float2(
                localPos.x / terrainAABB.Size.x,
                localPos.z / terrainAABB.Size.z);
            float2 samplePos = new float2(
                 sampleValue.x * (heightMapResolution - 1),
                 sampleValue.y * (heightMapResolution- 1));
            int2 sampleFloor = new int2(
                (int)samplePos.x,
                (int)samplePos.y);
            float2 sampleDecimal = new float2(
                samplePos.x - sampleFloor.x,
                samplePos.y - sampleFloor.y);
            int upperLeftTri = sampleDecimal.y > sampleDecimal.x ? 1 : 0;

            float3 v0 = GetVertexLocalPos(sampleFloor.x, sampleFloor.y);
            float3 v1 = GetVertexLocalPos(sampleFloor.x + 1, sampleFloor.y + 1);
            int upperLeftOrLowerRightX = sampleFloor.x + 1 - upperLeftTri;
            int upperLeftOrLowerRightY = sampleFloor.y + upperLeftTri;
            float3 v2 = GetVertexLocalPos(upperLeftOrLowerRightX, upperLeftOrLowerRightY);
            float3 n = math.cross(v1 - v0, v2 - v0);            
            // based on plane formula: a(x - x0) + b(y - y0) + c(z - z0) = 0
            float localY = ((-n.x * (localPos.x - v0.x) - n.z * (localPos.z - v0.z)) / n.y) + v0.y;
            worldPos.y = localY + terrainAABB.Min.y;
        }

        float3 GetVertexLocalPos(int x, int y)
        {            
            int index = x + y * heightMapResolution;
            float heightValue = heightMap[index];
            return new float3(
                (float)x / (heightMapResolution - 1) * terrainAABB.Size.x,
                heightValue * terrainAABB.Size.y,
                (float)y / (heightMapResolution - 1) * terrainAABB.Size.z);
        }

@joshrs926 I guess you deleted your question after I deleted mine =) I solved it:
if someone is using Unity’s Bounds struct for AABB, then you should multiply Extents by 2 in Bounds Constructor in GetTerrrainAABB(Terrain terrain) method.
Thanks a lot for this code! BTW I replaced UnsafeList with [ReadOnly] NativeArray and it works too.