318 lines
16 KiB
C#
318 lines
16 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.Rendering;
|
|
|
|
/// <summary>Deterministic meadow, generated in edit mode and at runtime. Wind and bending run on the GPU.</summary>
|
|
[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("Extra bounds in every direction to accommodate camera-relative shader bending.")]
|
|
[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;
|
|
|
|
private readonly List<Mesh> meshes = new List<Mesh>();
|
|
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<Transform> tiles = new List<Transform>();
|
|
private readonly List<MeshRenderer> tileRenderers = new List<MeshRenderer>();
|
|
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 => boundsPadding;
|
|
public uint RenderingLayers => renderingLayers;
|
|
public float Radius => Mathf.Min(size.x, size.y) * 0.5f;
|
|
public bool Endless => endless && Application.isPlaying;
|
|
/// <summary>Endless and currently recentering on the player (see SetFollowPlayer).</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>The meadow material's portal mask is the reference for every PortalB material.</summary>
|
|
public void ApplyPortalMask(Material target)
|
|
{
|
|
if (material && material.HasProperty("_MaskID") && target.HasProperty("_MaskID"))
|
|
target.SetFloat("_MaskID", material.GetFloat("_MaskID"));
|
|
}
|
|
|
|
/// <summary>The meadow material's bend is the reference for every PortalB material.</summary>
|
|
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; }
|
|
|
|
private void BeforeCameraRendering(ScriptableRenderContext context, Camera 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();
|
|
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<BoxCollider>();
|
|
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<Vector3>();
|
|
var normals = new List<Vector3>();
|
|
var uvs = new List<Vector2>();
|
|
var colors = new List<Color>();
|
|
var triangles = new List<int>();
|
|
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;
|
|
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<MeshFilter>().sharedMesh = mesh;
|
|
var renderer = tile.AddComponent<MeshRenderer>();
|
|
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;
|
|
}
|
|
}
|