Mesh editing overlays
Open the live demo · Read the source · View on GitHub
A modelling overlay is part of the interface, not part of the asset. It keeps edges and handles readable, respects the surface depth, and can still reveal a gizmo that passes behind the model.
Step 1: Register the contributor #
MeshOverlay draws inside the scene pass, where the depth buffer is available.
It uses the renderer's debug-line shaders and is registered once as a pass
contributor.
_overlay = context.renderer.addContributor(
MeshOverlay(
vertexShader: context.renderer.debugLineVertexShader,
fragmentShader: context.renderer.debugLineFragmentShader,
),
);
Step 2: Draw the editable surface #
The cube is an ordinary mesh with an ordinary material. None of the selection colours or handles are baked into its geometry, so clearing the overlay returns the original surface unchanged.
final MeshNode cube = MeshNode(
DeviceMesh.upload(
context.device,
CuboidShape(size: Vector3.all(2.0)).build(),
),
Material(
name: 'editable surface',
baseColor: Vector4(0.16, 0.32, 0.52, 1.0),
roughness: 0.58,
),
name: 'editable cube',
);
final Scene scene = Scene()
..ambientColor = Vector3(0.45, 0.52, 0.68)
..ambientIntensity = 0.16
..add(cube)
..add(
LightNode(name: 'key', intensity: 3.6)
..setLocalForward(Vector3(-0.45, -0.8, -0.35)),
);
Step 3: Follow the camera #
Points and ribbons are measured in screen pixels. After the camera moves, the overlay reads its eye, right, and up directions and converts one screen pixel to a world-space size. The batches are then rebuilt without touching the cube.
void _rebuild(DemoContext context) {
final Matrix4 cameraWorld = context.camera.worldMatrix;
final values = cameraWorld.storage;
final Vector3 right = Vector3(values[0], values[1], values[2]);
final Vector3 up = Vector3(values[4], values[5], values[6]);
final double fov =
context.camera.projection.verticalFieldOfView ?? math.pi / 4;
_overlay
..clear()
..lookFrom(
eye: context.camera.readWorldPosition(),
right: right,
up: up,
pixel: 2.0 * math.tan(fov * 0.5) / _viewportHeight,
);
_writeGeometry();
}
Step 4: Fill the three batches #
Thin edges go into lines. Square vertex handles and the selected diagonal go
into handles. The two translucent triangles over the front face go into
fill. Each non-empty batch costs one draw, regardless of how much geometry it
contains.
throughGeometry writes a second copy of the purple gizmo without a depth
test. Its lower opacity shows where the handle continues behind the cube.
void _writeGeometry() {
final List<Vector3> corners = <Vector3>[
for (final double z in <double>[-1, 1])
for (final double y in <double>[-1, 1])
for (final double x in <double>[-1, 1]) Vector3(x, y, z),
];
final Vector4 edgeColour = Vector4(0.2, 0.82, 1.0, 1.0);
for (final (int a, int b) in <(int, int)>[
(0, 1),
(0, 2),
(1, 3),
(2, 3),
(4, 5),
(4, 6),
(5, 7),
(6, 7),
(0, 4),
(1, 5),
(2, 6),
(3, 7),
]) {
_overlay.edge(corners[a], corners[b], edgeColour);
}
if (_vertices) {
for (final Vector3 corner in corners) {
_overlay.point(corner, Vector4(1.0, 0.72, 0.12, 1.0), size: 11);
}
}
if (_face) {
final Vector4 selected = Vector4(0.2, 1.0, 0.45, 1.0);
_overlay
..wash(corners[4], corners[5], corners[7], selected)
..wash(corners[4], corners[7], corners[6], selected)
..ribbon(corners[4], corners[7], selected, width: 5);
}
if (_through) {
_overlay.throughGeometry(() {
_overlay
..edge(
Vector3(-1.6, 0.0, 0.0),
Vector3(1.6, 0.0, 0.0),
Vector4(0.78, 0.42, 1.0, 1.0),
)
..ribbon(
Vector3(0.0, -1.6, 0.0),
Vector3(0.0, 1.6, 0.0),
Vector4(0.78, 0.42, 1.0, 1.0),
width: 7,
)
..point(Vector3(0.0, 1.6, 0.0), Vector4(0.78, 0.42, 1.0, 1.0));
});
}
}
Step 5: Change the editing view #
The controls independently hide vertex handles, the selected face, and the through-mesh gizmo. The edge cage remains visible as the stable outline of the editable topology.
ToggleControl(
'Vertex handles',
value: () => _vertices,
onChanged: (bool value) => _vertices = value,
),
ToggleControl(
'Selected face',
value: () => _face,
onChanged: (bool value) => _face = value,
),
ToggleControl(
'Through-mesh gizmo',
value: () => _through,
onChanged: (bool value) => _through = value,
),
Step 6: Check the submitted batches #
The page checks the expected vertex counts for all three ordinary batches and requires both through-mesh batches to contain geometry. The frame must also include their draw calls alongside the cube and composite.
if (_overlay.lines.vertexCount != 24 ||
_overlay.handles.vertexCount != 54 ||
_overlay.fill.vertexCount != 6 ||
_overlay.linesThrough.isEmpty ||
_overlay.handlesThrough.isEmpty ||
frame.drawCalls < 6) {
throw StateError('the mesh overlay batches did not reach the frame');
}