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

commitbf80c7a8516396207b2a095f4c35abffbe035897
parent4cbfecaf58
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 00:59
perf(visualize): read columns and a group mask, no longer quadratic

apply_visualize searched a list of every affected point once a point in
Vector mode, and looked every value up by name in both. It reads a mask
and whole columns now, the arithmetic unchanged: the old function, kept
under cfg(test), is held equal bit for bit over 160 cases. scene_attributes
and vis_marker_vertices read their columns once too. Two visualizers at
11.5k points: 55.6 ms a frame to 1.3.

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

 CLAUDE.md         |  20 +++++-
 src/geometry.rs   | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/visualizer.rs |   5 +-
 3 files changed, 212 insertions(+), 4 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9c02e12..549c7c3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3429,8 +3429,8 @@ on): the spreadsheet's refill and the point markers were most of it, and
 both got cheaper without changing what they show — `points_vertices`
 works the marker sphere out once and moves it to each point (and then
 the markers were instanced, below). A replayed frame went from 140 ms to
-24 there. What is left grows with the mesh: the visualizers' copy of the
-scene.
+24 there. The visualizers were most of what was left: see "Attribute
+visualizers".
 
 **The spreadsheet is columns of values** (2026-10-07, cce-ui's
 `SheetColumn` and `set_spreadsheet_columns`): `geometry_to_spreadsheet_columns`
@@ -3824,6 +3824,22 @@ the later over the earlier, as a chain of Visualize nodes composites.
 `attribute_visualizers_are_edited_in_the_params_hud` drives the rows end
 to end.
 
+**What a visualizer costs a frame** (2026-10-07). `apply_visualize`, which
+the node and every visualizer run, was QUADRATIC in Vector mode: the
+points it touches were a list of every point, searched once a point —
+some 60 million comparisons a frame at 11k points — and every value, in
+both modes, went through a lookup by name. It reads a group mask by index
+and the attribute and the colours as whole columns now, writing the
+colours back in one pass, with the arithmetic unchanged and in the same
+order; the old function is kept under `cfg(test)` and
+`visualize_matches_its_reference` holds the two equal bit for bit across
+160 cases (both modes, groups, ranges, blends, a flat, an integer and the
+`Cd` attribute, no `Cd`, a `Cd` of four floats). `scene_attributes` and
+`vis_marker_vertices`, which run on every scene rebuild, read their
+columns once too. On the user's project (an `N` Vector and a `val2` Ramp)
+at 11.5k points: 55.6 ms a frame to 1.3, the markers 1.5 and the
+attribute ranges 0.7 left.
+
 ### The Normal node writes point or vertex normals
 
 `normal` has a **Class** row (since 2026-09-29): `Points`, what it always
