using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; /// Deterministic meadow, generated in edit mode and at runtime. Wind and bending run on the GPU. [ExecuteAlways, DisallowMultipleComponent, DefaultExecutionOrder(100)] public sealed class PortalMeadow : MonoBehaviour { [SerializeField] private Material material; [SerializeField] private Vector2 size = new Vector2(32, 48); [SerializeField] private Vector3 center = new Vector3(0, -0.03f, 16); [SerializeField] private bool centerOnPlayerAtStart = true; [Tooltip("Optional player reference. Otherwise uses the Player tag, then the main camera.")] [SerializeField] private Transform initialPlayer; [SerializeField] private bool circular = true; [Tooltip("Keep the visible disk around the moving player and recycle existing grass tiles. No player teleportation.")] [SerializeField] private bool endless = true; [Tooltip("Disable while PortalB is only seen through a portal: the disk stays fixed until SetFollowPlayer(true).")] [SerializeField] private bool followPlayer = true; [SerializeField, Min(0.1f)] private float edgeFade = 4; [SerializeField] private Color edgeColor = new Color(0.36f, 0.43f, 0.46f, 1); [Tooltip("Thickness below the flat ground surface. Shader bending is visual only.")] [SerializeField, Min(0.01f)] private float colliderThickness = 0.2f; [SerializeField, Range(1, 60)] private int bladesPerSquareMetre = 30; [SerializeField] private Vector2 bladeHeight = new Vector2(0.25f, 0.65f); [SerializeField, Range(0.01f, 0.15f)] private float bladeWidth = 0.055f; [SerializeField] private int seed = 1978; [Tooltip("Maximum extra bounds in every direction to accommodate camera-relative shader bending. " + "In Play mode the padding is reduced to the real bend of the visible disk, so renderers can be culled.")] [SerializeField, Min(1)] private float boundsPadding = 128; [Tooltip("Rendering layers of every generated PortalB renderer, so lights can be restricted to this world.")] [SerializeField] private RenderingLayerMask renderingLayers = 1u; [Tooltip("Entrance (e.g. the portal). While not following the player, the bend is computed from this frame (looking along its forward axis) " + "instead of the camera, and the disk is centred on it, so everything stays aligned and still behind the portal.")] [SerializeField] private Transform bendAnchor; [Tooltip("Blend from the anchor frame to the camera once the player follows.")] [SerializeField, Min(0)] private float bendFadeDuration = 1.5f; private static readonly string[] BendProperties = { "_Curvature", "_StartDistance", "_HorizontalInfluence", "_BendAngle" }; private static readonly int FrameId = Shader.PropertyToID("_CurvedWorldFrame"); private static readonly int FrameInvId = Shader.PropertyToID("_CurvedWorldFrameInv"); private static readonly int FrameFixedId = Shader.PropertyToID("_CurvedWorldFrameFixed"); private float frameBlend; // Culling with the real bent bounds (the padded renderer bounds are too large for Unity to cull anything): // before the crossing PortalB is only visible through the portal openings, then through the camera frustum. private Portal[] portals; private readonly List portalFrusta = new List(); private readonly Plane[] cameraFrustum = new Plane[6]; private int portalFrustaCount; private bool throughPortals; private int frustaFrame = -1; private Camera frustaCamera; private bool culling; private bool tilesCulled; private float cullCurvature, cullStart, cullHorizontal; private Vector2 cullAxis; private Vector3 cullBendDirection, cullFramePosition; private Quaternion cullFrameRotation; private readonly List tileLocalBounds = new List(); private readonly List meshes = new List(); private GameObject generated; private bool rebuild; private Vector3 initialCenterOffset; private bool centerCaptured; private bool wasPlaying; private Transform capturedPlayer; private Vector3 surfaceCenter; private Material renderedMaterial; private BoxCollider groundCollider; private readonly List tiles = new List(); private readonly List tileRenderers = new List(); private Vector2 patchSize; private Vector2 tileSize; public Vector2 Size => size; public Vector3 Center => center + (centerOnPlayerAtStart ? initialCenterOffset : Vector3.zero); public Material MeadowMaterial => material; public float BoundsPadding => Application.isPlaying ? Mathf.Min(boundsPadding, MaxBendDisplacement() + 2) : boundsPadding; public uint RenderingLayers => renderingLayers; public float Radius => Mathf.Min(size.x, size.y) * 0.5f; public bool Endless => endless && Application.isPlaying; /// Endless and currently recentering on the player (see SetFollowPlayer). public bool Following => Endless && followPlayer; public Vector3 SurfaceCenter => Endless ? surfaceCenter : Center; public void ConfigureRegion(Material target) { Vector3 p = SurfaceCenter; target.SetVector("_MeadowRegion", new Vector4(p.x, p.y, p.z, circular ? Radius : 0)); target.SetMatrix("_MeadowWorldToLocal", transform.worldToLocalMatrix); target.SetFloat("_MeadowEdgeFade", Mathf.Min(edgeFade, Radius * 0.5f)); target.SetColor("_MeadowEdgeColor", edgeColor); ApplyPortalMask(target); } /// The meadow material's portal mask is the reference for every PortalB material. public void ApplyPortalMask(Material target) { if (material && material.HasProperty("_MaskID") && target.HasProperty("_MaskID")) target.SetFloat("_MaskID", material.GetFloat("_MaskID")); } /// The meadow material's bend is the reference for every PortalB material. public void ApplyBend(Material target) { if (!material) return; foreach (string property in BendProperties) if (material.HasProperty(property) && target.HasProperty(property)) target.SetFloat(property, material.GetFloat(property)); } public void SetFollowPlayer(bool follow) { followPlayer = follow; } /// Largest shader displacement inside the visible disk (the bend origin stays near its centre in Play mode). private float MaxBendDisplacement() { if (!material || !material.HasProperty("_Curvature")) return boundsPadding; // The disk plus the wrapped servers just past its edge. float reach = Radius + 8; float distance = Mathf.Max(0, reach - material.GetFloat("_StartDistance")); float horizontal = material.HasProperty("_HorizontalInfluence") ? material.GetFloat("_HorizontalInfluence") : 0; return (distance * distance + reach * reach * horizontal) * Mathf.Abs(material.GetFloat("_Curvature")); } /// True when these (unbent) world bounds cannot be seen by the camera once bent by the shader: /// outside the portal openings before the crossing, outside the camera frustum afterwards. public bool IsCulled(Bounds worldBounds, Camera camera) { if (!UpdateCulling(camera)) return false; Bounds bent = BendBounds(worldBounds); if (!throughPortals) return !GeometryUtility.TestPlanesAABB(cameraFrustum, bent); for (int i = 0; i < portalFrustaCount; i++) if (GeometryUtility.TestPlanesAABB(portalFrusta[i], bent)) return false; return true; } /// Whether this bent-bounds culling applies to this camera this frame (the main camera in Play mode). public bool Culls(Camera camera) { return UpdateCulling(camera); } private bool UpdateCulling(Camera camera) { if (frustaFrame == Time.frameCount && frustaCamera == camera) return culling; frustaFrame = Time.frameCount; frustaCamera = camera; portalFrustaCount = 0; culling = Application.isPlaying && material && material.HasProperty("_Curvature") && camera && camera == Camera.main; if (!culling) return false; // Same bend frame as the shaders: the anchor before the crossing, blending to the camera afterwards. Transform view = camera.transform; float blend = bendAnchor ? frameBlend : 1; cullFramePosition = bendAnchor ? Vector3.Lerp(bendAnchor.position, view.position, blend) : view.position; cullFrameRotation = bendAnchor ? Quaternion.Slerp(bendAnchor.rotation, view.rotation, blend) : view.rotation; cullCurvature = Mathf.Abs(material.GetFloat("_Curvature")); cullStart = material.GetFloat("_StartDistance"); cullHorizontal = material.HasProperty("_HorizontalInfluence") ? material.GetFloat("_HorizontalInfluence") : 0; float angle = material.GetFloat("_BendAngle") * Mathf.Deg2Rad; cullAxis = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); cullBendDirection = cullFrameRotation * new Vector3(Mathf.Sin(angle), -Mathf.Cos(angle), 0); throughPortals = !Following && bendAnchor; if (!throughPortals) { GeometryUtility.CalculateFrustumPlanes(camera, cameraFrustum); return true; } if (portals == null) portals = FindObjectsByType(FindObjectsSortMode.None); Vector3 eye = camera.transform.position; foreach (Portal portal in portals) { if (!portal || !portal.isActiveAndEnabled) continue; Transform t = portal.transform; // A portal opens onto PortalB along its forward axis, only for a camera in front of it. if (Vector3.Dot(t.position - eye, t.forward) <= 0) continue; if (portalFrustaCount == portalFrusta.Count) portalFrusta.Add(new Plane[5]); Plane[] planes = portalFrusta[portalFrustaCount++]; Vector3 right = t.right * (t.localScale.x * 0.5f), up = t.up * (t.localScale.y * 0.5f); Vector3 inside = t.position + (t.position - eye).normalized; Vector3 a = t.position - right - up, b = t.position - right + up, c = t.position + right + up, d = t.position + right - up; planes[0] = SidePlane(eye, a, b, inside); planes[1] = SidePlane(eye, b, c, inside); planes[2] = SidePlane(eye, c, d, inside); planes[3] = SidePlane(eye, d, a, inside); planes[4] = new Plane(t.forward, t.position); } return true; } private static Plane SidePlane(Vector3 eye, Vector3 a, Vector3 b, Vector3 inside) { var plane = new Plane(eye, a, b); if (!plane.GetSide(inside)) plane.Flip(); return plane; } /// World bounds grown by the bend (convex, so its maximum is on a corner) and the wind. private Bounds BendBounds(Bounds bounds) { bounds.Expand(1); Quaternion toFrame = Quaternion.Inverse(cullFrameRotation); Vector3 origin = cullFramePosition; float maxBend = 0; for (int i = 0; i < 8; i++) { Vector3 corner = bounds.center + Vector3.Scale(bounds.extents, new Vector3((i & 1) == 0 ? -1 : 1, (i & 2) == 0 ? -1 : 1, (i & 4) == 0 ? -1 : 1)); Vector3 local = toFrame * (corner - origin); float distance = Mathf.Max(0, local.z - cullStart); float lateral = local.x * cullAxis.x + local.y * cullAxis.y; maxBend = Mathf.Max(maxBend, (distance * distance + lateral * lateral * cullHorizontal) * cullCurvature); } Vector3 offset = cullBendDirection * maxBend; bounds.Encapsulate(bounds.min + offset); bounds.Encapsulate(bounds.max + offset); return bounds; } private void CullTiles(Camera camera) { if (camera != Camera.main) return; bool cull = Culls(camera); if (!cull && !tilesCulled) return; tilesCulled = cull; for (int i = 0; i < tileRenderers.Count; i++) { if (!tileRenderers[i].enabled) continue; Transform tile = tiles[i]; Bounds local = tileLocalBounds[i]; var world = new Bounds(tile.TransformPoint(local.center), Vector3.Scale(local.size, tile.lossyScale)); tileRenderers[i].forceRenderingOff = cull && IsCulled(world, camera); } } private void BeforeCameraRendering(ScriptableRenderContext context, Camera camera) { CullTiles(camera); if (!bendAnchor || frameBlend >= 1) { Shader.SetGlobalFloat(FrameFixedId, 0); return; } Transform view = camera.transform; Vector3 position = Vector3.Lerp(bendAnchor.position, view.position, frameBlend); Quaternion rotation = Quaternion.Slerp(bendAnchor.rotation, view.rotation, frameBlend); // Same convention as a camera view matrix: looks along -Z. Matrix4x4 frameToWorld = Matrix4x4.TRS(position, rotation, new Vector3(1, 1, -1)); Shader.SetGlobalMatrix(FrameId, frameToWorld.inverse); Shader.SetGlobalMatrix(FrameInvId, frameToWorld); Shader.SetGlobalFloat(FrameFixedId, 1); } private void LateUpdate() { frameBlend = !Following ? 0 : bendFadeDuration > 0 ? Mathf.MoveTowards(frameBlend, 1, Time.deltaTime / bendFadeDuration) : 1; surfaceCenter = Center; // Before the crossing, the disk is already centred on the entrance, so following the player does not jump. Transform focus = !Endless ? null : Following ? capturedPlayer : bendAnchor; if (focus) { Vector3 p = transform.InverseTransformPoint(focus.position); surfaceCenter.x = p.x; surfaceCenter.z = p.z; } if (groundCollider) groundCollider.center = SurfaceCenter + Vector3.down * (colliderThickness * 0.5f); if (renderedMaterial && material) { renderedMaterial.CopyPropertiesFromMaterial(material); ConfigureRegion(renderedMaterial); ApplyBend(renderedMaterial); } if (!generated) return; for (int i = 0; i < tiles.Count; i++) { Transform tile = tiles[i]; Vector3 p = tile.localPosition; if (Endless) { p.x += Mathf.Floor((SurfaceCenter.x - p.x - tileSize.x * 0.5f + patchSize.x * 0.5f) / patchSize.x) * patchSize.x; p.z += Mathf.Floor((SurfaceCenter.z - p.z - tileSize.y * 0.5f + patchSize.y * 0.5f) / patchSize.y) * patchSize.y; tile.localPosition = p; } float dx = Mathf.Max(0, Mathf.Abs(p.x + tileSize.x * 0.5f - SurfaceCenter.x) - tileSize.x * 0.5f); float dz = Mathf.Max(0, Mathf.Abs(p.z + tileSize.y * 0.5f - SurfaceCenter.z) - tileSize.y * 0.5f); tileRenderers[i].enabled = !circular || dx * dx + dz * dz <= (Radius + 0.3f) * (Radius + 0.3f); } } private void OnEnable() { rebuild = true; centerCaptured = false; initialCenterOffset = Vector3.zero; wasPlaying = Application.isPlaying; frameBlend = 0; RenderPipelineManager.beginCameraRendering += BeforeCameraRendering; } private void OnValidate() { size.x = Mathf.Clamp(size.x, 1, 128); size.y = Mathf.Clamp(size.y, 1, 128); colliderThickness = Mathf.Max(0.01f, colliderThickness); bladeHeight.x = Mathf.Clamp(bladeHeight.x, 0.05f, 2); bladeHeight.y = Mathf.Clamp(bladeHeight.y, bladeHeight.x, 3); // Defer Unity object creation until Update, outside the serialization callback. rebuild = true; } private void Update() { // Capture again when entering Play even when domain/scene reload is disabled. if (wasPlaying != Application.isPlaying) { wasPlaying = Application.isPlaying; centerCaptured = false; initialCenterOffset = Vector3.zero; rebuild = true; } if ((centerOnPlayerAtStart || endless) && !centerCaptured) CaptureInitialCenter(); if (!rebuild) return; rebuild = false; Build(); } private void OnDisable() { RenderPipelineManager.beginCameraRendering -= BeforeCameraRendering; Shader.SetGlobalFloat(FrameFixedId, 0); Clear(); } [ContextMenu("Regenerate meadow")] private void Regenerate() { rebuild = true; } [ContextMenu("Recenter on player once")] private void Recenter() { centerCaptured = false; rebuild = true; } private void CaptureInitialCenter() { Transform player = initialPlayer; if (!player) { foreach (GameObject candidate in GameObject.FindGameObjectsWithTag("Player")) if (candidate.scene == gameObject.scene) { player = candidate.transform; break; } } if (!player && Camera.main) player = Camera.main.transform; if (!player) return; capturedPlayer = player; Vector3 local = transform.InverseTransformPoint(player.position); initialCenterOffset = new Vector3(local.x - center.x, 0, local.z - center.z); surfaceCenter = Center; centerCaptured = true; rebuild = true; } private void Clear() { if (generated) { generated.SetActive(false); Release(generated); } generated = null; foreach (Mesh mesh in meshes) if (mesh) Release(mesh); meshes.Clear(); tiles.Clear(); tileRenderers.Clear(); tileLocalBounds.Clear(); tilesCulled = false; portals = null; if (renderedMaterial) Release(renderedMaterial); renderedMaterial = null; } private static void Release(Object value) { if (Application.isPlaying) Destroy(value); else DestroyImmediate(value); } private void Build() { Clear(); generated = new GameObject("Grass Ground (generated)") { hideFlags = HideFlags.DontSave, layer = gameObject.layer }; generated.transform.SetParent(transform, false); // The top face matches the undeformed ground; X/Z match the full meadow footprint. groundCollider = generated.AddComponent(); groundCollider.size = new Vector3(size.x, colliderThickness, size.y); groundCollider.center = Center + Vector3.down * (colliderThickness * 0.5f); if (!material) return; renderedMaterial = new Material(material) { name = "Meadow (circular)", hideFlags = HideFlags.DontSave }; ConfigureRegion(renderedMaterial); ApplyBend(renderedMaterial); var random = new System.Random(seed); int nx = Mathf.CeilToInt(size.x / 8f), nz = Mathf.CeilToInt(size.y / 8f); float sx = size.x / nx, sz = size.y / nz; // One extra tile on each side keeps the disk covered while tiles wrap around it. nx += 2; nz += 2; patchSize = new Vector2(nx * sx, nz * sz); tileSize = new Vector2(sx, sz); for (int z = 0; z < nz; z++) for (int x = 0; x < nx; x++) BuildTile(new Vector3(Center.x - patchSize.x / 2 + x * sx, Center.y, Center.z - patchSize.y / 2 + z * sz), sx, sz, random, x, z); } private void BuildTile(Vector3 origin, float sx, float sz, System.Random random, int tx, int tz) { var vertices = new List(); var normals = new List(); var uvs = new List(); var colors = new List(); var triangles = new List(); void Vertex(Vector3 position, Vector3 normal, Vector2 uv, Color color) { vertices.Add(position); normals.Add(normal); uvs.Add(uv); colors.Add(color); } float Rand(float a, float b) { return Mathf.Lerp(a, b, (float)random.NextDouble()); } // A subdivided ground is essential: a single quad cannot follow quadratic bending. int gx = Mathf.CeilToInt(sx * 2), gz = Mathf.CeilToInt(sz * 2); for (int z = 0; z <= gz; z++) for (int x = 0; x <= gx; x++) Vertex(new Vector3(sx * x / gx, 0, sz * z / gz), Vector3.up, Vector2.zero, new Color(0.5f, 0, 0, 0)); for (int z = 0; z < gz; z++) for (int x = 0; x < gx; x++) { int a = z * (gx + 1) + x, b = a + gx + 1; triangles.AddRange(new[] { a, b, a + 1, a + 1, b, b + 1 }); } int count = Mathf.RoundToInt(sx * sz * bladesPerSquareMetre); for (int blade = 0; blade < count; blade++) { Vector3 root = new Vector3(Rand(0, sx), 0.005f, Rand(0, sz)); float angle = Rand(0, Mathf.PI * 2), height = Rand(bladeHeight.x, bladeHeight.y); Vector3 side = new Vector3(Mathf.Cos(angle), 0, Mathf.Sin(angle)); Vector3 normal = Vector3.Cross(side, Vector3.up); Vector3 lean = normal * Rand(-0.18f, 0.18f); Color color = new Color(Rand(0, 1), Rand(0, 1), 0, 1); float width = bladeWidth * Rand(0.65f, 1.35f); int first = vertices.Count; for (int level = 0; level < 3; level++) { float t = level / 3f; Vector3 p = root + Vector3.up * height * t + lean * t * t; Vector3 half = side * width * (1 - t) * 0.5f; Vertex(p - half, normal, new Vector2(0, t), color); Vertex(p + half, normal, new Vector2(1, t), color); } Vertex(root + Vector3.up * height + lean, normal, new Vector2(0.5f, 1), color); triangles.AddRange(new[] { first, first + 2, first + 1, first + 1, first + 2, first + 3, first + 2, first + 4, first + 3, first + 3, first + 4, first + 5, first + 4, first + 6, first + 5 }); } var mesh = new Mesh { name = $"Meadow {tx},{tz}", hideFlags = HideFlags.DontSave }; if (vertices.Count > 65535) mesh.indexFormat = IndexFormat.UInt32; mesh.SetVertices(vertices); mesh.SetNormals(normals); mesh.SetUVs(0, uvs); mesh.SetColors(colors); mesh.SetTriangles(triangles, 0); mesh.RecalculateBounds(); Bounds bounds = mesh.bounds; tileLocalBounds.Add(bounds); bounds.Expand(BoundsPadding * 2); mesh.bounds = bounds; meshes.Add(mesh); var tile = new GameObject($"Meadow {tx},{tz}") { hideFlags = HideFlags.DontSave, layer = gameObject.layer }; tile.transform.SetParent(generated.transform, false); tile.transform.localPosition = origin; tile.AddComponent().sharedMesh = mesh; var renderer = tile.AddComponent(); renderer.sharedMaterial = renderedMaterial; renderer.renderingLayerMask = renderingLayers; tiles.Add(tile.transform); tileRenderers.Add(renderer); // Camera-relative bending must not be evaluated from a shadow light's view matrix. renderer.shadowCastingMode = ShadowCastingMode.Off; renderer.receiveShadows = false; renderer.allowOcclusionWhenDynamic = false; } }