Procedural 2D road spline example image

The Core Concept

The system operates in four main stages:

  1. Store editable spline points in local space.
  2. Sample positions along the spline using Cubic Bezier evaluation.
  3. Generate road geometry by calculating perpendicular offsets at each sample.
  4. 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();
}
Procedural 2D road spline example image