fix how it work loading screen

This commit is contained in:
2026-06-10 12:27:57 +05:30
parent d56e710e28
commit 10d73b6d31
15 changed files with 12252 additions and 11968 deletions

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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 23×
// 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)

View File

@@ -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,
}}

View File

@@ -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) {

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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 = [

View File

@@ -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

View 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}

View File

@@ -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' })
}
}