Skip to content

Level Roads System

Building road networks, spline-based paths, and terrain-aligned ribbon meshes in Unturned levels requires understanding how LevelRoads loads path joint data from Paths.dat, constructs Bézier-interpolated road surfaces with per-material UV mapping, and manages region-based visibility culling. LevelRoads (643 lines at Unturned/Level/LevelRoads.cs) manages the spline-based road system in the Unturned SDK. Roads are stored as List<Road> instances, each containing a series of RoadJoint spline vertices connected by cubic Bézier curves. The system handles material loading from texture bundles, region-based segment visibility tracking, mesh building with per-road smooth shading, and path loading from Paths.dat with version-aware deserialization.

Source code location: Unturned/Level/LevelRoads.cs

Architecture Overview

LevelRoads is a MonoBehaviour-based singleton that loads road data during the level loading sequence (steps 7-8). Key architectural components:

  • List<Road>: All road instances.
  • RoadMaterial[]: Material configurations loaded from Environment/Roads.unity3d.
  • Dictionary<Vector2Int, List<MeshRenderer>> regionSegmentRenderers: Region-based visibility tracking for culling.
  • RegionIncrementalVisibilityTracker: Incremental per-frame activation.
  • RoadMaxDistance: Configurable draw distance.

Road Model

Road Class

Each Road contains:

  • A List<RoadJoint> — the spline joints (vertices with tangents).
  • A material index into _materials array.
  • A RoadAssetRef (GUID-based asset reference) for the road's visual and physics asset.
  • An isLoop flag.
  • The generated mesh via MeshFilter + MeshRenderer.

RoadJoint

csharp
public class RoadJoint
{
    public Vector3 vertex;
    public Vector3[] tangents; // [0] = incoming, [1] = outgoing
    public ERoadMode mode;     // FREE, MIRROR, or CUSTOM
    public float offset;       // Vertical offset from terrain
    public bool ignoreTerrain; // Whether terrain height is ignored
}
FieldTypePurpose
vertexVector3Joint position
tangents[0]Vector3Incoming Bézier control point
tangents[1]Vector3Outgoing Bézier control point
modeERoadModeTangent behavior
offsetfloatVertical offset from terrain
ignoreTerrainboolTerrain height override flag

ERoadMode

ModeBehavior
FREETangents are independently adjustable
MIRROROutgoing tangent mirrors the incoming tangent (smooth continuous curve)
CUSTOMFully custom tangent placement

Road Materials

Loading

Road materials are loaded from Environment/Roads.unity3d:

csharp
Bundle materialBundle = Bundles.getBundle(Level.info.path + "/Environment/Roads.unity3d", false);
Texture2D[] materialTextures = materialBundle.loadAll<Texture2D>();
_materials = new RoadMaterial[materialTextures.Length];

RoadMaterial Fields

FieldTypePurpose
widthfloatRoad width
heightfloatRoad height (verts)
depthfloatRoad depth (extrusion)
offsetfloatVertical position offset
isConcreteboolWhether vehicles treat this as concrete surface

Path Loading from Paths.dat

The load() method reads Environment/Paths.dat with version-aware deserialization:

  1. Read joint count (ushort), material index, isLoop flag, road asset GUID.
  2. For each joint: read position, tangents (version > 2), road mode, offset (version > 4), ignoreTerrain flag (version > 3).
  3. If version < 3: auto-generate tangents from neighbor joints.

Mesh Construction

buildMeshes Pipeline

The Road class builds its mesh in updatePoints() and buildMeshes():

  1. Spline subdivision: Each road segment between two RoadJoint instances is subdivided into a configurable number of steps (typically 8–16). The subdivision uses cubic Bézier interpolation with the joint tangents as control points.

  2. Vertex generation: At each subdivision point, the road's width (material.width) determines the cross-section vertices. The road surface is a flat ribbon oriented perpendicular to the spline tangent.

  3. UV mapping: The U-coordinate runs along the road length, tiling the material texture. The V-coordinate runs across the road width.

  4. Tangents and normals: Normals point upward (Y+). Tangents follow the spline direction. On looped roads, the start and end vertices are welded.

  5. Mesh assembly: Generated vertices, triangles, UVs, and normals are assigned to a Mesh and attached to a MeshFilter + MeshRenderer with the road material.

  6. Terrain alignment: When ignoreTerrain is false, the road joint's Y coordinate is adjusted to match the landscape height at that point. Road vertex heights are interpolated between joint heights.

Region-Based Segment Visibility

Segment Assignment

After mesh building, each sub-road mesh is assigned to a region:

csharp
regionSegmentRenderers = new Dictionary<Vector2Int, List<MeshRenderer>>();

Region assignment is based on the world-space bounding box of each sub-mesh segment. Segments lying on region boundaries are assigned to the region containing their midpoint.

Visibility Tracking

