The Core Concept
The system operates in four main stages:
- Store editable spline points in local space.
- Sample positions along the spline using Cubic Bezier evaluation.
- Generate road geometry by calculating perpendicular offsets at each sample.
- Generate UV coordinates based on accumulated spline distance so textures tile seamlessly.
Step 1: Create the Road Component
using System;
using System.Collections.Generic;
using UnityEngine;
[ExecuteAlways]
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
public class RoadSpline2D : MonoBehaviour
{
}
ExecuteAlways allows updates in the Editor without entering Play mode.
RequireComponent ensures required rendering components are present.
Step 2: Define the Spline Point Data
[Serializable]
public class RoadPoint
{
public Vector2 position;
public bool useBezierToNext = true;
public Vector2 outgoingControlOffset = new Vector2(1f, 0f);
public Vector2 incomingControlOffset = new Vector2(-1f, 0f);
}
Handle offsets are stored relative to each point so moving a point carries its handles correctly.
Step 3: Add Road Settings
[SerializeField] private Sprite roadSprite;
[SerializeField] private Material materialOverride;
[SerializeField] private float roadWidth = 1f;
[SerializeField] private float tileLength = 1f;
[SerializeField] private int samplesPerSegment = 12;
[SerializeField] private float uvTileScale = 1f;
[SerializeField] private bool closedLoop;
[SerializeField] private bool rebuildInEditMode = true;
[SerializeField] private List<RoadPoint> points = new List<RoadPoint>();
private Mesh generatedMesh;
private Material internalMaterial;
Step 4: Safely Create and Reuse the Mesh
private void EnsureMesh()
{
MeshFilter meshFilter = GetComponent<MeshFilter>();
if (meshFilter == null) return;
string uniqueMeshName = "RoadMesh_" + GetInstanceID();
if (generatedMesh == null || meshFilter.sharedMesh == null || meshFilter.sharedMesh.name != uniqueMeshName)
{
generatedMesh = new Mesh();
generatedMesh.name = uniqueMeshName;
generatedMesh.MarkDynamic();
meshFilter.sharedMesh = generatedMesh;
}
}
This prevents duplicated objects from sharing and overwriting the same generated mesh.
Step 5: Sample a Cubic Bezier Curve
B(t) = (1-t)^3 P0 + 3(1-t)^2 t P1 + 3(1-t) t^2 P2 + t^3 P3
private Vector2 EvaluateBezier(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float t)
{
float u = 1f - t;
float tt = t * t;
float uu = u * u;
float uuu = uu * u;
float ttt = tt * t;
return (uuu * p0) +
(3f * uu * t * p1) +
(3f * u * tt * p2) +
(ttt * p3);
}
Step 6: Sample Every Road Segment
private List<Vector2> SampleSpline()
{
List<Vector2> sampledPoints = new List<Vector2>();
if (points.Count < 2) return sampledPoints;
int segmentCount = closedLoop ? points.Count : points.Count - 1;
for (int segment = 0; segment < segmentCount; segment++)
{
RoadPoint current = points[segment];
RoadPoint next = points[(segment + 1) % points.Count];
for (int sample = 0; sample < samplesPerSegment; sample++)
{
float t = sample / (float)samplesPerSegment;
Vector2 position;
if (current.useBezierToNext)
{
Vector2 p0 = current.position;
Vector2 p1 = current.position + current.outgoingControlOffset;
Vector2 p2 = next.position + next.incomingControlOffset;
Vector2 p3 = next.position;
position = EvaluateBezier(p0, p1, p2, p3, t);
}
else
{
position = Vector2.Lerp(current.position, next.position, t);
}
sampledPoints.Add(position);
}
}
if (!closedLoop)
{
sampledPoints.Add(points[points.Count - 1].position);
}
return sampledPoints;
}
Step 7: Calculate the Road Direction
private Vector2 CalculateDirection(List<Vector2> sampled, int index)
{
int count = sampled.Count;
if (closedLoop)
{
Vector2 prev = sampled[(index - 1 + count) % count];
Vector2 next = sampled[(index + 1) % count];
return (next - prev).normalized;
}
else
{
if (index == 0)
{
return (sampled[1] - sampled[0]).normalized;
}
if (index == count - 1)
{
return (sampled[index] - sampled[index - 1]).normalized;
}
return (sampled[index + 1] - sampled[index - 1]).normalized;
}
}
Step 8: Generate Mesh Geometry and UV Coordinates
private void BuildRoadMesh(List<Vector2> sampled)
{
List<Vector3> vertices = new List<Vector3>();
List<Vector2> uvs = new List<Vector2>();
List<int> triangles = new List<int>();
float halfWidth = roadWidth * 0.5f;
float accumulatedDistance = 0f;
for (int i = 0; i < sampled.Count; i++)
{
Vector2 centre = sampled[i];
Vector2 direction = CalculateDirection(sampled, i);
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
Vector2 left = centre + perpendicular * halfWidth;
Vector2 right = centre - perpendicular * halfWidth;
vertices.Add(new Vector3(left.x, left.y, 0f));
vertices.Add(new Vector3(right.x, right.y, 0f));
if (i > 0)
{
accumulatedDistance += Vector2.Distance(sampled[i - 1], sampled[i]);
}
float verticalUV = (accumulatedDistance / tileLength) * uvTileScale;
uvs.Add(new Vector2(0f, verticalUV));
uvs.Add(new Vector2(1f, verticalUV));
}
int segmentLoops = closedLoop ? sampled.Count : sampled.Count - 1;
for (int i = 0; i < segmentLoops; i++)
{
int currentLeft = i * 2;
int currentRight = currentLeft + 1;
int nextLeft = ((i + 1) % sampled.Count) * 2;
int nextRight = nextLeft + 1;
triangles.Add(currentLeft);
triangles.Add(nextLeft);
triangles.Add(currentRight);
triangles.Add(currentRight);
triangles.Add(nextLeft);
triangles.Add(nextRight);
}
generatedMesh.Clear();
generatedMesh.SetVertices(vertices);
generatedMesh.SetUVs(0, uvs);
generatedMesh.SetTriangles(triangles, 0);
generatedMesh.RecalculateBounds();
generatedMesh.RecalculateNormals();
}
Step 9: Leak-Free Material Management
private void UpdateMaterialAndTexture()
{
MeshRenderer meshRenderer = GetComponent<MeshRenderer>();
if (meshRenderer == null || roadSprite == null) return;
Texture2D texture = roadSprite.texture;
if (texture == null) return;
texture.wrapMode = TextureWrapMode.Repeat;
texture.filterMode = FilterMode.Point;
Material targetMat = materialOverride != null ? materialOverride : null;
if (targetMat == null)
{
if (internalMaterial == null)
{
internalMaterial = new Material(Shader.Find("Sprites/Default"));
internalMaterial.name = "InternalRoadMaterial_" + GetInstanceID();
}
internalMaterial.mainTexture = texture;
targetMat = internalMaterial;
}
if (meshRenderer.sharedMaterial != targetMat)
{
meshRenderer.sharedMaterial = targetMat;
}
}
private void CleanupResources()
{
if (internalMaterial != null)
{
if (Application.isPlaying) Destroy(internalMaterial);
else DestroyImmediate(internalMaterial);
internalMaterial = null;
}
}
private void OnDestroy()
{
CleanupResources();
}
Step 10: Rebuild Framework
public void Rebuild()
{
EnsureMesh();
if (points.Count < 2)
{
generatedMesh.Clear();
return;
}
List<Vector2> sampled = SampleSpline();
if (sampled.Count < 2)
{
generatedMesh.Clear();
return;
}
BuildRoadMesh(sampled);
UpdateMaterialAndTexture();
}
Step 11: Safely Update in Editor Mode
private void OnEnable()
{
EnsureMesh();
Rebuild();
}
private void OnValidate()
{
roadWidth = Mathf.Max(0.01f, roadWidth);
tileLength = Mathf.Max(0.01f, tileLength);
samplesPerSegment = Mathf.Clamp(samplesPerSegment, 2, 64);
uvTileScale = Mathf.Max(0.01f, uvTileScale);
if (rebuildInEditMode)
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.delayCall -= DelayRebuild;
UnityEditor.EditorApplication.delayCall += DelayRebuild;
#else
Rebuild();
#endif
}
}
private void DelayRebuild()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.delayCall -= DelayRebuild;
if (this != null) Rebuild();
#endif
}
Step 12: Add Coordinate-Safe Editing Methods
public void SetWorldPoint(int index, Vector2 worldPosition)
{
if (index < 0 || index >= points.Count) return;
Vector3 world3D = new Vector3(worldPosition.x, worldPosition.y, transform.position.z);
points[index].position = transform.InverseTransformPoint(world3D);
Rebuild();
}
public void SetOutgoingControlPoint(int index, Vector2 worldPosition)
{
if (index < 0 || index >= points.Count) return;
RoadPoint point = points[index];
Vector3 world3D = new Vector3(worldPosition.x, worldPosition.y, transform.position.z);
Vector2 localPosition = transform.InverseTransformPoint(world3D);
point.outgoingControlOffset = localPosition - point.position;
Rebuild();
}
public void SetIncomingControlPoint(int index, Vector2 worldPosition)
{
if (index < 0 || index >= points.Count) return;
RoadPoint point = points[index];
Vector3 world3D = new Vector3(worldPosition.x, worldPosition.y, transform.position.z);
Vector2 localPosition = transform.InverseTransformPoint(world3D);
point.incomingControlOffset = localPosition - point.position;
Rebuild();
}