Polylines in screen pixels
Open the live demo · Read the source · View on GitHub
A route should not become a hairline when the camera pulls back. A polyline keeps its width in pixels, joins every segment into one band, and carries a separate colour at each point.
Step 1: Lay out the routes #
The example uses three paths with different bends and widths. Each point is a position in the scene. The palette will be repeated along each route, making the interpolation between points easy to see.
final List<List<Vector3>> routes = <List<Vector3>>[
<Vector3>[
Vector3(-3.2, 1.35, 0.0),
Vector3(-2.0, 1.75, 0.2),
Vector3(-0.8, 1.05, -0.1),
Vector3(0.4, 1.62, 0.15),
Vector3(1.7, 1.12, -0.05),
Vector3(3.2, 1.52, 0.1),
],
<Vector3>[
Vector3(-3.2, 0.25, 0.0),
Vector3(-2.2, -0.35, 0.15),
Vector3(-1.0, 0.42, -0.1),
Vector3(0.0, -0.28, 0.1),
Vector3(1.25, 0.38, -0.15),
Vector3(2.15, -0.3, 0.1),
Vector3(3.2, 0.2, 0.0),
],
<Vector3>[
Vector3(-3.2, -1.45, 0.0),
Vector3(-1.9, -0.85, -0.1),
Vector3(-0.55, -1.62, 0.15),
Vector3(0.8, -0.92, -0.1),
Vector3(2.0, -1.55, 0.12),
Vector3(3.2, -1.05, 0.0),
],
];
final List<double> widths = <double>[6, 12, 22];
final List<Vector4> palette = <Vector4>[
Vector4(0.12, 0.78, 1.0, 1.0),
Vector4(0.48, 0.3, 0.96, 1.0),
Vector4(1.0, 0.34, 0.2, 1.0),
Vector4(1.0, 0.76, 0.15, 1.0),
];
Step 2: Build each joined band #
buildPolyline writes two vertices for every point. Those vertices also carry
the previous and next positions, so the vertex shader can form the join after
the camera has moved. Adjacent segments share their corner instead of meeting
as two separate rectangles.
The widths here are 6, 12, and 22 pixels. The colour list has exactly one entry for every route point.
_meshes.clear();
for (var routeIndex = 0; routeIndex < routes.length; routeIndex++) {
final List<Vector3> route = routes[routeIndex];
_meshes.add(
buildPolyline(
route,
width: widths[routeIndex],
colours: <Vector4>[
for (var point = 0; point < route.length; point++)
palette[(point + routeIndex) % palette.length],
],
),
);
}
Step 3: Use the matching material #
Polyline geometry needs Material.polyline. Its vertex stage widens the band
against the render target, while its unlit fragment stage preserves the
per-point colours. One mesh node draws each complete route.
_materials
..clear()
..addAll(<Material>[
for (var i = 0; i < _meshes.length; i++)
Material.polyline(
name: 'route ${i + 1}',
viewportWidth: _viewportWidth,
viewportHeight: _viewportHeight,
),
]);
_lines
..clear()
..addAll(<MeshNode>[
for (var i = 0; i < _meshes.length; i++)
MeshNode(
DeviceMesh.upload(context.device, _meshes[i]),
_materials[i],
name: 'route ${i + 1}',
),
]);
Step 4: Update the viewport after a resize #
The material stores the render target width and height. If that target changes,
update polylineViewport; the mesh itself does not need to be rebuilt. The
showcase renders at 1280 by 720 pixels, so those values are used initially.
void setViewport(double width, double height) {
for (final Material material in _materials) {
final viewport = material.polylineViewport!;
viewport[0] = width;
viewport[1] = height;
}
}
Step 5: Check the result #
The page checks the two-vertices-per-point index layout, the specialised lighting model, and the viewport values. It also requires all three routes to reach the frame as separate draw calls.
final bool geometryMatches = _meshes.every(
(MeshData mesh) =>
mesh.vertexCount >= 12 &&
mesh.indexCount == (mesh.vertexCount - 2) * 3,
);
final bool viewportsMatch = _materials.every((Material material) {
final viewport = material.polylineViewport;
return material.lighting == LightingModel.polyline &&
viewport != null &&
viewport[0] == _viewportWidth &&
viewport[1] == _viewportHeight;
});
if (_lines.length != 3 ||
!geometryMatches ||
!viewportsMatch ||
frame.drawCalls < 3) {
throw StateError('the joined pixel-width routes were not drawn');
}