Combining Meshes

Godot Version

4.2.1

Question

I am trying to combine several meshes into a single mesh. The code I have runs without error but doesn’t produce any visible result.

get_mesh_res(name) returns a resource that has an exported array named surface_array. This array contains each surface array returned from Mesh.surface_get_arrays().

The Skeleton3D is parented to a Node3D in the scene. I can see that the new mesh is being created and parented to the skeleton and that the new MeshInstance3D has the data from all of the combined meshes. Am I going about this the right way? Did I miss something? Is there a better way?

func deserialize_mesh_list(mesh_name_list: PackedStringArray, skeleton: Skeleton3D) -> Skeleton3D:
	var surface_tool = SurfaceTool.new()
	surface_tool.begin(Mesh.PRIMITIVE_TRIANGLES)
	for name in mesh_name_list:
		var tangent_count: int = 0
		var bone_count: int = 0
		var weight_count: int = 0
		var surface_array: Array = get_mesh_res(name).surface_array
		for surface in surface_array.size():
			for a in surface_array[surface][Mesh.ARRAY_VERTEX].size():
				if surface_array[surface][Mesh.ARRAY_VERTEX].size() == surface_array[surface][Mesh.ARRAY_NORMAL].size():
					surface_tool.set_normal(surface_array[surface][Mesh.ARRAY_NORMAL][a])
				if surface_array[surface][Mesh.ARRAY_VERTEX].size() * 4 == surface_array[surface][Mesh.ARRAY_TANGENT].size():
					var packed_array: PackedFloat32Array = PackedFloat32Array()
					for b in 4:
						packed_array.append(surface_array[surface][Mesh.ARRAY_TANGENT][tangent_count])
						tangent_count += 1
					surface_tool.set_tangent(Plane(packed_array[0],packed_array[1],packed_array[2],packed_array[3]))
				if surface_array[surface][Mesh.ARRAY_COLOR] != null and surface_array[surface][Mesh.ARRAY_VERTEX].size() == surface_array[surface][Mesh.ARRAY_COLOR].size():
					surface_tool.set_color(surface_array[surface][Mesh.ARRAY_COLOR][a])
				if surface_array[surface][Mesh.ARRAY_TEX_UV] != null and surface_array[surface][Mesh.ARRAY_VERTEX].size() == surface_array[surface][Mesh.ARRAY_TEX_UV].size():
					surface_tool.set_uv(surface_array[surface][Mesh.ARRAY_TEX_UV][a])
				if surface_array[surface][Mesh.ARRAY_TEX_UV2] != null and surface_array[surface][Mesh.ARRAY_VERTEX].size() == surface_array[surface][Mesh.ARRAY_TEX_UV2].size():
					surface_tool.set_uv2(surface_array[surface][Mesh.ARRAY_TEX_UV2][a])
				if surface_array[surface][Mesh.ARRAY_BONES] != null and surface_array[surface][Mesh.ARRAY_VERTEX].size() * 4 == surface_array[surface][Mesh.ARRAY_BONES].size():
					var packed_array: PackedInt32Array = PackedInt32Array()
					for c in 4:
						packed_array.append(surface_array[surface][Mesh.ARRAY_BONES][bone_count])
						bone_count += 1
					surface_tool.set_bones(packed_array)
				if surface_array[surface][Mesh.ARRAY_WEIGHTS] != null and surface_array[surface][Mesh.ARRAY_VERTEX].size() * 4 == surface_array[surface][Mesh.ARRAY_WEIGHTS].size():
					var packed_array: PackedFloat32Array = PackedFloat32Array()
					for d in 4:
						packed_array.append(surface_array[surface][Mesh.ARRAY_WEIGHTS][weight_count])
						weight_count += 1
					surface_tool.set_weights(packed_array)
				surface_tool.add_vertex(surface_array[surface][Mesh.ARRAY_VERTEX][a])
	surface_tool.index()
	var array_mesh: ArrayMesh = surface_tool.commit()
	var mesh: MeshInstance3D = MeshInstance3D.new()
	mesh.mesh = array_mesh
	skeleton.add_child(mesh)
	mesh.skeleton = NodePath("..")
	return skeleton

