fix how it work loading screen
This commit is contained in:
@@ -9,6 +9,7 @@ import MidMile from './components/sections/MidMile'
|
||||
import LastMile from './components/sections/LastMile'
|
||||
import Promise from './components/sections/Promise'
|
||||
import { useSceneStore } from './store/useSceneStore'
|
||||
import { useDeviceCaps } from './utils/deviceTier'
|
||||
import './styles/experience.css'
|
||||
|
||||
import Lenis from 'lenis'
|
||||
@@ -18,39 +19,98 @@ 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).
|
||||
* Experience3D — the scroll-driven 3D logistics story embedded in the How It
|
||||
* Works page.
|
||||
*
|
||||
* 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.
|
||||
* PERF refactor (this pass):
|
||||
* 1. Device tiering. `useDeviceCaps()` classifies desktop / tablet / mobile and
|
||||
* a `fallback` flag. The tier flows into the Canvas (dpr/shadows/AA),
|
||||
* Scene3D (LOD visibility), and ScrollRig (scroll length).
|
||||
* 2. Static fallback. Reduced-motion / no-WebGL / very-low-memory devices get a
|
||||
* poster instead of a live WebGL scene.
|
||||
* 3. No per-frame React renders at this level. This component no longer
|
||||
* subscribes to `scrollProgress`; the end-of-scroll canvas fade is applied
|
||||
* imperatively, and the story overlays live in <StorySections>, which only
|
||||
* re-renders when a section boolean flips.
|
||||
* 4. Touch-aware smooth scroll. Lenis runs on desktop only (driven by a
|
||||
* dedicated rAF, not gsap.ticker; syncTouch off). Touch devices use native
|
||||
* momentum scrolling — emulated inertia on a heavy WebGL page is the main
|
||||
* cause of mobile/tablet scroll lag.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Story text panels. Isolated so its boolean subscriptions don't re-render the
|
||||
* whole experience: each selector returns a boolean, so React only re-renders
|
||||
* this small component when a section actually enters/leaves its range.
|
||||
*/
|
||||
function StorySections() {
|
||||
const firstActive = useSceneStore((s) => s.scrollProgress >= 0.02 && s.scrollProgress < 0.14)
|
||||
const midActive = useSceneStore((s) => s.scrollProgress >= 0.38 && s.scrollProgress < 0.50)
|
||||
const lastActive = useSceneStore((s) => s.scrollProgress >= 0.78 && s.scrollProgress < 0.875)
|
||||
const promiseActive = useSceneStore((s) => s.scrollProgress >= 0.90)
|
||||
|
||||
return (
|
||||
<div className="sections-overlay-container">
|
||||
<FirstMile active={firstActive} />
|
||||
<MidMile active={midActive} />
|
||||
<LastMile active={lastActive} />
|
||||
<Promise active={promiseActive} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Lightweight poster shown when a live scene isn't appropriate/possible. */
|
||||
function StaticFallback() {
|
||||
return (
|
||||
<section
|
||||
className="dm-hiw-3d-fallback"
|
||||
style={{
|
||||
position: 'relative',
|
||||
minHeight: '70vh',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
background:
|
||||
'linear-gradient(180deg, #eef1f5 0%, #dfe5ec 55%, #cfd7e0 100%)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Optional poster image; if it 404s we keep the gradient + caption. */}
|
||||
<img
|
||||
src="/images/hiw-3d-fallback.jpg"
|
||||
alt="Doormile delivery journey — first mile to last mile"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<div style={{ position: 'relative', padding: '2rem clamp(1rem, 5vw, 4rem)', maxWidth: 720 }}>
|
||||
<p style={{ fontWeight: 700, fontSize: 'clamp(1.25rem, 3vw, 2rem)', lineHeight: 1.2, margin: 0 }}>
|
||||
From first mile to last mile, every delivery tracked.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Experience3D() {
|
||||
const scrollProgress = useSceneStore((state) => state.scrollProgress)
|
||||
const setLenis = useSceneStore((state) => state.setLenis)
|
||||
const caps = useDeviceCaps() // null until mounted on the client
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const canvasWrapperRef = 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)
|
||||
|
||||
const tier = caps?.tier ?? 'desktop'
|
||||
const useFallback = caps?.fallback ?? false
|
||||
const isTouch = caps?.isTouch ?? false
|
||||
const liveScene = caps != null && !useFallback
|
||||
|
||||
// Defer mounting the WebGL Canvas until the section nears the viewport.
|
||||
useEffect(() => {
|
||||
if (!liveScene) return
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver(
|
||||
@@ -64,104 +124,107 @@ export default function Experience3D() {
|
||||
)
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
}, [liveScene])
|
||||
|
||||
// Refresh ScrollTrigger when the scene actually mounts. WebGL canvas mounting
|
||||
// can block the main thread and shift elements, so a refresh here is critical.
|
||||
// Refresh ScrollTrigger once the scene mounts (canvas creation can shift layout).
|
||||
useEffect(() => {
|
||||
if (mountScene) {
|
||||
const timer = setTimeout(() => {
|
||||
ScrollTrigger.refresh()
|
||||
}, 150)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (!mountScene) return
|
||||
const timer = setTimeout(() => ScrollTrigger.refresh(), 150)
|
||||
return () => clearTimeout(timer)
|
||||
}, [mountScene])
|
||||
|
||||
// Own Lenis instance (global Lenis is gated off for this route).
|
||||
// Smooth scroll — DESKTOP ONLY. Touch devices keep native momentum (native is
|
||||
// smoother than emulated inertia on a heavy WebGL page, and avoids the
|
||||
// touch-scroll lag). Driven by a dedicated rAF rather than gsap.ticker.
|
||||
useEffect(() => {
|
||||
if (!liveScene || isTouch) return
|
||||
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
lerp: 0.08,
|
||||
syncTouch: true,
|
||||
syncTouch: false, // never emulate touch inertia
|
||||
})
|
||||
|
||||
setLenis(lenis)
|
||||
lenis.on('scroll', ScrollTrigger.update)
|
||||
|
||||
// Drive Lenis using GSAP's ticker to ensure synchronization with ScrollTrigger
|
||||
const tickerCb = (time) => lenis.raf(time * 1000)
|
||||
gsap.ticker.add(tickerCb)
|
||||
gsap.ticker.lagSmoothing(0)
|
||||
let rafId
|
||||
const raf = (time) => {
|
||||
lenis.raf(time)
|
||||
rafId = requestAnimationFrame(raf)
|
||||
}
|
||||
rafId = requestAnimationFrame(raf)
|
||||
ScrollTrigger.refresh()
|
||||
|
||||
return () => {
|
||||
gsap.ticker.remove(tickerCb)
|
||||
cancelAnimationFrame(rafId)
|
||||
lenis.destroy()
|
||||
setLenis(null)
|
||||
}
|
||||
}, [setLenis])
|
||||
}, [liveScene, isTouch, setLenis])
|
||||
|
||||
// End-of-scroll canvas fade — applied imperatively so it never triggers a
|
||||
// React render. Subscribes to the store but only touches the DOM on flip.
|
||||
useEffect(() => {
|
||||
if (!mountScene) return
|
||||
const el = canvasWrapperRef.current
|
||||
if (!el) return
|
||||
let lastDim = null
|
||||
const apply = (p) => {
|
||||
const dim = p >= 0.92
|
||||
if (dim !== lastDim) {
|
||||
lastDim = dim
|
||||
el.style.opacity = dim ? '0.85' : '1'
|
||||
}
|
||||
}
|
||||
apply(useSceneStore.getState().scrollProgress)
|
||||
const unsub = useSceneStore.subscribe((s) => apply(s.scrollProgress))
|
||||
return unsub
|
||||
}, [mountScene])
|
||||
|
||||
// 3D references shared between R3F and the GSAP scroll system.
|
||||
const truckRef = useRef(null)
|
||||
const wheelRefs = React.useMemo(
|
||||
() => [{ current: null }, { current: null }, { current: null }, { current: null }],
|
||||
[],
|
||||
)
|
||||
// Kept for API compatibility (Scene3D no longer wires these; dashboard refs
|
||||
// were never attached in the generated model — the animation is a no-op).
|
||||
const dashboardRefs = React.useMemo(
|
||||
() => ({ bars: [], floorBars: [], pieQuarters: [] }),
|
||||
[],
|
||||
)
|
||||
|
||||
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 }
|
||||
]
|
||||
}), [])
|
||||
// Pre-mount (caps unknown) / fallback: render a reserved placeholder or poster.
|
||||
if (caps == null) {
|
||||
return <div ref={containerRef} className="dm-hiw-3d" style={{ minHeight: '100vh' }} aria-hidden />
|
||||
}
|
||||
if (useFallback) {
|
||||
return <StaticFallback />
|
||||
}
|
||||
|
||||
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
|
||||
ref={canvasWrapperRef}
|
||||
className="canvas-wrapper"
|
||||
style={{
|
||||
opacity: scrollProgress >= 0.92 ? 0.85 : 1.0,
|
||||
transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
style={{ transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||
>
|
||||
{mountScene && (
|
||||
<Experience
|
||||
truckRef={truckRef}
|
||||
wheelRefs={wheelRefs}
|
||||
dashboardRefs={dashboardRefs}
|
||||
tier={tier}
|
||||
/>
|
||||
)}
|
||||
</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.78 && scrollProgress < 0.875} />
|
||||
{/* Final card: reveals as the journey closes (fills the slot the old
|
||||
workflow timeline card used to occupy — no blank gap). */}
|
||||
<Promise active={scrollProgress >= 0.90} />
|
||||
</div>
|
||||
<StorySections />
|
||||
</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} />
|
||||
<ScrollRig dashboardRefs={dashboardRefs} onPinState={setPinState} tier={tier} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,46 +2,52 @@ import React, { useRef } from 'react'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { useSceneStore } from '../store/useSceneStore'
|
||||
import { useCameraAnimation } from '../hooks/useCameraAnimation'
|
||||
import { computeCameraState } from '../hooks/useCameraAnimation'
|
||||
import { easing } from 'maath'
|
||||
|
||||
/**
|
||||
* CameraRig
|
||||
* ---------------------------------------------------------------------------
|
||||
* PERF: This used to `useSceneStore(s => s.scrollProgress)`, which re-rendered
|
||||
* the component (and re-ran the heavy useCameraAnimation useMemo, allocating
|
||||
* ~15 Vector3s) on EVERY scroll frame. Now it subscribes to nothing — it reads
|
||||
* scroll progress transiently via `getState()` inside the frame loop and solves
|
||||
* the camera with an allocation-free pure function writing into reused vectors.
|
||||
* Result: zero React renders while scrolling; all motion happens in the rAF loop.
|
||||
*/
|
||||
export default function CameraRig() {
|
||||
const scrollProgress = useSceneStore((state) => state.scrollProgress)
|
||||
const { position: targetPosition, target: lookAtTarget } = useCameraAnimation(scrollProgress)
|
||||
|
||||
// Track the current focus point of the camera in a ref so we can interpolate it smoothly
|
||||
const currentLookAt = useRef(new THREE.Vector3(19.7, 4.4, -31.08))
|
||||
const targetPosition = useRef(new THREE.Vector3()).current
|
||||
const lookAtTarget = useRef(new THREE.Vector3()).current
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const { camera } = state
|
||||
|
||||
// maath's easing.damp3 divides by delta internally; a delta of 0 (coincident
|
||||
// or first frames) yields NaN that poisons the damper and would push the
|
||||
// camera to NaN — blanking the whole scene. Clamp delta to a safe range.
|
||||
// Solve the target camera pose for the current scroll position (read without
|
||||
// subscribing — no re-render).
|
||||
const scrollProgress = useSceneStore.getState().scrollProgress
|
||||
computeCameraState(scrollProgress, targetPosition, lookAtTarget)
|
||||
|
||||
// maath's easing.damp3 divides by delta; a delta of 0 (coincident/first
|
||||
// frames) yields NaN that poisons the damper. Clamp to a safe range.
|
||||
const dt = Number.isFinite(delta) && delta > 0 ? Math.min(delta, 0.1) : 1 / 60
|
||||
|
||||
// Smoothly damp the camera position towards the target position
|
||||
easing.damp3(camera.position, targetPosition, 0.35, dt)
|
||||
|
||||
// Smoothly damp the camera focus target (lookAt)
|
||||
easing.damp3(currentLookAt.current, lookAtTarget, 0.25, dt)
|
||||
|
||||
// Defensive recovery: if anything upstream produced a non-finite value, snap
|
||||
// back to the target so the camera never gets stuck at NaN (black screen).
|
||||
// Defensive recovery: never let a non-finite value blank the scene.
|
||||
if (!Number.isFinite(camera.position.x)) camera.position.copy(targetPosition)
|
||||
if (!Number.isFinite(currentLookAt.current.x)) currentLookAt.current.copy(lookAtTarget)
|
||||
|
||||
// Apply lookAt orientation using the interpolated target vector
|
||||
camera.lookAt(currentLookAt.current)
|
||||
|
||||
// Responsive aspect ratio adjustments: increase FOV on portrait screens to zoom out and keep truck & buildings in frame
|
||||
// Portrait screens: widen FOV so the truck & buildings stay in frame.
|
||||
const aspect = state.size.width / state.size.height
|
||||
if (aspect < 1.0) {
|
||||
camera.fov = Math.min(75, 45 / Math.sqrt(aspect))
|
||||
} else {
|
||||
camera.fov = 45
|
||||
const fov = aspect < 1.0 ? Math.min(75, 45 / Math.sqrt(aspect)) : 45
|
||||
if (camera.fov !== fov) {
|
||||
camera.fov = fov
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
camera.updateProjectionMatrix()
|
||||
})
|
||||
|
||||
return null
|
||||
|
||||
@@ -6,23 +6,33 @@ import { Model as SceneModel } from '../models/Scene3D'
|
||||
import CameraRig from './CameraRig'
|
||||
import TruckAnimation from './TruckAnimation'
|
||||
import StreetLights from './StreetLights'
|
||||
import { useSceneStore } from '../store/useSceneStore'
|
||||
|
||||
const dayBgColor = new THREE.Color('#f5f5f7')
|
||||
const nightBgColor = new THREE.Color('#010103') // Pitch black sky with a tiny touch of midnight slate
|
||||
|
||||
const dayAmbientColor = new THREE.Color('#ffffff')
|
||||
const nightAmbientColor = new THREE.Color('#000000') // Pitch black ambient
|
||||
|
||||
const dayDirColor = new THREE.Color('#ffffff')
|
||||
const nightDirColor = new THREE.Color('#000000') // Pitch black sun/moon directional light
|
||||
|
||||
const tempColor = new THREE.Color()
|
||||
const _truckPos = new THREE.Vector3()
|
||||
|
||||
// Dynamic lighting rig that centers the shadow frustum on the moving truck
|
||||
const SceneLighting = React.memo(function SceneLighting({ truckRef }) {
|
||||
/**
|
||||
* Per-tier Canvas / lighting / feature settings. One table so the desktop ↔
|
||||
* tablet ↔ mobile trade-offs are visible and tuned in a single place.
|
||||
*
|
||||
* shadows — WebGL shadow map on at all? (mobile: off entirely)
|
||||
* softShadows — drei PCSS soft shadows (desktop only; expensive)
|
||||
* environment — drei "city" HDR (network + GPU; dropped on mobile)
|
||||
* streetLights — extra spotLights (currently visually off → desktop only)
|
||||
* dpr — device-pixel-ratio clamp
|
||||
* antialias — MSAA (desktop only; costly on mobile fill rate)
|
||||
* shadowMap — directional-light shadow resolution
|
||||
*/
|
||||
const TIER = {
|
||||
desktop: { shadows: true, softShadows: true, environment: true, streetLights: true, dpr: [1, 1.5], antialias: true, shadowMap: 2048 },
|
||||
tablet: { shadows: true, softShadows: false, environment: true, streetLights: false, dpr: [1, 1.5], antialias: false, shadowMap: 1024 },
|
||||
mobile: { shadows: false, softShadows: false, environment: false, streetLights: false, dpr: [1, 1], antialias: false, shadowMap: 512 },
|
||||
}
|
||||
|
||||
// Directional "sun" that keeps its shadow frustum centred on the moving truck.
|
||||
// PERF: reads the truck position transiently in useFrame — no React state.
|
||||
const SceneLighting = React.memo(function SceneLighting({ truckRef, shadows, shadowMap }) {
|
||||
const dirLightRef = useRef()
|
||||
const ambientLightRef = useRef()
|
||||
const targetRef = useRef()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,48 +42,30 @@ const SceneLighting = React.memo(function SceneLighting({ truckRef }) {
|
||||
}, [])
|
||||
|
||||
useFrame((state) => {
|
||||
// 1. Center shadows on the truck
|
||||
// Track the truck so the (small) shadow frustum always covers it.
|
||||
if (dirLightRef.current && targetRef.current && truckRef.current) {
|
||||
const truckPos = new THREE.Vector3()
|
||||
truckRef.current.getWorldPosition(truckPos)
|
||||
|
||||
targetRef.current.position.copy(truckPos)
|
||||
truckRef.current.getWorldPosition(_truckPos)
|
||||
targetRef.current.position.copy(_truckPos)
|
||||
targetRef.current.updateMatrixWorld()
|
||||
|
||||
dirLightRef.current.position.set(truckPos.x + 10, truckPos.y + 20, truckPos.z + 10)
|
||||
dirLightRef.current.position.set(_truckPos.x + 10, _truckPos.y + 20, _truckPos.z + 10)
|
||||
}
|
||||
|
||||
// 2. Day-to-Night transition calculations (disabled: keeping day view throughout the scroll)
|
||||
const nightFactor = 0
|
||||
|
||||
// 3. Mutate scene background color & environment intensity
|
||||
// Keep the daytime background colour stable (day→night transition is disabled).
|
||||
if (state.scene) {
|
||||
state.scene.background = tempColor.lerpColors(dayBgColor, nightBgColor, nightFactor)
|
||||
state.scene.environmentIntensity = 1.0 - nightFactor * 1.0 // Fades completely to 0.0
|
||||
}
|
||||
|
||||
// 4. Update lights properties
|
||||
if (ambientLightRef.current) {
|
||||
ambientLightRef.current.intensity = 0.45 - nightFactor * 0.45 // Fades completely to 0.0
|
||||
ambientLightRef.current.color.lerpColors(dayAmbientColor, nightAmbientColor, nightFactor)
|
||||
}
|
||||
|
||||
if (dirLightRef.current) {
|
||||
dirLightRef.current.intensity = 1.5 - nightFactor * 1.5 // Fades completely to 0.0
|
||||
dirLightRef.current.color.lerpColors(dayDirColor, nightDirColor, nightFactor)
|
||||
state.scene.background = tempColor.copy(dayBgColor)
|
||||
state.scene.environmentIntensity = 1.0
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ambientLight ref={ambientLightRef} intensity={0.45} />
|
||||
<ambientLight intensity={0.45} />
|
||||
<directionalLight
|
||||
ref={dirLightRef}
|
||||
castShadow
|
||||
castShadow={shadows}
|
||||
position={[10, 20, 10]}
|
||||
intensity={1.5}
|
||||
shadow-mapSize-width={2048}
|
||||
shadow-mapSize-height={2048}
|
||||
shadow-mapSize-width={shadowMap}
|
||||
shadow-mapSize-height={shadowMap}
|
||||
shadow-camera-far={100}
|
||||
shadow-camera-left={-35}
|
||||
shadow-camera-right={35}
|
||||
@@ -86,58 +78,50 @@ const SceneLighting = React.memo(function SceneLighting({ truckRef }) {
|
||||
)
|
||||
})
|
||||
|
||||
export default React.memo(function Experience({ dashboardRefs, wheelRefs, truckRef }) {
|
||||
function Experience({ dashboardRefs, wheelRefs, truckRef, tier = 'desktop' }) {
|
||||
const cfg = TIER[tier] ?? TIER.desktop
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'absolute', top: 0, left: 0 }}>
|
||||
<Canvas
|
||||
shadows
|
||||
// Cap the device-pixel-ratio: uncapped, a retina display renders this
|
||||
// heavy 32MB scene into a 2x (or 3x) framebuffer, multiplying GPU memory
|
||||
// and risking WebGL context loss. [1, 1.5] keeps it crisp but bounded —
|
||||
// matching the dpr caps the site's other R3F canvases use.
|
||||
dpr={[1, 1.5]}
|
||||
// Mobile turns the shadow map off entirely; desktop/tablet keep it but
|
||||
// only the truck casts and only the ground receives (see Scene3D pruning).
|
||||
shadows={cfg.shadows}
|
||||
// Cap DPR so a retina phone doesn't render this heavy scene into a 2–3×
|
||||
// framebuffer (GPU memory blowup → context loss).
|
||||
dpr={cfg.dpr}
|
||||
camera={{ position: [32, 12, -18], fov: 45 }}
|
||||
gl={{ antialias: true, powerPreference: 'high-performance' }}
|
||||
gl={{ antialias: cfg.antialias, powerPreference: 'high-performance' }}
|
||||
>
|
||||
<color attach="background" args={['#f5f5f7']} />
|
||||
|
||||
{/* Soft shadows */}
|
||||
<SoftShadows size={10} samples={12} focus={1.0} />
|
||||
{/* Soft (PCSS) shadows on desktop only — they multiply shadow-map cost. */}
|
||||
{cfg.softShadows && <SoftShadows size={10} samples={12} focus={1.0} />}
|
||||
|
||||
{/* Dynamic ambient and shadow-tracking directional lights */}
|
||||
<SceneLighting truckRef={truckRef} />
|
||||
<SceneLighting truckRef={truckRef} shadows={cfg.shadows} shadowMap={cfg.shadowMap} />
|
||||
|
||||
{/* Focused street lights along the road */}
|
||||
<StreetLights />
|
||||
{/* Decorative street spotlights (visually off in the day scene) — desktop only. */}
|
||||
{cfg.streetLights && <StreetLights />}
|
||||
|
||||
{/* Environment preset */}
|
||||
<Environment preset="city" />
|
||||
{/* Image-based lighting. The "city" HDR is a network fetch + GPU cost; on
|
||||
mobile we skip it and lean on the hemisphere fill below instead. */}
|
||||
{cfg.environment ? (
|
||||
<Environment preset="city" />
|
||||
) : (
|
||||
<hemisphereLight args={['#ffffff', '#9aa0a6', 0.9]} />
|
||||
)}
|
||||
|
||||
{/* Main 3D logistics scene model */}
|
||||
<SceneModel
|
||||
dashboardRefs={dashboardRefs}
|
||||
truckRef={truckRef}
|
||||
wheelRefs={wheelRefs}
|
||||
/>
|
||||
{/* Main scene — single <primitive>, tier drives LOD visibility + shadows. */}
|
||||
<SceneModel dashboardRefs={dashboardRefs} truckRef={truckRef} wheelRefs={wheelRefs} tier={tier} />
|
||||
|
||||
{/* Delivery truck model animation controller */}
|
||||
<TruckAnimation truckRef={truckRef} wheelRefs={wheelRefs} />
|
||||
|
||||
{/* Dynamic camera rig with damping and target interpolation */}
|
||||
<CameraRig />
|
||||
|
||||
{/* Post-processing (EffectComposer/Bloom/Vignette) intentionally omitted.
|
||||
@react-three/postprocessing's EffectComposer reads
|
||||
`renderer.getContextAttributes().alpha` while initializing its buffers;
|
||||
under Next dev's React StrictMode the canvas's WebGL context is torn
|
||||
down and re-created, so that read hits a null context and throws
|
||||
"Cannot read properties of null (reading 'alpha')", crashing the whole
|
||||
scene. Dropping the composer renders the scene directly (lighting +
|
||||
shadows + environment carry the look). To re-add Bloom later, set
|
||||
`reactStrictMode: false` in next.config.ts and restore a Bloom-only
|
||||
composer. */}
|
||||
{/* Post-processing intentionally omitted (EffectComposer + StrictMode +
|
||||
ssr:false interaction; see git history). Mobile would disable it anyway. */}
|
||||
</Canvas>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export default React.memo(Experience)
|
||||
|
||||
@@ -6,7 +6,14 @@ import { animateDashboard } from '../animations/dashboardAnimation'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
export default function ScrollRig({ dashboardRefs, onPinState }) {
|
||||
// Scroll length per device tier. The animation is driven by NORMALISED progress
|
||||
// (0→1), so compressing the spacer height only shortens how far the user has to
|
||||
// scroll — every camera/truck keyframe still lands at the same progress value,
|
||||
// preserving the visuals while cutting the 900vh marathon down to a tighter,
|
||||
// less laggy travel (and less time touch-scrolling a heavy WebGL page on mobile).
|
||||
const SCROLL_HEIGHT_VH = { desktop: 600, tablet: 550, mobile: 500 }
|
||||
|
||||
export default function ScrollRig({ dashboardRefs, onPinState, tier = 'desktop' }) {
|
||||
const setScrollProgress = useSceneStore((state) => state.setScrollProgress)
|
||||
const setActiveSection = useSceneStore((state) => state.setActiveSection)
|
||||
const lenis = useSceneStore((state) => state.lenis)
|
||||
@@ -51,12 +58,13 @@ export default function ScrollRig({ dashboardRefs, onPinState }) {
|
||||
section = 1
|
||||
}
|
||||
|
||||
// Only push to the store when the section actually changes — calling
|
||||
// setActiveSection every frame needlessly re-ran Navbar's subscriber.
|
||||
if (section !== activeSectionRef.current) {
|
||||
activeSectionRef.current = section
|
||||
setActiveSection(section)
|
||||
}
|
||||
|
||||
setActiveSection(section)
|
||||
|
||||
// Trigger dashboard animations inside R3F when entering the analytics stage (progress >= 0.92)
|
||||
if (dashboardRefs) {
|
||||
if (progress >= 0.92) {
|
||||
@@ -97,7 +105,9 @@ export default function ScrollRig({ dashboardRefs, onPinState }) {
|
||||
// absolutely/fixed-positioned sibling.
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '900vh', // Optimized scroll length for faster, smoother travel
|
||||
// Tier-driven scroll length (was a fixed 900vh). Shorter travel = less
|
||||
// scrub lag, especially while touch-scrolling on mobile/tablet.
|
||||
height: `${SCROLL_HEIGHT_VH[tier] ?? 600}vh`,
|
||||
pointerEvents: 'none', // Allow interacting with the R3F Canvas underneath
|
||||
zIndex: 0,
|
||||
}}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { 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 { computeTruckProgress } from '../hooks/useTruckMovement'
|
||||
import { animateWheels } from '../animations/wheelAnimation'
|
||||
import { easing } from 'maath'
|
||||
import { truckPath } from '../curves/truckPath'
|
||||
|
||||
/**
|
||||
* TruckAnimation
|
||||
* ---------------------------------------------------------------------------
|
||||
* PERF: previously subscribed to `scrollProgress` (re-running useTruckMovement's
|
||||
* useMemos and re-rendering every scroll frame) and pushed `truckProgress` back
|
||||
* into the store each render. Now it subscribes to nothing — scroll progress is
|
||||
* read transiently via `getState()` inside useFrame and the mapping is a pure,
|
||||
* allocation-free function. The store sync was removed (nothing reads
|
||||
* truckProgress). Net: zero React renders while the truck drives; identical motion.
|
||||
*/
|
||||
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)
|
||||
@@ -44,6 +43,11 @@ export default function TruckAnimation({ truckRef, wheelRefs }) {
|
||||
// positive range before any damping.
|
||||
const dt = Number.isFinite(delta) && delta > 0 ? Math.min(delta, 0.1) : 1 / 60
|
||||
|
||||
// Read scroll progress transiently (no subscription, no re-render) and map
|
||||
// it to spline progress with the pure piecewise function.
|
||||
const scrollProgress = useSceneStore.getState().scrollProgress
|
||||
const truckProgress = computeTruckProgress(scrollProgress)
|
||||
|
||||
// Detect scroll direction changes from the actual page scroll progress
|
||||
const deltaScroll = scrollProgress - lastScrollProgressRef.current
|
||||
if (deltaScroll < -0.0001) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { sections } from '../../constants/sectionConfig'
|
||||
import { useSceneStore } from '../../store/useSceneStore'
|
||||
import RevealCard from '../ui/RevealCard'
|
||||
import { progressToScrollY } from '../../utils/helpers'
|
||||
import { progressToScrollY, smoothScrollToY } from '../../utils/helpers'
|
||||
|
||||
export default function LastMile({ active }) {
|
||||
const config = sections[2]
|
||||
@@ -12,7 +12,7 @@ export default function LastMile({ active }) {
|
||||
// Smoothly scroll to 92% progress, which lands on the analytics-dashboard
|
||||
// view where the closing promise card is revealed.
|
||||
// Relative to the experience spacer (the section sits below the page hero).
|
||||
lenis?.scrollTo(progressToScrollY(0.92), { duration: 1.5 })
|
||||
smoothScrollToY(lenis, progressToScrollY(0.92))
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { sections } from '../../constants/sectionConfig'
|
||||
import { useSceneStore } from '../../store/useSceneStore'
|
||||
import RevealCard from '../ui/RevealCard'
|
||||
import { progressToScrollY } from '../../utils/helpers'
|
||||
import { progressToScrollY, smoothScrollToY } from '../../utils/helpers'
|
||||
|
||||
export default function MidMile({ active }) {
|
||||
const config = sections[1]
|
||||
@@ -11,7 +11,7 @@ export default function MidMile({ active }) {
|
||||
const handleClose = () => {
|
||||
// Smoothly scroll to 57.5% progress, which is just after the truck resumes moving (at 57%).
|
||||
// Relative to the experience spacer (the section sits below the page hero).
|
||||
lenis?.scrollTo(progressToScrollY(0.575), { duration: 1.5 })
|
||||
smoothScrollToY(lenis, progressToScrollY(0.575))
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { useSceneStore } from '../../store/useSceneStore'
|
||||
import { progressToScrollY } from '../../utils/helpers'
|
||||
import { progressToScrollY, smoothScrollToY } from '../../utils/helpers'
|
||||
|
||||
export default function Navbar() {
|
||||
const activeSection = useSceneStore((state) => state.activeSection)
|
||||
@@ -13,7 +13,7 @@ export default function Navbar() {
|
||||
const sectionFractions = [0, 0.38, 0.76, 0.92]
|
||||
const targetProgress = sectionFractions[index]
|
||||
// Relative to the experience spacer (the section sits below the page hero).
|
||||
lenis?.scrollTo(progressToScrollY(targetProgress), { duration: 1.5 })
|
||||
smoothScrollToY(lenis, progressToScrollY(targetProgress))
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
|
||||
@@ -1,164 +1,116 @@
|
||||
import { useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { truckPath } from '../curves/truckPath'
|
||||
import { clamp } from '../utils/helpers'
|
||||
import { computeTruckProgress } from './useTruckMovement'
|
||||
|
||||
export const useCameraAnimation = (scrollProgress) => {
|
||||
const cameraState = useMemo(() => {
|
||||
// 1. Calculate the truck position corresponding to the current scroll progress
|
||||
// Use the exact same piecewise mapping to keep camera follow 100% synchronized
|
||||
let truckProgress = 0
|
||||
if (scrollProgress < 0.14) {
|
||||
truckProgress = 0.0
|
||||
} else if (scrollProgress >= 0.14 && scrollProgress < 0.38) {
|
||||
truckProgress = 0.5 * (scrollProgress - 0.14) / 0.24
|
||||
} else if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
|
||||
truckProgress = 0.5
|
||||
} else if (scrollProgress >= 0.50 && scrollProgress < 0.76) {
|
||||
truckProgress = 0.5 + 0.5 * (scrollProgress - 0.50) / 0.26
|
||||
/**
|
||||
* Fixed camera "stations" along the story. Defined once at module scope so the
|
||||
* per-frame solver never re-allocates them.
|
||||
*/
|
||||
const VIEWS = {
|
||||
firstMileWhole: { position: new THREE.Vector3(38.0, 15.0, -10.0), target: new THREE.Vector3(24.377, 4.0, -39.303) },
|
||||
firstMileFront: { position: new THREE.Vector3(7.0, 3.0, -19.0), target: new THREE.Vector3(15.5, 1.5, -26.5) },
|
||||
midMile: { position: new THREE.Vector3(-7.0, 7.5, 8.0), target: new THREE.Vector3(-19.146, 2.5, -9.0) },
|
||||
lastMileClose: { position: new THREE.Vector3(-3.5, 4.0, 15.0), target: new THREE.Vector3(8.0, 2.0, 20.0) },
|
||||
lastMileZoomedOut: { position: new THREE.Vector3(-10.4, 5.2, 12.0), target: new THREE.Vector3(8.0, 2.0, 20.0) },
|
||||
analytics: { position: new THREE.Vector3(-13.5, 5.0, 31.0), target: new THREE.Vector3(-7.7, 3.5, 25.4) },
|
||||
}
|
||||
|
||||
// Module-level scratch vectors — reused every frame, zero allocation.
|
||||
const _truckPos = new THREE.Vector3()
|
||||
const _forward = new THREE.Vector3()
|
||||
const _up = new THREE.Vector3(0, 1, 0)
|
||||
const _right = new THREE.Vector3()
|
||||
const _cruise1Pos = new THREE.Vector3()
|
||||
const _cruise2Pos = new THREE.Vector3()
|
||||
|
||||
const smoothstep = (a) => a * a * (3 - 2 * a)
|
||||
|
||||
/**
|
||||
* Solve the camera position + lookAt target for a given scroll progress, writing
|
||||
* the result into the supplied vectors. Pure and allocation-free so it can run
|
||||
* inside useFrame without ever touching React state. Math is identical to the
|
||||
* original useCameraAnimation useMemo — only the storage was hoisted out.
|
||||
*/
|
||||
export function computeCameraState(scrollProgress, outPosition, outTarget) {
|
||||
const truckProgress = computeTruckProgress(scrollProgress)
|
||||
truckPath.getPoint(truckProgress, _truckPos)
|
||||
|
||||
// Local truck axes from the spline tangent (for the follow "cruise" cameras).
|
||||
truckPath.getTangent(truckProgress, _forward).normalize()
|
||||
_right.crossVectors(_forward, _up).normalize()
|
||||
|
||||
_cruise1Pos.copy(_truckPos).addScaledVector(_forward, 7.2).addScaledVector(_up, 3.2).addScaledVector(_right, -3.0)
|
||||
_cruise2Pos.copy(_truckPos).addScaledVector(_forward, 7.2).addScaledVector(_up, 3.2).addScaledVector(_right, 3.0)
|
||||
|
||||
const p = scrollProgress
|
||||
if (p < 0.04) {
|
||||
outPosition.copy(VIEWS.firstMileWhole.position)
|
||||
outTarget.copy(VIEWS.firstMileWhole.target)
|
||||
} else if (p < 0.14) {
|
||||
const a = smoothstep((p - 0.04) / 0.10)
|
||||
outPosition.lerpVectors(VIEWS.firstMileWhole.position, VIEWS.firstMileFront.position, a)
|
||||
outTarget.lerpVectors(VIEWS.firstMileWhole.target, VIEWS.firstMileFront.target, a)
|
||||
} else if (p < 0.18) {
|
||||
const a = smoothstep((p - 0.14) / 0.04)
|
||||
outPosition.lerpVectors(VIEWS.firstMileFront.position, _cruise1Pos, a)
|
||||
outTarget.lerpVectors(VIEWS.firstMileFront.target, _truckPos, a)
|
||||
} else if (p < 0.34) {
|
||||
outPosition.copy(_cruise1Pos)
|
||||
outTarget.copy(_truckPos)
|
||||
} else if (p < 0.38) {
|
||||
const a = smoothstep((p - 0.34) / 0.04)
|
||||
outPosition.lerpVectors(_cruise1Pos, VIEWS.midMile.position, a)
|
||||
outTarget.lerpVectors(_truckPos, VIEWS.midMile.target, a)
|
||||
} else if (p < 0.50) {
|
||||
outPosition.copy(VIEWS.midMile.position)
|
||||
outTarget.copy(VIEWS.midMile.target)
|
||||
} else if (p < 0.54) {
|
||||
const a = smoothstep((p - 0.50) / 0.04)
|
||||
outPosition.lerpVectors(VIEWS.midMile.position, _cruise2Pos, a)
|
||||
outTarget.lerpVectors(VIEWS.midMile.target, _truckPos, a)
|
||||
} else if (p < 0.72) {
|
||||
outPosition.copy(_cruise2Pos)
|
||||
outTarget.copy(_truckPos)
|
||||
} else if (p < 0.76) {
|
||||
const a = smoothstep((p - 0.72) / 0.04)
|
||||
outPosition.lerpVectors(_cruise2Pos, VIEWS.lastMileClose.position, a)
|
||||
outTarget.lerpVectors(_truckPos, VIEWS.lastMileClose.target, a)
|
||||
} else if (p < 0.92) {
|
||||
// Last Mile stop sequence: park → zoom out → hold.
|
||||
if (p < 0.80) {
|
||||
outPosition.copy(VIEWS.lastMileClose.position)
|
||||
outTarget.copy(VIEWS.lastMileClose.target)
|
||||
} else if (p < 0.84) {
|
||||
const a = smoothstep((p - 0.80) / 0.04)
|
||||
outPosition.lerpVectors(VIEWS.lastMileClose.position, VIEWS.lastMileZoomedOut.position, a)
|
||||
outTarget.lerpVectors(VIEWS.lastMileClose.target, VIEWS.lastMileZoomedOut.target, a)
|
||||
} else {
|
||||
truckProgress = 1.0
|
||||
outPosition.copy(VIEWS.lastMileZoomedOut.position)
|
||||
outTarget.copy(VIEWS.lastMileZoomedOut.target)
|
||||
}
|
||||
const truckPos = truckPath.getPoint(truckProgress)
|
||||
|
||||
const firstMileViewWhole = {
|
||||
position: new THREE.Vector3(38.0, 15.0, -10.0),
|
||||
target: new THREE.Vector3(24.377, 4.0, -39.303)
|
||||
}
|
||||
const firstMileViewFront = {
|
||||
position: new THREE.Vector3(7.0, 3.0, -19.0),
|
||||
target: new THREE.Vector3(15.5, 1.5, -26.5)
|
||||
}
|
||||
const midMileView = {
|
||||
position: new THREE.Vector3(-7.0, 7.5, 8.0),
|
||||
target: new THREE.Vector3(-19.146, 2.5, -9.0)
|
||||
}
|
||||
const lastMileViewClose = {
|
||||
position: new THREE.Vector3(-3.5, 4.0, 15.0),
|
||||
target: new THREE.Vector3(8.0, 2.0, 20.0)
|
||||
}
|
||||
const lastMileViewZoomedOut = {
|
||||
position: new THREE.Vector3(-10.4, 5.2, 12.0),
|
||||
target: new THREE.Vector3(8.0, 2.0, 20.0)
|
||||
}
|
||||
const analyticsView = {
|
||||
position: new THREE.Vector3(-13.5, 5.0, 31.0),
|
||||
target: new THREE.Vector3(-7.7, 3.5, 25.4)
|
||||
}
|
||||
|
||||
// 3. Calculate local coordinate axes of the truck based on the spline tangent
|
||||
const forward = truckPath.getTangent(truckProgress).normalize()
|
||||
const up = new THREE.Vector3(0, 1, 0)
|
||||
const right = new THREE.Vector3().crossVectors(forward, up).normalize()
|
||||
|
||||
// Cruise 1: Front-left follow perspective (facing the oncoming truck, zoomed out follow)
|
||||
const cruise1Pos = truckPos.clone()
|
||||
.addScaledVector(forward, 7.2)
|
||||
.addScaledVector(up, 3.2)
|
||||
.addScaledVector(right, -3.0)
|
||||
const cruise1Target = truckPos.clone()
|
||||
|
||||
// Cruise 2: Front-right follow perspective (facing the oncoming truck, zoomed out follow)
|
||||
const cruise2Pos = truckPos.clone()
|
||||
.addScaledVector(forward, 7.2)
|
||||
.addScaledVector(up, 3.2)
|
||||
.addScaledVector(right, 3.0)
|
||||
const cruise2Target = truckPos.clone()
|
||||
} else if (p < 0.96) {
|
||||
const a = smoothstep((p - 0.92) / 0.04)
|
||||
outPosition.lerpVectors(VIEWS.lastMileZoomedOut.position, VIEWS.analytics.position, a)
|
||||
outTarget.lerpVectors(VIEWS.lastMileZoomedOut.target, VIEWS.analytics.target, a)
|
||||
} else {
|
||||
outPosition.copy(VIEWS.analytics.position)
|
||||
outTarget.copy(VIEWS.analytics.target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy React hook kept for compatibility. No longer used on the render path
|
||||
* (CameraRig computes the state inside useFrame), so it no longer re-renders
|
||||
* every scroll frame.
|
||||
*/
|
||||
export const useCameraAnimation = (scrollProgress) => {
|
||||
return useMemo(() => {
|
||||
const position = new THREE.Vector3()
|
||||
const target = new THREE.Vector3()
|
||||
|
||||
// 4. Smoothly blend positions and targets depending on active scroll boundaries
|
||||
if (scrollProgress < 0.04) {
|
||||
// Step 1: Zoomed out overview of the whole building
|
||||
position.copy(firstMileViewWhole.position)
|
||||
target.copy(firstMileViewWhole.target)
|
||||
}
|
||||
else if (scrollProgress >= 0.04 && scrollProgress < 0.14) {
|
||||
// Step 2: Camera moves to the front close-up view of the building
|
||||
const alpha = (scrollProgress - 0.04) / 0.10
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(firstMileViewWhole.position, firstMileViewFront.position, smoothAlpha)
|
||||
target.lerpVectors(firstMileViewWhole.target, firstMileViewFront.target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.14 && scrollProgress < 0.18) {
|
||||
// Step 3: Truck starts moving, camera blends to close follow tracking
|
||||
const alpha = (scrollProgress - 0.14) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(firstMileViewFront.position, cruise1Pos, smoothAlpha)
|
||||
target.lerpVectors(firstMileViewFront.target, cruise1Target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.18 && scrollProgress < 0.34) {
|
||||
// Cruise 1: Close follow tracking
|
||||
position.copy(cruise1Pos)
|
||||
target.copy(cruise1Target)
|
||||
}
|
||||
else if (scrollProgress >= 0.34 && scrollProgress < 0.38) {
|
||||
// Blend: Cruise 1 Follow -> Mid Mile Building
|
||||
const alpha = (scrollProgress - 0.34) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(cruise1Pos, midMileView.position, smoothAlpha)
|
||||
target.lerpVectors(cruise1Target, midMileView.target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
|
||||
// Mid Mile Building focus
|
||||
position.copy(midMileView.position)
|
||||
target.copy(midMileView.target)
|
||||
}
|
||||
else if (scrollProgress >= 0.50 && scrollProgress < 0.54) {
|
||||
// Blend: Mid Mile Building -> Cruise 2 Follow
|
||||
const alpha = (scrollProgress - 0.50) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(midMileView.position, cruise2Pos, smoothAlpha)
|
||||
target.lerpVectors(midMileView.target, cruise2Target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.54 && scrollProgress < 0.72) {
|
||||
// Cruise 2: Close follow tracking
|
||||
position.copy(cruise2Pos)
|
||||
target.copy(cruise2Target)
|
||||
}
|
||||
else if (scrollProgress >= 0.72 && scrollProgress < 0.76) {
|
||||
// Blend: Cruise 2 Follow -> Last Mile Building Close-up
|
||||
const alpha = (scrollProgress - 0.72) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(cruise2Pos, lastMileViewClose.position, smoothAlpha)
|
||||
target.lerpVectors(cruise2Target, lastMileViewClose.target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.76 && scrollProgress < 0.92) {
|
||||
// Last Mile Building Stop Sequence:
|
||||
// - 0.76 to 0.80: Parked close-up view of the truck and building
|
||||
// - 0.80 to 0.84: Zoom out transition back along the camera viewing axis
|
||||
// - 0.84 to 0.92: Zoomed-out overview of the final delivery stage (card stays frozen here)
|
||||
if (scrollProgress < 0.80) {
|
||||
position.copy(lastMileViewClose.position)
|
||||
target.copy(lastMileViewClose.target)
|
||||
} else if (scrollProgress >= 0.80 && scrollProgress < 0.84) {
|
||||
const alpha = (scrollProgress - 0.80) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(lastMileViewClose.position, lastMileViewZoomedOut.position, smoothAlpha)
|
||||
target.lerpVectors(lastMileViewClose.target, lastMileViewZoomedOut.target, smoothAlpha)
|
||||
} else {
|
||||
position.copy(lastMileViewZoomedOut.position)
|
||||
target.copy(lastMileViewZoomedOut.target)
|
||||
}
|
||||
}
|
||||
else if (scrollProgress >= 0.92 && scrollProgress < 0.96) {
|
||||
// Blend: Last Mile Building Zoomed-Out -> Analytics Dashboard screen
|
||||
const alpha = (scrollProgress - 0.92) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(lastMileViewZoomedOut.position, analyticsView.position, smoothAlpha)
|
||||
target.lerpVectors(lastMileViewZoomedOut.target, analyticsView.target, smoothAlpha)
|
||||
}
|
||||
else {
|
||||
// Analytics Dashboard screen focus
|
||||
position.copy(analyticsView.position)
|
||||
target.copy(analyticsView.target)
|
||||
}
|
||||
|
||||
computeCameraState(scrollProgress, position, target)
|
||||
return { position, target }
|
||||
}, [scrollProgress])
|
||||
|
||||
return cameraState
|
||||
}
|
||||
|
||||
export default useCameraAnimation
|
||||
|
||||
38
src/modules/how-it-works-3d/hooks/useDeviceTier.js
Normal file
38
src/modules/how-it-works-3d/hooks/useDeviceTier.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* useDeviceTier — lightweight client-side device capability detection.
|
||||
*
|
||||
* Returns 'desktop' | 'tablet' | 'mobile' based on viewport width,
|
||||
* touch capability, and hardware concurrency. The result is computed
|
||||
* once on mount and never changes (no resize listener — the 3D scene
|
||||
* should not swap LOD tiers mid-session).
|
||||
*
|
||||
* Also exports a plain `getDeviceTier()` function for non-React contexts.
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/** Compute device tier from current browser globals. */
|
||||
export function getDeviceTier() {
|
||||
if (typeof window === 'undefined') return 'desktop'
|
||||
|
||||
const w = window.innerWidth
|
||||
const hasTouch = navigator.maxTouchPoints > 0
|
||||
const cores = navigator.hardwareConcurrency || 4
|
||||
|
||||
// Mobile: narrow viewport OR low-core touch device
|
||||
if (w <= 600 || (hasTouch && w <= 768)) return 'mobile'
|
||||
|
||||
// Tablet: mid-width touch device (iPad, Android tablet, etc.)
|
||||
if (w <= 1024 && hasTouch) return 'tablet'
|
||||
|
||||
// Low-powered desktops with ≤ 2 cores get tablet-tier rendering
|
||||
if (cores <= 2) return 'tablet'
|
||||
|
||||
return 'desktop'
|
||||
}
|
||||
|
||||
/** React hook — memoized once per component lifetime. */
|
||||
export function useDeviceTier() {
|
||||
return useMemo(() => getDeviceTier(), [])
|
||||
}
|
||||
|
||||
export default useDeviceTier
|
||||
@@ -1,37 +1,37 @@
|
||||
import { useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { truckPath } from '../curves/truckPath'
|
||||
import { clamp } from '../utils/helpers'
|
||||
|
||||
/**
|
||||
* Piecewise mapping of page scroll → position along the truck spline.
|
||||
* Pure, allocation-free, and safe to call every frame from inside useFrame.
|
||||
*
|
||||
* 0%–14% : parked at First Mile (0.0)
|
||||
* 14%–38% : drive First → Mid Mile (0.0 → 0.5)
|
||||
* 38%–50% : parked at Mid Mile (0.5)
|
||||
* 50%–76% : drive Mid → Last Mile (0.5 → 1.0)
|
||||
* 76%–100%: parked at Last Mile (1.0)
|
||||
*
|
||||
* Extracted from the old useMemo so the per-frame loop can read scroll progress
|
||||
* transiently (store.getState) and compute the mapping without triggering a
|
||||
* React re-render on every scroll tick.
|
||||
*/
|
||||
export function computeTruckProgress(scrollProgress) {
|
||||
if (scrollProgress < 0.14) return 0.0
|
||||
if (scrollProgress < 0.38) return (0.5 * (scrollProgress - 0.14)) / 0.24
|
||||
if (scrollProgress < 0.50) return 0.5
|
||||
if (scrollProgress < 0.76) return 0.5 + (0.5 * (scrollProgress - 0.50)) / 0.26
|
||||
return 1.0
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy React hook kept for any external caller. The live experience no longer
|
||||
* uses it on the render path (TruckAnimation reads computeTruckProgress inside
|
||||
* useFrame), so it no longer drives per-frame re-renders.
|
||||
*/
|
||||
export const useTruckMovement = (scrollProgress) => {
|
||||
// Piecewise mapping of scroll progress to make the truck stop at Mid Mile:
|
||||
// - 0% to 25%: Parked at First Mile (progress = 0)
|
||||
// - 25% to 45%: Driving from First Mile to Mid Mile (progress 0 -> 0.5)
|
||||
// - 45% to 55%: Parked at Mid Mile (progress = 0.5)
|
||||
// - 55% to 75%: Driving from Mid Mile to Last Mile (progress 0.5 -> 1.0)
|
||||
// - 75% to 100%: Parked at Last Mile (progress = 1.0)
|
||||
const truckProgress = useMemo(() => {
|
||||
if (scrollProgress < 0.14) {
|
||||
return 0.0
|
||||
}
|
||||
if (scrollProgress >= 0.14 && scrollProgress < 0.38) {
|
||||
return 0.5 * (scrollProgress - 0.14) / 0.24
|
||||
}
|
||||
if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
|
||||
return 0.5
|
||||
}
|
||||
if (scrollProgress >= 0.50 && scrollProgress < 0.76) {
|
||||
return 0.5 + 0.5 * (scrollProgress - 0.50) / 0.26
|
||||
}
|
||||
return 1.0
|
||||
}, [scrollProgress])
|
||||
|
||||
// Get current position on the curve
|
||||
const position = useMemo(() => {
|
||||
return truckPath.getPoint(truckProgress)
|
||||
}, [truckProgress])
|
||||
|
||||
// Get lookAt target (a point slightly ahead on the curve, using tangent at the end to prevent matrix collapse)
|
||||
const truckProgress = useMemo(() => computeTruckProgress(scrollProgress), [scrollProgress])
|
||||
const position = useMemo(() => truckPath.getPoint(truckProgress), [truckProgress])
|
||||
const lookAtTarget = useMemo(() => {
|
||||
if (truckProgress >= 0.99) {
|
||||
const tangent = truckPath.getTangent(1.0)
|
||||
@@ -42,11 +42,7 @@ export const useTruckMovement = (scrollProgress) => {
|
||||
return truckPath.getPoint(ahead)
|
||||
}, [truckProgress])
|
||||
|
||||
return {
|
||||
truckProgress,
|
||||
position,
|
||||
lookAtTarget,
|
||||
}
|
||||
return { truckProgress, position, lookAtTarget }
|
||||
}
|
||||
|
||||
export default useTruckMovement
|
||||
|
||||
11584
src/modules/how-it-works-3d/models/Scene3D.gltfjsx.bak.txt
Normal file
11584
src/modules/how-it-works-3d/models/Scene3D.gltfjsx.bak.txt
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
92
src/modules/how-it-works-3d/utils/deviceTier.js
Normal file
92
src/modules/how-it-works-3d/utils/deviceTier.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* deviceTier
|
||||
* ---------------------------------------------------------------------------
|
||||
* Single source of truth for "how much 3D can this device afford". Every perf
|
||||
* decision in the experience (Canvas dpr/shadows, which meshes to draw, whether
|
||||
* to run Lenis, scroll length, post-fx) keys off this one classifier so the
|
||||
* desktop / tablet / mobile behaviour stays consistent and is tuned in one place.
|
||||
*
|
||||
* 'desktop' — full scene, soft shadows, environment, Lenis smooth-scroll
|
||||
* 'tablet' — reduced foliage, hard shadows, environment, native scroll
|
||||
* 'mobile' — truck + road + warehouse shell, no shadows, native scroll
|
||||
*
|
||||
* `fallback` is the escape hatch: reduced-motion users, no-WebGL contexts, and
|
||||
* very low-memory devices get a static poster instead of a live canvas.
|
||||
*
|
||||
* All detection is guarded for SSR (Next static export prerenders this module),
|
||||
* where it resolves to 'desktop' with no capability flags — the real value is
|
||||
* computed on the client after mount.
|
||||
*/
|
||||
|
||||
function detectWebGL() {
|
||||
if (typeof document === 'undefined') return true
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
return !!(
|
||||
window.WebGLRenderingContext &&
|
||||
(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getDeviceCaps() {
|
||||
// SSR / prerender: assume the capable path; the client re-evaluates on mount.
|
||||
if (typeof window === 'undefined') {
|
||||
return { tier: 'desktop', isTouch: false, reducedMotion: false, fallback: false, lowMemory: false }
|
||||
}
|
||||
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const coarse = window.matchMedia('(pointer: coarse)').matches
|
||||
const isTouch = coarse || navigator.maxTouchPoints > 0
|
||||
const width = window.innerWidth
|
||||
// navigator.deviceMemory is Chromium-only; treat "unknown" as plenty.
|
||||
const mem = typeof navigator.deviceMemory === 'number' ? navigator.deviceMemory : 8
|
||||
const cores = navigator.hardwareConcurrency || 8
|
||||
const hasWebGL = detectWebGL()
|
||||
|
||||
const lowMemory = mem <= 4
|
||||
|
||||
let tier
|
||||
if (!isTouch && width >= 1024) {
|
||||
tier = 'desktop'
|
||||
} else if (width >= 768 && width <= 1366) {
|
||||
tier = 'tablet'
|
||||
} else if (isTouch && width < 768) {
|
||||
tier = 'mobile'
|
||||
} else {
|
||||
// Small non-touch window, or anything else ambiguous → treat as tablet.
|
||||
tier = width < 768 ? 'mobile' : 'tablet'
|
||||
}
|
||||
|
||||
// Hard downgrade for genuinely weak hardware regardless of width.
|
||||
if ((mem <= 2 || cores <= 2) && tier !== 'mobile') tier = 'mobile'
|
||||
|
||||
// Static poster instead of a live scene when the device can't (or shouldn't)
|
||||
// run it: reduced-motion preference, no WebGL, or extremely low memory.
|
||||
const fallback = reducedMotion || !hasWebGL || mem <= 1
|
||||
|
||||
return { tier, isTouch, reducedMotion, fallback, lowMemory }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve device caps once on mount. We intentionally do NOT update on resize:
|
||||
* the tier drives Canvas creation (dpr/shadows), and remounting the WebGL
|
||||
* context on every resize/orientation tweak would be far more expensive than
|
||||
* the marginal benefit of re-tiering mid-session. CameraRig already handles
|
||||
* aspect-ratio changes at runtime, so rotation still looks correct.
|
||||
*
|
||||
* Returns `null` until mounted so callers can hold off creating the Canvas
|
||||
* until the real (client) tier is known — avoids an SSR 'desktop' Canvas
|
||||
* flashing on a phone.
|
||||
*/
|
||||
export function useDeviceCaps() {
|
||||
const [caps, setCaps] = useState(null)
|
||||
useEffect(() => {
|
||||
setCaps(getDeviceCaps())
|
||||
}, [])
|
||||
return caps
|
||||
}
|
||||
@@ -39,3 +39,14 @@ export const progressToScrollY = (progress) => {
|
||||
const scrollable = rig.offsetHeight - window.innerHeight
|
||||
return top + clamp(progress, 0, 1) * scrollable
|
||||
}
|
||||
|
||||
// Smoothly scroll to an absolute document Y. Uses Lenis when available
|
||||
// (desktop), and falls back to native smooth scroll on touch devices where Lenis
|
||||
// is intentionally not instantiated — so the in-experience nav still works there.
|
||||
export const smoothScrollToY = (lenis, y) => {
|
||||
if (lenis) {
|
||||
lenis.scrollTo(y, { duration: 1.5 })
|
||||
} else if (typeof window !== 'undefined') {
|
||||
window.scrollTo({ top: y, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user