graphic design tool
git clone https://git.lucas.co/cce-designer.git
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);