The RegionIncrementalVisibilityTracker controls draw distance. In the editor, roads are always visible; shouldInstantlyLoad is true for roads. At runtime, the RoadMaxDistance setting controls culling distance.

Query Methods

MethodPurpose
FindRoadByRootTransform(Transform)Find road by its root game object
getRoad(int index)Road by list index
getRoadIndex(Road)Index of a road
getRoad(Transform, out int, out int)Road containing a specific vertex or tangent transform
getRoadMaterial(Transform road)Material config for a road's transform
GatherUniqueAssets(List<RoadAsset>)Collect all unique road assets

Landscape Integration

LevelRoads hooks into the Landscape.loaded event to rebuild road meshes after the landscape terrain is available. This ensures road vertices are correctly aligned with the terrain surface when the landscape loads after the roads.

Example: Finding a Road

csharp
Road road = LevelRoads.getRoad(0);
RoadMaterial mat = LevelRoads.getRoadMaterial(road.transform);
// mat.width, mat.isConcrete, etc.

Worked Code Example: Road Manipulation

Road Centerline Extraction

csharp
using SDG.Unturned;
using System.Collections.Generic;
using UnityEngine;

public class RoadAnalyzer
{
    /// <summary>
    /// Extracts the centerline spline path of a road as a list of
    /// world-space Vector3 points at a configurable sample density.
    /// </summary>
    public static List<Vector3> SampleRoadCenterline(Road road, int samplesPerSegment)
    {
        List<Vector3> points = new List<Vector3>();
        List<RoadJoint> joints = road.joints;

        if (joints == null || joints.Count < 2)
            return points;

        for (int i = 0; i < joints.Count - 1; i++)
        {
            RoadJoint a = joints[i];
            RoadJoint b = joints[i + 1];
            Vector3 tangentA = a.tangents[1]; // outgoing
            Vector3 tangentB = b.tangents[0]; // incoming

            for (int s = 0; s < samplesPerSegment; s++)
            {
                float t = (float)s / (samplesPerSegment - 1);
                Vector3 point = CubicBezier(a.vertex, tangentA, tangentB, b.vertex, t);
                points.Add(point);
            }
        }

        // Handle loop closure
        if (road.isLoop && joints.Count > 2)
        {
            RoadJoint lastJ = joints[joints.Count - 1];
            RoadJoint firstJ = joints[0];

            for (int s = 0; s < samplesPerSegment; s++)
            {
                float t = (float)s / (samplesPerSegment - 1);
                Vector3 point = CubicBezier(
                    lastJ.vertex, lastJ.tangents[1],
                    firstJ.tangents[0], firstJ.vertex, t
                );
                points.Add(point);
            }
        }

        return points;
    }

    private static Vector3 CubicBezier(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 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;
    }
}

Dynamic Road Material Switcher

csharp
using SDG.Unturned;
using UnityEngine;

public class RoadMaterialPlugin
{
    /// <summary>
    /// Changes a road's surface material to a different material index,
    /// triggering a full mesh rebuild with the new width, height, and UV mapping.
    /// </summary>
    public static bool SwitchRoadMaterial(Road road, int newMaterialIndex)
    {
        if (road == null)
            return false;

        RoadMaterial[] materials = LevelRoads.materials;
        if (materials == null || newMaterialIndex < 0 || newMaterialIndex >= materials.Length)
            return false;

        road.material = newMaterialIndex;
        road.updatePoints();
        road.buildMeshes(null, null);

        LevelRoads.RegisterRoadRegionsDirect(road);

        return true;
    }
}

Mermaid Diagram: Road Loading and Mesh Construction

Comparison: Road Systems Across Level Features

FeatureLevelRoadsLevelGround TreesObject PlacementWaterVolume
Data sourcePaths.dat binary fileProcedural spawn + saveObject scene filesEditor-placed volumes
Editing methodSpline tool in editorBake + hand-placeDrag-and-drop in editorPolygon draw tool
Mesh generationReal-time in buildMeshes()Pre-baked (FoliageTile)Static mesh referencesRuntime plane
Terrain alignmentY from terrain + offsetSpawned at terrain levelPlaced at world coordinatesWater surface height
Versioning5+ versions in binary formatMap version-basedObject transform dataSimple struct
Runtime modificationPossible (rebuild meshes)Possible (clear + rebake)Possible (move transforms)Possible (change height)
Region cullingRegionIncrementalVisibilityTrackerTile streamingObject LOD groupsDistance-based