diff --git a/src/geometry.rs b/src/geometry.rs
index 4c15f02..b71a60b 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -3975,6 +3975,7 @@ pub fn vis_marker_vertices(geom: &Detail, linearize: impl Fn([f32; 3]) -> [f32;
             continue;
         }
         let Some(data) = geom.points().get(name) else { continue };
+        let cd = geom.points().get(crate::detail::CD);
         for p in 0..geom.num_points() {
             let Some(v) = data.get(p) else { continue };
             let dir = v.as_vec3();
@@ -3983,7 +3984,12 @@ pub fn vis_marker_vertices(geom: &Detail, linearize: impl Fn([f32; 3]) -> [f32;
             }
             // Drawn in the point's own colour, so a Ramp Visualize upstream
             // colours the markers too and one chain says two things at once.
-            let color = linearize(geom.color(p));
+            // Read as `Detail::color` does, off the column found once.
+            let color = linearize(match cd.and_then(|c| c.get(p)) {
+                Some(AttribValue::Float3(c)) => c,
+                Some(other) => other.as_vec3().to_array(),
+                None => crate::detail::DEFAULT_COLOR,
+            });
             out.push(Vertex3D { position: geom.positions()[p], color });
             out.push(Vertex3D { position: (geom.pos(p) + dir).to_array(), color });
         }
@@ -4163,6 +4169,124 @@ pub(crate) fn apply_visualize(geom: &mut Detail, target: &FsNode, ocl_error: &mu
         return;
     }
 
+    // The points it touches: every one, or the group's, as a mask read by
+    // index. The attribute and the colours are read as whole columns. Until
+    // 2026-10-07 the group was a list of every affected point searched once
+    // a point — quadratic, 60 million comparisons a frame at 11k points for
+    // a Vector visualizer with no group — and every value went through a
+    // lookup by name. The arithmetic is unchanged, in the same order
+    // (`visualize_matches_its_reference`).
+    let n = geom.num_points();
+    let group = node_param_str(target, "group", "");
+    let group = group.trim().to_string();
+    let mask: Option<Vec<bool>> =
+        if group.is_empty() { None } else { Some((0..n).map(|p| geom.points().in_group(&group, p)).collect()) };
+    let affected = |p: usize| mask.as_ref().is_none_or(|m| m[p]);
+    let data = geom.points().get(&name).expect("checked above");
+
+    if node_param_str(target, "mode", "Ramp").eq_ignore_ascii_case("vector") {
+        let scale = node_param_f32(target, "scale", 0.2);
+        let staged: Vec<[f32; 3]> = (0..n)
+            .map(|p| {
+                if !affected(p) {
+                    return [0.0; 3];
+                }
+                (data.get(p).map(|v| v.as_vec3()).unwrap_or(Vec3::ZERO) * scale).to_array()
+            })
+            .collect();
+        // Derivative: a marker describes the state it was made from, and one
+        // left over from the previous step would draw a lie.
+        let vis = format!("{}{}", crate::detail::VIS_PREFIX, name);
+        let _ = geom
+            .points_mut()
+            .create_kind(&vis, AttribValue::Float3([0.0; 3]), crate::detail::AttribKind::Derivative);
+        let _ = geom.points_mut().insert(&vis, AttribData::Float3(staged));
+        return;
+    }
+
+    // Ramp. Auto measures across EVERY point, not just the group: a group's
+    // colours should sit where they belong on the whole picture's scale, or
+    // two Visualize nodes over two groups would each claim the full ramp.
+    let values: Vec<f32> = (0..n).map(|p| data.get(p).map(|v| v.as_f32()).unwrap_or(0.0)).collect();
+    let (from, to) = if node_param_str(target, "range", "Auto").eq_ignore_ascii_case("manual") {
+        let [from, to] = node_param_vec2(target, "manual_range", [0.0, 1.0]);
+        (from, to)
+    } else {
+        (
+            values.iter().copied().fold(f32::INFINITY, f32::min),
+            values.iter().copied().fold(f32::NEG_INFINITY, f32::max),
+        )
+    };
+    let span = to - from;
+
+    let ramp = node_param_str(target, "ramp", "Viridis").to_lowercase();
+    let blend = node_param_str(target, "blend", "Set").to_lowercase();
+    let opacity = node_param_f32(target, "opacity", 1.0).clamp(0.0, 1.0);
+
+    // The colours as `Detail::color` reads them, a column.
+    let old_colors: Vec<[f32; 3]> = match geom.points().get(crate::detail::CD) {
+        Some(AttribData::Float3(c)) => c.clone(),
+        Some(other) => (0..n).map(|p| other.get(p).map_or(crate::detail::DEFAULT_COLOR, |v| v.as_vec3().to_array())).collect(),
+        None => vec![crate::detail::DEFAULT_COLOR; n],
+    };
+    let mut written: Vec<(usize, [f32; 3])> = Vec::with_capacity(n);
+    for p in (0..n).filter(|&p| affected(p)) {
+        let v = values[p];
+        // A flat attribute has no range to spread across the ramp; showing it
+        // all at the bottom is the honest picture of "nothing varies here".
+        let t = if span.abs() < 1e-9 { 0.0 } else { (v - from) / span };
+        let c = ramp_color(&ramp, t);
+        let old = old_colors[p];
+        let mixed = match blend.as_str() {
+            "multiply" => [old[0] * c[0], old[1] * c[1], old[2] * c[2]],
+            "add" => [old[0] + c[0], old[1] + c[1], old[2] + c[2]],
+            _ => c,
+        };
+        // Opacity is applied the same way for every blend, so a stack of
+        // Visualize nodes fades uniformly. (A Mix blend was Set by another
+        // name, this fade being all it did; it is retired, and a save
+        // holding it loads as Set.)
+        written.push((
+            p,
+            [
+                old[0] + (mixed[0] - old[0]) * opacity,
+                old[1] + (mixed[1] - old[1]) * opacity,
+                old[2] + (mixed[2] - old[2]) * opacity,
+            ],
+        ));
+    }
+    // Written as `Detail::set_color` writes: `Cd` created at the first
+    // write, and nothing written into a `Cd` that is not three floats.
+    if written.is_empty() {
+        return;
+    }
+    geom.points_mut().get_or_create(crate::detail::CD, AttribValue::Float3(crate::detail::DEFAULT_COLOR));
+    if let Some(AttribData::Float3(cd)) = geom.points_mut().get_mut(crate::detail::CD) {
+        for (p, c) in written {
+            cd[p] = c;
+        }
+    }
+}
+
+/// The Visualize node as it was written until 2026-10-07, kept as the
+/// reference [`apply_visualize`] is held to bit for bit
+/// (`visualize_matches_its_reference`).
+#[cfg(test)]
+pub(crate) fn apply_visualize_reference(geom: &mut Detail, target: &FsNode, ocl_error: &mut Option<String>) {
+    let name = node_param_str(target, "attribute", "").trim().to_string();
+    if name.is_empty() {
+        return;
+    }
+    if !geom.points().has(&name) {
+        if ocl_error.is_none() {
+            *ocl_error = Some(format!(
+                "Visualize '{}': no point attribute named '{}'",
+                target.name, name
+            ));
+        }
+        return;
+    }
+
     let group = node_param_str(target, "group", "");
     let group = group.trim().to_string();
     let affected: Vec<usize> = (0..geom.num_points())
@@ -7617,6 +7741,71 @@ mod simnet_tests {
         }
     }
 
+    /// The Visualize node reads columns and a group mask where it read a
+    /// value by name at every point and searched a list of every point for
+    /// each one; what it writes is the reference's bit for bit, in every
+    /// mode: Vector and Ramp, a group or none, Auto and Manual ranges, the
+    /// three blends at part opacity, a flat attribute, an integer one, `Cd`
+    /// itself, a scene with no `Cd` and one whose `Cd` is not three floats.
+    #[test]
+    fn visualize_matches_its_reference() {
+        use crate::detail::{AttribData, CD};
+        use crate::visualizer::Visualizer;
+        let mut base = sphere_detail(Vec3::new(0.1, 0.2, -0.3), 0.7, 9, 13);
+        let n = base.num_points();
+        let wave: Vec<f32> = (0..n).map(|p| (p as f32 * 0.37).sin() * 2.5 + 0.25).collect();
+        base.points_mut().insert("wave", AttribData::Float(wave.clone())).unwrap();
+        base.points_mut().create("flat", AttribValue::Float(1.5));
+        base.points_mut().insert("count", AttribData::Int((0..n as i32).map(|i| i % 7 - 3).collect())).unwrap();
+        base.points_mut().insert("dir", AttribData::Float3(wave.iter().map(|w| [*w, -w * 0.5, 0.25]).collect())).unwrap();
+        base.points_mut().create_group("half");
+        for p in (0..n).step_by(2) {
+            base.points_mut().add_to_group("half", p);
+        }
+        let mut no_cd = base.clone();
+        no_cd.points_mut().remove(CD);
+        let mut odd_cd = base.clone();
+        odd_cd.points_mut().insert(CD, AttribData::Float4(vec![[0.2, 0.4, 0.6, 1.0]; n])).unwrap();
+        let mut colored = base.clone();
+        colored.points_mut().insert(CD, AttribData::Float3((0..n).map(|p| [p as f32 / n as f32, 0.5, 0.25]).collect())).unwrap();
+
+        let mut cases = Vec::new();
+        for attribute in ["wave", "flat", "count", "dir", "Cd"] {
+            for group in ["", "half"] {
+                let mut v = Visualizer::new(attribute);
+                v.group = group.to_string();
+                v.mode = "Vector".to_string();
+                v.scale = 0.37;
+                cases.push(v.clone());
+                v.mode = "Ramp".to_string();
+                for (ramp, range, blend, opacity) in
+                    [("Heat", "Auto", "Set", 1.0), ("Viridis", "Manual", "Multiply", 0.6), ("Spectrum", "Auto", "Add", 0.35)]
+                {
+                    v.ramp = ramp.to_string();
+                    v.range = range.to_string();
+                    v.manual_range = [-1.0, 2.0];
+                    v.blend = blend.to_string();
+                    v.opacity = opacity;
+                    cases.push(v.clone());
+                }
+            }
+        }
+        let mut checked = 0;
+        for scene in [&base, &no_cd, &odd_cd, &colored] {
+            for v in &cases {
+                let node = v.as_node();
+                let (mut got, mut want) = (scene.clone(), scene.clone());
+                let (mut e1, mut e2) = (None, None);
+                apply_visualize(&mut got, &node, &mut e1);
+                apply_visualize_reference(&mut want, &node, &mut e2);
+                assert_eq!(e1, e2);
+                assert!(got == want, "{v:?} differs from the reference");
+                checked += 1;
+            }
+        }
+        assert_eq!(checked, 4 * 5 * 2 * 4);
+    }
+
     /// Apply one Attribute-node operation to geometry in hand.
     fn run_attr(before: &Detail, params: &[(&str, &str)]) -> (Detail, Option<String>) {
         let mut geom = before.clone();
diff --git a/src/visualizer.rs b/src/visualizer.rs
index 1692db8..a1505cc 100644
--- a/src/visualizer.rs
+++ b/src/visualizer.rs
@@ -273,8 +273,11 @@ pub fn scene_attributes(geom: &Detail) -> Vec<SceneAttribute> {
         .filter(|n| !n.starts_with(crate::detail::VIS_PREFIX))
         .map(|name| {
             let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
+            // The column found once, not by name at every point: this runs
+            // on every scene rebuild, every frame of a playing simulation.
+            let data = geom.points().get(name);
             for p in 0..geom.num_points() {
-                if let Some(v) = geom.points().value(name, p) {
+                if let Some(v) = data.and_then(|d| d.get(p)) {
                     let x = v.as_f32();
                     if x.is_finite() {
                         min = min.min(x);