Files
doormile_react/src/animations/SmoothScroll.tsx
Aravind R 3d53f82e7b feat(how-it-works): integrate scroll-driven 3D experience
Migrate the standalone Vite + React Three Fiber experience into the existing
Next.js site as the body of the How It Works page, replacing the Miles3 /
WhyChooseDoormile / TheDoormileWay content sections while preserving the
Elementor hero, global Header/Footer, layout, routing and SEO.

- New self-contained module: src/modules/how-it-works-3d/ (R3F scene, hooks,
  zustand store, animations, curves, constants, utils, scoped CSS). App.jsx →
  Experience3D.jsx; 3d_scene.jsx → models/Scene3D.jsx.
- 32MB GLB moved to public/models/3d_scene_final.glb; useGLTF paths updated.
- Client-only entry via dynamic ssr:false loader (Experience3DLoader).
- Self-managed fixed pin (tall section + absolute stage toggled
  absolute(top)→fixed→absolute(bottom) from ScrollTrigger pin state), mirroring
  the site's StrategySection, since the fixed header + ancestor overflow:hidden
  break CSS sticky / GSAP pin.
- experience.css fully scoped under .dm-hiw-3d to avoid colliding with the
  site's Elementor CSS.
- Global Lenis disabled on /how-it-works; module runs its own tuned Lenis;
  jump-to-section scroll math made spacer-relative.
- Added zustand + maath; ESLint-ignored the ported module.

Rendering fixes (root causes found by driving headless Chrome):
- Bump three 0.171 → 0.184 to match @react-three/fiber@9.6 / drei@10.7 /
  postprocessing@6.39 (0.171 silently failed to render this GLB and caused the
  EffectComposer getContextAttributes().alpha crash). Other 3D routes verified.
- EffectComposer: Bloom + Vignette only. SSAO needs a NormalPass (v3 dropped
  the old `disableNormalPass`), and that extra full-scene pass exhausted the
  WebGL context on this heavy scene.
- Cap Canvas dpr to [1,1.5] to bound framebuffer memory on retina displays.
- Defer Canvas mount via IntersectionObserver (mountScene), matching
  StrategySection, to ease StrictMode/first-render GPU pressure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:47:10 +05:30

134 lines
4.7 KiB
TypeScript

"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* SmoothScroll
* ---------------------------------------------------------------------------
* One global Lenis instance, driven by a SINGLE rAF source (GSAP's ticker) and
* kept locked to ScrollTrigger via `lenis.on("scroll", ScrollTrigger.update)`.
*
* Enabled on every route. /miletruth's three scroll-driven WebGL sections use a
* self-managed `position: fixed` pin (toggled from ScrollTrigger.onUpdate via
* `self.progress`) — NOT GSAP's pin-spacer — so Lenis driving real document
* scroll keeps their progress correct and just smooths the wheel input. (It was
* previously gated off here when those sections used GSAP pins; that no longer
* applies, and native scroll there felt noticeably janky next to every Lenis
* route.)
*
* Still gated OFF on:
* - touch devices — native momentum is smoother than emulated inertia.
* - prefers-reduced-motion.
*
* Re-evaluates on every route change: the effect cleanup destroys the previous
* instance and re-inits on the next route.
*/
// /how-it-works runs its own tuned Lenis inside the embedded 3D experience
// (src/modules/how-it-works-3d); the global instance is gated off there so two
// Lenis instances don't fight over the same document scroll.
const DISABLED_ROUTES: string[] = ["/how-it-works"];
export default function SmoothScroll() {
const pathname = usePathname();
useEffect(() => {
const routeDisabled = DISABLED_ROUTES.some(
(r) => pathname === r || pathname.startsWith(`${r}/`),
);
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Mouse/desktop only — touch devices already have good native momentum.
const isPointerFine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
let hashTimer: ReturnType<typeof setTimeout>;
if (routeDisabled || prefersReduced || !isPointerFine) {
if (!window.location.hash) {
window.scrollTo(0, 0);
} else {
const scrollToHash = () => {
try {
const target = document.querySelector(window.location.hash) as HTMLElement | null;
if (target) {
target.scrollIntoView();
}
} catch (err) {
console.warn(err);
}
};
scrollToHash();
hashTimer = setTimeout(scrollToHash, 100);
}
return () => {
if (hashTimer) clearTimeout(hashTimer);
};
}
gsap.registerPlugin(ScrollTrigger);
// /miletruth is one long stack of tall, pinned 3D sections, each with its own
// ScrollTrigger `scrub`. The default duration-based momentum (1.05s) compounds
// with that scrub, so the wheel feels heavy, slow and disconnected. On this
// route we switch to a snappy `lerp`-based follow + a higher wheel multiplier:
// the page travels fast and stays tightly locked to the wheel, while each
// section's scrub supplies the visual smoothing. Other routes keep the softer
// duration-based feel that suits their normal content.
const isMileTruth =
pathname === "/miletruth" || pathname.startsWith("/miletruth/");
const lenis = new Lenis(
isMileTruth
? {
lerp: 0.13, // snappy follow (higher = less smoothing lag)
wheelMultiplier: 1.3, // travel further per wheel tick → fast
touchMultiplier: 1.6,
orientation: "vertical",
gestureOrientation: "vertical",
smoothWheel: true,
}
: {
duration: 1.05,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
orientation: "vertical",
gestureOrientation: "vertical",
smoothWheel: true,
},
);
if (!window.location.hash) {
lenis.scrollTo(0, { immediate: true });
window.scrollTo(0, 0);
} else {
const scrollToHash = () => {
try {
const target = document.querySelector(window.location.hash) as HTMLElement | null;
if (target) {
lenis.scrollTo(target, { immediate: true });
}
} catch (err) {
console.warn(err);
}
};
scrollToHash();
hashTimer = setTimeout(scrollToHash, 100);
}
lenis.on("scroll", ScrollTrigger.update);
const tickerCb = (time: number) => lenis.raf(time * 1000); // ticker is seconds, Lenis wants ms
gsap.ticker.add(tickerCb);
gsap.ticker.lagSmoothing(0);
ScrollTrigger.refresh();
return () => {
if (hashTimer) clearTimeout(hashTimer);
gsap.ticker.remove(tickerCb);
lenis.destroy();
};
}, [pathname]);
return null;
}