Not sure if this is still an issue for you, but I had a very similar question some months ago.
I was using a “modular” character for building my own avatar…but I wanted to combine the selected meshes into 1 to reduce draw calls once the character was created from the picked parts.

Here is what I came up that worked for me…it’s in C# rather than GDScript but I’m hoping there is something here that will help.
I also ran into another issue I vaguely remember where when reading the surface, the format of one did not match the others. The only reason I knew this was because the mesh would not render.
I can’t recall the details exactly, but I believe it had to with UV2. Once I saw that a specific mesh’s values from SurfaceGetArrays() did not look like any of the others, I opened it in blender and removed the UV2 I think it was.

// Private vars
private static ArrayMesh _arrayMesh;
private static Vector4 _scaleAndOffsetUV;
private static Material _material;
    
public static MeshInstance3D CombineVisibleChildrenMeshInstances(Node3D container, Node3D[] nodes, bool willIgnorePosition = false, bool willIgnoreRotation = false, bool willIgnoreScale = false, bool willUseDefaultUVs = false) {
  // Getting vars prepped
  _arrayMesh = new ArrayMesh();      
  _material = null;

  // Collect all the visible children
  for (int index = 0; index < nodes.Length; index++) {        
    GetVisibleChildrenMeshInstances(nodes[index], willIgnorePosition, willIgnoreRotation, willIgnoreScale);
  }

  // Create a mesh instance to hold the collected meshes
  MeshInstance3D combinedMesh = new MeshInstance3D() {
    Name = "combined_" + GD.Randi(),
    Mesh = _arrayMesh,
  };
  
  // Set the material for this mesh instance
  for (int index = 0; index < combinedMesh.Mesh.GetSurfaceCount(); index++) {
    if (willUseDefaultUVs) {
      // Specify that the instance uniforms for UV definitions in the shader should not be used as they were adjusted directly on the mesh
      combinedMesh.SetInstanceShaderParameter(RenderConst.WILL_USE_SCALE_AND_OFFSET_UV_SHADER_INSTANCE_PARAMETER_NAME, false);          
    }
    else {
      // Asked that the UVs were ignored (not calculated on the mesh) so use the shader instance uniforms instead
      combinedMesh.SetInstanceShaderParameter(RenderConst.WILL_USE_SCALE_AND_OFFSET_UV_SHADER_INSTANCE_PARAMETER_NAME, true);
      combinedMesh.SetInstanceShaderParameter(RenderConst.SCALE_AND_OFFSET_UV_SHADER_INSTANCE_PARAMETER_NAME, _scaleAndOffsetUV);
    }
    // Set the material
    combinedMesh.Mesh.SurfaceSetMaterial(index, _material);
  }

  // Finally add the mesh instance to the container as a child
  container.AddChild(combinedMesh);
  combinedMesh.Owner = container;      
  GD.Print("All visible mesh instances were combined into a mesh named: " + combinedMesh.Name);

  return combinedMesh;
}

