Mobile scroll perf: cut paint cost and re-render rate
Mobile scrolling was janky from a stack of expensive paint passes running every frame. This pass: - CSS: hide .grain (SVG feTurbulence is the single biggest cost), drop mix-blend-mode on .haze/.smoke-puff, drop backdrop-filter on .cmd-box, hide the blurred arm-glow, and remove blur filters from chip-glow / conveyor-pulse / scan-tear under <=760px. - JSX: capture an IS_MOBILE flag once at boot. On mobile, halve smoke puffs / chimneys / sparks / chip-pulse dots / embers, and switch GSAP scrub from 0.6 (rAF interpolation) to true (direct scroll-tied) — which is cheaper and more responsive. - HUD: gate setProgress() on a changed integer depth (0..100) so we re-render at most ~100 times per full scroll instead of every frame. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
33
app.jsx
33
app.jsx
@@ -3,6 +3,12 @@
|
|||||||
|
|
||||||
const { useEffect, useRef, useState } = React;
|
const { useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
|
// Mobile heuristic — captured once at boot. Used to skip expensive
|
||||||
|
// effects (extra particles, heavy scrub smoothing) on phones.
|
||||||
|
const IS_MOBILE =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(max-width: 760px), (pointer: coarse) and (max-width: 900px)").matches;
|
||||||
|
|
||||||
/* ───────────────── PixelLogo ─────────────────
|
/* ───────────────── PixelLogo ─────────────────
|
||||||
Bitmap glyphs rendered as a CSS grid of <span>s, so we control individual
|
Bitmap glyphs rendered as a CSS grid of <span>s, so we control individual
|
||||||
pixel colors / glow / animations. Original blocky design (no copyrighted mark). */
|
pixel colors / glow / animations. Original blocky design (no copyrighted mark). */
|
||||||
@@ -186,7 +192,7 @@ function PixelLogoStatic({ word, scale, gap, color }) {
|
|||||||
Each puff is a chunky, low-res-feeling translucent disc that drifts up + fades. */
|
Each puff is a chunky, low-res-feeling translucent disc that drifts up + fades. */
|
||||||
function SmokeLayer({ chimneys, scale = 1, intensity = 1 }) {
|
function SmokeLayer({ chimneys, scale = 1, intensity = 1 }) {
|
||||||
// Each chimney emits N puffs on staggered phases.
|
// Each chimney emits N puffs on staggered phases.
|
||||||
const PUFFS_PER = 3;
|
const PUFFS_PER = IS_MOBILE ? 2 : 3;
|
||||||
const puffs = [];
|
const puffs = [];
|
||||||
chimneys.forEach((ch, ci) => {
|
chimneys.forEach((ch, ci) => {
|
||||||
for (let p = 0; p < PUFFS_PER; p++) {
|
for (let p = 0; p < PUFFS_PER; p++) {
|
||||||
@@ -269,7 +275,8 @@ function SparkLayer() {
|
|||||||
const sparks = useRef(null);
|
const sparks = useRef(null);
|
||||||
if (!sparks.current) {
|
if (!sparks.current) {
|
||||||
const arr = [];
|
const arr = [];
|
||||||
for (let i = 0; i < 14; i++) {
|
const count = IS_MOBILE ? 7 : 14;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
arr.push({
|
arr.push({
|
||||||
a: -90 + (Math.random() - 0.5) * 160, // angle deg
|
a: -90 + (Math.random() - 0.5) * 160, // angle deg
|
||||||
d: 18 + Math.random() * 36, // distance
|
d: 18 + Math.random() * 36, // distance
|
||||||
@@ -310,7 +317,7 @@ function SparkLayer() {
|
|||||||
Subtle pulsing orange dots over the PCB area. */
|
Subtle pulsing orange dots over the PCB area. */
|
||||||
function ChipPulses() {
|
function ChipPulses() {
|
||||||
// hand-placed % over the chip image
|
// hand-placed % over the chip image
|
||||||
const dots = [
|
const allDots = [
|
||||||
{ x: 76, y: 38, dur: 2.4, delay: 0 },
|
{ x: 76, y: 38, dur: 2.4, delay: 0 },
|
||||||
{ x: 82, y: 28, dur: 2.0, delay: 0.7 },
|
{ x: 82, y: 28, dur: 2.0, delay: 0.7 },
|
||||||
{ x: 88, y: 33, dur: 2.8, delay: 1.2 },
|
{ x: 88, y: 33, dur: 2.8, delay: 1.2 },
|
||||||
@@ -320,6 +327,7 @@ function ChipPulses() {
|
|||||||
{ x: 70, y: 48, dur: 3.0, delay: 0.2 },
|
{ x: 70, y: 48, dur: 3.0, delay: 0.2 },
|
||||||
{ x: 95, y: 70, dur: 2.4, delay: 0.9 },
|
{ x: 95, y: 70, dur: 2.4, delay: 0.9 },
|
||||||
];
|
];
|
||||||
|
const dots = IS_MOBILE ? allDots.filter((_, i) => i % 2 === 0) : allDots;
|
||||||
return (
|
return (
|
||||||
<div className="chip-pulses">
|
<div className="chip-pulses">
|
||||||
{dots.map((d, i) => (
|
{dots.map((d, i) => (
|
||||||
@@ -364,9 +372,9 @@ function HeroSection({ bgRef, contentRef }) {
|
|||||||
className="bg-image"
|
className="bg-image"
|
||||||
style={{ backgroundImage: "url(assets/exterior-factory.png)" }}
|
style={{ backgroundImage: "url(assets/exterior-factory.png)" }}
|
||||||
/>
|
/>
|
||||||
<SmokeLayer chimneys={chimneys} />
|
<SmokeLayer chimneys={IS_MOBILE ? chimneys.filter((_, i) => i % 2 === 0) : chimneys} />
|
||||||
<EmberLayer
|
<EmberLayer
|
||||||
count={22}
|
count={IS_MOBILE ? 10 : 22}
|
||||||
area={{ x: 32, y: 48, w: 65, h: 8 }}
|
area={{ x: 32, y: 48, w: 65, h: 8 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,6 +516,7 @@ function App() {
|
|||||||
const instSceneRef = useRef(null);
|
const instSceneRef = useRef(null);
|
||||||
|
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const lastDepth = useRef(-1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.gsap || !window.ScrollTrigger) return;
|
if (!window.gsap || !window.ScrollTrigger) return;
|
||||||
@@ -527,8 +536,18 @@ function App() {
|
|||||||
trigger: ".scroll-track",
|
trigger: ".scroll-track",
|
||||||
start: "top top",
|
start: "top top",
|
||||||
end: "bottom bottom",
|
end: "bottom bottom",
|
||||||
scrub: 0.6,
|
// On mobile, scrub:true (no smoothing lag) feels snappier and
|
||||||
onUpdate: (self) => setProgress(self.progress),
|
// skips an extra rAF interpolation per frame.
|
||||||
|
scrub: IS_MOBILE ? true : 0.6,
|
||||||
|
onUpdate: (self) => {
|
||||||
|
// Re-render only when the displayed depth (0..100) changes —
|
||||||
|
// otherwise we re-render the whole HUD on every scroll frame.
|
||||||
|
const depth = Math.round(self.progress * 100);
|
||||||
|
if (depth !== lastDepth.current) {
|
||||||
|
lastDepth.current = depth;
|
||||||
|
setProgress(self.progress);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=VT323&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=VT323&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="styles.css?v=20260503-mobile" />
|
<link rel="stylesheet" href="styles.css?v=20260503-mobile2" />
|
||||||
|
|
||||||
<!-- Preload background images -->
|
<!-- Preload background images -->
|
||||||
<link rel="preload" as="image" href="assets/exterior-factory.png" />
|
<link rel="preload" as="image" href="assets/exterior-factory.png" />
|
||||||
@@ -28,6 +28,6 @@
|
|||||||
<script src="https://unpkg.com/gsap@3.12.5/dist/gsap.min.js"></script>
|
<script src="https://unpkg.com/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||||
<script src="https://unpkg.com/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
|
<script src="https://unpkg.com/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
|
||||||
|
|
||||||
<script type="text/babel" src="app.jsx?v=20260503-mobile"></script>
|
<script type="text/babel" src="app.jsx?v=20260503-mobile2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
15
styles.css
15
styles.css
@@ -910,6 +910,21 @@ a { color: inherit; text-decoration: none; }
|
|||||||
still plays out, just over a shorter total scroll distance. */
|
still plays out, just over a shorter total scroll distance. */
|
||||||
.scroll-track { height: 480vh; }
|
.scroll-track { height: 480vh; }
|
||||||
|
|
||||||
|
/* Perf: kill the heaviest paint work on mobile GPUs.
|
||||||
|
- SVG feTurbulence in .grain is the single most expensive thing
|
||||||
|
on the page; dropping it gives the biggest scroll-jank win.
|
||||||
|
- mix-blend-mode and backdrop-filter both force off-screen
|
||||||
|
compositing passes per frame. */
|
||||||
|
.grain { display: none; }
|
||||||
|
.haze, .haze-deep, .smoke-puff { mix-blend-mode: normal; }
|
||||||
|
.cmd-box { backdrop-filter: none; }
|
||||||
|
/* The blurred arm-glow and chip-glow are decorative — drop the
|
||||||
|
blurs that force a re-rasterization on every scrub frame. */
|
||||||
|
.arm-pivot { display: none; }
|
||||||
|
.conveyor-pulse { filter: none; }
|
||||||
|
.chip-glow { filter: none; }
|
||||||
|
.logo-wrap::before { filter: none; }
|
||||||
|
|
||||||
/* Re-aim the camera so the meaningful part of each landscape image
|
/* Re-aim the camera so the meaningful part of each landscape image
|
||||||
stays roughly in frame on a portrait viewport. */
|
stays roughly in frame on a portrait viewport. */
|
||||||
.bg-wrap { transform-origin: 70% 60% !important; }
|
.bg-wrap { transform-origin: 70% 60% !important; }
|
||||||
|
|||||||
Reference in New Issue
Block a user