Skip to content

V3: Connected Dots edge snap (+ Contour Graph and Facets styles) - #198

Merged
git-chad merged 150 commits into
git-chad/shader-lab-v3-planfrom
git-chad/v3-dots-edges
Oct 1, 2026
Merged

git-chad merged 150 commits into
git-chad/shader-lab-v3-planfrom
git-chad/v3-dots-edges

Conversation

@git-chad

Copy link
Copy Markdown
Collaborator

Roadmap 6.6 item 4: Connected Dots snapping to image edges. It's stacked on #197 (Grain). This is the last V3 delivery.

What you get

  • Edge Snap (0–1, default 0) pulls each dot toward the strongest image edge in its cell.

    • Contours such as jaw, eyes, nose and hair against the background get traced by dots and links, as in reference 21.
    • In Mesh mode, vertices land on edges and facets follow silhouettes.
  • How it works.

    • A small pass scores 4×4 candidates per cell, favouring the dark side and thin dark lines, above a noise floor so video grain doesn't move the dots.
    • The existing per-cell pass then pulls the dot toward the strong candidates, and only where the edge is concentrated in one part of the cell, so smooth gradients keep their jitter.
  • Works in Graph, Blobs, Plexus and Mesh. The main dot shader is unchanged.

  • Cost. Snapping costs under 0.2 ms at 1080p. Mesh now reads its points from the per-cell pass, which makes Low Poly and Wireframe about 2× faster (2.2 → 1.1 ms).

  • Two new styles:

    • Contour Graph: Portrait Graph with snapping.
    • Facets: low poly in the image's own colours, with triangles that follow the shapes.

    No existing style changed. At Edge Snap 0, all ten existing styles are pixel-identical to before.

Tests

  • connected-dots-edges.mjs, 107 cases:
    • Edge Snap 0 identity, and flat and grainy images leave dots in place;
    • hard edges pull dots to within 1.5 px, 0.5 moves them halfway, and noise moves snapped dots less than 0.5 px;
    • thin dark lines, cutout edges, and Blobs, Plexus and Mesh all follow;
    • editor/runtime parity, styles, hydration of old projects, history, duplication, save/reopen, shader export and runtime pixel parity.
  • Negative controls fail as expected: pull removed, noise floor removed.
  • The existing Connected Dots check (126 cases) still passes. The full bun run test:composition suite passes on this commit (SwiftShader), and the existing Connected Dots renders match the pre-V3-performance baseline within 1/255.

Manual test: tests/composition/CONNECTED-DOTS-EDGES-MANUAL-QA.md.

Notes

  • Switching between Mesh and the other modes now rebuilds the shader. It uses the usual compile path; blink wasn't measured.
  • The edge target exists only while Edge Snap is above 0. It's about 29 MB at the minimum spacing on a 4K document.
  • Contour Graph's lower link threshold adds threads in midtone areas like sky; tuning is up to you.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
shader-lab Ready Ready Preview Oct 1, 2026 2:05pm UTC

@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[Medium risk] Adds edge-snapping feature to Connected Dots visualization.

This PR is not ready to merge because supported exports can exceed the texture limit and light cutouts can lose their snapped outlines.

Summary

Connected Dots can now pull points toward image edges, so dots, links, and Mesh triangles can follow contours. The PR also adds two styles that use snapping and lets Mesh reuse points already placed per cell.

  • Adds Edge Snap, from 0 to 1, with existing styles keeping it off by default.
  • Adds Contour Graph and Facets with snapping turned on.
  • Adds tests and manual notes for edge snapping, styles, and saved projects.

Reviews (1) · Last reviewed commit: "Record Connected Dots edge snap in the r..."

Comment thread src/renderer/connected-dots-pass.ts
Comment thread src/renderer/connected-dots-pass.ts
…roup failures

Media load callbacks now ignore replaced or removed passes and pending loads are cleared on disposal. Strict-mode pass failures are reported once at the originating pass instead of at every group boundary.
Publishing now drops every descendant of a hidden group and clears the
parentId of any retained layer (the audio source) whose parent was removed,
so the published scene passes hierarchy validation on reopen.
Cap the neighbor row and column search to two cells each side so the
worst-case cellAt count per fragment is 25 instead of unbounded in the
packed minimum-size, maximum-stroke configuration. Mirrored in the
runtime package.
…e on buttons

