shadimage
svelte-bits
componentballpitsource: ok · hit

Ballpit

Interactive 3D ball pit using Three.js physics.

npx shadcn add "https://sveltebits.xyz/r/ballpit.json"
free

Visual style

3d

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · heavythree
webglthree
animationgsap

Files1 · source cached 10/9/2026, 6:07:22 AM

Ballpit.svelte
<script lang="ts" module>
	import {
		ACESFilmicToneMapping,
		AmbientLight,
		Clock,
		Color,
		InstancedMesh,
		MathUtils,
		MeshPhysicalMaterial,
		Object3D,
		PerspectiveCamera as ThreePerspectiveCamera,
		Plane,
		PMREMGenerator,
		PointLight,
		Raycaster,
		Scene,
		ShaderChunk,
		SphereGeometry,
		SRGBColorSpace,
		Vector2,
		Vector3,
		WebGLRenderer,
		type WebGLRendererParameters
	} from 'three';
	import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';

	type SizeData = { width: number; height: number; wWidth: number; wHeight: number; ratio: number; pixelRatio: number };

	type XConfig = {
		canvas?: HTMLCanvasElement;
		rendererOptions?: Partial<WebGLRendererParameters>;
		size?: 'parent' | { width: number; height: number };
	};

	class X {
		#config: XConfig;
		#postprocessing: { render: () => void; setSize?: (w: number, h: number) => void; dispose?: () => void } | null = null;
		#resizeObserver?: ResizeObserver;
		#intersectionObserver?: IntersectionObserver;
		#resizeTimer?: number;
		#animationFrameId = 0;
		#clock = new Clock();
		#animationState = { elapsed: 0, delta: 0 };
		#isAnimating = false;
		#isVisible = false;
		#boundOnResize = this.#onResize.bind(this);
		#boundOnVisibility = this.#onVisibilityChange.bind(this);

		canvas!: HTMLCanvasElement;
		camera!: ThreePerspectiveCamera;
		cameraMinAspect?: number;
		cameraMaxAspect?: number;
		cameraFov!: number;
		maxPixelRatio?: number;
		minPixelRatio?: number;
		scene!: Scene;
		renderer!: WebGLRenderer;
		size: SizeData = { width: 0, height: 0, wWidth: 0, wHeight: 0, ratio: 0, pixelRatio: 0 };
		render: () => void = this.#render.bind(this);
		onBeforeRender: (s: { elapsed: number; delta: number }) => void = () => {};
		onAfterRender: (s: { elapsed: number; delta: number }) => void = () => {};
		onAfterResize: (s: SizeData) => void = () => {};
		isDisposed = false;

		constructor(config: XConfig) {
			this.#config = { ...config };
			this.camera = new ThreePerspectiveCamera();
			this.cameraFov = this.camera.fov;
			this.scene = new Scene();
			this.canvas = this.#config.canvas!;
			this.canvas.style.display = 'block';
			this.renderer = new WebGLRenderer({
				canvas: this.canvas,
				powerPreference: 'high-performance',
				...(this.#config.rendererOptions ?? {})
			});
			this.renderer.outputColorSpace = SRGBColorSpace;
			this.renderer.setClearColor(0x000000, 0);
			this.resize();
			this.#initObservers();
		}

		#initObservers() {
			if (!(this.#config.size instanceof Object)) {
				window.addEventListener('resize', this.#boundOnResize);
				if (this.#config.size === 'parent' && this.canvas.parentNode) {
					this.#resizeObserver = new ResizeObserver(this.#boundOnResize);
					this.#resizeObserver.observe(this.canvas.parentNode as Element);
				}
			}
			this.#intersectionObserver = new IntersectionObserver((entries) => {
				this.#isAnimating = entries[0].isIntersecting;
				if (this.#isAnimating) this.#startAnimation();
				else this.#stopAnimation();
			}, { root: null, rootMargin: '0px', threshold: 0 });
			this.#intersectionObserver.observe(this.canvas);
			document.addEventListener('visibilitychange', this.#boundOnVisibility);
		}

		#onResize() {
			if (this.#resizeTimer) clearTimeout(this.#resizeTimer);
			this.#resizeTimer = window.setTimeout(() => this.resize(), 100);
		}

		resize() {
			let w: number, h: number;
			if (this.#config.size instanceof Object) {
				w = this.#config.size.width; h = this.#config.size.height;
			} else if (this.#config.size === 'parent' && this.canvas.parentNode) {
				w = (this.canvas.parentNode as HTMLElement).offsetWidth;
				h = (this.canvas.parentNode as HTMLElement).offsetHeight;
			} else {
				w = window.innerWidth; h = window.innerHeight;
			}
			this.size.width = w; this.size.height = h; this.size.ratio = w / h;
			this.camera.aspect = w / h;
			if (this.cameraMinAspect && this.camera.aspect < this.cameraMinAspect) {
				const tanFov = Math.tan(MathUtils.degToRad(this.cameraFov / 2));
				const newTan = tanFov / (this.camera.aspect / this.cameraMinAspect);
				this.camera.fov = 2 * MathUtils.radToDeg(Math.atan(newTan));
			} else if (this.cameraMaxAspect && this.camera.aspect > this.cameraMaxAspect) {
				const tanFov = Math.tan(MathUtils.degToRad(this.cameraFov / 2));
				const newTan = tanFov / (this.camera.aspect / this.cameraMaxAspect);
				this.camera.fov = 2 * MathUtils.radToDeg(Math.atan(newTan));
			} else {
				this.camera.fov = this.cameraFov;
			}
			this.camera.updateProjectionMatrix();
			const fovRad = (this.camera.fov * Math.PI) / 180;
			this.size.wHeight = 2 * Math.tan(fovRad / 2) * this.camera.position.length();
			this.size.wWidth = this.size.wHeight * this.camera.aspect;
			this.renderer.setSize(this.size.width, this.size.height);
			this.#postprocessing?.setSize?.(this.size.width, this.size.height);
			let pr = window.devicePixelRatio;
			if (this.maxPixelRatio && pr > this.maxPixelRatio) pr = this.maxPixelRatio;
			else if (this.minPixelRatio && pr < this.minPixelRatio) pr = this.minPixelRatio;
			this.renderer.setPixelRatio(pr);
			this.size.pixelRatio = pr;
			this.onAfterResize(this.size);
		}

		#onVisibilityChange() {
			if (this.#isAnimating) {
				if (document.hidden) this.#stopAnimation();
				else this.#startAnimation();
			}
		}

		#startAnimation() {
			if (this.#isVisible) return;
			const animateFrame = () => {
				this.#animationFrameId = requestAnimationFrame(animateFrame);
				this.#animationState.delta = this.#clock.getDelta();
				this.#animationState.elapsed += this.#animationState.delta;
				this.onBeforeRender(this.#animationState);
				this.render();
				this.onAfterRender(this.#animationState);
			};
			this.#isVisible = true;
			this.#clock.start();
			animateFrame();
		}

		#stopAnimation() {
			if (this.#isVisible) {
				cancelAnimationFrame(this.#animationFrameId);
				this.#isVisible = false;
				this.#clock.stop();
			}
		}

		#render() { this.renderer.render(this.scene, this.camera); }

		clear() {
			this.scene.traverse((obj) => {
				const m = obj as unknown as { isMesh?: boolean; material?: { dispose?: () => void; [k: string]: unknown }; geometry?: { dispose?: () => void } };
				if (m.isMesh && m.material && typeof m.material === 'object') {
					Object.keys(m.material).forEach((k) => {
						const p = (m.material as Record<string, unknown>)[k];
						if (p && typeof p === 'object' && typeof (p as { dispose?: () => void }).dispose === 'function') (p as { dispose: () => void }).dispose();
					});
					m.material.dispose?.();
					m.geometry?.dispose?.();
				}
			});
			this.scene.clear();
		}

		dispose() {
			window.removeEventListener('resize', this.#boundOnResize);
			this.#resizeObserver?.disconnect();
			this.#intersectionObserver?.disconnect();
			document.removeEventListener('visibilitychange', this.#boundOnVisibility);
			this.#stopAnimation();
			this.clear();
			this.#postprocessing?.dispose?.();
			this.renderer.dispose();
			this.renderer.forceContextLoss();
			this.isDisposed = true;
		}
	}

	type WConfig = {
		count: number; maxX: number; maxY: number; maxZ: number;
		maxSize: number; minSize: number; size0: number;
		gravity: number; friction: number; wallBounce: number; maxVelocity: number;
		controlSphere0?: boolean; followCursor?: boolean;
	};

	class W {
		config: WConfig;
		positionData: Float32Array;
		velocityData: Float32Array;
		sizeData: Float32Array;
		center = new Vector3();

		constructor(config: WConfig) {
			this.config = config;
			this.positionData = new Float32Array(3 * config.count);
			this.velocityData = new Float32Array(3 * config.count);
			this.sizeData = new Float32Array(config.count).fill(1);
			this.center.toArray(this.positionData, 0);
			for (let i = 1; i < config.count; i++) {
				const idx = 3 * i;
				this.positionData[idx] = MathUtils.randFloatSpread(2 * config.maxX);
				this.positionData[idx + 1] = MathUtils.randFloatSpread(2 * config.maxY);
				this.positionData[idx + 2] = MathUtils.randFloatSpread(2 * config.maxZ);
			}
			this.sizeData[0] = config.size0;
			for (let i = 1; i < config.count; i++) {
				this.sizeData[i] = MathUtils.randFloat(config.minSize, config.maxSize);
			}
		}

		update(deltaInfo: { delta: number }) {
			const { config, center, positionData, sizeData, velocityData } = this;
			let startIdx = 0;
			if (config.controlSphere0) {
				startIdx = 1;
				const firstVec = new Vector3().fromArray(positionData, 0);
				firstVec.lerp(center, 0.1).toArray(positionData, 0);
				new Vector3(0, 0, 0).toArray(velocityData, 0);
			}
			for (let idx = startIdx; idx < config.count; idx++) {
				const base = 3 * idx;
				const pos = new Vector3().fromArray(positionData, base);
				const vel = new Vector3().fromArray(velocityData, base);
				vel.y -= deltaInfo.delta * config.gravity * sizeData[idx];
				vel.multiplyScalar(config.friction);
				vel.clampLength(0, config.maxVelocity);
				pos.add(vel);
				pos.toArray(positionData, base);
				vel.toArray(velocityData, base);
			}
			for (let idx = startIdx; idx < config.count; idx++) {
				const base = 3 * idx;
				const pos = new Vector3().fromArray(positionData, base);
				const vel = new Vector3().fromArray(velocityData, base);
				const radius = sizeData[idx];
				for (let jdx = idx + 1; jdx < config.count; jdx++) {
					const otherBase = 3 * jdx;
					const otherPos = new Vector3().fromArray(positionData, otherBase);
					const otherVel = new Vector3().fromArray(velocityData, otherBase);
					const diff = new Vector3().copy(otherPos).sub(pos);
					const dist = diff.length();
					const sumRadius = radius + sizeData[jdx];
					if (dist < sumRadius) {
						const overlap = sumRadius - dist;
						const correction = diff.normalize().multiplyScalar(0.5 * overlap);
						const velCorrection = correction.clone().multiplyScalar(Math.max(vel.length(), 1));
						pos.sub(correction);
						vel.sub(velCorrection);
						pos.toArray(positionData, base);
						vel.toArray(velocityData, base);
						otherPos.add(correction);
						otherVel.add(correction.clone().multiplyScalar(Math.max(otherVel.length(), 1)));
						otherPos.toArray(positionData, otherBase);
						otherVel.toArray(velocityData, otherBase);
					}
				}
				if (config.controlSphere0) {
					const diff = new Vector3().copy(new Vector3().fromArray(positionData, 0)).sub(pos);
					const d = diff.length();
					const sumRadius0 = radius + sizeData[0];
					if (d < sumRadius0) {
						const correction = diff.normalize().multiplyScalar(sumRadius0 - d);
						const velCorrection = correction.clone().multiplyScalar(Math.max(vel.length(), 2));
						pos.sub(correction);
						vel.sub(velCorrection);
					}
				}
				if (Math.abs(pos.x) + radius > config.maxX) {
					pos.x = Math.sign(pos.x) * (config.maxX - radius);
					vel.x = -vel.x * config.wallBounce;
				}
				if (config.gravity === 0) {
					if (Math.abs(pos.y) + radius > config.maxY) {
						pos.y = Math.sign(pos.y) * (config.maxY - radius);
						vel.y = -vel.y * config.wallBounce;
					}
				} else if (pos.y - radius < -config.maxY) {
					pos.y = -config.maxY + radius;
					vel.y = -vel.y * config.wallBounce;
				}
				const maxBoundary = Math.max(config.maxZ, config.maxSize);
				if (Math.abs(pos.z) + radius > maxBoundary) {
					pos.z = Math.sign(pos.z) * (config.maxZ - radius);
					vel.z = -vel.z * config.wallBounce;
				}
				pos.toArray(positionData, base);
				vel.toArray(velocityData, base);
			}
		}
	}

	class Y extends MeshPhysicalMaterial {
		uniforms: Record<string, { value: unknown }> = {
			thicknessDistortion: { value: 0.1 },
			thicknessAmbient: { value: 0 },
			thicknessAttenuation: { value: 0.1 },
			thicknessPower: { value: 2 },
			thicknessScale: { value: 10 }
		};
		// @ts-expect-error override
		defines: { USE_UV: string };
		constructor(params: ConstructorParameters<typeof MeshPhysicalMaterial>[0]) {
			super(params);
			this.defines = { USE_UV: '' };
			this.onBeforeCompile = (shader) => {
				Object.assign(shader.uniforms, this.uniforms);
				shader.fragmentShader = `
uniform float thicknessPower;
uniform float thicknessScale;
uniform float thicknessDistortion;
uniform float thicknessAmbient;
uniform float thicknessAttenuation;
` + shader.fragmentShader;
				shader.fragmentShader = shader.fragmentShader.replace('void main() {', `
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) {
  vec3 scatteringHalf = normalize(directLight.direction + (geometryNormal * thicknessDistortion));
  float scatteringDot = pow(saturate(dot(geometryViewDir, -scatteringHalf)), thicknessPower) * thicknessScale;
  #ifdef USE_COLOR
    vec3 scatteringIllu = (scatteringDot + thicknessAmbient) * vColor.rgb;
  #else
    vec3 scatteringIllu = (scatteringDot + thicknessAmbient) * diffuse;
  #endif
  reflectedLight.directDiffuse += scatteringIllu * thicknessAttenuation * directLight.color;
}
void main() {
`);
				const lightsChunk = ShaderChunk.lights_fragment_begin.replaceAll(
					'RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );',
					'RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\nRE_Direct_Scattering(directLight, vUv, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, reflectedLight);'
				);
				shader.fragmentShader = shader.fragmentShader.replace('#include <lights_fragment_begin>', lightsChunk);
			};
		}
	}

	const DEFAULT_CONFIG = {
		count: 200,
		colors: [0, 0, 0],
		ambientColor: 0xffffff,
		ambientIntensity: 1,
		lightIntensity: 200,
		materialParams: { metalness: 0.5, roughness: 0.5, clearcoat: 1, clearcoatRoughness: 0.15 },
		minSize: 0.5, maxSize: 1, size0: 1,
		gravity: 0.5, friction: 0.9975, wallBounce: 0.95, maxVelocity: 0.15,
		maxX: 5, maxY: 5, maxZ: 2,
		controlSphere0: false, followCursor: true
	};

	const TEMP_OBJ = new Object3D();

	class Z extends InstancedMesh {
		config: typeof DEFAULT_CONFIG;
		physics: W;
		ambientLight: AmbientLight;
		light: PointLight;
		constructor(renderer: WebGLRenderer, params: Partial<typeof DEFAULT_CONFIG> = {}) {
			const config = { ...DEFAULT_CONFIG, ...params, materialParams: { ...DEFAULT_CONFIG.materialParams, ...(params.materialParams ?? {}) } };
			const roomEnv = new RoomEnvironment();
			const pmrem = new PMREMGenerator(renderer);
			const envTexture = pmrem.fromScene(roomEnv).texture;
			const geometry = new SphereGeometry();
			const material = new Y({ envMap: envTexture, ...config.materialParams });
			material.envMapRotation.x = -Math.PI / 2;
			super(geometry, material, config.count);
			this.config = config;
			this.physics = new W(config);
			this.ambientLight = new AmbientLight(config.ambientColor, config.ambientIntensity);
			this.add(this.ambientLight);
			this.light = new PointLight(config.colors[0], config.lightIntensity);
			this.add(this.light);
			this.setColors(config.colors);
		}

		setColors(colors: number[]) {
			if (Array.isArray(colors) && colors.length > 1) {
				const objs = colors.map((c) => new Color(c));
				const getColorAt = (ratio: number, out = new Color()) => {
					const clamped = Math.max(0, Math.min(1, ratio));
					const scaled = clamped * (colors.length - 1);
					const idx = Math.floor(scaled);
					const start = objs[idx];
					if (idx >= colors.length - 1) return start.clone();
					const alpha = scaled - idx;
					const end = objs[idx + 1];
					out.r = start.r + alpha * (end.r - start.r);
					out.g = start.g + alpha * (end.g - start.g);
					out.b = start.b + alpha * (end.b - start.b);
					return out;
				};
				for (let i = 0; i < this.count; i++) {
					this.setColorAt(i, getColorAt(i / this.count));
					if (i === 0) this.light.color.copy(getColorAt(i / this.count));
				}
				if (this.instanceColor) this.instanceColor.needsUpdate = true;
			}
		}

		update(deltaInfo: { delta: number }) {
			this.physics.update(deltaInfo);
			for (let idx = 0; idx < this.count; idx++) {
				TEMP_OBJ.position.fromArray(this.physics.positionData, 3 * idx);
				if (idx === 0 && this.config.followCursor === false) TEMP_OBJ.scale.setScalar(0);
				else TEMP_OBJ.scale.setScalar(this.physics.sizeData[idx]);
				TEMP_OBJ.updateMatrix();
				this.setMatrixAt(idx, TEMP_OBJ.matrix);
				if (idx === 0) this.light.position.copy(TEMP_OBJ.position);
			}
			this.instanceMatrix.needsUpdate = true;
		}
	}

	export type BallpitInstance = {
		three: X;
		spheres: Z;
		setCount: (count: number) => void;
		togglePause: () => void;
		dispose: () => void;
	};

	export function createBallpit(canvas: HTMLCanvasElement, config: Partial<typeof DEFAULT_CONFIG> = {}): BallpitInstance {
		const threeInstance = new X({ canvas, size: 'parent', rendererOptions: { antialias: true, alpha: true } });
		threeInstance.renderer.toneMapping = ACESFilmicToneMapping;
		threeInstance.camera.position.set(0, 0, 20);
		threeInstance.camera.lookAt(0, 0, 0);
		threeInstance.cameraMaxAspect = 1.5;
		threeInstance.resize();
		let spheres!: Z;
		const initialize = (cfg: Partial<typeof DEFAULT_CONFIG>) => {
			if (spheres) {
				threeInstance.clear();
				threeInstance.scene.remove(spheres);
			}
			spheres = new Z(threeInstance.renderer, cfg);
			threeInstance.scene.add(spheres);
		};
		initialize(config);
		const raycaster = new Raycaster();
		const plane = new Plane(new Vector3(0, 0, 1), 0);
		const intersection = new Vector3();
		let isPaused = false;

		canvas.style.touchAction = 'none';
		canvas.style.userSelect = 'none';

		const nPos = new Vector2();
		let hovering = false;
		const onPointerMove = (e: PointerEvent) => {
			const rect = canvas.getBoundingClientRect();
			if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) {
				if (hovering) { hovering = false; spheres.config.controlSphere0 = false; }
				return;
			}
			hovering = true;
			nPos.set(((e.clientX - rect.left) / rect.width) * 2 - 1, (-(e.clientY - rect.top) / rect.height) * 2 + 1);
			raycaster.setFromCamera(nPos, threeInstance.camera);
			threeInstance.camera.getWorldDirection(plane.normal);
			raycaster.ray.intersectPlane(plane, intersection);
			spheres.physics.center.copy(intersection);
			spheres.config.controlSphere0 = true;
		};
		const onPointerLeave = () => { hovering = false; spheres.config.controlSphere0 = false; };
		document.body.addEventListener('pointermove', onPointerMove);
		canvas.addEventListener('pointerleave', onPointerLeave);

		threeInstance.onBeforeRender = (deltaInfo) => { if (!isPaused) spheres.update(deltaInfo); };
		threeInstance.onAfterResize = (size) => { spheres.config.maxX = size.wWidth / 2; spheres.config.maxY = size.wHeight / 2; };

		return {
			three: threeInstance,
			get spheres() { return spheres; },
			setCount(count) { initialize({ ...spheres.config, count }); },
			togglePause() { isPaused = !isPaused; },
			dispose() {
				document.body.removeEventListener('pointermove', onPointerMove);
				canvas.removeEventListener('pointerleave', onPointerLeave);
				threeInstance.dispose();
			}
		};
	}
</script>

<script lang="ts">
	import { onMount } from 'svelte';

	type Props = {
		class?: string;
		count?: number;
		colors?: number[];
		ambientColor?: number;
		ambientIntensity?: number;
		lightIntensity?: number;
		minSize?: number;
		maxSize?: number;
		size0?: number;
		gravity?: number;
		friction?: number;
		wallBounce?: number;
		maxVelocity?: number;
		maxX?: number;
		maxY?: number;
		maxZ?: number;
		controlSphere0?: boolean;
		followCursor?: boolean;
		materialParams?: { metalness?: number; roughness?: number; clearcoat?: number; clearcoatRoughness?: number };
	};

	let {
		class: className = '',
		count,
		colors,
		ambientColor,
		ambientIntensity,
		lightIntensity,
		minSize,
		maxSize,
		size0,
		gravity,
		friction,
		wallBounce,
		maxVelocity,
		maxX,
		maxY,
		maxZ,
		controlSphere0,
		followCursor = true,
		materialParams
	}: Props = $props();

	let canvasRef: HTMLCanvasElement;

	onMount(() => {
		const cfg: Partial<typeof DEFAULT_CONFIG> = { followCursor };
		if (count !== undefined) cfg.count = count;
		if (colors !== undefined) cfg.colors = colors;
		if (ambientColor !== undefined) cfg.ambientColor = ambientColor;
		if (ambientIntensity !== undefined) cfg.ambientIntensity = ambientIntensity;
		if (lightIntensity !== undefined) cfg.lightIntensity = lightIntensity;
		if (minSize !== undefined) cfg.minSize = minSize;
		if (maxSize !== undefined) cfg.maxSize = maxSize;
		if (size0 !== undefined) cfg.size0 = size0;
		if (gravity !== undefined) cfg.gravity = gravity;
		if (friction !== undefined) cfg.friction = friction;
		if (wallBounce !== undefined) cfg.wallBounce = wallBounce;
		if (maxVelocity !== undefined) cfg.maxVelocity = maxVelocity;
		if (maxX !== undefined) cfg.maxX = maxX;
		if (maxY !== undefined) cfg.maxY = maxY;
		if (maxZ !== undefined) cfg.maxZ = maxZ;
		if (controlSphere0 !== undefined) cfg.controlSphere0 = controlSphere0;
		if (materialParams !== undefined) cfg.materialParams = materialParams as typeof DEFAULT_CONFIG.materialParams;
		const inst = createBallpit(canvasRef, cfg);
		return () => inst.dispose();
	});
</script>

<canvas bind:this={canvasRef} class="h-full w-full {className}"></canvas>

More from svelte-bits

Browse all components in this library →