// Crest Water System // Copyright © 2024 Wave Harmonic. All rights reserved. //#define PROFILE_CONSTRUCTION using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; namespace WaveHarmonic.Crest { /// /// Instantiates all the water geometry, as a set of tiles. /// static class WaterBuilder { // The comments below illustrate case when BASE_VERT_DENSITY = 2. The water mesh is built up from these patches. Rotational symmetry // is used where possible to eliminate combinations. The slim variants are used to eliminate overlap between patches. enum PatchType { /// /// Adds no skirt. Used in interior of highest detail LOD (0) /// /// 1 ------- /// | | | /// z ------- /// | | | /// 0 ------- /// 0 1 /// x /// /// Interior, /// /// Adds a full skirt all of the way around a patch /// /// ------------- /// | | | | | /// 1 ------------- /// | | | | | /// z ------------- /// | | | | | /// 0 ------------- /// | | | | | /// ------------- /// 0 1 /// x /// /// Fat, /// /// Adds a skirt on the right hand side of the patch /// /// 1 ---------- /// | | | | /// z ---------- /// | | | | /// 0 ---------- /// 0 1 /// x /// /// FatX, /// /// Adds a skirt on the right hand side of the patch, removes skirt from top /// FatXSlimZ, /// /// Outer most side - this adds an extra skirt on the left hand side of the patch, /// which will point outwards and be extended to Zfar /// /// 1 -------------------------------------------------------------------------------------- /// | | | | /// z -------------------------------------------------------------------------------------- /// | | | | /// 0 -------------------------------------------------------------------------------------- /// 0 1 /// x /// /// FatXOuter, /// /// Adds skirts at the top and right sides of the patch /// FatXZ, /// /// Adds skirts at the top and right sides of the patch and pushes them to horizon /// FatXZOuter, /// /// One less set of verts in x direction /// SlimX, /// /// One less set of verts in both x and z directions /// SlimXZ, /// /// One less set of verts in x direction, extra verts at start of z direction /// /// ---- /// | | /// 1 ---- /// | | /// z ---- /// | | /// 0 ---- /// 0 1 /// x /// /// SlimXFatZ, /// /// Number of patch types /// Count, } // Keep references to meshes so they can be cleaned up later. static Mesh[] s_Meshes; /// /// Destroy tiles and any resources. /// public static void CleanUp(WaterRenderer water) { // Not every mesh is assigned to a chunk thus we should destroy all of them here. for (var i = 0; i < s_Meshes?.Length; i++) { Helpers.Destroy(s_Meshes[i]); } water.Chunks.Clear(); // May not be present when entering play mode. if (water.Root) { Helpers.Destroy(water.Root.gameObject); } } public static Transform GenerateMesh(WaterRenderer water, List tiles, int lodDataResolution, int geoDownSampleFactor, int lodCount) { if (lodCount < 1) { Debug.LogError("Crest: Invalid LOD count: " + lodCount.ToString(), water); return null; } #if PROFILE_CONSTRUCTION var sw = new System.Diagnostics.Stopwatch(); sw.Start(); #endif var root = new GameObject("Root"); Debug.Assert(root != null, "Crest: The water Root transform could not be immediately constructed. Please report this issue to the Crest developers via our support email or GitHub at https://github.com/wave-harmonic/crest/issues ."); root.hideFlags = water._Debug._ShowHiddenObjects ? HideFlags.DontSave : HideFlags.HideAndDontSave; root.transform.parent = water.transform; root.transform.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity); root.transform.localScale = Vector3.one; if (!water.IsRunningHeadless && !water.IsRunningWithoutGraphics) { // create mesh data s_Meshes = new Mesh[(int)PatchType.Count]; var meshBounds = new Bounds[(int)PatchType.Count]; // 4 tiles across a LOD, and support lowering density by a factor var tileResolution = Mathf.Round(0.25f * lodDataResolution / geoDownSampleFactor); for (var i = 0; i < (int)PatchType.Count; i++) { s_Meshes[i] = BuildPatch(water, (PatchType)i, tileResolution, out meshBounds[i]); } for (var i = 0; i < lodCount; i++) { CreateLOD(water, tiles, root.transform, i, lodCount, s_Meshes, meshBounds, lodDataResolution, geoDownSampleFactor, water.Layer); } } #if PROFILE_CONSTRUCTION sw.Stop(); Debug.Log( "Crest: Finished generating " + lodCount.ToString() + " LODs, time: " + (1000.0*sw.Elapsed.TotalSeconds).ToString(".000") + "ms" ); #endif return root.transform; } static Mesh BuildPatch(WaterRenderer water, PatchType pt, float vertDensity, out Bounds bounds) { var verts = new List(); var indices = new List(); // stick a bunch of verts into a 1m x 1m patch (scaling happens later) var dx = 1f / vertDensity; ////////////////////////////////////////////////////////////////////////////////// // verts // see comments within PatchType for diagrams of each patch mesh // skirt widths on left, right, bottom and top (in order) float skirtXminus = 0f, skirtXplus = 0f; float skirtZminus = 0f, skirtZplus = 0f; // set the patch size if (pt == PatchType.Fat) { skirtXminus = skirtXplus = skirtZminus = skirtZplus = 1f; } else if (pt is PatchType.FatX or PatchType.FatXOuter) { skirtXplus = 1f; } else if (pt is PatchType.FatXZ or PatchType.FatXZOuter) { skirtXplus = skirtZplus = 1f; } else if (pt == PatchType.FatXSlimZ) { skirtXplus = 1f; skirtZplus = -1f; } else if (pt == PatchType.SlimX) { skirtXplus = -1f; } else if (pt == PatchType.SlimXZ) { skirtXplus = skirtZplus = -1f; } else if (pt == PatchType.SlimXFatZ) { skirtXplus = -1f; skirtZplus = 1f; } var sideLength_verts_x = 1f + vertDensity + skirtXminus + skirtXplus; var sideLength_verts_z = 1f + vertDensity + skirtZminus + skirtZplus; var start_x = -0.5f - skirtXminus * dx; var start_z = -0.5f - skirtZminus * dx; var end_x = 0.5f + skirtXplus * dx; var end_z = 0.5f + skirtZplus * dx; // With a default value of 100, this will reach the horizon at all levels at // a far plane of 200k. var extentsMultiplier = water._ExtentsSizeMultiplier * (Lod.k_MaximumSlices + 1 - water.LodLevels); for (float j = 0; j < sideLength_verts_z; j++) { // interpolate z across patch var z = Mathf.Lerp(start_z, end_z, j / (sideLength_verts_z - 1f)); // push outermost edge out to horizon if (pt == PatchType.FatXZOuter && j == sideLength_verts_z - 1f) z *= extentsMultiplier; for (float i = 0; i < sideLength_verts_x; i++) { // interpolate x across patch var x = Mathf.Lerp(start_x, end_x, i / (sideLength_verts_x - 1f)); // push outermost edge out to horizon if (i == sideLength_verts_x - 1f && (pt == PatchType.FatXOuter || pt == PatchType.FatXZOuter)) x *= extentsMultiplier; // could store something in y, although keep in mind this is a shared mesh that is shared across multiple lods verts.Add(new(x, 0f, z)); } } ////////////////////////////////////////////////////////////////////////////////// // indices var sideLength_squares_x = (int)sideLength_verts_x - 1; var sideLength_squares_z = (int)sideLength_verts_z - 1; for (var j = 0; j < sideLength_squares_z; j++) { for (var i = 0; i < sideLength_squares_x; i++) { var flipEdge = false; if (i % 2 == 1) flipEdge = !flipEdge; if (j % 2 == 1) flipEdge = !flipEdge; var i0 = i + j * (sideLength_squares_x + 1); var i1 = i0 + 1; var i2 = i0 + (sideLength_squares_x + 1); var i3 = i2 + 1; if (!flipEdge) { // tri 1 indices.Add(i3); indices.Add(i1); indices.Add(i0); // tri 2 indices.Add(i0); indices.Add(i2); indices.Add(i3); } else { // tri 1 indices.Add(i3); indices.Add(i1); indices.Add(i2); // tri 2 indices.Add(i0); indices.Add(i2); indices.Add(i1); } } } ////////////////////////////////////////////////////////////////////////////////// // create mesh var mesh = new Mesh(); if (verts != null && verts.Count > 0) { var arrV = new Vector3[verts.Count]; verts.CopyTo(arrV); var arrI = new int[indices.Count]; indices.CopyTo(arrI); mesh.SetIndices(null, MeshTopology.Triangles, 0); mesh.vertices = arrV; // HDRP needs full data. Do this on a define to keep door open to runtime changing of RP. #if d_UnityHDRP var norms = new Vector3[verts.Count]; for (var i = 0; i < norms.Length; i++) norms[i] = Vector3.up; var tans = new Vector4[verts.Count]; for (var i = 0; i < tans.Length; i++) tans[i] = new(1, 0, 0, 1); mesh.normals = norms; mesh.tangents = tans; #else mesh.normals = null; #endif mesh.SetIndices(arrI, MeshTopology.Triangles, 0); // recalculate bounds. add a little allowance for snapping. in the chunk renderer script, the bounds will be expanded further // to allow for horizontal displacement mesh.RecalculateBounds(); bounds = mesh.bounds; // Increase snapping allowance (see #1148). Value was chosen by observation with a // custom debug mode to show pixels that were out of bounds. dx *= 3f; bounds.extents = new(bounds.extents.x + dx, bounds.extents.y, bounds.extents.z + dx); mesh.bounds = bounds; mesh.name = pt.ToString(); } else { bounds = new(); } return mesh; } static void CreateLOD(WaterRenderer water, List tiles, Transform parent, int lodIndex, int lodCount, Mesh[] meshData, Bounds[] meshBounds, int lodDataResolution, int geoDownSampleFactor, int layer) { var horizScale = Mathf.Pow(2f, lodIndex); var isBiggestLOD = lodIndex == lodCount - 1; var generateSkirt = isBiggestLOD; #if CREST_DEBUG generateSkirt = generateSkirt && !water._Debug._DisableSkirt; #endif Vector2[] offsets; PatchType[] patchTypes; var leadSideType = generateSkirt ? PatchType.FatXOuter : PatchType.SlimX; var trailSideType = generateSkirt ? PatchType.FatXOuter : PatchType.FatX; var leadCornerType = generateSkirt ? PatchType.FatXZOuter : PatchType.SlimXZ; var trailCornerType = generateSkirt ? PatchType.FatXZOuter : PatchType.FatXZ; var tlCornerType = generateSkirt ? PatchType.FatXZOuter : PatchType.SlimXFatZ; var brCornerType = generateSkirt ? PatchType.FatXZOuter : PatchType.FatXSlimZ; if (lodIndex != 0) { // instance indices: // 0 1 2 3 // 4 5 // 6 7 // 8 9 10 11 offsets = new Vector2[] { new(-1.5f,1.5f), new(-0.5f,1.5f), new(0.5f,1.5f), new(1.5f,1.5f), new(-1.5f,0.5f), new(1.5f,0.5f), new(-1.5f,-0.5f), new(1.5f,-0.5f), new(-1.5f,-1.5f), new(-0.5f,-1.5f), new(0.5f,-1.5f), new(1.5f,-1.5f), }; // usually rings have an extra side of verts that point inwards. the outermost ring has both the inward // verts and also and additional outwards set of verts that go to the horizon patchTypes = new PatchType[] { tlCornerType, leadSideType, leadSideType, leadCornerType, trailSideType, leadSideType, trailSideType, leadSideType, trailCornerType, trailSideType, trailSideType, brCornerType, }; } else { // first LOD has inside bit as well: // 0 1 2 3 // 4 5 6 7 // 8 9 10 11 // 12 13 14 15 offsets = new Vector2[] { new(-1.5f,1.5f), new(-0.5f,1.5f), new(0.5f,1.5f), new(1.5f,1.5f), new(-1.5f,0.5f), new(-0.5f,0.5f), new(0.5f,0.5f), new(1.5f,0.5f), new(-1.5f,-0.5f), new(-0.5f,-0.5f), new(0.5f,-0.5f), new(1.5f,-0.5f), new(-1.5f,-1.5f), new(-0.5f,-1.5f), new(0.5f,-1.5f), new(1.5f,-1.5f), }; // all interior - the "side" types have an extra skirt that points inwards - this means that this inner most // section doesn't need any skirting. this is good - this is the highest density part of the mesh. patchTypes = new PatchType[] { tlCornerType, leadSideType, leadSideType, leadCornerType, trailSideType, PatchType.Interior, PatchType.Interior, leadSideType, trailSideType, PatchType.Interior, PatchType.Interior, leadSideType, trailCornerType, trailSideType, trailSideType, brCornerType, }; } #if CREST_DEBUG // debug toggle to force all patches to be the same. they'll be made with a surrounding skirt to make sure patches // overlap if (water._Debug._UniformTiles) { for (var i = 0; i < patchTypes.Length; i++) { patchTypes[i] = PatchType.Fat; } } #endif // create the water patches for (var i = 0; i < offsets.Length; i++) { // instantiate and place patch var patch = water._ChunkTemplate ? Helpers.InstantiatePrefab(water._ChunkTemplate) : new(); // Also applying the hide flags to the chunk will prevent it from being pickable in the editor. patch.hideFlags = water._Debug._ShowHiddenObjects ? HideFlags.DontSave : HideFlags.HideAndDontSave; patch.name = $"Tile_L{lodIndex}_{patchTypes[i]}"; patch.layer = layer; patch.transform.parent = parent; var pos = offsets[i]; patch.transform.localPosition = horizScale * new Vector3(pos.x, 0f, pos.y); // scale only horizontally, otherwise culling bounding box will be scaled up in y patch.transform.localScale = new(horizScale, 1f, horizScale); if (!patch.TryGetComponent(out var mr)) { mr = patch.AddComponent(); // I don't think one would use light probes for a purely specular water surface? (although diffuse // foam shading would benefit). mr.lightProbeUsage = LightProbeUsage.Off; } { var mesh = Object.Instantiate(meshData[(int)patchTypes[i]]); mesh.name = meshData[(int)patchTypes[i]].name; patch.AddComponent().sharedMesh = mesh; var chunk = patch.AddComponent(); chunk._Water = water; chunk._BoundsLocal = meshBounds[(int)patchTypes[i]]; chunk.SetInstanceData(lodIndex); tiles.Add(chunk); } // Sorting order to stop unity drawing it back to front. Make the innermost four tiles draw first, // followed by the rest of the tiles by LOD index. if (RenderPipelineHelper.IsHighDefinition) { // HDRP has a different rendering priority system: // https://docs.unity3d.com/Packages/com.unity.render-pipelines.high-definition@10.10/manual/Renderer-And-Material-Priority.html#sorting-by-renderer mr.rendererPriority = -lodCount + (patchTypes[i] == PatchType.Interior ? -1 : lodIndex); } else if (!water.AllowRenderQueueSorting) { // Sorting order to stop unity drawing it back to front. make the innermost 4 tiles draw first, followed by // the rest of the tiles by LOD index. all this happens before layer 0 - the sorting layer takes priority over the // render queue it seems! ( https://cdry.wordpress.com/2017/04/28/unity-render-queues-vs-sorting-layers/ ). This pushes // water rendering way early, so transparent objects will by default render afterwards, which is typical for water rendering. mr.sortingOrder = -lodCount + (patchTypes[i] == PatchType.Interior ? -1 : lodIndex); } mr.shadowCastingMode = water.CastShadows ? ShadowCastingMode.On : ShadowCastingMode.Off; // This setting is ignored by Unity for the transparent water shader. mr.receiveShadows = false; // BIRP needs further investigation. mr.motionVectorGenerationMode = !water.WriteMotionVectors ? MotionVectorGenerationMode.ForceNoMotion : MotionVectorGenerationMode.Object; mr.material = water.Material; // rotate side patches to point the +x side outwards var rotateXOutwards = patchTypes[i] is PatchType.FatX or PatchType.FatXOuter or PatchType.SlimX or PatchType.SlimXFatZ; if (rotateXOutwards) { if (Mathf.Abs(pos.y) >= Mathf.Abs(pos.x)) patch.transform.localEulerAngles = 90f * Mathf.Sign(pos.y) * -Vector3.up; else patch.transform.localEulerAngles = pos.x < 0f ? Vector3.up * 180f : Vector3.zero; } // rotate the corner patches so the +x and +z sides point outwards var rotateXZOutwards = patchTypes[i] is PatchType.FatXZ or PatchType.SlimXZ or PatchType.FatXSlimZ or PatchType.FatXZOuter; if (rotateXZOutwards) { // xz direction before rotation var from = new Vector3(1f, 0f, 1f).normalized; // target xz direction is outwards vector given by local patch position - assumes this patch is a corner (checked below) var to = patch.transform.localPosition.normalized; if (Mathf.Abs(patch.transform.localPosition.x) < 0.0001f || Mathf.Abs(Mathf.Abs(patch.transform.localPosition.x) - Mathf.Abs(patch.transform.localPosition.z)) > 0.001f) { Debug.LogWarning("Crest: Skipped rotating a patch because it isn't a corner, click here to highlight.", patch); continue; } // Detect 180 degree rotations as it doesn't always rotate around Y if (Vector3.Dot(from, to) < -0.99f) patch.transform.localEulerAngles = Vector3.up * 180f; else patch.transform.localRotation = Quaternion.FromToRotation(from, to); } } } } }