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 fromEnvironment/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
materialindex into_materialsarray. - A
RoadAssetRef(GUID-based asset reference) for the road's visual and physics asset. - An
isLoopflag. - 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
}| Field | Type | Purpose |
|---|---|---|
vertex | Vector3 | Joint position |
tangents[0] | Vector3 | Incoming Bézier control point |
tangents[1] | Vector3 | Outgoing Bézier control point |
mode | ERoadMode | Tangent behavior |
offset | float | Vertical offset from terrain |
ignoreTerrain | bool | Terrain height override flag |
ERoadMode
| Mode | Behavior |
|---|---|
| FREE | Tangents are independently adjustable |
| MIRROR | Outgoing tangent mirrors the incoming tangent (smooth continuous curve) |
| CUSTOM | Fully 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
| Field | Type | Purpose |
|---|---|---|
width | float | Road width |
height | float | Road height (verts) |
depth | float | Road depth (extrusion) |
offset | float | Vertical position offset |
isConcrete | bool | Whether vehicles treat this as concrete surface |
Path Loading from Paths.dat
The load() method reads Environment/Paths.dat with version-aware deserialization:
- Read joint count (
ushort), material index, isLoop flag, road asset GUID. - For each joint: read position, tangents (version > 2), road mode, offset (version > 4), ignoreTerrain flag (version > 3).
- If version < 3: auto-generate tangents from neighbor joints.
Mesh Construction
buildMeshes Pipeline
The Road class builds its mesh in updatePoints() and buildMeshes():
Spline subdivision: Each road segment between two
RoadJointinstances 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.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.UV mapping: The U-coordinate runs along the road length, tiling the material texture. The V-coordinate runs across the road width.
Tangents and normals: Normals point upward (Y+). Tangents follow the spline direction. On looped roads, the start and end vertices are welded.
Mesh assembly: Generated vertices, triangles, UVs, and normals are assigned to a
Meshand attached to aMeshFilter+MeshRendererwith the road material.Terrain alignment: When
ignoreTerrainis 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
| Method | Purpose |
|---|---|
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
| Feature | LevelRoads | LevelGround Trees | Object Placement | WaterVolume |
|---|---|---|---|---|
| Data source | Paths.dat binary file | Procedural spawn + save | Object scene files | Editor-placed volumes |
| Editing method | Spline tool in editor | Bake + hand-place | Drag-and-drop in editor | Polygon draw tool |
| Mesh generation | Real-time in buildMeshes() | Pre-baked (FoliageTile) | Static mesh references | Runtime plane |
| Terrain alignment | Y from terrain + offset | Spawned at terrain level | Placed at world coordinates | Water surface height |
| Versioning | 5+ versions in binary format | Map version-based | Object transform data | Simple struct |
| Runtime modification | Possible (rebuild meshes) | Possible (clear + rebake) | Possible (move transforms) | Possible (change height) |
| Region culling | RegionIncrementalVisibilityTracker | Tile streaming | Object LOD groups | Distance-based |
Failure Modes and Common Mistakes
Tangent generation skipping on sharp corners — When
version < 3and 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.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.
Terrain alignment gap at steep slopes —
ignoreTerrain = falsealigns 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.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
MeshRendereris disabled for the unloaded portion, but the mesh physically extends into it — half the road disappears while the other half remains.Loop closure welding precision — When
isLoopis 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
RoadJointvertices — not Unity Splines. The tangent system (FREE/MIRROR/CUSTOM modes) is custom-implemented in theRoadclass.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 timeupdatePoints()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 separateMeshRenderercomponents. They are not terrain texture overlays.SDG docs reference
RoadMaxDistanceas a graphics setting. The documentation presentsRoadMaxDistanceas a user-configurable option. In the SDK, it is an internal constant used by theRegionIncrementalVisibilityTracker. 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
- Level Terrain Generation — Terrain height data that roads align to when
ignoreTerrain = false. - Level Foliage System — Foliage bake pipeline that removes resource trees before road-related foliage is generated.
- Level Loading System — The loading sequence steps 7-8 where roads are initialized.
- Vehicle Physics Profile — How vehicles interact with road surfaces, particularly
isConcretefriction modifiers.