Failure Modes and Common Mistakes

  1. Tangent generation skipping on sharp corners — When version < 3 and tangents are auto-generated, sharp corners (>90 degrees) produce incorrect tangent vectors. The auto-generation averages neighbor directions, which creates unnatural smoothing at sharp turns. Roads appear rounded where they should be angular.

  2. UV tiling artifacts on long roads — The UV mapping uses road length for the U-coordinate. On extremely long roads (kilometers), the UV coordinate exceeds Unity's typical texture wrapping range, causing visible texture stretching or repeating patterns at unnatural scales. Road segments should be broken into shorter sections before baking.

  3. Terrain alignment gap at steep slopesignoreTerrain = false aligns joint Y to terrain height, but the road surface between joints is a straight line (Bézier interpolation of position, not height). On steep terrain, the road clips through terrain (if the terrain rises between joints) or floats above terrain (if the terrain dips). Increasing subdivision steps reduces but does not eliminate this.

  4. Region boundary ghost roads — A road segment whose bounding box midpoint falls exactly on a region boundary is assigned to one region but visually crosses into the adjacent region. If the adjacent region unloads, the road segment's MeshRenderer is disabled for the unloaded portion, but the mesh physically extends into it — half the road disappears while the other half remains.

  5. Loop closure welding precision — When isLoop is true, the first and last vertices are welded. Floating-point precision at large map coordinates can cause a visible gap at the loop closure if the vertex positions differ by more than ~0.01 units. The weld tolerance is fixed and may not catch all precision errors.

How This Field Behaves Differently from the SDG Docs

  • SDG docs describe roads as "Unity Spline" objects. Some documentation references Unity's built-in Spline package. In the SDK, roads use a custom cubic Bézier system with RoadJoint vertices — not Unity Splines. The tangent system (FREE/MIRROR/CUSTOM modes) is custom-implemented in the Road class.

  • SDG docs claim road meshes are "pre-baked" like foliage. The community resources sometimes group roads with the foliage baking system. In the SDK, roads are runtime-built meshes constructed in buildMeshes() each time updatePoints() is called. This allows dynamic road editing — changing a joint position triggers an immediate mesh rebuild.

  • SDG docs mention "road textures as terrain painting." The wiki sometimes suggests road appearance comes from painting terrain textures. In the SDK, roads are independent mesh objects with RoadMaterial-defined widths and separate MeshRenderer components. They are not terrain texture overlays.

  • SDG docs reference RoadMaxDistance as a graphics setting. The documentation presents RoadMaxDistance as a user-configurable option. In the SDK, it is an internal constant used by the RegionIncrementalVisibilityTracker. Only in the editor are roads always visible; at runtime, the culling distance is derived from the graphics quality preset.

Performance Considerations

Mesh Build Cost

buildMeshes() generates vertices, UVs, triangles, and tangents for each road. The cost scales with joint count and subdivision steps:

  • 10 joints, 8 steps: ~80 vertices, ~0.1ms build time.
  • 100 joints, 16 steps: ~1600 vertices, ~2ms build time.
  • 1000 joints (large network), 32 steps: ~32,000 vertices, ~40ms build time (noticeable hitch).

Region Culling Memory

Each visible region tracks road segment MeshRenderer instances in regionSegmentRenderers. With 500 road segments across 200 regions, the dictionary holds ~500 renderer references — ~16KB of memory.

Draw Call Count

Each road segment uses one MeshRenderer per material. With 32 materials (different road textures), a large road network may generate 32 draw calls per visible region. GPU instancing is not used for roads — each MeshRenderer is a separate draw call.

Deeper FAQ

Q: Can I add roads to a map after it's published?

Yes, but only through a level editor update. Roads are stored in Paths.dat, which is part of the level's Environment/ data. An updated level must be republished to Steam Workshop with the modified Paths.dat file. Plugins cannot add persistent roads — they can only affect the runtime road instances, which are lost on server restart.

Q: How do I make a road that bridges across two terrain levels?

Set ignoreTerrain = true on all joints of the bridge segment. This prevents terrain height from overriding the joint Y values. Then manually set the joint Y positions to the desired bridge height. The road mesh will float at the specified height regardless of terrain below it.

Q: Do vehicles recognize roads as a distinct surface type?

Vehicles check RoadMaterial.isConcrete to determine surface friction and speed modifiers. When isConcrete is true, vehicles move faster on the road surface compared to off-road terrain. This is checked through Physics.Raycast against the road's collider layer, not through the RoadMaterial reference directly.

Q: Can roads be used for non-road purposes (railways, rivers, walls)?

Yes. The RoadMaterial system is generic — the width, height, and material texture can be configured for any ribbon-like mesh. Custom asset bundles can supply railway sleeper textures, river water materials, or even wall/concrete barrier textures. The isConcrete flag should be set to false for non-road uses to avoid vehicle speed modifiers.

Q: How are road intersections handled?

Roads do not automatically handle intersections. If two roads cross, the meshes simply overlap — they clip through each other. There is no automatic junction mesh generation, traffic light placement, or intersection surface blending. Map authors manually adjust joint positions to create visually clean intersections, often by raising one road slightly above the other or connecting joints to form a junction vertex.

Cross-References

Document history