git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit0a56b08fada274e72e40ad519468db2e65d87dba
parentc91755c086
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:54
perf(markers): draw every marker kind as one instanced sphere

Point, vertex, selected-group, marked-group and spreadsheet-row markers
are a white marker_sphere (240 vertices) drawn over a list of instances
(cce-ui's SceneDraw::instances), where each marker was the sphere copied
to its point and the whole list uploaded every frame: 46 MB at ten
thousand points, 240 KB now. A size change re-uploads the sphere alone.
Shadow captures of the old and new builds differ in no pixel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       |  33 ++++++--
 src/app.rs      | 256 ++++++++++++++++++++++++++++++++++++--------------------
 src/geometry.rs |  57 ++++++++++---
 src/main.rs     | 119 ++++++++++++++++++--------
 src/render.rs   |  26 +++---
 src/vk_smoke.rs |   6 +-
 6 files changed, 334 insertions(+), 163 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b21ec32..3fce28d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2291,7 +2291,7 @@ wears and at Group Marker Size.
 
 - **Nothing is evaluated by a selection.** `State::spreadsheet_points` is
   where the rows' points were when the table was last filled, kept from
-  that evaluation; `rebuild_row_marker_verts` builds the markers from it,
+  that evaluation; `rebuild_row_markers` builds the markers from it,
   and runs from the press, from every refill of the table and from a
   change of Group Marker Size.
 - **The selection stands across a frame and an edit**, since the rows are
@@ -3402,9 +3402,26 @@ both got cheaper without changing what they show — the table looks each
 column up once and formats through `app::fmt4` (`{:.4}` to the
 character, `fmt4_is_format_4`), and `points_vertices` works the marker
 sphere out once and moves it to each point. A replayed frame went from
-140 ms to 24 there. What is left grows with the mesh: the markers'
-vertices (240 a point, uploaded every frame — instancing in cce-ui's
-renderer is the fix), the visualizers' copy of the scene, and the table.
+140 ms to 24 there. What is left grows with the mesh: the visualizers'
+copy of the scene, and the table.
+
+**The markers are instanced** (the same day, cce-ui's
+`SceneDraw::instances`): every kind — Show Point Markers, Show Vertex
+Markers, the selected group's, the marked groups' and the spreadsheet
+rows' — is one white `geometry::marker_sphere` (240 vertices) drawn over
+a list of instances, a marker's place and colour
+(`geometry::marker_instances`), where each marker was the sphere copied
+to its point and the whole list uploaded every frame (46 MB at ten
+thousand points; 240 KB instanced). The spheres are three meshes —
+the group markers' at Group Marker Size, shared by the selected group,
+the marked groups and the rows; the points' at Point Marker Size; the
+vertices' at `VERTEX_MARKER_SCALE` of it — re-uploaded by the flush when
+a size moves (`State::marker_sphere_radii`), so a size slider uploads 240
+vertices and builds nothing else. `State::drawn_markers` (tests) expands
+instances over their sphere as the shader does;
+`instanced_markers_draw_what_the_copied_spheres_drew` holds that to the
+old per-point copies bit for bit, and shadow captures of the old and new
+builds with point markers and with vertex markers on differ in no pixel.
 
 ### The playbar shows what is cached, and what is stale (since 2026-10-06)
 
@@ -3527,7 +3544,7 @@ number does, so all three of a vertex's overlays name one place. The
 markers are the point markers' spheres at `VERTEX_MARKER_SCALE` (0.6) of
 Point Marker Size, in the vertex green, smaller so a point's marker is not
 lost among the markers of the vertices around it; they share the point
-markers' mesh, and `rebuild_overlay_marker_verts` builds both lists, so
+markers' mesh, and `rebuild_overlay_markers` builds both lists, so
 the size slider re-sizes both without an evaluation. **A vertex's normal
 is its `N` attribute where the detail carries a Float3 one on its
 vertices, and its primitive's normal where it does not** — the normal of
@@ -3569,10 +3586,10 @@ anywhere over the open menu is swallowed rather than orbiting the scene.
 it feeds: opacity and wire thickness are draw-time, and group marker size
 re-bakes the Selected-Group
 markers — re-sized from `State::group_members`, the positions `sync_nodes`
-keeps from its evaluation, by `rebuild_group_marker_verts`; point marker
+keeps from its evaluation, by `rebuild_group_markers`; point marker
 size re-sizes the Show Point Markers overlay from the scene positions
 `rebuild_scene_geometry` keeps while it is on (`overlay_marker_points`,
-`rebuild_overlay_marker_verts`). None of it
+`rebuild_overlay_markers`). None of it
 re-evaluates the graph, which `apply_setting`'s regenerate pass would do per
 pixel of drag. `sync_nodes` also re-sizes the markers when only the size
 moved (`last_group_marker_size`), so the palette's Group
@@ -4105,7 +4122,7 @@ rather than two:
   that fails to parse reads as the defaults), so it rides the project
   file too. `State::scene_groups` is every point group of the scene as
   last built with its members' positions, kept by `rebuild_scene_geometry`
