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;
}
}