From dbe965d0e915b9c28fcd7083533c8ea65fcca7fb Mon Sep 17 00:00:00 2001 From: Knowit Date: Sun, 3 May 2026 03:09:44 +0800 Subject: [PATCH] Mobile scroll perf: cut paint cost and re-render rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app.jsx | 33 ++++++++++++++++++++++++++------- facere.html | 4 ++-- styles.css | 15 +++++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/app.jsx b/app.jsx index e2dbb5a..7fe5aca 100644 --- a/app.jsx +++ b/app.jsx @@ -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 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 (
{dots.map((d, i) => ( @@ -364,9 +372,9 @@ function HeroSection({ bgRef, contentRef }) { className="bg-image" style={{ backgroundImage: "url(assets/exterior-factory.png)" }} /> - + i % 2 === 0) : chimneys} />
@@ -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); + } + }, }, }); diff --git a/facere.html b/facere.html index 27eb938..1cd1be8 100644 --- a/facere.html +++ b/facere.html @@ -9,7 +9,7 @@ - + @@ -28,6 +28,6 @@ - + diff --git a/styles.css b/styles.css index 103fb4e..73adced 100644 --- a/styles.css +++ b/styles.css @@ -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; }