-  so the list and the markers (`rebuild_marked_group_verts`,
+  so the list and the markers (`rebuild_marked_group_markers`,
   `meshes.marked_points`) come from what is on screen and a switch
   evaluates nothing; the markers follow the geometry through a rebuild.
   A marked name the scene has no group for marks nothing and is kept, so
diff --git a/src/app.rs b/src/app.rs
index 6c1e610..cb3e91d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2928,8 +2928,20 @@ pub struct SceneMeshes {
     pub grid: cce_ui::vk::MeshId,
     pub origin: cce_ui::vk::MeshId,
     pub pivot: cce_ui::vk::MeshId,
+    /// The marker spheres the marker draws instance (`geometry::marker_sphere`,
+    /// white): the group markers' at Group Marker Size — the selected group,
+    /// the marked groups and the spreadsheet's rows share it — the points'
+    /// at Point Marker Size and the vertices' at `VERTEX_MARKER_SCALE` of
+    /// it. Re-uploaded when a size moves (`State::marker_sphere_radii`),
+    /// which is 240 vertices whatever the scene.
+    pub group_sphere: cce_ui::vk::MeshId,
+    pub point_sphere: cce_ui::vk::MeshId,
+    pub vertex_sphere: cce_ui::vk::MeshId,
     /// Selected-Group membership markers: while a Group node is selected, one
-    /// marker per vertex it tags, so the selection SHOWS the group.
+    /// marker per vertex it tags, so the selection SHOWS the group. This and
+    /// the other marker meshes below hold INSTANCES (cce-ui's
+    /// `SceneDraw::instances`) — a marker's place and colour — drawn over
+    /// their sphere.
     pub group_points: cce_ui::vk::MeshId,
     /// Markers on the points whose rows are selected in the spreadsheet.
     pub row_points: cce_ui::vk::MeshId,
@@ -2937,6 +2949,8 @@ pub struct SceneMeshes {
     pub marked_points: cce_ui::vk::MeshId,
     /// The Show Point Markers overlay.
     pub overlay_points: cce_ui::vk::MeshId,
+    /// The Show Vertex Markers overlay.
+    pub vertex_points: cce_ui::vk::MeshId,
     /// The Show Point Normals overlay (LINE_LIST whiskers).
     pub overlay_normals: cce_ui::vk::MeshId,
     /// Pull arrows (LINE_LIST): while a point-moving Attribute node is
@@ -2944,6 +2958,20 @@ pub struct SceneMeshes {
     pub pull_arrows: cce_ui::vk::MeshId,
 }
 
+/// Which markers [`State::drawn_markers`] reads.
+#[cfg(test)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MarkerKind {
+    /// The selected Group node's members.
+    Group,
+    /// The spreadsheet's selected rows.
+    Row,
+    /// The marked groups' members.
+    Marked,
+    /// Show Point Markers and Show Vertex Markers.
+    Overlay,
+}
+
 /// A left-press on the detached circular window's chrome that becomes an
 /// interactive move/resize once the pointer travels past a small threshold
 /// (so a plain click doesn't start a compositor grab).
@@ -3453,21 +3481,21 @@ pub struct State {
     pub sorted_fill_key: Option<(u64, bool, [f32; 3])>,
     /// Selected-Group membership markers: marker vertices staged CPU-side by
     /// `sync_nodes` whenever the selection is a Group node (empty otherwise),
-    /// flushed to `meshes.group_points`; `group_point_vertex_count` gates the
+    /// flushed to `meshes.group_points`; `group_point_count` gates the
     /// draw. The key — (node id, params, geometry version) — spares the
     /// re-evaluation on unrelated `sync_nodes` runs.
-    pub group_point_verts: Vec<Vertex3D>,
+    pub group_point_instances: Vec<Vertex3D>,
     /// The spreadsheet's selected rows, shown in the scene: a marker on the
     /// point each row is. `spreadsheet_points` is where the rows' points
     /// were when the table was last filled, kept so a selection stages its
     /// markers without an evaluation; the markers are staged by
-    /// `rebuild_row_marker_verts` and flushed to `meshes.row_points`.
+    /// `rebuild_row_markers` and flushed to `meshes.row_points`.
     pub spreadsheet_points: Vec<[f32; 3]>,
-    pub row_marker_verts: Vec<Vertex3D>,
+    pub row_marker_instances: Vec<Vertex3D>,
     pub row_markers_dirty: bool,
-    pub row_marker_vertex_count: u32,
+    pub row_marker_count: u32,
     pub group_points_dirty: bool,
-    pub group_point_vertex_count: u32,
+    pub group_point_count: u32,
     pub last_group_points_key: Option<(String, Vec<(String, String)>, u64)>,
     /// Pull arrows: while the params pane shows an Attribute node that moves
     /// points (`geometry::moves_points`), an arrow from where each of a
@@ -3485,9 +3513,9 @@ pub struct State {
     /// The selected Group's member positions, kept from the evaluation so
     /// the markers can be re-SIZED without re-evaluating the node — a
     /// marker size change (the viewport menu's slider, per motion of a
-    /// drag) only rebuilds the spheres (`rebuild_group_marker_verts`).
+    /// drag) only rebuilds the spheres (`rebuild_group_markers`).
     pub group_members: Vec<Vertex3D>,
-    /// The marker radius `group_point_verts` was built at.
+    /// The marker radius `group_point_instances` was built at.
     pub last_group_marker_size: f32,
     /// The point overlays on the visible scene, rebuilt with it: marker
     /// geometry for Show Point Markers, and (position, vertex index) labels
@@ -3500,18 +3528,25 @@ pub struct State {
     /// settings, and display settings belong to the view: three commands in
     /// the palette (`toggle_point_markers` / `_numbers` / `_normals`) over
     /// the flags below, persisted in `ViewportSettings` beside Show Grid.
-    pub overlay_marker_verts: Vec<Vertex3D>,
+    pub overlay_marker_instances: Vec<Vertex3D>,
     /// The scene's point positions, kept by `rebuild_scene_geometry` while
     /// Show Point Markers is on (empty otherwise), so the markers can be
     /// re-SIZED without re-evaluating the graph — the viewport menu's Point
     /// Marker Size slider does that on every motion of a drag
-    /// (`rebuild_overlay_marker_verts`).
+    /// (`rebuild_overlay_markers`).
     pub overlay_marker_points: Vec<Vertex3D>,
     /// The same for Show Vertex Markers: where each vertex's marker
     /// stands, inset from its point as its number is.
     pub overlay_vertex_marker_points: Vec<Vertex3D>,
     pub overlay_dirty: bool,
     pub overlay_point_count: u32,
+    /// The Show Vertex Markers instances, built beside the points'
+    /// (`rebuild_overlay_markers`) and flushed with them.
+    pub vertex_marker_instances: Vec<Vertex3D>,
+    pub vertex_marker_count: u32,
+    /// The radii the marker spheres were last uploaded at — group, point,
+    /// vertex — so the flush re-uploads them when a size moves.
+    pub marker_sphere_radii: Option<[f32; 3]>,
     pub overlay_number_labels: Vec<([f32; 3], u32)>,
     /// How much of each label above shows through the fill in front of its
     /// point (`geometry::point_transmittance`), worked out by the stage
@@ -3576,11 +3611,11 @@ pub struct State {
     /// positions: what the dialog lists and what the markers are built
     /// from, so a switch flipped evaluates nothing.
     pub scene_groups: Vec<(String, Vec<[f32; 3]>)>,
-    /// The marked groups' markers, staged by `rebuild_marked_group_verts`,
+    /// The marked groups' markers, staged by `rebuild_marked_group_markers`,
     /// flushed to `meshes.marked_points`.
-    pub marked_group_verts: Vec<Vertex3D>,
+    pub marked_group_instances: Vec<Vertex3D>,
     pub marked_groups_dirty: bool,
-    pub marked_group_vertex_count: u32,
+    pub marked_group_count: u32,
     /// The visible scene's own edges for the wire pass (LINE_LIST pairs),
     /// rebuilt with the scene while Show Wireframe is on and empty while it
     /// is off. Topological — see `render::scene_edge_verts`.
@@ -7201,11 +7236,11 @@ impl State {
             "wire_opacity" => self.wire_opacity = v.clamp(0.0, 1.0),
             "point_marker_size" => {
                 self.point_marker_size = v.clamp(0.005, 0.1);
-                self.rebuild_overlay_marker_verts();
+                self.rebuild_overlay_markers();
             }
             "group_marker_size" => {
                 self.group_marker_size = v.clamp(0.0, GROUP_MARKER_SIZE_MAX);
-                self.rebuild_group_marker_verts();
+                self.rebuild_group_markers();
             }
             "pull_arrow_scale" => {
                 self.pull_arrow_scale = v.clamp(0.25, 10.0);
@@ -8231,7 +8266,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             }
             self.spreadsheet_mut().set_spreadsheet_data(headers, rows);
             self.spreadsheet_points = points;
-            self.rebuild_row_marker_verts();
+            self.rebuild_row_markers();
             self.last_spreadsheet_node_name = current_name;
             self.last_spreadsheet_node_params = current_params;
             self.last_spreadsheet_read_at = read_at;
@@ -8239,11 +8274,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         if let Some(members) = group_update {
             self.group_members = members;
             self.last_group_points_key = group_key;
-            self.rebuild_group_marker_verts();
+            self.rebuild_group_markers();
         } else if (self.group_marker_size - self.last_group_marker_size).abs() > f32::EPSILON {
             // Same members, new size (the palette's Group Marker Size):
             // re-size without re-evaluating.
-            self.rebuild_group_marker_verts();
+            self.rebuild_group_markers();
         }
     }
 
@@ -8365,35 +8400,53 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         self.overlay_number_alpha = alpha;
     }
 
-    /// Build the Selected-Group marker spheres from the kept members at the
-    /// current size — the cheap half of the markers, with no evaluation, so
-    /// a size change can run it on every motion of a drag. The Highlight
-    /// bake's warm accent, so the markers and the tint read as one feature.
-    /// Build the marker overlays' spheres from the kept scene positions at
-    /// the current Point Marker Size, without the evaluation that produced
-    /// the positions: the points' in the marker colour, and the vertices'
-    /// in the vertex overlays' green at `VERTEX_MARKER_SCALE` of the size —
-    /// smaller, so that a point's marker is not lost among the markers of
-    /// the vertices around it.
-    pub(crate) fn rebuild_overlay_marker_verts(&mut self) {
-        self.overlay_marker_verts = crate::geometry::points_vertices(
+    /// Build the marker overlays' instances from the kept scene positions,
+    /// without the evaluation that produced the positions: the points' in
+    /// the marker colour, and the vertices' in the vertex overlays' green —
+    /// drawn over a sphere `VERTEX_MARKER_SCALE` the size of the points',
+    /// so that a point's marker is not lost among the markers of the
+    /// vertices around it. A SIZE change needs none of this: the instances
+    /// stand where they stood, and the flush re-uploads the sphere they are
+    /// drawn over (`marker_sphere_radii`).
+    pub(crate) fn rebuild_overlay_markers(&mut self) {
+        self.overlay_marker_instances = crate::geometry::marker_instances(
             &self.overlay_marker_points,
-            self.point_marker_size,
             cce_ui::colors::to_linear_rgb(self.point_marker_color),
         );
-        self.overlay_marker_verts.extend(crate::geometry::points_vertices(
+        self.vertex_marker_instances = crate::geometry::marker_instances(
             &self.overlay_vertex_marker_points,
-            self.point_marker_size * crate::render::VERTEX_MARKER_SCALE,
             cce_ui::colors::to_linear_rgb(crate::render::VERTEX_LABEL_COLOR.map(|c| c as f32 / 255.0)),
-        ));
+        );
         self.overlay_dirty = true;
     }
 
+    /// What the marker draws show, as vertices: each kind's instances over
+    /// its sphere at its size — what the renderer draws, expanded. For the
+    /// tests, which read a marker's size and place.
+    #[cfg(test)]
+    pub(crate) fn drawn_markers(&self, kind: MarkerKind) -> Vec<Vertex3D> {
+        use crate::geometry::{expand_instances, marker_sphere};
+        let group = || marker_sphere(self.group_marker_size);
+        match kind {
+            MarkerKind::Group => expand_instances(&group(), &self.group_point_instances),
+            MarkerKind::Row => expand_instances(&group(), &self.row_marker_instances),
+            MarkerKind::Marked => expand_instances(&group(), &self.marked_group_instances),
+            MarkerKind::Overlay => {
+                let mut out = expand_instances(&marker_sphere(self.point_marker_size), &self.overlay_marker_instances);
+                out.extend(expand_instances(
+                    &marker_sphere(self.point_marker_size * crate::render::VERTEX_MARKER_SCALE),
+                    &self.vertex_marker_instances,
+                ));
+                out
+            }
+        }
+    }
+
     /// Stage a marker on the point of every row selected in the
     /// spreadsheet, at the group markers' size and in the highlight colour
     /// the rows themselves wear. From the positions the table was filled
     /// from, so a press on a row evaluates nothing.
-    pub(crate) fn rebuild_row_marker_verts(&mut self) {
+    pub(crate) fn rebuild_row_markers(&mut self) {
         let _ = self.spreadsheet_mut().take_selection_change();
         let rows = self.spreadsheet_mut().selected_rows();
         let at: Vec<Vertex3D> = rows
@@ -8402,11 +8455,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             .map(|&position| Vertex3D { position, color: [0.0; 3] })
             .collect();
         let [r, g, b, _] = cce_ui::colors::highlight_primary_color();
-        self.row_marker_verts = if at.is_empty() {
-            Vec::new()
-        } else {
-            crate::geometry::points_vertices(&at, self.group_marker_size, cce_ui::colors::to_linear_rgb([r, g, b]))
-        };
+        self.row_marker_instances = crate::geometry::marker_instances(&at, cce_ui::colors::to_linear_rgb([r, g, b]));
         self.row_markers_dirty = true;
         self.viewport_dirty = true;
     }
@@ -8419,18 +8468,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     /// Stage a marker on every member of every marked group, at Group
     /// Marker Size in the group markers' amber, from the positions the
     /// last scene rebuild kept — a switch flipped evaluates nothing.
-    pub(crate) fn rebuild_marked_group_verts(&mut self) {
+    pub(crate) fn rebuild_marked_group_markers(&mut self) {
         let at: Vec<Vertex3D> = self
             .scene_groups
             .iter()
             .filter(|(name, _)| self.marked_groups.contains(name))
             .flat_map(|(_, members)| members.iter().map(|&position| Vertex3D { position, color: [0.0; 3] }))
             .collect();
-        self.marked_group_verts = if at.is_empty() {
-            Vec::new()
-        } else {
-            crate::geometry::points_vertices(&at, self.group_marker_size, cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]))
-        };
+        self.marked_group_instances = crate::geometry::marker_instances(&at, cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]));
         self.marked_groups_dirty = true;
         self.viewport_dirty = true;
     }
@@ -8465,23 +8510,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             self.marked_groups.retain(|g| g != group);
         }
         if on != was {
-            self.rebuild_marked_group_verts();
+            self.rebuild_marked_group_markers();
             self.save_settings();
         }
     }
 
-    pub(crate) fn rebuild_group_marker_verts(&mut self) {
+    pub(crate) fn rebuild_group_markers(&mut self) {
         // One size for every kind of marker on a group's members.
-        self.rebuild_row_marker_verts();
-        self.rebuild_marked_group_verts();
-        let size = self.group_marker_size;
-        self.group_point_verts = crate::geometry::points_vertices(
-            &self.group_members,
-            size,
-            cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]),
-        );
+        self.rebuild_row_markers();
+        self.rebuild_marked_group_markers();
+        self.group_point_instances =
+            crate::geometry::marker_instances(&self.group_members, cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]));
         self.group_points_dirty = true;
-        self.last_group_marker_size = size;
+        self.last_group_marker_size = self.group_marker_size;
     }
 
 
