using System.Collections.Generic; using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif namespace Procedural { /// /// Builds a panel out of individual equilateral-triangle GameObjects tiled edge-to-edge. /// Each tile is its own mesh/transform; a shared driver moves every tile's transform /// independently each frame so the panel reads as a wave, rather than deforming one mesh. /// [ExecuteAlways] public class TriangleWavePanel : MonoBehaviour { [Header("Grid")] [Min(1)] public int columns = 14; [Min(1)] public int rows = 10; public float triangleSize = 1.2f; [Range(0.5f, 1f)] public float gapScale = 1f; [Header("Volume & Corner Chamfer")] public float thickness = 0.18f; [Range(0f, 0.4f)] public float cornerRadius = 0f; [Range(1, 8)] public int cornerSegments = 1; [Header("Wave")] public bool animate = true; public float waveAmplitude = 0.22f; public float waveFrequency = 0.35f; public float waveSpeed = 1.1f; [Range(0f, 20f)] public float tiltAmount = 5f; [Header("Panel Sway (whole-panel rotation)")] [Range(0f, 45f)] public float swayAmplitude = 0f; public float swaySpeed = 0.2f; [Header("Backer (fills the gaps)")] public bool showBacker = true; public float backerOffset = -0.4f; public float backerPadding = 1.5f; [Header("Materials")] public Material faceMaterial; public Material backerMaterial; Transform[] _tileTransforms; Vector3[] _tileBasePos; Quaternion _baseRotation; bool _dirty = true; void OnEnable() { _baseRotation = transform.localRotation; Rebuild(); #if UNITY_EDITOR EditorApplication.update -= EditorTick; if (!Application.isPlaying) EditorApplication.update += EditorTick; #endif } void OnDisable() { #if UNITY_EDITOR EditorApplication.update -= EditorTick; #endif } void OnValidate() { _dirty = true; } /// /// Forces an immediate rebuild. Needed after configuring fields via code (e.g. from an /// editor script right after AddComponent), since plain field writes don't route through /// OnValidate the way Inspector edits do, so _dirty never flips back on by itself. /// public void RebuildNow() { _baseRotation = transform.localRotation; Rebuild(); } #if UNITY_EDITOR void EditorTick() { if (Application.isPlaying) return; if (_dirty) Rebuild(); if (animate) AnimateTiles((float)EditorApplication.timeSinceStartup); } #endif void Update() { if (!Application.isPlaying) return; if (_dirty) Rebuild(); if (animate) AnimateTiles(Time.time); } void Rebuild() { _dirty = false; ClearChildren(); float s = triangleSize; float h = s * Mathf.Sqrt(3f) * 0.5f; Mesh upMesh = BuildRoundedTrianglePrism( new Vector2(-s * 0.5f, -h / 3f), new Vector2(s * 0.5f, -h / 3f), new Vector2(0f, 2f * h / 3f), gapScale, cornerRadius, cornerSegments, thickness, "TrianglePanel_Up"); Mesh downMesh = BuildRoundedTrianglePrism( new Vector2(-s * 0.5f, h / 3f), new Vector2(s * 0.5f, h / 3f), new Vector2(0f, -2f * h / 3f), gapScale, cornerRadius, cornerSegments, thickness, "TrianglePanel_Down"); float originX = -(columns * s) * 0.5f; float originZ = -(rows * h) * 0.5f; var transforms = new List(columns * rows * 2); var basePositions = new List(columns * rows * 2); for (int r = 0; r < rows; r++) { for (int c = 0; c < columns; c++) { float rowZ = originZ + r * h; Vector3 upPos = new Vector3(originX + c * s + s * 0.5f, 0f, rowZ + h / 3f); CreateTile($"Tile_{r}_{c}_Up", upMesh, upPos, transforms, basePositions); Vector3 downPos = new Vector3(originX + c * s + s, 0f, rowZ + 2f * h / 3f); CreateTile($"Tile_{r}_{c}_Down", downMesh, downPos, transforms, basePositions); } } _tileTransforms = transforms.ToArray(); _tileBasePos = basePositions.ToArray(); if (showBacker) BuildBacker(originX, originZ, s, h); AnimateTiles(0f); } void CreateTile(string tileName, Mesh mesh, Vector3 localPos, List transforms, List basePositions) { var go = new GameObject(tileName); go.transform.SetParent(transform, false); go.transform.localPosition = localPos; go.AddComponent().sharedMesh = mesh; var mr = go.AddComponent(); mr.sharedMaterial = faceMaterial; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; transforms.Add(go.transform); basePositions.Add(localPos); } void BuildBacker(float originX, float originZ, float s, float h) { var go = new GameObject("Backer"); go.transform.SetParent(transform, false); float width = columns * s + backerPadding * 2f; float depth = rows * h + backerPadding * 2f; float cx = originX + (columns * s) * 0.5f; float cz = originZ + (rows * h) * 0.5f; go.transform.localPosition = new Vector3(cx, backerOffset, cz); var mesh = new Mesh { name = "TrianglePanel_Backer" }; float hw = width * 0.5f; float hd = depth * 0.5f; mesh.vertices = new[] { new Vector3(-hw, 0f, -hd), new Vector3(hw, 0f, -hd), new Vector3(-hw, 0f, hd), new Vector3(hw, 0f, hd), }; mesh.triangles = new[] { 0, 2, 1, 1, 2, 3 }; mesh.normals = new[] { Vector3.up, Vector3.up, Vector3.up, Vector3.up }; mesh.RecalculateBounds(); go.AddComponent().sharedMesh = mesh; var mr = go.AddComponent(); mr.sharedMaterial = backerMaterial; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; } static Mesh BuildRoundedTrianglePrism(Vector2 a, Vector2 b, Vector2 c, float gapScale, float requestedRadius, int cornerSegments, float thickness, string meshName) { Vector2[] pts = { a * gapScale, b * gapScale, c * gapScale }; float minEdge = Mathf.Min( Vector2.Distance(pts[0], pts[1]), Mathf.Min(Vector2.Distance(pts[1], pts[2]), Vector2.Distance(pts[2], pts[0]))); float radius = Mathf.Clamp(requestedRadius, 0f, minEdge * 0.28f); List outline = RoundedTriangleOutline(pts, radius, cornerSegments); int n = outline.Count; float halfT = thickness * 0.5f; var vertices = new List(n * 4 + 2); var normals = new List(n * 4 + 2); var triangles = new List(n * 12); int topCenter = vertices.Count; vertices.Add(new Vector3(0f, halfT, 0f)); normals.Add(Vector3.up); int topStart = vertices.Count; for (int i = 0; i < n; i++) { vertices.Add(new Vector3(outline[i].x, halfT, outline[i].y)); normals.Add(Vector3.up); } for (int i = 0; i < n; i++) { triangles.Add(topCenter); triangles.Add(topStart + i); triangles.Add(topStart + (i + 1) % n); } int bottomCenter = vertices.Count; vertices.Add(new Vector3(0f, -halfT, 0f)); normals.Add(Vector3.down); int bottomStart = vertices.Count; for (int i = 0; i < n; i++) { vertices.Add(new Vector3(outline[i].x, -halfT, outline[i].y)); normals.Add(Vector3.down); } for (int i = 0; i < n; i++) { triangles.Add(bottomCenter); triangles.Add(bottomStart + (i + 1) % n); triangles.Add(bottomStart + i); } int sideTopStart = vertices.Count; for (int i = 0; i < n; i++) { Vector2 p = outline[i]; Vector3 outward = new Vector3(p.x, 0f, p.y); outward = outward.sqrMagnitude > 0.0001f ? outward.normalized : Vector3.forward; vertices.Add(new Vector3(p.x, halfT, p.y)); normals.Add(outward); } int sideBottomStart = vertices.Count; for (int i = 0; i < n; i++) { Vector2 p = outline[i]; Vector3 outward = new Vector3(p.x, 0f, p.y); outward = outward.sqrMagnitude > 0.0001f ? outward.normalized : Vector3.forward; vertices.Add(new Vector3(p.x, -halfT, p.y)); normals.Add(outward); } for (int i = 0; i < n; i++) { int next = (i + 1) % n; int t0 = sideTopStart + i; int t1 = sideTopStart + next; int b0 = sideBottomStart + i; int b1 = sideBottomStart + next; triangles.Add(t0); triangles.Add(b0); triangles.Add(t1); triangles.Add(t1); triangles.Add(b0); triangles.Add(b1); } var mesh = new Mesh { name = meshName }; mesh.SetVertices(vertices); mesh.SetNormals(normals); mesh.SetTriangles(triangles, 0); mesh.RecalculateBounds(); return mesh; } static List RoundedTriangleOutline(Vector2[] pts, float radius, int segments) { var outline = new List(); for (int k = 0; k < 3; k++) { Vector2 v = pts[k]; if (radius < 0.0001f) { outline.Add(v); continue; } Vector2 prev = pts[(k + 2) % 3]; Vector2 next = pts[(k + 1) % 3]; Vector2 dirToPrev = (prev - v).normalized; Vector2 dirToNext = (next - v).normalized; float dot = Mathf.Clamp(Vector2.Dot(dirToPrev, dirToNext), -1f, 1f); float angle = Mathf.Acos(dot); float halfAngle = angle * 0.5f; float tangentDist = radius / Mathf.Tan(halfAngle); float centerDist = radius / Mathf.Sin(halfAngle); Vector2 bisector = dirToPrev + dirToNext; bisector = bisector.sqrMagnitude > 0.0001f ? bisector.normalized : dirToPrev; Vector2 tangentOnPrevEdge = v + dirToPrev * tangentDist; Vector2 tangentOnNextEdge = v + dirToNext * tangentDist; Vector2 center = v + bisector * centerDist; Vector2 startDir = (tangentOnPrevEdge - center).normalized; Vector2 endDir = (tangentOnNextEdge - center).normalized; float cross = startDir.x * endDir.y - startDir.y * endDir.x; float sweepSign = cross >= 0f ? 1f : -1f; float sweepAngle = Mathf.Acos(Mathf.Clamp(Vector2.Dot(startDir, endDir), -1f, 1f)); int segs = Mathf.Max(1, segments); for (int seg = 0; seg <= segs; seg++) { float t = (float)seg / segs; float a = sweepSign * sweepAngle * t; float ca = Mathf.Cos(a), sa = Mathf.Sin(a); Vector2 rotated = new Vector2( startDir.x * ca - startDir.y * sa, startDir.x * sa + startDir.y * ca); outline.Add(center + rotated * radius); } } return outline; } void AnimateTiles(float time) { if (swayAmplitude > 0.001f && animate) { float sway = Mathf.Sin(time * swaySpeed) * swayAmplitude; // Rotate around the world Y axis on top of whatever base orientation the panel // was authored with (e.g. tipped vertical), rather than a local axis, so the sway // always reads as a gentle turn regardless of how the panel itself is oriented. transform.localRotation = Quaternion.AngleAxis(sway, Vector3.up) * _baseRotation; } else { transform.localRotation = _baseRotation; } if (_tileTransforms == null) return; float t = time * waveSpeed; for (int i = 0; i < _tileTransforms.Length; i++) { var tf = _tileTransforms[i]; if (tf == null) continue; var b = _tileBasePos[i]; float sum = (b.x + b.z) * waveFrequency; float diff = (b.x - b.z) * waveFrequency * 0.7f; float wave = animate ? waveAmplitude * 0.5f * (Mathf.Sin(sum + t) + Mathf.Sin(diff + t * 1.3f)) : 0f; tf.localPosition = new Vector3(b.x, b.y + wave, b.z); if (tiltAmount > 0f && animate) { float tiltX = tiltAmount * Mathf.Cos(diff + t * 1.3f); float tiltZ = tiltAmount * Mathf.Cos(sum + t); tf.localRotation = Quaternion.Euler(tiltX, 0f, -tiltZ); } else { tf.localRotation = Quaternion.identity; } } } void ClearChildren() { for (int i = transform.childCount - 1; i >= 0; i--) { var child = transform.GetChild(i).gameObject; if (Application.isPlaying) Destroy(child); else DestroyImmediate(child); } } } }