Files
doormile_react/src/modules/how-it-works-3d/Experience3D.jsx
Aravind R 3d53f82e7b 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>
2026-06-08 20:47:10 +05:30

159 lines
5.6 KiB
JavaScript

"use client";
import React, { useRef, useEffect, useState } from 'react'
import Experience from './components/Experience'
import ScrollRig from './components/ScrollRig'
import Navbar from './components/ui/Navbar'
import FirstMile from './components/sections/FirstMile'
import MidMile from './components/sections/MidMile'
import LastMile from './components/sections/LastMile'
import Analytics from './components/sections/Analytics'
import { useSceneStore } from './store/useSceneStore'
import './styles/experience.css'
import Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
/**
* Experience3D
* ---------------------------------------------------------------------------
* The full scroll-driven 3D logistics story, ported from the standalone Vite
* app's App.jsx and embedded as the body of the How It Works page (below the
* existing Elementor hero, above the global Footer).
*
* Two integration changes vs. the standalone app:
* 1. Self-managed fixed pin. The site has a fixed header and an ancestor with
* `overflow:hidden`, both of which break CSS `position: sticky`. So this is
* a tall `position:relative` section (`.dm-hiw-3d`, its height supplied by
* the 900vh ScrollRig spacer) with an absolutely-positioned `.dm-hiw-3d-stage`
* toggled absolute(top) → fixed → absolute(bottom) via the ScrollTrigger pin
* state — the same approach the site's other 3D sections use (StrategySection).
* 2. The global Lenis is disabled on `/how-it-works` (SmoothScroll.tsx) so the
* experience runs its own tuned Lenis here without a second instance fighting
* it. The internal "Scroll to start" Hero overlay is dropped because the page
* keeps the Elementor HowItWorksHero above this section.
*/
export default function Experience3D() {
const scrollProgress = useSceneStore((state) => state.scrollProgress)
const setLenis = useSceneStore((state) => state.setLenis)
const containerRef = useRef(null)
const [pinState, setPinState] = useState('before')
// Defer mounting the WebGL Canvas until the section nears the viewport. This
// mirrors the site's other 3D sections (StrategySection's `mountScene`): besides
// saving the heavy 32MB scene until needed, it keeps the Canvas out of React
// StrictMode's initial synchronous double-mount, which otherwise creates and
// immediately loses the WebGL context in dev ("THREE.WebGLRenderer: Context Lost"),
// leaving a blank canvas. Once mounted it stays mounted.
const [mountScene, setMountScene] = useState(false)
useEffect(() => {
const el = containerRef.current
if (!el) return
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setMountScene(true)
io.disconnect()
}
},
{ rootMargin: '200% 0px' }, // mount well before it scrolls into view
)
io.observe(el)
return () => io.disconnect()
}, [])
// Own Lenis instance (global Lenis is gated off for this route).
useEffect(() => {
const lenis = new Lenis({
duration: 1.2,
lerp: 0.08,
syncTouch: true,
})
setLenis(lenis)
lenis.on('scroll', ScrollTrigger.update)
let rafId
function raf(time) {
lenis.raf(time)
rafId = requestAnimationFrame(raf)
}
rafId = requestAnimationFrame(raf)
gsap.ticker.lagSmoothing(0)
ScrollTrigger.refresh()
return () => {
cancelAnimationFrame(rafId)
lenis.destroy()
setLenis(null)
}
}, [setLenis])
// 3D references shared between R3F and the GSAP scroll system.
const truckRef = useRef(null)
const wheelRefs = React.useMemo(() => [
{ current: null }, // FR
{ current: null }, // FL
{ current: null }, // RL
{ current: null }, // RR
], [])
const dashboardRefs = React.useMemo(() => ({
bars: [
{ current: null }, { current: null }, { current: null },
{ current: null }, { current: null }, { current: null }
],
floorBars: [
{ current: null }, { current: null }, { current: null },
{ current: null }, { current: null }
],
pieQuarters: [
{ current: null }, { current: null }, { current: null }, { current: null }
]
}), [])
return (
<div ref={containerRef} className={`dm-hiw-3d is-${pinState}`}>
{/* Pinned stage: canvas + HTML overlays. Stays fixed across the scroll. */}
<div className="dm-hiw-3d-stage">
<div
className="canvas-wrapper"
style={{
opacity: scrollProgress >= 0.92 ? 0.85 : 1.0,
transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
{mountScene && (
<Experience
truckRef={truckRef}
wheelRefs={wheelRefs}
dashboardRefs={dashboardRefs}
/>
)}
</div>
{/* In-experience section navigation */}
<Navbar />
{/* Story stage text panels (revealed at their scroll ranges) */}
<div className="sections-overlay-container">
<FirstMile active={scrollProgress >= 0.02 && scrollProgress < 0.14} />
<MidMile active={scrollProgress >= 0.38 && scrollProgress < 0.50} />
<LastMile active={scrollProgress >= 0.80 && scrollProgress < 0.92} />
<Analytics active={scrollProgress >= 0.94} />
</div>
</div>
{/* GSAP scroll system: 900vh in-flow spacer that gives the section its
height, drives scroll progress, and reports pin state. */}
<ScrollRig dashboardRefs={dashboardRefs} onPinState={setPinState} />
</div>
)
}