@@ -8915,13 +8956,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             sorted_fill_key: None,
             scene_smooth_verts: Vec::new(),
             environment: crate::environment::Environment::default(),
-            group_point_verts: Vec::new(),
+            group_point_instances: Vec::new(),
             spreadsheet_points: Vec::new(),
-            row_marker_verts: Vec::new(),
+            row_marker_instances: Vec::new(),
             row_markers_dirty: false,
-            row_marker_vertex_count: 0,
+            row_marker_count: 0,
             group_points_dirty: false,
-            group_point_vertex_count: 0,
+            group_point_count: 0,
             last_group_points_key: None,
             pull_arrow_verts: Vec::new(),
             pull_arrows_dirty: false,
@@ -8929,10 +8970,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             last_pull_arrows_key: None,
             group_members: Vec::new(),
             last_group_marker_size: 0.0,
-            overlay_marker_verts: Vec::new(),
+            overlay_marker_instances: Vec::new(),
             overlay_marker_points: Vec::new(),
             overlay_vertex_marker_points: Vec::new(),
             overlay_dirty: false,
+            vertex_marker_instances: Vec::new(),
+            vertex_marker_count: 0,
+            marker_sphere_radii: None,
             overlay_point_count: 0,
             overlay_number_labels: Vec::new(),
             overlay_number_alpha: Vec::new(),
