feat(how-it-works): integrate scroll-driven 3D experience

Migrate the standalone Vite + React Three Fiber experience into the existing
Next.js site as the body of the How It Works page, replacing the Miles3 /
WhyChooseDoormile / TheDoormileWay content sections while preserving the
Elementor hero, global Header/Footer, layout, routing and SEO.

- New self-contained module: src/modules/how-it-works-3d/ (R3F scene, hooks,
  zustand store, animations, curves, constants, utils, scoped CSS). App.jsx →
  Experience3D.jsx; 3d_scene.jsx → models/Scene3D.jsx.
- 32MB GLB moved to public/models/3d_scene_final.glb; useGLTF paths updated.
- Client-only entry via dynamic ssr:false loader (Experience3DLoader).
- Self-managed fixed pin (tall section + absolute stage toggled
  absolute(top)→fixed→absolute(bottom) from ScrollTrigger pin state), mirroring
  the site's StrategySection, since the fixed header + ancestor overflow:hidden
  break CSS sticky / GSAP pin.
- experience.css fully scoped under .dm-hiw-3d to avoid colliding with the
  site's Elementor CSS.
- Global Lenis disabled on /how-it-works; module runs its own tuned Lenis;
  jump-to-section scroll math made spacer-relative.
- Added zustand + maath; ESLint-ignored the ported module.

Rendering fixes (root causes found by driving headless Chrome):
- Bump three 0.171 → 0.184 to match @react-three/fiber@9.6 / drei@10.7 /
  postprocessing@6.39 (0.171 silently failed to render this GLB and caused the
  EffectComposer getContextAttributes().alpha crash). Other 3D routes verified.
- EffectComposer: Bloom + Vignette only. SSAO needs a NormalPass (v3 dropped
  the old `disableNormalPass`), and that extra full-scene pass exhausted the
  WebGL context on this heavy scene.
- Cap Canvas dpr to [1,1.5] to bound framebuffer memory on retina displays.
- Defer Canvas mount via IntersectionObserver (mountScene), matching
  StrategySection, to ease StrictMode/first-render GPU pressure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:58:34 +05:30
parent e93785f2b6
commit 3d53f82e7b
37 changed files with 13694 additions and 29 deletions

View File

