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:
158
src/modules/how-it-works-3d/Experience3D.jsx
Normal file
158
src/modules/how-it-works-3d/Experience3D.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user