XR Procedural Box
A runtime-generated 3D box built for AR/VR, later evolving into real-world XRay use cases.
This project started as a simple experiment: generating a **procedural box mesh** entirely in code at runtime. The goal was to avoid relying on prebuilt meshes and instead give full control over dimensions, normals, and UVs directly inside Unity.
While humble at first glance, this procedural box became the foundation for a much larger feature later tied to **real-world XRay use cases**. Unfortunately, those details are under NDAâbut the journey began here.
The box is created by defining vertices, triangles, and UV mappings in C#. This gives total control over the boxâs size and orientation. Beyond just a static mesh, I implemented runtime adjustmentsâallowing the box to expand, shrink, and reconfigure itself procedurally without ever touching the Unity editorâs 3D primitives.
Even at this early stage, I added surface normals and UVs so that shaders and textures would work correctly on the generated box. This ensured it could be used in more advanced visual pipelines, like XR rendering and material-based effects.
While the procedural box itself may seem small, it served as the **building block for something much bigger**âa system that eventually intersected with real-world hardware and XR-based XRay visualization. Sadly, I canât go into detail on that due to NDA restrictions, but this little procedural mesh ended up having quite a large impact.
| public class BoxOutlineFace : MonoBehaviour | |
| { | |
| MeshFilter meshFilter; | |
| Mesh mesh; | |
| bool isDirty = false; | |
| Vector3[] vertices; | |
| Vector3[] normals; | |
| Vector2[] uv; | |
| int[] triangles; | |
| private void Awake() | |
| { | |
| ValidateMesh(); | |
| } | |
| private void OnEnable() | |
| { | |
| isDirty = true; | |
| } | |
| void Update() | |
| { | |
| if (isDirty) | |
| { | |
| isDirty = false; | |
| UpdateMesh(mesh); | |
| } | |
| } | |
| void ValidateMesh() | |
| { | |
| // Attach to mesh and filter | |
| if (meshFilter == null || mesh == null) | |
| { | |
| meshFilter = GetComponent<MeshFilter>(); | |
| mesh = meshFilter.mesh = new(); | |
| } | |
| // Assign shared mesh to collider if present | |
| if (TryGetComponent(out MeshCollider collider)) | |
| collider.sharedMesh = mesh; | |
| UpdateMesh(mesh); | |
| } | |
| [Header("Sizing")] | |
| [SerializeField] float width = 1.0f; | |
| [SerializeField] float height = 1.0f; | |
| [SerializeField] float lineThickness = 0.1f; | |
| [Header("Texturing")] | |
| [SerializeField] bool createUV = true; | |
| internal void SetThickness(float thickness) | |
| { | |
| lineThickness = thickness; | |
| isDirty = true; | |
| if (!Application.isPlaying) | |
| { | |
| isDirty = false; | |
| UpdateMesh(mesh); | |
| } | |
| } | |
| internal void SetSize(float quadWidth, float quadHeight, float thickness) | |
| { | |
| width = quadWidth; | |
| height = quadHeight; | |
| lineThickness = thickness; | |
| isDirty = true; | |
| if(!Application.isPlaying) | |
| { | |
| isDirty = false; | |
| UpdateMesh(mesh); | |
| } | |
| } | |
| void UpdateMesh(Mesh mesh) | |
| { | |
| // Outer corners | |
| // (-width/2, height/2), ( width/2, height/2), | |
| // ( width/2, -height/2),(-width/2, -height/2) | |
| // Inner corners are offset inward by lineThickness. | |
| float halfWidth = width * 0.5f; | |
| float halfHeight = height * 0.5f; | |
| // Prevent inner rectangle from inverting if lineThickness is too large | |
| float innerHalfWidth = Mathf.Max(0, halfWidth - lineThickness); | |
| float innerHalfHeight = Mathf.Max(0, halfHeight - lineThickness); | |
| // Weâll store 4 corners for the outer ring and 4 for the inner ring. | |
| // Thatâs 8 vertices total if singleâsided, or 16 if doubleâsided. | |
| int ringVertexCount = 4; | |
| int totalRingCount = 1;//doubleSided ? 2 : 1; | |
| int vCount = ringVertexCount * 2 * totalRingCount; // outer + inner, possibly doubled | |
| // Each âringâ is effectively a loop of 4 corners. Connecting outer/inner forms 4 quads => 8 triangles. | |
| // Singleâsided => 8 triangles => 24 indices. Doubleâsided => double that => 48 indices. | |
| int triCount = ringVertexCount * 2 * totalRingCount; | |
| int indexCount = triCount * 3; | |
| // Create arrays | |
| if (vertices == null || vertices.Length != vCount) | |
| { | |
| vertices = new Vector3[vCount]; | |
| normals = new Vector3[vCount]; | |
| uv = new Vector2[vCount]; | |
| } | |
| if (triangles == null || triangles.Length != indexCount) | |
| { | |
| triangles = new int[indexCount]; | |
| } | |
| // Define 4 corners (outer ring) | |
| Vector3[] outerCorners = new Vector3[4]; | |
| outerCorners[0] = new Vector3(-halfWidth, halfHeight, 0f); // topâleft | |
| outerCorners[1] = new Vector3(halfWidth, halfHeight, 0f); // topâright | |
| outerCorners[2] = new Vector3(halfWidth, -halfHeight, 0f); // bottomâright | |
| outerCorners[3] = new Vector3(-halfWidth, -halfHeight, 0f); // bottomâleft | |
| // Define 4 corners (inner ring) offset inward by lineThickness | |
| Vector3[] innerCorners = new Vector3[4]; | |
| innerCorners[0] = new Vector3(-innerHalfWidth, innerHalfHeight, 0f); | |
| innerCorners[1] = new Vector3(innerHalfWidth, innerHalfHeight, 0f); | |
| innerCorners[2] = new Vector3(innerHalfWidth, -innerHalfHeight, 0f); | |
| innerCorners[3] = new Vector3(-innerHalfWidth, -innerHalfHeight, 0f); | |
| // For building UV, we can do a simple [0..1] mapping across the outer extents | |
| Func<Vector3, Vector2> getUV = (pos) => { | |
| float u = (pos.x + halfWidth) / width; | |
| float v = (pos.y + halfHeight) / height; | |
| return new Vector2(u, v); | |
| }; | |
| // Fill front vertices | |
| int frontOuterStart = 0; | |
| int frontInnerStart = 4; // ringVertexCount | |
| for (int i = 0; i < 4; i++) | |
| { | |
| vertices[frontOuterStart + i] = outerCorners[i]; | |
| vertices[frontInnerStart + i] = innerCorners[i]; | |
| normals[frontOuterStart + i] = -Vector3.forward; | |
| normals[frontInnerStart + i] = -Vector3.forward; | |
| if (createUV) | |
| { | |
| uv[frontOuterStart + i] = getUV(outerCorners[i]); | |
| uv[frontInnerStart + i] = getUV(innerCorners[i]); | |
| } | |
| } | |
| int backOuterStart = 8; // ringVertexCount * 2 | |
| int backInnerStart = 12; // ringVertexCount * 3 | |
| // Build triangles | |
| // We'll connect corners in a loop: 0->1->2->3->(wrap to0). | |
| // For each edge of outer ring, connect to inner ring => 2 triangles per edge. | |
| int triIndex = 0; | |
| Action<int, int> buildQuads = (localIndexStart, ringIndexOffset) => { | |
| // 4 corners => edges i=0..3 | |
| for (int i = 0; i < 4; i++) | |
| { | |
| int iNext = (i + 1) % 4; | |
| int outerA = localIndexStart + i; | |
| int outerB = localIndexStart + iNext; | |
| int innerA = localIndexStart + ringIndexOffset + i; | |
| int innerB = localIndexStart + ringIndexOffset + iNext; | |
| // 2 triangles per edge | |
| triangles[triIndex + 0] = outerA; | |
| triangles[triIndex + 1] = outerB; | |
| triangles[triIndex + 2] = innerB; | |
| triangles[triIndex + 3] = outerA; | |
| triangles[triIndex + 4] = innerB; | |
| triangles[triIndex + 5] = innerA; | |
| triIndex += 6; | |
| } | |
| }; | |
| // Build front side | |
| buildQuads(0, 4); | |
| // Assign mesh | |
| mesh.Clear(); | |
| mesh.vertices = vertices; | |
| mesh.normals = normals; | |
| mesh.uv = uv; | |
| mesh.triangles = triangles; | |
| mesh.RecalculateBounds(); | |
| mesh.RecalculateNormals(); | |
| } | |
| private void OnDrawGizmosSelected() | |
| { | |
| if (!Application.isPlaying) | |
| { | |
| ValidateMesh(); | |
| } | |
| } | |
| } |
| public class BoxOutlineMesh : MonoBehaviour | |
| { | |
| //Vars for Calcs and serialized refs for handles | |
| public Action OutlineUpdated;//Called when the box oulines shape is updated | |
| Vector3[] corners = new Vector3[8]; | |
| Vector3[] originalCorners = new Vector3[8]; | |
| [Header("Setup Required")] | |
| [SerializeField] BoxOutlineFace[] faces = new BoxOutlineFace[6]; | |
| [SerializeField] PinchHandle[] pinchHandles = new PinchHandle[8]; // Must be length 8 | |
| Vector3 boundsMin = Vector3.one * float.MaxValue; | |
| Vector3 boundsMax = Vector3.one * float.MinValue; | |
| [SerializeField] Vector3 size = Vector3.zero; | |
| [SerializeField, Min(0.002f)] float edgeThickness = 0.03f; | |
| void CalculateBoundsByHandles() | |
| { | |
| boundsMin = Vector3.one * float.MaxValue; | |
| boundsMax = Vector3.one * float.MinValue; | |
| //We know that pinchHandles are children of this transform | |
| //So we can just loop through them and get the min and max dimensions the box should be | |
| for (int i = 0; i < pinchHandles.Length; i++) | |
| { | |
| Transform handle = pinchHandles[i].transform; | |
| // Update boundsMin and boundsMax based on the position of the current handle | |
| Vector3 position = handle.localPosition;// - transform.position); | |
| boundsMin = Vector3.Min(boundsMin, position); | |
| boundsMax = Vector3.Max(boundsMax, position); | |
| } | |
| size = boundsMax - boundsMin; | |
| } | |
| void UpdateFaces() | |
| { | |
| // Update the corners array | |
| for (int i = 0; i < pinchHandles.Length; i++) | |
| { | |
| corners[i] = pinchHandles[i].transform.localPosition; | |
| } | |
| // Update the faces | |
| for (int i = 0; i < faces.Length; i++) | |
| { | |
| // Adjust size based on rotation | |
| Vector3 faceSize = Vector3.zero; | |
| if (i == 0 || i == 1) // Front and Back | |
| faceSize = new Vector3(size.x, size.y, 0); | |
| else if (i == 2 || i == 3) // Left and Right | |
| faceSize = new Vector3(size.z, size.y, 0); | |
| else if (i == 4 || i == 5) // Top and Bottom | |
| faceSize = new Vector3(size.x, size.z, 0); | |
| // Set size for the face | |
| faces[i].SetSize(faceSize.x, faceSize.y, edgeThickness); | |
| // Determine the rotation for each face | |
| Quaternion faceRotation = Quaternion.identity; | |
| Vector3 facePosition = Vector3.zero; | |
| switch (i) | |
| { | |
| case 0: // Front face | |
| faceRotation = transform.rotation * Quaternion.Euler(0, 0, 0); | |
| facePosition = new Vector3(0, 0, size.z / 2); | |
| break; | |
| case 1: // Back face | |
| faceRotation = transform.rotation * Quaternion.Euler(0, 180, 0); | |
| facePosition = new Vector3(0, 0, -size.z / 2); | |
| break; | |
| case 2: // Left face | |
| faceRotation = transform.rotation * Quaternion.Euler(0, -90, 0); | |
| facePosition = new Vector3(-size.x / 2, 0, 0); | |
| break; | |
| case 3: // Right face | |
| faceRotation = transform.rotation * Quaternion.Euler(0, 90, 0); | |
| facePosition = new Vector3(size.x / 2, 0, 0); | |
| break; | |
| case 4: // Top face | |
| faceRotation = transform.rotation * Quaternion.Euler(-90, 0, 0); | |
| facePosition = new Vector3(0, size.y / 2, 0); | |
| break; | |
| case 5: // Bottom face | |
| faceRotation = transform.rotation * Quaternion.Euler(90, 0, 0); | |
| facePosition = new Vector3(0, -size.y / 2, 0); | |
| break; | |
| default: | |
| Debug.LogError("Invalid face index: " + i); | |
| break; | |
| } | |
| // Apply the rotation to the face | |
| faces[i].transform.rotation = faceRotation; | |
| faces[i].transform.localPosition = facePosition; | |
| } | |
| } | |
| [SerializeField] bool debugHandles = false; | |
| private void OnDrawGizmosSelected() | |
| { | |
| Gizmos.color = Color.yellow; | |
| if (!Application.isPlaying) | |
| { | |
| if (debugHandles || size == Vector3.zero) | |
| CalculateBoundsByHandles(); | |
| UpdateFaces(); | |
| } | |
| var center = transform.position; | |
| var rot = transform.rotation; | |
| var gizmoLength = 0.085f; | |
| var leftEdge = center + (-transform.right * size.x * 0.5f); | |
| var rightEdge = center + (transform.right * size.x * 0.5f); | |
| var bottomEdge = center + (-transform.up * size.y * 0.5f); | |
| var topEdge = center + (transform.up * size.y * 0.5f); | |
| var backEdge = center + (-transform.forward * size.z * 0.5f); | |
| var frontEdge = center + (transform.forward * size.z * 0.5f); | |
| Gizmos.color = Color.red; | |
| Gizmos.DrawLine(leftEdge, rightEdge); | |
| Gizmos.DrawLine(leftEdge + (rot * Vector3.up * gizmoLength), leftEdge + (rot * Vector3.down * gizmoLength)); | |
| Gizmos.DrawLine(rightEdge + (rot * Vector3.up * gizmoLength), rightEdge + (rot * Vector3.down * gizmoLength)); | |
| Gizmos.DrawLine(leftEdge + (rot * Vector3.forward * gizmoLength), leftEdge + (rot * Vector3.back * gizmoLength)); | |
| Gizmos.DrawLine(rightEdge + (rot * Vector3.forward * gizmoLength), rightEdge + (rot * Vector3.back * gizmoLength)); | |
| Gizmos.color = Color.green; | |
| Gizmos.DrawLine(topEdge, bottomEdge); | |
| Gizmos.DrawLine(topEdge + (rot * Vector3.left * gizmoLength), topEdge + (rot * Vector3.right * gizmoLength)); | |
| Gizmos.DrawLine(topEdge + (rot * Vector3.forward * gizmoLength), topEdge + (rot * Vector3.back * gizmoLength)); | |
| Gizmos.DrawLine(bottomEdge + (rot * Vector3.left * gizmoLength), bottomEdge + (rot * Vector3.right * gizmoLength)); | |
| Gizmos.DrawLine(bottomEdge + (rot * Vector3.forward * gizmoLength), bottomEdge + (rot * Vector3.back * gizmoLength)); | |
| Gizmos.color = Color.blue; | |
| Gizmos.DrawLine(frontEdge, backEdge); | |
| Gizmos.DrawLine(frontEdge + (rot * Vector3.left * gizmoLength), frontEdge + (rot * Vector3.right * gizmoLength)); | |
| Gizmos.DrawLine(frontEdge + (rot * Vector3.up * gizmoLength), frontEdge + (rot * Vector3.down * gizmoLength)); | |
| Gizmos.DrawLine(backEdge + (rot * Vector3.left * gizmoLength), backEdge + (rot * Vector3.right * gizmoLength)); | |
| Gizmos.DrawLine(backEdge + (rot * Vector3.up * gizmoLength), backEdge + (rot * Vector3.down * gizmoLength)); | |
| } | |
| } |