Skip to content

World-Space Surfaces ​

Use the surface profile for roads, tracks, trails, and flat ribbons that must live in the scene as real geometry. It expands the supplied polyline on the CPU, writes positions and normals, adds only the optional attributes used by the active material, and shades the result with a standard PBR material. The surface builder consumes your point topology as given; curve fitting and longitudinal subdivision belong in the path generator.

js
import { MeshLine } from 'makio-meshline'

const road = new MeshLine()
	.lines( roadPoints )
	.lineWidth( 4 )
	.color( 0xffffff )
	.map( roadTexture )
	.surface( {
		frame: 'transport',
		up: [0, 1, 0],
		bank: progress => Math.sin( progress * Math.PI * 2 ) * 0.05,
		uvMode: 'distance',
		uvScale: 5,
		roughness: 0.9,
		metalness: 0,
	} )
	.join( { type: 'miter', limit: 3, segments: 8 } )
	.cap( { type: 'round', segments: 8 } )
	.build()

scene.add( road )

lineWidth is the full width in world units. It does not change with camera distance.

See V2 API and Demo Coverage for the complete surface demo set and the shared WebGL2 verification switch.

Surface and Screen Profiles ​

The two profiles solve different geometry problems and intentionally remain separate.

Screen profile (default)Surface profile
Best forStrokes, graphs, trails, outlinesRoads, tracks, flat ribbons
ExpansionCamera-facing in the vertex shaderCamera-independent world geometry
WidthCSS pixels or projected scene widthWorld units
ShadingLine gradients, dashes, maps, TSL hooksLit PBR surface and texture maps
Geometry frameDepends on the active cameraTransported or fixed-up frame

Calling .surface( options ) selects the surface profile. Do not use it as a pixel-perfect HUD line: use the default screen profile for that job. Screen-only dash, gradient, and billboard hook options do not define the surface material.

Surface Options ​

Pass these fields to .surface( options ) or to the surface member of either low-level constructor:

OptionTypeDefaultContract
frame'transport' | 'fixed-up''transport'Rotation-minimizing transport for arbitrary 3D paths, or projection against a preferred up vector.
upVector3, XYZ tuple, or { x, y, z }[0, 1, 0]Finite, non-zero initial/reference direction.
banknumber, per-point/per-line arrays, or callback0Rotation around the tangent in radians. The callback receives (progress, pointIndex, lineIndex).
uvMode'distance' | 'stretch''distance'Physical repeats or one normalized U span per path.
uvScalenumber1Positive world units represented by one U repeat in distance mode.
normalMapTexture | nullnullEnables tangent generation and UVs.
roughnessMapTexture | nullnullStandard PBR roughness texture; enables UVs.
metalnessMapTexture | nullnullStandard PBR metalness texture; enables UVs.
roughnessnumber0.9Base PBR roughness.
metalnessnumber0Base PBR metalness.
sideThree.js side constantFrontSideUse DoubleSide when the underside must render.

Surface construction also uses the facade's normal geometry controls:

Related optionDefaultSurface behavior
lineWidth0.3Full world-space width.
widthCallbacknullArc-length progress multiplier for the full width.
closedfalseAccepts one boolean or one value per batched line; transport frames correct a closed-loop seam.
join{ type: 'miter', limit: 4, segments: 8 }True miter, bevel, or round surface topology. 'simple' is accepted as a miter alias.
cap{ type: 'butt', segments: 8 }Butt, square, or round open-path endpoint. Ignored for closed paths.
usage / dynamicStaticDrawUsage / falseSelects generated attribute usage; dynamic: true defaults to stream usage unless usage is explicit.

frame ​

  • 'transport' (default) uses a rotation-minimizing frame. It is the stable general choice for arbitrary 3D paths and applies a distributed seam correction to closed loops.
  • 'fixed-up' projects up against each path tangent. It is the right choice for terrain roads that should stay level across their width: each row depends only on its own tangent, so editing one part of the path never rolls the rest of it. When the tangent and up become parallel, the geometry falls back to a deterministic valid frame.

