From a73a65849591fd8a406205d0cf3b04e281c2cbb2 Mon Sep 17 00:00:00 2001 From: DANNY NA Date: Wed, 15 Jul 2026 10:45:20 +0900 Subject: [PATCH 1/3] perf(web): render membrane nebula at 1/4 resolution to kill load jank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed background canvas painted 6 screen-sized radial-gradient glow clouds every frame directly to the main canvas. A forced-raster microbenchmark at 2560x1237 measured this at ~21ms/frame — on its own over the 16.7ms frame budget — making it the dominant cost of the heavy first animation frames that produce the intermittent startup jank. The clouds are inherently soft, so render them to a 1/4-resolution offscreen canvas and upscale-blit once per frame. Drift and pulse stay fully live; only the fill overdraw shrinks. Measured 21.2ms -> 0.28ms per frame (~77x). Stars (0.37ms) and the frame shadowBlur (0.9ms) were confirmed minor and left unchanged. Note: felt jank must be confirmed live (rAF is suspended in the background automation tab used to benchmark), but the dominant per-frame op cost is verified to drop dramatically. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/hooks/useMembrane.ts | 42 +++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/apps/web/src/hooks/useMembrane.ts b/apps/web/src/hooks/useMembrane.ts index b9810cf..293b2a1 100644 --- a/apps/web/src/hooks/useMembrane.ts +++ b/apps/web/src/hooks/useMembrane.ts @@ -35,6 +35,15 @@ export function useMembrane(canvasRef: React.RefObject if (!ctx) { return } const reduced = prefersReducedMotion() + /* 은하 가스 레이어 — 화면 크기의 거대한 라디얼 글로우를 매 프레임 6번 칠하면 + 프레임당 ~20ms를 잡아먹어 로드·상시 jank의 주범이 된다(fill 오버드로우). + 구름이 본디 흐릿하므로 1/4 해상도 오프스크린에 그린 뒤 업스케일 블릿한다 + — 드리프트·펄스는 매 프레임 그대로 유지하면서 비용만 ~77배 줄어든다. */ + const NEB_SCALE = 0.25 + const nebCanvas = document.createElement('canvas') + const nebCtx = nebCanvas.getContext('2d') + let nebW = 0; let nebH = 0 + const C = { star: (a: number) => `rgba(196, 208, 228, ${a})`, raw: (a: number) => `rgba(150, 158, 178, ${a})`, @@ -61,6 +70,8 @@ export function useMembrane(canvasRef: React.RefObject W = rect.width; H = rect.height canvas!.width = W * dpr; canvas!.height = H * dpr ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) + nebW = Math.max(1, Math.ceil(W * NEB_SCALE)); nebH = Math.max(1, Math.ceil(H * NEB_SCALE)) + nebCanvas.width = nebW; nebCanvas.height = nebH cx = W * (W > 920 ? 0.66 : 0.5) // 모바일: 오브제를 상단-중앙(0.36)에 두어 내비 아래 여백을 확보하고, // 하단으로 올라온 히어로 카피와 균형을 이룬다(살짝 작게). @@ -339,19 +350,24 @@ export function useMembrane(canvasRef: React.RefObject cosY = Math.cos(TH + yawO); sinY = Math.sin(TH + yawO) cp_ = Math.cos(pitchO); sp_ = Math.sin(pitchO) - /* 은하 가스 글로우 */ - for (const nb of nebula) { - nb.x += nb.dx; nb.y += nb.dy - if (nb.x < -nb.r) { nb.x = W + nb.r } - else if (nb.x > W + nb.r) { nb.x = -nb.r } - if (nb.y < -nb.r) { nb.y = H + nb.r } - else if (nb.y > H + nb.r) { nb.y = -nb.r } - const na = nb.a * (0.75 + 0.25 * Math.sin(tm * nb.sp + nb.ph)) - const g = ctx!.createRadialGradient(nb.x, nb.y, 0, nb.x, nb.y, nb.r) - g.addColorStop(0, nb.col(na)) - g.addColorStop(1, nb.col(0)) - ctx!.fillStyle = g - ctx!.beginPath(); ctx!.arc(nb.x, nb.y, nb.r, 0, 7); ctx!.fill() + /* 은하 가스 글로우 — 1/4 해상도 오프스크린에 렌더 후 업스케일 블릿 */ + if (nebCtx) { + nebCtx.clearRect(0, 0, nebW, nebH) + for (const nb of nebula) { + nb.x += nb.dx; nb.y += nb.dy + if (nb.x < -nb.r) { nb.x = W + nb.r } + else if (nb.x > W + nb.r) { nb.x = -nb.r } + if (nb.y < -nb.r) { nb.y = H + nb.r } + else if (nb.y > H + nb.r) { nb.y = -nb.r } + const na = nb.a * (0.75 + 0.25 * Math.sin(tm * nb.sp + nb.ph)) + const sx = nb.x * NEB_SCALE; const sy = nb.y * NEB_SCALE; const sr = nb.r * NEB_SCALE + const g = nebCtx.createRadialGradient(sx, sy, 0, sx, sy, sr) + g.addColorStop(0, nb.col(na)) + g.addColorStop(1, nb.col(0)) + nebCtx.fillStyle = g + nebCtx.beginPath(); nebCtx.arc(sx, sy, sr, 0, 7); nebCtx.fill() + } + ctx!.drawImage(nebCanvas, 0, 0, nebW, nebH, 0, 0, W, H) } for (const s of stars) { From 2fd9e0fac55323e317f209534ef420db748ea1a1 Mon Sep 17 00:00:00 2001 From: DANNY NA Date: Wed, 15 Jul 2026 10:49:59 +0900 Subject: [PATCH 2/3] perf(web): guard nebula blit against zero-size source Belt-and-suspenders: skip the offscreen render + drawImage when the nebula buffer dimensions are 0, so no IndexSizeError can fire on the first frame before resize() runs (Safari throws on a zero-size source rect). Addresses PR review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/hooks/useMembrane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useMembrane.ts b/apps/web/src/hooks/useMembrane.ts index 293b2a1..0565f90 100644 --- a/apps/web/src/hooks/useMembrane.ts +++ b/apps/web/src/hooks/useMembrane.ts @@ -351,7 +351,7 @@ export function useMembrane(canvasRef: React.RefObject cp_ = Math.cos(pitchO); sp_ = Math.sin(pitchO) /* 은하 가스 글로우 — 1/4 해상도 오프스크린에 렌더 후 업스케일 블릿 */ - if (nebCtx) { + if (nebCtx && nebW > 0 && nebH > 0) { nebCtx.clearRect(0, 0, nebW, nebH) for (const nb of nebula) { nb.x += nb.dx; nb.y += nb.dy From b255bfb4e00bd15c4d33bf938c5017fd413aa067 Mon Sep 17 00:00:00 2001 From: DANNY NA Date: Wed, 15 Jul 2026 11:00:12 +0900 Subject: [PATCH 3/3] chore: retrigger CI for fresh CodSpeed baseline CodSpeed flagged a phantom -32% regression on an unrelated Rust benchmark (parse_k8s_path) though this branch changes only frontend TS. The first commit's CodSpeed run passed; forcing a fresh measurement. Co-Authored-By: Claude Opus 4.8 (1M context)