@@ -8959,9 +9003,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             scene_attributes: Vec::new(),
             scene_base: None,
             scene_groups: Vec::new(),
-            marked_group_verts: Vec::new(),
+            marked_group_instances: Vec::new(),
             marked_groups_dirty: false,
-            marked_group_vertex_count: 0,
+            marked_group_count: 0,
             scene_edge_verts: Vec::new(),
             point_marker_size: settings.viewport.point_marker_size,
             point_marker_color: settings.viewport.point_marker_color,
@@ -12129,7 +12173,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 // A press on a row selects it, and what is
                                 // selected is marked in the scene.
                                 if i == SPREADSHEET_IDX {
-                                    self.rebuild_row_marker_verts();
+                                    self.rebuild_row_markers();
                                 }
 
                             }
@@ -13102,11 +13146,25 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             renderer.update_mesh(meshes.sphere_edges, bytemuck::cast_slice(&self.scene_edge_verts));
         }
 
+        // The spheres the markers are drawn over, at the sizes as they are.
+        let radii = [
+            self.group_marker_size,
+            self.point_marker_size,
+            self.point_marker_size * crate::render::VERTEX_MARKER_SCALE,
+        ];
+        if self.marker_sphere_radii != Some(radii) {
+            self.marker_sphere_radii = Some(radii);
+            for (mesh, r) in [meshes.group_sphere, meshes.point_sphere, meshes.vertex_sphere].into_iter().zip(radii) {
+                renderer.update_mesh(mesh, bytemuck::cast_slice(&crate::geometry::marker_sphere(r)));
+            }
+            self.viewport_dirty = true;
+        }
+
         // Selected-Group markers, staged by sync_nodes.
         if self.group_points_dirty {
             self.group_points_dirty = false;
-            renderer.update_mesh(meshes.group_points, bytemuck::cast_slice(&self.group_point_verts));
-            self.group_point_vertex_count = self.group_point_verts.len() as u32;
+            renderer.update_mesh(meshes.group_points, bytemuck::cast_slice(&self.group_point_instances));
+            self.group_point_count = self.group_point_instances.len() as u32;
             self.viewport_dirty = true;
         }
 
@@ -13114,24 +13172,26 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         // by every scene rebuild.
         if self.marked_groups_dirty {
             self.marked_groups_dirty = false;
-            renderer.update_mesh(meshes.marked_points, bytemuck::cast_slice(&self.marked_group_verts));
-            self.marked_group_vertex_count = self.marked_group_verts.len() as u32;
+            renderer.update_mesh(meshes.marked_points, bytemuck::cast_slice(&self.marked_group_instances));
+            self.marked_group_count = self.marked_group_instances.len() as u32;
             self.viewport_dirty = true;
         }
 
         // The spreadsheet's selected rows, staged by their selection.
         if self.row_markers_dirty {
             self.row_markers_dirty = false;
-            renderer.update_mesh(meshes.row_points, bytemuck::cast_slice(&self.row_marker_verts));
-            self.row_marker_vertex_count = self.row_marker_verts.len() as u32;
+            renderer.update_mesh(meshes.row_points, bytemuck::cast_slice(&self.row_marker_instances));
+            self.row_marker_count = self.row_marker_instances.len() as u32;
             self.viewport_dirty = true;
         }
 
         // The point overlays, staged by rebuild_scene_geometry.
         if self.overlay_dirty {
             self.overlay_dirty = false;
-            renderer.update_mesh(meshes.overlay_points, bytemuck::cast_slice(&self.overlay_marker_verts));
-            self.overlay_point_count = self.overlay_marker_verts.len() as u32;
+            renderer.update_mesh(meshes.overlay_points, bytemuck::cast_slice(&self.overlay_marker_instances));
+            self.overlay_point_count = self.overlay_marker_instances.len() as u32;
+            renderer.update_mesh(meshes.vertex_points, bytemuck::cast_slice(&self.vertex_marker_instances));
+            self.vertex_marker_count = self.vertex_marker_instances.len() as u32;
             renderer
                 .update_mesh(meshes.overlay_normals, bytemuck::cast_slice(&self.overlay_normal_verts));
             self.overlay_normal_count = self.overlay_normal_verts.len() as u32;
@@ -13194,10 +13254,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             grid: renderer.create_mesh(bytemuck::cast_slice(&grid_verts)),
             origin: renderer.create_mesh(bytemuck::cast_slice(&origin_verts)),
             pivot: renderer.create_mesh(bytemuck::cast_slice(&pivot_verts)),
+            group_sphere: renderer.create_mesh(&[]),
+            point_sphere: renderer.create_mesh(&[]),
+            vertex_sphere: renderer.create_mesh(&[]),
             group_points: renderer.create_mesh(&[]),
             row_points: renderer.create_mesh(&[]),
             marked_points: renderer.create_mesh(&[]),
             overlay_points: renderer.create_mesh(&[]),
+            vertex_points: renderer.create_mesh(&[]),
             overlay_normals: renderer.create_mesh(&[]),
             // Seeded with what is staged: a replacement renderer gets the
             // arrows back without waiting for the selection to change.
@@ -13205,8 +13269,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         });
         self.pull_arrow_count = self.pull_arrow_verts.len() as u32;
         // Scene geometry built during `State::new` (before the renderer
-        // existed) uploads on the first frame's flush.
+        // existed) uploads on the first frame's flush, the markers with it:
+        // the spheres at their sizes and the instances already staged.
         self.spheres_dirty = !self.rt_sphere_verts.is_empty();
+        self.marker_sphere_radii = None;
+        self.group_points_dirty = true;
+        self.marked_groups_dirty = true;
+        self.row_markers_dirty = true;
+        self.overlay_dirty = true;
         self.viewport_dirty = true;
     }
 
@@ -13460,33 +13530,37 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         // nothing re-uploads; the next entry just sorts anew.
                         self.sorted_fill_key = None;
                     }