up ​

The initial or fixed reference direction. It accepts a Three.js vector or an array such as [0, 1, 0]. It must be finite and non-zero.

bank ​

Rotation around the path tangent, in radians. Use a single number, one value per point, or a callback:

js
.surface( {
	bank: ( progress, pointIndex, lineIndex ) => progress * 0.1
} )

Banking rotates both the surface normal and its side direction, so lighting and geometry stay aligned. Per-point arrays address the final supplied centerline points. If bank is authored on a smaller set of controls, interpolate it while sampling the path before passing the resulting points and bank values to MeshLine.

uvMode and uvScale ​

  • uvMode: 'distance' (default) sets u = distance / uvScale. Distance is the accumulated Euclidean length of the supplied XYZ segments, so climbs and descents contribute to U. A texture repeats consistently even when points are unevenly spaced. uvScale must be greater than zero.
  • uvMode: 'stretch' maps the complete path to u = 0…1. uvScale does not change stretched UVs.

v always runs from the left edge to the right edge.

Point Density and Supplied Topology ​

Surface geometry expands one cross-road row at every supplied centerline point. It does not infer a spline, insert rows along a long segment, or smooth a sparse corner. Bevel and round joins can add local fan vertices, but they do not increase the fidelity of the centreline between input points.

Sample curved or rotating sources before calling .lines() or .setPositions(). A robust road planner should bound at least:

  • the maximum 3D edge length;
  • the curve-to-chord deviation;
  • the tangent direction change between rows; and
  • the bank change between rows.

These are geometry-quality budgets, not texture-repeat budgets. Perspective-correct UV interpolation is exact on a straight triangle, while curves, elevation changes, and rotation need enough rows to approximate their shape and frame. Distance UVs then use the resulting full 3D polyline length, so adaptive or uneven point spacing does not stretch the texture.

The Road Surface example demonstrates this workflow with exact raw-planner XYZ anchors, an iterative terrain-fit/re-plan pass, adaptive cubic and circular spans, per-point elevation and bank, level fixed-up frames, and compact miter topology.

PBR options ​

The surface material accepts:

  • roughness and metalness
  • normalMap, roughnessMap, and metalnessMap
  • side, when the underside must also render

Use the regular fluent methods for base color, diffuse map, alpha map, opacity, alpha test, transparency, and wireframe.

Width Along the Path ​

widthCallback returns a multiplier for the full lineWidth. Its progress argument is based on arc length, not point index:

js
const ribbon = new MeshLine()
	.lines( points )
	.lineWidth( 2 )
	.widthCallback( progress => 0.35 + Math.sin( progress * Math.PI ) * 0.65 )
	.surface()
	.build()

Consecutive duplicate points are compacted before the surface is built. Non-finite coordinates and paths without enough distinct points are rejected instead of producing corrupt GPU buffers.

Keep centerline coordinates near the object's local origin and place the MeshLine with its object transform. If Float32 conversion would collapse a segment or materially deform its width, surface construction throws with a rebasing message instead of emitting zero-area road triangles.

Joins, Caps, and Reversals ​

The surface builder has a compact fast path and a variable-topology path. Both consume the supplied centreline; neither resamples it:

  • Ordinary miter + butt roads stay on the two-vertices-per-row strip whenever every miter fits inside join.limit.
  • bevel emits separate incoming/outgoing outer corners and a real fill wedge.
  • round emits an outer arc. join.segments is the subdivision budget for a 180° turn; shallower corners use proportionally fewer triangles.
  • An over-limit miter bevels the outer corner instead of producing a spike; the inner edge still uses its exact offset-line intersection, independently of join.limit.
  • join.inner defaults to 'exact', which rejects intersecting inner footprints. Opt into 'clip' for continuously edited roads that should keep their centerline and trim only the infeasible inner edge.
  • Open paths support butt, square, and tessellated round caps. cap.segments is the full semicircle budget.
  • A zero-radius reversal has no unique tangent or finite miter. It is split into two independent runs and each half follows the selected cap policy, so no triangle bridges across the cusp.