Clear the active paint layer when the layer stops being paintable (selection,
mode, visibility or lock change) so Paint never resumes without Edit Paint.
Only preventDefault on Space for non-interactive targets so focused buttons
and links still activate.
three 0.186.1 compiles some TSL graphs with a variable assigned inside only
one branch of an If/select and read outside it, so other pixels read 0
(three #34339). None of the composition checks fail on most of those reads,
so upgrades need a second gate.

wgsl-capture.mjs runs every registered composition check (discovered from
window, plus the baseline projects) and saves each unique shader module the
checks create. THREE_DIR swaps in an unpacked three package through a Bun
build plugin, so a candidate version can be captured without touching the
installed one; CHECKS limits the run. wgsl-scan.mjs tracks which variables
each block has assigned, treating an if/else chain as assigning a variable
only when every arm does, and reports reads with no assignment on the path.
Given a baseline capture it reports only new patterns and exits non-zero.
On the September 29 captures it finds 40 new patterns on 0.186.1 in exactly
the six layers that broke, and none comparing 0.185.1 with itself.

The roadmap gets a "Para retomar" block: what is pending acceptance, the
unfinished full run on the stack and unconfirmed CI, the six steps for
moving to r187 (types, runtime peer and changeset, MCP reference, dropping
the r186-fixed pose workaround, suite, shader scan), and that the
path-traced Render mode can start on 0.185.1.
Connected Dots cost 43-50 ms per frame at 1080p on an Apple GPU and was the
main source of heat. Each pixel recomputed 81 dot sites; a pre-pass now
stores the 25 distinct sites once per cell and the shader reads them back,
and only the active mode's link maths runs. It now takes about 5 ms.

Other passes, native GPU at 1080p, before -> after (ms):
Displaced Rings 5.4 -> 2.3, Focus Blur depth of field 3.2 -> 0.3 and
tilt-shift 3.4 -> 1.9, Glass hammered 3.5 -> 1.3, Plotter stipple 3.2 -> 1.0,
Signal Rot 1.95 -> 0.65, Outline stroke 2.0 -> 1.2, Photocopy 1.0 -> 0.4-0.85.
The savings come from running only the selected pattern, mode or shape,
skipping work whose slider is 0, sharing dependency chains with Fn/toVar,
and skipping the blur pyramid when no pixel reaches its first level.

The editor pipeline redrew every layer each frame whenever any layer
animated. It now caches the static layers at the bottom of the stack when a
continuous layer sits above them, and reuses them until something changes.
A photo with Connected Dots and Glass under an animated Signal Rot goes from
8.0 to 0.9 ms per frame, and the cached frame is pixel-identical to a full
render.

Passes now learn whether their input may have changed this frame. Blob
Tracking uses it to stop forcing continuous rendering once its temporal
state settles (it previously redrew the whole pipeline forever), restarts
when a layer below changes, and joins the static cache while settled.
Annotations re-read the Edges field only when their input changes instead of
every third frame, and shade only each mark's own kind.

Outline, Flares and Relief bound two texture nodes to one placeholder, which
merged into a single binding when the material compiled before its first
render; they now use separate placeholders.

Verified with the composition suite (all checks pass; 83 of 95 renders
byte-identical, the rest within 1/255 except 4 pixels at 3/255) and a
native-GPU dump of every style of every V3 layer at 1080p against HEAD.
Lumen Print Sabattier re-rolls grain on single-pixel columns and Dot Grid
Night moves a few dot edges by less than a pixel at rounding ties.
… asset in the reopen test

- Carry fill-opacity and stroke-opacity onto the SVG materials so semi-transparent logo parts stay translucent in Original mode
- Make the save/reopen check register the SVG asset, assert it resolves after reopen, and render the reopened project
Preset -> Custom shows a Motifs tray under the preset: up to ten images or
SVGs, added several at a time with the + tile or by dropping files on the
tray. Dragging a tile lifts and tilts it while the others make room (the
user's sketch, reference 35); the drop is one undo step and Esc cancels.
Alt+arrows reorder and Delete removes from the keyboard.

Motifs are drawn into an RGBA atlas that keeps their colors, instead of the
presets' thresholded mask. The first motif covers the lightest perceptual
tones and the last the darkest, in equal bands; Invert reverses the order.
The new Motif colors mode draws each motif's own colors over the
Background, and is chosen when the first motifs are added if the mode was
still Source. The other modes tint the motif's silhouette through its alpha.
Bars, Candles and Shapes are unchanged.

The ordered list is `patternAssetIds` on the layer, so it follows save,
autosave, history, duplication and publishing through
collectReferencedAssetIds. A motif missing on reopen is dropped with
"Missing motif: <file>"; one that fails to decode is reported while the
others still render. The runtime config gains `patternAssets`, with a
distinct /replace/image/ placeholder per motif. The limit of ten keeps a
scene under the community's twelve-asset cap.

pattern-motifs.mjs checks the editor and runtime passes on a tone ramp,
the project path and runtime parity; reversing the bands fails it.
… paths, drag handling

- Keep the motif load warning until a load succeeds (src and runtime pass)
- Treat Motif colors as source color for built-in pattern presets
- Make exported motif placeholder paths unique across all layers
- End the undo interaction when a tile loses pointer capture or is cancelled, outside the state updater
- Check motif capacity after loading and report files that did not fit
Shape -> SVG takes an uploaded file as the layer's asset. The SVG is
rasterized once at 1024 px on its long side with a margin, and an exact
Euclidean distance transform (inside and outside, refined by the
anti-aliased alpha at the edge) turns it into a half-float signed
distance field. The shape shader samples it as kind 7 of the same
branchless shader, so switching shapes still doesn't recompile, edges
stay sharp at any size, and Softness and Outline mean exactly what they
mean on the procedural shapes. A distance field was chosen over
rasterizing like the Image layer for that reason.

Original colors come from a color texture whose colors are dilated
outward, so softness never shows a dark fringe. File colors are listed
from attributes, inline styles, <style> rules, gradient stops and the
implicit black of shapes without a fill, and each can be changed;
recoloring reuses the distance field and only redraws colors. Single
color and Outline use the layer Color.

Because the SVG is the layer's assetId, saving, autosave, history,
duplication, publishing, the Missing asset notice and export work as on
Image layers. Replaced fields are disposed a few frames later so a
texture is never freed while still bound.
…opic edges

- Keep SVG interior opacity through the distance field and color texture
- Preserve alpha when extracting and replacing SVG file colors
- Skip the implicit-fill swatch when a parent group sets fill
- Retry a failed SVG load on the next sync
- Keep the export wait entry until the current SVG request finishes
- Scale sampled SVG distance per axis for stretched shapes
The ramp was a JSON text param, so it could not be animated. It is now one
timeline property, Ramp, with a new "gradient" value type: the diamond next
to "Dark to light" keys the whole ramp, Auto-Key edits at the playhead create
or update keyframes, and without Auto-Key an edit only previews until keyed,
like every other animated param.

Between two keyframes the ramps are aligned stop by stop. With equal counts
the sorted stops pair up and glide in position and color. With different
counts the shorter ramp gains stops at the positions of the unmatched ones
(a monotone matching that minimises position travel), colored by sampling
the shorter ramp itself, so it looks unchanged and nothing pops at either
keyframe; nested presets crossfade exactly. Colors blend in sRGB like the
ramp and color tracks already do, per-keyframe easing applies, and the
alignment is cached per keyframe pair. The same code runs in the editor and
the runtime timeline, so preview, PNG/video export and shader export agree.

The pass rewrites its LUT in place and only when the evaluated ramp string
changes, so static scenes and projects without ramp tracks behave as before.
- Interpolate inserted ramp stops between the adjacent matched stops so overlapping stops keep the correct side of the color change (app and runtime copy)
- Pass onInteractionStart/End through GradientRamp to its ColorPicker so a color drag is one interaction
The roadmap left three Annotations items open: connected dots (reference 21),
placement guided by image regions, and rotating individual elements.

Placement → Regions reuses the 128×72 field Edges already renders and reads
back only when the image changes. A second edge material writes the Sobel
magnitude unchanged plus averaged tone and two chroma axes; the CPU groups
cells into regions with warm-started k-means, a majority filter and connected
components, drops thin slivers, and keeps region ids across frames by overlap,
so marks glide with video instead of blinking. Boxes frame compact regions,
dots, crosses and labels sit on outlines picked by seeded anchors, rings sit
on centers, and every per-region mark uses its own seeded stream so regions
appearing or disappearing do not reshuffle the others.

Connected Dots draws small constellations: a minimum spanning tree over a few
seeded nodes plus occasional second-neighbour links, respecting every
placement and drawn last so the element budget drops it first. Rotation
Jitter turns rings, crosses and boxes by a hashed angle, and Align to Edges
turns them along edge tangents or each region's long axis; text, rulers and
metadata stay upright.

Every new control starts off and draws from separate random streams, so saved
projects keep their exact layout; a golden digest of legacy layouts computed
from the previous code guards that. Plain Edges keeps its original material
and field.
…sts in the harness

The Annotations region study seeks through aura.mp4 72 times. Chrome aborts in-flight media range requests on every seek, which the harness counted as a failed request. Only media requests aborted by the element itself are ignored now; every other failed request still fails the run.
… edge angles, spread sparse marks

- Annotations pass redraws its output once the edge/region readback completes so still texture sources no longer keep seeded marks
- Edge tangents are converted from the 128x72 grid to the composition aspect before rotating marks
- eachRegion spreads limited marks evenly across regions instead of filling the first IDs
- Mirrored into packages/shader-lab-react
Lumen Print's grain is static and tied to the print look; the user asked for grain as its own layer so photos and video can get real film grain that moves. Grain is one cheap pass: three rotated lattices of a small shared noise texture (bilinear with smoothstep weights) give isotropic, clumpy grain skewed toward dark silver specks instead of symmetric TV static, sized in document pixels so every export resolution matches. Chroma adds per-layer color grain with zero luma, so it never changes brightness. Response weights the midtones like film, and Soft Light and Overlay scale the pixel uniformly so grain keeps its hue, while Add behaves like sensor noise. Speed steps to a new pattern 24 times a second at 1 instead of sliding, derived only from the clock time so preview and export agree; at Speed 0 the layer is static and joins the reusable static prefix. Five styles follow references 31, 22, 06/12 and 07 plus a digital sensor look.
The Grain check renders three illustrative video frames through a video layer. SwiftShader can't import decoded video frames, so those renders are black there and their paired validation errors don't always balance. They are only artifacts, so they now render on hardware adapters only.
- GrainPass advances its frame from the timeline time so preview and export agree
- Scale the chroma offset so no channel clips below zero and shifts brightness
- Render 30 timing frames per style instead of 150 in the grain composition test
The roadmap left snapping the dots to the image's edges open, and the
user asked for it: reference 21 reads as a portrait because its dots
and links follow the jaw, eyes and hair. Edge Snap moves each cell's
site toward the strongest edge inside its cell, so contours come out
as rows of dots and links, and Mesh triangles line up with silhouettes.

A small parallel pass scores a 4x4 set of candidates per cell (luma and
alpha gradient plus a dark-ridge term, so thin dark lines pull points
onto the line instead of its flanks, above a noise floor so grain does
not move sites). The site pre-pass then pulls the jittered site toward
the proximity-weighted centroid of the strong candidates, scaled by how
localized the edge is, so smooth gradients do not collapse the jitter
into a grid. The main shader is untouched for Graph, Blobs and Plexus.

Mesh now reads its points from the same pre-pass instead of recomputing
36 sites per pixel, and the mesh and dot shaders are built separately
so neither carries the other's registers. At Edge Snap 0 every existing
style renders pixel-identical to before (checked at 1080p and on two
other inputs); Mesh is about twice as fast and Edge Snap costs well
under 0.2 ms at 1080p. Contour Graph and Facets are new styles that use
it; existing styles keep it at 0.
Control descriptions moved from captions under each label to an info
icon that shows them on hover, and the explanations under each effect's
Style picker went into the same icon.

Controls in the new V3 layers now follow the order you work in: mode
first, then geometry, then output and finishing. Controls that do
nothing in the current mode are hidden, and a control whose parent
toggle is hidden now hides too, so Plotter's Cross Angle no longer
shows outside Hatch mode.

Annotations had two preset pickers; the one in Text now fills Words and
Metadata, and its palette and brush tools sit under Color and Placement.
Connected Dots' palette sits under Color. Paint brushes use a
full-width Edit button with a trash button beside it.
…ha edges

Disable edge snap when the edge target would exceed 8192 pixels, such as
Ultra exports at minimum spacing.
Score edges with alpha-weighted darkness so light cutouts over transparent
black snap to the opaque side.
@git-chad
git-chad force-pushed the git-chad/v3-dots-edges branch from 8867ebe to 3065ba7 Compare October 1, 2026 14:04
@git-chad
git-chad changed the base branch from git-chad/v3-grain to git-chad/shader-lab-v3-plan October 1, 2026 15:15
@git-chad
git-chad merged commit 3065ba7 into git-chad/shader-lab-v3-plan Oct 1, 2026
4 of 5 checks passed

This branch was successfully deployed

1 active deployment
Preview — 3065ba7f Deployed Oct 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant