From 10d73b6d31abbe2ef80a952f819f2278dd11ef05 Mon Sep 17 00:00:00 2001 From: Aravind R Date: Wed, 10 Jun 2026 12:27:57 +0530 Subject: [PATCH] fix how it work loading screen --- src/modules/how-it-works-3d/Experience3D.jsx | 223 +- .../how-it-works-3d/components/CameraRig.jsx | 46 +- .../how-it-works-3d/components/Experience.jsx | 138 +- .../how-it-works-3d/components/ScrollRig.jsx | 18 +- .../components/TruckAnimation.jsx | 30 +- .../components/sections/LastMile.jsx | 4 +- .../components/sections/MidMile.jsx | 4 +- .../how-it-works-3d/components/ui/Navbar.jsx | 4 +- .../hooks/useCameraAnimation.js | 252 +- .../how-it-works-3d/hooks/useDeviceTier.js | 38 + .../how-it-works-3d/hooks/useTruckMovement.js | 64 +- .../models/Scene3D.gltfjsx.bak.txt | 11584 +++++++++++++++ .../how-it-works-3d/models/Scene3D.jsx | 11712 +--------------- .../how-it-works-3d/utils/deviceTier.js | 92 + src/modules/how-it-works-3d/utils/helpers.js | 11 + 15 files changed, 12252 insertions(+), 11968 deletions(-) create mode 100644 src/modules/how-it-works-3d/hooks/useDeviceTier.js create mode 100644 src/modules/how-it-works-3d/models/Scene3D.gltfjsx.bak.txt create mode 100644 src/modules/how-it-works-3d/utils/deviceTier.js diff --git a/src/modules/how-it-works-3d/Experience3D.jsx b/src/modules/how-it-works-3d/Experience3D.jsx index 852b374..71b4dbf 100644 --- a/src/modules/how-it-works-3d/Experience3D.jsx +++ b/src/modules/how-it-works-3d/Experience3D.jsx @@ -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 , 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 ( +
+ + + + +
+ ) +} + +/** Lightweight poster shown when a live scene isn't appropriate/possible. */ +function StaticFallback() { + return ( +
+ {/* Optional poster image; if it 404s we keep the gradient + caption. */} + Doormile delivery journey — first mile to last mile { + e.currentTarget.style.display = 'none' + }} + style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} + /> +
+

+ From first mile to last mile, every delivery tracked. +

+
+
+ ) +} + 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
+ } + if (useFallback) { + return + } return (
- {/* Pinned stage: canvas + HTML overlays. Stays fixed across the scroll. */}
= 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 && ( )}
- {/* In-experience section navigation */} - - {/* Story stage text panels (revealed at their scroll ranges) */} -
- = 0.02 && scrollProgress < 0.14} /> - = 0.38 && scrollProgress < 0.50} /> - = 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). */} - = 0.90} /> -
+
- {/* GSAP scroll system: 900vh in-flow spacer that gives the section its - height, drives scroll progress, and reports pin state. */} - +
) } diff --git a/src/modules/how-it-works-3d/components/CameraRig.jsx b/src/modules/how-it-works-3d/components/CameraRig.jsx index 8c189d6..89f30a1 100644 --- a/src/modules/how-it-works-3d/components/CameraRig.jsx +++ b/src/modules/how-it-works-3d/components/CameraRig.jsx @@ -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 diff --git a/src/modules/how-it-works-3d/components/Experience.jsx b/src/modules/how-it-works-3d/components/Experience.jsx index 1652970..602526d 100644 --- a/src/modules/how-it-works-3d/components/Experience.jsx +++ b/src/modules/how-it-works-3d/components/Experience.jsx @@ -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 ( - + - {/* Soft shadows */} - + {/* Soft (PCSS) shadows on desktop only — they multiply shadow-map cost. */} + {cfg.softShadows && } - {/* Dynamic ambient and shadow-tracking directional lights */} - + - {/* Focused street lights along the road */} - + {/* Decorative street spotlights (visually off in the day scene) — desktop only. */} + {cfg.streetLights && } - {/* Environment preset */} - + {/* 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 ? ( + + ) : ( + + )} - {/* Main 3D logistics scene model */} - + {/* Main scene — single , tier drives LOD visibility + shadows. */} + - {/* Delivery truck model animation controller */} - - {/* Dynamic camera rig with damping and target interpolation */} - {/* 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. */}
) -}) +} +export default React.memo(Experience) diff --git a/src/modules/how-it-works-3d/components/ScrollRig.jsx b/src/modules/how-it-works-3d/components/ScrollRig.jsx index 9d798bf..f2c2edf 100644 --- a/src/modules/how-it-works-3d/components/ScrollRig.jsx +++ b/src/modules/how-it-works-3d/components/ScrollRig.jsx @@ -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, }} diff --git a/src/modules/how-it-works-3d/components/TruckAnimation.jsx b/src/modules/how-it-works-3d/components/TruckAnimation.jsx index 750c092..9ed7a8e 100644 --- a/src/modules/how-it-works-3d/components/TruckAnimation.jsx +++ b/src/modules/how-it-works-3d/components/TruckAnimation.jsx @@ -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) { diff --git a/src/modules/how-it-works-3d/components/sections/LastMile.jsx b/src/modules/how-it-works-3d/components/sections/LastMile.jsx index 07ba049..217b22c 100644 --- a/src/modules/how-it-works-3d/components/sections/LastMile.jsx +++ b/src/modules/how-it-works-3d/components/sections/LastMile.jsx @@ -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 ( diff --git a/src/modules/how-it-works-3d/components/sections/MidMile.jsx b/src/modules/how-it-works-3d/components/sections/MidMile.jsx index 1db00f0..b2c684d 100644 --- a/src/modules/how-it-works-3d/components/sections/MidMile.jsx +++ b/src/modules/how-it-works-3d/components/sections/MidMile.jsx @@ -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 ( diff --git a/src/modules/how-it-works-3d/components/ui/Navbar.jsx b/src/modules/how-it-works-3d/components/ui/Navbar.jsx index 81d443c..2ec7111 100644 --- a/src/modules/how-it-works-3d/components/ui/Navbar.jsx +++ b/src/modules/how-it-works-3d/components/ui/Navbar.jsx @@ -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 = [ diff --git a/src/modules/how-it-works-3d/hooks/useCameraAnimation.js b/src/modules/how-it-works-3d/hooks/useCameraAnimation.js index bf6ea1e..750456b 100644 --- a/src/modules/how-it-works-3d/hooks/useCameraAnimation.js +++ b/src/modules/how-it-works-3d/hooks/useCameraAnimation.js @@ -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 diff --git a/src/modules/how-it-works-3d/hooks/useDeviceTier.js b/src/modules/how-it-works-3d/hooks/useDeviceTier.js new file mode 100644 index 0000000..24fe499 --- /dev/null +++ b/src/modules/how-it-works-3d/hooks/useDeviceTier.js @@ -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 diff --git a/src/modules/how-it-works-3d/hooks/useTruckMovement.js b/src/modules/how-it-works-3d/hooks/useTruckMovement.js index 08fbb5d..fb3caed 100644 --- a/src/modules/how-it-works-3d/hooks/useTruckMovement.js +++ b/src/modules/how-it-works-3d/hooks/useTruckMovement.js @@ -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 diff --git a/src/modules/how-it-works-3d/models/Scene3D.gltfjsx.bak.txt b/src/modules/how-it-works-3d/models/Scene3D.gltfjsx.bak.txt new file mode 100644 index 0000000..af6824e --- /dev/null +++ b/src/modules/how-it-works-3d/models/Scene3D.gltfjsx.bak.txt @@ -0,0 +1,11584 @@ +/* +Auto-generated by: https://github.com/pmndrs/gltfjsx +*/ + +import React, { useRef } from 'react' +import { useGLTF } from '@react-three/drei' + +export function Model({ truckRef, wheelRefs, dashboardRefs, ...props }) { + const { nodes, materials } = useGLTF('/models/3d_scene_final.glb') + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +useGLTF.preload('/models/3d_scene_final.glb') + + + + diff --git a/src/modules/how-it-works-3d/models/Scene3D.jsx b/src/modules/how-it-works-3d/models/Scene3D.jsx index af6824e..a780519 100644 --- a/src/modules/how-it-works-3d/models/Scene3D.jsx +++ b/src/modules/how-it-works-3d/models/Scene3D.jsx @@ -1,11584 +1,128 @@ -/* -Auto-generated by: https://github.com/pmndrs/gltfjsx -*/ - -import React, { useRef } from 'react' -import { useGLTF } from '@react-three/drei' - -export function Model({ truckRef, wheelRefs, dashboardRefs, ...props }) { - const { nodes, materials } = useGLTF('/models/3d_scene_final.glb') - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} - -useGLTF.preload('/models/3d_scene_final.glb') - - - - +/* + * Scene3D + * --------------------------------------------------------------------------- + * Was: ~11.6k lines of gltfjsx-generated components (1,372 meshes), each + * with hardcoded castShadow/receiveShadow. That JSX shipped ~11.6k lines of JS, + * forced React to reconcile 1,372 mesh fibers on mount (a long main-thread block), + * and made every mesh cast + receive shadows. + * + * Now: the GLTF scene is rendered with a single . Animation refs and + * shadow/LOD policy are applied by traversing the loaded graph once. + * + * REF WIRING — verified against the actual GLB node tree (not the gltfjsx JSX, + * which flattens it): + * tyre mesh (LCT300007_WheelStock_XX_RB1c_Tire_1k_*) + * └─ Object_NN (wheel hub wrapper) + * └─ LCT300007_WheelStock_XX ← wheelRef target (matches the old wheel + * └─ RootNode group's transform exactly; Y is the axle) + * └─ *.fbx + * └─ Sketchfab_model ← truckRef target (a scene root; its + * transform == the old truckRef group) + * So: wheelRef = tyre.parent.parent, and truckRef = the tyre's ancestor whose + * parent is the scene root. These point at the SAME nodes the original JSX did, + * so TruckAnimation/wheelAnimation behave identically. + * + * The original generated file is preserved at Scene3D.gltfjsx.bak.txt. + * + * NOTE: still loads the single 31 MB GLB. Splitting + Draco/WebP compression is a + * binary-asset step (see PR notes) and is what fixes DOWNLOAD weight; the changes + * here reduce RUNTIME (JS bundle, mount cost, draw calls, shadow fill). + */ +import React, { useLayoutEffect } from 'react' +import { useGLTF } from '@react-three/drei' + +const GLB = '/models/3d_scene_final.glb' + +// Tyre meshes in the exact order the rig expects: [FR, FL, RL, RR]. Order is +// load-bearing — animateWheels() flips spin direction by index parity. +const TYRE_ORDER = [ + /WheelStock_FR_RB1c_Tire_1k_0$/i, // FR (suffix _0, NOT .001) + /WheelStock_FR_RB1c_Tire_1k_0\.001$/i, // FL (the .001 duplicate) + /WheelStock_RL_RB1c_Tire/i, // RL + /WheelStock_RR_RB1c_Tire/i, // RR +] + +// Shadow policy: keep shadows ONLY on the truck (caster) and ground (receiver). +const TRUCK_NAME_RX = /^LCT300007/i +const GROUND_NAME_RX = /road|floor|slab|driveway|apron|grass|ground|pad|curb/i +const GROUND_MAT_RX = /asphalt|concrete|lane|apron|curb|pavement|tarmac|grass/i + +// LOD buckets (matched against mesh name OR material name — both are reliable on +// the graph). Hidden meshes are skipped at draw time. +const FOLIAGE_NAME_RX = /tree|foliage|atlas|bush|hedge|shrub/i +const FOLIAGE_MAT_RX = /tree|leaf|leaves|bark|foliage|shrub|grass/i +const CLUTTER_NAME_RX = /carton|cube|crate|package_box|barrel|pallet|bench/i +const CLUTTER_MAT_RX = /cardboard|pallet/i +const STREETLIGHT_NAME_RX = /street_light|streetlight|lamp/i +const STREETLIGHT_MAT_RX = /street_light/i +const BG_TREE_NAME_RX = /atlas|background_tree/i +const BG_TREE_MAT_RX = /background_tree_atlas/i + +const matName = (o) => (Array.isArray(o.material) ? o.material[0]?.name : o.material?.name) || '' + +export function Model({ truckRef, wheelRefs, tier = 'desktop', /* dashboardRefs (unused) */ ...props }) { + const { scene } = useGLTF(GLB) + + // useLayoutEffect: wire refs + prune before first paint so TruckAnimation / + // CameraRig (which run in useFrame) see a fully-configured graph on frame 1. + useLayoutEffect(() => { + // 1) Wire animation refs by traversal (drei's `nodes` dict sanitises names + // like ".001" → reading the live graph avoids that mismatch) ----------- + const tyres = [null, null, null, null] + let anyTyre = null + scene.traverse((o) => { + if (!o.isMesh) return + const n = o.name || '' + if (!/WheelStock_(FR|RL|RR)_RB1c_Tire/i.test(n)) return + anyTyre = o + TYRE_ORDER.forEach((rx, i) => { + if (!tyres[i] && rx.test(n)) tyres[i] = o + }) + }) + + tyres.forEach((tyre, i) => { + const wheelEmpty = tyre?.parent?.parent ?? null // tyre → Object_NN → WheelStock_XX + if (wheelEmpty && wheelRefs?.[i]) wheelRefs[i].current = wheelEmpty + }) + + // Truck root = the tyre's ancestor that is a direct child of the scene root + // (== Sketchfab_model, the node the old JSX truckRef pointed at). + if (truckRef) { + let t = anyTyre + while (t && t.parent && t.parent !== scene) t = t.parent + truckRef.current = t ?? null + if (!t && process.env.NODE_ENV !== 'production') { + console.warn('[Scene3D] Could not resolve truck root — truck will not animate.') + } + } + + // 2) Shadow pruning + per-tier LOD visibility (single traversal) ---------- + scene.traverse((o) => { + if (!o.isMesh) return + const n = o.name || '' + const m = matName(o) + const isTruck = TRUCK_NAME_RX.test(n) + const isGround = GROUND_NAME_RX.test(n) || GROUND_MAT_RX.test(m) + + // GLTFLoader defaults cast/receive to false. Enable only where it matters. + o.castShadow = isTruck + o.receiveShadow = isTruck || isGround + if (isTruck) o.frustumCulled = false // truck is the subject — never cull it + + let hidden = false + if (tier === 'mobile') { + hidden = + FOLIAGE_NAME_RX.test(n) || FOLIAGE_MAT_RX.test(m) || + CLUTTER_NAME_RX.test(n) || CLUTTER_MAT_RX.test(m) || + STREETLIGHT_NAME_RX.test(n) || STREETLIGHT_MAT_RX.test(m) + } else if (tier === 'tablet') { + hidden = BG_TREE_NAME_RX.test(n) || BG_TREE_MAT_RX.test(m) + } + o.visible = !hidden + }) + }, [scene, truckRef, wheelRefs, tier]) + + return +} + +useGLTF.preload(GLB) diff --git a/src/modules/how-it-works-3d/utils/deviceTier.js b/src/modules/how-it-works-3d/utils/deviceTier.js new file mode 100644 index 0000000..4c55ef3 --- /dev/null +++ b/src/modules/how-it-works-3d/utils/deviceTier.js @@ -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 +} diff --git a/src/modules/how-it-works-3d/utils/helpers.js b/src/modules/how-it-works-3d/utils/helpers.js index 93903db..5be791e 100644 --- a/src/modules/how-it-works-3d/utils/helpers.js +++ b/src/modules/how-it-works-3d/utils/helpers.js @@ -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' }) + } +}