Square/round cap UVs extend by physical path distance, and closed paths duplicate their texture seam before emitting seam joins. With the default inner: 'exact', the builder validates the combined inner setbacks on every segment and rejects a road whose adjacent joins would cross. Use inner: 'clip' only when preserving a dynamic centerline is more important than exact offset edges; it proportionally bounds the inner footprint while retaining the configured outer join.

Low-Level Class API ​

Most projects should use MeshLine().surface(). Advanced integrations can compose the profile directly:

js
import {
	MeshLineSurfaceGeometry,
	MeshLineSurfaceNodeMaterial,
} from 'makio-meshline'

const geometry = new MeshLineSurfaceGeometry( {
	lines: roadPoints,
	lineWidth: 4,
	surface: { frame: 'fixed-up', up: [0, 1, 0] },
	join: { type: 'round', limit: 3, segments: 8, inner: 'clip' },
	cap: { type: 'round', segments: 8 },
} )

const material = new MeshLineSurfaceNodeMaterial( {
	color: 0x303030,
	surface: { roughness: 0.9, metalness: 0 },
} )

MeshLineSurfaceGeometryOptions accepts the related geometry fields above plus these buffer controls:

OptionDefaultEffect
needsUVfalseForces uv; maps also enable it automatically.
needsTangentsfalseForces four-component tangents; surface.normalMap also enables them automatically.
needsProgressfalseAdds one arc-length progress value per emitted vertex.
needsSidefalseAdds the generated left/right side value.

MeshLineSurfaceNodeMaterial consumes color, map, alphaMap, opacity, alphaTest, transparent, wireframe, depthWrite, and depthTest plus the PBR surface options. Color defaults to white, opacity to 1, alpha test to 0, depth testing to true, and depth writing to !transparent when it is not supplied explicitly.

Class-specific members ​

The tables list the surface-specific contract. Both classes retain their inherited Three.js BufferGeometry or MeshStandardNodeMaterial APIs.

Class memberReturnsContract
new MeshLineSurfaceGeometry(options?)MeshLineSurfaceGeometryOptionally builds one or more centerlines immediately. isMeshLineSurfaceGeometry is always true.
geometry.buildLine(options?)voidMerges configuration and rebuilds when lines are present.
geometry.setLines(points, closed?)voidReplaces centerlines (and optional open/closed state) and rebuilds all derived frames, joins, attributes, indices, and bounds.
geometry.setPositions(points, updateBounding?, closed?)voidSurface alias for a complete setLines() rebuild; range updates are intentionally unavailable.
geometry.setUsage(usage, name?)voidReplaces all generated attributes, or one named attribute, with the requested usage.
geometry.dispose()voidReleases geometry buffers.
new MeshLineSurfaceNodeMaterial(options?)MeshLineSurfaceNodeMaterialOptionally configures the PBR material immediately. isMeshLineSurfaceNodeMaterial is always true.
material.buildLine(options?)voidReconfigures the supported base, alpha, depth, wireframe, and surface PBR fields.
material.setShadow(enabled)voidCompatibility method for the facade; surface shadows already use the generated geometry, so it performs no shader rewrite.
material.dispose()voidInherited Three.js material cleanup.

The geometry always exposes standard position and normal attributes. It adds uv for texture maps, tangent for normal mapping (or low-level needsTangents), and progress/side only when explicitly requested. This keeps the default road buffer compact while remaining compatible with ordinary mesh raycasting, lighting, and tooling.

See the complete Road Surface example for a generated texture and standalone Three.js setup.

The interactive Variable Topology gallery overlays wireframes for every join/cap policy and includes a split reversal case.