Dot-Cloud Score Cylinder
A score wrapped around a cylinder and drawn entirely in dots: measure rings, vertical bar dividers, and note clusters that light as a playhead sweeps them. Two cylinders are stacked and turned to different yaws so they never present the same face at once, and both live in one buffer. Nothing uses a depth buffer — every dot is flattened to z = 0 before projection, and solidity comes from alpha and point size falling off with the model-space z.
Dot-Cloud Score Cylinder
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/cylinder-dot-score.jsonAlso installs webgl, prng, use-raf, use-resize-observer, use-reduced-motion from this registry.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
7 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| ringCount | number | = 10 | Measure rings per cylinder, split evenly above and below the equator gap. |
| ringDots | number | = 260 | Dots in each measure ring. |
| dividerCount | number | = 8 | Vertical bar dividers per cylinder. |
| dividerDots | number | = 34 | Dots making up each divider. |
| noteGroups | number | = 6 | Note clusters per cylinder; the playhead cycles through them. |
| noteDots | number | = 26 | Dots per note cluster. |
| spinSpeed | number | = 12 | Degrees of yaw per second. Linear. |
| noteInterval | number | = 1.2 | Seconds each note group stays lit. |
| dotScale | number | = 1 | Global dot radius multiplier in CSS pixels. |
| viewScale | number | = 0.62 | Model-to-pixel scale as a fraction of the container short side. |
| opacity | number | = 1 | Multiplier applied to every dot. |
| color | string | = '#7d838c' | Measure and divider dot colour. |
| noteColor | string | = '#ffffff' | Note dot colour; lit notes go fully to this. |
| seed | number | string | = 'cylinder-dot-score' | Seeds ring angles and note scatter for a stable, hydration-safe layout. |
| className | string | — | Merged onto the host element via cn. |
How it works
The technique in order, not the code in order.
One
Float32Arraypair is filled in six consecutive passes — both cylinders' measure rings, then both cylinders' dividers, then both cylinders' notes. Buffer order is the draw order, so notes end up on top of everything without a sort and without a second object.Identity travels in
aPointData = vec4(instanceIndex, isNote, pointScale, groupIndex), so the two cylinders share a geometry and the vertex shader picks the transform: instance 1 is mirrored withp.z *= -1.0and yawed -166°, instance 0 is yawed -90°.Both are then yawed by
uSpin(degrees, advancing linearly atspinSpeedper second) and tilted 8° on X, and instance 0 is pushed up by 0.3 *after* the rotation so the stack separation is a pure screen-space offset that does not tip with the spin.Depth is faked from the transformed z:
depthT = clamp((z + 0.6) / 1.2, 0, 1)drives alpha from 0.25 to 1.0 and point size from 0.65 to 1.0, so the far wall of the cylinder recedes without a depth test.The playhead is
mod(floor(uElapsed / uNoteInterval), uGroupCount), computed in the vertex shader from the clock that is already there. A note group whose index matches gets 2x point size and full alpha, so highlighting a beat costs no React render and no uniform write.Angles within each ring are sorted before being written into the buffer, and
gl_PointSizegets the same clamp-plus-area-compensated-alpha treatment as every other point field here.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| BASE_SCALE | = 1.5 | Applied in the shader, not baked into the buffer, so re-framing the piece never re-uploads a vertex. |
| TILT | = 8.0 degrees | X tilt. Below ~4° the cylinder collapses to a straight band and stops reading as a cylinder; past ~15° you look down the tube and the far wall crowds the near one. |
| INSTANCE_B_YAW | = -166.0 degrees | Not -180. At exactly half a turn the two cylinders present mirror-image faces and the pair looks like one object reflected; 166° keeps them legibly distinct through the whole rotation. |
| STACK_OFFSET | = 0.3 | Vertical separation, applied after rotateX so it is screen-space. Applied before, the offset would tilt with the cylinder and the two would drift into each other as they turn. |
| DEPTH_HALF_RANGE | = 0.6 | z is normalised over ±0.6 while the cloud actually spans ±0.75, so the nearest and furthest dots saturate. Normalising over the exact range leaves the middle band looking flat. |
| DEPTH_MIN_SIZE / DEPTH_MAX_SIZE | = 0.65 / 1.0 | Far dots at 65% of near dots. Alpha alone is not enough — without the size falloff the far wall reads as a grey copy of the near one rather than as distance. |
| NOTE_ACTIVE_SCALE | = 2.0 | Point size multiplier for the lit note group. Area goes as the square, so 2x is a 4x brighter mark — visible at a glance without any colour change being required. |
| spinSpeed | = 12 degrees/second | One revolution every 30s. The original ran at 1°/s, which is right for an ambient full-page object and far too slow to read in a preview card. Linear either way: easing it would make it look like it is arriving somewhere. |
| MEASURE_SCALE / NOTE_SCALE | = 0.7 / 1.4 | Notes are exactly double the measure dots so they survive the depth size falloff on the far wall, where a measure dot is already down to 0.46. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No frame loop is opened. One frame is drawn at uElapsed = 0.4, which shows both cylinders at a legible yaw with the first note group lit, so the structure and the highlight convention are both visible. Neither the rotation nor the playhead advances.
Nulls the stage ref so a queued frame cannot touch a dead renderer, disposes the geometry through disposeObject(scene), disposes the material, calls disposeRenderer for dispose + forceContextLoss + a zeroed backing store, and removes the canvas. useRaf unsubscribes on unmount.
Two stacked cylinders of dots turn at a constant rate, showing different faces to the viewer. Dots on the far side are visibly smaller and dimmer than those on the near side, and no dot ever pops in front of one it should be behind. A cluster of larger note dots lights every 1.2 seconds and cycles around the cylinder. Resize the container and the piece rescales without a flicker. With reduced motion it holds a single legible frame.
Pitfalls
- Leaving
depthTeston. Every dot is projected with z = 0, so the first fragment written at a pixel wins and the far wall of the cylinder punches holes in the near one. The whole design depends on buffer order, not on the depth buffer. - Sorting note dots into the buffer before measure dots, or drawing them as a second object added earlier. With depth testing off, "on top" means "later in the buffer" — get it wrong and the notes are half-eaten by the rings that surround them.
- Applying the stack offset before
rotateX. It then rotates with the model, and the two cylinders slowly drift into each other over the course of a revolution instead of staying vertically separated. - Writing raw random angles into a ring. Blending happens in buffer order, so unsorted angles interleave near and far dots and the ring visibly stipples rather than reading as a continuous engraved line.
- Driving the playhead from React state and an interval. It re-renders the tree once per beat, and the highlight lands one frame after the beat because the uniform write happens in a commit rather than in the frame loop — deriving it from
uElapsedin the shader avoids both. - Rebuilding the buffer when the container resizes. Model space is size-independent; only
uViewScaleanduViewportYOffsetchange, so a reflow costs two uniform writes rather than a dropped GL context. - Setting
frustumCulledat its default. Positions are unit-scale around the origin and the real placement happens in the shader, so three's bounding sphere does not describe what is on screen.
Notes
- The two cylinders are "instances" only in the conceptual sense — this is one non-instanced
Pointswith an index in an attribute, which is cheaper than instancing for a case where every vertex differs anyway. - Total dot count is
2 * (ringCount * ringDots + dividerCount * dividerDots + noteGroups * noteDots), about 6100 at the defaults, in one draw call.