You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(shaders): add CursorTrail, a heat trail behind the pointer - #281
Composition 2 (SHA-304) needs a heat trail that follows the pointer and runs through the same palette and dither as the field. CursorTrail is that trail, built as a general cursor effect.
What changes
CursorTrail
A cursor effect that adds light along the pointer's recent path. path.ts records the path, rounds the corners between one frame's move and the next into a curve, and packs it as points. field.ts draws those points into a half-resolution heat texture as one stroke, widest at the pointer and tapering to a point. When the pointer stops, the tail draws in along the path over decay seconds. The props are radius, intensity, decay, grow, smoothing, and hold, documented in cursor-trail.tsx. The scene stops drawing once the trail settles.
A resample, not a color transform
CursorTrail registers through useResample and adds its heat to the sample beneath. PaletteMap mounted after it maps the trail with the image, and LedWall's cells each read one heat value (ADR 0006). Composition 2's rough scene now stacks the field, CursorTrail, PaletteMap, then LedWall.
Docs page and specs
The docs page has controls for every prop, and a dev probe sits at /dev/cursor-trail-probe. The Playwright spec drives the probe with the browser clock paused, so a moving trail is the same image on every run. SelectInput now accepts boolean options, so hold gets a control.
Known limitations
On WebGL2 without EXT_color_buffer_float, the heat target can't be created and the trail doesn't draw, as ADR 0003 describes.
Under the slow motion policy, the trail runs at full speed. Only paused turns it off.
The poster shows only the gradient, because it is captured before the pointer moves.
…inter
A resample that adds a fading heat field to the image beneath it, so a UV
warp mounted after it, such as LedWall's cell snap, lights whole cells
(ADR 0006). The field ping-pongs two half-float textures in a pre-pass:
each draw cools every texel linearly and stamps the pointer's segment,
aging each point by its share of the frame. Adds a dev probe route.
The shader draws the spot under the pointer from its position, and the
heat is the larger of the spot and the trail. A still pointer keeps its
spot while the field cools to zero, so the scene parks. When the spot
goes, it is stamped into the field and cools like the trail. The field
moves to half resolution, which removes the stair-stepped rim, and the
first tick after the scene parks counts for at most one frame. Under the
paused motion policy only the spot shows.
The cooling field stamped a disc each frame, so the head and the tail
read as two things, and a held spot showed its edge as the tail faded.
path.ts now records the pointer's path, and field.ts draws it as one
shape, full at the head and tapering to a point. When the pointer stops,
the tail draws in along the path at a steady pace and reaches the head in
`decay` seconds. A moving pointer trails about as far as it travels in
that time.
Adds `grow`, which widens the whole trail with the pointer's speed and
eases it back as the pointer slows. Defaults move to the values tuned in
the probe: `decay` 0.75 and `grow` 0.3. The tuning prop is gone, and the
heat is added as linear light.
The rough scene's CursorSpotlight stand-in brightened a spot after the
palette, so it could not push a cell into a hotter color. The stack is now
the field, CursorTrail, PaletteMap, then LedWall, so the trail's heat runs
through the same palette and dither as the field. The presets move their
colors from the field's stops to PaletteMap.
The path recorded one point per frame and the shader joined them with
straight segments, so a fast flick drew a polygon with a corner every
frame, and a shaky hand left a lump at every wobble. pack() now runs a
curve through the recorded points, from the middle of each run to the
middle of the next, so the trail has no corners. It then lays points out
evenly along that curve, so a long trail spreads the 96-point budget
instead of skipping recorded points.
Adds `smoothing`, which averages each point with its neighbors within
that distance along the path, in canvas heights. The reach narrows to 0
at both ends, so the head stays on the pointer. Defaults to 0.05.
No control could write a boolean prop, so CursorTrail's `hold` had no
way onto its docs page. Options may now carry a boolean value. The
select works in strings and writes the option's own value back.
The page shows the trail over a dark gradient, with controls for every
prop, and its usage notes cover `decay`, `grow`, `smoothing`, `hold`,
and where the trail sits in a stack. CursorTrail joins the component
list, the composition component map, and the poster script. The poster
shows only the gradient, because it is captured before the pointer
moves.
The spec drives the probe with the browser clock paused, so a moving
trail is the same image on every run. It checks the tapering trail, that
the image returns to the original once the trail draws in, and that a
held spot stays steady and fades once the pointer leaves. Headless
Chromium takes the WebGL2 fallback, so the spec runs on the backend the
trail must support. changedPixels moves to helpers.ts so the smudge and
trail specs share it.
The easing that grows the brush with the pointer's speed lived inside the
scene's pre-pass, where no unit test could reach it. grow.ts now holds it
as createGrowth, with tests for growing, settling to exactly 0, and holding
still when no time passes. It isn't called a swell, which GLOSSARY.md
reserves for LedWall's reaction.
Review cleanup: the file-top summaries fit the 2-to-5-line rule in
code-style.md, with the detail moved under their sections, and field.ts
glosses texel and pre-pass. The `radius` doc now says 0 turns the effect
off. field.ts stops exporting two helpers nothing else imports. Adds the
changeset for CursorTrail.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved defects can draw unwanted trails between held spots, freeze fading trails after resume, and leave resting tails retracting at stale speeds after decay changes.
Three fixes from review:
- A held spot with no tail ended on its full head, so field.ts drew a
cone from it to the next spot. Close each such piece with a zero point.
- A step with no time in it, such as the first after a pause, drew the
same field and stopped the scene, which froze a fading trail. Count it
as a change while the trail shows.
- A resting tail kept the pace it had when the pointer stopped, so a new
decay did not reach it. Store the tail's length at the stop instead,
and divide by the current decay each frame.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
The shader darkens faint transparent pixels even when the trail is disabled and continues requesting unnecessary frames when growth cannot affect the image.
No open issues 🎉
2 previously missed issues
In code that hasn't changed since last review
Preserve faint transparent pixels when trail intensity is zero
The alpha clamp makes this resample change the underlying image even when heat is zero or intensity={0}. For example, with alpha 0.00005, dividing by 0.0001 and multiplying back by alpha halves the existing RGB, darkening faint transparent Source pixels. Multiply the base color back by the same denominator, or add the alpha-scaled lift directly in premultiplied space, so a disabled trail preserves the original image.
Stop scheduling frames for growth that cannot affect the brush
The growth accumulator keeps requesting frames even when grow={0} means it cannot change the image. For example, with decay={0} and hold={false}, a fast swipe clears the trail on the next frame, but this condition keeps an otherwise static scene rendering for roughly another 1.7 seconds solely to settle unused growth. Reset or stop accumulating growth when it cannot affect the brush, and only let visible growth keep the scheduler running.
Two fixes from review:
- The resample undid premultiplied alpha with a clamped divide, which
darkened pixels with alpha under 1e-4 even with no heat. Add the lift
scaled by alpha instead, which needs no divide.
- Brush growth kept the scene drawing for about 1.7 seconds after the
trail was gone or with grow at 0. Reset it when it cannot show.
The reason will be displayed to describe this comment to others. Learn more.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
pack() spread the point budget over every piece, even those it had no
room to write. With 32 pieces or more, the newest packed as just a head
and a tail. Keep only the newest pieces that fit before spacing.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed to evaluate a subtle interaction where off-canvas scrolling can enlarge fading trail pieces despite the input having zero presence.
A touch scroll that carries the brush off the canvas sends strokes with
no presence. The path ignored them, but growth still read their speed,
so every fading piece widened. Count such a stroke as no stroke.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Toggling the trail changes effect registration order and breaks palette and LED cell processing, while incorrect path-length accounting can delay tail retraction.
A move that slid the head added its full distance to the tail's length,
though the path grew by less or even shrank. After small moves back and
forth, the length outran the path, so a resting tail held still before it
drew in.
Composition 2's rough scene now remounts when an entry is switched on or
off, so an Effect switched back on takes its place in the stack instead of
landing on top.
CursorTrail's probe copied CursorSmudge's picker, its state, and the
effect that restores the app's policy on leaving, which React Doctor
flagged as duplicate JSX. Both probes now render one MotionPolicyPicker.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human review is needed to assess cursor initialization and resize-state interactions that can leave a held cursor invisible or a stale tail visible after the scheduler stops.
A held spot could disappear with the pointer still on the canvas, and stay
gone until the pointer next moved. Window blur cleared the pointer state,
and a still pointer sends no move to set it again, so focus now restores
it unless the pointer left the canvas.
Pausing also cleared the position the spot rests on, so under the paused
motion policy a resize redraw removed the spot. The scene then parked
before it could draw the spot again. The position now stays through a
pause, and resuming moves it to the pointer, so a move made during the
pause still draws no stroke.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed for a critical interaction between aspect-ratio resizing and trail decay that can let the scheduler stop while the tail remains visible.
A tail's length and reach are stored in canvas heights, but a new aspect
stretches the points under them. After the canvas narrowed, a held tail
was longer than its path, so trim cut nothing, the packed points stopped
changing, and the scene parked with the tail still showing. After the
canvas widened, the tail lost part of its length in one frame.
Scale both values by how much each path stretched, which also keeps the
time left to draw in.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Native WebGPU output and GPU cost need human validation because the supplied visual coverage exercises only WebGL2 and this review environment cannot run the renderer.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Composition 2 (SHA-304) needs a heat trail that follows the pointer and runs through the same palette and dither as the field. CursorTrail is that trail, built as a general cursor effect.
What changes
CursorTrail
A cursor effect that adds light along the pointer's recent path.
path.tsrecords the path, rounds the corners between one frame's move and the next into a curve, and packs it as points.field.tsdraws those points into a half-resolution heat texture as one stroke, widest at the pointer and tapering to a point. When the pointer stops, the tail draws in along the path overdecayseconds. The props areradius,intensity,decay,grow,smoothing, andhold, documented incursor-trail.tsx. The scene stops drawing once the trail settles.A resample, not a color transform
CursorTrail registers through
useResampleand adds its heat to the sample beneath. PaletteMap mounted after it maps the trail with the image, and LedWall's cells each read one heat value (ADR 0006). Composition 2's rough scene now stacks the field, CursorTrail, PaletteMap, then LedWall.Docs page and specs
The docs page has controls for every prop, and a dev probe sits at
/dev/cursor-trail-probe. The Playwright spec drives the probe with the browser clock paused, so a moving trail is the same image on every run.SelectInputnow accepts boolean options, soholdgets a control.Known limitations
EXT_color_buffer_float, the heat target can't be created and the trail doesn't draw, as ADR 0003 describes.slowmotion policy, the trail runs at full speed. Onlypausedturns it off.Closes SHA-169