How to modify a 3D mesh in Unity to follow terrain while preserving thickness?

Hello,

I tried to create a script that modifies the mesh of a 3D object during runtime so that it follows the shape of the terrain underneath it.

Currently, I’ve achieved this result (before/after):


The model correctly follows the curve of the ground, but the thickness is not preserved at all. The model becomes very thin, and all details are lost.


I’ve created a diagram showing what’s happening vs. what should happen:

Here’s the current code:

public LayerMask groundLayer;
public float maxRaycastDistance = 100f;
public float deformationStrength = 1.0f;
private MeshFilter meshFilter;
private Mesh originalMeshe;
private Mesh workingMeshe;
private Vector3[] originalVertice;
private Vector3[] deformedVertice;
public float height = 3f;

private void InitializeMeshe()
{
    meshFilter = GetComponent<MeshFilter>();
    var meshRenderers = GetComponent<MeshRenderer>();

    height = meshRenderers.bounds.size.y;

    originalMeshe = meshFilter.sharedMesh;
    workingMeshe = Instantiate(originalMeshe);
    originalVertice = workingMeshe.vertices;
    deformedVertice = new Vector3[originalVertice.Length];

    meshFilter.mesh = workingMeshe;
}

public void DeformMeshe()
{
    Vector3[] deformedVerts = new Vector3[originalVertice.Length];

    for (int j = 0; j < originalVertice.Length; j++)
    {
        Vector3 worldPos = meshFilter.transform.TransformPoint(originalVertice[j]);
        Vector3 closestPoint = worldPos;
        Vector3? groundHeight = GetGroundHeight(worldPos);

        if (groundHeight == null)
        {
            deformedVerts[j] = originalVertice[j];
            continue;
        }

        float closestDistance = Vector3.Distance(worldPos, groundHeight.Value);
        if (closestDistance < maxRaycastDistance) closestPoint = groundHeight.Value;

        Vector3 targetPosition = Vector3.Lerp(worldPos, closestPoint, deformationStrength);
        targetPosition.y += height;

        deformedVerts[j] = meshFilter.transform.InverseTransformPoint(targetPosition);
    }

    workingMeshe.vertices = deformedVerts;
    workingMeshe.RecalculateNormals();
    workingMeshe.RecalculateBounds();
}

public Vector3? GetGroundHeight(Vector3 worldPos)
{
    if (Physics.Raycast(worldPos, Vector3.down, out RaycastHit hitDown, maxRaycastDistance, groundLayer))
        return hitDown.point;

    if (Physics.Raycast(worldPos, Vector3.up, out RaycastHit hitUp, maxRaycastDistance, groundLayer))
        return hitUp.point;

    return null;
}
  1. The initial technique involves casting raycasts toward the ground to determine its height and adjusting the Y position of the vertices accordingly.
  2. I tried using the Normals to maintain a consistent distance between the points.
Vector3 normalWorld = meshFilter.transform.TransformDirection(vertexNormals[j]);
float originalDistance = Vector3.Dot(originalVertice[j], vertexNormals[j]);
Vector3 adjustedPosition = closestPoint + normalWorld * originalDistance;

Vector3 targetPosition = Vector3.Lerp(worldPos, adjustedPosition, deformationStrength);

  1. Still using the Normals, I attempted to move the points along a plane. The result is identical to attempt 2.
Vector3 displacement = closestPoint - worldPos;
Vector3 displacementOnPlane = displacement - Vector3.Dot(displacement, worldNormal) * worldNormal;
Vector3 targetPosition = worldPos + displacementOnPlane * deformationStrength;
targetPosition += worldNormal * height;

How can I fix my code to achieve my goal?

Oof that’s not an easy problem. Try to reconstruct this concept with a basic diagram.
Imagine if you had a circle and a thin rectangle as long as the diameter of the circle.

How exactly should that rectangle wrap around to lie along the circumference of the circle is not an easy question.

From this abstract vantage point, you’d like the rectangle to preserve its thickness. But if you think about it, due to this setup it becomes painfully obvious that you cannot preserve the area of the rectangle.

Look, the radius of the connecting edge (in contact with the circle) is smaller than the radius of the opposite edge. On the other hand, if you just offset the upper edge, than you no longer match the circle’s “surface” radius, so it becomes apparent that you need to employ a trick to make this work, you need to cut corners somehow, i.e. you need to make a conscious choice how to approach this.

From what I can see in your code box, you’ve opted for this offsetting trick, aka “displacement”, and this will uniformly displace the vertices according to the surface profile. But you do not account for the relative vertex distances in the object space. You treat every vertex the same, even though some were near the surface, and others were far.

So one way to approach this would be to introduce strength of this displacement, and it’s proportional to the distance from the surface. However, if you do this, you will get an object that is less affected at the top, and more affected at the bottom, instead of its upper surface tracking the terrain perfectly. So you have to make a conscious choice, what would constitute the perfect result?

If, for example, you want to maintain the underlying terrain, and you don’t care about volume preservation, then you’d want to do these 1) preserve vertex distance from the surface, 2) preserve internal object-space distances.

The easiest way to achieve this would be to understand the object in its original flat, planar space, and consider each vertex as sitting on top of a column at some height from this imaginary plane. Then you simply transform these columns according to the actual surface normals of the broken terrain, and recreate the vertices at the same heights.

I.e. You have some vertex at (x, y, z). Lets say imaginary XZ plane is at 0. You now cast a downward ray from this vertex to the actual surface and find out that it hits at point D, and that its normal is not (0, 1, 0) but something like (o, p, q) where p is close to 1, but not quite. Originally, with a perfect flatness, y of this vertex denoted vertex height. However with the different normal, you can find the new vertex position if you take point D and add the new normal vector scaled by this original height.

public Vector3 GetVertexPosition(Vector3 surfaceProjection, Vector3 surfaceNormal, float originalHeight)
  => surfaceProjection + originalHeight * surfaceNormal;

(pseudocode)

for(int i = 0; i < vertices.Length; i++) {
  if(Physics.Raycast(vertices[i], Vector3.down, out var hit))
    vertices[i] = GetVertexPosition(hit.point, hit.normal, vertices[i].y);
}

(^ the above code assumes that all vertices lie above the imaginary plane.)

However, there is a problem with this approach if you want to have series of connected objects, as you need to find a way to actually merge normals when you encounter a surface mesh with non-smooth normals, or else your objects will look bad and overlap at the seams.

Edit:
Obviously you’re doing something similar, so try to see what is different in your solution.

1 Like

If you want to robustly find all normals for your vertices that lie on the fringes of the object, to be able to merge them, you can use Physics.SphereCastAll and average all normals that you find in this manner. This way an object will share the same averaged normals with its neighbors, in turn allowing objects’ modified vertices to align perfectly with each other.

(Although averaging doesn’t work out of box, you’d also want to weigh normals according to triangle angle and area, so it’s not exactly trivial.)

Also there is a (potential) problem of vertex density in your object model. If your terrain is particularly rough, or too broken/angled, you’d want to dynamically introduce additional vertices, to be able to refine the transformation and follow the surface more closely.

This is easier said than done, and for the long same-ish objects like pipelines or roads and such, it is advisable to instead dynamically build meshes from extrudable profiles (like spline extrusion works in Blender). But this is beyond what I’m able to explain succinctly. You should be able to find some good tutorials for this on Youtube.

1 Like

Thank you for all the details (indeed, many questions arise if we need to fully explore the concepts).

For a simple version, assuming the terrain won’t be too deformed, the following solution is possible:

var p = worldPos;
p.y = REF_Y;
float closestDistance = Vector3.Distance(p, groundHeight.Value);

var targetPosition = worldPos;
targetPosition.y -= closestDistance * deformationStrength;
targetPosition.y += height;

With REF_Y = transform.position.y + height;.