Click Halo Interactive
We'll build an interactive playground: clicks spawn procedural neon rings. Focus on connecting React events with TSL logic.
Featuring uv、 vec2、 vec3、 length、 smoothstep、 mx_hsvtorgb、 uniformfor logic and gradients.
1. The Goal
- Immediate Feedback: Using an 'invisible floor' to capture clicks without visual clutter.
- SDF Modeling: Zero textures. We calculate the ring shape purely using math (Signed Distance Fields).
- Independent State: Each ring manages its own birth, expansion, and death.
2. The Invisible Stage
We need a target for Raycasting. An InteractPlane with visible={false} works perfectly to capture UV coordinates.
// Bottom layer: The invisible touch receiver
function InteractPlane({ onPointerDown }) {
return (
<mesh onPointerDown={onPointerDown}>
<planeGeometry args={[10, 10]} />
{/* Visible=false meshes still receive raycast events! */}
<meshBasicMaterial visible={false} />
</mesh>
)
}
export default function ClickHalo() {
// ... state management
return (
<Canvas gl={async (p) => { const r = new WebGPURenderer(p); await r.init(); return r }}>
<InteractPlane onPointerDown={handlePointerDown} />
{rings.map(r => (
<Ring key={r.id} centerUV={r.center} onDone={() => removeRing(r.id)} />
))}
</Canvas>
)
}3. TSL Geometry & Color
The shader has two jobs: carve the shape using smoothstep (Mask), and generate random neon colors (Color).
// Inside <Ring>: Pure math, no textures
const radius = uniform(0.0) // Driven by useFrame later
// 1. Geometry: Signed Distance Field
const d = length(uv().sub(center)) // Distance from pixel to click center
const outer = smoothstep(radius.sub(halfT).sub(feather), radius.add(halfT).add(feather), d)
const inner = smoothstep(radius.sub(halfT).add(feather), radius.add(halfT).sub(feather), d)
const ringMask = outer.sub(inner) // Outer circle minus Inner circle
// 2. Color: Neon & Gradient
const neon = mx_hsvtorgb(vec3(hueSeed, 0.8, 0.8)) // Fixed saturation, random hue
const tBand = d.sub(radius.sub(halfT)).div(thickness).saturate() // 0..1 position across ring thickness
const brightness = tBand.sub(EDGE_BIAS).div(1.0 - EDGE_BIAS).saturate() // Boost brightness at the inner edge
const finalColor = neon.mul(brightness).mul(2.0)
const alpha = ringMask.mul(fade.oneMinus()) // Fade out as animation ends4. Lifecycle & Animation
React handles 'birth' (adding to array), but the Ring component handles its own animation loop via useFrame.
// Animation Loop & Clean up
useFrame((_, delta) => {
// Calculate easing in JS
tRef.current += delta / duration
const t = easeOutCubic(tRef.current)
// Push values to GPU uniforms
radius.value = t * maxR
fade.value = t
if (t >= 1) onDone()
})
return (
// CRITICAL: Don't block the floor!
<mesh raycast={null}>
<planeGeometry />
<meshBasicNodeMaterial ... />
</mesh>
)5. Pro Tips
- Raycast Blocking: New rings are meshes! Set raycast={null} on them, otherwise they block clicks intended for the floor.
- Uniform Pipeline: We compute animation curves in JS (EaseOut) and push them to 'radius.value' every frame.
- Garbage Collection: Always call onDone to remove the ring from React state, or memory usage will climb forever.