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.
Answer by Bunny83 · Jun 23, 2019 at 02:50 AM
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.
Thanks. Luckily we already have the heightmap on our server, we just didn't know how to sample a y-coordinate from a given x- and z-coordinate the way Unity would. With some slight modifications to your proposed solution we've been able to sample y-coordinates very accurately, almost as TerrainData.SampleHeight would've.
Here's our implementation (unoptimized):
public static float SampleHeight(float x, float z)
{
float[,] heights = terrainData.GetHeights(0, 0, 1024, 1024);
// 1. Get the given worldspace coordinate in localspace of the terrain object by using Transform.InverseTransformPoint.
Vector3 localPos = terrain.gameObject.transform.InverseTransformPoint(x, 0, z);
// 2. 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.
float xPos = localPos.x / terrainData.size.x * terrainData.heightmapResolution;
float zPos = localPos.z / terrainData.size.z * terrainData.heightmapResolution;
// 3. Split the coordinates into the integer part and the fractional part.
int xIntegral = $$anonymous$$athf.FloorToInt(xPos);
int zIntegral = $$anonymous$$athf.FloorToInt(zPos);
float xDecimal = xPos - xIntegral;
float zDecimal = xPos - zIntegral;
// 4. Use the floored integer parts as well as the ceiled integer parts to read the 4 adjacent heightmap values.
int xiFloored = $$anonymous$$athf.FloorToInt(xPos);
int xiCeiled = $$anonymous$$athf.CeilToInt(xPos);
int ziFloored = $$anonymous$$athf.FloorToInt(zPos);
int ziCeiled = $$anonymous$$athf.CeilToInt(zPos);
float p0 = heights[ziFloored, xiFloored];
float p1 = heights[ziFloored, xiCeiled];
float p2 = heights[ziCeiled, xiFloored];
float p3 = heights[ziCeiled, xiCeiled];
// 5. Use the fractional parts (0-1) to do a bilinear interpolation using 3 lerps.
float v0 = $$anonymous$$athf.Lerp(p0, p1, xDecimal);
float v1 = $$anonymous$$athf.Lerp(p2, p3, xDecimal);
float v2 = $$anonymous$$athf.Lerp(v0, v1, zDecimal);
// 6. $$anonymous$$ultiply the result by terrainData.heightmapScale.y to get the actual local space height at the given point.
return v2 * terrainData.heightmapScale.y;
}
Answer by josh926 · Nov 24, 2020 at 07:30 AM
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);
}
You are amazing!!! This works perfectly. This is the correct answer.
Answer by joshrs926 · Jul 12, 2021 at 03:00 AM
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[i],
(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<float> GetHeightMap(Terrain terrain, Allocator alloc)
{
int resolution = terrain.terrainData.heightmapResolution;
var heightList = new UnsafeList<float>(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[i] = map[y, x];
}
}
return heightList;
}
}
public readonly struct Triangle
{
public float3 V0 { get; }
public float3 V1 { get; }
public float3 V2 { get; }
/// <summary>
/// This is already normalized.
/// </summary>
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;
}
}
Answer by luvjungle · Aug 23, 2021 at 07:07 PM
@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.
No problem! Yeah it looks like Bounds takes in a Size rather than extents while AABB asks for an Extents rather than Size. Extents is exactly half the Size. Btw you should be able to just pass a Terrain instance to the TerrainCopy constructor and it should set everything up for you without having to worry about Bounds or AABBs if you want to.