Files
vue-bits/public/r/PixelTrail.json
2026-01-21 16:08:55 +05:30

1 line
12 KiB
JSON

{"name":"PixelTrail","title":"PixelTrail","description":"Pixel grid trail effect that follows cursor movement with customizable gooey filter.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, useTemplateRef, watch } from 'vue';\nimport * as THREE from 'three';\n\ninterface GooeyFilterConfig {\n id: string;\n strength: number;\n}\n\ninterface PixelTrailProps {\n gridSize?: number;\n trailSize?: number;\n maxAge?: number;\n interpolate?: number;\n color?: string;\n gooeyFilter?: GooeyFilterConfig;\n className?: string;\n}\n\nconst props = withDefaults(defineProps<PixelTrailProps>(), {\n gridSize: 40,\n trailSize: 0.1,\n maxAge: 250,\n interpolate: 5,\n color: '#ffffff',\n gooeyFilter: undefined,\n className: ''\n});\n\nconst containerRef = useTemplateRef<HTMLDivElement>('containerRef');\n\nlet renderer: THREE.WebGLRenderer | null = null;\nlet scene: THREE.Scene | null = null;\nlet camera: THREE.OrthographicCamera | null = null;\nlet mesh: THREE.Mesh | null = null;\nlet animationFrameId: number | null = null;\nlet lastTime = 0;\nlet containerWidth = 0;\nlet containerHeight = 0;\n\n// Trail texture system (matching React's useTrailTexture config)\nconst TEXTURE_SIZE = 512; // React uses size: 512\nconst INTENSITY = 0.2;\nconst MIN_FORCE = 0.3;\nconst SMOOTHING = 0;\n\ninterface TrailPoint {\n x: number;\n y: number;\n age: number;\n force: number;\n}\n\nlet trailCanvas: HTMLCanvasElement | null = null;\nlet trailCtx: CanvasRenderingContext2D | null = null;\nlet trailTexture: THREE.CanvasTexture | null = null;\nlet trail: TrailPoint[] = [];\nlet force = 0;\n\n// Smooth average for force calculation (from drei)\nfunction smoothAverage(current: number, measurement: number, smoothing: number = 0.9): number {\n return measurement * smoothing + current * (1.0 - smoothing);\n}\n\n// Vertex shader\nconst vertexShader = `\n void main() {\n gl_Position = vec4(position.xy, 0.0, 1.0);\n }\n`;\n\n// Fragment shader for pixel grid (identical to React)\nconst fragmentShader = `\n uniform vec2 resolution;\n uniform sampler2D mouseTrail;\n uniform float gridSize;\n uniform vec3 pixelColor;\n\n vec2 coverUv(vec2 uv) {\n vec2 s = resolution.xy / max(resolution.x, resolution.y);\n vec2 newUv = (uv - 0.5) * s + 0.5;\n return clamp(newUv, 0.0, 1.0);\n }\n\n void main() {\n vec2 screenUv = gl_FragCoord.xy / resolution;\n vec2 uv = coverUv(screenUv);\n\n vec2 gridUv = fract(uv * gridSize);\n vec2 gridUvCenter = (floor(uv * gridSize) + 0.5) / gridSize;\n\n float trail = texture2D(mouseTrail, gridUvCenter).r;\n\n gl_FragColor = vec4(pixelColor, trail);\n }\n`;\n\nfunction hexToRgb(hex: string): THREE.Color {\n return new THREE.Color(hex);\n}\n\n// Apply coverUv transformation to convert screen coords to texture coords\n// Must match the shader's coverUv EXACTLY\nfunction screenToTextureUv(screenX: number, screenY: number): { x: number; y: number } {\n // Match shader: vec2 s = resolution.xy / max(resolution.x, resolution.y);\n const maxDim = Math.max(containerWidth, containerHeight);\n const sx = containerWidth / maxDim;\n const sy = containerHeight / maxDim;\n\n // Match shader: vec2 newUv = (uv - 0.5) * s + 0.5;\n const x = (screenX - 0.5) * sx + 0.5;\n const y = (screenY - 0.5) * sy + 0.5;\n\n return {\n x: Math.max(0, Math.min(1, x)),\n y: Math.max(0, Math.min(1, y))\n };\n}\n\nfunction initTrailTexture() {\n trailCanvas = document.createElement('canvas');\n trailCanvas.width = trailCanvas.height = TEXTURE_SIZE;\n trailCtx = trailCanvas.getContext('2d')!;\n trailCtx.fillStyle = 'black';\n trailCtx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);\n\n trailTexture = new THREE.CanvasTexture(trailCanvas);\n trailTexture.minFilter = THREE.NearestFilter;\n trailTexture.magFilter = THREE.NearestFilter;\n trailTexture.wrapS = THREE.ClampToEdgeWrapping;\n trailTexture.wrapT = THREE.ClampToEdgeWrapping;\n}\n\nfunction clearTrail() {\n if (!trailCtx) return;\n trailCtx.globalCompositeOperation = 'source-over';\n trailCtx.fillStyle = 'black';\n trailCtx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);\n}\n\nfunction addTouch(point: { x: number; y: number }) {\n const last = trail[trail.length - 1];\n\n if (last) {\n const dx = last.x - point.x;\n const dy = last.y - point.y;\n const dd = dx * dx + dy * dy;\n\n const newForce = Math.max(MIN_FORCE, Math.min(dd * 10000, 1));\n force = smoothAverage(newForce, force, SMOOTHING);\n\n // Interpolation (matching drei's logic)\n if (props.interpolate > 0) {\n const lines = Math.ceil(dd / Math.pow((props.trailSize * 0.5) / props.interpolate, 2));\n\n if (lines > 1) {\n for (let i = 1; i < lines; i++) {\n trail.push({\n x: last.x - (dx / lines) * i,\n y: last.y - (dy / lines) * i,\n age: 0,\n force: newForce\n });\n }\n }\n }\n }\n\n trail.push({ x: point.x, y: point.y, age: 0, force });\n}\n\nfunction drawTouch(point: TrailPoint) {\n if (!trailCtx) return;\n\n const pos = {\n x: point.x * TEXTURE_SIZE,\n y: (1 - point.y) * TEXTURE_SIZE\n };\n\n // Calculate intensity based on age (matching drei's logic)\n // React uses linear easing: ease = (x) => x (identity function)\n let intensity = 1;\n if (point.age < props.maxAge * 0.3) {\n // Fade in phase (0 to 30% of maxAge)\n intensity = point.age / (props.maxAge * 0.3);\n } else {\n // Fade out phase (30% to 100% of maxAge)\n intensity = 1 - (point.age - props.maxAge * 0.3) / (props.maxAge * 0.7);\n }\n\n intensity *= point.force;\n\n // Apply blending\n trailCtx.globalCompositeOperation = 'screen';\n\n const radius = TEXTURE_SIZE * props.trailSize * intensity;\n\n if (radius <= 0) return;\n\n const grd = trailCtx.createRadialGradient(\n pos.x,\n pos.y,\n Math.max(0, radius * 0.25),\n pos.x,\n pos.y,\n Math.max(0, radius)\n );\n grd.addColorStop(0, `rgba(255, 255, 255, ${INTENSITY})`);\n grd.addColorStop(1, 'rgba(0, 0, 0, 0.0)');\n\n trailCtx.beginPath();\n trailCtx.fillStyle = grd;\n trailCtx.arc(pos.x, pos.y, Math.max(0, radius), 0, Math.PI * 2);\n trailCtx.fill();\n}\n\nfunction updateTrailTexture(delta: number) {\n if (!trailCtx || !trailTexture) return;\n\n clearTrail();\n\n // Age points and remove old ones\n trail = trail.filter(point => {\n point.age += delta * 1000;\n return point.age <= props.maxAge;\n });\n\n // Reset force when empty\n if (!trail.length) force = 0;\n\n // Draw all points\n trail.forEach(point => drawTouch(point));\n\n trailTexture.needsUpdate = true;\n}\n\nfunction setupScene() {\n const container = containerRef.value;\n if (!container) return;\n\n const width = container.clientWidth;\n const height = container.clientHeight;\n containerWidth = width;\n containerHeight = height;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n\n // Initialize trail texture\n initTrailTexture();\n\n // Main renderer\n renderer = new THREE.WebGLRenderer({\n antialias: false,\n alpha: true,\n powerPreference: 'high-performance'\n });\n renderer.setSize(width, height);\n renderer.setPixelRatio(dpr);\n container.appendChild(renderer.domElement);\n\n // Main scene\n scene = new THREE.Scene();\n camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);\n camera.position.z = 1;\n\n // Main mesh with pixel shader\n const pixelColor = hexToRgb(props.color);\n const material = new THREE.ShaderMaterial({\n uniforms: {\n resolution: { value: new THREE.Vector2(width * dpr, height * dpr) },\n mouseTrail: { value: trailTexture },\n gridSize: { value: props.gridSize },\n pixelColor: { value: new THREE.Vector3(pixelColor.r, pixelColor.g, pixelColor.b) }\n },\n vertexShader,\n fragmentShader,\n transparent: true\n });\n\n const geometry = new THREE.PlaneGeometry(2, 2);\n mesh = new THREE.Mesh(geometry, material);\n scene.add(mesh);\n\n // Event listeners\n container.addEventListener('pointermove', handlePointerMove);\n window.addEventListener('resize', handleResize);\n\n // Start animation\n trail = [];\n force = 0;\n lastTime = performance.now();\n animate();\n}\n\nfunction handlePointerMove(event: PointerEvent) {\n const container = containerRef.value;\n if (!container) return;\n\n const rect = container.getBoundingClientRect();\n const screenX = (event.clientX - rect.left) / rect.width;\n const screenY = 1 - (event.clientY - rect.top) / rect.height;\n\n // Convert screen coordinates to texture UV space (apply coverUv transformation)\n const uv = screenToTextureUv(screenX, screenY);\n\n // Add touch point\n addTouch(uv);\n}\n\nfunction handleResize() {\n const container = containerRef.value;\n if (!container || !renderer || !mesh) return;\n\n const width = container.clientWidth;\n const height = container.clientHeight;\n containerWidth = width;\n containerHeight = height;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n\n renderer.setSize(width, height);\n\n const material = mesh.material as THREE.ShaderMaterial;\n material.uniforms.resolution.value.set(width * dpr, height * dpr);\n}\n\nfunction animate() {\n if (!renderer || !scene || !camera || !mesh) return;\n\n animationFrameId = requestAnimationFrame(animate);\n\n // Calculate delta time\n const currentTime = performance.now();\n const delta = (currentTime - lastTime) / 1000;\n lastTime = currentTime;\n\n // Update trail texture with delta time\n updateTrailTexture(delta);\n\n // Render\n renderer.render(scene, camera);\n}\n\nfunction cleanup() {\n if (animationFrameId) {\n cancelAnimationFrame(animationFrameId);\n animationFrameId = null;\n }\n\n const container = containerRef.value;\n if (container) {\n container.removeEventListener('pointermove', handlePointerMove);\n }\n window.removeEventListener('resize', handleResize);\n\n // Clear trail data\n trail = [];\n force = 0;\n\n if (renderer) {\n if (container && container.contains(renderer.domElement)) {\n container.removeChild(renderer.domElement);\n }\n renderer.dispose();\n renderer = null;\n }\n\n if (trailTexture) {\n trailTexture.dispose();\n trailTexture = null;\n }\n\n if (mesh) {\n (mesh.material as THREE.ShaderMaterial).dispose();\n mesh.geometry.dispose();\n mesh = null;\n }\n\n trailCanvas = null;\n trailCtx = null;\n scene = null;\n camera = null;\n}\n\nonMounted(setupScene);\nonUnmounted(cleanup);\n\nwatch(\n () => [props.gridSize, props.trailSize, props.maxAge, props.interpolate, props.color],\n () => {\n cleanup();\n setupScene();\n },\n { deep: true }\n);\n</script>\n\n<template>\n <div class=\"relative w-full h-full\">\n <svg v-if=\"props.gooeyFilter\" class=\"absolute overflow-hidden z-[1]\">\n <defs>\n <filter :id=\"props.gooeyFilter.id\">\n <feGaussianBlur in=\"SourceGraphic\" :stdDeviation=\"props.gooeyFilter.strength\" result=\"blur\" />\n <feColorMatrix in=\"blur\" type=\"matrix\" values=\"1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9\" result=\"goo\" />\n <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n </filter>\n </defs>\n </svg>\n\n <div\n ref=\"containerRef\"\n :class=\"['absolute z-[1] w-full h-full', props.className]\"\n :style=\"props.gooeyFilter ? { filter: `url(#${props.gooeyFilter.id})` } : undefined\"\n />\n </div>\n</template>\n","path":"PixelTrail/PixelTrail.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"three","version":"^0.178.0"}],"devDependencies":[],"categories":["Animations"]}