{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "Ballpit-TS-TW", "type": "registry:block", "title": "Ballpit", "description": "Physics ball pit simulation with bouncing colorful spheres.", "dependencies": [ "gsap", "three" ], "files": [ { "path": "public/ts/tailwind/src/ts-tailwind/Backgrounds/Ballpit/Ballpit.tsx", "content": "import React, { useRef, useEffect } from 'react';\r\nimport {\r\n Clock,\r\n PerspectiveCamera,\r\n Scene,\r\n WebGLRenderer,\r\n WebGLRendererParameters,\r\n SRGBColorSpace,\r\n MathUtils,\r\n Vector2,\r\n Vector3,\r\n MeshPhysicalMaterial,\r\n ShaderChunk,\r\n Color,\r\n Object3D,\r\n InstancedMesh,\r\n PMREMGenerator,\r\n SphereGeometry,\r\n AmbientLight,\r\n PointLight,\r\n ACESFilmicToneMapping,\r\n Raycaster,\r\n Plane\r\n} from 'three';\r\nimport { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';\r\nimport { Observer } from 'gsap/Observer';\r\nimport { gsap } from 'gsap';\r\n\r\ngsap.registerPlugin(Observer);\r\n\r\ninterface XConfig {\r\n canvas?: HTMLCanvasElement;\r\n id?: string;\r\n rendererOptions?: Partial;\r\n size?: 'parent' | { width: number; height: number };\r\n}\r\n\r\ninterface SizeData {\r\n width: number;\r\n height: number;\r\n wWidth: number;\r\n wHeight: number;\r\n ratio: number;\r\n pixelRatio: number;\r\n}\r\n\r\nclass X {\r\n #config: XConfig;\r\n #postprocessing: any;\r\n #resizeObserver?: ResizeObserver;\r\n #intersectionObserver?: IntersectionObserver;\r\n #resizeTimer?: number;\r\n #animationFrameId: number = 0;\r\n #clock: Clock = new Clock();\r\n #animationState = { elapsed: 0, delta: 0 };\r\n #isAnimating: boolean = false;\r\n #isVisible: boolean = false;\r\n\r\n canvas!: HTMLCanvasElement;\r\n camera!: PerspectiveCamera;\r\n cameraMinAspect?: number;\r\n cameraMaxAspect?: number;\r\n cameraFov!: number;\r\n maxPixelRatio?: number;\r\n minPixelRatio?: number;\r\n scene!: Scene;\r\n renderer!: WebGLRenderer;\r\n size: SizeData = {\r\n width: 0,\r\n height: 0,\r\n wWidth: 0,\r\n wHeight: 0,\r\n ratio: 0,\r\n pixelRatio: 0\r\n };\r\n\r\n render: () => void = this.#render.bind(this);\r\n onBeforeRender: (state: { elapsed: number; delta: number }) => void = () => {};\r\n onAfterRender: (state: { elapsed: number; delta: number }) => void = () => {};\r\n onAfterResize: (size: SizeData) => void = () => {};\r\n isDisposed: boolean = false;\r\n\r\n constructor(config: XConfig) {\r\n this.#config = { ...config };\r\n this.#initCamera();\r\n this.#initScene();\r\n this.#initRenderer();\r\n this.resize();\r\n this.#initObservers();\r\n }\r\n\r\n #initCamera() {\r\n this.camera = new PerspectiveCamera();\r\n this.cameraFov = this.camera.fov;\r\n }\r\n\r\n #initScene() {\r\n this.scene = new Scene();\r\n }\r\n\r\n #initRenderer() {\r\n if (this.#config.canvas) {\r\n this.canvas = this.#config.canvas;\r\n } else if (this.#config.id) {\r\n const elem = document.getElementById(this.#config.id);\r\n if (elem instanceof HTMLCanvasElement) {\r\n this.canvas = elem;\r\n } else {\r\n console.error('Three: Missing canvas or id parameter');\r\n }\r\n } else {\r\n console.error('Three: Missing canvas or id parameter');\r\n }\r\n this.canvas!.style.display = 'block';\r\n const rendererOptions: WebGLRendererParameters = {\r\n canvas: this.canvas,\r\n powerPreference: 'high-performance',\r\n ...(this.#config.rendererOptions ?? {})\r\n };\r\n this.renderer = new WebGLRenderer(rendererOptions);\r\n this.renderer.outputColorSpace = SRGBColorSpace;\r\n }\r\n\r\n #initObservers() {\r\n if (!(this.#config.size instanceof Object)) {\r\n window.addEventListener('resize', this.#onResize.bind(this));\r\n if (this.#config.size === 'parent' && this.canvas.parentNode) {\r\n this.#resizeObserver = new ResizeObserver(this.#onResize.bind(this));\r\n this.#resizeObserver.observe(this.canvas.parentNode as Element);\r\n }\r\n }\r\n this.#intersectionObserver = new IntersectionObserver(this.#onIntersection.bind(this), {\r\n root: null,\r\n rootMargin: '0px',\r\n threshold: 0\r\n });\r\n this.#intersectionObserver.observe(this.canvas);\r\n document.addEventListener('visibilitychange', this.#onVisibilityChange.bind(this));\r\n }\r\n\r\n #onResize() {\r\n if (this.#resizeTimer) clearTimeout(this.#resizeTimer);\r\n this.#resizeTimer = window.setTimeout(this.resize.bind(this), 100);\r\n }\r\n\r\n resize() {\r\n let w: number, h: number;\r\n if (this.#config.size instanceof Object) {\r\n w = this.#config.size.width;\r\n h = this.#config.size.height;\r\n } else if (this.#config.size === 'parent' && this.canvas.parentNode) {\r\n w = (this.canvas.parentNode as HTMLElement).offsetWidth;\r\n h = (this.canvas.parentNode as HTMLElement).offsetHeight;\r\n } else {\r\n w = window.innerWidth;\r\n h = window.innerHeight;\r\n }\r\n this.size.width = w;\r\n this.size.height = h;\r\n this.size.ratio = w / h;\r\n this.#updateCamera();\r\n this.#updateRenderer();\r\n this.onAfterResize(this.size);\r\n }\r\n\r\n #updateCamera() {\r\n this.camera.aspect = this.size.width / this.size.height;\r\n if (this.camera.isPerspectiveCamera && this.cameraFov) {\r\n if (this.cameraMinAspect && this.camera.aspect < this.cameraMinAspect) {\r\n this.#adjustFov(this.cameraMinAspect);\r\n } else if (this.cameraMaxAspect && this.camera.aspect > this.cameraMaxAspect) {\r\n this.#adjustFov(this.cameraMaxAspect);\r\n } else {\r\n this.camera.fov = this.cameraFov;\r\n }\r\n }\r\n this.camera.updateProjectionMatrix();\r\n this.updateWorldSize();\r\n }\r\n\r\n #adjustFov(aspect: number) {\r\n const tanFov = Math.tan(MathUtils.degToRad(this.cameraFov / 2));\r\n const newTan = tanFov / (this.camera.aspect / aspect);\r\n this.camera.fov = 2 * MathUtils.radToDeg(Math.atan(newTan));\r\n }\r\n\r\n updateWorldSize() {\r\n if (this.camera.isPerspectiveCamera) {\r\n const fovRad = (this.camera.fov * Math.PI) / 180;\r\n this.size.wHeight = 2 * Math.tan(fovRad / 2) * this.camera.position.length();\r\n this.size.wWidth = this.size.wHeight * this.camera.aspect;\r\n } else if ((this.camera as any).isOrthographicCamera) {\r\n const cam = this.camera as any;\r\n this.size.wHeight = cam.top - cam.bottom;\r\n this.size.wWidth = cam.right - cam.left;\r\n }\r\n }\r\n\r\n #updateRenderer() {\r\n this.renderer.setSize(this.size.width, this.size.height);\r\n this.#postprocessing?.setSize(this.size.width, this.size.height);\r\n let pr = window.devicePixelRatio;\r\n if (this.maxPixelRatio && pr > this.maxPixelRatio) {\r\n pr = this.maxPixelRatio;\r\n } else if (this.minPixelRatio && pr < this.minPixelRatio) {\r\n pr = this.minPixelRatio;\r\n }\r\n this.renderer.setPixelRatio(pr);\r\n this.size.pixelRatio = pr;\r\n }\r\n\r\n get postprocessing() {\r\n return this.#postprocessing;\r\n }\r\n set postprocessing(value: any) {\r\n this.#postprocessing = value;\r\n this.render = value.render.bind(value);\r\n }\r\n\r\n #onIntersection(entries: IntersectionObserverEntry[]) {\r\n this.#isAnimating = entries[0].isIntersecting;\r\n this.#isAnimating ? this.#startAnimation() : this.#stopAnimation();\r\n }\r\n\r\n #onVisibilityChange() {\r\n if (this.#isAnimating) {\r\n document.hidden ? this.#stopAnimation() : this.#startAnimation();\r\n }\r\n }\r\n\r\n #startAnimation() {\r\n if (this.#isVisible) return;\r\n const animateFrame = () => {\r\n this.#animationFrameId = requestAnimationFrame(animateFrame);\r\n this.#animationState.delta = this.#clock.getDelta();\r\n this.#animationState.elapsed += this.#animationState.delta;\r\n this.onBeforeRender(this.#animationState);\r\n this.render();\r\n this.onAfterRender(this.#animationState);\r\n };\r\n this.#isVisible = true;\r\n this.#clock.start();\r\n animateFrame();\r\n }\r\n\r\n #stopAnimation() {\r\n if (this.#isVisible) {\r\n cancelAnimationFrame(this.#animationFrameId);\r\n this.#isVisible = false;\r\n this.#clock.stop();\r\n }\r\n }\r\n\r\n #render() {\r\n this.renderer.render(this.scene, this.camera);\r\n }\r\n\r\n clear() {\r\n this.scene.traverse(obj => {\r\n if ((obj as any).isMesh && typeof (obj as any).material === 'object' && (obj as any).material !== null) {\r\n Object.keys((obj as any).material).forEach(key => {\r\n const matProp = (obj as any).material[key];\r\n if (matProp && typeof matProp === 'object' && typeof matProp.dispose === 'function') {\r\n matProp.dispose();\r\n }\r\n });\r\n (obj as any).material.dispose();\r\n (obj as any).geometry.dispose();\r\n }\r\n });\r\n this.scene.clear();\r\n }\r\n\r\n dispose() {\r\n this.#onResizeCleanup();\r\n this.#stopAnimation();\r\n this.clear();\r\n this.#postprocessing?.dispose();\r\n this.renderer.dispose();\r\n this.isDisposed = true;\r\n }\r\n\r\n #onResizeCleanup() {\r\n window.removeEventListener('resize', this.#onResize.bind(this));\r\n this.#resizeObserver?.disconnect();\r\n this.#intersectionObserver?.disconnect();\r\n document.removeEventListener('visibilitychange', this.#onVisibilityChange.bind(this));\r\n }\r\n}\r\n\r\ninterface WConfig {\r\n count: number;\r\n maxX: number;\r\n maxY: number;\r\n maxZ: number;\r\n maxSize: number;\r\n minSize: number;\r\n size0: number;\r\n gravity: number;\r\n friction: number;\r\n wallBounce: number;\r\n maxVelocity: number;\r\n controlSphere0?: boolean;\r\n followCursor?: boolean;\r\n}\r\n\r\nclass W {\r\n config: WConfig;\r\n positionData: Float32Array;\r\n velocityData: Float32Array;\r\n sizeData: Float32Array;\r\n center: Vector3 = new Vector3();\r\n\r\n constructor(config: WConfig) {\r\n this.config = config;\r\n this.positionData = new Float32Array(3 * config.count).fill(0);\r\n this.velocityData = new Float32Array(3 * config.count).fill(0);\r\n this.sizeData = new Float32Array(config.count).fill(1);\r\n this.center = new Vector3();\r\n this.#initializePositions();\r\n this.setSizes();\r\n }\r\n\r\n #initializePositions() {\r\n const { config, positionData } = this;\r\n this.center.toArray(positionData, 0);\r\n for (let i = 1; i < config.count; i++) {\r\n const idx = 3 * i;\r\n positionData[idx] = MathUtils.randFloatSpread(2 * config.maxX);\r\n positionData[idx + 1] = MathUtils.randFloatSpread(2 * config.maxY);\r\n positionData[idx + 2] = MathUtils.randFloatSpread(2 * config.maxZ);\r\n }\r\n }\r\n\r\n setSizes() {\r\n const { config, sizeData } = this;\r\n sizeData[0] = config.size0;\r\n for (let i = 1; i < config.count; i++) {\r\n sizeData[i] = MathUtils.randFloat(config.minSize, config.maxSize);\r\n }\r\n }\r\n\r\n update(deltaInfo: { delta: number }) {\r\n const { config, center, positionData, sizeData, velocityData } = this;\r\n let startIdx = 0;\r\n if (config.controlSphere0) {\r\n startIdx = 1;\r\n const firstVec = new Vector3().fromArray(positionData, 0);\r\n firstVec.lerp(center, 0.1).toArray(positionData, 0);\r\n new Vector3(0, 0, 0).toArray(velocityData, 0);\r\n }\r\n for (let idx = startIdx; idx < config.count; idx++) {\r\n const base = 3 * idx;\r\n const pos = new Vector3().fromArray(positionData, base);\r\n const vel = new Vector3().fromArray(velocityData, base);\r\n vel.y -= deltaInfo.delta * config.gravity * sizeData[idx];\r\n vel.multiplyScalar(config.friction);\r\n vel.clampLength(0, config.maxVelocity);\r\n pos.add(vel);\r\n pos.toArray(positionData, base);\r\n vel.toArray(velocityData, base);\r\n }\r\n for (let idx = startIdx; idx < config.count; idx++) {\r\n const base = 3 * idx;\r\n const pos = new Vector3().fromArray(positionData, base);\r\n const vel = new Vector3().fromArray(velocityData, base);\r\n const radius = sizeData[idx];\r\n for (let jdx = idx + 1; jdx < config.count; jdx++) {\r\n const otherBase = 3 * jdx;\r\n const otherPos = new Vector3().fromArray(positionData, otherBase);\r\n const otherVel = new Vector3().fromArray(velocityData, otherBase);\r\n const diff = new Vector3().copy(otherPos).sub(pos);\r\n const dist = diff.length();\r\n const sumRadius = radius + sizeData[jdx];\r\n if (dist < sumRadius) {\r\n const overlap = sumRadius - dist;\r\n const correction = diff.normalize().multiplyScalar(0.5 * overlap);\r\n const velCorrection = correction.clone().multiplyScalar(Math.max(vel.length(), 1));\r\n pos.sub(correction);\r\n vel.sub(velCorrection);\r\n pos.toArray(positionData, base);\r\n vel.toArray(velocityData, base);\r\n otherPos.add(correction);\r\n otherVel.add(correction.clone().multiplyScalar(Math.max(otherVel.length(), 1)));\r\n otherPos.toArray(positionData, otherBase);\r\n otherVel.toArray(velocityData, otherBase);\r\n }\r\n }\r\n if (config.controlSphere0) {\r\n const diff = new Vector3().copy(new Vector3().fromArray(positionData, 0)).sub(pos);\r\n const d = diff.length();\r\n const sumRadius0 = radius + sizeData[0];\r\n if (d < sumRadius0) {\r\n const correction = diff.normalize().multiplyScalar(sumRadius0 - d);\r\n const velCorrection = correction.clone().multiplyScalar(Math.max(vel.length(), 2));\r\n pos.sub(correction);\r\n vel.sub(velCorrection);\r\n }\r\n }\r\n if (Math.abs(pos.x) + radius > config.maxX) {\r\n pos.x = Math.sign(pos.x) * (config.maxX - radius);\r\n vel.x = -vel.x * config.wallBounce;\r\n }\r\n if (config.gravity === 0) {\r\n if (Math.abs(pos.y) + radius > config.maxY) {\r\n pos.y = Math.sign(pos.y) * (config.maxY - radius);\r\n vel.y = -vel.y * config.wallBounce;\r\n }\r\n } else if (pos.y - radius < -config.maxY) {\r\n pos.y = -config.maxY + radius;\r\n vel.y = -vel.y * config.wallBounce;\r\n }\r\n const maxBoundary = Math.max(config.maxZ, config.maxSize);\r\n if (Math.abs(pos.z) + radius > maxBoundary) {\r\n pos.z = Math.sign(pos.z) * (config.maxZ - radius);\r\n vel.z = -vel.z * config.wallBounce;\r\n }\r\n pos.toArray(positionData, base);\r\n vel.toArray(velocityData, base);\r\n }\r\n }\r\n}\r\n\r\nclass Y extends MeshPhysicalMaterial {\r\n uniforms: { [key: string]: { value: any } } = {\r\n thicknessDistortion: { value: 0.1 },\r\n thicknessAmbient: { value: 0 },\r\n thicknessAttenuation: { value: 0.1 },\r\n thicknessPower: { value: 2 },\r\n thicknessScale: { value: 10 }\r\n };\r\n\r\n constructor(params: any) {\r\n super(params);\r\n this.defines = { USE_UV: '' };\r\n this.onBeforeCompile = shader => {\r\n Object.assign(shader.uniforms, this.uniforms);\r\n shader.fragmentShader =\r\n `\r\n uniform float thicknessPower;\r\n uniform float thicknessScale;\r\n uniform float thicknessDistortion;\r\n uniform float thicknessAmbient;\r\n uniform float thicknessAttenuation;\r\n ` + shader.fragmentShader;\r\n shader.fragmentShader = shader.fragmentShader.replace(\r\n 'void main() {',\r\n `\r\n void RE_Direct_Scattering(const in IncidentLight directLight, const in vec2 uv, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, inout ReflectedLight reflectedLight) {\r\n vec3 scatteringHalf = normalize(directLight.direction + (geometryNormal * thicknessDistortion));\r\n float scatteringDot = pow(saturate(dot(geometryViewDir, -scatteringHalf)), thicknessPower) * thicknessScale;\r\n #ifdef USE_COLOR\r\n vec3 scatteringIllu = (scatteringDot + thicknessAmbient) * vColor;\r\n #else\r\n vec3 scatteringIllu = (scatteringDot + thicknessAmbient) * diffuse;\r\n #endif\r\n reflectedLight.directDiffuse += scatteringIllu * thicknessAttenuation * directLight.color;\r\n }\r\n\r\n void main() {\r\n `\r\n );\r\n const lightsChunk = ShaderChunk.lights_fragment_begin.replaceAll(\r\n 'RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );',\r\n `\r\n RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\r\n RE_Direct_Scattering(directLight, vUv, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, reflectedLight);\r\n `\r\n );\r\n shader.fragmentShader = shader.fragmentShader.replace('#include ', lightsChunk);\r\n if (this.onBeforeCompile2) this.onBeforeCompile2(shader);\r\n };\r\n }\r\n onBeforeCompile2?: (shader: any) => void;\r\n}\r\n\r\nconst XConfig = {\r\n count: 200,\r\n colors: [0, 0, 0],\r\n ambientColor: 0xffffff,\r\n ambientIntensity: 1,\r\n lightIntensity: 200,\r\n materialParams: {\r\n metalness: 0.5,\r\n roughness: 0.5,\r\n clearcoat: 1,\r\n clearcoatRoughness: 0.15\r\n },\r\n minSize: 0.5,\r\n maxSize: 1,\r\n size0: 1,\r\n gravity: 0.5,\r\n friction: 0.9975,\r\n wallBounce: 0.95,\r\n maxVelocity: 0.15,\r\n maxX: 5,\r\n maxY: 5,\r\n maxZ: 2,\r\n controlSphere0: false,\r\n followCursor: true\r\n};\r\n\r\nconst U = new Object3D();\r\n\r\nlet globalPointerActive = false;\r\nconst pointerPosition = new Vector2();\r\n\r\ninterface PointerData {\r\n position: Vector2;\r\n nPosition: Vector2;\r\n hover: boolean;\r\n touching: boolean;\r\n onEnter: (data: PointerData) => void;\r\n onMove: (data: PointerData) => void;\r\n onClick: (data: PointerData) => void;\r\n onLeave: (data: PointerData) => void;\r\n dispose?: () => void;\r\n}\r\n\r\nconst pointerMap = new Map();\r\n\r\nfunction createPointerData(options: Partial & { domElement: HTMLElement }): PointerData {\r\n const defaultData: PointerData = {\r\n position: new Vector2(),\r\n nPosition: new Vector2(),\r\n hover: false,\r\n touching: false,\r\n onEnter: () => {},\r\n onMove: () => {},\r\n onClick: () => {},\r\n onLeave: () => {},\r\n ...options\r\n };\r\n if (!pointerMap.has(options.domElement)) {\r\n pointerMap.set(options.domElement, defaultData);\r\n if (!globalPointerActive) {\r\n document.body.addEventListener('pointermove', onPointerMove as EventListener);\r\n document.body.addEventListener('pointerleave', onPointerLeave as EventListener);\r\n document.body.addEventListener('click', onPointerClick as EventListener);\r\n\r\n document.body.addEventListener('touchstart', onTouchStart as EventListener, {\r\n passive: false\r\n });\r\n document.body.addEventListener('touchmove', onTouchMove as EventListener, {\r\n passive: false\r\n });\r\n document.body.addEventListener('touchend', onTouchEnd as EventListener, {\r\n passive: false\r\n });\r\n document.body.addEventListener('touchcancel', onTouchEnd as EventListener, {\r\n passive: false\r\n });\r\n globalPointerActive = true;\r\n }\r\n }\r\n defaultData.dispose = () => {\r\n pointerMap.delete(options.domElement);\r\n if (pointerMap.size === 0) {\r\n document.body.removeEventListener('pointermove', onPointerMove as EventListener);\r\n document.body.removeEventListener('pointerleave', onPointerLeave as EventListener);\r\n document.body.removeEventListener('click', onPointerClick as EventListener);\r\n\r\n document.body.removeEventListener('touchstart', onTouchStart as EventListener);\r\n document.body.removeEventListener('touchmove', onTouchMove as EventListener);\r\n document.body.removeEventListener('touchend', onTouchEnd as EventListener);\r\n document.body.removeEventListener('touchcancel', onTouchEnd as EventListener);\r\n globalPointerActive = false;\r\n }\r\n };\r\n return defaultData;\r\n}\r\n\r\nfunction onPointerMove(e: PointerEvent) {\r\n pointerPosition.set(e.clientX, e.clientY);\r\n processPointerInteraction();\r\n}\r\n\r\nfunction processPointerInteraction() {\r\n for (const [elem, data] of pointerMap) {\r\n const rect = elem.getBoundingClientRect();\r\n if (isInside(rect)) {\r\n updatePointerData(data, rect);\r\n if (!data.hover) {\r\n data.hover = true;\r\n data.onEnter(data);\r\n }\r\n data.onMove(data);\r\n } else if (data.hover && !data.touching) {\r\n data.hover = false;\r\n data.onLeave(data);\r\n }\r\n }\r\n}\r\n\r\nfunction onTouchStart(e: TouchEvent) {\r\n if (e.touches.length > 0) {\r\n e.preventDefault();\r\n pointerPosition.set(e.touches[0].clientX, e.touches[0].clientY);\r\n for (const [elem, data] of pointerMap) {\r\n const rect = elem.getBoundingClientRect();\r\n if (isInside(rect)) {\r\n data.touching = true;\r\n updatePointerData(data, rect);\r\n if (!data.hover) {\r\n data.hover = true;\r\n data.onEnter(data);\r\n }\r\n data.onMove(data);\r\n }\r\n }\r\n }\r\n}\r\n\r\nfunction onTouchMove(e: TouchEvent) {\r\n if (e.touches.length > 0) {\r\n e.preventDefault();\r\n pointerPosition.set(e.touches[0].clientX, e.touches[0].clientY);\r\n for (const [elem, data] of pointerMap) {\r\n const rect = elem.getBoundingClientRect();\r\n updatePointerData(data, rect);\r\n if (isInside(rect)) {\r\n if (!data.hover) {\r\n data.hover = true;\r\n data.touching = true;\r\n data.onEnter(data);\r\n }\r\n data.onMove(data);\r\n } else if (data.hover && data.touching) {\r\n data.onMove(data);\r\n }\r\n }\r\n }\r\n}\r\n\r\nfunction onTouchEnd() {\r\n for (const [, data] of pointerMap) {\r\n if (data.touching) {\r\n data.touching = false;\r\n if (data.hover) {\r\n data.hover = false;\r\n data.onLeave(data);\r\n }\r\n }\r\n }\r\n}\r\n\r\nfunction onPointerClick(e: PointerEvent) {\r\n pointerPosition.set(e.clientX, e.clientY);\r\n for (const [elem, data] of pointerMap) {\r\n const rect = elem.getBoundingClientRect();\r\n updatePointerData(data, rect);\r\n if (isInside(rect)) data.onClick(data);\r\n }\r\n}\r\n\r\nfunction onPointerLeave() {\r\n for (const data of pointerMap.values()) {\r\n if (data.hover) {\r\n data.hover = false;\r\n data.onLeave(data);\r\n }\r\n }\r\n}\r\n\r\nfunction updatePointerData(data: PointerData, rect: DOMRect) {\r\n data.position.set(pointerPosition.x - rect.left, pointerPosition.y - rect.top);\r\n data.nPosition.set((data.position.x / rect.width) * 2 - 1, (-data.position.y / rect.height) * 2 + 1);\r\n}\r\n\r\nfunction isInside(rect: DOMRect) {\r\n return (\r\n pointerPosition.x >= rect.left &&\r\n pointerPosition.x <= rect.left + rect.width &&\r\n pointerPosition.y >= rect.top &&\r\n pointerPosition.y <= rect.top + rect.height\r\n );\r\n}\r\n\r\nclass Z extends InstancedMesh {\r\n config: typeof XConfig;\r\n physics: W;\r\n ambientLight: AmbientLight | undefined;\r\n light: PointLight | undefined;\r\n\r\n constructor(renderer: WebGLRenderer, params: Partial = {}) {\r\n const config = { ...XConfig, ...params };\r\n const roomEnv = new RoomEnvironment();\r\n const pmrem = new PMREMGenerator(renderer);\r\n const envTexture = pmrem.fromScene(roomEnv).texture;\r\n const geometry = new SphereGeometry();\r\n const material = new Y({ envMap: envTexture, ...config.materialParams });\r\n material.envMapRotation.x = -Math.PI / 2;\r\n super(geometry, material, config.count);\r\n this.config = config;\r\n this.physics = new W(config);\r\n this.#setupLights();\r\n this.setColors(config.colors);\r\n }\r\n\r\n #setupLights() {\r\n this.ambientLight = new AmbientLight(this.config.ambientColor, this.config.ambientIntensity);\r\n this.add(this.ambientLight);\r\n this.light = new PointLight(this.config.colors[0], this.config.lightIntensity);\r\n this.add(this.light);\r\n }\r\n\r\n setColors(colors: number[]) {\r\n if (Array.isArray(colors) && colors.length > 1) {\r\n const colorUtils = (function (colorsArr: number[]) {\r\n let baseColors: number[] = colorsArr;\r\n let colorObjects: Color[] = [];\r\n baseColors.forEach(col => {\r\n colorObjects.push(new Color(col));\r\n });\r\n return {\r\n setColors: (cols: number[]) => {\r\n baseColors = cols;\r\n colorObjects = [];\r\n baseColors.forEach(col => {\r\n colorObjects.push(new Color(col));\r\n });\r\n },\r\n getColorAt: (ratio: number, out: Color = new Color()) => {\r\n const clamped = Math.max(0, Math.min(1, ratio));\r\n const scaled = clamped * (baseColors.length - 1);\r\n const idx = Math.floor(scaled);\r\n const start = colorObjects[idx];\r\n if (idx >= baseColors.length - 1) return start.clone();\r\n const alpha = scaled - idx;\r\n const end = colorObjects[idx + 1];\r\n out.r = start.r + alpha * (end.r - start.r);\r\n out.g = start.g + alpha * (end.g - start.g);\r\n out.b = start.b + alpha * (end.b - start.b);\r\n return out;\r\n }\r\n };\r\n })(colors);\r\n for (let idx = 0; idx < this.count; idx++) {\r\n this.setColorAt(idx, colorUtils.getColorAt(idx / this.count));\r\n if (idx === 0) {\r\n this.light!.color.copy(colorUtils.getColorAt(idx / this.count));\r\n }\r\n }\r\n\r\n if (!this.instanceColor) return;\r\n this.instanceColor.needsUpdate = true;\r\n }\r\n }\r\n\r\n update(deltaInfo: { delta: number }) {\r\n this.physics.update(deltaInfo);\r\n for (let idx = 0; idx < this.count; idx++) {\r\n U.position.fromArray(this.physics.positionData, 3 * idx);\r\n if (idx === 0 && this.config.followCursor === false) {\r\n U.scale.setScalar(0);\r\n } else {\r\n U.scale.setScalar(this.physics.sizeData[idx]);\r\n }\r\n U.updateMatrix();\r\n this.setMatrixAt(idx, U.matrix);\r\n if (idx === 0) this.light!.position.copy(U.position);\r\n }\r\n this.instanceMatrix.needsUpdate = true;\r\n }\r\n}\r\n\r\ninterface CreateBallpitReturn {\r\n three: X;\r\n spheres: Z;\r\n setCount: (count: number) => void;\r\n togglePause: () => void;\r\n dispose: () => void;\r\n}\r\n\r\nfunction createBallpit(canvas: HTMLCanvasElement, config: any = {}): CreateBallpitReturn {\r\n const threeInstance = new X({\r\n canvas,\r\n size: 'parent',\r\n rendererOptions: { antialias: true, alpha: true }\r\n });\r\n let spheres: Z;\r\n threeInstance.renderer.toneMapping = ACESFilmicToneMapping;\r\n threeInstance.camera.position.set(0, 0, 20);\r\n threeInstance.camera.lookAt(0, 0, 0);\r\n threeInstance.cameraMaxAspect = 1.5;\r\n threeInstance.resize();\r\n initialize(config);\r\n const raycaster = new Raycaster();\r\n const plane = new Plane(new Vector3(0, 0, 1), 0);\r\n const intersectionPoint = new Vector3();\r\n let isPaused = false;\r\n\r\n canvas.style.touchAction = 'none';\r\n canvas.style.userSelect = 'none';\r\n (canvas.style as any).webkitUserSelect = 'none';\r\n\r\n const pointerData = createPointerData({\r\n domElement: canvas,\r\n onMove() {\r\n raycaster.setFromCamera(pointerData.nPosition, threeInstance.camera);\r\n threeInstance.camera.getWorldDirection(plane.normal);\r\n raycaster.ray.intersectPlane(plane, intersectionPoint);\r\n spheres.physics.center.copy(intersectionPoint);\r\n spheres.config.controlSphere0 = true;\r\n },\r\n onLeave() {\r\n spheres.config.controlSphere0 = false;\r\n }\r\n });\r\n function initialize(cfg: any) {\r\n if (spheres) {\r\n threeInstance.clear();\r\n threeInstance.scene.remove(spheres);\r\n }\r\n spheres = new Z(threeInstance.renderer, cfg);\r\n threeInstance.scene.add(spheres);\r\n }\r\n threeInstance.onBeforeRender = deltaInfo => {\r\n if (!isPaused) spheres.update(deltaInfo);\r\n };\r\n threeInstance.onAfterResize = size => {\r\n spheres.config.maxX = size.wWidth / 2;\r\n spheres.config.maxY = size.wHeight / 2;\r\n };\r\n return {\r\n three: threeInstance,\r\n get spheres() {\r\n return spheres;\r\n },\r\n setCount(count: number) {\r\n initialize({ ...spheres.config, count });\r\n },\r\n togglePause() {\r\n isPaused = !isPaused;\r\n },\r\n dispose() {\r\n pointerData.dispose?.();\r\n threeInstance.dispose();\r\n }\r\n };\r\n}\r\n\r\ninterface BallpitProps {\r\n className?: string;\r\n followCursor?: boolean;\r\n [key: string]: any;\r\n}\r\n\r\nconst Ballpit: React.FC = ({ className = '', followCursor = true, ...props }) => {\r\n const canvasRef = useRef(null);\r\n const spheresInstanceRef = useRef(null);\r\n\r\n useEffect(() => {\r\n const canvas = canvasRef.current;\r\n if (!canvas) return;\r\n\r\n spheresInstanceRef.current = createBallpit(canvas, {\r\n followCursor,\r\n ...props\r\n });\r\n\r\n return () => {\r\n if (spheresInstanceRef.current) {\r\n spheresInstanceRef.current.dispose();\r\n }\r\n };\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, []);\r\n\r\n return ;\r\n};\r\n\r\nexport default Ballpit;\r\n", "type": "registry:component" } ] }