using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.Serialization; public class GrassField : MonoBehaviour { public int size = 100; public int numChunks = 20; public int bladesPerSquareMeter = 100; public GrassChunk chunkPrefab; public bool ignoreHeightForLOD = true; [Header("LOD 0")] public Material grassMaterialLOD0; public float minDistanceLOD0 = 2f; [Range(0f, 1f)] public float densityLOD0 = 1f; public bool enableShadowsLOD0 = true; [Header("LOD 1")] public float distanceLOD1 = 5f; public Material grassMaterialLOD1; [Range(0f, 1f)] public float densityLOD1 = 0.5f; public bool enableShadowsLOD1 = true; [Header("LOD 2")] public float distanceLOD2 = 15f; public Material grassMaterialLOD2; [Range(0f, 1f)] public float densityLOD2 = 0.25f; public bool enableShadowsLOD2 = false; [Header("LOD 3")] public float distanceLOD3 = 25f; public Material grassMaterialLOD3; [Range(0f, 1f)] public float densityLOD3 = 0.125f; public bool enableShadowsLOD3 = false; private const float SQRT_2 = 1.4142135623730f; private static readonly int pId_WorldSpaceCameraPosEditor = Shader.PropertyToID("_WorldSpaceCameraPosEditor"); private static readonly int pId_TransitionRange = Shader.PropertyToID("_TransitionRange"); // a 2D array would be cleaner, but it can't be serialized // serialize it, because the chunks are generated offline and used during runtime [SerializeField, HideInInspector] private GrassChunk[] _chunks; public void GenerateGrassField() { var bladesLOD0 = GrassMeshGeneration.GenerateBladesRandom( size, size * size * (int)(bladesPerSquareMeter * densityLOD0) ); var bladesLOD1 = GrassMeshGeneration.GenerateBladesReduced(bladesLOD0, densityLOD1); var bladesLOD2 = GrassMeshGeneration.GenerateBladesReduced(bladesLOD1, densityLOD2 / densityLOD1); var bladesLOD3 = GrassMeshGeneration.GenerateBladesReduced(bladesLOD2, densityLOD3 / densityLOD2); CreateChunks(bladesLOD0, bladesLOD1, bladesLOD2, bladesLOD3); var camPosWorld = Camera.main ? Camera.main.transform.position : Vector3.zero; UpdateLODs(camPosWorld); UpdateMaterials(camPosWorld); } public void UpdateLODsAndMaterials() { var camPosWorld = Camera.main ? Camera.main.transform.position : Vector3.zero; UpdateMaterials(camPosWorld); UpdateLODs(camPosWorld); } private void OnEnable() { var chunkSize = ((float)size) / numChunks; var minDistLOD2 = distanceLOD1 + chunkSize * SQRT_2; if (distanceLOD2 < minDistLOD2) { Debug.LogWarning("It is recommended that minDistLOD2 is greater than " + minDistLOD2 + "."); } var minDistLOD3 = distanceLOD2 + chunkSize * SQRT_2; if (distanceLOD3 < minDistLOD3) { Debug.LogWarning("It is recommended that minDistLOD3 is greater than " + minDistLOD3 + "."); } if (_chunks.Length != numChunks * numChunks) { Debug.LogError(this.name + ": Existing chunks does not match numChunks."); gameObject.SetActive(false); } UpdateLODsAndMaterials(); } private void OnDisable() { UpdateLODsAndMaterials(); } private void FixedUpdate() { var cam = Camera.main; if (!cam) return; var camPosWorld = cam.transform.position; #if UNITY_EDITOR // update LOD distances and camera position UpdateMaterials(camPosWorld); #endif if (_chunks.Length != numChunks * numChunks) return; UpdateLODs(camPosWorld, false); // while the game is running, only the chunks near the camera are updated } private void UpdateLODs(Vector3 camPosWorld, bool forceCompleteCheck = true) { var camPos = camPosWorld - transform.position; // cam position relative to grass field if (ignoreHeightForLOD) { camPos.y = 0; } var chunkSize = ((float)size) / numChunks; var bounds = new Bounds( new Vector3(0.5f * chunkSize, 0f, 0.5f * chunkSize), new Vector3(chunkSize, 0f, chunkSize) ); // small performance difference (0.13ms vs 0.02ms on a 100m 30x30 grid, LOD dist 26.6, camera at corner) var range = forceCompleteCheck ? new GridRange{ fromX = 0, toX = numChunks-1, fromZ = 0, toZ = numChunks-1 } : GetLODGridRange(camPos, chunkSize); for (int x = range.fromX; x <= range.toX; x++) for (int y = range.fromZ; y <= range.toZ; y++) { var chunkPos = new Vector3(x, 0f, y) * chunkSize; var localCamPos = camPos - chunkPos; // cam position relative to chunk var sqrCamDist = bounds.SqrDistance(localCamPos); if (sqrCamDist > distanceLOD3 * distanceLOD3) { _chunks[x + y * numChunks].LOD = 3; } else if (sqrCamDist > distanceLOD2 * distanceLOD2) { _chunks[x + y * numChunks].LOD = 2; } else if (sqrCamDist > distanceLOD1 * distanceLOD1) { _chunks[x + y * numChunks].LOD = 1; } else { _chunks[x + y * numChunks].LOD = 0; } } } private struct GridRange { public int fromX; public int toX; public int fromZ; public int toZ; } private GridRange GetLODGridRange(Vector3 camPos, float chunkSize) { GridRange range; var maxCamMove = 3f; // assume that the camera did not move further than this distance var fromXWorld = camPos.x - distanceLOD3 - maxCamMove; var toXWorld = camPos.x + distanceLOD3 + maxCamMove; var fromZWorld = camPos.z - distanceLOD3 - maxCamMove; var toZWorld = camPos.z + distanceLOD3 + maxCamMove; range.fromX = Math.Max( 0, (int) (fromXWorld / chunkSize) ); range.toX = Math.Max( 0, (int) (toXWorld / chunkSize) ); range.fromZ = Math.Max( 0, (int) (fromZWorld / chunkSize) ); range.toZ = (int) (toZWorld / chunkSize); range.fromX = Math.Clamp(range.fromX, 0, numChunks - 1); range.toX = Math.Clamp(range.toX, 0, numChunks - 1); range.fromZ = Math.Clamp(range.fromZ, 0, numChunks - 1); range.toZ = Math.Clamp(range.toZ, 0, numChunks - 1); return range; } private void UpdateMaterials(Vector3 camPosWorld) { var chunkSize = ((float)size) / numChunks; var chunkDiagonalLength = chunkSize * SQRT_2; var transition0From = minDistanceLOD0; var transition0To = distanceLOD1; var transition1From = distanceLOD1 + chunkDiagonalLength; var transition1To = distanceLOD2; var transition2From = distanceLOD2 + chunkDiagonalLength; var transition2To = distanceLOD3; grassMaterialLOD0.SetVector(pId_TransitionRange, new Vector2(transition0From, transition0To)); grassMaterialLOD1.SetVector(pId_TransitionRange, new Vector2(transition1From, transition1To)); grassMaterialLOD2.SetVector(pId_TransitionRange, new Vector2(transition2From, transition2To)); #if UNITY_EDITOR if (ignoreHeightForLOD) { camPosWorld.y = 0; } grassMaterialLOD0.SetVector(pId_WorldSpaceCameraPosEditor, camPosWorld); grassMaterialLOD1.SetVector(pId_WorldSpaceCameraPosEditor, camPosWorld); grassMaterialLOD2.SetVector(pId_WorldSpaceCameraPosEditor, camPosWorld); grassMaterialLOD3.SetVector(pId_WorldSpaceCameraPosEditor, camPosWorld); #endif } private void CreateChunks( BladeData[] bladesLOD0, BladeData[] bladesLOD1, BladeData[] bladesLOD2, BladeData[] bladesLOD3 ) { GameObject chunksContainer; // If a child called "Chunks" exists, destroy it -> children are also destroyed var chunksTransform = gameObject.transform.Find("Chunks"); if (chunksTransform != null) { // Destroy(obj) does not work in edit mode DestroyImmediate(chunksTransform.gameObject); } // create GameObject "Chunks" as a container for all GrassChunk objects chunksContainer = new GameObject("Chunks"); chunksContainer.transform.SetParent(this.transform, false); var chunkSize = ((float)size) / numChunks; // bounds of the chunk (relative to chunk) - do not consider y position var chunkBounds = new Bounds( new Vector3(0.5f * chunkSize, 0.0f, 0.5f * chunkSize), new Vector3(chunkSize, 10000f, chunkSize) ); _chunks = new GrassChunk[numChunks * numChunks]; for (int x = 0; x < numChunks; x++) for (int y = 0; y < numChunks; y++) { var chunkPos = new Vector3(x, 0.0f, y) * chunkSize; var meshLOD0 = GenerateChunkMesh(chunkPos, chunkBounds, bladesLOD0, "grass_chunk_" + x + "_" + y + "_lod0"); var meshLOD1 = GenerateChunkMesh(chunkPos, chunkBounds, bladesLOD1, "grass_chunk_" + x + "_" + y + "_lod1"); var meshLOD2 = GenerateChunkMesh(chunkPos, chunkBounds, bladesLOD2, "grass_chunk_" + x + "_" + y + "_lod2"); var meshLOD3 = GenerateChunkMesh(chunkPos, chunkBounds, bladesLOD3, "grass_chunk_" + x + "_" + y + "_lod3"); var chunk = Instantiate(chunkPrefab, chunksContainer.transform); chunk.name = "Chunk [" + x + ", " + y +"]"; chunk.transform.localPosition = chunkPos; chunk.MaterialLOD0 = grassMaterialLOD0; chunk.MaterialLOD1 = grassMaterialLOD1; chunk.MaterialLOD2 = grassMaterialLOD2; chunk.MaterialLOD3 = grassMaterialLOD3; chunk.MeshLOD0 = meshLOD0; chunk.MeshLOD1 = meshLOD1; chunk.MeshLOD2 = meshLOD2; chunk.MeshLOD3 = meshLOD3; chunk.EnableShadowsLOD0 = enableShadowsLOD0; chunk.EnableShadowsLOD1 = enableShadowsLOD1; chunk.EnableShadowsLOD2 = enableShadowsLOD2; chunk.EnableShadowsLOD3 = enableShadowsLOD3; _chunks[x + y * numChunks] = chunk; } } private Mesh GenerateChunkMesh(Vector3 chunkPos, Bounds chunkBounds, BladeData[] blades, string meshName) { List rootPositions = new(); // vertices List tipOffsets_missingInNextLOD = new(); // uv0 List indices = new(); foreach (var blade in blades) { // position relative to chunk var localRootPos = blade.rootPosition - chunkPos; if (chunkBounds.Contains(localRootPos)) { rootPositions.Add(localRootPos); tipOffsets_missingInNextLOD.Add(new Vector4( blade.tipOffset.x, blade.tipOffset.y, blade.tipOffset.z, blade.missingInNextLOD ? 1f : 0f) ); indices.Add(rootPositions.Count - 1); } } var mesh = new Mesh(); mesh.name = meshName; if (rootPositions.Count > 65536) { // 16 bit (default) supports 65536 vertices, 32 bit supports 4 billion mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32; Debug.LogWarning(meshName + " has more than 65536 grass blades. Mesh.indexFormat set to UInt32."); } mesh.SetVertices(rootPositions); mesh.SetUVs(0, tipOffsets_missingInNextLOD); // Vector4 UVs // AABB gets calculated automatically (considers all root positions) mesh.SetIndices(indices, MeshTopology.Points, 0); // update the AABB to match the actual grass (not just the root positions) // use mesh.bounds rather than meshRenderer.localBounds, // because the latter gets overwritten automatically var previousBounds = mesh.bounds; const float width = 1f; // a blade should never bend sideways farther than this const float height = 1f; // a blade should not be higher than this mesh.bounds = new Bounds( previousBounds.center + new Vector3(0f, height * 0.5f, 0f), previousBounds.size + new Vector3(2f*width, height, 2f*width) ); return mesh; } private void OnDrawGizmos() { var chunkSize = ((float)size) / numChunks; var tPos = transform.position; // grid for (int i = 0; i < numChunks + 1; i++) { Gizmos.color = new Color(0f, 0.3f, 1f, 1f); Gizmos.DrawLine( tPos + new Vector3(chunkSize * i, 0.01f, 0), tPos + new Vector3(chunkSize * i, 0.01f, 0) + new Vector3(0, 0, size) ); Gizmos.DrawLine( tPos + new Vector3(0, 0.01f, chunkSize * i), tPos + new Vector3(0, 0.01f, chunkSize * i) + new Vector3(size, 0, 0) ); } var cam = Camera.main; if (!cam) return; var center = cam.transform.position; var oldMatrix = Gizmos.matrix; if (ignoreHeightForLOD) { center.y = 0.01f; // draw a circle instead of a sphere Gizmos.matrix = Matrix4x4.TRS(Vector3.zero, Quaternion.identity, new Vector3(1f, 0.001f, 1f)); } Gizmos.color = new Color(0f, 0.3f, 1f, 1f); Gizmos.DrawWireSphere(center, distanceLOD1); Gizmos.DrawWireSphere(center, distanceLOD2); Gizmos.DrawWireSphere(center, distanceLOD3); Gizmos.color = new Color(1.0f, 1.0f, 0.0f, 1f); var minDistLOD0 = minDistanceLOD0; var minDistLOD1 = distanceLOD1 + chunkSize * SQRT_2; var minDistLOD2 = distanceLOD2 + chunkSize * SQRT_2; Gizmos.DrawWireSphere(center, minDistLOD0); Gizmos.DrawWireSphere(center, minDistLOD1); Gizmos.DrawWireSphere(center, minDistLOD2); Gizmos.matrix = oldMatrix; } }