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:
2026-05-03 03:09:44 +08:00
parent 277d161a95
commit dbe965d0e9
3 changed files with 43 additions and 9 deletions

33
app.jsx
View File

@@ -3,6 +3,12 @@
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 ─────────────────
Bitmap glyphs rendered as a CSS grid of <span>s, so we control individual
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. */
function SmokeLayer({ chimneys, scale = 1, intensity = 1 }) {
// Each chimney emits N puffs on staggered phases.
const PUFFS_PER = 3;
const PUFFS_PER = IS_MOBILE ? 2 : 3;
const puffs = [];
chimneys.forEach((ch, ci) => {
for (let p = 0; p < PUFFS_PER; p++) {
@@ -269,7 +275,8 @@ function SparkLayer() {
const sparks = useRef(null);
if (!sparks.current) {
const arr = [];
for (let i = 0; i < 14; i++) {
const count = IS_MOBILE ? 7 : 14;
for (let i = 0; i < count; i++) {
arr.push({
a: -90 + (Math.random() - 0.5) * 160, // angle deg
d: 18 + Math.random() * 36, // distance
@@ -310,7 +317,7 @@ function SparkLayer() {
Subtle pulsing orange dots over the PCB area. */
function ChipPulses() {
// hand-placed % over the chip image
const dots = [
const allDots = [
{ x: 76, y: 38, dur: 2.4, delay: 0 },
{ x: 82, y: 28, dur: 2.0, delay: 0.7 },
{ 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: 95, y: 70, dur: 2.4, delay: 0.9 },
];
const dots = IS_MOBILE ? allDots.filter((_, i) => i % 2 === 0) : allDots;
return (
<div className="chip-pulses">
{dots.map((d, i) => (
@@ -364,9 +372,9 @@ function HeroSection({ bgRef, contentRef }) {
className="bg-image"
style={{ backgroundImage: "url(assets/exterior-factory.png)" }}
/>
<SmokeLayer chimneys={chimneys} />
<SmokeLayer chimneys={IS_MOBILE ? chimneys.filter((_, i) => i % 2 === 0) : chimneys} />
<EmberLayer
count={22}
count={IS_MOBILE ? 10 : 22}
area={{ x: 32, y: 48, w: 65, h: 8 }}
/>
</div>
@@ -508,6 +516,7 @@ function App() {
const instSceneRef = useRef(null);
const [progress, setProgress] = useState(0);
const lastDepth = useRef(-1);
useEffect(() => {
if (!window.gsap || !window.ScrollTrigger) return;
@@ -527,8 +536,18 @@ function App() {
trigger: ".scroll-track",
start: "top top",
end: "bottom bottom",
scrub: 0.6,
onUpdate: (self) => setProgress(self.progress),
// On mobile, scrub:true (no smoothing lag) feels snappier and
// 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);
}
},
},
});

View File

@@ -9,7 +9,7 @@
<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 rel="stylesheet" href="styles.css?v=20260503-mobile" />
<link rel="stylesheet" href="styles.css?v=20260503-mobile2" />
<!-- Preload background images -->
<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/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>
</html>

View File

@@ -910,6 +910,21 @@ a { color: inherit; text-decoration: none; }
still plays out, just over a shorter total scroll distance. */
.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
stays roughly in frame on a portrait viewport. */
.bg-wrap { transform-origin: 70% 60% !important; }