@@ -0,0 +1,159 @@
import React, { useEffect, useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { useSceneStore } from '../store/useSceneStore'
import { useTruckMovement } from '../hooks/useTruckMovement'
import { animateWheels } from '../animations/wheelAnimation'
import { easing } from 'maath'
import { truckPath } from '../curves/truckPath'
export default function TruckAnimation({ truckRef, wheelRefs }) {
const scrollProgress = useSceneStore((state) => state.scrollProgress)
const activeSection = useSceneStore((state) => state.activeSection)
const setTruckProgress = useSceneStore((state) => state.setTruckProgress)
const { truckProgress } = useTruckMovement(scrollProgress)
const initialized = useRef(false)
// Sync truck progress to the global store
useEffect(() => {
setTruckProgress(truckProgress)
}, [truckProgress, setTruckProgress])
// Float trackers for 1D progress and direction detection
const dampedProgressRef = useRef(0)
const lastScrollProgressRef = useRef(0)
const isReversingRef = useRef(false)
// Tracker for smooth 180-degree yaw rotation (prevents glitches by pivoting Y rotation angle directly)
const extraRotationRef = useRef(0)
// Track wheel rotation accumulation
const accumulatedRotationRef = useRef(0)
const lastDampedProgressRef = useRef(0)
useFrame((state, delta) => {
if (!truckRef.current) return
// Detect scroll direction changes from the actual page scroll progress
const deltaScroll = scrollProgress - lastScrollProgressRef.current
if (deltaScroll < -0.0001) {
isReversingRef.current = true
} else if (deltaScroll > 0.0001) {
isReversingRef.current = false
}
lastScrollProgressRef.current = scrollProgress
// Ensure correct parent-child structure and orientation for the truck (runs reactively on re-renders)
const innerGroup = truckRef.current.children[0]
if (innerGroup && truckRef.current.children.length > 1) {
const siblings = [...truckRef.current.children].slice(1)
siblings.forEach((sibling) => {
innerGroup.attach(sibling)
})
innerGroup.rotation.set(0, -Math.PI / 2, 0)
// Disable frustum culling on all child meshes so the truck/shadow is always visible
truckRef.current.traverse((child) => {
if (child.isMesh) {
child.frustumCulled = false
child.castShadow = true
child.receiveShadow = true
}
})
}
// Run one-time state initialization for progress trackers
if (!initialized.current) {
dampedProgressRef.current = truckProgress
lastDampedProgressRef.current = truckProgress
lastScrollProgressRef.current = scrollProgress
isReversingRef.current = false
extraRotationRef.current = 0
const position = truckPath.getPoint(dampedProgressRef.current)
let lookAtTargetVector
if (dampedProgressRef.current >= 0.99) {
const tangent = truckPath.getTangent(1.0)
const endPoint = truckPath.getPoint(1.0)
lookAtTargetVector = new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
} else {
const ahead = Math.min(dampedProgressRef.current + 0.01, 1.0)
lookAtTargetVector = truckPath.getPoint(ahead)
}
truckRef.current.position.copy(position)
if (truckRef.current.position.distanceToSquared(lookAtTargetVector) > 0.0001) {
truckRef.current.lookAt(lookAtTargetVector)
}
initialized.current = true
}
// Smoothly damp the 1D progress scalar along the curve path
easing.damp(dampedProgressRef, 'current', truckProgress, 0.30, delta)
// Evaluate the 3D position and orientation directly on the spline curve
const position = truckPath.getPoint(dampedProgressRef.current)
let lookAtTargetVector
if (dampedProgressRef.current >= 0.99) {
const tangent = truckPath.getTangent(1.0)
const endPoint = truckPath.getPoint(1.0)
lookAtTargetVector = new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
} else {
const ahead = Math.min(dampedProgressRef.current + 0.01, 1.0)
lookAtTargetVector = truckPath.getPoint(ahead)
}
// Update position and base forward rotation directly (ensures 100% spline compliance, zero corner cutting)
truckRef.current.position.copy(position)
if (truckRef.current.position.distanceToSquared(lookAtTargetVector) > 0.0001) {
truckRef.current.lookAt(lookAtTargetVector)
}
// Determine target extra rotation:
// - 0 radians when moving forward
// - Math.PI radians (180 degrees) when reversing
// We disable U-turns at the extreme start and end of the path to keep the truck stable at warehouse/delivery spots
let targetExtraRotation = 0
if (dampedProgressRef.current > 0.05 && dampedProgressRef.current < 0.95) {
if (isReversingRef.current) {
targetExtraRotation = Math.PI
}
}
// Smoothly damp the extra rotation angle directly (prevents pitch/roll glitches or 3D target collapse)
easing.damp(extraRotationRef, 'current', targetExtraRotation, 0.20, delta)
// Apply the yaw pivot around the local vertical axis
truckRef.current.rotateY(extraRotationRef.current)
// Calculate progress delta for wheels and audio
const deltaDamped = Math.abs(dampedProgressRef.current - lastDampedProgressRef.current)
lastDampedProgressRef.current = dampedProgressRef.current
// Accumulate wheel rotation based on absolute movement delta so they always roll forward locally
const isMoving = dampedProgressRef.current > 0.001 && dampedProgressRef.current < 0.999
if (isMoving) {
accumulatedRotationRef.current += deltaDamped * 250 // spinFactor
}
// Spin wheels
animateWheels(wheelRefs, accumulatedRotationRef.current)
// Add engine vibration to the inner group to prevent coordinate pollution on the root group
if (truckRef.current.children && truckRef.current.children[0]) {
const innerGroup = truckRef.current.children[0]
innerGroup.position.y = Math.sin(state.clock.getElapsedTime() * 45) * 0.003
}
})
return null
}