// // This file is part of the Tremble package by Tiny Goose. // Copyright (c) 2024-2026 TinyGoose Ltd., All Rights Reserved. // using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.Rendering; #if UNITY_EDITOR using UnityEditor; #endif namespace TinyGoose.Tremble { public class MeshBuilder { public delegate Material FindMaterialFunc(string name); // ----------------------------------------------------------------------------------------------------------------------------- // Setup // ----------------------------------------------------------------------------------------------------------------------------- private readonly TrembleMapImportSettings m_ImportSettings; private readonly string m_WorldspawnName; private readonly float m_SmoothingAngle; private readonly float m_MaxMeshSurfaceArea; private readonly FindMaterialFunc m_MaterialFinder; // ----------------------------------------------------------------------------------------------------------------------------- // State // ----------------------------------------------------------------------------------------------------------------------------- private int m_MeshCounter; private readonly List m_FaceIndicesBuffer = new(); private readonly List m_MeshVerticesBuffer = new(2048); private readonly List m_MeshSubmeshesBuffer = new(16); private readonly Dictionary m_BaseIndexToIndex = new(2048); public MeshBuilder(TrembleMapImportSettings importSettings, FindMaterialFunc materialFinder, string worldspawnName, float smoothingAngle, float maxMeshSurfaceArea) { m_ImportSettings = importSettings; m_WorldspawnName = worldspawnName; m_SmoothingAngle = smoothingAngle; m_MaxMeshSurfaceArea = maxMeshSurfaceArea; m_MaterialFinder = materialFinder; } private static readonly HashSet s_InnerFaceList = new(2048); public void BuildMesh_Recursive(MapBsp mapBsp, Transform parent, int nodeID, HashSet faceList, float importScale, int unityLayer, List intoListOrNull = null) { // Not a leaf - recurse unless smol if (nodeID >= 0) { bool subdivide = TrembleSyncSettings.Get().SubdivideAllLeafs; ref BspNode node = ref mapBsp.Nodes[nodeID]; if (!subdivide) { float sqrSurfArea = 0; BspParser.GetSurfaceAreaSqr(mapBsp, node, ref sqrSurfArea, faceList); subdivide = (sqrSurfArea > m_MaxMeshSurfaceArea * m_MaxMeshSurfaceArea); } if (subdivide) { s_InnerFaceList.Clear(); BuildMesh_Recursive(mapBsp, parent, node.Child1, s_InnerFaceList, importScale, unityLayer, intoListOrNull); BuildMesh_Recursive(mapBsp, parent, node.Child2, s_InnerFaceList, importScale, unityLayer, intoListOrNull); // Don't build this one return; } } // This is a leaf, or as small as we want to go - create! Bounds bounds = new(); if (nodeID < 0) { bounds.min = new Vector3(mapBsp.Leaves[-nodeID - 1].Min.x, mapBsp.Leaves[-nodeID - 1].Min.z, mapBsp.Leaves[-nodeID - 1].Min.y) * importScale; bounds.max = new Vector3(mapBsp.Leaves[-nodeID - 1].Max.x, mapBsp.Leaves[-nodeID - 1].Max.z, mapBsp.Leaves[-nodeID - 1].Max.y) * importScale; } else { bounds.min = new Vector3(mapBsp.Nodes[nodeID].Min.x, mapBsp.Nodes[nodeID].Min.z, mapBsp.Nodes[nodeID].Min.y) * importScale; bounds.max = new Vector3(mapBsp.Nodes[nodeID].Max.x, mapBsp.Nodes[nodeID].Max.z, mapBsp.Nodes[nodeID].Max.y) * importScale; } GameObject meshGo = BuildMesh(mapBsp, parent, nodeID, true, m_WorldspawnName, true, bounds.center, true); if (meshGo) { meshGo.layer = unityLayer; intoListOrNull?.Add(meshGo); } } public GameObject BuildMesh(MapBsp mapBsp, Transform parent, int sourceID, bool isBspRoot, string modelName, bool isFromNode, Vector3 origin, bool isStatic, Quaternion? rotation = null, float smoothingAngleOverride = -1f) { // Thank you again John Evans. This method would have been awful to figure out by myself! // Collect face indices m_FaceIndicesBuffer.Clear(); if (isFromNode) { if (sourceID < 0) { BspParser.GetFaceList(mapBsp, mapBsp.Leaves[-sourceID - 1], m_FaceIndicesBuffer); } else { BspParser.GetFaceList(mapBsp, mapBsp.Nodes[sourceID], m_FaceIndicesBuffer); } } else { for (int i = 0; i < mapBsp.Models[sourceID].NumFaces; i++) { m_FaceIndicesBuffer.Add(mapBsp.Models[sourceID].StartFaceIdx + i); } } if (m_FaceIndicesBuffer.Count == 0) return null; // Gather vertices and submeshes m_MeshVerticesBuffer.Clear(); m_MeshSubmeshesBuffer.Clear(); m_BaseIndexToIndex.Clear(); // Get faces for this mesh foreach (BspFace face in m_FaceIndicesBuffer.Select(faceIdx => mapBsp.Faces[faceIdx])) { // Get each vertex for this face for (int faceVertIdx = face.MeshVertexStartIdx; faceVertIdx < face.MeshVertexStartIdx + face.NumMeshVertices; faceVertIdx++) { int baseIdx = face.VertexStartIdx + mapBsp.MeshVertices[faceVertIdx]; // Find or add a vertex to this mesh int localVertIndex = FindOrAddVertexForBaseIndex(baseIdx, mapBsp, origin, rotation); AddVertexToSubmeshWithTexID(m_MeshSubmeshesBuffer, face.TexID, localVertIndex); } } // Discover and apply materials List meshMaterials = new(m_MeshSubmeshesBuffer.Count); { for (int materialIdx = 0; materialIdx < m_MeshSubmeshesBuffer.Count; materialIdx++) { string matName = mapBsp.TexInfos[m_MeshSubmeshesBuffer[materialIdx].TexID].Name; // Skip? Skip! if (matName.ContainsInvariant(TBConsts.SKIP_TEXTURE, caseSensitive: true)) { m_MeshSubmeshesBuffer.RemoveAt(materialIdx); materialIdx--; continue; } Material foundMaterial = m_MaterialFinder.Invoke(matName); meshMaterials.Add(foundMaterial); if (foundMaterial) { m_ImportSettings.AddDependency(foundMaterial); } } } // Create Unity mesh Mesh mesh = CreateUnityMesh(isBspRoot, ref modelName, m_MeshVerticesBuffer, m_MeshSubmeshesBuffer, smoothingAngleOverride); bool isTinyMesh = mesh.triangles.Length == 0 || Mathf.Max(mesh.bounds.extents.x, mesh.bounds.extents.y, mesh.bounds.extents.z) < 0.1f; if (isTinyMesh) return null; m_ImportSettings.SaveObjectInMap(mesh.name, mesh); // Create model GameObject and attach mesh GameObject modelObject = new(modelName); modelObject.transform.parent = parent; modelObject.transform.position = origin; modelObject.AddComponent().mesh = mesh; modelObject.AddComponent().sharedMaterials = meshMaterials.ToArray(); TrembleSyncSettings syncSettings = TrembleSyncSettings.Get(); // Generate mesh collider Mesh collisionMesh; if (syncSettings.PipelineSimplifyCollisionMeshes) { using TrembleTimerScope _ = new(TrembleTimer.Context.SimplifyCollisionMeshes); collisionMesh = GenerateSimplifiedUnityCollisionMesh(mesh); } else { collisionMesh = Mesh.Instantiate(mesh); } collisionMesh.name = mesh.name.Replace("_mesh", "_collision"); m_ImportSettings.SaveObjectInMap(collisionMesh.name, collisionMesh); modelObject.AddComponent().sharedMesh = collisionMesh; // Calculate lightmap UVs #if UNITY_EDITOR if (isStatic && syncSettings.PipelineUnwrapUV2) { using TrembleTimerScope _ = new(TrembleTimer.Context.GenerateUV2); Unwrapping.GenerateSecondaryUVSet(mesh, new() { hardAngle = 70.0f, angleError = 8.0f, areaError = 15.0f, packMargin = mapBsp.Options.UV2Padding, }); } #endif modelObject.isStatic = isStatic; return modelObject; } private Mesh CreateUnityMesh(bool isBspRoot, ref string modelName, List meshVertices, List meshSubmeshes, float smoothingAngleOverride) { IndexFormat meshFormat = IndexFormat.UInt16; foreach (BspSubMesh submesh in meshSubmeshes) { if (submesh.Triangles.Count * 3 > 65535) { Debug.Log($"Note: Mesh '{modelName}' using 32-bit indices due to high index count. Render performance may be reduced!"); meshFormat = IndexFormat.UInt32; break; } } // Create Unity mesh Mesh mesh = new() { indexFormat = meshFormat, subMeshCount = meshSubmeshes.Count, vertices = meshVertices.Select(mv => mv.Position).ToArray(), normals = meshVertices.Select(mv => mv.Normal).ToArray(), uv = meshVertices.Select(mv => mv.UV).ToArray(), }; for (int i = 0; i < meshSubmeshes.Count; i++) { mesh.SetTriangles(meshSubmeshes[i].Triangles.ToArray(), i); } mesh.RecalculateBounds(); mesh.RecalculateTangents(); // Name the mesh if (isBspRoot) { modelName += $"_{++m_MeshCounter}"; mesh.name = $"{modelName}_mesh"; } else { mesh.name = $"{modelName}_{++m_MeshCounter}_mesh"; } float smoothingAngle = m_SmoothingAngle; if (smoothingAngleOverride >= 0f) smoothingAngle = smoothingAngleOverride; // Smooth out normals if required if (smoothingAngle > 0 && TrembleSyncSettings.Get().PipelineSmoothMeshNormals) { using TrembleTimerScope _ = new(TrembleTimer.Context.SmoothMeshNormals); SmoothMeshNormals(mesh, smoothingAngle); } return mesh; } private int FindOrAddVertexForBaseIndex(int baseIdx, MapBsp mapBsp, Vector3 origin, Quaternion? rotation) { // Find existing value if (m_BaseIndexToIndex.TryGetValue(baseIdx, out int currentMeshVertIdx)) return currentMeshVertIdx; Vector3 position = mapBsp.Vertices[baseIdx].Position - origin; Vector3 normal = mapBsp.Vertices[baseIdx].Normal; if (rotation != null) { position = rotation.Value * position; normal = rotation.Value * normal; } // Not found, add a new one BspMeshVertex currentMeshVertex = new() { Position = position, Normal = normal, UV = mapBsp.Vertices[baseIdx].TexCoord + new Vector2(0.5f, 0.5f), BaseIndex = baseIdx }; m_MeshVerticesBuffer.Add(currentMeshVertex); m_BaseIndexToIndex.Add(baseIdx, m_MeshVerticesBuffer.Count - 1); return m_MeshVerticesBuffer.Count - 1; } private static void AddVertexToSubmeshWithTexID(List meshSubmeshes, int texID, int localVertexIdx) { // Find a submesh that uses this texture ID foreach (BspSubMesh subMesh in meshSubmeshes) { if (texID != subMesh.TexID) continue; subMesh.Triangles.Add(localVertexIdx); return; } // Not found, make a new submesh for this face meshSubmeshes.Add(new() { TexID = texID, Triangles = new() { localVertexIdx }, }); } // ----------------------------------------------------------------------------------------------------------------------------- // Utilities // ----------------------------------------------------------------------------------------------------------------------------- private static void SmoothMeshNormals(Mesh mesh, float maxAngle = 45.0f) { Vector3[] newVertices = mesh.vertices; Vector3[] newNormals = mesh.normals; Vector3[] originalNormals = new Vector3[newNormals.Length]; Array.Copy(newNormals, originalNormals, newNormals.Length); float cosThreshold = Mathf.Cos(Mathf.Deg2Rad * maxAngle); for (int i = 0; i < newVertices.Length; i++) { int groupCount = 1; Vector3 currentNormal = originalNormals[i]; for (int j = 0; j < newVertices.Length; j++) { if (i == j) continue; // Inlined SqrMagnitude to avoid Vector3 .ctor ref Vector3 from = ref newVertices[i]; ref Vector3 to = ref newVertices[j]; float xDiff = from.x - to.x; float yDiff = from.y - to.y; float zDiff = from.z - to.z; float sqrDistance = xDiff * xDiff + yDiff * yDiff + zDiff * zDiff; if (sqrDistance >= 0.01f * 0.01f) continue; // Merge verts! to = from; // Also inline AngleBetween and avoid Acos for speed up... these 3 changes reduce from 700ms to about 20ms ;) float cosBetween = Vector3.Dot(originalNormals[i], originalNormals[j]); if (cosBetween > cosThreshold) { currentNormal += originalNormals[j]; groupCount++; } } if (groupCount >= 2) { newNormals[i] = currentNormal / groupCount; } } mesh.vertices = newVertices; mesh.normals = newNormals; mesh.RecalculateBounds(); mesh.RecalculateTangents(); } private static Mesh GenerateSimplifiedUnityCollisionMesh(Mesh inputMesh, float threshold = 0.01f) { Mesh outputMesh = new() { name = inputMesh.name}; Vector3[] inputVertices = inputMesh.vertices; int inputVertexCount = inputMesh.vertexCount; int[] inputTriangles = inputMesh.triangles; int[] vertexRemap = new int[inputVertexCount]; List newVertices = new() { inputVertices[0] }; float sqrThreshold = threshold * threshold; for (int inputVertIdx = 0; inputVertIdx < inputVertexCount; inputVertIdx++) { bool wasRemapped = false; float newVertCount = newVertices.Count; for (int newVertIdx = 0; newVertIdx < newVertCount; newVertIdx++) { Vector3 from = inputVertices[inputVertIdx]; Vector3 to = newVertices[newVertIdx]; float xDiff = from.x - to.x; float yDiff = from.y - to.y; float zDiff = from.z - to.z; float sqrDistance = xDiff * xDiff + yDiff * yDiff + zDiff * zDiff; if (sqrDistance > sqrThreshold) continue; vertexRemap[inputVertIdx] = newVertIdx; wasRemapped = true; break; } if (!wasRemapped) { newVertices.Add(inputVertices[inputVertIdx]); vertexRemap[inputVertIdx] = newVertices.Count - 1; } } // Create triangles array int[] newTriangles = new int[inputTriangles.Length]; int triPtr = 0; for (int i = 0; i < inputTriangles.Length; i += 3) { if (vertexRemap[inputTriangles[i]] == vertexRemap[inputTriangles[i + 1]] || vertexRemap[inputTriangles[i]] == vertexRemap[inputTriangles[i + 2]] || vertexRemap[inputTriangles[i + 1]] == vertexRemap[inputTriangles[i + 2]]) continue; newTriangles[triPtr++] = vertexRemap[inputTriangles[i + 0]]; newTriangles[triPtr++] = vertexRemap[inputTriangles[i + 1]]; newTriangles[triPtr++] = vertexRemap[inputTriangles[i + 2]]; } outputMesh.vertices = newVertices.ToArray(); outputMesh.triangles = newTriangles; return outputMesh; } } }