# Facet > Facet is a copy-paste registry of production-ready React Three Fiber components. Components are installed as source files into your project. No runtime dependency, no lock-in. ## Install ```bash npx facet3d init npx facet3d add ``` ## Usage Components render inside a react-three-fiber Canvas: ```tsx import { Canvas } from '@react-three/fiber' import { HeroBlob } from '@/components/facet/hero-blob' export default function Page() { return ( ) } ``` ## Components ### hero-blob Hero Blob: Morphing distortion sphere: the classic 3D hero centerpiece. Props: - color: color = "#a3e635" - speed: number (0–10) = 2 - distort: number (0–1) = 0.4 - radius: number (0.5–3) = 1.5 - wireframe: boolean = false Install: `npx facet3d add hero-blob` Source: ```tsx // hero-blob — must be rendered inside a react-three-fiber . 'use client' import { ElementRef, useRef } from 'react' import { Group, Mesh } from 'three' import { useFrame, useThree } from '@react-three/fiber' import { Float, MeshDistortMaterial as DistortMaterial } from '@react-three/drei' export interface HeroBlobProps { color?: string speed?: number distort?: number radius?: number wireframe?: boolean } export function HeroBlob({ color = '#a3e635', speed = 2, distort = 0.4, radius = 1.5, wireframe = false, }: HeroBlobProps) { const groupRef = useRef(null) const meshRef = useRef(null) const materialRef = useRef>(null) const { pointer } = useThree() useFrame((state, delta) => { const t = state.clock.getElapsedTime() if (groupRef.current) { groupRef.current.rotation.x += (pointer.y * 0.35 - groupRef.current.rotation.x) * 0.04 groupRef.current.rotation.y += (pointer.x * 0.5 - groupRef.current.rotation.y) * 0.04 } if (meshRef.current) { meshRef.current.rotation.x += delta * 0.15 meshRef.current.rotation.y += delta * 0.2 } if (materialRef.current) { materialRef.current.distort = distort + Math.sin(t * speed * 0.5) * 0.12 } }) return ( ) } ``` ### particle-field Particle Field: Interactive particle cloud that reacts to the cursor. Props: - count: number (100–10000) = 3000 - color: color = "#a3e635" - radius: number (1–10) = 4 - size: number (0.005–0.1) = 0.02 - speed: number (0–1) = 0.1 Install: `npx facet3d add particle-field` Source: ```tsx // particle-field — interactive particle cloud. Must be rendered inside a react-three-fiber . 'use client' import { useMemo, useRef } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' export interface ParticleFieldProps { count?: number color?: string radius?: number size?: number speed?: number } export function ParticleField({ count = 3000, color = '#a3e635', radius = 4, size = 0.02, speed = 0.1, }: ParticleFieldProps) { const groupRef = useRef(null) const positions = useMemo(() => { const positions = new Float32Array(count * 3) for (let i = 0; i < count; i++) { const r = radius * Math.cbrt(Math.random()) const theta = Math.random() * Math.PI * 2 const phi = Math.acos(2 * Math.random() - 1) positions[i * 3] = r * Math.sin(phi) * Math.cos(theta) positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta) positions[i * 3 + 2] = r * Math.cos(phi) } return positions }, [count, radius]) useFrame((state, delta) => { const group = groupRef.current if (!group) return const targetY = group.rotation.y + delta * speed const { x, y } = state.pointer group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, targetY + x * 0.4, 0.05) group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, y * 0.4, 0.05) }) return ( ) } ``` ### image-particles Image Particles: Particles that assemble into any text or image, and scatter under the cursor. Props: - text: text = "FACET" - color: color = "#a3e635" - particleSize: number (0.01–0.2) = 0.05 - density: number (1–8) = 3 - scatter: number (0–5) = 1.5 Install: `npx facet3d add image-particles` Source: ```tsx // ImageParticles — particles assemble into text and scatter under the cursor. // Must be rendered inside a react-three-fiber . 'use client' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { extend, useFrame } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' export interface ImageParticlesProps { text?: string color?: string particleSize?: number density?: number scatter?: number } const ImageParticlesMaterial = shaderMaterial( { uProgress: 0, uMouse: new THREE.Vector3(9999, 9999, 0), uScatter: 1.5, uSize: 0.05, uScale: 1, uColor: new THREE.Color('#a3e635'), uTime: 0, }, /* glsl */ ` uniform float uProgress; uniform vec3 uMouse; uniform float uScatter; uniform float uSize; uniform float uScale; uniform float uTime; attribute vec3 aTarget; attribute float aRandom; void main() { float t = smoothstep(0.0, 1.0, clamp(uProgress * 1.5 - aRandom * 0.5, 0.0, 1.0)); vec3 pos = mix(position, aTarget, t); pos.x += sin(uTime + aRandom * 6.28) * 0.05; pos.y += cos(uTime * 0.8 + aRandom * 6.28) * 0.05; vec2 toParticle = pos.xy - uMouse.xy; float d = length(toParticle); // Note: smoothstep edges must stay ascending — reversed edges are // undefined behavior in GLSL and return NaN on Metal (macOS/iOS), // which wipes out every vertex. float repel = 1.0 - smoothstep(0.0, max(uScatter, 0.001), d); pos.xy += (toParticle / max(d, 0.001)) * repel * 0.6; vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); // uSize is a world-space diameter; uScale converts world units to // device pixels at unit distance (set from viewport size + fov + dpr). gl_PointSize = uSize * uScale / max(-mvPosition.z, 0.1); gl_Position = projectionMatrix * mvPosition; } `, /* glsl */ ` uniform vec3 uColor; void main() { float d = distance(gl_PointCoord, vec2(0.5)); if (d > 0.5) discard; float alpha = smoothstep(0.5, 0.1, d); gl_FragColor = vec4(uColor, alpha); } ` ) extend({ ImageParticlesMaterial }) declare global { namespace JSX { interface IntrinsicElements { imageParticlesMaterial: any } } } interface ParticleData { start: Float32Array target: Float32Array random: Float32Array } export function ImageParticles({ text = 'FACET', color = '#a3e635', particleSize = 0.05, density = 3, scatter = 1.5, }: ImageParticlesProps) { const materialRef = useRef(null) const [data, setData] = useState(null) const tmpVec = useMemo(() => new THREE.Vector3(), []) const tmpDir = useMemo(() => new THREE.Vector3(), []) // Only scatter once the cursor has actually moved over the window — // state.pointer defaults to (0,0), which would dent the text's center. const pointerActive = useRef(false) useEffect(() => { const onMove = () => { pointerActive.current = true } const onLeave = () => { pointerActive.current = false } window.addEventListener('pointermove', onMove) document.documentElement.addEventListener('pointerleave', onLeave) return () => { window.removeEventListener('pointermove', onMove) document.documentElement.removeEventListener('pointerleave', onLeave) } }, []) // Sample the text pixels offscreen (DOM access — must stay inside useEffect for SSR safety). useEffect(() => { const width = 400 const height = 200 const canvas = document.createElement('canvas') canvas.width = width canvas.height = height const ctx = canvas.getContext('2d') if (!ctx || !text) { setData(null) return } let fontSize = 200 ctx.font = `bold ${fontSize}px sans-serif` const measured = ctx.measureText(text).width if (measured > width) { fontSize = Math.floor((fontSize * width) / measured) ctx.font = `bold ${fontSize}px sans-serif` } ctx.fillStyle = '#ffffff' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(text, width / 2, height / 2) const pixels = ctx.getImageData(0, 0, width, height).data const step = Math.max(1, Math.round(density)) const coords: number[] = [] for (let y = 0; y < height; y += step) { for (let x = 0; x < width; x += step) { if (pixels[(y * width + x) * 4 + 3] > 128) coords.push(x, y) } } if (coords.length === 0) { setData(null) return } const count = coords.length / 2 const start = new Float32Array(count * 3) const target = new Float32Array(count * 3) const random = new Float32Array(count) const scale = 6 / width for (let i = 0; i < count; i++) { target[i * 3] = (coords[i * 2] - width / 2) * scale target[i * 3 + 1] = -(coords[i * 2 + 1] - height / 2) * scale target[i * 3 + 2] = 0 const theta = Math.random() * Math.PI * 2 const phi = Math.acos(2 * Math.random() - 1) const r = 5 + Math.random() * 5 start[i * 3] = r * Math.sin(phi) * Math.cos(theta) start[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta) start[i * 3 + 2] = r * Math.cos(phi) random[i] = Math.random() } setData({ start, target, random }) // Re-assemble from scratch on text/density change. if (materialRef.current) materialRef.current.uProgress = 0 }, [text, density]) useFrame((state, delta) => { const m = materialRef.current if (!m) return m.uTime += delta m.uProgress = THREE.MathUtils.damp(m.uProgress, 1, 3, delta) // Convert the world-space uSize to device pixels: pixels per world unit // at unit distance, from viewport height, camera fov and pixel ratio. const fov = (state.camera as THREE.PerspectiveCamera).fov ?? 50 m.uScale = (state.size.height * state.viewport.dpr) / (2 * Math.tan(THREE.MathUtils.degToRad(fov) / 2)) // Unproject the pointer onto the z=0 plane. if (pointerActive.current) { tmpVec.set(state.pointer.x, state.pointer.y, 0.5).unproject(state.camera) tmpDir.copy(tmpVec).sub(state.camera.position).normalize() const dist = -state.camera.position.z / tmpDir.z if (Number.isFinite(dist) && dist > 0) { m.uMouse.copy(tmpVec.copy(state.camera.position).add(tmpDir.multiplyScalar(dist))) } } else { m.uMouse.set(9999, 9999, 0) } }) if (!data) return null return ( ) } ``` ### galaxy Galaxy: Procedural spiral galaxy with tens of thousands of shader-driven stars. Props: - count: number (2000–50000) = 20000 - branches: number (2–10) = 3 - spin: number (-3–3) = 1 - radius: number (1–20) = 8 - randomness: number (0–2) = 0.4 - insideColor: color = "#ff6030" - outsideColor: color = "#1b3984" Install: `npx facet3d add galaxy` Source: ```tsx // galaxy — procedural spiral galaxy. Must be rendered inside a react-three-fiber . 'use client' import { useMemo, useRef } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' export interface GalaxyProps { count?: number branches?: number spin?: number radius?: number randomness?: number insideColor?: string outsideColor?: string } export function Galaxy({ count = 20000, branches = 3, spin = 1, radius = 8, randomness = 0.4, insideColor = '#ff6030', outsideColor = '#1b3984', }: GalaxyProps) { const groupRef = useRef(null) const [positions, colors] = useMemo(() => { const positions = new Float32Array(count * 3) const colors = new Float32Array(count * 3) const colorInside = new THREE.Color(insideColor) const colorOutside = new THREE.Color(outsideColor) for (let i = 0; i < count; i++) { const i3 = i * 3 const r = Math.random() * radius const branchAngle = ((i % branches) / branches) * Math.PI * 2 const spinAngle = r * spin // gaussian-ish randomness, scaled by distance from the core const randomX = (Math.random() + Math.random() + Math.random() - 1.5) * randomness * r const randomY = (Math.random() + Math.random() + Math.random() - 1.5) * randomness * r const randomZ = (Math.random() + Math.random() + Math.random() - 1.5) * randomness * r positions[i3] = Math.cos(branchAngle + spinAngle) * r + randomX positions[i3 + 1] = randomY positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * r + randomZ const mixedColor = colorInside.clone().lerp(colorOutside, r / radius) // slight lightness jitter so stars don't look like a smooth gradient mixedColor.offsetHSL(0, 0, (Math.random() - 0.5) * 0.2) colors[i3] = mixedColor.r colors[i3 + 1] = mixedColor.g colors[i3 + 2] = mixedColor.b } return [positions, colors] }, [count, branches, spin, radius, randomness, insideColor, outsideColor]) useFrame((state, delta) => { const group = groupRef.current if (!group) return group.rotation.y += delta * 0.05 // subtle pointer parallax on the tilt axis const targetX = state.pointer.y * 0.15 group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, targetX, 0.05) }) return ( // ~25° static tilt so the disc reads with depth ) } ``` ### globe-arcs Globe Arcs: Interactive dotted globe with animated connection arcs: the infra-startup staple. Props: - color: color = "#a3e635" - arcColor: color = "#f97316" - arcCount: number (0–24) = 10 - speed: number (0–2) = 0.3 Install: `npx facet3d add globe-arcs` Source: ```tsx // globe-arcs — dotted globe with animated connection arcs. Must be rendered inside a react-three-fiber . 'use client' import { useMemo, useRef } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' import { Line } from '@react-three/drei' export interface GlobeArcsProps { color?: string arcColor?: string arcCount?: number speed?: number } const RADIUS = 2.5 const DOT_COUNT = 900 const ARC_SAMPLES = 64 // Deterministic PRNG so arc pairs don't reshuffle between renders. function mulberry32(seed: number) { return () => { seed |= 0 seed = (seed + 0x6d2b79f5) | 0 let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } export function GlobeArcs({ color = '#a3e635', arcColor = '#f97316', arcCount = 10, speed = 0.3, }: GlobeArcsProps) { const groupRef = useRef(null) const markerRefs = useRef<(THREE.Mesh | null)[]>([]) // Fibonacci-distributed dots on the sphere. const { positions, points } = useMemo(() => { const positions = new Float32Array(DOT_COUNT * 3) const points: THREE.Vector3[] = [] const goldenAngle = Math.PI * (3 - Math.sqrt(5)) for (let i = 0; i < DOT_COUNT; i++) { const y = 1 - (i / (DOT_COUNT - 1)) * 2 const r = Math.sqrt(1 - y * y) const theta = goldenAngle * i const p = new THREE.Vector3( Math.cos(theta) * r * RADIUS, y * RADIUS, Math.sin(theta) * r * RADIUS ) points.push(p) positions[i * 3] = p.x positions[i * 3 + 1] = p.y positions[i * 3 + 2] = p.z } return { positions, points } }, []) // Arcs between random dot pairs (seeded → stable across renders). const arcs = useMemo(() => { const rand = mulberry32(1337) const result: { curve: THREE.QuadraticBezierCurve3; samples: THREE.Vector3[] }[] = [] for (let i = 0; i < arcCount; i++) { const a = points[Math.floor(rand() * points.length)] const b = points[Math.floor(rand() * points.length)] if (a === b) continue const dist = a.distanceTo(b) const mid = a .clone() .add(b) .multiplyScalar(0.5) .normalize() .multiplyScalar(RADIUS * (1.4 + dist * 0.15)) const curve = new THREE.QuadraticBezierCurve3(a, mid, b) result.push({ curve, samples: curve.getPoints(ARC_SAMPLES) }) } return result }, [arcCount, points]) useFrame((state, delta) => { const group = groupRef.current if (group) { group.rotation.y += delta * speed group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, state.pointer.y * 0.3, 0.05) } const t0 = state.clock.elapsedTime for (let i = 0; i < arcs.length; i++) { const marker = markerRefs.current[i] if (!marker) continue const t = (t0 * 0.15 + i * 0.37) % 1 marker.position.copy(arcs[i].curve.getPoint(t)) } }) return ( {/* Occlusion core */} {/* Dotted globe */} {/* Arcs, endpoints, and travelling markers */} {arcs.map((arc, i) => ( { markerRefs.current[i] = m }} > ))} ) } ``` ### aurora Aurora: Animated aurora gradient shader: the landing-page background everyone wants. Props: - colorA: color = "#a3e635" - colorB: color = "#22d3ee" - colorC: color = "#f97316" - speed: number (0–3) = 0.6 - complexity: number (1–8) = 4 Install: `npx facet3d add aurora` Source: ```tsx // aurora — animated aurora/mesh-gradient shader background. It fills the camera // viewport; render it alone or behind your content. Must be rendered inside a // react-three-fiber . 'use client' import { useRef } from 'react' import { Color, ShaderMaterial } from 'three' import { extend, useFrame, useThree } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' const vertexShader = /* glsl */ ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const fragmentShader = /* glsl */ ` uniform float uTime; uniform vec3 uColorA; uniform vec3 uColorB; uniform vec3 uColorC; uniform float uOctaves; varying vec2 vUv; float hash(vec2 p) { p = fract(p * vec2(234.34, 435.345)); p += dot(p, p + 34.23); return fract(p.x * p.y); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); float a = hash(i); float b = hash(i + vec2(1.0, 0.0)); float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0)); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } float fbm(vec2 p) { float value = 0.0; float amplitude = 0.5; for (int i = 0; i < 8; i++) { if (float(i) >= uOctaves) break; value += amplitude * noise(p); p *= 2.0; amplitude *= 0.5; } return value; } void main() { vec2 uv = vUv; vec2 p = uv * 3.0; float t = uTime * 0.1; // domain-warped fbm: warp the field with a second fbm layer float warp = fbm(p + fbm(p + t)); float field = fbm(p + warp + vec2(t * 0.5, -t * 0.3)); vec3 color = mix(uColorA, uColorB, smoothstep(0.2, 0.8, field)); color = mix(color, uColorC, smoothstep(0.4, 0.9, warp)); // vertical gradient bias — darker toward the bottom color *= mix(0.35, 1.0, pow(uv.y, 0.8)); // subtle vignette at the edges float d = distance(uv, vec2(0.5)); color *= smoothstep(0.95, 0.35, d); gl_FragColor = vec4(color, 1.0); } ` const AuroraMaterial = shaderMaterial( { uTime: 0, uColorA: new Color('#a3e635'), uColorB: new Color('#22d3ee'), uColorC: new Color('#f97316'), uOctaves: 4, }, vertexShader, fragmentShader ) extend({ AuroraMaterial }) declare global { namespace JSX { interface IntrinsicElements { auroraMaterial: any } } } export interface AuroraProps { colorA?: string colorB?: string colorC?: string speed?: number complexity?: number } export function Aurora({ colorA = '#a3e635', colorB = '#22d3ee', colorC = '#f97316', speed = 0.6, complexity = 4, }: AuroraProps) { const materialRef = useRef(null) const viewport = useThree((s) => s.viewport) useFrame((_, delta) => { const material = materialRef.current if (!material) return material.uniforms.uTime.value += delta * speed ;(material.uniforms.uColorA.value as Color).set(colorA) ;(material.uniforms.uColorB.value as Color).set(colorB) ;(material.uniforms.uColorC.value as Color).set(colorC) material.uniforms.uOctaves.value = complexity }) return ( ) } ``` ### ripple-plane Ripple Plane: Touch-responsive water surface. Move your cursor and watch it ripple. Props: - color: color = "#38bdf8" - waveHeight: number (0–1) = 0.35 - speed: number (0–3) = 1 Install: `npx facet3d add ripple-plane` Source: ```tsx // RipplePlane — touch-responsive water surface with interactive ripples. // Must be rendered inside a react-three-fiber . 'use client' import { useRef } from 'react' import * as THREE from 'three' import { useFrame, extend } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' export interface RipplePlaneProps { color?: string waveHeight?: number speed?: number } const MAX_RIPPLES = 16 const RipplePlaneMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color('#38bdf8'), uWaveHeight: 0.35, uSpeed: 1, uRipples: Array.from({ length: MAX_RIPPLES }, () => new THREE.Vector4(0, 0, -100, 0)), }, /* glsl */ ` uniform float uTime; uniform float uWaveHeight; uniform vec4 uRipples[${MAX_RIPPLES}]; varying float vElevation; varying vec2 vUv; void main() { vUv = uv; vec3 pos = position; // Base ambient waves (2 layered sines, small) float h = sin(pos.x * 1.2 + uTime * 0.8) * 0.15; h += sin(pos.y * 1.7 - uTime * 0.6 + pos.x * 0.5) * 0.1; // Interactive ripples: xy = center (plane-local), z = start time for (int i = 0; i < ${MAX_RIPPLES}; i++) { vec4 r = uRipples[i]; float t = uTime - r.z; if (t >= 0.0 && t <= 4.0) { float d = distance(pos.xy, r.xy); h += sin(d * 6.0 - t * 5.0) * exp(-d * 0.8) * exp(-t * 1.2) * smoothstep(0.0, 0.15, t); } } pos.z += h * uWaveHeight; vElevation = h; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } `, /* glsl */ ` uniform vec3 uColor; varying float vElevation; varying vec2 vUv; void main() { // Shade by elevation: deep color in troughs, bright on crests float e = clamp(vElevation * 0.5 + 0.5, 0.0, 1.0); vec3 col = mix(uColor * 0.15, uColor, e); // Subtle fresnel-ish edge fade toward the plane borders float edge = smoothstep(0.85, 0.45, distance(vUv, vec2(0.5))); col *= mix(0.35, 1.0, edge); gl_FragColor = vec4(col, 1.0); } ` ) extend({ RipplePlaneMaterial }) declare global { namespace JSX { interface IntrinsicElements { ripplePlaneMaterial: any } } } export function RipplePlane({ color = '#38bdf8', waveHeight = 0.35, speed = 1, }: RipplePlaneProps) { const meshRef = useRef(null) const materialRef = useRef(null) const rippleIndex = useRef(0) const lastRipple = useRef(new THREE.Vector2(1e9, 1e9)) useFrame((_, delta) => { if (materialRef.current) { materialRef.current.uTime += delta * speed } }) const addRipple = (x: number, y: number) => { const mat = materialRef.current if (!mat) return mat.uRipples.value[rippleIndex.current].set(x, y, mat.uTime, 0) rippleIndex.current = (rippleIndex.current + 1) % MAX_RIPPLES lastRipple.current.set(x, y) } const toLocal = (e: ThreeEvent) => { if (!meshRef.current) return null return meshRef.current.worldToLocal(e.point.clone()) } const handlePointerMove = (e: ThreeEvent) => { const p = toLocal(e) if (!p) return if (lastRipple.current.distanceTo(new THREE.Vector2(p.x, p.y)) > 0.5) { addRipple(p.x, p.y) } } const handlePointerDown = (e: ThreeEvent) => { const p = toLocal(e) if (!p) return addRipple(p.x, p.y) } return ( ) } ``` ### node-network Node Network: 3D plexus network of drifting, connected nodes, built for AI landing pages. Props: - nodeCount: number (10–300) = 120 - color: color = "#a3e635" - maxDistance: number (0.5–5) = 2 - speed: number (0–2) = 0.4 Install: `npx facet3d add node-network` Source: ```tsx // NodeNetwork — 3D plexus network of connected nodes, built for AI landing pages. // Must be rendered inside a react-three-fiber . 'use client' import { useMemo, useRef } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' export interface NodeNetworkProps { nodeCount?: number color?: string maxDistance?: number speed?: number } // Deterministic PRNG so the layout is stable across reloads and nodeCount tweaks. function mulberry32(seed: number) { return function () { seed |= 0 seed = (seed + 0x6d2b79f5) | 0 let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } const SPHERE_RADIUS = 4.5 const HUB_RATIO = 0.15 export function NodeNetwork({ nodeCount = 120, color = '#a3e635', maxDistance = 2, speed = 0.4, }: NodeNetworkProps) { const spinRef = useRef(null) const parallaxRef = useRef(null) const { positions, hubPositions, edges } = useMemo(() => { const rand = mulberry32(42) const count = Math.max(2, Math.floor(nodeCount)) // Uniform random points inside a sphere. const pts: THREE.Vector3[] = [] const hubIdx: number[] = [] for (let i = 0; i < count; i++) { // Rejection-free sphere sampling: random direction, radius scaled by cbrt. const u = rand() * 2 - 1 const theta = rand() * Math.PI * 2 const r = SPHERE_RADIUS * Math.cbrt(rand()) const s = Math.sqrt(1 - u * u) pts.push(new THREE.Vector3(r * s * Math.cos(theta), r * s * Math.sin(theta), r * u)) if (rand() < HUB_RATIO) hubIdx.push(i) } const positions = new Float32Array(count * 3) pts.forEach((p, i) => { positions[i * 3] = p.x positions[i * 3 + 1] = p.y positions[i * 3 + 2] = p.z }) const hubPositions = new Float32Array(hubIdx.length * 3) hubIdx.forEach((idx, i) => { hubPositions[i * 3] = pts[idx].x hubPositions[i * 3 + 1] = pts[idx].y hubPositions[i * 3 + 2] = pts[idx].z }) // Connect every pair closer than maxDistance. const edgeList: number[] = [] const maxDistSq = maxDistance * maxDistance for (let i = 0; i < count; i++) { for (let j = i + 1; j < count; j++) { if (pts[i].distanceToSquared(pts[j]) < maxDistSq) { edgeList.push(pts[i].x, pts[i].y, pts[i].z, pts[j].x, pts[j].y, pts[j].z) } } } return { positions, hubPositions, edges: new Float32Array(edgeList) } }, [nodeCount, maxDistance]) // Brighter tint for the larger "hub" nodes. const hubColor = useMemo( () => new THREE.Color(color).lerp(new THREE.Color('#ffffff'), 0.55), [color] ) useFrame((state, delta) => { const spin = spinRef.current const parallax = parallaxRef.current if (!spin || !parallax) return spin.rotation.y += delta * speed * 0.2 spin.rotation.x += delta * speed * 0.05 // Pointer parallax on an outer group so it never fights the continuous spin. const targetY = state.pointer.x * 0.15 const targetX = -state.pointer.y * 0.15 parallax.rotation.y = THREE.MathUtils.damp(parallax.rotation.y, targetY, 3, delta) parallax.rotation.x = THREE.MathUtils.damp(parallax.rotation.x, targetX, 3, delta) }) return ( ) } ``` ### cursor-trail Cursor Trail: A fluid ribbon of light that follows the cursor. Props: - color: color = "#22d3ee" - length: number (10–200) = 60 - width: number (1–12) = 4 Install: `npx facet3d add cursor-trail` Source: ```tsx // cursor-trail — fluid ribbon of light following the cursor. Must be rendered inside a react-three-fiber . 'use client' import { useEffect, useMemo, useRef } from 'react' import type { ElementRef } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' import { Line } from '@react-three/drei' export interface CursorTrailProps { color?: string length?: number width?: number } export function CursorTrail({ color = '#22d3ee', length = 60, width = 4 }: CursorTrailProps) { const lineRef = useRef>(null) const trailRef = useRef([]) const scratchRef = useRef({ point: new THREE.Vector3(), dir: new THREE.Vector3() }) // Reset the trail whenever the point budget changes. useEffect(() => { trailRef.current = [] }, [length]) // Initial (placeholder) points — drei Line builds the Line2 geometry from these // once per length change; positions are then driven imperatively in useFrame. const points = useMemo( () => Array.from({ length }, () => new THREE.Vector3(0, 0, 0)), [length] ) // Head → tail vertex color fade (bright `color` at the head, black at the tail). // Rebuilt only when length or color changes; black fades to invisible with additive blending. const vertexColors = useMemo(() => { const c = new THREE.Color(color) return Array.from({ length }, (_, i) => { const t = length > 1 ? i / (length - 1) : 1 return c.clone().multiplyScalar(t * t) }) }, [color, length]) useFrame((state) => { const line = lineRef.current if (!line) return // Unproject the pointer onto the z=0 plane (camera ray / plane intersection). const { camera, pointer } = state const { point, dir } = scratchRef.current point.set(pointer.x, pointer.y, 0.5).unproject(camera) dir.copy(point).sub(camera.position).normalize() if (Math.abs(dir.z) < 1e-6) return const dist = -camera.position.z / dir.z if (dist <= 0) return const px = camera.position.x + dir.x * dist const py = camera.position.y + dir.y * dist // Push a new point only when the cursor moved enough — keeps the ribbon smooth. const trail = trailRef.current const last = trail[trail.length - 1] if (!last || Math.hypot(px - last.x, py - last.y) > 0.01) { trail.push(new THREE.Vector3(px, py, 0)) if (trail.length > length) trail.splice(0, trail.length - length) } const n = trail.length if (n < 2) return // Flatten the trail into the geometry, oldest first so the head stays at the // brightest vertex. Short trails are padded at the tail with the oldest point, // producing invisible zero-length black segments until the trail fills up. const flat = new Float32Array(length * 3) const pad = length - n for (let i = 0; i < length; i++) { const p = i < pad ? trail[0] : trail[i - pad] flat[i * 3] = p.x flat[i * 3 + 1] = p.y flat[i * 3 + 2] = p.z } ;(line.geometry as { setPositions: (a: Float32Array) => void }).setPositions(flat) }) return ( ) } ``` ### audio-visualizer Audio Visualizer: Audio-reactive frequency rings: microphone or built-in simulation. Props: - color: color = "#a3e635" - bars: number (16–128) = 64 - sensitivity: number (0.5–5) = 2 - mode: one of simulated|microphone = "simulated" Install: `npx facet3d add audio-visualizer` Source: ```tsx // audio-visualizer — audio-reactive frequency rings. Must be rendered inside a react-three-fiber . // mode="microphone" requires a user gesture + mic permission in the browser; on any failure it silently falls back to "simulated". 'use client' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' export interface AudioVisualizerProps { color?: string bars?: number sensitivity?: number mode?: 'simulated' | 'microphone' } const RADIUS = 3 export function AudioVisualizer({ color = '#a3e635', bars = 64, sensitivity = 2, mode = 'simulated', }: AudioVisualizerProps) { const groupRef = useRef(null) const meshesRef = useRef<(THREE.Mesh | null)[]>([]) const valuesRef = useRef(new Float32Array(0)) const analyserRef = useRef(null) const freqDataRef = useRef(null) const [micFailed, setMicFailed] = useState(false) const geometry = useMemo(() => { const geo = new THREE.BoxGeometry(0.16, 1, 0.16) geo.translate(0, 0.5, 0) // grow upward from the disc return geo }, []) const indices = useMemo(() => Array.from({ length: bars }, (_, i) => i), [bars]) // Microphone capture: AudioContext + AnalyserNode. Any failure (denied // permission, no user gesture, unsupported API) falls back to simulated. useEffect(() => { if (mode !== 'microphone' || micFailed) return if ( typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof AudioContext === 'undefined' ) { setMicFailed(true) return } let cancelled = false let stream: MediaStream | null = null let ctx: AudioContext | null = null navigator.mediaDevices .getUserMedia({ audio: true }) .then((s) => { if (cancelled) { s.getTracks().forEach((t) => t.stop()) return } stream = s ctx = new AudioContext() const source = ctx.createMediaStreamSource(s) const analyser = ctx.createAnalyser() analyser.fftSize = 256 analyser.smoothingTimeConstant = 0.8 source.connect(analyser) analyserRef.current = analyser freqDataRef.current = new Uint8Array(analyser.frequencyBinCount) }) .catch(() => { if (!cancelled) setMicFailed(true) }) return () => { cancelled = true analyserRef.current = null freqDataRef.current = null stream?.getTracks().forEach((t) => t.stop()) ctx?.close().catch(() => {}) } }, [mode, micFailed]) useFrame((state, delta) => { const t = state.clock.elapsedTime const values = valuesRef.current if (values.length !== bars) valuesRef.current = new Float32Array(bars) const vals = valuesRef.current const analyser = mode === 'microphone' && !micFailed ? analyserRef.current : null const freqData = freqDataRef.current if (analyser && freqData) analyser.getByteFrequencyData(freqData) for (let i = 0; i < bars; i++) { let v: number if (analyser && freqData) { const bin = Math.floor((i / bars) * freqData.length) v = freqData[bin] / 255 } else { v = Math.abs( Math.sin(t * 2 + i * 0.3) * 0.5 + Math.sin(t * 3.7 + i * 1.7) * 0.3 + Math.sin(t * 0.9 + i) * 0.2 ) v = v * v // squared for punch } vals[i] = THREE.MathUtils.damp(vals[i], v, 10, delta) const mesh = meshesRef.current[i] if (!mesh) continue mesh.scale.y = 0.1 + vals[i] * sensitivity const mat = mesh.material as THREE.MeshStandardMaterial mat.emissiveIntensity = 0.3 + vals[i] * 2 } if (groupRef.current) groupRef.current.rotation.y += delta * 0.15 }) return ( {indices.map((i) => { const angle = (i / bars) * Math.PI * 2 return ( { meshesRef.current[i] = m }} geometry={geometry} position={[Math.cos(angle) * RADIUS, 0, Math.sin(angle) * RADIUS]} rotation={[0, -angle, 0]} > ) })} {/* dark reflective disc under the ring */} ) } ``` ### floating-shapes Floating Shapes: Drifting geometric primitives for ambient backgrounds. Props: - count: number (1–30) = 12 - speed: number (0–5) = 1 - spread: number (2–20) = 8 Install: `npx facet3d add floating-shapes` Source: ```tsx // floating-shapes — ambient drifting geometric primitives. Must be rendered inside a react-three-fiber . 'use client' import { useMemo, useRef } from 'react' import { Color, Group } from 'three' import { useFrame } from '@react-three/fiber' import { Float } from '@react-three/drei' export interface FloatingShapesProps { count?: number colors?: string[] speed?: number spread?: number } function mulberry32(seed: number) { return function () { let t = (seed += 0x6d2b79f5) t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } type GeometryKind = 'torusKnot' | 'icosahedron' | 'octahedron' | 'torus' const GEOMETRIES: GeometryKind[] = ['torusKnot', 'icosahedron', 'octahedron', 'torus'] interface ShapeConfig { geometry: GeometryKind color: string wireframe: boolean position: [number, number, number] scale: number floatSpeed: number rotationIntensity: number floatIntensity: number } export function FloatingShapes({ count = 12, colors = ['#a3e635', '#f97316', '#f5f5f5'], speed = 1, spread = 8, }: FloatingShapesProps) { const group = useRef(null) const shapes = useMemo(() => { return Array.from({ length: count }, (_, i) => { const rand = mulberry32(i * 1000 + 7) return { geometry: GEOMETRIES[i % GEOMETRIES.length], color: colors[i % colors.length], wireframe: rand() > 0.5, position: [(rand() - 0.5) * spread * 2, (rand() - 0.5) * spread, (rand() - 0.5) * spread], scale: 0.35 + rand() * 0.65, floatSpeed: 1 + rand() * 2, rotationIntensity: 0.5 + rand() * 1.5, floatIntensity: 0.5 + rand() * 1.5, } }) }, [count, colors, spread]) useFrame((_, delta) => { if (group.current) { group.current.rotation.y += delta * 0.05 * speed group.current.rotation.x += delta * 0.02 * speed } }) return ( {shapes.map((shape, i) => ( {shape.geometry === 'torusKnot' && } {shape.geometry === 'icosahedron' && } {shape.geometry === 'octahedron' && } {shape.geometry === 'torus' && } ))} ) } ``` ### wave-grid Wave Grid: Shader-driven undulating wireframe terrain. Props: - color: color = "#22d3ee" - speed: number (0–5) = 1 - amplitude: number (0–2) = 0.6 - frequency: number (0.5–6) = 2 Install: `npx facet3d add wave-grid` Source: ```tsx // WaveGrid — shader-driven undulating wireframe terrain. // Must be rendered inside a react-three-fiber . 'use client' import { useRef } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' import { extend } from '@react-three/fiber' export interface WaveGridProps { color?: string speed?: number amplitude?: number frequency?: number size?: number segments?: number } const WaveGridMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color('#22d3ee'), uAmplitude: 0.6, uFrequency: 2, }, /* glsl */ ` uniform float uTime; uniform float uAmplitude; uniform float uFrequency; varying vec2 vUv; void main() { vUv = uv; vec3 pos = position; float f = uFrequency; float t = uTime; float wave = sin(pos.x * f + t) * 0.5; wave += sin(pos.y * f * 1.3 + t * 1.2) * 0.3; wave += sin((pos.x + pos.y) * f * 0.6 + t * 0.8) * 0.2; pos.z += wave * uAmplitude; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } `, /* glsl */ ` uniform vec3 uColor; varying vec2 vUv; void main() { float dist = distance(vUv, vec2(0.5)); float alpha = smoothstep(0.5, 0.15, dist); gl_FragColor = vec4(uColor, alpha); } ` ) extend({ WaveGridMaterial }) declare global { namespace JSX { interface IntrinsicElements { waveGridMaterial: any } } } export function WaveGrid({ color = '#22d3ee', speed = 1, amplitude = 0.6, frequency = 2, size = 10, segments = 80, }: WaveGridProps) { const materialRef = useRef(null) useFrame((_, delta) => { if (materialRef.current) { materialRef.current.uTime += delta * speed } }) return ( ) } ``` ### text-3d 3D Text: Extruded 3D typography with environment-lit materials. Props: - text: text = "FACET" - size: number (0.2–3) = 0.8 - color: color = "#ffffff" - bevelSize: number (0–0.1) = 0.02 - floatIntensity: number (0–3) = 0.6 Install: `npx facet3d add text-3d` Source: ```tsx 'use client' // Must be rendered inside a react-three-fiber . import { Center, Float, Text3D as DreiText3D } from '@react-three/drei' export interface Text3DProps { text?: string size?: number color?: string font?: string bevelSize?: number floatIntensity?: number } export function Text3D({ text = 'HELLO', size = 1, color = '#ffffff', font = 'https://unpkg.com/three@0.166.1/examples/fonts/helvetiker_bold.typeface.json', bevelSize = 0.02, floatIntensity = 1, }: Text3DProps) { return (
{text}
) } ``` ### model-viewer Model Viewer: Drop-in GLTF model viewer with staging and orbit controls. Props: - url: text = "https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf" - autoRotate: boolean = true - autoRotateSpeed: number (0–10) = 1.5 - environment: one of city|sunset|dawn|night|warehouse|forest|apartment|studio|park|lobby = "city" - enableZoom: boolean = false Install: `npx facet3d add model-viewer` Source: ```tsx 'use client' // ModelViewer — drop-in GLTF model viewer. // Must be rendered inside a react-three-fiber . import { Suspense } from 'react' import { useGLTF, Stage, OrbitControls, Environment } from '@react-three/drei' export interface ModelViewerProps { url?: string autoRotate?: boolean autoRotateSpeed?: number environment?: | 'sunset' | 'dawn' | 'night' | 'warehouse' | 'forest' | 'apartment' | 'studio' | 'city' | 'park' | 'lobby' enableZoom?: boolean } const DEFAULT_URL = 'https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf' function Model({ url }: { url: string }) { const { scene } = useGLTF(url) return } export function ModelViewer({ url = DEFAULT_URL, autoRotate = true, autoRotateSpeed = 1.5, environment = 'city', enableZoom = false, }: ModelViewerProps) { return ( <> ) } ``` ### holo-card Holo Card: Holographic fresnel card with an iridescent sheen. Props: - colorA: color = "#a3e635" - colorB: color = "#f97316" - intensity: number (0–3) = 1.2 - speed: number (0–5) = 1 Install: `npx facet3d add holo-card` Source: ```tsx 'use client' // HoloCard — holographic fresnel card. Must be rendered inside a react-three-fiber . import * as THREE from 'three' import { useRef } from 'react' import { useFrame } from '@react-three/fiber' import { RoundedBox, shaderMaterial } from '@react-three/drei' import { extend } from '@react-three/fiber' export interface HoloCardProps { colorA?: string colorB?: string intensity?: number speed?: number size?: [number, number, number] } const HoloCardMaterial = shaderMaterial( { uTime: 0, uColorA: new THREE.Color('#a3e635'), uColorB: new THREE.Color('#f97316'), uIntensity: 1.2, }, /* glsl */ ` varying vec3 vNormal; varying vec3 vViewDir; void main() { vec4 worldPosition = modelMatrix * vec4(position, 1.0); vNormal = normalize(mat3(modelMatrix) * normal); vViewDir = normalize(cameraPosition - worldPosition.xyz); gl_Position = projectionMatrix * viewMatrix * worldPosition; } `, /* glsl */ ` uniform float uTime; uniform vec3 uColorA; uniform vec3 uColorB; uniform float uIntensity; varying vec3 vNormal; varying vec3 vViewDir; void main() { vec3 normal = normalize(vNormal); vec3 viewDir = normalize(vViewDir); float fresnel = pow(1.0 - abs(dot(viewDir, normal)), 2.0); float bands = 0.5 + 0.5 * sin(fresnel * 10.0 + uTime); float drift = 0.5 + 0.5 * sin(uTime * 0.4 + normal.y * 2.0); vec3 base = mix(uColorA, uColorB, drift); vec3 iridescent = mix(uColorA, uColorB, bands); vec3 color = mix(base, iridescent, fresnel) * (0.15 + fresnel * uIntensity); float alpha = clamp(0.35 + fresnel * 0.65, 0.0, 1.0); gl_FragColor = vec4(color, alpha); } ` ) extend({ HoloCardMaterial }) declare global { namespace JSX { interface IntrinsicElements { holoCardMaterial: any } } } export function HoloCard({ colorA = '#a3e635', colorB = '#f97316', intensity = 1.2, speed = 1, size = [2, 2.8, 0.08], }: HoloCardProps) { const groupRef = useRef(null) const materialRef = useRef(null) useFrame((state, delta) => { if (materialRef.current) { materialRef.current.uTime += delta * speed } const group = groupRef.current if (group) { const { x, y } = state.pointer const targetY = x * 0.5 const targetX = -y * 0.35 group.rotation.y = THREE.MathUtils.damp(group.rotation.y, targetY, 4, delta) group.rotation.x = THREE.MathUtils.damp(group.rotation.x, targetX, 4, delta) } }) return ( ) } ``` ### scroll-camera Scroll Camera: Scroll-driven camera flythrough scene. Props: - pages: number (1–6) = 3 - color: color = "#a3e635" - damping: number (0.05–1) = 0.2 Install: `npx facet3d add scroll-camera` Source: ```tsx // scroll-camera — cinematic scroll-driven camera flythrough. Must be rendered inside a react-three-fiber . 'use client' import { useMemo, useRef } from 'react' import type { CSSProperties } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' import { ScrollControls, Scroll, Stars, useScroll } from '@react-three/drei' export interface ScrollCameraProps { pages?: number color?: string damping?: number } // Gentle S-curve: descends from z=10 to z=-35 with lateral sway. const WAYPOINTS: [number, number, number][] = [ [0, 0.4, 10], [2.4, -0.6, 2], [-2.2, 0.8, -8], [1.8, -0.5, -20], [0, 0.2, -35], ] const COPY = ['Scroll to fly.', 'The camera follows the curve.', 'Copy it. Ship it.'] function usePathCurve() { return useMemo( () => new THREE.CatmullRomCurve3( WAYPOINTS.map(([x, y, z]) => new THREE.Vector3(x, y, z)), false, 'centripetal' ), [] ) } function usePalette(color: string) { return useMemo(() => { const base = new THREE.Color(color) return [ base.clone(), base.clone().offsetHSL(0.08, 0, 0.15), base.clone().offsetHSL(-0.06, 0.05, 0.22), base.clone().offsetHSL(0.5, -0.1, 0.1), ] }, [color]) } function FlythroughCamera({ curve, damping, }: { curve: THREE.CatmullRomCurve3 damping: number }) { const scroll = useScroll() const progress = useRef(0) const curveLength = useMemo(() => curve.getLength(), [curve]) const lookTarget = useMemo(() => new THREE.Vector3(), []) useFrame((state, delta) => { // frame-rate-independent damping toward the scroll offset progress.current = THREE.MathUtils.damp(progress.current, scroll.offset, damping * 12, delta) const t = THREE.MathUtils.clamp(progress.current, 0, 1) curve.getPointAt(t, state.camera.position) // aim ~6 units further along the path curve.getPointAt(Math.min(t + 6 / curveLength, 1), lookTarget) state.camera.lookAt(lookTarget) }) return null } interface PathObject { position: [number, number, number] kind: 'torusKnot' | 'icosahedron' | 'ring' wireframe: boolean scale: number color: THREE.Color spin: number } function PathMesh({ object }: { object: PathObject }) { const ref = useRef(null) useFrame((_, delta) => { const mesh = ref.current if (!mesh) return mesh.rotation.x += delta * object.spin mesh.rotation.y += delta * object.spin * 0.7 }) return ( {object.kind === 'torusKnot' && } {object.kind === 'icosahedron' && } {object.kind === 'ring' && } {object.wireframe ? ( ) : ( )} ) } function PathScene({ curve, palette }: { curve: THREE.CatmullRomCurve3; palette: THREE.Color[] }) { const objects = useMemo(() => { const kinds: PathObject['kind'][] = ['torusKnot', 'icosahedron', 'ring'] const point = new THREE.Vector3() return Array.from({ length: 10 }, (_, i) => { curve.getPointAt((i + 0.5) / 10, point) const side = i % 2 === 0 ? 1 : -1 return { position: [ point.x + side * (2.4 + (i % 3) * 0.9), point.y + ((i % 3) - 1) * 1.3, point.z, ], kind: kinds[i % 3], wireframe: i % 2 === 1, scale: 0.8 + (i % 4) * 0.3, color: palette[i % palette.length], spin: 0.12 + (i % 3) * 0.08, } }) }, [curve, palette]) return ( {objects.map((object, i) => ( ))} ) } function PathLights({ curve, palette }: { curve: THREE.CatmullRomCurve3; palette: THREE.Color[] }) { const positions = useMemo(() => { return [0.12, 0.7].map((t) => { const p = curve.getPointAt(t) return [p.x, p.y + 2.5, p.z] as [number, number, number] }) }, [curve]) return ( <> ) } const sectionStyle: CSSProperties = { height: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', fontFamily: 'system-ui, -apple-system, sans-serif', pointerEvents: 'none', userSelect: 'none', } const numeralStyle: CSSProperties = { margin: 0, fontSize: 'clamp(6rem, 20vw, 16rem)', fontWeight: 100, lineHeight: 1, letterSpacing: '0.12em', color: 'rgba(255,255,255,0.7)', } const lineStyle: CSSProperties = { margin: '1.75rem 0 0', fontSize: 'clamp(0.7rem, 1.4vw, 0.95rem)', fontWeight: 400, letterSpacing: '0.42em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.45)', } export function ScrollCamera({ pages = 3, color = '#a3e635', damping = 0.2 }: ScrollCameraProps) { const curve = usePathCurve() const palette = usePalette(color) const pageCount = Math.max(1, Math.round(pages)) return (
{Array.from({ length: pageCount }, (_, i) => (

{String(i + 1).padStart(2, '0')}

{COPY[i % COPY.length]}

))}
) } ``` ### glass-prism Glass Prism: Cinematic glass dispersion crystal with real refraction and chromatic aberration. Props: - shape: one of prism|torusknot|icosahedron = "prism" - speed: number (0–2) = 0.4 - ior: number (1–2.5) = 1.5 - chromaticAberration: number (0–1) = 0.3 - tint: color = "#a3e635" - floatIntensity: number (0–3) = 1 - distortion: number (0–1) = 0.2 - parallax: boolean = true Install: `npx facet3d add glass-prism` Source: ```tsx // GlassPrism — cinematic glass dispersion hero: a slowly rotating crystal with // real refraction (MeshTransmissionMaterial), chromatic aberration, a fresnel // rim glow, and an in-file light rig that bends warm/cool gradients and a lime // accent strip through the glass. Must be rendered inside a react-three-fiber // . // // Usage: // // // // // // Install: // npx facet3d add glass-prism // // Dependencies: three, @react-three/fiber, @react-three/drei // (self-contained light rig via + — no HDR files). 'use client' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { extend, useFrame } from '@react-three/fiber' import { Environment, Float, Lightformer, MeshTransmissionMaterial, shaderMaterial, } from '@react-three/drei' export interface GlassPrismProps { /** Crystal geometry: triangular prism, torus knot, or icosahedron. */ shape?: 'prism' | 'torusknot' | 'icosahedron' /** Rotation speed multiplier, 0–2. */ speed?: number /** Index of refraction, 1–2.5 (glass ≈ 1.5, diamond ≈ 2.4). */ ior?: number /** RGB split strength inside the glass, 0–1. */ chromaticAberration?: number /** Accent color for the rim glow and the light-rig strip. */ tint?: string /** Vertical bobbing amplitude, 0–3. */ floatIntensity?: number /** Surface noise distortion of the refraction, 0–1. */ distortion?: number /** Lerp the crystal toward the pointer. */ parallax?: boolean } // Additive backside shell that blooms the accent color along grazing angles, // faking the bright spectral edge real dispersion throws on a crystal's rim. const RimGlowMaterial = shaderMaterial( { uColor: new THREE.Color('#a3e635'), uIntensity: 2.2, }, /* glsl */ ` varying vec3 vNormal; varying vec3 vViewDir; void main() { vec4 worldPosition = modelMatrix * vec4(position, 1.0); vNormal = normalize(mat3(modelMatrix) * normal); vViewDir = cameraPosition - worldPosition.xyz; gl_Position = projectionMatrix * viewMatrix * worldPosition; } `, /* glsl */ ` uniform vec3 uColor; uniform float uIntensity; varying vec3 vNormal; varying vec3 vViewDir; void main() { vec3 normal = normalize(vNormal); // Guard against a zero-length view vector before normalize (NaN risk). vec3 viewDir = vViewDir / max(length(vViewDir), 1e-4); float rim = pow(1.0 - clamp(abs(dot(viewDir, normal)), 0.0, 1.0), 3.0); vec3 color = uColor * rim * uIntensity; gl_FragColor = vec4(color, rim); } ` ) extend({ RimGlowMaterial }) declare global { namespace JSX { interface IntrinsicElements { rimGlowMaterial: any } } } // Faceted geometries are rebuilt as non-indexed so every face gets a true // flat normal — that's what makes the refraction read as cut crystal. function buildGeometry(shape: NonNullable): THREE.BufferGeometry { switch (shape) { case 'torusknot': return new THREE.TorusKnotGeometry(0.85, 0.26, 220, 36) case 'icosahedron': { const geo = new THREE.IcosahedronGeometry(1.25, 0).toNonIndexed() geo.computeVertexNormals() return geo } case 'prism': default: { const geo = new THREE.CylinderGeometry(1.05, 1.05, 2.3, 3, 1, false).toNonIndexed() geo.computeVertexNormals() return geo } } } export function GlassPrism({ shape = 'prism', speed = 0.4, ior = 1.5, chromaticAberration = 0.3, tint = '#a3e635', floatIntensity = 1, distortion = 0.2, parallax = true, }: GlassPrismProps) { const groupRef = useRef(null) const spinRef = useRef(null) const s = THREE.MathUtils.clamp(speed, 0, 2) const iorClamped = THREE.MathUtils.clamp(ior, 1, 2.5) const ca = THREE.MathUtils.clamp(chromaticAberration, 0, 1) const floatI = THREE.MathUtils.clamp(floatIntensity, 0, 3) const dist = THREE.MathUtils.clamp(distortion, 0, 1) const geometry = useMemo(() => buildGeometry(shape), [shape]) const tintColor = useMemo(() => new THREE.Color(tint), [tint]) // Dark olive fill for the transmission buffer — keeps the crystal body // reading as tinted glass instead of the black page behind it. const bodyColor = useMemo(() => new THREE.Color('#11150d'), []) useEffect(() => { return () => geometry.dispose() }, [geometry]) useFrame((state, delta) => { const t = state.clock.elapsedTime const spin = spinRef.current if (spin) { // Slow elegant tumble: steady yaw plus a gentle sinusoidal lean. spin.rotation.y += delta * s * 0.6 spin.rotation.x = Math.sin(t * 0.25) * 0.18 * Math.min(s * 2.5, 1) spin.rotation.z = Math.cos(t * 0.2) * 0.1 * Math.min(s * 2.5, 1) } const group = groupRef.current if (group) { // Pointer parallax: damped tilt of the whole assembly. When disabled, // ease back to neutral so toggling the prop never snaps. const targetY = parallax ? state.pointer.x * 0.25 : 0 const targetX = parallax ? -state.pointer.y * 0.18 : 0 group.rotation.y = THREE.MathUtils.damp(group.rotation.y, targetY, 3, delta) group.rotation.x = THREE.MathUtils.damp(group.rotation.x, targetX, 3, delta) } }) return ( {/* Spin group carries BOTH the crystal and its rim shell so the fresnel tracks the facets as they tumble. */} {/* Slightly larger additive shell for the fresnel rim — pure glow, no depth write, so it never occludes the refraction behind it. */} null}> {/* Self-contained light rig: gradient panels the crystal can bend. Warm key from the left, cool fill from the right, a white overhead strip for specular life, and the lime accent that streaks through the glass as it turns. No external HDR files. */} {/* Large dim fill behind the crystal so the glass body refracts a soft gradient instead of pure black — this is what keeps it reading as glass rather than a silhouette. */} ) } ``` ### face-puppet Face Puppet: Webcam face-tracked spirit head that mirrors your expressions, with mouse-follow fallback. Props: - color: color = "#a3e635" - smoothing: number (0.05–3) = 1 - trackingSensitivity: number (0–3) = 1 - followMouse: boolean = true - showPreview: boolean = false - pupilScale: number (0.5–2) = 1 - floatIntensity: number (0–3) = 1 Install: `npx facet3d add face-puppet` Source: ```tsx // face-puppet — webcam face-tracked stylized spirit head. Uses the // @mediapipe/tasks-vision FaceLandmarker (dynamically imported at runtime) to // drive head pose, eyelids, jaw and brows; falls back smoothly to mouse-follow // when the camera is unavailable, then to a gentle autopilot when no face is // in frame. Must be rendered inside a react-three-fiber . // // // // // // Install: npx facet3d add face-puppet // Dependencies: three, @react-three/fiber, @react-three/drei // + npm i @mediapipe/tasks-vision 'use client' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { extend, useFrame } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' export interface FacePuppetProps { color?: string // spirit body / rim accent color smoothing?: number // higher = smoother (slower) head response; 1 buttery, 2 dreamy trackingSensitivity?: number // multiplies tracked head rotation and jaw range followMouse?: boolean // follow the mouse when webcam tracking is unavailable showPreview?: boolean // corner picture-in-picture webcam preview plane pupilScale?: number // scales the glowing pupils floatIntensity?: number // amplitude of idle breathing / bob / sway } // Glossy "spirit" skin: wrap diffuse for a soft subsurface feel, fresnel rim // in the accent color, a hot top-left specular and a gentle vertical shimmer. // No scene lights required — everything is analytic. // Both materials share this vertex stage (vLocalY is unused by the aura). const SHARED_VERTEX = /* glsl */ ` varying vec3 vNormalW; varying vec3 vViewW; varying float vLocalY; void main() { vec4 wp = modelMatrix * vec4(position, 1.0); vNormalW = normalize(mat3(modelMatrix) * normal); vViewW = cameraPosition - wp.xyz; vLocalY = position.y; gl_Position = projectionMatrix * viewMatrix * wp; } ` const SpiritMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color('#a3e635'), uRimColor: new THREE.Color('#d9f99d'), }, SHARED_VERTEX, /* glsl */ ` uniform float uTime; uniform vec3 uColor; uniform vec3 uRimColor; varying vec3 vNormalW; varying vec3 vViewW; varying float vLocalY; void main() { vec3 n = normalize(vNormalW); vec3 v = normalize(vViewW); vec3 lightDir = normalize(vec3(0.45, 0.85, 0.6)); // Wrap diffuse: light bleeds around the terminator -> waxy subsurface. float wrap = clamp((dot(n, lightDir) + 0.55) / 1.55, 0.0, 1.0); wrap = wrap * wrap * (3.0 - 2.0 * wrap); // Vertical tint: deeper color low, brighter crown. float grad = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); vec3 base = uColor * mix(0.45, 1.05, grad); // Backlight bleed: silhouette glows faintly when lit from behind. float back = pow(clamp(dot(v, -lightDir) * 0.5 + 0.5, 0.0, 1.0), 2.0); vec3 col = base * (0.3 + wrap * 0.85) + uColor * back * 0.35; // Fresnel rim. float fres = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 3.0); col += uRimColor * fres * 1.15; // Glossy key highlight. vec3 h = normalize(lightDir + v); float spec = pow(clamp(dot(n, h), 0.0, 1.0), 64.0); col += vec3(1.0) * spec * 0.55; // Slow shimmer traveling up the body. col += uRimColor * 0.04 * (0.5 + 0.5 * sin(uTime * 1.7 + vLocalY * 5.0)); gl_FragColor = vec4(col, 1.0); } ` ) // Backside additive halo slightly larger than the skull — sells the spirit. const AuraMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color('#a3e635'), uIntensity: 0.55, }, SHARED_VERTEX, /* glsl */ ` uniform float uTime; uniform vec3 uColor; uniform float uIntensity; varying vec3 vNormalW; varying vec3 vViewW; void main() { vec3 n = normalize(vNormalW); vec3 v = normalize(vViewW); // abs(): safe for BackSide where the geometric normal faces away. float fres = pow(1.0 - abs(dot(n, v)), 2.2); float pulse = 0.85 + 0.15 * sin(uTime * 1.3); gl_FragColor = vec4(uColor * fres * uIntensity * pulse, fres); } ` ) extend({ SpiritMaterial, AuraMaterial }) declare global { namespace JSX { interface IntrinsicElements { spiritMaterial: any auraMaterial: any } } } // Face tracking plumbing (browser-only, everything created inside an effect). const WASM_URL = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.8/wasm' const MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task' // Minimal structural types so the dynamically imported module stays untyped // at build time — the package is an optional peer of this component. interface BlendshapeCategory { categoryName: string score: number } interface FaceLandmarkerResult { faceBlendshapes?: { categories: BlendshapeCategory[] }[] facialTransformationMatrixes?: { data: ArrayLike }[] } interface FaceLandmarkerLike { detectForVideo: (video: HTMLVideoElement, timestamp: number) => FaceLandmarkerResult close: () => void } // Raw (unsmoothed) tracking targets written by the detection loop, read by // useFrame. All values are unitless and roughly in [-1, 1] / [0, 1]. interface TrackState { yaw: number pitch: number roll: number blinkL: number blinkR: number jaw: number brow: number lastFace: number // performance.now() / 1000 of the last detection, -1 = never } // Damps an angle toward a target taking the shortest way around the circle. function dampAngle(current: number, target: number, lambda: number, delta: number) { let diff = (target - current) % (Math.PI * 2) if (diff > Math.PI) diff -= Math.PI * 2 if (diff < -Math.PI) diff += Math.PI * 2 return current + diff * (1 - Math.exp(-lambda * delta)) } const clamp = THREE.MathUtils.clamp const damp = THREE.MathUtils.damp const lerp = THREE.MathUtils.lerp // Per-channel damping rates: blinks snap, jaw follows speech, head is buttery. const LAMBDA_BLINK = 26 const LAMBDA_JAW = 14 const LAMBDA_BROW = 10 const LAMBDA_PUPIL = 18 const LAMBDA_MODE_BLEND = 2.5 const LID_OPEN = -0.62 // lid cap tucked up behind the eye const LID_CLOSED = 1.35 // lid cap swung down over the eye const EYE_X = 0.37, EYE_Y = 0.14, EYE_Z = 0.84 export function FacePuppet({ color = '#a3e635', smoothing = 1, trackingSensitivity = 1, followMouse = true, showPreview = false, pupilScale = 1, floatIntensity = 1, }: FacePuppetProps) { const rootRef = useRef(null) const headRef = useRef(null) const lidLRef = useRef(null) const lidRRef = useRef(null) const browLRef = useRef(null) const browRRef = useRef(null) const mouthRef = useRef(null) const pupilLRef = useRef(null) const pupilRRef = useRef(null) const previewRef = useRef(null) const matsRef = useRef([]) const [trackingFailed, setTrackingFailed] = useState(false) const [videoTex, setVideoTex] = useState(null) const trackRef = useRef({ yaw: 0, pitch: 0, roll: 0, blinkL: 0, blinkR: 0, jaw: 0, brow: 0, lastFace: -1, }) // Smoothed, blended values actually applied to the rig. const rig = useRef({ blinkL: 0, blinkR: 0, jaw: 0, brow: 0, pupilX: 0, pupilY: 0 }) const modeWeight = useRef(0) // 0 = autopilot, 1 = live source (face or mouse) const autoBlink = useRef({ next: 1.5, phase: 0 }) const saccade = useRef({ next: 0.8, x: 0, y: 0 }) const scratch = useRef({ v: new THREE.Vector3() }) // Collects shader material instances so useFrame can tick their uTime. const matRef = (i: number) => (m: any) => { if (m) matsRef.current[i] = m } // Webcam + FaceLandmarker lifecycle. Any failure (no camera, denied // permission, WASM/model fetch error, unsupported API) flips the component // into mouse-follow / autopilot mode instead of throwing. useEffect(() => { if ( typeof window === 'undefined' || typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia ) { setTrackingFailed(true) return } let cancelled = false let raf = 0 let stream: MediaStream | null = null let landmarker: FaceLandmarkerLike | null = null let tex: THREE.VideoTexture | null = null // Stop the camera no matter which await the teardown races with. const stopStream = () => { stream?.getTracks().forEach((t) => t.stop()) stream = null } const video = document.createElement('video') video.muted = true video.playsInline = true // Scratch objects for pose extraction (allocated once, reused per frame). const m = new THREE.Matrix4() const basis = new THREE.Matrix4() const xAxis = new THREE.Vector3() const yAxis = new THREE.Vector3() const zAxis = new THREE.Vector3() const q = new THREE.Quaternion() const eul = new THREE.Euler(0, 0, 0, 'YXZ') const start = async () => { try { const vision = await import('@mediapipe/tasks-vision') const fileset = await vision.FilesetResolver.forVisionTasks(WASM_URL) const created = (await vision.FaceLandmarker.createFromOptions(fileset, { baseOptions: { modelAssetPath: MODEL_URL, delegate: 'GPU' }, runningMode: 'VIDEO', numFaces: 1, outputFaceBlendshapes: true, outputFacialTransformationMatrixes: true, })) as unknown as FaceLandmarkerLike if (cancelled) { created.close() return } landmarker = created stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: 'user' }, audio: false, }) // Unmounted while the permission prompt was open: cleanup already ran // with stream still null, so we must release the camera ourselves. if (cancelled) { stopStream() return } video.srcObject = stream await video.play() if (cancelled) { stopStream() return } tex = new THREE.VideoTexture(video) tex.colorSpace = THREE.SRGBColorSpace setVideoTex(tex) const tr = trackRef.current const loop = () => { if (cancelled) return try { if (landmarker && video.readyState >= 2) { const res = landmarker.detectForVideo(video, performance.now()) const mat = res.facialTransformationMatrixes?.[0]?.data const cats = res.faceBlendshapes?.[0]?.categories if (mat && mat.length >= 16 && cats) { // Orthonormal rotation from the 4x4 (column-major, the layout // Matrix4.fromArray expects). Axis signs are tuned for a // mirrored selfie view — flip here if it ever feels inverted. m.fromArray(Array.from(mat)) const e = m.elements xAxis.set(e[0], e[1], e[2]) yAxis.set(e[4], e[5], e[6]) zAxis.set(e[8], e[9], e[10]) if (xAxis.lengthSq() > 1e-8 && zAxis.lengthSq() > 1e-8) { xAxis.normalize() yAxis.normalize() zAxis.normalize() basis.makeBasis(xAxis, yAxis, zAxis) q.setFromRotationMatrix(basis) eul.setFromQuaternion(q, 'YXZ') tr.yaw = clamp(-eul.y, -0.8, 0.8) tr.pitch = clamp(-eul.x, -0.6, 0.6) tr.roll = clamp(-eul.z, -0.6, 0.6) } const get = (n: string) => cats.find((c) => c.categoryName === n)?.score ?? 0 // Raw blink scores rest around ~0.3-0.5 for open eyes and only // peak near 1 on a real blink — remap so open eyes read open. const remapBlink = (s: number) => clamp((s - 0.4) / 0.45, 0, 1) tr.blinkL = remapBlink(get('eyeBlinkLeft')) tr.blinkR = remapBlink(get('eyeBlinkRight')) tr.jaw = clamp(get('jawOpen'), 0, 1) tr.brow = clamp(get('browInnerUp'), 0, 1) tr.lastFace = performance.now() / 1000 } } } catch { // A single bad frame (tab hidden, video seeking) is not fatal. } raf = requestAnimationFrame(loop) } raf = requestAnimationFrame(loop) } catch { // Partial startup (e.g. play() rejected after the camera was granted) // must not leave the webcam light on. stopStream() if (!cancelled) setTrackingFailed(true) } } start() return () => { cancelled = true cancelAnimationFrame(raf) stopStream() video.pause() video.srcObject = null landmarker?.close() landmarker = null tex?.dispose() setVideoTex(null) } }, []) useFrame((state, delta) => { const dt = Math.min(delta, 1 / 30) const t = state.clock.elapsedTime const nowS = performance.now() / 1000 const tr = trackRef.current const r = rig.current const sens = trackingSensitivity const poseLambda = 9 / Math.max(smoothing, 0.05) for (const mat of matsRef.current) if (mat) mat.uTime = t // --- Autopilot: gentle procedural pose, always computed ---------------- const auto = { yaw: Math.sin(t * 0.31) * 0.34 + Math.sin(t * 0.17 + 1.3) * 0.1, pitch: Math.sin(t * 0.23 + 0.7) * 0.11 - 0.02, roll: Math.sin(t * 0.19 + 2.1) * 0.07, } // Autopilot blink: a short closed pulse every ~2–5s. if (t >= autoBlink.current.next) { autoBlink.current.phase = 0.16 autoBlink.current.next = t + 1.8 + Math.random() * 3.2 } autoBlink.current.phase = Math.max(0, autoBlink.current.phase - dt) const autoBlinkV = autoBlink.current.phase > 0 ? 1 : 0 // Saccades: pupils dart to a new random micro-target every ~0.7–2.5s. if (t >= saccade.current.next) { saccade.current.x = (Math.random() - 0.5) * 0.09 saccade.current.y = (Math.random() - 0.5) * 0.055 saccade.current.next = t + 0.7 + Math.random() * 1.8 } // --- Pick the live source --------------------------------------------- const faceFresh = tr.lastFace > 0 && nowS - tr.lastFace < 1.2 const useFace = !trackingFailed && faceFresh const useMouse = trackingFailed && followMouse const targetWeight = useFace || useMouse ? 1 : 0 modeWeight.current = damp(modeWeight.current, targetWeight, LAMBDA_MODE_BLEND, dt) const w = modeWeight.current let srcYaw = 0, srcPitch = 0, srcRoll = 0, srcJaw = 0, srcBrow = 0, srcBlinkL = autoBlinkV, srcBlinkR = autoBlinkV if (useFace) { srcYaw = tr.yaw * sens srcPitch = tr.pitch * sens srcRoll = tr.roll * sens srcJaw = tr.jaw * clamp(sens, 0, 1.5) srcBrow = tr.brow srcBlinkL = tr.blinkL srcBlinkR = tr.blinkR } else if (useMouse) { srcYaw = state.pointer.x * 0.7 * sens srcPitch = -state.pointer.y * 0.45 * sens srcRoll = -state.pointer.x * 0.14 } // Blend autopilot <-> live source so mode changes never snap. const targetYaw = lerp(auto.yaw, srcYaw, w) const targetPitch = lerp(auto.pitch, srcPitch, w) const targetRoll = lerp(auto.roll, srcRoll, w) const targetJaw = lerp(0, srcJaw, w) const targetBrow = lerp(0, srcBrow, w) const targetBlinkL = lerp(autoBlinkV, srcBlinkL, w) const targetBlinkR = lerp(autoBlinkV, srcBlinkR, w) // --- Apply to the rig with per-channel damping ------------------------- const head = headRef.current if (head) { head.rotation.y = dampAngle(head.rotation.y, targetYaw, poseLambda, dt) head.rotation.x = dampAngle(head.rotation.x, targetPitch, poseLambda, dt) head.rotation.z = dampAngle(head.rotation.z, targetRoll, poseLambda, dt) } r.blinkL = damp(r.blinkL, targetBlinkL, LAMBDA_BLINK, dt) r.blinkR = damp(r.blinkR, targetBlinkR, LAMBDA_BLINK, dt) if (lidLRef.current) lidLRef.current.rotation.x = lerp(LID_OPEN, LID_CLOSED, r.blinkL) if (lidRRef.current) lidRRef.current.rotation.x = lerp(LID_OPEN, LID_CLOSED, r.blinkR) r.jaw = damp(r.jaw, targetJaw, LAMBDA_JAW, dt) const mouth = mouthRef.current if (mouth) { mouth.scale.set(1 + r.jaw * 0.25, 0.22 + r.jaw * 1.5, 0.55) mouth.position.y = -0.4 - r.jaw * 0.08 } r.brow = damp(r.brow, targetBrow, LAMBDA_BROW, dt) if (browLRef.current) { browLRef.current.position.y = 0.52 + r.brow * 0.09 browLRef.current.rotation.z = -0.18 - r.brow * 0.12 } if (browRRef.current) { browRRef.current.position.y = 0.52 + r.brow * 0.09 browRRef.current.rotation.z = 0.18 + r.brow * 0.12 } // Pupils: saccade offset plus a push toward the look direction. r.pupilX = damp(r.pupilX, saccade.current.x + targetYaw * 0.11, LAMBDA_PUPIL, dt) r.pupilY = damp(r.pupilY, saccade.current.y - targetPitch * 0.09, LAMBDA_PUPIL, dt) if (pupilLRef.current) pupilLRef.current.position.set(r.pupilX, r.pupilY, 0.175) if (pupilRRef.current) pupilRRef.current.position.set(r.pupilX, r.pupilY, 0.175) // --- Idle life: breathing, bob, sway ----------------------------------- const root = rootRef.current if (root) { const fi = floatIntensity root.position.y = Math.sin(t * 0.85) * 0.06 * fi root.rotation.z = Math.sin(t * 0.6 + 1.2) * 0.025 * fi const breathe = 1 + Math.sin(t * 1.5) * 0.012 * fi root.scale.set(1 / Math.sqrt(breathe), breathe, 1 / Math.sqrt(breathe)) } // --- Picture-in-picture preview pinned to the camera corner ------------ const preview = previewRef.current if (preview) { const cam = state.camera scratch.current.v.set(0.98, -0.56, -2.2).applyQuaternion(cam.quaternion).add(cam.position) preview.position.copy(scratch.current.v) preview.quaternion.copy(cam.quaternion) } }) // Memoized so state changes (tracking fallback, preview texture) don't // allocate fresh colors and re-push uniforms every render. const { accent, rim, dark } = useMemo(() => { const accent = new THREE.Color(color) return { accent, rim: accent.clone().lerp(new THREE.Color('#ffffff'), 0.45), dark: accent.clone().multiplyScalar(0.55), } }, [color]) return ( {/* Additive halo behind the head. */} {/* Skull. */} {/* Eyes: dark glossy sockets + glowing pupils + shader lids. */} {[-1, 1].map((side) => ( {/* Eyelid: upper-hemisphere cap pivoting over the socket. */} ))} {/* Brows. */} {[-1, 1].map((side) => ( ))} {/* Button nose. */} {/* Mouth: dark oval that opens with jawOpen. */} {/* Corner webcam preview (camera-following). */} {showPreview && videoTex && ( )} ) } ``` ### god-rays God Rays: Volumetric light shafts with drifting dust: cinematic background glow, zero post-processing. Props: - color: color = "#fde68a" - intensity: number (0–3) = 1 - rayCount: number (1–32) = 12 - density: number (0–1) = 0.7 - speed: number (0–3) = 1 - flicker: number (0–1) = 0.35 - dust: boolean = true Install: `npx facet3d add god-rays` Source: ```tsx // GodRays — volumetric crepuscular light shafts with zero post-processing: // a fan of additive beam planes with scrolling fbm mist, soft edges, radial // falloff, and organic flicker, plus a fresnel-halo light source and drifting // dust motes. Sits behind hero text — captures no pointer events. // Must be rendered inside a react-three-fiber . // // Usage: // // // // // Install: // npx facet3d add god-rays // // Dependencies: three, @react-three/fiber 'use client' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useFrame, useThree } from '@react-three/fiber' export interface GodRaysProps { /** Beam + source color. Warm "#fde68a" by default; lime "#a3e635" works great too. */ color?: string /** Global brightness multiplier. Default 1. */ intensity?: number /** Number of beam planes in the fan (clamped 1..32). Default 12. */ rayCount?: number /** Mist coverage inside the beams, 0..1+. Default 0.7. */ density?: number /** Global animation speed multiplier. Default 1. */ speed?: number /** Flicker amount, 0 = perfectly steady. Default 0.35. */ flicker?: number /** Drifting dust motes inside the fan. Default true. */ dust?: boolean /** World position of the light source the beams emanate from. */ sourcePosition?: [number, number, number] } const MAX_RAYS = 32 const DUST_COUNT = 180 // hard cap — preallocated, never grows const DUST_RANGE = 7.5 // vertical span of the dust volume below the source // Deterministic layout per rayCount — no layout shift between reloads. function mulberry32(seed: number) { let a = seed >>> 0 return () => { a |= 0 a = (a + 0x6d2b79f5) | 0 let t = Math.imul(a ^ (a >>> 15), 1 | a) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } const NOISE_GLSL = /* glsl */ ` float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix( mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y ); } float fbm(vec2 p) { float v = noise(p) * 0.55; v += noise(p * 2.13 + vec2(17.3, 9.1)) * 0.30; v += noise(p * 4.41 + vec2(-4.7, 3.9)) * 0.15; return v; } ` // Unit plane, origin translated to the pivot (local y runs 0..1 along the beam), // so each mesh is just position + rotation.z + scale — the shader does the rest. const BEAM_VS = /* glsl */ ` uniform float uTime; uniform float uSeed; varying vec2 vUv; varying vec3 vWorldPos; void main() { vUv = uv; // Gentle sway around the pivot, growing toward the far end of the shaft. float sway = sin(uTime * 0.12 + uSeed * 9.0) * 0.05 * position.y; float c = cos(sway); float s = sin(sway); vec3 p = vec3(position.x * c - position.y * s, position.x * s + position.y * c, position.z); vec4 wp = modelMatrix * vec4(p, 1.0); vWorldPos = wp.xyz; gl_Position = projectionMatrix * viewMatrix * wp; } ` const BEAM_FS = NOISE_GLSL + /* glsl */ ` uniform float uTime; uniform vec3 uColor; uniform float uIntensity; uniform float uDensity; uniform float uFlicker; uniform float uSeed; varying vec2 vUv; varying vec3 vWorldPos; void main() { // Mist/dust scrolling along the shaft, per-beam offsets via uSeed. float n = fbm(vec2(vUv.x * 2.5 + uSeed * 11.0, vUv.y * 5.0 - uTime * 0.55 + uSeed * 23.0)); // Density lowers the mist threshold; hi stays strictly above lo (Metal NaN guard). float lo = clamp(0.85 - uDensity * 0.5, 0.05, 0.85); float mist = smoothstep(lo, lo + 0.35, n); // Soft lateral edges — smoothstep edges always ascending. float edge = smoothstep(0.0, 0.4, vUv.x) * (1.0 - smoothstep(0.6, 1.0, vUv.x)); // Radial falloff from the source: long luminous shaft + a hot head at the // pivot. Exponent < 1 keeps the tail visible so beams read as shafts, not // short triangles dissolving a third of the way down. float head = clamp(1.0 - vUv.y, 0.0, 1.0); float radial = pow(head, 0.9) * 1.15 + pow(head, 8.0) * 1.5; // Organic flicker — two incommensurate sines, unique phase per beam. float f1 = sin(uTime * (0.9 + fract(uSeed * 0.371) * 1.3) + uSeed * 17.0); float f2 = sin(uTime * (2.3 + fract(uSeed * 0.717) * 2.1) + uSeed * 41.0); float flick = 1.0 - uFlicker * 0.5 * (0.5 + 0.5 * f1) * (0.5 + 0.5 * f2); // Fade out when the camera flies through the fan. float camDist = length(cameraPosition - vWorldPos); float camFade = smoothstep(0.6, 3.2, camDist); float a = mist * edge * radial * flick * camFade * uIntensity; // Ordered-hash dither breaks 8-bit banding in the long additive gradients. a = clamp(a + (hash(gl_FragCoord.xy) - 0.5) * (1.0 / 255.0), 0.0, 1.0); gl_FragColor = vec4(uColor * a, a); } ` const SOURCE_VS = /* glsl */ ` varying vec2 vUv; varying vec3 vWorldPos; varying vec3 vNormal; void main() { vUv = uv; vNormal = normalize(normalMatrix * normal); vec4 wp = modelMatrix * vec4(position, 1.0); vWorldPos = wp.xyz; gl_Position = projectionMatrix * viewMatrix * wp; } ` const SOURCE_FS = /* glsl */ ` uniform float uTime; uniform vec3 uColor; uniform float uIntensity; varying vec2 vUv; varying vec3 vWorldPos; varying vec3 vNormal; void main() { float d = length(vUv - 0.5) * 2.0; // 0 center .. 1 rim float core = pow(clamp(1.0 - d, 0.0, 1.0), 2.0) * 2.4; float halo = (1.0 - smoothstep(0.25, 1.0, d)) * 0.55; // Fresnel halo — blooms when the source is viewed at a grazing angle. // The 1e-4 bias guards normalize() against the zero vector (Metal NaN). vec3 viewDir = normalize(cameraPosition - vWorldPos + vec3(1e-4)); float fres = pow(1.0 - abs(dot(viewDir, vNormal)), 2.5); float pulse = 1.0 + 0.07 * sin(uTime * 1.6); vec3 col = uColor * (core + halo + fres * 0.9) * uIntensity * pulse; gl_FragColor = vec4(col, 1.0); } ` const DUST_VS = /* glsl */ ` uniform float uTime; uniform float uMinY; uniform float uRange; uniform float uPixelRatio; attribute float aRand; varying float vRand; void main() { vRand = aRand; vec3 p = position; // Sideways sway + endless downward drift wrapped inside the fan volume. p.x += sin(uTime * (0.15 + aRand * 0.25) + aRand * 40.0) * 0.35; p.y = uMinY + mod(position.y - uMinY - uTime * (0.08 + aRand * 0.18) + uRange * 8.0, uRange); vec4 mv = modelViewMatrix * vec4(p, 1.0); gl_PointSize = (2.0 + aRand * 4.0) * uPixelRatio * (10.0 / max(-mv.z, 0.1)); gl_Position = projectionMatrix * mv; } ` const DUST_FS = /* glsl */ ` uniform float uTime; uniform vec3 uColor; uniform float uIntensity; varying float vRand; void main() { float d = distance(gl_PointCoord, vec2(0.5)); if (d > 0.5) discard; float a = 1.0 - smoothstep(0.05, 0.5, d); float tw = 0.35 + 0.65 * (0.5 + 0.5 * sin(uTime * (0.8 + vRand * 2.0) + vRand * 30.0)); gl_FragColor = vec4(uColor * a * tw * uIntensity * 0.5, a * tw * 0.5); } ` interface Beam { angle: number width: number length: number zOff: number material: THREE.ShaderMaterial } export function GodRays({ color = '#fde68a', intensity = 1, rayCount = 12, density = 0.7, speed = 1, flicker = 0.35, dust = true, sourcePosition = [1.8, 4.2, -3], }: GodRaysProps) { const [sx, sy, sz] = sourcePosition const dpr = useThree((s) => s.viewport.dpr) const timeRef = useRef(0) // Shared beam geometry: unit plane with the origin at the pivot (y 0..1). const beamGeo = useMemo(() => { const geo = new THREE.PlaneGeometry(1, 1, 1, 8) geo.translate(0, 0.5, 0) return geo }, []) // Fan layout + one shader material per beam (per-beam seed uniform). const beams = useMemo(() => { const count = Math.max(1, Math.min(MAX_RAYS, Math.floor(rayCount))) const rng = mulberry32(count * 7919) const spread = 1.15 // ~66 degrees, fanning downward from the source const arr: Beam[] = [] for (let i = 0; i < count; i++) { const t = count > 1 ? i / (count - 1) - 0.5 : 0 const seed = rng() * 100 arr.push({ angle: Math.PI + t * spread + (rng() - 0.5) * 0.08, width: 0.5 + rng() * 0.9, length: 7 + rng() * 4, zOff: (rng() - 0.5) * 0.6, material: new THREE.ShaderMaterial({ vertexShader: BEAM_VS, fragmentShader: BEAM_FS, uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(color) }, uIntensity: { value: intensity }, uDensity: { value: density }, uFlicker: { value: flicker }, uSeed: { value: seed }, }, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, }), }) } return arr // Props baked into uniforms at construction are re-synced every frame below. // eslint-disable-next-line react-hooks/exhaustive-deps }, [rayCount]) const sourceMat = useMemo( () => new THREE.ShaderMaterial({ vertexShader: SOURCE_VS, fragmentShader: SOURCE_FS, uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(color) }, uIntensity: { value: intensity }, }, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, }), // eslint-disable-next-line react-hooks/exhaustive-deps [] ) // Dust motes: one Points draw call, positions preallocated inside the fan volume. const dustGeo = useMemo(() => { const rng = mulberry32(1234) const positions = new Float32Array(DUST_COUNT * 3) const randoms = new Float32Array(DUST_COUNT) for (let i = 0; i < DUST_COUNT; i++) { positions[i * 3] = sx + (rng() - 0.5) * 9 positions[i * 3 + 1] = sy - 0.3 - rng() * DUST_RANGE positions[i * 3 + 2] = sz + (rng() - 0.5) * 1.6 randoms[i] = rng() } const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) geo.setAttribute('aRand', new THREE.BufferAttribute(randoms, 1)) return geo }, [sx, sy, sz]) const dustMat = useMemo( () => new THREE.ShaderMaterial({ vertexShader: DUST_VS, fragmentShader: DUST_FS, uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(color) }, uIntensity: { value: intensity }, uMinY: { value: 0 }, // synced every frame from sourcePosition uRange: { value: DUST_RANGE }, uPixelRatio: { value: 1 }, }, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, }), // Uniforms are re-synced every frame; only the shell is memoized once. // eslint-disable-next-line react-hooks/exhaustive-deps [] ) // Dispose everything imperatively created. One effect per resource so a // rayCount change can't dispose the still-shared beam geometry. useEffect(() => () => beamGeo.dispose(), [beamGeo]) useEffect(() => () => beams.forEach((b) => b.material.dispose()), [beams]) useEffect(() => () => sourceMat.dispose(), [sourceMat]) useEffect( () => () => { dustGeo.dispose() dustMat.dispose() }, [dustGeo, dustMat] ) useFrame((_, delta) => { timeRef.current += delta * speed const t = timeRef.current for (const b of beams) { const u = b.material.uniforms u.uTime.value = t ;(u.uColor.value as THREE.Color).set(color) u.uIntensity.value = intensity u.uDensity.value = density u.uFlicker.value = flicker } const su = sourceMat.uniforms su.uTime.value = t ;(su.uColor.value as THREE.Color).set(color) su.uIntensity.value = intensity const du = dustMat.uniforms du.uTime.value = t ;(du.uColor.value as THREE.Color).set(color) du.uIntensity.value = intensity du.uPixelRatio.value = dpr du.uMinY.value = sy - 0.3 - DUST_RANGE }) return ( {beams.map((b, i) => ( ))} {dust && ( )} ) } ``` ### portal Portal: A standing portal that renders another world live, with true perspective parallax. Props: - theme: one of galaxy|ocean = "galaxy" - frameColor: color = "#a3e635" - glowIntensity: number (0–3) = 1 - swirlSpeed: number (0–3) = 1 - wisps: boolean = true - float: boolean = true - size: number (0.5–2) = 1 Install: `npx facet3d add portal` Source: ```tsx // Portal — a standing portal frame revealing another world, rendered live from // the main camera's perspective (true oblique parallax via a mirrored virtual // camera + render target). Themes: "galaxy" and "ocean". // Must be rendered inside a react-three-fiber . // // Usage: // // // // // Install: // npx facet3d add portal // // Dependencies: three, @react-three/fiber, @react-three/drei 'use client' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { createPortal, extend, useFrame } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' export interface PortalProps { theme?: 'galaxy' | 'ocean' frameColor?: string glowIntensity?: number swirlSpeed?: number wisps?: boolean float?: boolean size?: number } // --------------------------------------------------------------------------- // Shared GLSL — 2D value noise / fbm (ring, surface, ocean) and 3D (nebula). // Note: smoothstep edges must stay ascending — reversed edges are undefined // behavior in GLSL and return NaN on Metal (macOS/iOS). // --------------------------------------------------------------------------- const NOISE_2D = /* glsl */ ` float hash2(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float noise2(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix( mix(hash2(i), hash2(i + vec2(1.0, 0.0)), u.x), mix(hash2(i + vec2(0.0, 1.0)), hash2(i + vec2(1.0, 1.0)), u.x), u.y ); } float fbm2(vec2 p) { float v = noise2(p) * 0.55; v += noise2(p * 2.13 + vec2(17.3, 9.1)) * 0.30; v += noise2(p * 4.41 + vec2(-4.7, 3.9)) * 0.15; return v; } ` const NOISE_3D = /* glsl */ ` float hash3(vec3 p) { p = fract(p * 0.3183099 + 0.1); p *= 17.0; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); } float noise3(vec3 x) { vec3 i = floor(x); vec3 f = fract(x); f = f * f * (3.0 - 2.0 * f); return mix( mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x), mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y), mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x), mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y), f.z ); } float fbm3(vec3 p) { float v = noise3(p) * 0.5; v += noise3(p * 2.07 + vec3(11.3, 5.1, 7.7)) * 0.28; v += noise3(p * 4.13 + vec3(-3.9, 8.2, 1.4)) * 0.15; v += noise3(p * 8.31 + vec3(2.2, -6.6, 9.9)) * 0.07; return v; } ` // --------------------------------------------------------------------------- // Portal surface — samples the inner-world render target. uViewProj is the // virtual camera's view-projection matrix; projecting the fragment's // portal-local position with it yields the exact texel the mirrored camera // sees behind that point (correct planar UVs at any viewing angle). // --------------------------------------------------------------------------- const PortalSurfaceMaterial = shaderMaterial( { uMap: new THREE.Texture(), // replaced with the render target's texture via props uViewProj: new THREE.Matrix4(), uColor: new THREE.Color('#a3e635'), uGlow: 1, uTime: 0, }, /* glsl */ ` varying vec3 vLocal; void main() { vLocal = position; // geometry-local == portal-local (mesh sits unscaled at the group origin) gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, /* glsl */ ` uniform sampler2D uMap; uniform mat4 uViewProj; uniform vec3 uColor; uniform float uGlow; uniform float uTime; varying vec3 vLocal; ${NOISE_2D} void main() { vec4 clip = uViewProj * vec4(vLocal, 1.0); // Guard the perspective divide — never 0/0. float w = abs(clip.w) < 1e-4 ? 1e-4 : clip.w; vec2 uv = clip.xy / w * 0.5 + 0.5; float r = length(vLocal.xy); float mask = 1.0 - smoothstep(0.93, 0.995, r); // Liquid shimmer on the window, strongest toward the rim float n = fbm2(vLocal.xy * 3.0 + vec2(uTime * 0.35, -uTime * 0.27)); uv += (n - 0.5) * 0.06 * smoothstep(0.55, 0.97, r); uv = clamp(uv, 0.001, 0.999); vec3 world = texture2D(uMap, uv).rgb; world *= step(0.0, clip.w); // camera slipped behind the portal plane — show nothing world *= mask; // Soft inner edge glow, gently pulsing float edge = smoothstep(0.72, 0.98, r) * mask; float pulse = 0.7 + 0.3 * sin(uTime * 2.1); vec3 glow = uColor * edge * pulse * uGlow * 0.9; gl_FragColor = vec4(world + glow, 1.0); } ` ) // --------------------------------------------------------------------------- // Energy ring — polar fbm swirl, additive. Sampling noise on (cos a, sin a) // keeps the field periodic, so there is no seam where atan wraps. // --------------------------------------------------------------------------- const PortalRingMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color('#a3e635'), uGlow: 1, uSwirl: 1, }, /* glsl */ ` varying vec2 vPos; void main() { vPos = position.xy; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, /* glsl */ ` uniform float uTime; uniform vec3 uColor; uniform float uGlow; uniform float uSwirl; varying vec2 vPos; ${NOISE_2D} void main() { float r = length(vPos); // ring spans 1.0 .. 1.45 float a = atan(vPos.y, vPos.x); vec2 pol = vec2(cos(a), sin(a)); float t = uTime * uSwirl; float n1 = fbm2(pol * 2.3 + vec2(t * 0.40, -t * 0.26) + r * 2.0); float n2 = fbm2(pol * 4.7 - vec2(t * 0.31, t * 0.22) + vec2(r * 5.0, 1.7)); float energy = pow(clamp(n1 * 0.7 + n2 * 0.55, 0.0, 1.2), 2.4); float width = smoothstep(1.0, 1.05, r) * (1.0 - smoothstep(1.18, 1.44, r)); float core = smoothstep(1.0, 1.03, r) * (1.0 - smoothstep(1.02, 1.16, r)); vec3 col = uColor * (energy * width * 1.7 + core * 1.1) * uGlow; col += vec3(1.0) * pow(energy, 3.0) * width * 0.35 * uGlow; // white-hot flickers gl_FragColor = vec4(col, 1.0); } ` ) // --------------------------------------------------------------------------- // Wisps — pooled point sprites orbiting the rim. Motion is 100% GPU-side from // a preallocated seed attribute; no buffer growth, no CPU updates. // --------------------------------------------------------------------------- const PortalWispsMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color('#a3e635'), uScale: 1, uSwirl: 1, }, /* glsl */ ` attribute vec4 aSeed; // x: radius jitter, y: angle0, z: speed, w: size uniform float uTime; uniform float uScale; uniform float uSwirl; varying float vAlpha; void main() { float ang = aSeed.y + uTime * aSeed.z * uSwirl * 0.55; float rad = 1.14 + aSeed.x * 0.36; vec3 pos = vec3(cos(ang) * rad, sin(ang) * rad, sin(uTime * 0.6 + aSeed.y * 7.0) * 0.12); vec4 mv = modelViewMatrix * vec4(pos, 1.0); // uScale converts a world-space diameter to device pixels (set from viewport + fov + dpr) gl_PointSize = aSeed.w * uScale / max(-mv.z, 0.1); vAlpha = 0.35 + 0.65 * (0.5 + 0.5 * sin(uTime * 1.7 + aSeed.y * 11.0)); gl_Position = projectionMatrix * mv; } `, /* glsl */ ` uniform vec3 uColor; varying float vAlpha; void main() { float d = distance(gl_PointCoord, vec2(0.5)); if (d > 0.5) discard; float soft = 1.0 - smoothstep(0.08, 0.5, d); vec3 col = mix(vec3(1.0), uColor, 0.72); gl_FragColor = vec4(col, soft * vAlpha * 0.8); } ` ) // --------------------------------------------------------------------------- // Galaxy world — star dome (3D fbm nebula + twinkling cell stars) and // floating fresnel crystals. Rendered into the portal's inner scene. // --------------------------------------------------------------------------- const PortalStarDomeMaterial = shaderMaterial( { uTime: 0 }, /* glsl */ ` varying vec3 vDir; void main() { vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, /* glsl */ ` uniform float uTime; varying vec3 vDir; ${NOISE_3D} // One star per noise cell: point-sprite falloff around a hashed cell // position, gated by a density hash, twinkled over time. float stars(vec3 d, float scale, float t) { vec3 p = d * scale; vec3 i = floor(p); vec3 f = fract(p); vec3 sp = vec3(hash3(i + 11.1), hash3(i + 27.7), hash3(i + 43.3)); float dist = length(f - sp); float m = (1.0 - smoothstep(0.0, 0.12, dist)) * step(0.92, hash3(i)); float tw = 0.6 + 0.4 * sin(t * 2.0 + hash3(i + 5.5) * 40.0); return m * tw; } void main() { vec3 d = normalize(vDir); float neb = fbm3(d * 2.6 + vec3(0.0, uTime * 0.015, uTime * 0.01)); float neb2 = fbm3(d * 5.1 - vec3(uTime * 0.02, 0.0, 0.0)); vec3 col = vec3(0.012, 0.008, 0.030); col += vec3(0.10, 0.30, 0.62) * pow(neb, 1.9) * 1.30; // cobalt clouds (brand: no purple) col += vec3(0.05, 0.45, 0.40) * pow(neb2, 3.0) * 0.85; // teal wisps col += vec3(0.55, 0.80, 0.20) * pow(neb * neb2, 3.0); // lime dust lanes col += vec3(0.90, 0.95, 1.00) * stars(d, 40.0, uTime) * 0.9; col += vec3(1.00) * stars(d + 3.7, 90.0, uTime * 1.3) * 0.5; gl_FragColor = vec4(col, 1.0); } ` ) const PortalCrystalMaterial = shaderMaterial( { uTime: 0, uColorA: new THREE.Color('#22d3ee'), // brand: no purple — cyan into lime uColorB: new THREE.Color('#a3e635'), }, /* glsl */ ` varying vec3 vNormal; varying vec3 vViewDir; void main() { vec4 worldPosition = modelMatrix * vec4(position, 1.0); vNormal = normalize(mat3(modelMatrix) * normal); // uniform scale only — safe vViewDir = normalize(cameraPosition - worldPosition.xyz); gl_Position = projectionMatrix * viewMatrix * worldPosition; } `, /* glsl */ ` uniform float uTime; uniform vec3 uColorA; uniform vec3 uColorB; varying vec3 vNormal; varying vec3 vViewDir; void main() { vec3 n = normalize(vNormal); vec3 v = normalize(vViewDir); float fresnel = pow(1.0 - abs(dot(v, n)), 2.5); float bands = 0.5 + 0.5 * sin(fresnel * 14.0 + uTime * 1.2); vec3 col = mix(uColorA, uColorB, fresnel * 0.85 + bands * 0.15); col *= 0.22 + fresnel * 1.7; float alpha = clamp(0.30 + fresnel * 0.70, 0.0, 1.0); gl_FragColor = vec4(col, alpha); } ` ) // --------------------------------------------------------------------------- // Ocean world — procedural dusk sky dome (gradient + sun + drifting clouds) // and a compact 4-train Gerstner sea with analytic normals and sun glint. // --------------------------------------------------------------------------- const OCEAN_SUN = 'vec3(0.1186, 0.0988, -0.9882)' // normalize(vec3(0.12, 0.10, -1.0)) — low sun near the window center const SKY_GLSL = /* glsl */ ` const vec3 SUN_DIR = ${OCEAN_SUN}; vec3 sky(vec3 d) { float sd = max(dot(d, SUN_DIR), 0.0); float h = clamp(d.y, 0.0, 1.0); vec3 col = mix(vec3(0.34, 0.47, 0.60), vec3(0.04, 0.13, 0.32), pow(h, 0.55)); // warm dusk band hugging the horizon on the sun side col += vec3(1.00, 0.45, 0.18) * pow(sd, 4.0) * exp(-h * 7.0) * 0.30; vec2 cuv = d.xz / max(d.y, 0.06); // guard: near-horizon division float cl = fbm2(cuv * 1.3 + vec2(uTime * 0.012, 0.0)); col += vec3(0.95, 0.92, 0.88) * smoothstep(0.55, 0.85, cl) * 0.28 * smoothstep(0.04, 0.25, d.y); col += vec3(1.00, 0.92, 0.70) * pow(sd, 900.0) * 2.5; // disc col += vec3(1.00, 0.85, 0.55) * pow(sd, 24.0) * 0.28; // halo col += vec3(1.00, 0.75, 0.45) * pow(sd, 5.0) * 0.08; // broad warmth // below the horizon, fall back to deep sea tone so reflections stay sane col = mix(vec3(0.02, 0.10, 0.16), col, smoothstep(-0.08, 0.02, d.y)); return col; } ` const PortalSkyMaterial = shaderMaterial( { uTime: 0 }, /* glsl */ ` varying vec3 vDir; void main() { vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, /* glsl */ ` uniform float uTime; varying vec3 vDir; ${NOISE_2D} ${SKY_GLSL} void main() { gl_FragColor = vec4(sky(normalize(vDir)), 1.0); } ` ) const PortalWaterMaterial = shaderMaterial( { uTime: 0, uWaveHeight: 0.38 }, /* glsl */ ` uniform float uTime; uniform float uWaveHeight; varying vec3 vWorldPos; varying vec3 vNormal; varying float vCrest; // One Gerstner train (local space: xy horizontal, z up); binormal/tangent // accumulate exact partial derivatives for an analytic normal. void gerstner( vec2 dir, float wavelength, float ampScale, float steepScale, vec2 p, inout vec2 horiz, inout float height, inout vec3 binormal, inout vec3 tangent ) { float k = 6.28318530718 / wavelength; float c = sqrt(9.8 / k); float a = uWaveHeight * ampScale; float q = min(steepScale / (k * a * 4.0 + 1e-4), 1.0 / (k * a + 1e-4)); float f = k * (dot(dir, p) - c * uTime); float sf = sin(f); float cf = cos(f); horiz += q * a * dir * cf; height += a * sf; float wa = k * a; binormal.x -= q * wa * dir.x * dir.x * sf; binormal.y -= q * wa * dir.x * dir.y * sf; binormal.z += wa * dir.x * cf; tangent.x -= q * wa * dir.x * dir.y * sf; tangent.y -= q * wa * dir.y * dir.y * sf; tangent.z += wa * dir.y * cf; } void main() { vec2 p = position.xy; vec2 horiz = vec2(0.0); float height = 0.0; vec3 binormal = vec3(1.0, 0.0, 0.0); vec3 tangent = vec3(0.0, 1.0, 0.0); gerstner(normalize(vec2( 1.00, 0.20)), 26.0, 1.00, 1.00, p, horiz, height, binormal, tangent); gerstner(normalize(vec2( 0.60, 0.80)), 13.0, 0.55, 0.90, p, horiz, height, binormal, tangent); gerstner(normalize(vec2(-0.50, 0.85)), 7.0, 0.30, 0.75, p, horiz, height, binormal, tangent); gerstner(normalize(vec2( 0.90, -0.45)), 3.5, 0.12, 0.55, p, horiz, height, binormal, tangent); vec3 pos = vec3(position.xy + horiz, height); vec3 n = normalize(cross(binormal, tangent)); vCrest = height / (uWaveHeight * 1.97 + 1e-4); // sum of ampScales = 1.97 vWorldPos = (modelMatrix * vec4(pos, 1.0)).xyz; vNormal = normalize(mat3(modelMatrix) * n); // uniform rotation only — safe gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } `, /* glsl */ ` uniform float uTime; varying vec3 vWorldPos; varying vec3 vNormal; varying float vCrest; ${NOISE_2D} ${SKY_GLSL} void main() { vec3 viewDir = normalize(cameraPosition - vWorldPos); vec3 n = normalize(vNormal); // Fresnel-weighted procedural sky reflection over a deep teal body float fresnel = 0.03 + 0.97 * pow(1.0 - max(dot(viewDir, n), 0.0), 5.0); vec3 refl = sky(reflect(-viewDir, n)); vec3 body = mix(vec3(0.004, 0.05, 0.08), vec3(0.02, 0.22, 0.28), clamp(n.z, 0.0, 1.0)); vec3 col = mix(body, refl, fresnel); // Soft crest foam, broken up with value noise float crest01 = clamp(vCrest * 0.5 + 0.5, 0.0, 1.0); float foamN = noise2(vWorldPos.xz * 1.4) * 0.6 + noise2(vWorldPos.xz * 4.5) * 0.4; float foam = smoothstep(0.52, 0.96, crest01 + (foamN - 0.5) * 0.35); col = mix(col, vec3(0.85, 0.92, 0.94), foam * 0.7); // Sun glint: Blinn-Phong specular vec3 h = normalize(SUN_DIR + viewDir); float spec = pow(max(dot(n, h), 0.0), 260.0); col += vec3(1.0, 0.9, 0.7) * spec * 1.0 * (1.0 - foam); // Fade into the sky's actual horizon tone with distance — sells the // horizon line and keeps the dusk warmth instead of going flat grey. float dist = length(cameraPosition - vWorldPos); vec2 dxz = vWorldPos.xz - cameraPosition.xz; float dl = max(length(dxz), 1e-3); vec3 horizonCol = sky(normalize(vec3(dxz.x / dl, 0.035, dxz.y / dl))); col = mix(col, horizonCol, smoothstep(28.0, 62.0, dist)); gl_FragColor = vec4(col, 1.0); } ` ) extend({ PortalSurfaceMaterial, PortalRingMaterial, PortalWispsMaterial, PortalStarDomeMaterial, PortalCrystalMaterial, PortalSkyMaterial, PortalWaterMaterial, }) declare global { namespace JSX { interface IntrinsicElements { portalSurfaceMaterial: any portalRingMaterial: any portalWispsMaterial: any portalStarDomeMaterial: any portalCrystalMaterial: any portalSkyMaterial: any portalWaterMaterial: any } } } // Inner worlds live behind the virtual portal: scene origin, facing +z, world at z < 0. function GalaxyWorld() { const domeRef = useRef(null) const crystalMatsRef = useRef([]) const crystalsRef = useRef(null) const crystals = useMemo( () => Array.from({ length: 9 }, (_, i) => ({ position: [ (Math.random() - 0.5) * 3.4, (Math.random() - 0.5) * 2.8, -2.2 - Math.random() * 6.5, ] as [number, number, number], scale: 0.35 + Math.random() * 0.75, phase: Math.random() * Math.PI * 2, spin: 0.25 + Math.random() * 0.6, key: i, })), [] ) useFrame((state, delta) => { if (domeRef.current) domeRef.current.uTime += delta for (let i = 0; i < crystalMatsRef.current.length; i++) { const m = crystalMatsRef.current[i] if (m) m.uTime += delta } const g = crystalsRef.current if (g) { const t = state.clock.elapsedTime for (let i = 0; i < g.children.length; i++) { const m = g.children[i] const c = crystals[i] m.rotation.x = t * c.spin * 0.7 + c.phase m.rotation.y = t * c.spin + c.phase m.position.y = c.position[1] + Math.sin(t * 0.6 + c.phase) * 0.18 } } }, -2) // run before the portal's render-target pass (priority -1) return ( {crystals.map((c) => ( { crystalMatsRef.current[c.key] = el }} transparent depthWrite={false} /> ))} ) } function OceanWorld() { const skyRef = useRef(null) const waterRef = useRef(null) useFrame((_, delta) => { if (skyRef.current) skyRef.current.uTime += delta if (waterRef.current) waterRef.current.uTime += delta }, -2) return ( ) } export function Portal({ theme = 'galaxy', frameColor = '#a3e635', glowIntensity = 1, swirlSpeed = 1, wisps = true, float = true, size = 1, }: PortalProps) { const groupRef = useRef(null) const surfaceRef = useRef(null) const ringRef = useRef(null) const wispsRef = useRef(null) const timeRef = useRef(0) // The inner world lives in its own scene; a mirrored virtual camera renders // it to a target that the portal surface samples. const innerScene = useMemo(() => new THREE.Scene(), []) const rt = useMemo( () => new THREE.WebGLRenderTarget(1024, 1024, { depthBuffer: true, samples: 4, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, }), [] ) const vCam = useMemo(() => new THREE.PerspectiveCamera(50, 1, 0.05, 200), []) // Scratch objects — allocated once, never in the frame loop. const tmp = useMemo( () => ({ inv: new THREE.Matrix4(), viewProj: new THREE.Matrix4(), camPos: new THREE.Vector3(), camQuat: new THREE.Quaternion(), portalQuat: new THREE.Quaternion(), }), [] ) // Pooled wisp seeds: position attribute is a dummy (motion is GPU-side). const wispGeo = useMemo(() => { const count = 130 const geo = new THREE.BufferGeometry() const positions = new Float32Array(count * 3) const seeds = new Float32Array(count * 4) for (let i = 0; i < count; i++) { seeds[i * 4] = Math.random() seeds[i * 4 + 1] = Math.random() * Math.PI * 2 seeds[i * 4 + 2] = (0.3 + Math.random() * 0.9) * (Math.random() > 0.5 ? 1 : -1) seeds[i * 4 + 3] = 0.02 + Math.random() * 0.05 } geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) geo.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 4)) return geo }, []) useEffect(() => { return () => { rt.dispose() wispGeo.dispose() innerScene.traverse((obj: any) => { if (obj.geometry) obj.geometry.dispose() if (obj.material) { const mats = Array.isArray(obj.material) ? obj.material : [obj.material] mats.forEach((m: any) => m.dispose()) } }) } }, [rt, wispGeo, innerScene]) // Priority -1: runs before the default render, so the target is fresh when // the portal surface samples it. Inner-world animators run at -2, earlier still. useFrame((state, delta) => { timeRef.current += delta const t = timeRef.current const group = groupRef.current if (!group) return if (float) { group.position.y = Math.sin(t * 0.8) * 0.06 group.rotation.y = Math.sin(t * 0.35) * 0.05 group.rotation.z = Math.sin(t * 0.5) * 0.012 } else if (group.position.y !== 0 || group.rotation.y !== 0 || group.rotation.z !== 0) { // Toggled off mid-float: settle back to the neutral pose. group.position.y = 0 group.rotation.set(0, 0, 0) } // Match the target to the viewport (clamped) so the window stays sharp at // any DPR without paying for a fixed oversize buffer. Only reallocates on // resize. const dpr = state.viewport.dpr const rtW = Math.min(2048, Math.round(state.size.width * dpr)) const rtH = Math.min(2048, Math.round(state.size.height * dpr)) if (rt.width !== rtW || rt.height !== rtH) rt.setSize(rtW, rtH) // Place the virtual camera exactly where the main camera sits in // portal-local space — that's what makes the view through the frame // perspective-correct from any angle. group.updateWorldMatrix(true, false) tmp.inv.copy(group.matrixWorld).invert() state.camera.getWorldPosition(tmp.camPos).applyMatrix4(tmp.inv) state.camera.getWorldQuaternion(tmp.camQuat) group.getWorldQuaternion(tmp.portalQuat).invert() vCam.position.copy(tmp.camPos) vCam.quaternion.copy(tmp.portalQuat).multiply(tmp.camQuat) const pc = state.camera as THREE.PerspectiveCamera if (pc.isPerspectiveCamera) { vCam.fov = pc.fov vCam.aspect = pc.aspect vCam.updateProjectionMatrix() } vCam.updateMatrixWorld(true) vCam.matrixWorldInverse.copy(vCam.matrixWorld).invert() tmp.viewProj.multiplyMatrices(vCam.projectionMatrix, vCam.matrixWorldInverse) const surface = surfaceRef.current if (surface) { surface.uTime = t surface.uColor.set(frameColor) surface.uGlow = glowIntensity surface.uViewProj.copy(tmp.viewProj) } const ring = ringRef.current if (ring) { ring.uTime = t ring.uColor.set(frameColor) ring.uGlow = glowIntensity ring.uSwirl = swirlSpeed } const wispsMat = wispsRef.current if (wispsMat) { wispsMat.uTime = t wispsMat.uColor.set(frameColor) wispsMat.uSwirl = swirlSpeed const fov = pc.isPerspectiveCamera ? pc.fov : 50 wispsMat.uScale = (state.size.height * state.viewport.dpr) / (2 * Math.tan(THREE.MathUtils.degToRad(fov) / 2)) } // Render the inner world into the target with the mirrored camera. state.gl.setRenderTarget(rt) state.gl.render(innerScene, vCam) state.gl.setRenderTarget(null) }, -1) return ( {createPortal(theme === 'ocean' ? : , innerScene)} {/* Window into the inner world */} {/* Dark structural rim behind the energy ring */} {/* Swirling energy ring */} {/* Particle wisps orbiting the rim */} {wisps && ( )} ) } ``` ### silk-cloth Silk Cloth: Silk banner billowing in gusting wind, with smooth pointer push and drag. Props: - color: color = "#a3e635" - windStrength: number (0–3) = 1 - gustiness: number (0–3) = 1 - segments: number (8–64) = 36 - pointerRadius: number (0.1–3) = 0.9 - pointerStrength: number (0–3) = 1 - pinMode: one of top|corners = "top" - sheen: number (0–2) = 1 Install: `npx facet3d add silk-cloth` Source: ```tsx // SilkCloth — an elegant silk banner: CPU verlet cloth billowing in fbm-gusted // wind, with smooth pointer push/drag interaction (no tearing). // Must be rendered inside a react-three-fiber . // // Usage: // // // // // Install: // npx facet3d add silk-cloth // // Dependencies: react, three, @react-three/fiber // // The simulation runs on a fixed-timestep accumulator (verlet integration + // 5 constraint-relaxation iterations), writes directly into the geometry's // position attribute and recomputes vertex normals every frame. All buffers // are preallocated — zero per-frame allocations in the hot loop. // Sheen-rich double-sided MeshPhysicalMaterial + baked vertical-gradient // vertex colors give the soft self-shadowed silk look, and self-contained // lights mean no scene lighting is required. 'use client' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' export interface SilkClothProps { color?: string windStrength?: number gustiness?: number segments?: number pointerRadius?: number pointerStrength?: number pinMode?: 'top' | 'corners' sheen?: number } const CLOTH_W = 3.4 const CLOTH_H = 2.4 const STEP = 1 / 60 // fixed simulation timestep const MAX_STEPS = 4 // per-frame substep cap (tab-switch spiral guard) const DAMPING = 0.985 const GRAVITY = -2.2 // silk is light — wind dominates const RELAX_ITERS = 5 // keeps hem stretch under ~5% — silk, not rubber const POINTER_ACCEL = 15 // u/s² at the cursor's center — a firm dent, not a launch const POINTER_DRAG = 4 // extra shove from fast cursor movement (per u/s of cursor vel) // --- tiny deterministic value-noise fbm (CPU) -------------------------------- function hash2(x: number, y: number): number { const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123 return s - Math.floor(s) } function valueNoise(x: number, y: number): number { const ix = Math.floor(x) const iy = Math.floor(y) const fx = x - ix const fy = y - iy const ux = fx * fx * (3 - 2 * fx) const uy = fy * fy * (3 - 2 * fy) const a = hash2(ix, iy) const b = hash2(ix + 1, iy) const c = hash2(ix, iy + 1) const d = hash2(ix + 1, iy + 1) return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy } function fbm(x: number, y: number): number { let v = valueNoise(x, y) * 0.55 v += valueNoise(x * 2.13 + 17.3, y * 2.13 + 9.1) * 0.3 v += valueNoise(x * 4.41 - 4.7, y * 4.41 + 3.9) * 0.15 return v // ~0..1 } // --- cloth buffers ------------------------------------------------------------ interface ClothSim { geometry: THREE.BufferGeometry pos: Float32Array // live positions — IS the geometry position attribute array prev: Float32Array rest: Float32Array pinned: Uint8Array linksA: Int32Array linksB: Int32Array linksRest: Float32Array count: number } function buildCloth(segments: number, pinMode: 'top' | 'corners'): ClothSim { const seg = Math.max(4, Math.min(64, Math.floor(segments))) // hard cap: 64 const n = seg + 1 const count = n * n const pos = new Float32Array(count * 3) const rest = new Float32Array(count * 3) const colors = new Float32Array(count * 3) const uvs = new Float32Array(count * 2) const pinned = new Uint8Array(count) // Grid hangs in the local XY plane, top edge at +CLOTH_H/2. for (let iy = 0; iy < n; iy++) { const v = iy / seg const y = CLOTH_H / 2 - v * CLOTH_H for (let ix = 0; ix < n; ix++) { const u = ix / seg const x = -CLOTH_W / 2 + u * CLOTH_W const i = iy * n + ix rest[i * 3] = x rest[i * 3 + 1] = y rest[i * 3 + 2] = 0 pos[i * 3] = x pos[i * 3 + 1] = y pos[i * 3 + 2] = 0 uvs[i * 2] = u uvs[i * 2 + 1] = 1 - v // Baked vertical gradient — bright at the pinned edge, softly shadowed // toward the free hem. Reads as gentle self-shadowing under any light. const b = 1.04 - 0.36 * v colors[i * 3] = b colors[i * 3 + 1] = b colors[i * 3 + 2] = b } } if (pinMode === 'top') { for (let ix = 0; ix < n; ix++) pinned[ix] = 1 } else { pinned[0] = 1 // top-left pinned[n - 1] = 1 // top-right } // Winding gives front faces toward +z (verified: cross(c-a, b-a) = +z). const indices = new Uint32Array(seg * seg * 6) let k = 0 for (let iy = 0; iy < seg; iy++) { for (let ix = 0; ix < seg; ix++) { const a = iy * n + ix const b = a + 1 const c = a + n const d = c + 1 indices[k++] = a indices[k++] = c indices[k++] = b indices[k++] = b indices[k++] = c indices[k++] = d } } // Structural links only (horizontal + vertical) — silk stays drapey. const linkCount = 2 * seg * n const linksA = new Int32Array(linkCount) const linksB = new Int32Array(linkCount) const linksRest = new Float32Array(linkCount) let l = 0 const dx = CLOTH_W / seg const dy = CLOTH_H / seg for (let iy = 0; iy < n; iy++) { for (let ix = 0; ix < seg; ix++) { linksA[l] = iy * n + ix linksB[l] = iy * n + ix + 1 linksRest[l] = dx l++ } } for (let iy = 0; iy < seg; iy++) { for (let ix = 0; ix < n; ix++) { linksA[l] = iy * n + ix linksB[l] = (iy + 1) * n + ix linksRest[l] = dy l++ } } const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3)) geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)) geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)) geometry.setIndex(new THREE.BufferAttribute(indices, 1)) geometry.computeVertexNormals() return { geometry, pos, prev: pos.slice(), rest, pinned, linksA, linksB, linksRest, count, } } // One fixed-timestep substep: integrate → pointer impulse → constraint relax. // Everything writes in place into preallocated typed arrays. function stepCloth( sim: ClothSim, time: number, windStrength: number, gustiness: number, px: number, py: number, pvx: number, pvy: number, pointerRadius: number, pointerStrength: number, pointerInfluence: number ): void { const { pos, prev, rest, pinned, linksA, linksB, linksRest, count } = sim const h2 = STEP * STEP const windBase = 6 * windStrength // Verlet integration with an fbm gust field drifting over the cloth. for (let i = 0; i < count; i++) { if (pinned[i]) continue const i3 = i * 3 const x = pos[i3] const y = pos[i3 + 1] const z = pos[i3 + 2] const nse = fbm(x * 0.55 + time * 0.45, y * 0.55 - time * 0.3) // Peak-clamped so max windStrength × max gustiness flaps hard but never // knots the cloth into creases it can't unfold from. const gust = Math.min(windBase * (0.35 + gustiness * 1.5 * nse), 22) const ax = Math.sin(time * 0.6 + y * 0.8) * gust * 0.25 const ay = GRAVITY + Math.cos(time * 0.5 + x * 0.7) * gust * 0.12 const az = gust * (0.75 + 0.25 * Math.sin(time * 0.9 + x * 1.3 + y * 0.6)) pos[i3] = x + (x - prev[i3]) * DAMPING + ax * h2 pos[i3 + 1] = y + (y - prev[i3 + 1]) * DAMPING + ay * h2 pos[i3 + 2] = z + (z - prev[i3 + 2]) * DAMPING + az * h2 prev[i3] = x prev[i3 + 1] = y prev[i3 + 2] = z } // Pointer impulse: smooth quadratic radial falloff, push along +z plus a // slight radial shove and a drag term from the cursor's world velocity. // Only `pos` moves — verlet reads the displacement as velocity, and the // constraint pass below absorbs the stretch, so the cloth never tears. // The push is an acceleration scaled by h² (like gravity/wind above), so it // dents the cloth instead of launching it; the drag term scales with STEP // for the same reason. if (pointerInfluence > 0.001) { const r = Math.max(pointerRadius, 1e-3) const r2 = r * r const push = pointerStrength * pointerInfluence for (let i = 0; i < count; i++) { if (pinned[i]) continue const i3 = i * 3 const ddx = pos[i3] - px const ddy = pos[i3 + 1] - py const d2 = ddx * ddx + ddy * ddy if (d2 > r2) continue const d = Math.sqrt(d2) const fall = 1 - d / r const f = fall * fall * push const inv = 1 / Math.max(d, 1e-4) // guarded — never normalize(0) pos[i3] += (ddx * inv * POINTER_ACCEL * 0.45 + pvx * POINTER_DRAG) * f * h2 pos[i3 + 1] += (ddy * inv * POINTER_ACCEL * 0.45 + pvy * POINTER_DRAG) * f * h2 pos[i3 + 2] += f * POINTER_ACCEL * h2 } } // Constraint relaxation; pinned verts are hard-reset every iteration so the // pinned edge never accumulates drift. for (let iter = 0; iter < RELAX_ITERS; iter++) { for (let li = 0; li < linksA.length; li++) { const a = linksA[li] const b = linksB[li] const a3 = a * 3 const b3 = b * 3 const dxv = pos[b3] - pos[a3] const dyv = pos[b3 + 1] - pos[a3 + 1] const dzv = pos[b3 + 2] - pos[a3 + 2] const dist = Math.sqrt(dxv * dxv + dyv * dyv + dzv * dzv) if (dist < 1e-7) continue const diff = (dist - linksRest[li]) / dist const pa = pinned[a] const pb = pinned[b] if (pa && pb) continue const wa = pa ? 0 : pb ? 1 : 0.5 const wb = pb ? 0 : pa ? 1 : 0.5 pos[a3] += dxv * diff * wa pos[a3 + 1] += dyv * diff * wa pos[a3 + 2] += dzv * diff * wa pos[b3] -= dxv * diff * wb pos[b3 + 1] -= dyv * diff * wb pos[b3 + 2] -= dzv * diff * wb } for (let i = 0; i < count; i++) { if (pinned[i]) { const i3 = i * 3 pos[i3] = rest[i3] pos[i3 + 1] = rest[i3 + 1] pos[i3 + 2] = rest[i3 + 2] } } } } export function SilkCloth({ color = '#a3e635', windStrength = 1, gustiness = 1, segments = 36, pointerRadius = 0.9, pointerStrength = 1, pinMode = 'top', sheen = 1, }: SilkClothProps) { const materialRef = useRef(null) const sim = useMemo(() => buildCloth(segments, pinMode), [segments, pinMode]) // Simulation clock + fixed-timestep accumulator. const timeRef = useRef(0) const accRef = useRef(0) // Pointer state in world space on the z=0 plane (the cloth's rest plane). const pointerActive = useRef(false) const hadPointer = useRef(false) const influence = useRef(0) // eased 0..1 so engagement never pops const pointerPos = useRef(new THREE.Vector2(9999, 9999)) const pointerVel = useRef(new THREE.Vector2(0, 0)) const scratch = useMemo(() => ({ point: new THREE.Vector3(), dir: new THREE.Vector3() }), []) // Sheen tint: base color lifted toward white — reads as silk highlights. const sheenColor = useMemo( () => new THREE.Color(color).lerp(new THREE.Color('#ffffff'), 0.45), [color] ) const rimColor = useMemo(() => new THREE.Color(color), [color]) // Only push the cloth once the cursor has actually moved over the window — // state.pointer defaults to (0,0), which would dent the banner's center. useEffect(() => { const onMove = () => { pointerActive.current = true } const onLeave = () => { pointerActive.current = false } window.addEventListener('pointermove', onMove) document.documentElement.addEventListener('pointerleave', onLeave) return () => { window.removeEventListener('pointermove', onMove) document.documentElement.removeEventListener('pointerleave', onLeave) } }, []) // Dispose GPU resources on unmount / rebuild (material is disposed too — // R3F would auto-dispose the JSX material, this just makes it explicit). useEffect(() => () => sim.geometry.dispose(), [sim]) useEffect( () => () => { materialRef.current?.dispose() }, [] ) useFrame((state, delta) => { // Unproject the pointer onto the z=0 world plane (camera ray / plane hit). let targetInfluence = 0 if (pointerActive.current) { const { camera, pointer } = state const { point, dir } = scratch point.set(pointer.x, pointer.y, 0.5).unproject(camera) dir.copy(point).sub(camera.position) const len = dir.length() if (len > 1e-6) { dir.multiplyScalar(1 / len) if (Math.abs(dir.z) > 1e-6) { const t = -camera.position.z / dir.z if (t > 0) { const wx = camera.position.x + dir.x * t const wy = camera.position.y + dir.y * t if (hadPointer.current) { const dt = Math.max(delta, 1e-4) let pvx = (wx - pointerPos.current.x) / dt let pvy = (wy - pointerPos.current.y) / dt const vmag = Math.hypot(pvx, pvy) if (vmag > 18) { pvx = (pvx / vmag) * 18 pvy = (pvy / vmag) * 18 } // Smoothed cursor velocity drives the drag term. pointerVel.current.x += (pvx - pointerVel.current.x) * 0.35 pointerVel.current.y += (pvy - pointerVel.current.y) * 0.35 } else { pointerVel.current.set(0, 0) // re-entry: no velocity spike hadPointer.current = true } pointerPos.current.set(wx, wy) targetInfluence = 1 } } } } if (targetInfluence === 0) { hadPointer.current = false pointerVel.current.multiplyScalar(Math.max(0, 1 - delta * 8)) } influence.current += (targetInfluence - influence.current) * Math.min(1, delta * 6) // Fixed-timestep accumulator, clamped against tab-switch spirals. accRef.current = Math.min(accRef.current + Math.min(delta, 0.1), STEP * MAX_STEPS) let stepped = false while (accRef.current >= STEP) { timeRef.current += STEP stepCloth( sim, timeRef.current, windStrength, gustiness, pointerPos.current.x, pointerPos.current.y, pointerVel.current.x, pointerVel.current.y, pointerRadius, pointerStrength, influence.current ) accRef.current -= STEP stepped = true } if (stepped) { sim.geometry.getAttribute('position').needsUpdate = true sim.geometry.computeVertexNormals() } }) return ( {/* Self-contained lighting: cool ambient, neutral key, colored rim. */} ) } ``` ### lightning-arcs Lightning Arcs: Branching electric arcs with a white-hot core, strike flicker, and pointer chasing. Props: - color: color = "#a3e635" - branches: number (1–4) = 3 - strikeRate: number (0.1–5) = 1.5 - thickness: number (0.01–0.2) = 0.04 - flicker: number (0–1) = 0.7 - followPointer: boolean = false - glowIntensity: number (0–3) = 1.6 Install: `npx facet3d add lightning-arcs` Source: ```tsx // LightningArcs — branching electric arcs between two endpoints, regenerated on // a strike timer with recursive midpoint displacement. Additive shader ribbons // with a white-hot core, colored glow falloff, per-branch alpha fade, intensity // flicker, and a point-light flash with fast afterglow decay on every strike. // Must be rendered inside a react-three-fiber . // // Usage: // // // // Install: // npx facet3d add lightning-arcs // // Dependencies: react, three, @react-three/fiber, @react-three/drei 'use client' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useFrame, extend } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' export interface LightningArcsProps { color?: string branches?: number strikeRate?: number thickness?: number flicker?: number followPointer?: boolean glowIntensity?: number from?: [number, number, number] to?: [number, number, number] } // Hard caps — every buffer below is preallocated once and never grows. // A strike writes into these pools and sets the draw range; no per-strike // allocation beyond bounded scratch reuse. const MAX_QUADS = 1024 // ribbon segments (4 verts / 6 indices each) const MAX_PATH_POINTS = 33 // 2^5 + 1 — deepest midpoint-displacement level const MAX_BRANCHES = 128 // pending branch queue cap const LightningArcsMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color('#a3e635'), uIntensity: 1, uGlow: 1.6, }, /* glsl */ ` uniform float uTime; attribute vec3 aDir; attribute float aSide; attribute float aWidth; attribute float aFade; attribute float aRand; varying float vSide; varying float vFade; varying float vRand; void main() { // Camera-facing ribbon expansion in view space: push each vertex // sideways along the perpendicular of (segment dir, view dir). vec4 mv = modelViewMatrix * vec4(position, 1.0); vec3 dirV = normalize((modelViewMatrix * vec4(aDir, 0.0)).xyz + vec3(1e-6)); vec3 viewFwd = normalize(-mv.xyz + vec3(1e-6)); vec3 c = cross(dirV, viewFwd); // Never divide by ~0: when the segment points at the camera the cross // length collapses and the quad simply degenerates instead of NaN-ing. vec3 perp = c / max(length(c), 0.001); mv.xyz += perp * aSide * aWidth; vSide = aSide; vFade = aFade; vRand = aRand; gl_Position = projectionMatrix * mv; } `, /* glsl */ ` uniform vec3 uColor; uniform float uTime; uniform float uIntensity; uniform float uGlow; varying float vSide; varying float vFade; varying float vRand; void main() { float d = abs(vSide); // The ribbon is expanded ~3x past the core width (see aWidth emit), so // the profile below splits it into: thin white-hot core, tight hot // sheath, and a wide soft halo — without the spread the glow is // subpixel and the bolt reads as a rope instead of an arc. float core = 1.0 - smoothstep(0.0, 0.1, d); float sheath = pow(max(1.0 - d, 0.0), 5.0); float glow = pow(max(1.0 - d, 0.0), 2.0); // Per-branch shimmer so each arc breathes on its own phase. float shimmer = 0.8 + 0.2 * sin(uTime * 21.0 + vRand * 40.0); vec3 hot = mix(uColor, vec3(1.0, 0.99, 0.96), 0.8); vec3 col = vec3(1.0, 0.99, 0.96) * core * 1.4 + hot * sheath * 0.9 + uColor * glow * uGlow; float alpha = (core + sheath * 0.8 + glow * 0.5) * vFade * uIntensity * shimmer; if (alpha < 0.003) discard; gl_FragColor = vec4(col * vFade * uIntensity * shimmer, alpha); } ` ) extend({ LightningArcsMaterial }) declare global { namespace JSX { interface IntrinsicElements { lightningArcsMaterial: any } } } export function LightningArcs({ color = '#a3e635', branches = 3, strikeRate = 1.5, thickness = 0.04, flicker = 0.7, followPointer = false, glowIntensity = 1.6, from = [-3.4, 0.5, 0], to = [3.4, 0.5, 0], }: LightningArcsProps) { const materialRef = useRef(null) const lightRef = useRef(null) const timeRef = useRef(0) const nextStrikeRef = useRef(0) const lastStrikeRef = useRef(-100) const nextStepRef = useRef(0) const stepRef = useRef(1) const pointerTargetRef = useRef(new THREE.Vector3()) const pointerCurRef = useRef(new THREE.Vector3()) const pointerInitRef = useRef(false) const lastColorRef = useRef('') // Preallocated buffer pools (fixed size, created once). const pools = useMemo(() => { const quadVerts = MAX_QUADS * 4 const position = new Float32Array(quadVerts * 3) const aDir = new Float32Array(quadVerts * 3) const aSide = new Float32Array(quadVerts) const aWidth = new Float32Array(quadVerts) const aFade = new Float32Array(quadVerts) const aRand = new Float32Array(quadVerts) const index = new Uint32Array(MAX_QUADS * 6) for (let q = 0; q < MAX_QUADS; q++) { const v = q * 4 const i = q * 6 index[i] = v index[i + 1] = v + 2 index[i + 2] = v + 1 index[i + 3] = v + 1 index[i + 4] = v + 2 index[i + 5] = v + 3 } const geometry = new THREE.BufferGeometry() const setAttr = (name: string, arr: Float32Array, size: number) => { const attr = new THREE.BufferAttribute(arr, size) attr.setUsage(THREE.DynamicDrawUsage) geometry.setAttribute(name, attr) } setAttr('position', position, 3) setAttr('aDir', aDir, 3) setAttr('aSide', aSide, 1) setAttr('aWidth', aWidth, 1) setAttr('aFade', aFade, 1) setAttr('aRand', aRand, 1) geometry.setIndex(new THREE.BufferAttribute(index, 1)) geometry.setDrawRange(0, 0) return { geometry, position, aDir, aSide, aWidth, aFade, aRand, // generation scratch: current bolt polyline + branch queue path: new Float32Array(MAX_PATH_POINTS * 3), queue: new Float32Array(MAX_BRANCHES * 8), // ax ay az bx by bz depth seed } }, []) // Dispose GPU resources on unmount. useEffect(() => { const { geometry } = pools return () => { geometry.dispose() materialRef.current?.dispose() } }, [pools]) // Rebuild the whole arc system into the pooled buffers. Bounded by // MAX_QUADS / MAX_BRANCHES — safe to call as often as needed. const regenerate = (ax: number, ay: number, az: number, bx: number, by: number, bz: number) => { const p = pools const maxDepth = Math.max(1, Math.min(4, Math.round(branches))) let quadCursor = 0 let qHead = 0 let qTail = 0 const pushBranch = ( sax: number, say: number, saz: number, sbx: number, sby: number, sbz: number, depth: number, seed: number ) => { if (qTail >= MAX_BRANCHES) return const o = qTail * 8 p.queue[o] = sax; p.queue[o + 1] = say; p.queue[o + 2] = saz p.queue[o + 3] = sbx; p.queue[o + 4] = sby; p.queue[o + 5] = sbz p.queue[o + 6] = depth; p.queue[o + 7] = seed qTail++ } // Midpoint displacement between path[i0] and path[i1], recursive. const subdivide = (i0: number, i1: number, disp: number) => { if (i1 - i0 <= 1 || disp < 1e-5) return const mid = (i0 + i1) >> 1 const a3 = i0 * 3 const b3 = i1 * 3 const m3 = mid * 3 const dx = p.path[b3] - p.path[a3] const dy = p.path[b3 + 1] - p.path[a3 + 1] const dz = p.path[b3 + 2] - p.path[a3 + 2] // Random direction, projected perpendicular to the segment. let rx = Math.random() - 0.5 let ry = Math.random() - 0.5 let rz = Math.random() - 0.5 const lenSq = dx * dx + dy * dy + dz * dz if (lenSq > 1e-10) { const dot = (rx * dx + ry * dy + rz * dz) / lenSq rx -= dx * dot; ry -= dy * dot; rz -= dz * dot } const rLen = Math.sqrt(rx * rx + ry * ry + rz * rz) const scale = rLen > 1e-6 ? disp / rLen : 0 p.path[m3] = (p.path[a3] + p.path[b3]) * 0.5 + rx * scale p.path[m3 + 1] = (p.path[a3 + 1] + p.path[b3 + 1]) * 0.5 + ry * scale p.path[m3 + 2] = (p.path[a3 + 2] + p.path[b3 + 2]) * 0.5 + rz * scale subdivide(i0, mid, disp * 0.55) subdivide(mid, i1, disp * 0.55) } pushBranch(ax, ay, az, bx, by, bz, 0, Math.random()) while (qHead < qTail && quadCursor < MAX_QUADS) { const o = qHead * 8 qHead++ const depth = p.queue[o + 6] const seed = p.queue[o + 7] const fax = p.queue[o]; const fay = p.queue[o + 1]; const faz = p.queue[o + 2] const fbx = p.queue[o + 3]; const fby = p.queue[o + 4]; const fbz = p.queue[o + 5] // Fewer displacement levels on deeper branches (thinner, simpler arcs). const sub = Math.max(2, 5 - depth) const count = (1 << sub) + 1 p.path[0] = fax; p.path[1] = fay; p.path[2] = faz const last3 = (count - 1) * 3 p.path[last3] = fbx; p.path[last3 + 1] = fby; p.path[last3 + 2] = fbz const boltLen = Math.hypot(fbx - fax, fby - fay, fbz - faz) subdivide(0, count - 1, boltLen * 0.22) const depthScale = Math.pow(0.62, depth) const pathFade = (0.7 + 0.3 * seed) * depthScale // Emit ribbon quads for this bolt path. const segs = Math.min(count - 1, MAX_QUADS - quadCursor) for (let i = 0; i < segs; i++) { const a3 = i * 3 const b3 = (i + 1) * 3 let dx = p.path[b3] - p.path[a3] let dy = p.path[b3 + 1] - p.path[a3 + 1] let dz = p.path[b3 + 2] - p.path[a3 + 2] const dLen = Math.sqrt(dx * dx + dy * dy + dz * dz) if (dLen > 1e-8) { dx /= dLen; dy /= dLen; dz /= dLen } else { dx = 1; dy = 0; dz = 0 } const v = (quadCursor + i) * 4 for (let corner = 0; corner < 4; corner++) { const src = corner < 2 ? a3 : b3 const t = (i + (corner >= 2 ? 1 : 0)) / (count - 1) // Main bolt: pinched at both ends. Branches: taper toward the tip. const taper = depth === 0 ? 0.35 + 0.65 * Math.pow(Math.sin(Math.PI * Math.min(Math.max(t, 0.02), 0.98)), 0.6) : (1 - 0.65 * t) * 0.9 const idx = v + corner p.position[idx * 3] = p.path[src] p.position[idx * 3 + 1] = p.path[src + 1] p.position[idx * 3 + 2] = p.path[src + 2] p.aDir[idx * 3] = dx; p.aDir[idx * 3 + 1] = dy; p.aDir[idx * 3 + 2] = dz p.aSide[idx] = corner % 2 === 0 ? -1 : 1 // Ribbon spans ~3x the core width so the fragment profile has // room for the outer glow halo (core lives in the inner ~10%). p.aWidth[idx] = thickness * depthScale * taper * 3.0 p.aFade[idx] = pathFade p.aRand[idx] = seed } } quadCursor += segs // Spawn child branches from interior points of this path. if (depth < maxDepth) { const kids = depth === 0 ? 3 : depth === 1 ? 2 : 1 for (let k = 0; k < kids; k++) { const at = 1 + Math.floor(Math.random() * (count - 2)) const s3 = at * 3 // Rotate the remaining direction by a random angle off-axis. const e3 = last3 let bdx = p.path[e3] - p.path[s3] let bdy = p.path[e3 + 1] - p.path[s3 + 1] let bdz = p.path[e3 + 2] - p.path[s3 + 2] const bLen = Math.sqrt(bdx * bdx + bdy * bdy + bdz * bdz) if (bLen < 1e-6) continue bdx /= bLen; bdy /= bLen; bdz /= bLen // Random perpendicular kick. let kx = Math.random() - 0.5 let ky = Math.random() - 0.5 let kz = Math.random() - 0.5 const kdot = kx * bdx + ky * bdy + kz * bdz kx -= bdx * kdot; ky -= bdy * kdot; kz -= bdz * kdot const kLen = Math.sqrt(kx * kx + ky * ky + kz * kz) if (kLen < 1e-6) continue const spread = 0.5 + Math.random() * 0.7 const branchLen = boltLen * (0.3 + Math.random() * 0.25) const inv = 1 / kLen const ex = (bdx + kx * inv * spread) * branchLen const ey = (bdy + ky * inv * spread) * branchLen const ez = (bdz + kz * inv * spread) * branchLen pushBranch( p.path[s3], p.path[s3 + 1], p.path[s3 + 2], p.path[s3] + ex, p.path[s3 + 1] + ey, p.path[s3 + 2] + ez, depth + 1, Math.random() ) } } } p.geometry.setDrawRange(0, quadCursor * 6) for (const name of ['position', 'aDir', 'aSide', 'aWidth', 'aFade', 'aRand']) { const attr = p.geometry.getAttribute(name) as THREE.BufferAttribute attr.needsUpdate = true } } useFrame((state, delta) => { const dt = Math.min(delta, 0.1) timeRef.current += dt const t = timeRef.current const mat = materialRef.current if (!mat) return // followPointer: the `to` endpoint chases the cursor with a slight lag, // unprojected onto the plane halfway between from and to. let tx = to[0]; let ty = to[1]; let tz = to[2] if (followPointer) { const { camera, pointer } = state const target = pointerTargetRef.current target.set(pointer.x, pointer.y, 0.5).unproject(camera) const dir = target.sub(camera.position) const dirLen = dir.length() if (dirLen > 1e-6) { dir.divideScalar(dirLen) const planeZ = (from[2] + to[2]) * 0.5 if (Math.abs(dir.z) > 1e-6) { const dist = (planeZ - camera.position.z) / dir.z if (dist > 0) { target.copy(camera.position).addScaledVector(dir, dist) if (!pointerInitRef.current) { pointerCurRef.current.copy(target) pointerInitRef.current = true } } } } // Damped chase — the arc lags behind the cursor. const lag = 1 - Math.exp(-8 * dt) pointerCurRef.current.lerp(target, lag) tx = pointerCurRef.current.x; ty = pointerCurRef.current.y; tz = pointerCurRef.current.z } // Strike scheduling. followPointer re-strikes fast so arcs chase the mouse. const interval = followPointer ? 1 / 24 : 1 / Math.max(strikeRate, 0.05) * (0.7 + Math.random() * 0.6) if (t >= nextStrikeRef.current) { regenerate(from[0], from[1], from[2], tx, ty, tz) lastStrikeRef.current = t nextStrikeRef.current = t + interval } // Intensity flicker: stepped random values + time noise. if (t >= nextStepRef.current) { stepRef.current = 0.55 + Math.random() * 0.45 nextStepRef.current = t + 0.04 + Math.random() * 0.09 } const sinceStrike = t - lastStrikeRef.current const flash = Math.exp(-sinceStrike * 10) const noise = 0.5 + 0.3 * Math.sin(t * 39.0 + Math.sin(t * 17.0)) + 0.2 * Math.sin(t * 83.0) const flickAmt = 1 - flicker * 0.45 + flicker * (0.45 * stepRef.current + 0.3 * noise) mat.uTime = t mat.uIntensity = Math.max(flickAmt * (1 + flash * 1.5), 0.05) mat.uGlow = glowIntensity // Parse the color string only when it actually changes. const colorDirty = lastColorRef.current !== color if (colorDirty) { lastColorRef.current = color mat.uColor.set(color) } // Flash light at the arc midpoint: spikes on strike, fast afterglow decay. const light = lightRef.current if (light) { light.position.set((from[0] + tx) * 0.5, (from[1] + ty) * 0.5, (from[2] + tz) * 0.5) light.intensity = glowIntensity * 8 * Math.exp(-sinceStrike * 7) if (colorDirty) light.color.set(color) } }) return ( {/* strike flash light — lights surroundings with fast afterglow decay */} ) } ``` ### character-controller Character Controller: Third-person character controller: WASD movement, jumping, and a collision-aware camera. Props: - color: color = "#a3e635" - speed: number (1–20) = 6 - jumpForce: number (1–20) = 8 - cameraDistance: number (2–12) = 6 Install: `npx facet3d add character-controller` Source: ```tsx // character-controller — third-person character controller with game feel: // camera-relative WASD/arrow movement, Shift to sprint (1.6x), jumping with // coyote time, procedural squash & stretch, lean into movement and turns, // landing/running dust puffs, FOV kick, subtle camera bob, and a // collision-aware drag-to-orbit camera. Built on @react-three/rapier. // // Renders its own rapier Physics world — do NOT nest it inside another . // Pass level geometry as children; it is simulated in the same world: // // // // // // ... // // // // // Controls: WASD / arrow keys to move, hold Shift to sprint, Space to jump, // pointer-drag on the canvas to orbit the camera. The controller owns the // camera — do not add OrbitControls. The character drops in from ~3m on load. 'use client' import { useEffect, useMemo, useRef, type ReactNode } from 'react' import { MathUtils, Vector3, type BufferAttribute, type BufferGeometry, type Group, type PerspectiveCamera, type PointsMaterial, } from 'three' import { useFrame, useThree } from '@react-three/fiber' import { Physics, RigidBody, CapsuleCollider, useRapier, type RapierRigidBody, } from '@react-three/rapier' export interface CharacterControllerProps { color?: string speed?: number jumpForce?: number cameraDistance?: number children?: ReactNode } const CAPSULE_HALF_HEIGHT = 0.5 const CAPSULE_RADIUS = 0.35 const FEET_OFFSET = CAPSULE_HALF_HEIGHT + CAPSULE_RADIUS const HEAD_OFFSET = 0.6 const GROUND_RAY_LENGTH = 1.15 const COYOTE_TIME = 0.12 const MOVE_SMOOTHING = 10 const TURN_SMOOTHING = 14 const CAMERA_SMOOTHING = 12 const CAMERA_PULL_IN = 0.3 const MIN_CAMERA_DIST = 0.4 const PITCH_LIMIT = 1.2 const GRAVITY = -22 const SPRINT_MULTIPLIER = 1.6 // Squash & stretch spring: slightly under-damped so it overshoots once and // settles in ~0.35s. const SQUASH_K = 170 const SQUASH_C = 15 const SQUASH_MIN = 0.55 const SQUASH_MAX = 1.5 const JUMP_STRETCH_IMPULSE = 2.4 // Camera feel. const BASE_FOV = 55 const SPEED_FOV = 7 const SPRINT_FOV = 2 const MAX_LANDING_FOV_KICK = 3.5 const FOV_MIN = 50 const FOV_MAX = 68 const FOV_SMOOTHING = 6 const BOB_MAX_AMP = 0.045 // Dust puffs: a small pool of CPU-animated point bursts. const DUST_POOL = 8 const DUST_COUNT = 20 const DUST_LIFE = 0.4 const RUN_DUST_INTERVAL = 0.25 const MOVE_KEYS = new Set([ 'KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ]) // Damps an angle toward a target taking the shortest way around the circle. function dampAngle(current: number, target: number, lambda: number, delta: number) { let diff = (target - current) % (Math.PI * 2) if (diff > Math.PI) diff -= Math.PI * 2 if (diff < -Math.PI) diff += Math.PI * 2 return current + diff * (1 - Math.exp(-lambda * delta)) } interface BodyProps { color: string speed: number jumpForce: number cameraDistance: number } function ControllerBody({ color, speed, jumpForce, cameraDistance }: BodyProps) { const body = useRef(null) const visual = useRef(null) const tilt = useRef(null) const squashGroup = useRef(null) const { camera, gl } = useThree() const { world, rapier } = useRapier() const keys = useRef(new Set()) const sprinting = useRef(false) const jumpQueued = useRef(false) const coyote = useRef(0) const grounded = useRef(false) const maxFallSpeed = useRef(0) const squashSpring = useRef({ v: 1, vel: 0 }) const yawRate = useRef(0) const bobPhase = useRef(0) const bobAmp = useRef(0) const fovKick = useRef(0) const runDustTimer = useRef(0) const yaw = useRef(0) const pitch = useRef(0.35) const scratch = useRef({ move: new Vector3(), head: new Vector3(), desired: new Vector3(), boomDir: new Vector3(), camRay: null as InstanceType | null, groundRay: null as InstanceType | null, }) // Dust burst pool: world-space point clouds, recycled round-robin. const dustGeoms = useRef<(BufferGeometry | null)[]>([]) const dustMats = useRef<(PointsMaterial | null)[]>([]) const nextBurst = useRef(0) const bursts = useMemo( () => Array.from({ length: DUST_POOL }, () => ({ active: false, life: 0, floor: 0, size: 0.09, peakOpacity: 0.5, pos: new Float32Array(DUST_COUNT * 3), vel: new Float32Array(DUST_COUNT * 3), })), [] ) const spawnDust = (x: number, y: number, z: number, intensity: number) => { const b = bursts[nextBurst.current] nextBurst.current = (nextBurst.current + 1) % DUST_POOL b.active = true b.life = 0 b.floor = y b.peakOpacity = 0.25 + intensity * 0.32 b.size = 0.06 + intensity * 0.06 for (let i = 0; i < DUST_COUNT; i++) { const a = Math.random() * Math.PI * 2 const r = (0.6 + Math.random() * 1.6) * intensity b.pos[i * 3] = x + (Math.random() - 0.5) * 0.2 b.pos[i * 3 + 1] = y + 0.02 + Math.random() * 0.06 b.pos[i * 3 + 2] = z + (Math.random() - 0.5) * 0.2 b.vel[i * 3] = Math.cos(a) * r b.vel[i * 3 + 1] = (0.6 + Math.random() * 1.4) * intensity b.vel[i * 3 + 2] = Math.sin(a) * r } } // Keyboard input (window listeners, client-only). useEffect(() => { const pressed = keys.current const onKeyDown = (e: KeyboardEvent) => { if (e.code === 'Space') { if (!e.repeat) jumpQueued.current = true e.preventDefault() return } if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') { sprinting.current = true return } if (MOVE_KEYS.has(e.code)) { pressed.add(e.code) e.preventDefault() } } const onKeyUp = (e: KeyboardEvent) => { if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') sprinting.current = false pressed.delete(e.code) } const onBlur = () => { pressed.clear() sprinting.current = false } window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) window.addEventListener('blur', onBlur) return () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) window.removeEventListener('blur', onBlur) } }, []) // Drag-to-orbit camera (pointer events on the canvas, no pointer lock). useEffect(() => { const el = gl.domElement el.style.touchAction = 'none' let dragging = false const onPointerDown = (e: PointerEvent) => { dragging = true el.setPointerCapture(e.pointerId) } const onPointerMove = (e: PointerEvent) => { if (!dragging) return yaw.current -= e.movementX * 0.0045 pitch.current = MathUtils.clamp(pitch.current + e.movementY * 0.0045, -PITCH_LIMIT, PITCH_LIMIT) } const onPointerUp = (e: PointerEvent) => { dragging = false if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) } el.addEventListener('pointerdown', onPointerDown) el.addEventListener('pointermove', onPointerMove) el.addEventListener('pointerup', onPointerUp) el.addEventListener('pointercancel', onPointerUp) return () => { el.removeEventListener('pointerdown', onPointerDown) el.removeEventListener('pointermove', onPointerMove) el.removeEventListener('pointerup', onPointerUp) el.removeEventListener('pointercancel', onPointerUp) } }, [gl]) useFrame((_, delta) => { const rb = body.current if (!rb) return const dt = Math.min(delta, 1 / 30) const s = scratch.current if (!s.groundRay) s.groundRay = new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }) if (!s.camRay) s.camRay = new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 1 }) const pos = rb.translation() const vel = rb.linvel() const horizSpeed = Math.hypot(vel.x, vel.z) // Grounded check: short ray straight down from the body center, excluding // the player. Landing is the airborne -> grounded transition; impact // strength comes from the peak fall speed seen while airborne. s.groundRay.origin = pos const groundHit = world.castRay(s.groundRay, GROUND_RAY_LENGTH, true, undefined, undefined, undefined, rb) const isGrounded = groundHit !== null if (isGrounded) { if (!grounded.current) { const impact = -maxFallSpeed.current if (impact > 2) { squashSpring.current.vel -= MathUtils.clamp(impact * 0.3, 1.2, 4) fovKick.current = MathUtils.clamp(impact * 0.25, 0, MAX_LANDING_FOV_KICK) spawnDust(pos.x, pos.y - FEET_OFFSET, pos.z, MathUtils.clamp(impact / 11, 0.35, 1.4)) } maxFallSpeed.current = 0 } coyote.current = COYOTE_TIME } else { coyote.current = Math.max(0, coyote.current - dt) maxFallSpeed.current = Math.min(maxFallSpeed.current, vel.y) } grounded.current = isGrounded // Camera-relative movement direction on the ground plane. const pressed = keys.current const ix = (pressed.has('KeyD') || pressed.has('ArrowRight') ? 1 : 0) - (pressed.has('KeyA') || pressed.has('ArrowLeft') ? 1 : 0) const iz = (pressed.has('KeyW') || pressed.has('ArrowUp') ? 1 : 0) - (pressed.has('KeyS') || pressed.has('ArrowDown') ? 1 : 0) const fwdX = -Math.sin(yaw.current) const fwdZ = -Math.cos(yaw.current) // right = (-fwdZ, 0, fwdX) const move = s.move.set(fwdX * iz + -fwdZ * ix, 0, fwdZ * iz + fwdX * ix) const hasInput = move.lengthSq() > 0 if (hasInput) move.normalize() // Horizontal velocity eases toward the target; vertical velocity is preserved. const targetSpeed = speed * (sprinting.current ? SPRINT_MULTIPLIER : 1) let vy = vel.y if (jumpQueued.current) { jumpQueued.current = false if (coyote.current > 0) { vy = jumpForce coyote.current = 0 grounded.current = false // Stretch on takeoff. squashSpring.current.vel += JUMP_STRETCH_IMPULSE } } rb.setLinvel( { x: MathUtils.damp(vel.x, move.x * targetSpeed, MOVE_SMOOTHING, dt), y: vy, z: MathUtils.damp(vel.z, move.z * targetSpeed, MOVE_SMOOTHING, dt), }, true ) // Face the direction of travel; track turn rate for the lean-into-turn roll. if (visual.current) { if (hasInput) { const prevYaw = visual.current.rotation.y const newYaw = dampAngle(prevYaw, Math.atan2(move.x, move.z), TURN_SMOOTHING, dt) visual.current.rotation.y = newYaw yawRate.current = MathUtils.damp(yawRate.current, (newYaw - prevYaw) / dt, 10, dt) } else { yawRate.current = MathUtils.damp(yawRate.current, 0, 10, dt) } } // Squash & stretch spring (semi-implicit Euler), volume-preserving scale. const sp = squashSpring.current sp.vel += (-SQUASH_K * (sp.v - 1) - SQUASH_C * sp.vel) * dt sp.v = MathUtils.clamp(sp.v + sp.vel * dt, SQUASH_MIN, SQUASH_MAX) if (squashGroup.current) { const sy = sp.v const sxz = 1 / Math.sqrt(sy) squashGroup.current.scale.set(sxz, sy, sxz) // Keep the feet planted while squashing/stretching. squashGroup.current.position.y = -FEET_OFFSET * (1 - sy) } // Lean: forward with speed, sideways into turns. const speedNorm = MathUtils.clamp(horizSpeed / Math.max(targetSpeed, 0.001), 0, 1) if (tilt.current) { const leanFwd = speedNorm * 0.14 * (isGrounded ? 1 : 0.6) const leanSide = MathUtils.clamp(-yawRate.current * 0.05, -0.16, 0.16) tilt.current.rotation.x = MathUtils.damp(tilt.current.rotation.x, leanFwd, 8, dt) tilt.current.rotation.z = MathUtils.damp(tilt.current.rotation.z, leanSide, 8, dt) } // Faint running dust while moving fast on the ground. runDustTimer.current -= dt if (isGrounded && horizSpeed > Math.max(speed * 0.55, 3) && runDustTimer.current <= 0) { runDustTimer.current = RUN_DUST_INTERVAL spawnDust(pos.x, pos.y - FEET_OFFSET, pos.z, 0.3 + speedNorm * 0.25) } // Dust burst simulation: expand with drag + light gravity, fade out. for (let bi = 0; bi < DUST_POOL; bi++) { const b = bursts[bi] if (!b.active) continue b.life += dt const t = b.life / DUST_LIFE const m = dustMats.current[bi] const g = dustGeoms.current[bi] if (t >= 1) { b.active = false if (m) m.opacity = 0 continue } const drag = Math.max(0, 1 - 3 * dt) for (let i = 0; i < DUST_COUNT; i++) { b.vel[i * 3] *= drag b.vel[i * 3 + 1] = (b.vel[i * 3 + 1] - 5.5 * dt) * drag b.vel[i * 3 + 2] *= drag b.pos[i * 3] += b.vel[i * 3] * dt b.pos[i * 3 + 1] = Math.max(b.floor + 0.01, b.pos[i * 3 + 1] + b.vel[i * 3 + 1] * dt) b.pos[i * 3 + 2] += b.vel[i * 3 + 2] * dt } if (g) (g.attributes.position as BufferAttribute).needsUpdate = true if (m) { m.opacity = b.peakOpacity * (1 - t) m.size = b.size * (0.7 + t * 1.6) } } // Collision-aware boom camera. const head = s.head.set(pos.x, pos.y + HEAD_OFFSET, pos.z) const cp = Math.cos(pitch.current) const boom = s.boomDir.set(Math.sin(yaw.current) * cp, Math.sin(pitch.current), Math.cos(yaw.current) * cp) let dist = cameraDistance s.camRay.origin = head s.camRay.dir = boom const camHit = world.castRay(s.camRay, cameraDistance, true, undefined, undefined, undefined, rb) if (camHit) dist = Math.max(camHit.timeOfImpact - CAMERA_PULL_IN, MIN_CAMERA_DIST) const desired = s.desired.copy(boom).multiplyScalar(dist).add(head) camera.position.set( MathUtils.damp(camera.position.x, desired.x, CAMERA_SMOOTHING, dt), MathUtils.damp(camera.position.y, desired.y, CAMERA_SMOOTHING, dt), MathUtils.damp(camera.position.z, desired.z, CAMERA_SMOOTHING, dt) ) // Camera bob: tiny vertical oscillation while running, fades out in air. bobPhase.current += horizSpeed * dt * 1.5 const bobTarget = isGrounded ? MathUtils.clamp(horizSpeed / 12, 0, 1) * BOB_MAX_AMP : 0 bobAmp.current = MathUtils.damp(bobAmp.current, bobTarget, 8, dt) camera.position.y += Math.sin(bobPhase.current) * bobAmp.current camera.lookAt(head) // FOV kick: widens with speed, sprint, and landing impacts; eases back. fovKick.current = MathUtils.damp(fovKick.current, 0, 5, dt) if ('fov' in camera) { const pc = camera as PerspectiveCamera const speedFov = MathUtils.clamp(horizSpeed / 12, 0, 1) * SPEED_FOV const sprintFov = sprinting.current && hasInput ? SPRINT_FOV : 0 const fovTarget = MathUtils.clamp(BASE_FOV + speedFov + sprintFov + fovKick.current, FOV_MIN, FOV_MAX) pc.fov = MathUtils.damp(pc.fov, fovTarget, FOV_SMOOTHING, dt) pc.updateProjectionMatrix() } }) return ( <> {/* Visor so facing is readable. */} {bursts.map((b, i) => ( { dustGeoms.current[i] = g }} > { dustMats.current[i] = m }} size={0.09} sizeAttenuation transparent opacity={0} depthWrite={false} color="#cfcabd" /> ))} ) } export function CharacterController({ color = '#a3e635', speed = 6, jumpForce = 8, cameraDistance = 6, children, }: CharacterControllerProps) { return ( {children} ) } ``` ### procedural-terrain Procedural Terrain: Seeded noise terrain with elevation biomes, from sand to snow. Props: - seed: number (1–100) = 42 - size: number (10–100) = 40 - maxHeight: number (1–20) = 6 - roughness: number (0.1–2) = 0.8 - wireframe: boolean = false Install: `npx facet3d add procedural-terrain` Source: ```tsx // ProceduralTerrain — seeded island terrain with slope-based biomes, ridged // mountain peaks, instanced vegetation (trees + rocks) and a surrounding sea. // Self-contained: includes its own mulberry32 PRNG + 2D value noise + fbm and // ridged multifractal. Zero extra deps. // Must be rendered inside a react-three-fiber . Add your own lights // (a castShadow directional light is recommended — the terrain and vegetation // already cast/receive shadows): // // // // // // 'use client' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' export interface ProceduralTerrainProps { seed?: number size?: number maxHeight?: number roughness?: number wireframe?: boolean } // --------------------------------------------------------------------------- // Deterministic PRNG (mulberry32) + seeded 2D value noise + fractal Brownian // motion + ridged multifractal. Kept tiny and self-contained so the component // stays copy-paste. Same seed always produces the same island. // --------------------------------------------------------------------------- function mulberry32(seed: number) { let a = seed >>> 0 return () => { a |= 0 a = (a + 0x6d2b79f5) | 0 let t = Math.imul(a ^ (a >>> 15), 1 | a) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } /** 512-entry doubled permutation table shuffled by the seed. */ function buildPermutation(seed: number): Uint8Array { const rng = mulberry32(Math.floor(seed)) const p = new Uint8Array(256) for (let i = 0; i < 256; i++) p[i] = i for (let i = 255; i > 0; i--) { const j = Math.floor(rng() * (i + 1)) const tmp = p[i] p[i] = p[j] p[j] = tmp } const perm = new Uint8Array(512) for (let i = 0; i < 512; i++) perm[i] = p[i & 255] return perm } function fade(t: number) { return t * t * (3 - 2 * t) } function lerp(a: number, b: number, t: number) { return a + (b - a) * t } /** clamped smoothstep with a < b (never reversed). */ function sstep(a: number, b: number, x: number) { const t = Math.max(0, Math.min(1, (x - a) / (b - a))) return t * t * (3 - 2 * t) } /** Smooth 2D value noise in [-1, 1]. */ function valueNoise2D(perm: Uint8Array, x: number, y: number): number { const xi = Math.floor(x) const yi = Math.floor(y) const xf = x - xi const yf = y - yi const X = xi & 255 const Y = yi & 255 // Hash each lattice corner to a pseudo-random value in [-1, 1]. const v00 = (perm[perm[X] + Y] / 255) * 2 - 1 const v10 = (perm[perm[X + 1] + Y] / 255) * 2 - 1 const v01 = (perm[perm[X] + Y + 1] / 255) * 2 - 1 const v11 = (perm[perm[X + 1] + Y + 1] / 255) * 2 - 1 const u = fade(xf) const v = fade(yf) return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v) } /** 5-octave fbm, normalized back to roughly [-1, 1]. */ function fbm2D(perm: Uint8Array, x: number, y: number, octaves = 5): number { let value = 0 let amplitude = 0.5 let frequency = 1 let max = 0 for (let i = 0; i < octaves; i++) { value += valueNoise2D(perm, x * frequency, y * frequency) * amplitude max += amplitude amplitude *= 0.5 frequency *= 2 } return value / max } /** * Ridged multifractal in [0, 1]: folds the noise (1 - |n|) and squares it so * crests sharpen into mountain ridges while valleys stay broad. Successive * octaves are weighted by the previous ridge, which is what gives the classic * "range" look instead of uniform dunes. */ function ridgedFbm2D(perm: Uint8Array, x: number, y: number, octaves = 5): number { let value = 0 let amplitude = 0.5 let frequency = 1 let max = 0 let weight = 1 for (let i = 0; i < octaves; i++) { let n = 1 - Math.abs(valueNoise2D(perm, x * frequency, y * frequency)) n = n * n value += n * amplitude * weight max += amplitude weight = Math.max(0, Math.min(1, n * 2)) amplitude *= 0.5 frequency *= 2 } return value / max } type HeightSampler = (x: number, z: number) => number /** * Builds the island height function. fbm base elevation blended toward a * ridged multifractal at higher elevations (rolling lowlands, jagged peaks), * multiplied by a radial falloff so every island meets the sea at its rim. */ function makeHeightSampler( seed: number, size: number, maxHeight: number, roughness: number ): HeightSampler { const perm = buildPermutation(seed) // Base frequency scales with roughness and inversely with world size so any // `size` still yields a full island, not a zoomed-in crop. const baseFrequency = (roughness * 6) / size const half = size / 2 return (x, z) => { const base = fbm2D(perm, x * baseFrequency, z * baseFrequency, 5) * 0.5 + 0.5 // Offset sampled far from the base field so ridges don't align with dunes. const ridge = ridgedFbm2D( perm, x * baseFrequency * 0.85 + 37.3, z * baseFrequency * 0.85 + 11.9, 5 ) // Blend in the ridged field only above the lowlands. const ridgeMix = sstep(0.35, 0.7, base) const n = base + (ridge - base) * ridgeMix // Radial falloff: full height inside 55% of the radius, tapering to sea // level at the edges for the island feel. const d = Math.min(1, Math.sqrt(x * x + z * z) / half) const falloff = sstep(0, 1, (1 - d) / 0.45) return n * falloff * maxHeight } } // --------------------------------------------------------------------------- // Slope-based biomes // --------------------------------------------------------------------------- const SAND = new THREE.Color('#d9c99a') const GRASS_A = new THREE.Color('#4d7c0f') // deep meadow green const GRASS_B = new THREE.Color('#84cc16') // bright acid-leaning green const ROCK = new THREE.Color('#78716c') const SNOW = new THREE.Color('#fafaf9') const WATER_Y = 0.15 /** * Vertex color from world height + slope (1 - normal.y) + a low-frequency * grass-patch noise. Rock takes over steep slopes regardless of height, sand * only hugs the waterline, and snow only settles on high AND flat ground. */ function biomeColor( y: number, slope: number, grassMix: number, maxHeight: number, target: THREE.Color ) { const sandTop = WATER_Y + maxHeight * 0.05 const grassTop = maxHeight * 0.5 // Elevation bands first. if (y < sandTop) { target.copy(SAND) } else if (y < sandTop + maxHeight * 0.05) { target.lerpColors(SAND, GRASS_A, (y - sandTop) / (maxHeight * 0.05)) } else if (y < grassTop) { // Subtle hue-variation patches across the grassland. target.lerpColors(GRASS_A, GRASS_B, grassMix) } else if (y < grassTop + maxHeight * 0.08) { const t = (y - grassTop) / (maxHeight * 0.08) target.lerpColors(GRASS_A, ROCK, t) } else { target.copy(ROCK) } // Slope override: cliffs read as rock even inside the grass/snow bands. if (y > WATER_Y) { const rockAmt = sstep(0.1, 0.24, slope) * 0.95 target.lerp(ROCK, rockAmt) } // Snow only on high AND flat areas — steep peaks stay rocky. const hNorm = y / maxHeight const highAmt = sstep(0.72, 0.8, hNorm) const flatAmt = 1 - sstep(0.06, 0.14, slope) target.lerp(SNOW, highAmt * flatAmt) } // --------------------------------------------------------------------------- // Vegetation scattering (seeded rejection sampling on the height function) // --------------------------------------------------------------------------- interface ScatterItem { x: number y: number z: number rot: number scale: number color: THREE.Color } /** World-space gradient magnitude of the height field via finite differences. */ function heightGradient(sample: HeightSampler, x: number, z: number, eps: number) { const dx = (sample(x + eps, z) - sample(x - eps, z)) / (2 * eps) const dz = (sample(x, z + eps) - sample(x, z - eps)) / (2 * eps) return Math.sqrt(dx * dx + dz * dz) } function scatterVegetation( seed: number, size: number, maxHeight: number, sample: HeightSampler ): { trees: ScatterItem[]; rocks: ScatterItem[] } { const rng = mulberry32(Math.floor(seed) * 2654435761 + 97) const half = size / 2 const eps = size / 256 const sandTop = WATER_Y + maxHeight * 0.05 // Tree scale follows the island so a tiny/flat map never gets giant trees. const worldScale = Math.max(0.2, Math.min(1, maxHeight / 6, size / 40)) // Slope tests must be scale-invariant: normalize the world-space gradient // against this island's relief so a small-but-tall map (naturally steep in // world units) still gets vegetation on its relatively-gentle spots. const slopeScale = (6 / 40) / (maxHeight / size) const treeTarget = Math.round(Math.max(80, Math.min(150, (size * size) / 18))) const trees: ScatterItem[] = [] const color = new THREE.Color() for (let attempt = 0; attempt < treeTarget * 60 && trees.length < treeTarget; attempt++) { const x = (rng() * 2 - 1) * half const z = (rng() * 2 - 1) * half const y = sample(x, z) // Grass band only: above the beach, below the rockline, on gentle slopes. if (y < sandTop + 0.05 || y > maxHeight * 0.5) continue if (heightGradient(sample, x, z, eps) * slopeScale > 0.45) continue const hue = 0.23 + rng() * 0.06 // per-instance hue jitter, deep green → lime color.setHSL(hue, 0.55 + rng() * 0.15, 0.26 + rng() * 0.1) trees.push({ x, y: y - 0.04, // seat the trunk slightly into the ground z, rot: rng() * Math.PI * 2, scale: (0.55 + rng() * 0.65) * worldScale, color: color.clone(), }) } const rockTarget = Math.round(Math.max(20, Math.min(60, size))) const rocks: ScatterItem[] = [] for (let attempt = 0; attempt < rockTarget * 60 && rocks.length < rockTarget; attempt++) { const x = (rng() * 2 - 1) * half const z = (rng() * 2 - 1) * half const y = sample(x, z) if (y < WATER_Y + 0.1) continue // Rock band: high ground or steep cliff faces. if (y < maxHeight * 0.45 && heightGradient(sample, x, z, eps) * slopeScale < 0.55) continue const shade = 0.4 + rng() * 0.3 // gray jitter color.setHSL(0.08 + rng() * 0.03, 0.04 + rng() * 0.05, shade) rocks.push({ x, y: y - 0.08, // partially buried z, rot: rng() * Math.PI * 2, scale: (0.5 + rng() * 1.1) * worldScale, color: color.clone(), }) } return { trees, rocks } } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function ProceduralTerrain({ seed = 42, size = 40, maxHeight = 6, roughness = 0.8, wireframe = false, }: ProceduralTerrainProps) { const trunkRef = useRef(null) const canopyRef = useRef(null) const rockRef = useRef(null) const geometry = useMemo(() => { const segments = Math.min(256, Math.max(1, Math.round(size * 5))) const geo = new THREE.PlaneGeometry(size, size, segments, segments) geo.rotateX(-Math.PI / 2) const sample = makeHeightSampler(seed, size, maxHeight, roughness) const perm = buildPermutation(seed) const jitterRng = mulberry32(Math.floor(seed) * 7919 + 13) const positions = geo.attributes.position as THREE.BufferAttribute const vertexCount = positions.count for (let i = 0; i < vertexCount; i++) { positions.setY(i, sample(positions.getX(i), positions.getZ(i))) } positions.needsUpdate = true geo.computeVertexNormals() // Color pass needs the normals for slope-based biomes. const normals = geo.attributes.normal as THREE.BufferAttribute const colors = new Float32Array(vertexCount * 3) const color = new THREE.Color() const grassFreq = (roughness * 6) / size / 3.5 // low-frequency patchiness for (let i = 0; i < vertexCount; i++) { const x = positions.getX(i) const z = positions.getZ(i) const y = positions.getY(i) const slope = 1 - normals.getY(i) const grassMix = fbm2D(perm, x * grassFreq + 213.7, z * grassFreq + 91.3, 3) * 0.5 + 0.5 biomeColor(y, slope, grassMix, maxHeight, color) const jitter = (jitterRng() - 0.5) * 0.05 colors[i * 3] = Math.max(0, Math.min(1, color.r + jitter)) colors[i * 3 + 1] = Math.max(0, Math.min(1, color.g + jitter)) colors[i * 3 + 2] = Math.max(0, Math.min(1, color.b + jitter)) } geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)) return geo }, [seed, size, maxHeight, roughness]) // Vegetation placement re-uses the same height function as the mesh, so // trees and rocks always sit exactly on the terrain surface. const { trees, rocks } = useMemo(() => { const sample = makeHeightSampler(seed, size, maxHeight, roughness) return scatterVegetation(seed, size, maxHeight, sample) }, [seed, size, maxHeight, roughness]) // Pre-translated geometries so instance transforms put bases on the ground. const trunkGeometry = useMemo(() => { const g = new THREE.CylinderGeometry(0.08, 0.13, 0.7, 5) g.translate(0, 0.35, 0) return g }, []) const canopyGeometry = useMemo(() => { const g = new THREE.ConeGeometry(0.5, 1.5, 6) g.translate(0, 1.25, 0) return g }, []) const rockGeometry = useMemo(() => new THREE.DodecahedronGeometry(0.4, 0), []) const waterGeometry = useMemo(() => new THREE.CircleGeometry(size * 0.75, 48), [size]) // Apply instance transforms + per-instance colors before first paint. useLayoutEffect(() => { const dummy = new THREE.Object3D() const trunk = trunkRef.current const canopy = canopyRef.current if (trunk && canopy) { trees.forEach((t, i) => { dummy.position.set(t.x, t.y, t.z) dummy.rotation.set(0, t.rot, 0) dummy.scale.setScalar(t.scale) dummy.updateMatrix() trunk.setMatrixAt(i, dummy.matrix) canopy.setMatrixAt(i, dummy.matrix) canopy.setColorAt(i, t.color) }) trunk.instanceMatrix.needsUpdate = true canopy.instanceMatrix.needsUpdate = true if (canopy.instanceColor) canopy.instanceColor.needsUpdate = true } const rockMesh = rockRef.current if (rockMesh) { rocks.forEach((r, i) => { dummy.position.set(r.x, r.y, r.z) dummy.rotation.set(r.rot * 0.7, r.rot, r.rot * 1.3) // tumble the boulders dummy.scale.set(r.scale, r.scale * 0.75, r.scale) dummy.updateMatrix() rockMesh.setMatrixAt(i, dummy.matrix) rockMesh.setColorAt(i, r.color) }) rockMesh.instanceMatrix.needsUpdate = true if (rockMesh.instanceColor) rockMesh.instanceColor.needsUpdate = true } }, [trees, rocks]) // Dispose geometries whenever the terrain rebuilds or unmounts. useEffect(() => { return () => { geometry.dispose() waterGeometry.dispose() } }, [geometry, waterGeometry]) useEffect(() => { return () => { trunkGeometry.dispose() canopyGeometry.dispose() rockGeometry.dispose() } }, [trunkGeometry, canopyGeometry, rockGeometry]) return ( {/* Sea: transparent deep teal with a sky-glint metalness kick. */} {/* Vegetation is hidden (not unmounted) in wireframe mode so instance matrices survive the toggle. */} {trees.length > 0 && ( )} {rocks.length > 0 && ( )} ) } ``` ### ocean Ocean: Gerstner-wave water with fresnel, crest foam, and sun glint. Props: - color: color = "#075985" - waveHeight: number (0–3) = 0.8 - choppiness: number (0–3) = 1 - speed: number (0–3) = 1 Install: `npx facet3d add ocean` Source: ```tsx // Ocean — AAA Gerstner-wave water: 6 wave trains, procedural sky reflection, // scrolling detail normals, subsurface backscatter, and two-band foam. // Must be rendered inside a react-three-fiber . // // Usage: // // // // // The water shading is fully self-contained in the shader (procedural sky, // sun glint, subsurface, foam) — no scene lights required. 'use client' import { useRef } from 'react' import * as THREE from 'three' import { useFrame, extend } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' export interface OceanProps { color?: string waveHeight?: number choppiness?: number speed?: number } const OceanMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color('#075985'), uWaveHeight: 0.8, uChoppiness: 1, }, /* glsl */ ` uniform float uTime; uniform float uWaveHeight; uniform float uChoppiness; varying vec3 vWorldPos; varying vec3 vNormal; varying float vCrest; // Accumulate one Gerstner wave train (local space: xy horizontal, z up). // binormal = dP/dx, tangent = dP/dy — summed partial derivatives give // the exact analytic normal, no finite differences needed. void gerstner( vec2 dir, float wavelength, float ampScale, float steepScale, vec2 p, inout vec2 horiz, inout float height, inout vec3 binormal, inout vec3 tangent ) { float k = 6.28318530718 / wavelength; float c = sqrt(9.8 / k); // deep-water dispersion: long waves travel faster float a = uWaveHeight * ampScale; // Per-wave steepness Q, clamped so the surface never self-intersects (Q*k*a <= 1) float q = min(uChoppiness * steepScale / (k * a * 4.0 + 1e-4), 1.0 / (k * a + 1e-4)); float f = k * (dot(dir, p) - c * uTime); float sf = sin(f); float cf = cos(f); horiz += q * a * dir * cf; height += a * sf; float wa = k * a; binormal.x -= q * wa * dir.x * dir.x * sf; binormal.y -= q * wa * dir.x * dir.y * sf; binormal.z += wa * dir.x * cf; tangent.x -= q * wa * dir.x * dir.y * sf; tangent.y -= q * wa * dir.y * dir.y * sf; tangent.z += wa * dir.y * cf; } void main() { vec2 p = position.xy; vec2 horiz = vec2(0.0); float height = 0.0; vec3 binormal = vec3(1.0, 0.0, 0.0); vec3 tangent = vec3(0.0, 1.0, 0.0); // Primary swell trains gerstner(normalize(vec2( 1.00, 0.20)), 30.0, 1.00, 1.00, p, horiz, height, binormal, tangent); gerstner(normalize(vec2( 0.70, 0.70)), 18.0, 0.60, 0.90, p, horiz, height, binormal, tangent); gerstner(normalize(vec2(-0.40, 0.90)), 12.0, 0.40, 0.80, p, horiz, height, binormal, tangent); gerstner(normalize(vec2( 0.30, -0.95)), 8.0, 0.25, 0.70, p, horiz, height, binormal, tangent); // Short chop trains — tiny amplitudes, just surface detail gerstner(normalize(vec2( 0.90, -0.45)), 5.0, 0.12, 0.60, p, horiz, height, binormal, tangent); gerstner(normalize(vec2(-0.80, -0.60)), 3.0, 0.07, 0.50, p, horiz, height, binormal, tangent); vec3 pos = vec3(position.xy + horiz, height); vec3 n = normalize(cross(binormal, tangent)); vCrest = height / (uWaveHeight * 2.44 + 1e-4); // normalized -1..1 (sum of ampScales = 2.44) vWorldPos = (modelMatrix * vec4(pos, 1.0)).xyz; vNormal = normalize(mat3(modelMatrix) * n); // uniform rotation only — safe gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } `, /* glsl */ ` uniform float uTime; uniform vec3 uColor; uniform float uWaveHeight; varying vec3 vWorldPos; varying vec3 vNormal; varying float vCrest; // Low dusk sun, ahead of the default camera — drives glint, sky, subsurface. const vec3 SUN_DIR = vec3(0.24214, 0.28250, -0.92820); // normalize(vec3(0.3, 0.35, -1.15)) float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix( mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y ); } float fbm(vec2 p) { float v = noise(p) * 0.55; v += noise(p * 2.13 + vec2(17.3, 9.1)) * 0.30; v += noise(p * 4.41 + vec2(-4.7, 3.9)) * 0.15; return v; } // Procedural dusk sky: warm gray-blue horizon -> deep blue zenith, // sun disc + halo along SUN_DIR. Below-horizon clamps to the horizon tone. vec3 sky(vec3 d) { float h = pow(clamp(d.y, 0.0, 1.0), 0.55); vec3 col = mix(vec3(0.52, 0.46, 0.42), vec3(0.03, 0.09, 0.24), h); float sd = max(dot(d, SUN_DIR), 0.0); col += vec3(1.00, 0.85, 0.55) * pow(sd, 800.0) * 4.0; // disc col += vec3(1.00, 0.70, 0.40) * pow(sd, 32.0) * 0.30; // halo col += vec3(0.90, 0.55, 0.30) * pow(sd, 6.0) * 0.12; // broad dusk glow return col; } // Finite-difference gradient of scrolling fbm — feeds detail normals. vec2 detailGrad(vec2 p) { float e = 0.35; float h0 = fbm(p); float hx = fbm(p + vec2(e, 0.0)); float hy = fbm(p + vec2(0.0, e)); return vec2(hx - h0, hy - h0) / e; } void main() { vec3 viewDir = normalize(cameraPosition - vWorldPos); vec2 xz = vWorldPos.xz; // Two scrolling detail-normal layers (fine + coarse) — what sells water up close vec2 g1 = detailGrad(xz * 1.15 + vec2(uTime * 0.32, uTime * 0.18)); vec2 g2 = detailGrad(xz * 0.30 - vec2(uTime * 0.11, -uTime * 0.07)); vec3 n = normalize(vNormal + vec3(g1.x + g2.x * 1.6, 0.0, g1.y + g2.y * 1.6) * 0.15); // Base water body color: deep absorption head-on vec3 deep = uColor * 0.22; vec3 body = uColor * 0.9; // Fresnel-weighted procedural sky reflection float fresnel = 0.02 + 0.98 * pow(1.0 - max(dot(viewDir, n), 0.0), 5.0); vec3 refl = sky(reflect(-viewDir, n)); vec3 col = mix(mix(deep, body, clamp(n.z, 0.0, 1.0) * 0.5), refl, fresnel); // Subsurface backscatter: turquoise glow on flanks facing away from the sun float transmit = pow(max(dot(viewDir, -SUN_DIR), 0.0), 3.0); float flank = clamp(vCrest * 0.5 + 0.5, 0.0, 1.0); vec3 sss = vec3(0.05, 0.55, 0.50) * transmit * flank * min(uWaveHeight, 2.0) * 0.55; col += sss; // Foam band 1 — soft crest foam, broken up with 2-octave value noise float crest01 = clamp(vCrest * 0.5 + 0.5, 0.0, 1.0); float foamN = noise(xz * 1.5) * 0.6 + noise(xz * 5.0) * 0.4; float foam = smoothstep(0.42, 0.98, crest01 + (foamN - 0.5) * 0.35); // Foam band 2 — streaky whitecaps on the highest third of crests float streak = fbm(vec2(xz.x * 0.35 + uTime * 0.15, xz.y * 2.6 - uTime * 0.4)); float caps = smoothstep(0.66, 0.88, crest01) * smoothstep(0.45, 0.75, streak); float foamAll = clamp(foam * 0.7 + caps * 0.9, 0.0, 1.0); col = mix(col, vec3(0.88, 0.93, 0.95), foamAll * 0.85); // Sun glint: Blinn-Phong specular vec3 h = normalize(SUN_DIR + viewDir); float spec = pow(max(dot(n, h), 0.0), 240.0); col += vec3(1.0, 0.9, 0.75) * spec * 1.2 * (1.0 - foamAll); // Manual distance fade to black (custom shaders ignore scene fog) float dist = length(cameraPosition - vWorldPos); col = mix(col, vec3(0.0), smoothstep(18.0, 58.0, dist)); gl_FragColor = vec4(col, 0.94); } ` ) extend({ OceanMaterial }) declare global { namespace JSX { interface IntrinsicElements { oceanMaterial: any } } } export function Ocean({ color = '#075985', waveHeight = 0.8, choppiness = 1, speed = 1, }: OceanProps) { const materialRef = useRef(null) useFrame((_, delta) => { const mat = materialRef.current if (!mat) return mat.uTime += delta * speed mat.uColor.set(color) mat.uWaveHeight = waveHeight mat.uChoppiness = choppiness }) return ( ) } ``` ### vfx-burst VFX Burst: GPU particle bursts with game-ready presets: explosion, fire, smoke, magic. Props: - preset: one of explosion|fire|smoke|magic = "explosion" - color: color = "#f97316" - count: number (100–3000) = 800 - size: number (0.01–0.3) = 0.08 - auto: boolean = true Install: `npx facet3d add vfx-burst` Source: ```tsx // VfxBurst — game juice: GPU particle bursts with presets (explosion, fire, smoke, magic). // Must be rendered inside a react-three-fiber . Click anywhere on the canvas // to trigger a burst; with auto=true it also re-triggers on a loop. // // The explosion preset is a multi-layer effect: core flash + radial fireball + // debris streaks + an expanding ground shockwave ring + a colored flash point // light that spikes on every trigger and lights the surroundings. 'use client' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useFrame, useThree, extend } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' export interface VfxBurstProps { preset?: 'explosion' | 'fire' | 'smoke' | 'magic' color?: string count?: number size?: number auto?: boolean } const PRESET_INDEX: Record = { explosion: 0, fire: 1, smoke: 2, magic: 3, } // flash-light peak intensity per preset const FLASH_PEAK: Record = { explosion: 40, fire: 10, smoke: 3, magic: 18, } // layer ids encoded in the aLayer attribute const LAYER_CORE = 0 // core flash (explosion) / inner core (fire) const LAYER_MAIN = 1 // main body of the effect const LAYER_SPARK = 2 // debris (explosion) / embers (fire) / orbit sparkles (magic) const VfxBurstMaterial = shaderMaterial( { uTime: 0, uStart: -100, uColor: new THREE.Color('#f97316'), uSize: 0.08, uPreset: 0, }, /* glsl */ ` uniform float uTime; uniform float uStart; uniform float uSize; uniform int uPreset; attribute vec3 aVelocity; attribute float aSeed; attribute vec3 aOffset; attribute float aLayer; varying float vLife; varying float vSeed; varying float vLayer; void main() { vec3 dir = normalize(aVelocity); float mag = length(aVelocity); float elapsed = uTime - uStart; // per-particle lifetime, varied by seed; layers override per preset float lifetime = mix(0.8, 2.0, aSeed); if (uPreset == 0) { if (aLayer < 0.5) { lifetime = 0.15; // core flash dies fast } else if (aLayer > 1.5) { lifetime = 1.0 + aSeed * 1.0; // debris lingers } } else if (uPreset == 1) { if (aLayer < 0.5) { lifetime = mix(0.5, 1.0, aSeed); // inner core burns fast } else if (aLayer > 1.5) { lifetime = 1.4 + aSeed * 1.2; // embers linger } } float t = elapsed / lifetime; float life = clamp(t, 0.0, 1.0); vec3 pos = vec3(0.0); float sizeCurve = 1.0; if (uPreset == 0) { if (aLayer < 0.5) { // core flash — huge white bloom, slow expand, gone in 0.15s pos = dir * mag * life * 0.8; sizeCurve = 3.0 + 7.0 * pow(life, 0.4); } else if (aLayer > 1.5) { // debris streaks — fast, tiny, gravity drops them float speed = 4.5 + aSeed * 5.0; pos = dir * speed * elapsed; pos.y -= 1.8 * elapsed * elapsed; sizeCurve = 0.28; } else { // main fireball — decelerating radial burst + gravity sag float ease = 1.0 - pow(1.0 - life, 3.0); pos = dir * mag * ease * 3.0; pos.y -= life * life * 1.2; sizeCurve = (1.0 - life) * 1.6 + 0.3; } } else if (uPreset == 1) { if (aLayer < 0.5) { // inner core — brighter, smaller, faster rise, tight column float rise = life * (2.4 + aSeed * 1.2); float spread = life * mag * 0.22; float flick = sin(uTime * 12.0 + aSeed * 40.0) * 0.06 * life; pos = vec3(dir.x * spread + flick, rise, dir.z * spread + flick * 0.6); sizeCurve = 0.55 - life * 0.3; } else if (aLayer > 1.5) { // ember sparks — detach sideways with wiggle, then fall float rise = life * (1.2 + aSeed * 0.8) - life * life * 1.6; vec3 outDir = dir * life * (1.4 + aSeed * 1.2); float wig = sin(uTime * 11.0 + aSeed * 60.0) * 0.18 * life; pos = vec3( outDir.x + wig, rise, outDir.z + cos(uTime * 9.0 + aSeed * 45.0) * 0.18 * life ); sizeCurve = 0.32 - life * 0.18; } else { // main flame — upward cone with sideways flicker float rise = life * (1.6 + aSeed * 1.4); float spread = life * mag * 0.55; float flick = sin(uTime * 9.0 + aSeed * 40.0) * 0.12 * life; pos = vec3(dir.x * spread + flick, rise, dir.z * spread + flick * 0.6); sizeCurve = 1.0 - life * 0.75; } } else if (uPreset == 2) { // smoke — slow rise, expand, drift float rise = life * (0.9 + aSeed * 0.6); vec3 drift = vec3( sin(uTime * 0.8 + aSeed * 20.0), 0.0, cos(uTime * 0.6 + aSeed * 17.0) ) * 0.25 * life; pos = dir * mag * life * 0.7 + vec3(0.0, rise, 0.0) + drift; sizeCurve = 0.4 + life * 2.4; } else { // magic — implosion phase, then spiral orbit with rise float spin = (aLayer > 1.5) ? 5.5 : 3.0; float ang = uTime * spin + aSeed * 6.2831; vec2 rotated = vec2( dir.x * cos(ang) - dir.z * sin(ang), dir.x * sin(ang) + dir.z * cos(ang) ); float radius; if (life < 0.15) { // first 15% of life pulls INWARD before spiraling out radius = mag * mix(1.6, 0.12, life / 0.15); } else { radius = mag * mix(0.12, 1.15, (life - 0.15) / 0.85); } pos = vec3(rotated.x * radius, life * (1.0 + aSeed * 0.8), rotated.y * radius); if (aLayer > 1.5) { // orbiting sparkles — smaller, pulsing sizeCurve = 0.35 + 0.25 * sin(uTime * 8.0 + aSeed * 30.0); } else { sizeCurve = 0.6 + 0.4 * sin(uTime * 6.0 + aSeed * 30.0); } } pos += aOffset; vLife = life; vSeed = aSeed; vLayer = aLayer; vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); gl_Position = projectionMatrix * mvPosition; // dead particles collapse to zero size float alive = (t >= 0.0 && t < 1.0) ? 1.0 : 0.0; gl_PointSize = uSize * 500.0 * sizeCurve * alive / max(-mvPosition.z, 0.1); } `, /* glsl */ ` uniform vec3 uColor; uniform float uTime; uniform int uPreset; varying float vLife; varying float vSeed; varying float vLayer; void main() { if (vLife >= 1.0) discard; // soft round point float d = length(gl_PointCoord - 0.5); float alpha = 1.0 - smoothstep(0.0, 0.5, d); vec3 col; if (uPreset == 0) { if (vLayer < 0.5) { // core flash — blinding white col = vec3(1.0, 1.0, 0.97); alpha *= 1.0 - smoothstep(0.3, 1.0, vLife); } else if (vLayer > 1.5) { // debris — hot sparks cooling into the preset color col = mix(vec3(1.0, 0.95, 0.8), uColor, smoothstep(0.0, 0.4, vLife)); alpha *= 1.0 - smoothstep(0.6, 1.0, vLife); } else { // main fireball ramp: white -> color -> deep ember #7c2d12 -> gone col = mix(vec3(1.0, 0.98, 0.9), uColor, smoothstep(0.0, 0.25, vLife)); col = mix(col, vec3(0.486, 0.176, 0.071), smoothstep(0.3, 0.75, vLife)); alpha *= 1.0 - smoothstep(0.55, 1.0, vLife); } } else if (uPreset == 1) { if (vLayer < 0.5) { // inner core — near white-hot col = mix(vec3(1.0, 0.99, 0.92), uColor, smoothstep(0.2, 0.9, vLife)); alpha *= 1.0 - smoothstep(0.5, 1.0, vLife); } else if (vLayer > 1.5) { // embers — bright and flickering col = mix(vec3(1.0, 0.9, 0.6), uColor, smoothstep(0.0, 0.5, vLife)); float flicker = 0.55 + 0.45 * sin(uTime * 18.0 + vSeed * 80.0); alpha *= flicker * (1.0 - smoothstep(0.5, 1.0, vLife)); } else { // main flame ramp: white-hot -> color -> fade col = mix(vec3(1.0, 0.98, 0.9), uColor, smoothstep(0.0, 0.3, vLife)); alpha *= 1.0 - smoothstep(0.55, 1.0, vLife); } } else if (uPreset == 2) { // smoke — gray ramp by life (#404040 -> #a3a3a3), alpha peaks mid-life col = mix(vec3(0.251), vec3(0.639), smoothstep(0.0, 0.7, vLife)); alpha *= 0.32 * smoothstep(0.0, 0.3, vLife) * (1.0 - smoothstep(0.5, 1.0, vLife)); } else { // magic — per-particle hue twinkle toward white float tw = 0.5 + 0.5 * sin(vSeed * 40.0 + vLife * 25.0); float twAmt = (vLayer > 1.5) ? 0.75 : 0.35; col = mix(uColor, vec3(1.0), tw * twAmt); alpha *= 1.0 - smoothstep(0.6, 1.0, vLife); } gl_FragColor = vec4(col, alpha); } ` ) const VfxShockwaveMaterial = shaderMaterial( { uOpacity: 0, uColor: new THREE.Color('#f97316'), }, /* glsl */ ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, /* glsl */ ` uniform float uOpacity; uniform vec3 uColor; varying vec2 vUv; void main() { float d = length(vUv - 0.5) * 2.0; // soft ring band near the rim of the disc float band = smoothstep(0.55, 0.8, d) * (1.0 - smoothstep(0.85, 1.0, d)); vec3 col = mix(uColor, vec3(1.0), 0.6 * band); float alpha = band * uOpacity; if (alpha < 0.003) discard; gl_FragColor = vec4(col, alpha); } ` ) extend({ VfxBurstMaterial, VfxShockwaveMaterial }) declare global { namespace JSX { interface IntrinsicElements { vfxBurstMaterial: any vfxShockwaveMaterial: any } } } export function VfxBurst({ preset = 'explosion', color = '#f97316', count = 800, size = 0.08, auto = true, }: VfxBurstProps) { const materialRef = useRef(null) const shockwaveRef = useRef(null) const shockwaveMatRef = useRef(null) const lightRef = useRef(null) const timeRef = useRef(0) const lastTriggerRef = useRef(-100) const gl = useThree((state) => state.gl) const [positions, velocities, seeds, offsets, layers] = useMemo(() => { const positions = new Float32Array(count * 3) const velocities = new Float32Array(count * 3) const seeds = new Float32Array(count) const offsets = new Float32Array(count * 3) const layers = new Float32Array(count) // first 10% = core layer, next 15% = spark/debris layer, rest = main body const coreCount = Math.max(1, Math.floor(count * 0.1)) const sparkCount = Math.floor(count * 0.15) for (let i = 0; i < count; i++) { const i3 = i * 3 // random direction on a sphere, varied magnitude — the shader // interprets this per preset (radial burst / cone / orbit basis) const theta = Math.random() * Math.PI * 2 const z = Math.random() * 2 - 1 const r = Math.sqrt(1 - z * z) const mag = 0.5 + Math.random() velocities[i3] = r * Math.cos(theta) * mag velocities[i3 + 1] = z * mag velocities[i3 + 2] = r * Math.sin(theta) * mag seeds[i] = Math.random() layers[i] = i < coreCount ? LAYER_CORE : i < coreCount + sparkCount ? LAYER_SPARK : LAYER_MAIN // small origin jitter so the burst core isn't a single point offsets[i3] = (Math.random() - 0.5) * 0.15 offsets[i3 + 1] = (Math.random() - 0.5) * 0.15 offsets[i3 + 2] = (Math.random() - 0.5) * 0.15 } return [positions, velocities, seeds, offsets, layers] }, [count]) const trigger = () => { if (!materialRef.current) return materialRef.current.uStart = timeRef.current lastTriggerRef.current = timeRef.current } // click anywhere on the canvas to burst useEffect(() => { const el = gl.domElement el.addEventListener('pointerdown', trigger) return () => el.removeEventListener('pointerdown', trigger) }, [gl]) // preset change re-triggers useEffect(() => { trigger() }, [preset]) useFrame((_, delta) => { timeRef.current += delta if (!materialRef.current) return materialRef.current.uTime = timeRef.current if (auto && timeRef.current - lastTriggerRef.current >= 2.5) { trigger() } const sinceTrigger = timeRef.current - lastTriggerRef.current // shockwave ring — expands 0 -> 8 over 0.5s while fading out (explosion only) const shock = shockwaveRef.current if (shock && shockwaveMatRef.current) { const st = sinceTrigger / 0.5 const active = preset === 'explosion' && st >= 0 && st < 1 shock.visible = active if (active) { const ease = 1 - Math.pow(1 - st, 3) // plane is rotated flat, so its local XY maps to world XZ const s = Math.max(8 * ease, 0.001) shock.scale.set(s, s, 1) shockwaveMatRef.current.uOpacity = (1 - st) * 0.9 } } // flash light — intensity spikes on trigger, exponential decay const light = lightRef.current if (light) { const peak = FLASH_PEAK[preset] ?? 20 light.intensity = sinceTrigger >= 0 ? peak * Math.exp(-sinceTrigger * 4.5) : 0 light.color.set(color) } }) return ( {/* ground shockwave ring (explosion only, visibility toggled per frame) */} {/* flash light — lights the surroundings on each trigger */} ) } ``` ### day-night-sky Day/Night Sky: Procedural sky dome with a full sun cycle, stars, and drifting clouds. Props: - timeOfDay: number (0–24) = 10 - speed: number (0–5) = 0.5 - cloudCover: number (0–1) = 0.4 Install: `npx facet3d add day-night-sky` Source: ```tsx // day-night-sky — procedural sky dome with a full sun cycle: gradient sky, // sun disc + halo, a moon, twinkling stars and drifting fbm clouds, all driven // by a 0–24 hour clock. Must be rendered inside a react-three-fiber . 'use client' import { useRef } from 'react' import { BackSide, ShaderMaterial } from 'three' import { extend, useFrame } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' const vertexShader = /* glsl */ ` varying vec3 vDir; void main() { vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const fragmentShader = /* glsl */ ` uniform float uTime; // time of day in hours, 0-24 uniform float uClock; // seconds, drives star twinkle + cloud scroll uniform float uCloudCover; // 0-1 varying vec3 vDir; float hash13(vec3 p) { p = fract(p * 0.1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); } float hash12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } float noise2(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); float a = hash12(i); float b = hash12(i + vec2(1.0, 0.0)); float c = hash12(i + vec2(0.0, 1.0)); float d = hash12(i + vec2(1.0, 1.0)); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } float fbm(vec2 p) { float value = 0.0; float amplitude = 0.5; for (int i = 0; i < 5; i++) { value += amplitude * noise2(p); p = p * 2.03 + vec2(17.3, 9.1); amplitude *= 0.5; } return value; } void main() { vec3 dir = normalize(vDir); // sun path: rises +x at 6h, peaks at noon, sets -x at 18h float sunAngle = (uTime - 6.0) / 12.0 * 3.14159265; vec3 sunDir = normalize(vec3(cos(sunAngle), sin(sunAngle), 0.35)); float se = sunDir.y; // phase weights from sun elevation float dayF = smoothstep(0.06, 0.30, se); float nightF = 1.0 - smoothstep(-0.30, -0.08, se); float duskF = clamp(1.0 - dayF - nightF, 0.0, 1.0); // sky gradient (zenith / horizon per phase) vec3 dayZen = vec3(0.231, 0.510, 0.769); // #3b82c4 vec3 dayHor = vec3(0.749, 0.851, 0.910); // #bfd9e8 vec3 duskZen = vec3(0.110, 0.150, 0.290); vec3 duskHor = vec3(0.980, 0.420, 0.160); vec3 nightZen = vec3(0.020, 0.031, 0.063); // #050810 vec3 nightHor = vec3(0.055, 0.090, 0.160); vec3 zen = nightZen; zen = mix(zen, duskZen, duskF); zen = mix(zen, dayZen, dayF); vec3 hor = nightHor; hor = mix(hor, duskHor, duskF); hor = mix(hor, dayHor, dayF); float grad = pow(1.0 - clamp(dir.y, 0.0, 1.0), 1.6); vec3 sky = mix(zen, hor, grad); // warm band hugging the horizon on the sun's side at dawn/dusk vec3 sunFlat = normalize(vec3(sunDir.x, 0.0, sunDir.z)); float sunAz = max(dot(dir, sunFlat), 0.0); float band = pow(sunAz, 3.0) * exp(-max(dir.y, 0.0) * 5.0) * duskF; sky += vec3(1.0, 0.38, 0.14) * band * 0.85; sky += vec3(1.0, 0.16, 0.34) * band * band * 0.45; // darken the below-horizon dome float above = smoothstep(-0.20, 0.0, dir.y); sky *= mix(0.30, 1.0, above); // sun disc + halo, fading as it dips below the horizon float sunDot = dot(dir, sunDir); float sunVis = smoothstep(-0.10, 0.02, se); float sunDisc = smoothstep(0.99935, 0.99965, sunDot); float halo = pow(max(sunDot, 0.0), 600.0) * 0.55 + pow(max(sunDot, 0.0), 60.0) * 0.16; vec3 sunCol = mix(vec3(1.0, 0.55, 0.20), vec3(1.0, 0.98, 0.90), dayF); sky += sunCol * (sunDisc * 2.2 + halo) * sunVis; // moon: pale disc riding the opposite arc, visible at night vec3 moonDir = normalize(-sunDir + vec3(0.0, 0.10, -0.15)); float moonDot = dot(dir, moonDir); float moonVis = smoothstep(0.02, 0.12, moonDir.y); float moonDisc = smoothstep(0.99965, 0.99985, moonDot); vec3 mu = normalize(cross(moonDir, vec3(0.0, 1.0, 0.0))); vec3 mv = cross(mu, moonDir); vec2 muv = vec2(dot(dir, mu), dot(dir, mv)) * 60.0; float crater = fbm(muv); vec3 moonCol = vec3(0.92, 0.94, 0.97) * (0.72 + 0.36 * crater); float moonGlow = pow(max(moonDot, 0.0), 800.0) * 0.35; sky += (moonCol * moonDisc + vec3(0.60, 0.70, 0.90) * moonGlow) * moonVis; // stars: hashed 3D grid on the dome, twinkling, night only vec3 sp = dir * 60.0; vec3 cell = floor(sp); vec3 f3 = fract(sp) - 0.5; float h1 = hash13(cell); vec3 offs = vec3( hash13(cell + 11.3), hash13(cell + 27.7), hash13(cell + 43.1) ) - 0.5; float sd = length(f3 - offs * 0.7); float star = (1.0 - smoothstep(0.0, 0.06 + h1 * 0.05, sd)) * step(0.92, h1); float twinkle = 0.55 + 0.45 * sin(uClock * (1.5 + h1 * 4.0) + h1 * 40.0); float starVis = (1.0 - smoothstep(-0.10, 0.04, se)) * smoothstep(-0.05, 0.12, dir.y); sky += vec3(0.90, 0.93, 1.0) * star * twinkle * starVis; // clouds: fbm on a plane projection of the upper hemisphere float cloudMask = smoothstep(0.03, 0.18, dir.y); vec2 cp = dir.xz / max(dir.y, 0.05) * 1.4; cp += vec2(uClock * 0.008, uClock * 0.004); float cn = fbm(cp * 0.9); float cov = 1.0 - uCloudCover * 0.9; float cdens = smoothstep(cov, cov + 0.35, cn); vec3 cloudCol = vec3(0.10, 0.12, 0.17); cloudCol = mix(cloudCol, vec3(1.0, 0.55, 0.35), duskF); cloudCol = mix(cloudCol, vec3(1.0, 1.0, 1.0), dayF); cloudCol += vec3(1.0, 0.6, 0.3) * band * 0.6; cloudCol *= 0.75 + 0.25 * smoothstep(cov, cov + 0.6, cn); sky = mix(sky, cloudCol, cdens * cloudMask * 0.9); gl_FragColor = vec4(sky, 1.0); } ` const DayNightSkyMaterial = shaderMaterial( { uTime: 10, uClock: 0, uCloudCover: 0.4, }, vertexShader, fragmentShader ) extend({ DayNightSkyMaterial }) declare global { namespace JSX { interface IntrinsicElements { dayNightSkyMaterial: any } } } export interface DayNightSkyProps { timeOfDay?: number speed?: number cloudCover?: number } export function DayNightSky({ timeOfDay = 10, speed = 0.5, cloudCover = 0.4, }: DayNightSkyProps) { const materialRef = useRef(null) const timeRef = useRef(timeOfDay) const lastPropRef = useRef(timeOfDay) useFrame((_, delta) => { const material = materialRef.current if (!material) return // resync when the playground slider moves, then keep advancing if (timeOfDay !== lastPropRef.current) { timeRef.current = timeOfDay lastPropRef.current = timeOfDay } timeRef.current = (timeRef.current + delta * speed) % 24 material.uniforms.uTime.value = timeRef.current material.uniforms.uClock.value += delta material.uniforms.uCloudCover.value = cloudCover }) return ( ) } ``` ### grass-field Grass Field: Tens of thousands of instanced grass blades swaying in the wind. Props: - count: number (5000–100000) = 30000 - color: color = "#65a30d" - windStrength: number (0–3) = 1 - windSpeed: number (0–5) = 1.5 - area: number (5–50) = 20 Install: `npx facet3d add grass-field` Source: ```tsx // Grass Field — tens of thousands of instanced, wind-blown grass blades. // Must be rendered inside a react-three-fiber . // // Usage: // // // // // All blades live in a single THREE.InstancedBufferGeometry drawn with one // custom shader — one draw call for the whole field. Wind, per-blade lean // and the root→tip color ramp are computed in the vertex shader, so no // scene lights are required for the grass itself (the soil disc is lit). 'use client' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useFrame, extend } from '@react-three/fiber' import { shaderMaterial } from '@react-three/drei' export interface GrassFieldProps { count?: number color?: string windStrength?: number windSpeed?: number area?: number } const GrassFieldMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color('#65a30d'), uWindStrength: 1, uWindSpeed: 1.5, }, /* glsl */ ` uniform float uTime; uniform vec3 uColor; uniform float uWindStrength; uniform float uWindSpeed; attribute vec3 aOffset; attribute float aScale; attribute float aRotation; attribute float aPhase; attribute float aColorJitter; varying vec3 vColor; varying float vT; void main() { vec3 pos = position * aScale; // Yaw the blade around its own base float c = cos(aRotation); float s = sin(aRotation); pos = vec3(pos.x * c - pos.z * s, pos.y, pos.x * s + pos.z * c); // Progressive bend: nothing at the root, full sway at the tip float t = uv.y * uv.y; // Traveling wave across the field (aOffset.x phase) + per-blade flutter float wave = ( sin(uTime * uWindSpeed + aPhase + aOffset.x * 0.3) + 0.5 * sin(uTime * uWindSpeed * 2.7 + aPhase * 1.3) ) * uWindStrength * 0.15; vec2 windDir = vec2(0.94, 0.34); // pre-normalized main wind direction vec2 leanDir = vec2(cos(aRotation * 1.7), sin(aRotation * 1.7)); vec2 bend = windDir * wave + leanDir * 0.06; pos.xz += bend * t * aScale; // Root → tip color ramp, jittered blades lean toward pale lime vec3 root = uColor * 0.5; vec3 tip = mix(uColor * 1.3, vec3(0.851, 0.976, 0.616), aColorJitter * 0.6); vColor = mix(root, tip, uv.y); vT = uv.y; vec3 world = aOffset + pos; gl_Position = projectionMatrix * modelViewMatrix * vec4(world, 1.0); } `, /* glsl */ ` varying vec3 vColor; varying float vT; void main() { // Fake lambert: blades face the sky more as they rise, fixed warm sun vec3 n = normalize(mix(vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0), vT)); vec3 lightDir = normalize(vec3(0.5, 0.8, 0.3)); float diff = max(dot(n, lightDir), 0.0); vec3 col = vColor * (0.55 + 0.5 * diff); // Slight translucency glow toward the tip (sun shining through blades) col += vec3(0.35, 0.45, 0.12) * vT * vT * 0.35; gl_FragColor = vec4(col, 1.0); } ` ) extend({ GrassFieldMaterial }) declare global { namespace JSX { interface IntrinsicElements { grassFieldMaterial: any } } } // Single blade: 3-segment tapered strip, ~0.06 wide at the root, ~0.5 tall, // single tip vertex, slight forward curve baked into the z coordinate. function buildBladeGeometry(): THREE.BufferGeometry { const halfWidths = [0.03, 0.024, 0.014] const heights = [0, 0.166, 0.333, 0.5] const positions: number[] = [] const uvs: number[] = [] for (let i = 0; i < 3; i++) { const t = heights[i] / 0.5 const z = t * t * 0.12 // gentle forward arc const w = halfWidths[i] positions.push(-w, heights[i], z, w, heights[i], z) uvs.push(0, t, 1, t) } // Tip vertex positions.push(0, 0.5, 0.12) uvs.push(0.5, 1) const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) geo.setIndex([0, 1, 2, 1, 3, 2, 2, 3, 4, 3, 5, 4, 4, 5, 6]) return geo } export function GrassField({ count = 30000, color = '#65a30d', windStrength = 1, windSpeed = 1.5, area = 20, }: GrassFieldProps) { const materialRef = useRef(null) const geometry = useMemo(() => { const base = buildBladeGeometry() const geo = new THREE.InstancedBufferGeometry() geo.index = base.index geo.setAttribute('position', base.getAttribute('position')) geo.setAttribute('uv', base.getAttribute('uv')) const offsets = new Float32Array(count * 3) const scales = new Float32Array(count) const rotations = new Float32Array(count) const phases = new Float32Array(count) const jitters = new Float32Array(count) const radius = area / 2 for (let i = 0; i < count; i++) { // Uniform disc distribution const r = radius * Math.sqrt(Math.random()) const theta = Math.random() * Math.PI * 2 offsets[i * 3] = Math.cos(theta) * r offsets[i * 3 + 1] = 0 offsets[i * 3 + 2] = Math.sin(theta) * r scales[i] = 0.7 + Math.random() * 0.7 rotations[i] = Math.random() * Math.PI * 2 phases[i] = Math.random() * 6.28 jitters[i] = Math.random() } geo.setAttribute('aOffset', new THREE.InstancedBufferAttribute(offsets, 3)) geo.setAttribute('aScale', new THREE.InstancedBufferAttribute(scales, 1)) geo.setAttribute('aRotation', new THREE.InstancedBufferAttribute(rotations, 1)) geo.setAttribute('aPhase', new THREE.InstancedBufferAttribute(phases, 1)) geo.setAttribute('aColorJitter', new THREE.InstancedBufferAttribute(jitters, 1)) geo.instanceCount = count geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0.3, 0), radius + 1) return geo }, [count, area]) useEffect(() => () => geometry.dispose(), [geometry]) useFrame((_, delta) => { const mat = materialRef.current if (!mat) return mat.uTime += delta mat.uColor.set(color) mat.uWindStrength = windStrength mat.uWindSpeed = windSpeed }) return ( {/* Dark soil disc under the blades */} ) } ``` ### avatar-maker Avatar Maker: Design a stylized 3D character with expressions and export it as a .glb for your game. Props: - skinColor: color = "#f2c89b" - hairColor: color = "#3f3f46" - hairStyle: one of bowl|buzz|spiky|ponytail|messy = "bowl" - shirtColor: color = "#a3e635" - pantsColor: color = "#262626" - accessory: one of none|glasses|cap = "none" - expression: one of happy|neutral|surprised|sleepy = "happy" - headSize: number (0.8–1.4) = 1.1 - height: number (0.8–1.3) = 1 - animate: boolean = true Install: `npx facet3d add avatar-maker` Source: ```tsx 'use client' // AvatarMaker — parametric stylized chibi character generator with GLB export. // Designer-vinyl-toy aesthetic: sculpted layered hair, expressive faces // (happy / neutral / surprised / sleepy), physical clearcoat materials. // // Usage: // // // // // The forwarded ref points at the outer THREE.Group (feet at y=0, total // standing height ~2.2 * `height` units). Pass that group to the export // helper to download a binary glTF of the character: // // import { downloadAvatarGLB } from './avatar-maker' // // // downloadAvatarGLB is client-only: it no-ops on the server. import { forwardRef, useRef } from 'react' import type { RefObject } from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' import { RoundedBox } from '@react-three/drei' import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' export interface AvatarMakerProps { skinColor?: string hairColor?: string hairStyle?: 'bowl' | 'buzz' | 'spiky' | 'ponytail' | 'messy' shirtColor?: string pantsColor?: string accessory?: 'none' | 'glasses' | 'cap' expression?: 'happy' | 'neutral' | 'surprised' | 'sleepy' headSize?: number height?: number animate?: boolean } type Expression = NonNullable type HairStyle = NonNullable const DARK = '#1c1c20' const MOUTH = '#5b3a2e' const IRIS = '#5a4632' const BLUSH = '#f0a8b0' // Vinyl-toy material: soft diffuse base with a clearcoat sheen. function Vinyl({ color, roughness = 0.55 }: { color: string; roughness?: number }) { return ( ) } // Mix a hex color toward white (for two-tone hair tips). Deterministic. function lighten(hex: string, amount: number): string { const n = parseInt(hex.slice(1), 16) const mix = (c: number) => Math.round(c + (255 - c) * amount) const r = mix((n >> 16) & 255) const g = mix((n >> 8) & 255) const b = mix(n & 255) return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}` } // Deterministic PRNG so sculpted hair detail is identical on every render. function mulberry32(seed: number) { return () => { seed |= 0 seed = (seed + 0x6d2b79f5) | 0 let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } // Bowl fringe: 4 segmented bang pieces across the forehead hairline. const FRINGE: { position: [number, number, number]; scale: [number, number, number]; rotationZ: number }[] = [ { position: [-0.26, 0.24, 0.36], scale: [1.15, 1, 0.85], rotationZ: 0.25 }, { position: [-0.09, 0.26, 0.38], scale: [1.1, 0.95, 0.8], rotationZ: 0.08 }, { position: [0.09, 0.26, 0.38], scale: [1.1, 0.95, 0.8], rotationZ: -0.08 }, { position: [0.26, 0.24, 0.36], scale: [1.15, 1, 0.85], rotationZ: -0.25 }, ] // Temple/side pieces framing the face. const SIDES: { position: [number, number, number]; scale: [number, number, number] }[] = [ { position: [-0.4, 0.04, 0.2], scale: [0.5, 1.35, 0.75] }, { position: [0.4, 0.04, 0.2], scale: [0.5, 1.35, 0.75] }, ] // Spiky: 9 finer cones with seeded tilt/height variance across the crown. const SPIKES = (() => { const rand = mulberry32(77) return Array.from({ length: 9 }, (_, i) => { const a = -1.05 + i * 0.2625 const r = 0.3 + rand() * 0.06 return { position: [Math.sin(a) * r, Math.cos(a) * 0.4 + 0.07, (rand() - 0.5) * 0.12] as [ number, number, number, ], rotation: [(rand() - 0.5) * 0.3, 0, -a + (rand() - 0.5) * 0.3] as [number, number, number], height: 0.2 + rand() * 0.12, radius: 0.055 + rand() * 0.02, isTip: i % 2 === 0, } }) })() // Messy: 8 overlapping ellipsoid tufts sculpted onto the bowl shell // (sphere r=0.475 centered at y=0.1), so they read as lumps, not bumps. const MESSY_TUFTS = (() => { const rand = mulberry32(1337) return Array.from({ length: 8 }, (_, i) => { const theta = 0.15 + rand() * 0.7 const phi = rand() * Math.PI * 2 const r = 0.46 return { position: [ Math.sin(theta) * Math.cos(phi) * r, 0.1 + Math.cos(theta) * r, Math.sin(theta) * Math.sin(phi) * r, ] as [number, number, number], scale: [1.1 + rand() * 0.4, 0.8 + rand() * 0.3, 1 + rand() * 0.3] as [number, number, number], radius: 0.11 + rand() * 0.05, isTip: i % 2 === 0, } }) })() function Hair({ style, color, tieColor, }: { style: HairStyle color: string tieColor: string }) { const main = const tip = const baseCap = ( {main} ) const fringe = FRINGE.map((f, i) => ( {i % 2 === 0 ? tip : main} )) const sides = SIDES.map((s, i) => ( {main} )) switch (style) { case 'bowl': return ( {baseCap} {fringe} {sides} ) case 'buzz': return ( {main} ) case 'spiky': return ( {baseCap} {SPIKES.map((s, i) => ( {s.isTip ? tip : main} ))} ) case 'ponytail': return ( {baseCap} {fringe} {sides} {/* Two-segment curved tail: kicks back off the crown, then hangs */} {main} {tip} {/* Tie */} ) case 'messy': return ( {baseCap} {MESSY_TUFTS.map((t, i) => ( {t.isTip ? tip : main} ))} {fringe} ) } } // One eye. The inner group is the blink target: useFrame scales its y. // Positioned on the head sphere and yawed/pitched so local +z is the // surface normal — iris/pupil/highlight stack along local z. function Eye({ side, expression, skinColor, eyeRef, }: { side: 1 | -1 expression: Expression skinColor: string eyeRef: RefObject }) { const dark = ( ) return ( {expression === 'happy' ? ( // Happy closed eye: downward-curved arc (⌒) {dark} ) : ( <> {/* Sclera */} {/* Iris */} {/* Pupil */} {dark} {/* Specular highlight, up-left */} {/* Upper lash line */} {dark} {/* Sleepy drooping lid: skin-colored cap over the upper eye */} {expression === 'sleepy' && ( )} )} ) } // Brow placement per expression: height + tilt (outer end down = positive). const BROWS: Record = { happy: { y: 0.165, tilt: 0.12 }, neutral: { y: 0.16, tilt: 0.04 }, surprised: { y: 0.21, tilt: -0.15 }, sleepy: { y: 0.13, tilt: 0.35 }, } function Brows({ expression }: { expression: Expression }) { const { y, tilt } = BROWS[expression] return ( <> {([-1, 1] as const).map((side) => ( ))} ) } function Mouth({ expression }: { expression: Expression }) { const mat = ( ) switch (expression) { case 'happy': // Upturned smile: torus arc centered on the bottom return ( {mat} ) case 'neutral': // Flat relaxed bar — thick enough and seated proud of the face surface // (head sphere surface at this height is z≈0.434). return ( {mat} ) case 'surprised': // Small 'o' ring return ( {mat} ) case 'sleepy': return ( {mat} ) } } function Accessory({ kind, color }: { kind: NonNullable; color: string }) { if (kind === 'none') return null if (kind === 'glasses') { const mat = ( ) // Rims sit slightly proud of the eye surface, yawed/pitched to follow the // head sphere normal at the eye position (same orientation as the eyes). return ( {([-1, 1] as const).map((side) => ( {/* Thick rim */} {mat} {/* Temple arm: angles back and outward to reach the head side */} {mat} ))} {/* Bridge connecting the rims */} {mat} ) } // cap — uses the shirt color const mat = return ( {mat} {mat} ) } export const AvatarMaker = forwardRef(function AvatarMaker( { skinColor = '#f2c89b', hairColor = '#3f3f46', hairStyle = 'bowl', shirtColor = '#a3e635', pantsColor = '#262626', accessory = 'none', expression = 'happy', headSize = 1.1, height = 1, animate = true, }, ref ) { const bodyRef = useRef(null) const headRef = useRef(null) const eyeLRef = useRef(null) const eyeRRef = useRef(null) const armLRef = useRef(null) const armRRef = useRef(null) // Blink scheduler: next blink time + blink start, deterministic jitter via LCG. const blink = useRef({ next: 2.2, start: -1, seed: 7 }) useFrame(({ clock }) => { const body = bodyRef.current const head = headRef.current const eyeL = eyeLRef.current const eyeR = eyeRRef.current const armL = armLRef.current const armR = armRRef.current if (!body || !head || !eyeL || !eyeR || !armL || !armR) return const eyeScale = expression === 'surprised' ? 1.15 : 1 if (!animate) { body.scale.y = 1 head.rotation.set(0, 0, 0) eyeL.scale.y = eyeScale eyeR.scale.y = eyeScale armL.rotation.x = 0 armR.rotation.x = 0 return } const t = clock.getElapsedTime() // Breathing: ~1.5Hz, ±0.02 on the torso group. body.scale.y = 1 + Math.sin(t * Math.PI * 2 * 1.5) * 0.02 // Blink: both eyes dip to 0.05 for ~0.12s every 2.5–4s. const b = blink.current if (t >= b.next) { b.start = t b.seed = (b.seed * 16807) % 2147483647 b.next = t + 2.5 + (b.seed / 2147483647) * 1.5 } const phase = (t - b.start) / 0.12 const lid = phase >= 0 && phase <= 1 ? 1 - 0.95 * Math.sin(Math.PI * phase) : 1 eyeL.scale.y = lid * eyeScale eyeR.scale.y = lid * eyeScale // Subtle head sway. head.rotation.z = Math.sin(t * 0.6) * 0.03 head.rotation.y = Math.sin(t * 0.43) * 0.05 // Arm sway, counter-phase. armL.rotation.x = Math.sin(t * 1.9) * 0.04 armR.rotation.x = -Math.sin(t * 1.9) * 0.04 }) return ( {/* Legs: thigh + shin segments */} {[-1, 1].map((side) => ( {/* Sneaker: rounded body + lighter sole */} ))} {/* Pelvis: slight hip taper bridging pants and shirt */} {/* Torso + shoulders + arms (breathing group, origin at torso center) */} {/* Shoulders */} {[-1, 1].map((side) => ( ))} {/* Arms: upper + forearm with elbow bend, mitten hands */} {[-1, 1].map((side) => ( {/* Mitten hand: flattened sphere */} ))} {/* Head: everything inside scales with headSize so hair/glasses stay seated */} {/* Nose: tiny rounded bump */} {/* Blush */} {[-1, 1].map((side) => ( ))} ) }) /** * Export the avatar group as a binary glTF (.glb) and trigger a browser * download. Client-only: no-ops on the server or when `group` is null. */ export async function downloadAvatarGLB(group: THREE.Group | null, filename = 'avatar.glb') { if (typeof window === 'undefined' || typeof document === 'undefined' || !group) return const exporter = new GLTFExporter() const result = await exporter.parseAsync(group, { binary: true }) const blob = new Blob([result as ArrayBuffer], { type: 'model/gltf-binary' }) const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() URL.revokeObjectURL(url) } ``` ### drift-car Drift Car: Arcade drift car with raycast suspension, handbrake slides, smoke, and skid marks. Props: - carColor: color = "#a3e635" - acceleration: number (5–40) = 18 - topSpeed: number (5–50) = 26 - driftFactor: number (0–1) = 0.6 - cameraLag: number (1–20) = 6 - fovKick: boolean = true - smoke: boolean = true - skidmarks: boolean = true Install: `npx facet3d add drift-car` Source: ```tsx // drift-car — arcade third-person drift car with real game feel on // @react-three/rapier: hand-rolled raycast suspension (4 wheel rays, // spring/damper), split lateral/longitudinal tire friction, a Space handbrake // that collapses rear lateral grip into controllable drifts, snappy // counter-steer, a laggy chase camera with velocity look-ahead, FOV kick and // roll-into-drift, fade-out skid-mark ribbons, pooled GPU drift smoke, and a // self-contained playground (dark plane, ramps, scatterable cones). // // Renders its own rapier Physics world — do NOT nest inside another // . The car owns the camera — do not add OrbitControls. // // // // // // Controls: WASD / arrow keys to drive, hold Space for the handbrake. // Install: npx facet3d add drift-car // Requires: three, @react-three/fiber, @react-three/drei, @react-three/rapier 'use client' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { extend, useFrame, useThree } from '@react-three/fiber' import { Grid, RoundedBox, shaderMaterial } from '@react-three/drei' import { Physics, RigidBody, CuboidCollider, useRapier, type RapierRigidBody } from '@react-three/rapier' export interface DriftCarProps { carColor?: string acceleration?: number topSpeed?: number driftFactor?: number // 0 = grippy, 1 = very slidey cameraLag?: number // higher = snappier camera fovKick?: boolean smoke?: boolean skidmarks?: boolean } // --- tuning (forces are per-unit-mass, applied as impulses) ----------------- const GRAVITY = -20 // snappier than real gravity, keeps landings arcade-tight const WHEEL_X = 0.62, WHEEL_Z = 0.92, WHEEL_RADIUS = 0.3, SUSP_REST = 0.34, RAY_ORIGIN_Y = 0.06, RAY_LEN = RAY_ORIGIN_Y + SUSP_REST + WHEEL_RADIUS const SPRING_K = 55, // suspension spring / damper SPRING_D = 5.5, FRONT_GRIP = 9, // lateral accel per m/s of slip MAX_TIRE = 12, // friction-circle clamp BRAKE_DECEL = 26, MAX_STEER = 0.62, STEER_SMOOTHING = 12, YAW_GAIN = 1.9 const CAM_DIST = 6.4, CAM_HEIGHT = 2.5, BASE_FOV = 58, FOV_KICK_MAX = 13 const SKID_SEGS = 160, // ring-buffered quads per rear-wheel ribbon SKID_LIFE = 6, SKID_HALF_W = 0.09, SKID_SLIP_MIN = 1.6 const SMOKE_MAX = 384 // pooled GPU point cloud, animated entirely in-shader const DRIVE_KEYS = new Set(['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']) // Deterministic RNG so the playground layout is stable across reloads. function mulberry32(seed: number) { return () => { seed |= 0 seed = (seed + 0x6d2b79f5) | 0 let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } // Drift smoke: spawn data lives in attributes; the shader ages, rises, // expands and fades each puff. Dead particles collapse to zero size. const DriftSmokeMaterial = shaderMaterial( { uTime: 0, uPixelRatio: 1, uColor: new THREE.Color('#d4d4d1') }, /* glsl */ ` uniform float uTime; uniform float uPixelRatio; attribute vec3 aVelocity; attribute float aBirth; attribute float aSeed; varying float vT; varying float vSeed; void main() { float age = uTime - aBirth; float life = mix(0.5, 1.1, aSeed); float t = clamp(age / life, 0.0, 1.0); float alive = step(0.0, age) * (1.0 - step(life, age)); vec3 pos = position + aVelocity * age; pos.y += 0.8 * age + 0.4 * age * age; // buoyant rise pos.x += sin(aSeed * 37.0 + age * 2.5) * 0.15 * age; pos.z += cos(aSeed * 41.0 + age * 2.0) * 0.15 * age; vT = t; vSeed = aSeed; vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); float sizeCurve = 0.35 + t * 2.2; // puffs expand as they dissipate gl_PointSize = sizeCurve * alive * uPixelRatio * 220.0 / max(-mvPosition.z, 0.1); gl_Position = projectionMatrix * mvPosition; } `, /* glsl */ ` uniform vec3 uColor; varying float vT; varying float vSeed; void main() { // smoothstep edges stay ascending — reversed edges are undefined in // GLSL and return NaN on Metal, wiping out every particle. float d = length(gl_PointCoord - 0.5); float disc = 1.0 - smoothstep(0.15, 0.5, d); float alpha = disc * 0.3 * smoothstep(0.0, 0.15, vT) * (1.0 - smoothstep(0.45, 1.0, vT)); if (alpha < 0.004) discard; gl_FragColor = vec4(mix(uColor * 0.7, uColor, vSeed), alpha); } ` ) extend({ DriftSmokeMaterial }) declare global { namespace JSX { interface IntrinsicElements { driftSmokeMaterial: any } } } function CarRig({ carColor, acceleration, topSpeed, driftFactor, cameraLag, fovKick, smoke, skidmarks }: Required) { const body = useRef(null) const tilt = useRef(null) const tailMat = useRef(null) const { camera, gl } = useThree() const { world, rapier } = useRapier() const keys = useRef(new Set()) const handbrake = useRef(false) const steerAngle = useRef(0), yawRate = useRef(0), accelSm = useRef(0), camRoll = useRef(0) const firstFrame = useRef(true) const time = useRef(0) const wheels = useMemo( () => [ { ox: WHEEL_X, oz: WHEEL_Z, front: true }, { ox: -WHEEL_X, oz: WHEEL_Z, front: true }, { ox: WHEEL_X, oz: -WHEEL_Z, front: false }, { ox: -WHEEL_X, oz: -WHEEL_Z, front: false }, ], [] ) // Per-wheel visual state (suspension height + spin) and mesh refs. const wheelState = useRef(wheels.map(() => ({ y: -SUSP_REST, spin: 0 }))) const wheelGroups = useRef<(THREE.Group | null)[]>([]) const steerGroups = useRef<(THREE.Group | null)[]>([]) const spinGroups = useRef<(THREE.Group | null)[]>([]) const scratch = useRef({ q: new THREE.Quaternion(), fwd: new THREE.Vector3(), right: new THREE.Vector3(), vf: new THREE.Vector3(), vr: new THREE.Vector3(), origin: new THREE.Vector3(), contact: new THREE.Vector3(), impulse: new THREE.Vector3(), camTarget: new THREE.Vector3(), look: new THREE.Vector3(), ray: null as InstanceType | null, }) // Smoke pool: ring buffer of preallocated attributes, recycled in place. const smokePool = useMemo( () => ({ pos: new Float32Array(SMOKE_MAX * 3), vel: new Float32Array(SMOKE_MAX * 3), birth: new Float32Array(SMOKE_MAX).fill(-100), seed: Float32Array.from({ length: SMOKE_MAX }, () => Math.random()), cursor: 0, dirty: false, }), [] ) const smokeGeom = useRef(null) const smokeMat = useRef(null) const spawnSmoke = (x: number, y: number, z: number, vx: number, vy: number, vz: number) => { const p = smokePool, i = p.cursor p.cursor = (p.cursor + 1) % SMOKE_MAX p.pos.set([x, y, z], i * 3) p.vel.set([vx + (Math.random() - 0.5) * 0.8, vy + Math.random() * 0.5, vz + (Math.random() - 0.5) * 0.8], i * 3) p.birth[i] = time.current p.dirty = true } // Skid ribbons: one per rear wheel. Each is a fixed pool of quads; every // quad fades out over SKID_LIFE seconds and is then recycled. const skids = useMemo( () => [0, 1].map(() => ({ positions: new Float32Array(SKID_SEGS * 6 * 3), colors: new Float32Array(SKID_SEGS * 6 * 4), base: new Float32Array(SKID_SEGS), age: new Float32Array(SKID_SEGS), head: 0, hasPrev: false, prev: new THREE.Vector3(), })), [] ) const skidGeoms = useRef<(THREE.BufferGeometry | null)[]>([]) const addSkidQuad = (sk: (typeof skids)[number], si: number, p: THREE.Vector3, right: THREE.Vector3, k: number) => { // Skip micro-segments; long gaps (respawns) start a fresh strip. const d2 = sk.prev.distanceToSquared(p) if (!sk.hasPrev || d2 < 0.002 || d2 > 4) { sk.prev.copy(p) sk.hasPrev = true return } const i = sk.head, w = SKID_HALF_W sk.head = (sk.head + 1) % SKID_SEGS sk.base[i] = k sk.age[i] = 0 // Two triangles (a,b,c)(b,d,c), ribbon width along the chassis right. // prettier-ignore sk.positions.set([ sk.prev.x - right.x * w, sk.prev.y, sk.prev.z - right.z * w, sk.prev.x + right.x * w, sk.prev.y, sk.prev.z + right.z * w, p.x - right.x * w, p.y, p.z - right.z * w, sk.prev.x + right.x * w, sk.prev.y, sk.prev.z + right.z * w, p.x + right.x * w, p.y, p.z + right.z * w, p.x - right.x * w, p.y, p.z - right.z * w, ], i * 18) // Marks read as worn rubber streaks: slightly lighter than the near-black // ground (pure dark-on-dark is invisible on this playground). for (let v = 0; v < 6; v++) sk.colors.set([0.3, 0.3, 0.31, k], (i * 6 + v) * 4) sk.prev.copy(p) const g = skidGeoms.current[si] if (g) { ;(g.attributes.position as THREE.BufferAttribute).needsUpdate = true ;(g.attributes.color as THREE.BufferAttribute).needsUpdate = true } } // Keyboard input (window listeners, client-only). useEffect(() => { const pressed = keys.current const onKeyDown = (e: KeyboardEvent) => { if (e.code === 'Space') handbrake.current = true else if (DRIVE_KEYS.has(e.code)) pressed.add(e.code) else return e.preventDefault() } const onKeyUp = (e: KeyboardEvent) => { if (e.code === 'Space') handbrake.current = false pressed.delete(e.code) } const onBlur = () => { pressed.clear() handbrake.current = false } window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) window.addEventListener('blur', onBlur) return () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) window.removeEventListener('blur', onBlur) } }, []) useFrame((_, delta) => { const rb = body.current if (!rb) return const dt = Math.min(delta, 1 / 30) time.current += dt const s = scratch.current if (!s.ray) s.ray = new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }) const pos = rb.translation(), vel = rb.linvel() const m = Math.max(rb.mass(), 0.001) const drift = THREE.MathUtils.clamp(driftFactor, 0, 1) // Respawn if the car falls off the playground. if (pos.y < -8) { rb.setTranslation({ x: 0, y: 1.2, z: 0 }, true) rb.setLinvel({ x: 0, y: 0, z: 0 }, true) rb.setAngvel({ x: 0, y: 0, z: 0 }, true) rb.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true) return } // Chassis frame on the ground plane (pitch/roll are physics-locked). const q = rb.rotation() s.q.set(q.x, q.y, q.z, q.w) s.fwd.set(0, 0, 1).applyQuaternion(s.q) s.fwd.y = 0 if (s.fwd.lengthSq() < 1e-6) s.fwd.set(0, 0, 1) s.fwd.normalize() s.right.set(s.fwd.z, 0, -s.fwd.x) // up x fwd const vLon = vel.x * s.fwd.x + vel.z * s.fwd.z, vLat = vel.x * s.right.x + vel.z * s.right.z, speed = Math.hypot(vel.x, vel.z), omega = rb.angvel().y // --- input --- const pressed = keys.current const steerInput = (pressed.has('KeyD') || pressed.has('ArrowRight') ? 1 : 0) - (pressed.has('KeyA') || pressed.has('ArrowLeft') ? 1 : 0) const throttle = (pressed.has('KeyW') || pressed.has('ArrowUp') ? 1 : 0) - (pressed.has('KeyS') || pressed.has('ArrowDown') ? 1 : 0) const hb = handbrake.current, braking = throttle < 0 && vLon > 0.5 // Steering: less lock at speed, snappy both ways (fast counter-steer). const maxSteer = MAX_STEER / (1 + speed * 0.055) steerAngle.current = THREE.MathUtils.damp(steerAngle.current, steerInput * maxSteer, STEER_SMOOTHING, dt) // Longitudinal engine/brake force (m/s^2, multiplied by mass later). let engine = 0 if (throttle > 0) engine = vLon < topSpeed ? acceleration : 0 else if (throttle < 0) engine = vLon > 0.5 ? -BRAKE_DECEL : vLon > -topSpeed * 0.35 ? -acceleration * 0.6 : 0 engine += -vLon * (throttle === 0 ? 0.9 : 0.12) // engine braking + drag // Rear grip is the drift knob; the handbrake collapses it. const rearGrip = THREE.MathUtils.lerp(9, 5.2, drift) * (hb ? THREE.MathUtils.lerp(0.32, 0.06, drift) : 1) // --- per-wheel raycast suspension + tire forces --- s.impulse.set(0, 0, 0) let groundedCount = 0 const steer = steerAngle.current for (let i = 0; i < 4; i++) { const w = wheels[i] s.origin.set(pos.x + s.right.x * w.ox + s.fwd.x * w.oz, pos.y + RAY_ORIGIN_Y, pos.z + s.right.z * w.ox + s.fwd.z * w.oz) s.ray.origin = s.origin const hit = world.castRay(s.ray, RAY_LEN, true, undefined, undefined, undefined, rb) const ws = wheelState.current[i] if (!hit) { ws.y = THREE.MathUtils.damp(ws.y, -(SUSP_REST + 0.08), 10, dt) // droop in air continue } groundedCount++ const toi = hit.timeOfImpact // Suspension spring/damper along world up. const spring = Math.max(0, (RAY_LEN - toi) * SPRING_K - vel.y * SPRING_D) s.impulse.y += Math.min(spring, 30) // Wheel axes (front wheels rotated by the steer angle). const a = w.front ? steer : 0, ca = Math.cos(a), sa = Math.sin(a) s.vf.set(s.fwd.x * ca + s.right.x * sa, 0, s.fwd.z * ca + s.right.z * sa) s.vr.set(s.right.x * ca - s.fwd.x * sa, 0, s.right.z * ca - s.fwd.z * sa) // Velocity at the contact patch includes yaw rotation (w x r). const pvx = vel.x + omega * w.oz, pvz = vel.z - omega * w.ox, wLon = pvx * s.vf.x + pvz * s.vf.z, wLat = pvx * s.vr.x + pvz * s.vr.z // Longitudinal: RWD engine, braking bias, handbrake locks the rears. let fLon = 0 if (!w.front) fLon += engine * 0.5 if (braking) fLon += THREE.MathUtils.clamp(-wLon * 8, -BRAKE_DECEL, BRAKE_DECEL) * (w.front ? 0.35 : 0.15) if (hb && !w.front) fLon += THREE.MathUtils.clamp(-wLon * 12, -BRAKE_DECEL, BRAKE_DECEL) * 0.5 // Lateral grip: kills sideways slip, split front/rear, friction circle. let fLat = -wLat * (w.front ? FRONT_GRIP : rearGrip) const fLen = Math.hypot(fLon, fLat) if (fLen > MAX_TIRE) { fLon *= MAX_TIRE / fLen fLat *= MAX_TIRE / fLen } s.impulse.x += s.vf.x * fLon + s.vr.x * fLat s.impulse.z += s.vf.z * fLon + s.vr.z * fLat // Contact point for fx and wheel placement. s.contact.set(s.origin.x, s.origin.y - toi, s.origin.z) ws.y = THREE.MathUtils.damp(ws.y, RAY_ORIGIN_Y - toi + WHEEL_RADIUS, 22, dt) ws.spin += (wLon / WHEEL_RADIUS) * dt // Rear wheels: skid marks + smoke when sliding. if (!w.front) { const sliding = Math.abs(wLat) > SKID_SLIP_MIN && speed > 2 if (skidmarks && sliding) { const k = THREE.MathUtils.clamp(Math.abs(wLat) / 7, 0.35, 1) // Lay quads above the drei Grid plane (y=0.02), which writes depth — // coplanar marks are rejected by the depth test and never show. addSkidQuad(skids[i - 2], i - 2, s.contact.setY(s.contact.y + 0.04), s.right, k) } else skids[i - 2].hasPrev = false if (smoke && sliding) spawnSmoke(s.contact.x, s.contact.y + 0.05, s.contact.z, -s.vf.x * wLon * 0.06 + s.vr.x * wLat * 0.15, 0.4, -s.vf.z * wLon * 0.06 + s.vr.z * wLat * 0.15) } } // Accumulated tire+suspension force as one impulse at the COM. rb.applyImpulse({ x: s.impulse.x * m * dt, y: s.impulse.y * m * dt, z: s.impulse.z * m * dt }, true) // Yaw: manual angular velocity gives the arcade-direct steering feel. // Handbrake gets extra yaw authority so the rear steps out on demand. const dir = vLon >= -0.5 ? 1 : -1, speedFac = THREE.MathUtils.clamp(Math.abs(vLon) / 5, 0, 1) * dir, yawTarget = groundedCount > 0 ? steer * speedFac * YAW_GAIN * (hb ? 2.0 : 1) : 0 yawRate.current = THREE.MathUtils.damp(yawRate.current, yawTarget, 10, dt) rb.setAngvel({ x: 0, y: yawRate.current, z: 0 }, true) // --- visuals --- // Fake body roll/pitch (physics rotations are locked, so lean is visual). accelSm.current = THREE.MathUtils.damp(accelSm.current, engine, 6, dt) if (tilt.current) { const roll = THREE.MathUtils.clamp(yawRate.current * speed * 0.004, -0.12, 0.12), pitch = THREE.MathUtils.clamp(-accelSm.current * 0.004, -0.07, 0.09) tilt.current.rotation.z = THREE.MathUtils.damp(tilt.current.rotation.z, roll, 8, dt) tilt.current.rotation.x = THREE.MathUtils.damp(tilt.current.rotation.x, pitch, 8, dt) } for (let i = 0; i < 4; i++) { const g = wheelGroups.current[i] if (g) g.position.y = wheelState.current[i].y const sg = spinGroups.current[i] if (sg) sg.rotation.x = wheelState.current[i].spin const st = steerGroups.current[i] if (st && wheels[i].front) st.rotation.y = steer } // Taillights flare under braking / handbrake. if (tailMat.current) { tailMat.current.emissiveIntensity = THREE.MathUtils.damp(tailMat.current.emissiveIntensity, braking || hb ? 5 : 1.8, 12, dt) } // Flush smoke pool uploads once per frame. if (smokePool.dirty && smokeGeom.current) { const g = smokeGeom.current ;(g.attributes.position as THREE.BufferAttribute).needsUpdate = true ;(g.attributes.aVelocity as THREE.BufferAttribute).needsUpdate = true ;(g.attributes.aBirth as THREE.BufferAttribute).needsUpdate = true smokePool.dirty = false } if (smokeMat.current) { smokeMat.current.uTime = time.current smokeMat.current.uPixelRatio = gl.getPixelRatio() } // Fade skid quads; dead ones are recycled by the ring buffer. for (let si = 0; si < 2; si++) { const sk = skids[si], g = skidGeoms.current[si] let dirty = false for (let i = 0; i < SKID_SEGS; i++) { if (sk.base[i] <= 0) continue sk.age[i] += dt let alpha = sk.base[i] * (1 - sk.age[i] / SKID_LIFE) if (alpha <= 0.004) { alpha = 0; sk.base[i] = 0 } for (let v = 0; v < 6; v++) sk.colors[(i * 6 + v) * 4 + 3] = alpha dirty = true } if (dirty && g) (g.attributes.color as THREE.BufferAttribute).needsUpdate = true } // --- chase camera: lagged position, velocity look-ahead, drift roll --- s.camTarget.set( pos.x - s.fwd.x * (CAM_DIST + speed * 0.05), pos.y + CAM_HEIGHT + speed * 0.012, pos.z - s.fwd.z * (CAM_DIST + speed * 0.05) ) if (firstFrame.current) { camera.position.copy(s.camTarget) firstFrame.current = false } else { camera.position.set( THREE.MathUtils.damp(camera.position.x, s.camTarget.x, cameraLag, dt), THREE.MathUtils.damp(camera.position.y, s.camTarget.y, cameraLag, dt), THREE.MathUtils.damp(camera.position.z, s.camTarget.z, cameraLag, dt) ) } const lookK = Math.min(speed * 0.28, 4) / 4 s.look.set(pos.x + vel.x * 0.28 * lookK, pos.y + 0.9, pos.z + vel.z * 0.28 * lookK) camera.lookAt(s.look) // Roll into drifts, proportional to the slip angle. const slipAngle = Math.atan2(vLat, Math.max(Math.abs(vLon), 3)) camRoll.current = THREE.MathUtils.damp(camRoll.current, THREE.MathUtils.clamp(slipAngle * 0.3, -0.12, 0.12), 6, dt) camera.rotateZ(camRoll.current) // FOV kick with speed. if (fovKick && 'fov' in camera) { const pc = camera as THREE.PerspectiveCamera pc.fov = THREE.MathUtils.damp(pc.fov, BASE_FOV + THREE.MathUtils.clamp(speed / Math.max(topSpeed, 0.001), 0, 1) * FOV_KICK_MAX, 5, dt) pc.updateProjectionMatrix() } }) const darkMat = return ( <> {/* Rounded body shell + dark glass cabin. */} {/* Spoiler + struts, taillight bar (flares on brake), headlights. */} {darkMat} {[0.35, -0.35].map((x) => ( {darkMat} ))} {[0.38, -0.38].map((x) => ( ))} {/* Underglow strip + accent light. */} {/* Wheels: outer group tracks suspension, mid steers, inner spins. */} {wheels.map((w, i) => ( { wheelGroups.current[i] = g }}> { steerGroups.current[i] = g }}> { spinGroups.current[i] = g }}> ))} {/* Pooled GPU drift smoke. */} {/* Fade-out skid-mark ribbons (one per rear wheel). */} {skids.map((sk, i) => ( { skidGeoms.current[i] = g }}> {/* DoubleSide: the ribbon winding faces down, and ground decals are only ever viewed from above — culling would hide them. */} ))} ) } // Traffic cone: dynamic rigid body, scatters when clipped. function Cone({ position }: { position: [number, number, number] }) { return ( ) } // Fixed ramp: tilted box, drivable via the same raycast suspension. function Ramp({ position, yaw }: { position: [number, number, number]; yaw: number }) { return ( ) } function Playground() { // Deterministic cone scatter: a slalom ahead of spawn plus a loose ring. const cones = useMemo(() => { const rng = mulberry32(1337) const list: [number, number, number][] = [] for (let i = 0; i < 6; i++) list.push([(i % 2 === 0 ? -1 : 1) * (3 + rng() * 1.5), 0.28, 8 + i * 4]) for (let i = 0; i < 6; i++) { const a = rng() * Math.PI * 2 const r = 16 + rng() * 10 list.push([Math.cos(a) * r, 0.28, Math.sin(a) * r]) } return list }, []) return ( <> {/* Ground: physics slab + dark plane + faint grid overlay. */} {cones.map((p, i) => ( ))} ) } export function DriftCar({ carColor = '#a3e635', acceleration = 18, topSpeed = 26, driftFactor = 0.6, cameraLag = 6, fovKick = true, smoke = true, skidmarks = true, }: DriftCarProps) { return ( <> {/* Self-contained lighting so the car reads on a bare dark canvas. */} ) } ``` ## CLI - `npx facet3d init`: set up the components directory in your project - `npx facet3d add `: copy a component into your project as source - `npx facet3d list`: list all available components - `npx facet3d docs `: print usage docs for a component ## MCP server AI agents can also use the `@facet3d/mcp` MCP server (tools: facet_list, facet_docs, facet_source, facet_add). Config: {"mcpServers": {"facet": {"command": "npx", "args": ["-y", "@facet3d/mcp"]}}}