{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"liquid-media","type":"registry:component","title":"Liquid Media","description":"An image or video that ripples under the cursor.","meta":{"pro":false},"docs":"Usage: https://atelier-ui.com/r/liquid-media.md\nDocs: https://atelier-ui.com/docs/components/cursor/liquid-media","dependencies":["three","@types/three","@react-three/fiber","@react-three/drei"],"registryDependencies":["https://atelier-ui.com/r/webgl-image.json","https://atelier-ui.com/r/webgl-video.json","https://atelier-ui.com/r/agent-rules.json"],"files":[{"path":"registry/liquid-media/liquid-media.tsx","type":"registry:component","target":"components/liquid-media/liquid-media.tsx","content":"\"use client\"\n\nimport { useFBO, useTexture } from \"@react-three/drei\"\nimport { createPortal, useFrame, useThree } from \"@react-three/fiber\"\nimport { useEffect, useMemo, useRef } from \"react\"\nimport type { Group, Mesh, MeshBasicMaterial, PlaneGeometry, ShaderMaterial, Texture } from \"three\"\nimport { AdditiveBlending, LinearFilter, MathUtils, OrthographicCamera, Scene } from \"three\"\nimport { type Pointer, WebglImage } from \"../webgl-image/webgl-image\"\nimport { WebglVideo } from \"../webgl-video/webgl-video\"\n\nconst ROTATION_SPEED = 0.1\nconst INITIAL_OPACITY = 0.22\nconst DISPLACEMENT_DAMPING = 6.3\nconst VELOCITY_DAMPING = 6.3\nconst IDLE_VELOCITY_DAMPING = 0.3\nconst SPAWN_SPACING = 0.2\nconst MIN_SPAWN_INTERVAL = 1 / 60\nconst MIN_SPAWN_DISTANCE = 0.005\nconst MIN_VISIBLE_OPACITY = 0.002\n\ntype Splat = Mesh<PlaneGeometry, MeshBasicMaterial>\n\nconst RIPPLE_BRUSH =\n    \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAnFBMVEUAAAD////////////////////////////////////////////////////+/v7////9/f3////9/f39/f3////////////9/f3+/v79/f3////////+/v79/f3+/v7////////+/v7////+/v7////+/v7+/v7////////////+/v7////+/v7+/v7+/v7+/v7////////////////Clg1EAAAANHRSTlMACAwRBBUcICQZKDM4LEAwSjxGTml2XEOCUmRhh1eLcXpUbpl+Wo+1kqumsJafnKK+ucPKHu8hDAAADa9JREFUeNrslcl6olAUhJOWCCKDgDJeJkEEFBzy/u/Wda7na9PboK6sRRbJon7qVN18vPXWW2/9UspNH68Wm3/e9XoIcv9zFzG8EkJRyH42m31BM4gYXgjB9l+LxVxqISkY4gUM5I9vn6uqpum6rmmqCghmeAGC/HzYa7ppL0m2aeqaykGA4ZkI/PmzxVzT7aVhWUEQWJZjAAIMKjM8sZMc/0LVzaUTuJ4XRZHnrdzAMgwwAAIMHANTPMOfPt+wXC/1kySOE39XRh4zSAQeBpfysfbsv3Rcr/TjbU7arteJn0bIgW7BdfgxT+Wx54e/Cf9oF283RR1CdbEBRLJLcQvrXx1AwY14FMInn1/6l/46L0LRNG3bNFkYgmEdyxhwCbkLnucNQXnQ+rh+FvlvatFU4zgM41gdGxHWhJDsZBscAxTLWxZAeAiBwuvXTcNalcl6E2bH8dR3UH8axqrNgLBFCrvSW61cl/dpm5q6YIKp/jOuP/x96T/03WEPHQ5gGCmFAghIYZemZVqWkeyErTPBg+bH/jX5H/aXy/l8uYCh6zkE2sQ6juWPxC+9wFkSwWSA//3zWrRj35E/BAQQUAYZLeKmoqBtxH7kWiD4mkiAAPD8qtI/8mP0vzkOHQI4XyEQUAQDIhAYZU3bFBkk0IokXTm2PieCCQB8AOq/R/sPRVv1EuB88+cbNEQghGjaIwYyHltR53HpGrY27QgKv/94/j0MIC9oAQTwswOD9CdlbTXQPuh3WZ0nUWDQEfAeTQ3AsPgBDMlDEtxXQAcQMvxG9nNPXH2VFVt/5XAEEwOwjQANlAAwGQf6SJh3PduHNZpX42/Vqduf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\"\n\nconst vertexShader = /* glsl */ `\nvarying vec2 vUv;\nvarying vec2 vScreenUv;\n\nvoid main() {\n    vUv = uv;\n    vec4 pos = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n    vScreenUv = pos.xy / pos.w * 0.5 + 0.5;\n    gl_Position = pos;\n}`\n\nconst fragmentShader = /* glsl */ `\nprecision highp float;\nuniform sampler2D uTexture;\nuniform sampler2D uDisplacement;\nuniform float uDisplacementIntensity;\nvarying vec2 vUv;\nvarying vec2 vScreenUv;\n\n#define PI 3.14159265\n\nvoid main() {\n    vec4 displacement = texture2D(uDisplacement, vScreenUv);\n    float theta = displacement.r * 2.0 * PI;\n    vec2 direction = vec2(sin(theta), cos(theta));\n    vec2 displacedUv = vUv + direction * displacement.r * uDisplacementIntensity;\n    vec4 color = texture2D(uTexture, displacedUv);\n    gl_FragColor = color;\n}`\n\ntype LiquidMediaMaterialProps = {\n    map: Texture\n    pointer: Pointer\n} & Pick<LiquidEffectProps, \"rippleMap\" | \"intensity\" | \"radius\" | \"expandRate\" | \"decayRate\">\n\ntype Uniforms = {\n    uTexture: { value: Texture | null }\n    uDisplacement: { value: Texture | null }\n    uDisplacementIntensity: { value: number }\n}\n\nexport type LiquidEffectProps = {\n    rippleMap?: Texture\n    intensity?: number\n    radius?: number\n    expandRate?: number\n    decayRate?: number\n    segments?: number\n    webglEnabled?: boolean\n    postProcessing?: boolean\n}\n\ntype LiquidMediaImageProps = LiquidEffectProps & {\n    type?: \"image\"\n    src: string\n    alt: string\n} & Omit<React.ComponentPropsWithoutRef<\"img\">, \"src\" | \"alt\">\n\ntype LiquidMediaVideoProps = LiquidEffectProps & {\n    type: \"video\"\n    src: string\n} & Omit<React.ComponentPropsWithoutRef<\"video\">, \"src\">\n\nexport type LiquidMediaProps = LiquidMediaImageProps | LiquidMediaVideoProps\n\nfunction LiquidMediaMaterial({\n    map,\n    pointer,\n    rippleMap,\n    intensity = 0.2,\n    radius = 12,\n    expandRate = 11,\n    decayRate = 3,\n}: LiquidMediaMaterialProps) {\n    const { viewport, size, gl } = useThree()\n\n    const defaultBrush = useTexture(RIPPLE_BRUSH)\n    const brush = rippleMap ?? defaultBrush\n    const anchorRef = useRef<Group>(null)\n    const splatIndex = useRef(0)\n    const spriteRefs = useRef<Splat[]>([])\n    const spriteScene = useMemo(() => new Scene(), [])\n    const spriteCamera = useMemo(() => new OrthographicCamera(-1, 1, 1, -1, 0, 1), [])\n    const materialRef = useRef<ShaderMaterial>(null)\n    const mouse = useRef({\n        x: 0,\n        y: 0,\n        velocity: 0,\n        spawnX: Infinity,\n        spawnY: Infinity,\n        spawnElapsed: Infinity,\n    })\n\n    const uniforms = useMemo<Uniforms>(\n        () => ({\n            uTexture: { value: map },\n            uDisplacement: { value: null },\n            uDisplacementIntensity: { value: 0 },\n        }),\n        [map],\n    )\n\n    const FBO = useFBO(size.width, size.height, {\n        minFilter: LinearFilter,\n        magFilter: LinearFilter,\n    })\n\n    useEffect(() => {\n        uniforms.uTexture.value = map\n    }, [map, uniforms])\n\n    useEffect(() => {\n        spriteCamera.left = -viewport.width / 2\n        spriteCamera.right = viewport.width / 2\n        spriteCamera.top = viewport.height / 2\n        spriteCamera.bottom = -viewport.height / 2\n        spriteCamera.updateProjectionMatrix()\n    }, [viewport, spriteCamera])\n\n    useFrame((_, delta) => {\n        const parent = anchorRef.current?.parent as Mesh | null\n        const mat = materialRef.current\n        if (!parent || !mat || delta <= 0) return\n\n        const sprites = spriteRefs.current\n        const _mouse = mouse.current\n        const hovering = pointer.hover > 0.5\n\n        const pointerX = parent.position.x + (pointer.uv.x - 0.5) * parent.scale.x\n        const pointerY = parent.position.y + (pointer.uv.y - 0.5) * parent.scale.y\n\n        const dx = pointerX - _mouse.x\n        const dy = pointerY - _mouse.y\n\n        // Normalized to 60Hz so speed remains identical on every display.\n        const speed = Math.sqrt(dx * dx + dy * dy) / (delta * 60)\n\n        _mouse.x = pointerX\n        _mouse.y = pointerY\n\n        if (hovering) {\n            _mouse.velocity = MathUtils.damp(_mouse.velocity, speed, VELOCITY_DAMPING, delta)\n        } else {\n            _mouse.velocity = MathUtils.damp(_mouse.velocity, 0, IDLE_VELOCITY_DAMPING, delta)\n        }\n\n        const scale = (radius * Math.min(viewport.width, viewport.height)) / 100\n        const spacing = Math.max(scale * SPAWN_SPACING, MIN_SPAWN_DISTANCE)\n        const spawnDx = pointerX - _mouse.spawnX\n        const spawnDy = pointerY - _mouse.spawnY\n        const spawnDist = Math.sqrt(spawnDx * spawnDx + spawnDy * spawnDy)\n\n        _mouse.spawnElapsed += delta\n\n        if (hovering && spawnDist > spacing && _mouse.spawnElapsed > MIN_SPAWN_INTERVAL) {\n            const sprite = sprites[splatIndex.current]\n\n            if (sprite) {\n                sprite.visible = true\n                sprite.position.set(pointerX, pointerY, 0)\n                sprite.scale.set(scale, scale, 1)\n                sprite.material.opacity = INITIAL_OPACITY\n            }\n\n            splatIndex.current = (splatIndex.current + 1) % 100\n            _mouse.spawnX = pointerX\n            _mouse.spawnY = pointerY\n            _mouse.spawnElapsed = 0\n        }\n\n        for (const sprite of sprites) {\n            if (sprite.visible) {\n                sprite.rotation.z += 2 * delta * ROTATION_SPEED\n                sprite.material.opacity = MathUtils.damp(\n                    sprite.material.opacity,\n                    0,\n                    MathUtils.clamp(decayRate, 3, 10),\n                    delta,\n                )\n                sprite.scale.x += delta * expandRate * scale\n                sprite.scale.y = sprite.scale.x\n                if (sprite.material.opacity < MIN_VISIBLE_OPACITY) sprite.visible = false\n            }\n        }\n\n        gl.setRenderTarget(FBO)\n        gl.clear()\n        gl.render(spriteScene, spriteCamera)\n        gl.setRenderTarget(null)\n\n        mat.uniforms.uDisplacement.value = FBO.texture\n        mat.uniforms.uDisplacementIntensity.value = MathUtils.damp(\n            mat.uniforms.uDisplacementIntensity.value,\n            intensity * _mouse.velocity * 5,\n            DISPLACEMENT_DAMPING,\n            delta,\n        )\n    })\n\n    const portal = useMemo(\n        () =>\n            createPortal(\n                <group>\n                    {Array.from({ length: 100 }, (_, i) => (\n                        <mesh\n                            key={i}\n                            ref={(mesh) => {\n                                if (mesh) spriteRefs.current[i] = mesh as Splat\n                            }}\n                            visible={false}\n                            rotation-z={Math.random() * Math.PI * 2}\n                        >\n                            <planeGeometry args={[1, 1]} />\n                            <meshBasicMaterial\n                                map={brush}\n                                transparent\n                                blending={AdditiveBlending}\n                                depthTest={false}\n                                depthWrite={false}\n                            />\n                        </mesh>\n                    ))}\n                </group>,\n                spriteScene,\n            ),\n        [brush, spriteScene],\n    )\n\n    return (\n        <>\n            <group ref={anchorRef} />\n\n            <shaderMaterial\n                ref={materialRef}\n                attach=\"material\"\n                vertexShader={vertexShader}\n                fragmentShader={fragmentShader}\n                uniforms={uniforms}\n            />\n\n            {portal}\n        </>\n    )\n}\n\nexport function LiquidMedia(props: LiquidMediaProps) {\n    const { rippleMap, intensity, radius, expandRate, decayRate, segments, webglEnabled, ...rest } =\n        props\n\n    const material = (map: Texture, pointer: Pointer) => (\n        <LiquidMediaMaterial\n            map={map}\n            pointer={pointer}\n            rippleMap={rippleMap}\n            intensity={intensity}\n            radius={radius}\n            expandRate={expandRate}\n            decayRate={decayRate}\n        />\n    )\n\n    if (rest.type === \"video\") {\n        const { type: _type, ...videoProps } = rest\n        return (\n            <WebglVideo\n                segments={segments}\n                webglEnabled={webglEnabled}\n                material={material}\n                {...videoProps}\n            />\n        )\n    }\n\n    const { type: _type, ...imageProps } = rest\n    return (\n        <WebglImage\n            segments={segments}\n            webglEnabled={webglEnabled}\n            material={material}\n            {...imageProps}\n        />\n    )\n}\n"}]}