private static void GetVisibleChildrenMeshInstances(Node3D node, bool willIgnorePosition = false, bool willIgnoreRotation = false, bool willIgnoreScale = false, bool willIgnoreUVs = false) {
  // Enusure we have a node and it is not null
  if (node == null) {
    return;
  }
  // Pull out the child count for easier readability
  int childCount = node.GetChildCount();
  // Enumerate all children, only collecting the ones that are visible
  for (int index = 0; index < childCount; index++) {

    // Recursively evaluate all children
    if (node.GetChild(index).GetChildCount() > 0) {
      GetVisibleChildrenMeshInstances((Node3D)node.GetChild(index), willIgnorePosition, willIgnoreRotation, willIgnoreScale, willIgnoreUVs);
    }

    // Only evaulate MeshInstance3D types
    if (node.GetChild(index) is not MeshInstance3D) {
      continue;
    }

    // Create more usable/readable variable
    MeshInstance3D child = (MeshInstance3D)node.GetChild(index);
    if (child.Visible && child.IsVisibleInTree()) {
      GD.Print("'" + child.Name + "' will be combined");

      float volume = child.Mesh.GetAabb().Volume; ///// REMOVE THIS LINE, ONLY FOR TESTING

      // This mesh is visible, get its surface. Assumption it has only 1
      Array surface = child.Mesh.SurfaceGetArrays(0);

      // When all of the flags are false, no need to process this section
      if (willIgnorePosition == false 
        && willIgnoreRotation == false 
        && willIgnoreScale == false 
        && willIgnoreUVs == false) {
        // Capture the UV settings from instance parameter in shader
        Vector4 scaleAndOffsetUV = (Vector4)child.GetInstanceShaderParameter(RenderConst.SCALE_AND_OFFSET_UV_SHADER_INSTANCE_PARAMETER_NAME);

        // Capture transform information
        Node3D logisticsInfo = child;
        Vector3 position = Vector3.Zero;
        Quaternion quaternion = Quaternion.Identity;
        Vector3 scale = Vector3.One;
        do {
          position += logisticsInfo.Position;
          quaternion *= logisticsInfo.Quaternion;
          scale *= logisticsInfo.Scale;
          if (logisticsInfo.GetParent() is not Node3D verifyParent) {
            break;
          }
          logisticsInfo = verifyParent;
        } while (logisticsInfo != null);
       
        // Capture vertices to operate on
        Vector3[] vertices = surface[(int)Mesh.ArrayType.Vertex].AsVector3Array();
        Vector3[] normals = surface[(int)Mesh.ArrayType.Normal].AsVector3Array();
        Vector2[] texUVs = surface[(int)Mesh.ArrayType.TexUV].AsVector2Array();

        // Adjust transform of all the vertices according to where the parent is located          
        int vertexCount = vertices.Length;
        for (int vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) {
          // Apply scale
          if (willIgnoreScale == false && scale.IsEqualApprox(Vector3.One) == false) {
            vertices[vertexIndex] *= scale;
          }

          // Apply rotation
          if (willIgnoreRotation == false && quaternion.IsEqualApprox(Quaternion.Identity) == false) {
            // Adjust for any offsets the child may have so rotation will work as expected, child expected to be located at 0, 0, 0
            Vector3 offset = child.Position;
            // Rotate position of vertex
            vertices[vertexIndex] = (vertices[vertexIndex] + offset).Rotated(quaternion.GetAxis().IsNormalized() ? quaternion.GetAxis() : quaternion.GetAxis().Normalized(), quaternion.GetAngle());
            // Rotate normal so lighting is correct
            normals[vertexIndex] = normals[vertexIndex].Rotated(quaternion.GetAxis().IsNormalized() ? quaternion.GetAxis() : quaternion.GetAxis().Normalized(), quaternion.GetAngle());               
          }

          // Apply translation
          if (willIgnorePosition == false && position.IsEqualApprox(Vector3.Zero) == false) {
            vertices[vertexIndex] += position;
          }

          // Apply UV based on shader instance uniforms so texture mapping is correct for each mesh
          if (willIgnoreUVs == false) {
            texUVs[vertexIndex].X *= scaleAndOffsetUV.X;
            texUVs[vertexIndex].Y *= scaleAndOffsetUV.Y;
            texUVs[vertexIndex].X += scaleAndOffsetUV.Z;
            texUVs[vertexIndex].Y += scaleAndOffsetUV.W;
          }
        }

        //Assign data back to the surface to store any changes
        surface[(int)Mesh.ArrayType.Vertex] = vertices;
        surface[(int)Mesh.ArrayType.Normal] = normals;
        surface[(int)Mesh.ArrayType.TexUV] = texUVs;
      }

      // Clear out any data in index 5-9
      // WAIT UNTIL RAW IMPORT TEST IS COMPLETE

      // Add the processed surface to the mesh array
      _arrayMesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles, surface);          

      // Grab the material used if it isn't known yet
      if (_material == null) {
        _material = child.Mesh.SurfaceGetMaterial(0);
        _scaleAndOffsetUV = (Vector4) child.GetInstanceShaderParameter(RenderConst.SCALE_AND_OFFSET_UV_SHADER_INSTANCE_PARAMETER_NAME);
      }

      // Set the mesh child to invisible since it's been processed
      child.Visible = false;          
    }
  }

And this is how I would call it from code. I’d generally tell it to ignore scale when working with a skeleton as it would cause some very weird results. It’s not perfect, but it served the purpose that I needed

ModelProcessing.CombineVisibleChildrenMeshInstances(skeleton, new Node3D[] { skeleton }, false, false, true);