XR Procedural Box | Dencho

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();
}
}
}
view raw BoxOutlineFace.cs hosted with ❀ by GitHub
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));
}
}
view raw BoxOutlineMesh.cs hosted with ❀ by GitHub

Related