From c32be8f8bfacbe380b02fc8d2aa2eee3dec3b6b1 Mon Sep 17 00:00:00 2001 From: Tusharkhadde Date: Tue, 12 May 2026 14:35:31 +0530 Subject: [PATCH 1/2] feat(home): add interactive 3D forge WebGL hero visualization --- frontend/package-lock.json | 60 +++++- frontend/package.json | 4 +- .../components/home/ForgeVisualization.tsx | 178 ++++++++++++++++++ frontend/src/components/home/HeroSection.tsx | 2 + 4 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/home/ForgeVisualization.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3a96bde2c..51f98f1e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@tailwindcss/postcss": "^4.2.2", "@tanstack/react-query": "^5.91.3", + "@types/three": "^0.184.1", "autoprefixer": "^10.4.27", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", @@ -20,7 +21,8 @@ "recharts": "^3.8.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "three": "^0.184.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.5", @@ -502,6 +504,12 @@ "node": ">=18" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1829,6 +1837,12 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2063,6 +2077,26 @@ "@types/react": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2075,6 +2109,12 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3025,6 +3065,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -4219,6 +4265,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -5656,6 +5708,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f3f83792a..c01e0c681 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "dependencies": { "@tailwindcss/postcss": "^4.2.2", "@tanstack/react-query": "^5.91.3", + "@types/three": "^0.184.1", "autoprefixer": "^10.4.27", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", @@ -22,7 +23,8 @@ "recharts": "^3.8.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "three": "^0.184.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.5", diff --git a/frontend/src/components/home/ForgeVisualization.tsx b/frontend/src/components/home/ForgeVisualization.tsx new file mode 100644 index 000000000..3122a6439 --- /dev/null +++ b/frontend/src/components/home/ForgeVisualization.tsx @@ -0,0 +1,178 @@ +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; + +export function ForgeVisualization() { + const mountRef = useRef(null); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + + const scene = new THREE.Scene(); + scene.fog = new THREE.Fog(0x02030a, 4, 18); + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(mount.clientWidth, mount.clientHeight); + renderer.outputColorSpace = THREE.SRGBColorSpace; + mount.appendChild(renderer.domElement); + + const camera = new THREE.PerspectiveCamera(55, mount.clientWidth / mount.clientHeight, 0.1, 100); + camera.position.set(0, 1.6, 5.8); + + const ambient = new THREE.AmbientLight(0x4a6077, 0.55); + scene.add(ambient); + + const key = new THREE.DirectionalLight(0x80f5ff, 0.7); + key.position.set(2, 5, 3); + scene.add(key); + + const fireLight = new THREE.PointLight(0xff6a00, 2.2, 12, 2); + fireLight.position.set(0, 0.45, 0); + scene.add(fireLight); + + const forgeBase = new THREE.Mesh( + new THREE.CylinderGeometry(1.7, 2.1, 1.25, 32), + new THREE.MeshStandardMaterial({ color: 0x111723, roughness: 0.86, metalness: 0.15 }), + ); + forgeBase.position.y = -0.5; + scene.add(forgeBase); + + const core = new THREE.Mesh( + new THREE.TorusGeometry(0.75, 0.24, 24, 64), + new THREE.MeshStandardMaterial({ + color: 0xff7d1a, + emissive: 0xff4f00, + emissiveIntensity: 1.1, + roughness: 0.3, + metalness: 0.1, + }), + ); + core.rotation.x = Math.PI / 2; + scene.add(core); + + const anvil = new THREE.Mesh( + new THREE.BoxGeometry(1.05, 0.26, 0.5), + new THREE.MeshStandardMaterial({ color: 0x8c97a8, roughness: 0.44, metalness: 0.75 }), + ); + anvil.position.set(0, 0.42, 0); + scene.add(anvil); + + const hammerHead = new THREE.Mesh( + new THREE.BoxGeometry(0.44, 0.2, 0.25), + new THREE.MeshStandardMaterial({ color: 0xaab4c7, roughness: 0.35, metalness: 0.9 }), + ); + const hammerHandle = new THREE.Mesh( + new THREE.CylinderGeometry(0.05, 0.05, 1, 12), + new THREE.MeshStandardMaterial({ color: 0x5a3626, roughness: 0.85, metalness: 0.15 }), + ); + hammerHead.position.set(0.95, 1.05, 0.1); + hammerHandle.position.set(0.95, 0.6, 0.1); + hammerHandle.rotation.z = 0.18; + scene.add(hammerHead, hammerHandle); + + const sparkCount = 520; + const sparkPositions = new Float32Array(sparkCount * 3); + const sparkVelocities = new Float32Array(sparkCount * 3); + for (let i = 0; i < sparkCount; i += 1) { + const j = i * 3; + sparkPositions[j] = 0; + sparkPositions[j + 1] = 0.35; + sparkPositions[j + 2] = 0; + } + const sparkGeo = new THREE.BufferGeometry(); + sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPositions, 3)); + const sparks = new THREE.Points( + sparkGeo, + new THREE.PointsMaterial({ color: 0xffb347, size: 0.04, transparent: true, opacity: 0.85 }), + ); + scene.add(sparks); + + let lastForge = 0; + const forgeIntervalMs = 2200; + const clock = new THREE.Clock(); + + const triggerForge = () => { + fireLight.intensity = 3.7; + for (let i = 0; i < sparkCount; i += 1) { + const j = i * 3; + const angle = Math.random() * Math.PI * 2; + const speed = 0.9 + Math.random() * 2.4; + sparkPositions[j] = (Math.random() - 0.5) * 0.25; + sparkPositions[j + 1] = 0.4 + Math.random() * 0.16; + sparkPositions[j + 2] = (Math.random() - 0.5) * 0.25; + sparkVelocities[j] = Math.cos(angle) * speed * 0.4; + sparkVelocities[j + 1] = 1.5 + Math.random() * 2.2; + sparkVelocities[j + 2] = Math.sin(angle) * speed * 0.4; + } + sparkGeo.attributes.position.needsUpdate = true; + }; + + const animate = () => { + const elapsed = clock.getElapsedTime(); + const delta = Math.min(clock.getDelta(), 0.03); + + if (performance.now() - lastForge > forgeIntervalMs) { + lastForge = performance.now(); + triggerForge(); + } + + core.rotation.z += delta * 0.7; + core.scale.setScalar(1 + Math.sin(elapsed * 3.4) * 0.03); + + const hammerPhase = (elapsed * 2.1) % 1; + const strikeCurve = Math.sin(hammerPhase * Math.PI); + hammerHead.position.y = 1.05 - strikeCurve * 0.54; + hammerHandle.position.y = 0.6 - strikeCurve * 0.5; + hammerHead.rotation.z = -strikeCurve * 0.18; + hammerHandle.rotation.z = 0.18 - strikeCurve * 0.2; + + fireLight.intensity = 2.2 + Math.sin(elapsed * 6) * 0.35 + Math.max(0, 0.7 - hammerPhase * 0.7); + + for (let i = 0; i < sparkCount; i += 1) { + const j = i * 3; + sparkPositions[j] += sparkVelocities[j] * delta; + sparkPositions[j + 1] += sparkVelocities[j + 1] * delta; + sparkPositions[j + 2] += sparkVelocities[j + 2] * delta; + sparkVelocities[j + 1] -= 4.2 * delta; + sparkVelocities[j] *= 0.986; + sparkVelocities[j + 2] *= 0.986; + if (sparkPositions[j + 1] < -0.2) { + sparkPositions[j] = 0; + sparkPositions[j + 1] = 0.25; + sparkPositions[j + 2] = 0; + sparkVelocities[j] = 0; + sparkVelocities[j + 1] = 0; + sparkVelocities[j + 2] = 0; + } + } + sparkGeo.attributes.position.needsUpdate = true; + + camera.position.x = Math.sin(elapsed * 0.24) * 0.85; + camera.lookAt(0, 0.35, 0); + + renderer.render(scene, camera); + frame = requestAnimationFrame(animate); + }; + + let frame = requestAnimationFrame(animate); + + const onResize = () => { + if (!mount) return; + camera.aspect = mount.clientWidth / mount.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(mount.clientWidth, mount.clientHeight); + }; + window.addEventListener('resize', onResize); + + return () => { + cancelAnimationFrame(frame); + window.removeEventListener('resize', onResize); + renderer.dispose(); + sparkGeo.dispose(); + mount.removeChild(renderer.domElement); + }; + }, []); + + return