-                    let mut draws = vec![SceneDraw { mesh: meshes.viewport_bg, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false }];
+                    let mut draws = vec![SceneDraw { mesh: meshes.viewport_bg, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: None }];
                     if self.viewport().show_grid {
-                        draws.push(SceneDraw { mesh: meshes.grid, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
+                        draws.push(SceneDraw { mesh: meshes.grid, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: None });
                     }
                     if self.viewport().show_origin {
-                        draws.push(SceneDraw { mesh: meshes.origin, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
+                        draws.push(SceneDraw { mesh: meshes.origin, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: None });
                     }
                     if self.viewport().show_camera_pivot {
-                        draws.push(SceneDraw { mesh: meshes.pivot, mvp: mvp_pivot, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
+                        draws.push(SceneDraw { mesh: meshes.pivot, mvp: mvp_pivot, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: None });
                     }
                     // Selected-Group markers: full-opacity selection feedback,
                     // deliberately outside the Render node's Opacity.
-                    if self.group_point_vertex_count > 0 {
-                        draws.push(SceneDraw { mesh: meshes.group_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
+                    if self.group_point_count > 0 {
+                        draws.push(SceneDraw { mesh: meshes.group_sphere, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: Some(meshes.group_points) });
                     }
                     // The marked groups: the same amber as a selected
                     // group's markers, and the same tier.
-                    if self.marked_group_vertex_count > 0 {
-                        draws.push(SceneDraw { mesh: meshes.marked_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
+                    if self.marked_group_count > 0 {
+                        draws.push(SceneDraw { mesh: meshes.group_sphere, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: Some(meshes.marked_points) });
                     }
                     // The spreadsheet's selected rows, while it is shown.
-                    if self.show_spreadsheet && self.row_marker_vertex_count > 0 {
-                        draws.push(SceneDraw { mesh: meshes.row_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
+                    if self.show_spreadsheet && self.row_marker_count > 0 {
+                        draws.push(SceneDraw { mesh: meshes.group_sphere, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: Some(meshes.row_points) });
                     }
-                    // Per-node meta "Point Markers", same full-opacity tier.
+                    // Show Point Markers and Show Vertex Markers, the same
+                    // full-opacity tier, each its sphere instanced.
                     if self.overlay_point_count > 0 {
-                        draws.push(SceneDraw { mesh: meshes.overlay_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
+                        draws.push(SceneDraw { mesh: meshes.point_sphere, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: Some(meshes.overlay_points) });
+                    }
+                    if self.vertex_marker_count > 0 {
+                        draws.push(SceneDraw { mesh: meshes.vertex_sphere, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: Some(meshes.vertex_points) });
                     }
                     // The page the level shows stands in the scene as an
                     // image: after the furniture and the markers, which are
@@ -13509,18 +13583,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         // width deliberately fixed (a chunky Wire Width is a
                         // wireframe styling choice, not a normals one).
                         if self.overlay_normal_count > 0 {
-                            draws.push(SceneDraw { mesh: meshes.overlay_normals, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: true });
+                            draws.push(SceneDraw { mesh: meshes.overlay_normals, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: true, instances: None });
                         }
                         // Pull arrows: selection feedback, like the group
                         // markers — full opacity, a little heavier than the
                         // whiskers.
                         if self.pull_arrow_count > 0 {
-                            draws.push(SceneDraw { mesh: meshes.pull_arrows, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 2.0, wire_base_width: 0.0, prelit: false, see_through: true });
+                            draws.push(SceneDraw { mesh: meshes.pull_arrows, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 2.0, wire_base_width: 0.0, prelit: false, see_through: true, instances: None });
                         }
                         // With wires coming, the fill is pushed back by its
                         // slope-scaled offset so the lattice reads solid.
                         let base = if self.wireframe { self.wire_width } else { 0.0 };
-                        let mut fill = Some(SceneDraw { mesh: meshes.spheres, mvp, wireframe: false, wire_tint: NO_TINT, opacity: geo_opacity, line_width: 1.0, wire_base_width: base, prelit: self.smooth_shading, see_through });
+                        let mut fill = Some(SceneDraw { mesh: meshes.spheres, mvp, wireframe: false, wire_tint: NO_TINT, opacity: geo_opacity, line_width: 1.0, wire_base_width: base, prelit: self.smooth_shading, see_through, instances: None });
                         // A see-through fill writes no depth, so wires drawn
                         // AFTER it pass everywhere and the far side's paint
                         // over the near faces. Seen through, the wires go
@@ -13550,7 +13624,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 [0.0, 0.0, 0.0, 0.0]
                             };
                             let wire_alpha = self.wire_opacity.clamp(0.0, 1.0);
-                            draws.push(SceneDraw { mesh: meshes.sphere_edges, mvp, wireframe: true, wire_tint: tint, opacity: wire_alpha, line_width: self.wire_width, wire_base_width: 0.0, prelit: false, see_through });
+                            draws.push(SceneDraw { mesh: meshes.sphere_edges, mvp, wireframe: true, wire_tint: tint, opacity: wire_alpha, line_width: self.wire_width, wire_base_width: 0.0, prelit: false, see_through, instances: None });
                         }
                         draws.extend(fill);
                     }
diff --git a/src/geometry.rs b/src/geometry.rs
index bb7b908..67d16cf 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -6541,18 +6541,20 @@ pub fn origin_vectors_vertices(scale: f32) -> Vec<Vertex3D> {
 /// sphere kernel's exactly — that winding is the one the raster pass's
 /// backface cull is known to keep.
 pub fn points_vertices(src: &[Vertex3D], size: f32, color: [f32; 3]) -> Vec<Vertex3D> {
+    expand_instances(&marker_sphere(size), &marker_instances(src, color))
+}
+
+/// The sphere every point marker is, about the origin at radius `size`, in
+/// white: the mesh a marker draw instances ([`marker_instances`], cce-ui's
+/// `SceneDraw::instances`), each instance moving it to a point and giving
+/// it its colour. 240 corners — 4 bands of 10 quads.
+pub fn marker_sphere(size: f32) -> Vec<Vertex3D> {
     let r = size.max(0.001);
     const LAT_STEPS: usize = 4;
     const LON_STEPS: usize = 10;
     let pi = std::f32::consts::PI;
-    // The sphere's corners about the origin, worked out once: every marker
-    // is the same sphere moved, and each is its centre plus these. Until
-    // 2026-10-06 each of a marker's 240 corners took its own sines and
-    // cosines, which at ten thousand points was most of a playing frame.
-    // The same arithmetic in the same order, so the corners are the same
-    // to the bit.
     let off = |theta: f32, phi: f32| [r * theta.sin() * phi.cos(), r * theta.cos(), r * theta.sin() * phi.sin()];
-    let mut unit = Vec::with_capacity(LAT_STEPS * LON_STEPS * 6);
+    let mut out = Vec::with_capacity(LAT_STEPS * LON_STEPS * 6);
     for lat in 0..LAT_STEPS {
         let theta0 = pi * lat as f32 / LAT_STEPS as f32;
         let theta1 = pi * (lat + 1) as f32 / LAT_STEPS as f32;
@@ -6571,22 +6573,51 @@ pub fn points_vertices(src: &[Vertex3D], size: f32, color: [f32; 3]) -> Vec<Vert
             // surface showed only where that far half poked out of the
             // mesh, vanishing from the views where it did not.
             // `point_markers_wind_outward` holds the sign.
-            unit.extend([p00, p11, p10, p00, p01, p11]);
+            for position in [p00, p11, p10, p00, p01, p11] {
+                out.push(Vertex3D { position, color: [1.0; 3] });
+            }
         }
     }
+    out
+}
+
+/// One marker instance per distinct point of `src` — where the marker
+/// stands and its colour — for a draw of [`marker_sphere`]. Points within
+/// a thousandth of a unit of each other share one marker. Until 2026-10-06
+/// every marker was the sphere's 240 corners copied to its point, so the
+/// markers on ten thousand points were 2.4 million vertices built and
+/// uploaded on every frame of a playing simulation; instanced, they are
+/// ten thousand of these.
+pub fn marker_instances(src: &[Vertex3D], color: [f32; 3]) -> Vec<Vertex3D> {
     let mut seen = std::collections::HashSet::with_capacity(src.len());
-    let mut out = Vec::with_capacity(src.len() * unit.len());
+    let mut out = Vec::with_capacity(src.len());
     for v in src {
         let key = (
             (v.position[0] * 1000.0).round() as i32,
             (v.position[1] * 1000.0).round() as i32,
             (v.position[2] * 1000.0).round() as i32,
         );
-        if !seen.insert(key) {
-            continue;
+        if seen.insert(key) {
+            out.push(Vertex3D { position: v.position, color });
         }
-        let [cx, cy, cz] = v.position;
-        out.extend(unit.iter().map(|o| Vertex3D { position: [cx + o[0], cy + o[1], cz + o[2]], color }));
+    }
+    out
+}
+
+/// What a renderer draws for `mesh` instanced over `instances` (cce-ui's
+/// `SceneDraw::instances`): the mesh once per instance, each instance's
+/// position added to every vertex and its colour multiplying theirs — the
+/// same arithmetic as the shader, so the same to the bit. For tests, and
+/// for reading what a marker draw shows.
+pub fn expand_instances(mesh: &[Vertex3D], instances: &[Vertex3D]) -> Vec<Vertex3D> {
+    let mut out = Vec::with_capacity(mesh.len() * instances.len());
+    for i in instances {
+        let [cx, cy, cz] = i.position;
+        let [r, g, b] = i.color;
+        out.extend(mesh.iter().map(|v| Vertex3D {
+            position: [v.position[0] + cx, v.position[1] + cy, v.position[2] + cz],
+            color: [v.color[0] * r, v.color[1] * g, v.color[2] * b],
+        }));
     }
     out
 }
diff --git a/src/main.rs b/src/main.rs
index b9fe369..d15aca3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3057,16 +3057,18 @@ mod tests {
         state.show_point_markers = false;
         state.show_vertex_markers = false;
         state.rebuild_scene_geometry();
-        assert!(state.overlay_marker_verts.is_empty());
+        let drawn = |state: &State| state.drawn_markers(crate::app::MarkerKind::Overlay);
+        assert!(drawn(&state).is_empty());
         state.run_command("toggle_vertex_markers");
-        let built = state.overlay_marker_verts.clone();
+        let built = drawn(&state);
         assert!(!built.is_empty() && !state.overlay_vertex_marker_points.is_empty());
+        assert_eq!(built.len(), state.vertex_marker_instances.len() * 240, "a sphere drawn over each instance");
         state.point_marker_size *= 2.0;
-        state.rebuild_overlay_marker_verts();
-        assert_eq!(state.overlay_marker_verts.len(), built.len());
-        assert_ne!(state.overlay_marker_verts[0].position, built[0].position, "re-sized");
+        state.rebuild_overlay_markers();
+        assert_eq!(drawn(&state).len(), built.len());
+        assert_ne!(drawn(&state)[0].position, built[0].position, "re-sized");
         state.run_command("toggle_vertex_markers");
-        assert!(state.overlay_marker_verts.is_empty(), "and gone with the switch");
+        assert!(drawn(&state).is_empty(), "and gone with the switch");
 
         // And the app collects them by its switches, and persists those.
         let mut state = State::new(false);
@@ -3423,11 +3425,11 @@ mod tests {
         };
         let label = crate::command::by_id("toggle_point_markers").unwrap().label;
         assert_eq!(row(&state), format!("○ {label}"));
-        assert!(state.overlay_marker_verts.is_empty());
+        assert!(state.overlay_marker_instances.is_empty());
         state.run_viewport_menu_action(A::Command("toggle_point_markers"));
         assert!(state.show_point_markers);
         assert_eq!(row(&state), format!("● {label}"));
-        assert!(!state.overlay_marker_verts.is_empty(), "the overlay was built");
+        assert!(!state.overlay_marker_instances.is_empty(), "the overlay was built");
         assert!(!state.overlay_marker_points.is_empty(), "and its positions kept for re-sizing");
         let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
         assert!(crate::app::DesignSettings::from_kdl_str(&kdl).viewport.show_point_markers, "persisted");
@@ -3551,12 +3553,12 @@ mod tests {
         assert!((state.point_marker_size - 0.04).abs() < 1e-5, "{}", state.point_marker_size);
         assert_eq!(state.rt_geometry_version, version, "no rebuild ran");
         assert!(state.overlay_dirty);
-        let resized: Vec<[f32; 3]> = state.overlay_marker_verts.iter().map(|v| v.position).collect();
+        let resized: Vec<[f32; 3]> = state.drawn_markers(crate::app::MarkerKind::Overlay).iter().map(|v| v.position).collect();
         context_menu::hide();
 
         // The full path at the same size draws the same spheres.
         state.rebuild_scene_geometry();
-        let rebuilt: Vec<[f32; 3]> = state.overlay_marker_verts.iter().map(|v| v.position).collect();
+        let rebuilt: Vec<[f32; 3]> = state.drawn_markers(crate::app::MarkerKind::Overlay).iter().map(|v| v.position).collect();
         assert_eq!(resized, rebuilt);
 
         // Off, nothing is kept to re-size.
@@ -3617,9 +3619,9 @@ mod tests {
         state.group_marker_size = 0.025;
         let centre = [0.0f32, 1.0, 0.0];
         state.group_members = vec![crate::geometry::Vertex3D { position: centre, color: [0.0; 3] }];
-        state.rebuild_group_marker_verts();
+        state.rebuild_group_markers();
         let radius = |state: &State| {
-            state.group_point_verts.iter().map(|v| (v.position[1] - centre[1]).abs()).fold(0.0f32, f32::max)
+            state.drawn_markers(crate::app::MarkerKind::Group).iter().map(|v| (v.position[1] - centre[1]).abs()).fold(0.0f32, f32::max)
         };
         assert!((radius(&state) - 0.025).abs() < 1e-5);
 
@@ -4984,6 +4986,56 @@ mod tests {
         assert_eq!(markers.len() % 240, 0);
     }
 
+    /// A marker draw instances one white sphere over a marker a point
+    /// (cce-ui's `SceneDraw::instances`). What that draws — each instance's
+    /// position added to the sphere, its colour multiplying the white — is
+    /// the sphere copied to every point as markers were built until
+    /// 2026-10-06, to the bit: the reference below is that code.
+    #[test]
+    fn instanced_markers_draw_what_the_copied_spheres_drew() {
+        use crate::geometry::{expand_instances, marker_instances, marker_sphere, Vertex3D};
+        let reference = |src: &[Vertex3D], size: f32, color: [f32; 3]| -> Vec<Vertex3D> {
+            let mut seen = std::collections::HashSet::new();
+            let mut out = Vec::new();
+            let r = size.max(0.001);
+            let pi = std::f32::consts::PI;
+            for v in src {
+                let key = ((v.position[0] * 1000.0).round() as i32, (v.position[1] * 1000.0).round() as i32, (v.position[2] * 1000.0).round() as i32);
+                if !seen.insert(key) {
+                    continue;
+                }
+                let [cx, cy, cz] = v.position;
+                let sp = |theta: f32, phi: f32| [cx + r * theta.sin() * phi.cos(), cy + r * theta.cos(), cz + r * theta.sin() * phi.sin()];
+                for lat in 0..4 {
+                    let (t0, t1) = (pi * lat as f32 / 4.0, pi * (lat + 1) as f32 / 4.0);
+                    for lon in 0..10 {
+                        let (p0, p1) = (2.0 * pi * lon as f32 / 10.0, 2.0 * pi * (lon + 1) as f32 / 10.0);
+                        let (p00, p10, p11, p01) = (sp(t0, p0), sp(t1, p0), sp(t1, p1), sp(t0, p1));
+                        for position in [p00, p11, p10, p00, p01, p11] {
+                            out.push(Vertex3D { position, color });
+                        }
+                    }
+                }
+            }
+            out
+        };
+        let src: Vec<Vertex3D> = (0..200)
+            .map(|i| {
+                let t = i as f32 * 0.37;
+                Vertex3D { position: [t.sin() * 3.1, (t * 0.7).cos() * -2.3 + 0.001 * (i % 3) as f32, t * 0.05 - 4.0], color: [0.0; 3] }
+            })
+            .collect();
+        for (size, color) in [(0.02f32, [1.0, 0.5, 0.0]), (0.0005, [0.2, 0.9, 0.3]), (0.137, [0.11, 0.22, 0.33])] {
+            let drawn = expand_instances(&marker_sphere(size), &marker_instances(&src, color));
+            let want = reference(&src, size, color);
+            assert_eq!(drawn.len(), want.len());
+            for (a, b) in drawn.iter().zip(&want) {
+                assert_eq!(a.position.map(f32::to_bits), b.position.map(f32::to_bits));
+                assert_eq!(a.color.map(f32::to_bits), b.color.map(f32::to_bits));
+            }
+        }
+    }
+
     /// A marker sphere winds counter-clockwise seen from OUTSIDE — the
     /// raster fill's culling convention, and what `sphere_detail` does.
     /// Until 2026-09-24 `points_vertices` kept the retired soup's inward
@@ -5415,12 +5467,13 @@ mod tests {
             crate::render::scene_point_overlays(&geom, false, false, false, 0.02, [1.0, 0.5, 0.0]);
         assert!(markers.is_empty() && labels.is_empty() && normals.is_empty());
 
-        // …and all three off the one Detail: 240 marker verts per POINT, one
-        // label per point, one whisker pair per point.
+        // …and all three off the one Detail: one marker instance per POINT
+        // (drawn over the marker sphere), one label per point, one whisker
+        // pair per point.
         let (markers, labels, normals) =
             crate::render::scene_point_overlays(&geom, true, true, true, 0.02, [1.0, 0.5, 0.0]);
         assert_eq!(labels.len(), points, "one label per point");
-        assert_eq!(markers.len(), points * 240);
+        assert_eq!(markers.len(), points);
         assert!(labels.iter().any(|(_, i)| *i > 0));
         // The marker color parameter flows into the vertices (linearized).
         let expect = cce_ui::colors::to_linear_rgb([1.0, 0.5, 0.0]);
@@ -8797,7 +8850,7 @@ mod tests {
         state.current_dir_mut().set_child_geometry_visible(tagged, true);
         state.rebuild_scene_geometry();
         assert!(state.scene_groups.iter().any(|(n, m)| n == "five" && m.len() == 5), "{:?}", state.scene_groups.iter().map(|(n, m)| (n.clone(), m.len())).collect::<Vec<_>>());
-        assert!(state.marked_group_verts.is_empty(), "nothing is marked yet");
+        assert!(state.marked_group_instances.is_empty(), "nothing is marked yet");
 
         // From the palette: the command turns it into the groups list.
         state.run_command("command_palette");
@@ -8815,12 +8868,12 @@ mod tests {
         assert!(state.dialog_visible(), "a switch is worked in place");
         assert_eq!(state.slots.dialog.rows[row].toggle(), Some(true));
         assert!(state.group_marked("five"));
-        assert!(!state.marked_group_verts.is_empty() && state.marked_groups_dirty, "the markers are staged");
-        let one = state.marked_group_verts.len();
+        assert!(!state.marked_group_instances.is_empty() && state.marked_groups_dirty, "the markers are staged");
+        let one = state.marked_group_instances.len();
         // On the group's points, at Group Marker Size.
         let members: Vec<[f32; 3]> = state.scene_groups.iter().find(|(n, _)| n == "five").unwrap().1.clone();
         for m in &members {
-            assert!(state.marked_group_verts.iter().any(|v| (0..3).all(|k| (v.position[k] - m[k]).abs() <= state.group_marker_size + 1e-4)), "a marker at {m:?}");
+            assert!(state.marked_group_instances.iter().any(|v| (0..3).all(|k| (v.position[k] - m[k]).abs() <= state.group_marker_size + 1e-4)), "a marker at {m:?}");
         }
         let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
         assert_eq!(crate::app::DesignSettings::from_kdl_str(&kdl).viewport.marked_groups, "five", "persisted");
@@ -8828,16 +8881,16 @@ mod tests {
 
         // The markers follow the geometry: a bigger sphere, farther points.
         let sphere = state.current_dir().children.iter().position(|c| c.name == "sphere1").unwrap();
-        let far = |state: &State| state.marked_group_verts.iter().map(|v| (v.position[0].powi(2) + v.position[2].powi(2)).sqrt()).fold(0.0f32, f32::max);
+        let far = |state: &State| state.marked_group_instances.iter().map(|v| (v.position[0].powi(2) + v.position[2].powi(2)).sqrt()).fold(0.0f32, f32::max);
         let before = far(&state);
         state.apply_action(McpAction::SetParam { slot: sphere, name: "radius".into(), value: "2.0".into() }, &mut redraw).unwrap();
         assert!(far(&state) > before * 1.5, "{} against {before}", far(&state));
-        assert_eq!(state.marked_group_verts.len(), one);
+        assert_eq!(state.marked_group_instances.len(), one);
 
         // Enter again unmarks; Escape closes; a query filters the names.
         state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
         assert!(!state.group_marked("five"));
-        assert!(state.marked_group_verts.is_empty());
+        assert!(state.marked_group_instances.is_empty());
         state.dialog_key_input(&typed("z"));
         assert!(state.slots.dialog.rows.is_empty(), "no group matches");
         state.dialog_key_input(&key_press(Key::Named(NamedKey::Escape)));
@@ -8845,7 +8898,7 @@ mod tests {
 
         // A marked name the scene has no group for marks nothing and is kept.
         state.set_group_marked("gone", true);
-        assert!(state.marked_group_verts.is_empty());
+        assert!(state.marked_group_instances.is_empty());
         assert!(state.group_marked("gone"));
     }
 
@@ -18755,7 +18808,7 @@ mod tests {
         state.apply_action(McpAction::Select { slot: a }, &mut redraw).unwrap();
         state.sync_nodes();
         assert_eq!(state.spreadsheet_points.len(), 8, "a box has eight points, a row each");
-        assert!(state.row_marker_verts.is_empty());
+        assert!(state.row_marker_instances.is_empty());
 
         let (sx, sy, sw, sh) = state.positions[SPREADSHEET_IDX];
         assert!(sw > 0.0 && sh > 60.0, "the spreadsheet is laid out: {sw} x {sh}");
@@ -18769,19 +18822,19 @@ mod tests {
         let version = state.rt_geometry_version;
         press(&mut state, 1);
         assert_eq!(state.selected_spreadsheet_points(), vec![1]);
-        assert!(!state.row_marker_verts.is_empty() && state.row_markers_dirty, "the marker is staged");
-        let one = state.row_marker_verts.len();
+        assert!(!state.row_marker_instances.is_empty() && state.row_markers_dirty, "the marker is staged");
+        let one = state.row_marker_instances.len();
         // The marker stands on the row's point.
         let p = state.spreadsheet_points[1];
         let n = one as f32;
-        let mid = state.row_marker_verts.iter().fold([0.0f32; 3], |m, v| [m[0] + v.position[0] / n, m[1] + v.position[1] / n, m[2] + v.position[2] / n]);
+        let mid = state.row_marker_instances.iter().fold([0.0f32; 3], |m, v| [m[0] + v.position[0] / n, m[1] + v.position[1] / n, m[2] + v.position[2] / n]);
         assert!((0..3).all(|k| (mid[k] - p[k]).abs() < 1e-3), "{mid:?} is not at {p:?}");
 
         state.modifiers.ctrl = true;
         press(&mut state, 0);
         state.modifiers.ctrl = false;
         assert_eq!(state.selected_spreadsheet_points(), vec![0, 1]);
-        assert_eq!(state.row_marker_verts.len(), 2 * one, "a marker a row");
+        assert_eq!(state.row_marker_instances.len(), 2 * one, "a marker a row");
         assert_eq!(state.rt_geometry_version, version, "selecting evaluates nothing");
 
         // A refresh of the same node's table keeps it.
@@ -18789,7 +18842,7 @@ mod tests {
         state.sync_nodes();
         assert_eq!(state.selected_spreadsheet_points(), vec![0, 1]);
         let moved = state.spreadsheet_points[1];
-        let mid = state.row_marker_verts[one..].iter().chain(&state.row_marker_verts[..one]).fold([0.0f32; 3], |m, v| [m[0] + v.position[0], m[1] + v.position[1], m[2] + v.position[2]]);
+        let mid = state.row_marker_instances[one..].iter().chain(&state.row_marker_instances[..one]).fold([0.0f32; 3], |m, v| [m[0] + v.position[0], m[1] + v.position[1], m[2] + v.position[2]]);
         let both = [moved, state.spreadsheet_points[0]];
         let want = [both[0][0] + both[1][0], both[0][1] + both[1][1], both[0][2] + both[1][2]];
         assert!((0..3).all(|k| (mid[k] / one as f32 - want[k]).abs() < 1e-2), "the markers followed the points");
@@ -18798,7 +18851,7 @@ mod tests {
         state.apply_action(McpAction::Select { slot: b }, &mut redraw).unwrap();
         state.sync_nodes();
         assert!(state.selected_spreadsheet_points().is_empty());
-        assert!(state.row_marker_verts.is_empty());
+        assert!(state.row_marker_instances.is_empty());
     }
 
     /// A node INSIDE a simnet is read as the scene draws it there: as the
@@ -18839,10 +18892,10 @@ mod tests {
         state.sync_nodes();
         assert!(!state.spreadsheet_points.is_empty());
         state.spreadsheet_mut().set_selected_rows(&[3]);
-        state.rebuild_row_marker_verts();
+        state.rebuild_row_markers();
         let middle = |state: &State| {
-            let n = state.row_marker_verts.len() as f32;
-            state.row_marker_verts.iter().fold(0.0f32, |m, v| m + v.position[0] / n)
+            let n = state.row_marker_instances.len() as f32;
+            state.row_marker_instances.iter().fold(0.0f32, |m, v| m + v.position[0] / n)
         };
         let (row_at, marker_at) = (state.spreadsheet_points[3][0], middle(&state));
         assert!((row_at - marker_at).abs() < 1e-3);
diff --git a/src/render.rs b/src/render.rs
index 4fa5d2b..16f8b63 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1544,7 +1544,7 @@ impl State {
             .iter()
             .map(|g| (g.to_string(), geom.points().group_members(g).iter().map(|&p| geom.positions()[p as usize]).collect()))
             .collect();
-        self.rebuild_marked_group_verts();
+        self.rebuild_marked_group_markers();
 
         // The point overlays ride the same rebuild, off the same `geom`:
         // they annotate what is on screen, and what is on screen is exactly
@@ -1557,9 +1557,9 @@ impl State {
             self.point_marker_size,
             self.point_marker_color,
         );
-        self.overlay_marker_verts = markers;
+        self.overlay_marker_instances = markers;
         // Kept for re-sizing the markers without this evaluation — see
-        // `State::rebuild_overlay_marker_verts`.
+        // `State::rebuild_overlay_markers`.
         self.overlay_marker_points = if self.show_point_markers {
             geom.positions()
                 .iter()
@@ -1588,10 +1588,10 @@ impl State {
         self.overlay_vertex_alpha.clear();
         self.overlay_normal_verts.extend(elements.normals);
         self.overlay_vertex_marker_points = elements.vertex_markers;
-        if !self.overlay_vertex_marker_points.is_empty() {
-            // Both marker lists, by the one builder the size slider uses.
-            self.rebuild_overlay_marker_verts();
-        }
+        self.vertex_marker_instances = crate::geometry::marker_instances(
+            &self.overlay_vertex_marker_points,
+            cce_ui::colors::to_linear_rgb(VERTEX_LABEL_COLOR.map(|c| c as f32 / 255.0)),
+        );
         // The wire pass's edges, likewise — topological, and only while the
         // wireframe is actually on.
         self.scene_edge_verts =
@@ -1658,8 +1658,8 @@ impl State {
     }
 }
 
-/// The point overlays on the displayed scene: marker geometry for Show
-/// Point Markers, `(position, index)` labels for Show Point Numbers, and
+/// The point overlays on the displayed scene: marker INSTANCES for Show
+/// Point Markers (one a point, drawn over `geometry::marker_sphere`), `(position, index)` labels for Show Point Numbers, and
 /// normal whiskers for Show Point Normals — each read straight off the
 /// merged scene `Detail` the geometry rebuild has already produced.
 ///
@@ -1686,17 +1686,13 @@ pub(crate) fn scene_point_overlays(
     let mut normals = Vec::new();
     if markers_on {
         // One marker per point. The soup emitted one per corner and leaned
-        // on points_vertices deduping by position.
+        // on the instances deduping by position.
         let src: Vec<crate::geometry::Vertex3D> = geom
             .positions()
             .iter()
             .map(|&position| crate::geometry::Vertex3D { position, color: [0.0; 3] })
             .collect();
-        markers.extend(crate::geometry::points_vertices(
-            &src,
-            point_size,
-            cce_ui::colors::to_linear_rgb(marker_color),
-        ));
+        markers = crate::geometry::marker_instances(&src, cce_ui::colors::to_linear_rgb(marker_color));
     }
     if normals_on {
         // Smooth point normals: for each point, the normalized sum of the
diff --git a/src/vk_smoke.rs b/src/vk_smoke.rs
index 70162f6..cea73d2 100644
--- a/src/vk_smoke.rs
+++ b/src/vk_smoke.rs
@@ -481,9 +481,9 @@ fn main() {
                 renderer.stage_scene(
                     pane,
                     vec![
-                        SceneDraw { mesh: bg_mesh, mvp: Mat4::IDENTITY.to_cols_array_2d(), wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false },
-                        SceneDraw { mesh: grid_mesh, mvp, wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false },
-                        SceneDraw { mesh: cube_mesh, mvp, wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false },
+                        SceneDraw { mesh: bg_mesh, mvp: Mat4::IDENTITY.to_cols_array_2d(), wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: None },
+                        SceneDraw { mesh: grid_mesh, mvp, wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: None },
+                        SceneDraw { mesh: cube_mesh, mvp, wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false, instances: None },
                     ],
                 );
             }