Single-Draw Orbit Rings
Five concentric orbits of static path dots, each carrying one brighter dot that travels it. Every dot in the system is a vertex of one `THREE.Points` object, so ring count and dot count cost buffer memory but never a second draw call. The outer rings are dimmer than the inner ones, which reads as depth even though the camera is orthographic and nothing is actually further away.
Single-Draw Orbit Rings
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/orbit-ring-points.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 | = 5 | Concentric rings. Each gets exactly one travelling marker. |
| dotsPerRing | number | = 170 | Static path dots per ring. |
| speed | number | = 0.3 | Marker angular speed in radians per second. |
| baseRadius | number | = 0.75 | Outermost ring diameter as a fraction of the container short side. |
| dotRadius | number | = 1 | Path dot radius in CSS pixels, before pixel-ratio scaling. |
| markerRadius | number | = 3 | Marker radius in CSS pixels. |
| opacity | number | = 1 | Multiplier applied to every dot in the vertex shader. |
| color | string | = '#8a8f98' | Path dot colour, any value THREE.Color accepts. |
| markerColor | string | = '#ffffff' | Travelling marker colour. |
| seed | number | string | = 'orbit-ring-points' | Seeds the angular scatter, so the ring is identical on server and client. |
| className | string | — | Merged onto the host element via cn. |
How it works
The technique in order, not the code in order.
Each dot packs its whole identity into one attribute,
aPointData = vec4(kind, ringIndex, angle, dotRadius), wherekindis 0 for a path dot and 1 for the orbiting marker.positionis zeroed for every vertex and carries no information at all.The vertex shader derives the ring radius as
uBaseRadius * scale * uExtent * 0.5withscale = 1.0 - (uRingCount - 1.0 - ringIndex) * 0.1, so rings step 10% apart with the outermost at full radius, anduExtent = min(width, height)keeps the structure inside a preview of any aspect.Path dots sit at
vec2(cos(angle), sin(angle)) * ringRadius; markers useangle - PI * 0.5 + uElapsed * uSpeedinstead, so the phase offset baked into the attribute becomes a starting position and the clock does the travelling.Depth is faked with a focal length that no camera uses:
alpha = 0.15 + 0.65 * FOCAL_LENGTH / (FOCAL_LENGTH + ringRadius)dims a ring in proportion to its size, so wider rings recede.gl_PointSizeis clamped to a 2px floor and the fragment alpha is then divided by the area that clamping invented —vAlphaScale = unclamped² / clamped²— so a dot forced twice as wide comes out four times fainter and the field looks identical at 1x and 3x device pixel ratio.The fragment shader turns each square point sprite into a disc with
1.0 - smoothstep(0.43, 0.5, length(gl_PointCoord - 0.5))and discards anything fully transparent, so the renderer is created withantialias: false.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| RING_STEP | = 0.1 | 10% radius difference between adjacent rings. Below ~0.06 the rings visually merge into a band at preview size; above ~0.15 the innermost ring collapses to a dot when ringCount is 5. |
| FOCAL_LENGTH | = 800.0 | Not a camera property — the denominator of the alpha falloff. At 800 the outermost ring lands about 30% dimmer than the innermost at typical preview sizes: enough to read as depth, not enough to read as a fade-out. At 200 the outer rings vanish. |
| MIN_POINT_SIZE | = 2.0 | Hardware floor. Below roughly 1px most GPUs rasterise nothing at all, so a 1px dot at DPR 1 would simply be missing on some machines rather than faint. |
| UNCLAMPED_MIN_POINT_SIZE | = 1.0 | The size the dot *would* have had. It is the numerator of the area correction — without a separate constant there is nothing to compare the clamped size against and the compensation cannot be computed. |
| disc edge | = smoothstep(0.43, 0.5, d) | A soft band 7% of the point radius wide. Narrower and a 2px dot aliases into a square; wider and every dot looks blurred rather than small. |
| baseRadius | = 0.75 | Outermost ring diameter as a fraction of the container short side. At 1.0 the ring touches the edge and the marker clips as it passes; 0.75 leaves a margin the marker can travel through. |
| dotsPerRing | = 170 | Dense enough that the path reads as a continuous line at 320px wide, sparse enough that the innermost ring is not a solid filled circle. |
| speed | = 0.3 | Radians per second — one lap every ~21s. Linear, no easing: a constant angular rate is what makes it read as a mechanism rather than an animation. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The rAF subscription is never opened. One frame is drawn at uElapsed = 0, which parks every marker at twelve o'clock on its ring, so the full structure is visible and only the orbiting stops. Resizing still redraws that frame.
Clears the stage ref first so no queued frame can touch a dead renderer, then disposeObject(scene) for the geometry, material.dispose(), disposeRenderer(renderer) for dispose + forceContextLoss + zeroing the drawing buffer, and finally removes the canvas from the DOM. The rAF subscription is released by useRaf on unmount.
Five rings of dots with one brighter dot travelling each, all at the same angular rate, the outer rings visibly dimmer than the inner ones. Resize the container and the rings rescale without flicker or a dropped context. Zoom the browser to 200% and the dots stay the same apparent brightness rather than getting denser. With reduced motion on, the rings render fully and the markers sit still at the top.
Pitfalls
- Leaving
frustumCulledat its default. Every vertex sits at the origin, so three computes a bounding sphere of radius zero; the displacement is invisible to the culler and the entire object disappears the moment the camera is not pointed exactly at (0,0,0). - Not sorting the angles within a ring before writing them into the buffer. Points are blended in buffer order with depth testing off, so an unsorted ring stipples — the path visibly breaks into a dotted texture instead of reading as one line.
- Clamping
gl_PointSizewithout correcting the alpha. The clamp is invisible at DPR 3 and doubles the covered area at DPR 1, so the same component looks twice as bright on a cheap monitor as on a retina laptop. - Turning on
antialias: trueto smooth the dots. The fragment shader already anti-aliases each disc; MSAA then allocates a multisample buffer the size of the canvas to solve nothing. - Rebuilding the renderer on every resize. The buffer is size-independent — only the camera frustum and
uExtentchange — so a container reflow should cost asetSizecall, not a teardown that drops and re-acquires a GL context. - Calling
renderer.dispose()and stopping there. It frees GPU objects but leaves the context alive;disposeRendereralso callsforceContextLoss, without which a grid of previews silently blacks out the oldest one at around sixteen contexts.
Notes
- The camera is orthographic and spans the canvas 1:1 in CSS pixels, so
gl_PointSizeis a real pixel count and ring radii are real pixel radii. That is what makes the pixel-ratio compensation expressible at all. - Ring count and dots per ring are both free at draw time. A hundred rings is one draw call and one buffer upload.