// hero.jsx — Three.js hero with user-supplied GLB bottle. // Loads /assets/bottle.glb, strips text/label geometry, applies a tuned glass material // so surface details (curves, facets) read clearly, and rotates slowly. const { useEffect, useRef } = React; // Build procedural studio environment via PMREM for crisp glass reflections. function buildEnvironment(THREE, renderer) { const envScene = new THREE.Scene(); const bgGeo = new THREE.SphereGeometry(50, 64, 64); const bgMat = new THREE.ShaderMaterial({ side: THREE.BackSide, vertexShader: `varying vec3 vN; void main(){ vN = normalize(position); gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);} `, fragmentShader: `varying vec3 vN; void main(){ float t = clamp(vN.y * 0.5 + 0.5, 0.0, 1.0); vec3 top = vec3(0.55, 0.45, 0.30); vec3 bot = vec3(0.06, 0.05, 0.04); vec3 col = mix(bot, top, smoothstep(0.0, 1.0, t)); gl_FragColor = vec4(col, 1.0); }` }); envScene.add(new THREE.Mesh(bgGeo, bgMat)); const addSoftbox = (w, h, color, pos) => { const m = new THREE.Mesh( new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color }) ); m.position.set(...pos); m.lookAt(0, 0, 0); envScene.add(m); }; // Warm key softbox so metal reads as bronze/gunmetal, not silver. addSoftbox(8, 12, 0xffd9a8, [6, 4, 4]); // warm amber key addSoftbox(6, 8, 0xb8a888, [-5, 3, 3]); // warm fill (desaturated bronze) addSoftbox(6, 8, 0xfff2dc, [0, 4, -6]); // warm rim addSoftbox(4, 4, 0xa07a4a, [3, -2, 2]); // bronze underglow const pmrem = new THREE.PMREMGenerator(renderer); pmrem.compileEquirectangularShader(); const target = pmrem.fromScene(envScene, 0.04); pmrem.dispose(); return target.texture; } // Heuristic: which meshes are "text/label" decals? They are usually: // - very thin / flat in one axis (a decal slab) // - small relative to the body // - or a node whose name contains "text", "label", "logo", "decal" function isLabelMesh(mesh, bodyBox) { const name = (mesh.name || "").toLowerCase(); if (/text|label|logo|decal|sticker|brand|writing|inscription|engrav/.test(name)) return true; // also check material/texture name const mat = mesh.material; if (mat) { const mname = (mat.name || "").toLowerCase(); if (/text|label|logo|decal/.test(mname)) return true; } // geometry-based heuristic: very thin slab much smaller than body if (!mesh.geometry) return false; if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox(); const bb = mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld); const sx = bb.max.x - bb.min.x; const sy = bb.max.y - bb.min.y; const sz = bb.max.z - bb.min.z; const minDim = Math.min(sx, sy, sz); const maxDim = Math.max(sx, sy, sz); const bodySize = Math.max(bodyBox.max.x - bodyBox.min.x, bodyBox.max.y - bodyBox.min.y, bodyBox.max.z - bodyBox.min.z); // Very flat AND much smaller than body → likely a decal if (minDim < bodySize * 0.012 && maxDim < bodySize * 0.45) return true; return false; } function HeroBottle({ accent, rotationSpeed }) { const ref = useRef(null); const stateRef = useRef({}); useEffect(() => { const THREE = window.THREE; if (!THREE) return; const mount = ref.current; const isMobile = window.innerWidth < 768; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); if (THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace; else renderer.outputEncoding = THREE.sRGBEncoding; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.85; renderer.setSize(mount.clientWidth, mount.clientHeight); mount.appendChild(renderer.domElement); renderer.domElement.style.display = "block"; renderer.domElement.style.width = "100%"; renderer.domElement.style.height = "100%"; const scene = new THREE.Scene(); scene.background = null; scene.environment = buildEnvironment(THREE, renderer); // lights — kept low; metal needs deep mid-tones with punchy specular highlights, // not uniform brightness. scene.add(new THREE.AmbientLight(0xffffff, 0.04)); scene.add(new THREE.HemisphereLight(0x554433, 0x080604, 0.10)); const key = new THREE.SpotLight(0xfff0d8, 30, 30, Math.PI/5, 0.4, 1.5); key.position.set(4, 6, 4); scene.add(key); const fill = new THREE.SpotLight(0xc8b89a, 8, 30, Math.PI/4, 0.4, 1.5); fill.position.set(-3.5, 4, 3); scene.add(fill); const rim = new THREE.SpotLight(0xffe8c0, 25, 30, Math.PI/5, 0.4, 1.5); rim.position.set(-2, 4, -5); scene.add(rim); const accentLight = new THREE.PointLight(new THREE.Color(accent), 3, 6, 2); accentLight.position.set(1.2, 0.6, 1.2); scene.add(accentLight); const accentRim = new THREE.SpotLight(new THREE.Color(accent), 14, 18, Math.PI/4, 0.6, 1.4); accentRim.position.set(-3, -1, -3); scene.add(accentRim); const bottleGroup = new THREE.Group(); scene.add(bottleGroup); // shadow disc const shadowMat = new THREE.ShaderMaterial({ transparent: true, depthWrite: false, vertexShader: `varying vec2 vUv; void main(){ vUv=uv; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);} `, fragmentShader: `varying vec2 vUv; void main(){ float d = length(vUv - 0.5); float a = smoothstep(0.5, 0.0, d) * 0.55; gl_FragColor = vec4(0.0, 0.0, 0.0, a); }` }); const shadow = new THREE.Mesh(new THREE.CircleGeometry(1.4, 64), shadowMat); shadow.rotation.x = -Math.PI/2; shadow.position.y = -0.42; scene.add(shadow); // camera const camera = new THREE.PerspectiveCamera(28, mount.clientWidth/mount.clientHeight, 0.1, 100); camera.position.set(0, 1.4, 6); camera.lookAt(0, 0.85, 0); // Bronze-silver alloy: cooler, more neutral than pure bronze const glassMat = new THREE.MeshPhysicalMaterial({ color: 0x6a6358, // bronze-silver: warm-neutral metal base metalness: 0.92, roughness: 0.20, // crisper specular — picks up surface relief ior: 1.46, clearcoat: 0.5, clearcoatRoughness: 0.10, envMapIntensity: 0.85, side: THREE.FrontSide, }); const capMat = new THREE.MeshPhysicalMaterial({ color: 0x18181c, metalness: 0.95, roughness: 0.22, clearcoat: 0.5, }); // --- load GLB --- const loader = new THREE.GLTFLoader(); loader.load("assets/bottle.glb", (gltf) => { const root = gltf.scene; // First pass: world bbox of everything (to size the body reference) root.updateMatrixWorld(true); const fullBox = new THREE.Box3().setFromObject(root); // Identify and strip label/text meshes; reapply material to remaining meshes. const toRemove = []; const meshes = []; root.traverse((node) => { if (node.isMesh) meshes.push(node); }); meshes.forEach((node) => { node.updateMatrixWorld(true); if (isLabelMesh(node, fullBox)) { toRemove.push(node); return; } node.geometry.computeBoundingBox(); const bb = node.geometry.boundingBox.clone().applyMatrix4(node.matrixWorld); const cy = (bb.min.y + bb.max.y) * 0.5; const fullH = fullBox.max.y - fullBox.min.y; // If there are multiple meshes and this one is in the top 18% only, treat as cap. // For single-mesh GLBs, never cap (cy>min+h*0.82 is always false for a full bottle). const isCap = meshes.length > 1 && cy > fullBox.min.y + fullH * 0.82; // Smooth normals — picks up curvature highlights. node.geometry.computeVertexNormals(); node.material = isCap ? capMat : glassMat; node.castShadow = true; node.receiveShadow = false; }); toRemove.forEach((m) => { if (m.parent) m.parent.remove(m); if (m.geometry) m.geometry.dispose(); }); // Center + scale the model into a known frame const box = new THREE.Box3().setFromObject(root); const size = new THREE.Vector3(); box.getSize(size); const center = new THREE.Vector3(); box.getCenter(center); // scale so height ≈ 1.89 units (10% smaller than 2.1) const targetH = 1.89; const scale = targetH / Math.max(size.y, 0.0001); root.scale.setScalar(scale); // recompute box after scale root.updateMatrixWorld(true); const box2 = new THREE.Box3().setFromObject(root); const center2 = new THREE.Vector3(); box2.getCenter(center2); // Place so the bottle base sits at y = -0.4 root.position.x -= center2.x; root.position.z -= center2.z; root.position.y -= box2.min.y + 0.4; bottleGroup.add(root); stateRef.current.loaded = true; }, undefined, (err) => { console.error("GLB load failed", err); }); stateRef.current = { ...stateRef.current, renderer, scene, camera, bottleGroup, accentLight, accentRim, mount, glassMat, capMat, hover: 0, tiltX: 0, tiltY: 0, curTiltX: 0, curTiltY: 0, rotSpeed: rotationSpeed, accent, mounted: true, }; const onResize = () => { const w = mount.clientWidth, h = mount.clientHeight; renderer.setSize(w, h); camera.aspect = w/h; camera.updateProjectionMatrix(); }; window.addEventListener("resize", onResize); const onMove = (e) => { const rect = mount.getBoundingClientRect(); const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1; const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1; stateRef.current.tiltX = nx * 0.10; stateRef.current.tiltY = -ny * 0.06; stateRef.current.hover = 1; }; const onLeave = () => { stateRef.current.tiltX = 0; stateRef.current.tiltY = 0; stateRef.current.hover = 0; }; mount.addEventListener("mousemove", onMove); mount.addEventListener("mouseleave", onLeave); let raf; let last = performance.now(); const start = last; const loop = (now) => { const dt = Math.min(0.05, (now - last) / 1000); last = now; const t = (now - start) / 1000; const speedFactor = 1 - 0.5 * stateRef.current.hover; bottleGroup.rotation.y += stateRef.current.rotSpeed * 0.3 * speedFactor * dt * 6; stateRef.current.curTiltX += (stateRef.current.tiltX - stateRef.current.curTiltX) * 0.08; stateRef.current.curTiltY += (stateRef.current.tiltY - stateRef.current.curTiltY) * 0.08; bottleGroup.rotation.z = stateRef.current.curTiltX; bottleGroup.rotation.x = stateRef.current.curTiltY; camera.position.y = 1.4 + Math.sin(t * 0.5) * 0.02; camera.lookAt(0, 0.85, 0); renderer.render(scene, camera); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => { stateRef.current.mounted = false; cancelAnimationFrame(raf); window.removeEventListener("resize", onResize); mount.removeEventListener("mousemove", onMove); mount.removeEventListener("mouseleave", onLeave); renderer.dispose(); if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement); }; }, []); // accent → accent point light + accent rim (metal material doesn't take attenuation) useEffect(() => { const s = stateRef.current; if (!s || !s.accentLight) return; const THREE = window.THREE; s.accentLight.color = new THREE.Color(accent); if (s.accentRim) s.accentRim.color = new THREE.Color(accent); }, [accent]); useEffect(() => { if (stateRef.current) stateRef.current.rotSpeed = rotationSpeed; }, [rotationSpeed]); return
; } window.HeroBottle = HeroBottle;