Files
Ipseity-Demo/Assets/_Content/Scripts/PortalServerGrid.cs
T
2026-09-28 15:54:36 +02:00

250 lines
11 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
/// <summary>Fixed concentric server rings, facing the initial meadow centre. Only the shader bends them.</summary>
[ExecuteAlways, DisallowMultipleComponent, RequireComponent(typeof(PortalMeadow)), DefaultExecutionOrder(200)]
public sealed class PortalServerGrid : MonoBehaviour
{
[SerializeField] private GameObject serverPrefab;
[SerializeField] private Shader curvedServerShader;
[Tooltip("X: minimum chord distance between neighbours. Y: distance between rings.")]
[SerializeField] private Vector2 spacing = new Vector2(5, 6);
[Tooltip("Empty border inside the terrain on X and Z.")]
[SerializeField] private Vector2 edgeMargin = new Vector2(1, 1);
[SerializeField, Min(1)] private float firstRingRadius = 8;
[SerializeField, Range(0, 360)] private float angleOffset;
[Tooltip("Pivot adjustment along each server's facing direction (positive = towards the centre).")]
[SerializeField] private float offsetZ;
[Tooltip("Additional height above the ground, after automatically aligning the model base.")]
[SerializeField] private float heightOffset;
[SerializeField] private Vector3 rotation = new Vector3(0, 180, 0);
[Tooltip("Multiplier applied to the prefab's original scale.")]
[SerializeField, Min(0.001f)] private float scaleMultiplier = 1;
[Tooltip("Optional entrance (e.g. the portal): no server is placed in a circle just past it, along its forward axis.")]
[SerializeField] private Transform keepClear;
[SerializeField, Min(0)] private float keepClearRadius = 5;
private PortalMeadow meadow;
private GameObject generated;
private Vector2 lastSize;
private Vector3 lastCenter;
private bool rebuild;
private readonly Dictionary<Material, Material> curvedMaterials = new Dictionary<Material, Material>();
private readonly List<Transform> servers = new List<Transform>();
private float wrapExtent;
// Distance between the centres of two repeated server clusters.
public float RepetitionPeriod => wrapExtent * 2;
private bool lastEndless;
private void OnEnable()
{
meadow = GetComponent<PortalMeadow>();
rebuild = true;
RenderPipelineManager.beginCameraRendering += BeforeCameraRendering;
}
private void BeforeCameraRendering(ScriptableRenderContext context, Camera camera)
{
// Match the terrain's current material, including changes made after Update.
if (meadow) SyncBend();
}
private void OnValidate()
{
spacing.x = Mathf.Max(0.1f, spacing.x);
spacing.y = Mathf.Max(0.1f, spacing.y);
edgeMargin.x = Mathf.Max(0, edgeMargin.x);
edgeMargin.y = Mathf.Max(0, edgeMargin.y);
scaleMultiplier = Mathf.Max(0.001f, scaleMultiplier);
firstRingRadius = Mathf.Max(1, firstRingRadius);
rebuild = true;
}
private void Update()
{
if (!meadow) meadow = GetComponent<PortalMeadow>();
if (!meadow) return;
SyncBend();
if (!rebuild && lastSize == meadow.Size && lastCenter == meadow.Center && lastEndless == meadow.Endless) return;
rebuild = false;
lastSize = meadow.Size;
lastCenter = meadow.Center;
lastEndless = meadow.Endless;
Build();
}
private void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= BeforeCameraRendering;
Clear();
}
private void LateUpdate()
{
if (!meadow || !meadow.Following || wrapExtent <= 0) return;
Vector3 centre = meadow.SurfaceCenter;
float period = wrapExtent * 2;
foreach (Transform server in servers)
{
Vector3 p = server.localPosition;
// Recycle only outside the visible disk. Nearby transforms/rotations stay fixed.
p.x += Mathf.Floor((centre.x - p.x + wrapExtent) / period) * period;
p.z += Mathf.Floor((centre.z - p.z + wrapExtent) / period) * period;
server.localPosition = p;
}
}
[ContextMenu("Regenerate server rings")]
private void Regenerate() { rebuild = true; }
private static void Release(Object value)
{
if (Application.isPlaying) Destroy(value);
else DestroyImmediate(value);
}
private void Clear()
{
if (generated) { generated.SetActive(false); Release(generated); }
generated = null;
foreach (Material material in curvedMaterials.Values) if (material) Release(material);
curvedMaterials.Clear();
servers.Clear();
}
private GameObject CreateServer()
{
var instance = Instantiate(serverPrefab, generated.transform, false);
instance.transform.localPosition = Vector3.zero;
instance.transform.localRotation = Quaternion.Euler(rotation) * serverPrefab.transform.localRotation;
instance.transform.localScale = serverPrefab.transform.localScale * scaleMultiplier;
foreach (Transform child in instance.GetComponentsInChildren<Transform>(true))
child.gameObject.hideFlags = HideFlags.DontSave;
return instance;
}
private void Build()
{
Clear();
if (!serverPrefab) return;
// Inactive during setup: avoid briefly enabling all instances at the origin.
generated = new GameObject("Server Rings (generated)") { hideFlags = HideFlags.DontSave };
generated.SetActive(false);
generated.transform.SetParent(transform, false);
var prototype = CreateServer();
if (!TryGetLocalBounds(prototype, out Bounds bounds))
{
Debug.LogWarning("Server grid: the prefab needs a Renderer to calculate its footprint.", this);
Clear();
return;
}
float footprint = new Vector2(bounds.size.x, bounds.size.z).magnitude;
wrapExtent = meadow.Radius + footprint + 1;
float pitch = Mathf.Max(spacing.x, footprint, 0.1f);
float ringStep = Mathf.Max(spacing.y, footprint, 0.1f);
float maxRadius = Mathf.Min(lastSize.x * 0.5f - edgeMargin.x,
lastSize.y * 0.5f - edgeMargin.y) - footprint * 0.5f - Mathf.Abs(offsetZ);
float startRadius = Mathf.Max(firstRingRadius, footprint * 0.5f + Mathf.Abs(offsetZ));
var poses = new List<Pose>();
// The circle touches the entrance and extends in front of it, so the player never lands in a server.
Vector3 clearCentre = keepClear
? transform.InverseTransformPoint(keepClear.position + keepClear.forward * keepClearRadius * 0.5f) : Vector3.zero;
int ring = 0;
for (float radius = startRadius; radius <= maxRadius; radius += ringStep, ring++)
{
// Use chord length, not arc length, so neighbours cannot overlap on small rings.
float placementRadius = radius - offsetZ;
int around = pitch <= 2 * placementRadius
? Mathf.Max(1, Mathf.FloorToInt(Mathf.PI / Mathf.Asin(pitch / (2 * placementRadius)))) : 1;
if (poses.Count + around > 4096)
{
Debug.LogWarning("Server rings: more than 4096 instances requested. Increase Spacing.", this);
Clear();
return;
}
for (int i = 0; i < around; i++)
{
float angle = angleOffset * Mathf.Deg2Rad + (i + (ring % 2) * 0.5f) * Mathf.PI * 2 / around;
Vector3 radial = new Vector3(Mathf.Sin(angle), 0, Mathf.Cos(angle));
Vector3 ringPoint = lastCenter + radial * radius;
if (keepClear && new Vector2(ringPoint.x - clearCentre.x, ringPoint.z - clearCentre.z).magnitude
< keepClearRadius + footprint * 0.5f) continue;
Quaternion facing = Quaternion.LookRotation(-radial, Vector3.up);
Vector3 position = lastCenter + radial * radius + facing * Vector3.forward * offsetZ;
// Bounds already include the prefab rotation, scale and user rotation correction.
position -= facing * new Vector3(bounds.center.x, bounds.min.y, bounds.center.z);
position.y += heightOffset;
poses.Add(new Pose(position, facing));
}
}
int count = 0;
foreach (Pose pose in poses)
{
GameObject server = count == 0 ? prototype : CreateServer();
server.transform.localPosition = pose.position;
server.transform.localRotation = pose.rotation * Quaternion.Euler(rotation) * serverPrefab.transform.localRotation;
server.name = $"Server {++count:000}";
ApplyCurvedMaterials(server);
servers.Add(server.transform);
}
if (count == 0) { Clear(); return; }
SyncBend();
generated.SetActive(true);
}
private void ApplyCurvedMaterials(GameObject server)
{
if (!curvedServerShader) return;
foreach (Renderer renderer in server.GetComponentsInChildren<Renderer>(true))
{
Material[] materials = renderer.sharedMaterials;
for (int i = 0; i < materials.Length; i++)
{
Material source = materials[i];
if (!source) continue;
if (!curvedMaterials.TryGetValue(source, out Material curved))
{
curved = new Material(source) { shader = curvedServerShader,
name = source.name + " (Curved)", hideFlags = HideFlags.DontSave };
curved.shaderKeywords = source.shaderKeywords;
curvedMaterials.Add(source, curved);
}
materials[i] = curved;
}
renderer.sharedMaterials = materials;
renderer.renderingLayerMask = meadow.RenderingLayers;
renderer.shadowCastingMode = ShadowCastingMode.Off;
renderer.allowOcclusionWhenDynamic = false;
Bounds expanded = renderer.localBounds;
Vector3 scale = renderer.transform.lossyScale;
float minScale = Mathf.Max(0.001f, Mathf.Min(Mathf.Abs(scale.x), Mathf.Abs(scale.y), Mathf.Abs(scale.z)));
expanded.Expand(meadow.BoundsPadding * 2 / minScale);
renderer.localBounds = expanded;
}
}
private void SyncBend()
{
if (!meadow.MeadowMaterial) return;
foreach (Material material in curvedMaterials.Values)
{
meadow.ApplyBend(material);
meadow.ConfigureRegion(material);
}
}
private bool TryGetLocalBounds(GameObject instance, out Bounds bounds)
{
bounds = default;
bool found = false;
foreach (Renderer renderer in instance.GetComponentsInChildren<Renderer>(true))
{
Bounds local = renderer.localBounds;
Matrix4x4 matrix = generated.transform.worldToLocalMatrix * renderer.transform.localToWorldMatrix;
for (int i = 0; i < 8; i++)
{
Vector3 corner = local.center + Vector3.Scale(local.extents,
new Vector3((i & 1) == 0 ? -1 : 1, (i & 2) == 0 ? -1 : 1, (i & 4) == 0 ? -1 : 1));
Vector3 point = matrix.MultiplyPoint3x4(corner);
if (!found) { bounds = new Bounds(point, Vector3.zero); found = true; }
else bounds.Encapsulate(point);
}
}
return found;
}
}