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);
}
},
},
});