Files
phy-vr360/docs/superpowers/plans/2026-06-25-toi-uu-hieu-nang.md
T
2026-06-25 08:40:42 +07:00

33 KiB
Raw Blame History

Kế hoạch triển khai: Tối ưu hiệu năng web phy-vr360

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Giảm tải GPU/CPU để web hết giật/lag, laptop không còn nóng/quạt rú, vẫn giữ thẩm mỹ.

Architecture: Tối ưu theo 4 giai đoạn — (1) chặn vòng lặp vẽ chạy nền vô ích, (2) nén tài nguyên ảnh/model + giới hạn cache VRAM, (3) cắt animation CSS luôn-bật & hiệu ứng nặng, (4) cấu hình build + chia nhỏ bundle. Mỗi giai đoạn đo lại trước khi sang bước kế.

Tech Stack: Next.js 16 (bản đã chỉnh sửa), React 19, three.js 0.184, framer-motion, @paper-design/shaders-react, sharp (đã cài), @gltf-transform/cli (sẽ cài).

Spec: docs/superpowers/specs/2026-06-25-toi-uu-hieu-nang-design.md


Lưu ý chung cho người triển khai

  • Next.js 16 ở đây là bản đã chỉnh sửa (AGENTS.md). Trước khi sửa next.config.ts hoặc dùng next/dynamic, đọc tài liệu tương ứng trong node_modules/next/dist/docs/ để dùng đúng API.
  • Không có test tự động cho hiệu năng. "Kiểm chứng" ở đây = npm run lint + npm run build pass, xem bằng mắt không vỡ giao diện, và đo (DevTools Performance/Rendering, kích thước file). Mỗi task nêu rõ cách đo.
  • Làm việc trên nhánh main (đúng quy ước hiện tại của dự án). Commit thường xuyên, mỗi task một commit.
  • Mở DevTools → Rendering → "Frame Rendering Stats" và tab Performance để so trước/sau. Đếm ngữ cảnh WebGL bằng about:gpu hoặc đếm thẻ <canvas> đang vẽ.

File Structure

File Trách nhiệm Hành động
src/app/lib/render-gate.ts Tiện ích chung: bật/tắt vòng lặp vẽ theo khả năng nhìn + trạng thái tab Create
src/app/components/ui/hero-shader-background.tsx Nền hero: bỏ canvas three.js, còn 1 shader, gate theo tầm nhìn Modify
src/app/components/ui/hero-panorama-background.tsx Canvas three.js xoay (gỡ khỏi hero) Delete (hoặc bỏ dùng)
src/app/components/antiquity/AntiquityViewer.tsx Viewer .glb: render theo nhu cầu + gate + giảm pixelRatio + meshopt Modify
scripts/optimize-images.mjs Nén panorama: 4096px + xuất WebP, sinh LQIP theo path webp Modify
scripts/optimize-models.mjs Nén .glb (meshopt + texture webp), backup bản gốc Create
src/app/tours/chua-thai-lac.ts Trỏ ảnh tour sang .webp Modify
src/app/components/VirtualTour.tsx Cache texture có giới hạn (LRU) + pin cảnh hiện tại/lân cận Modify
src/app/globals.css Tắt animation vô hạn ít giá trị, giảm blur/will-change Modify
src/app/layout.tsx (Tùy chọn) tinh giản lớp grain Modify
next.config.ts Bật tối ưu ảnh (AVIF/WebP) Modify
src/app/tour/chua-thai-lac/page.tsx Dynamic import viewer Modify
src/app/co-vat/page.tsx Dynamic import showcase Modify
package.json Thêm script optimize:images, optimize:models Modify

GIAI ĐOẠN 1 — Chặn việc chạy nền vô ích

Task 1: Tiện ích render-gate dùng chung

Files:

  • Create: src/app/lib/render-gate.ts

  • Step 1: Tạo file tiện ích

// src/app/lib/render-gate.ts
// Chỉ cho phép vòng lặp vẽ (rAF/setAnimationLoop) chạy khi phần tử nằm trong vùng nhìn
// VÀ tab đang hiển thị. Gọi onChange(active) mỗi khi trạng thái đổi. Trả về hàm dọn dẹp.
export function createRenderGate(
  element: Element,
  onChange: (active: boolean) => void,
): () => void {
  let onScreen = false;
  let visible = typeof document === "undefined" ? true : !document.hidden;
  let last = false;

  const emit = () => {
    const next = onScreen && visible;
    if (next !== last) {
      last = next;
      onChange(next);
    }
  };

  const io = new IntersectionObserver(
    (entries) => {
      onScreen = entries.some((entry) => entry.isIntersecting);
      emit();
    },
    { threshold: 0.01 },
  );
  io.observe(element);

  const onVisibility = () => {
    visible = !document.hidden;
    emit();
  };
  document.addEventListener("visibilitychange", onVisibility);

  return () => {
    io.disconnect();
    document.removeEventListener("visibilitychange", onVisibility);
  };
}
  • Step 2: Kiểm chứng biên dịch

Run: npm run lint Expected: không lỗi mới ở file vừa tạo.

  • Step 3: Commit
git add src/app/lib/render-gate.ts
git commit -m "perf: tiện ích render-gate (dừng vòng lặp vẽ khi ngoài màn hình/đổi tab)"

Task 2: Hero — bỏ canvas three.js, còn 1 shader, gate theo tầm nhìn

Files:

  • Modify: src/app/components/ui/hero-shader-background.tsx
  • Delete: src/app/components/ui/hero-panorama-background.tsx

Hiện hero chồng: ảnh nền CSS + HeroPanoramaBackground (canvas three.js xoay liên tục) + 2× MeshGradient + mix-blend-mode: soft-light. Sau task: ảnh nền CSS giữ nguyên (đã đủ "nền"), bỏ canvas three.js, còn 1 MeshGradient, bỏ soft-light, và chỉ render shader khi hero trong tầm nhìn.

  • Step 1: Viết lại hero-shader-background.tsx
"use client";

import { MeshGradient } from "@paper-design/shaders-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { createRenderGate } from "@/app/lib/render-gate";

interface HeroShaderBackgroundProps {
  children: React.ReactNode;
  imageSrc: string;
}

export function HeroShaderBackground({ children, imageSrc }: HeroShaderBackgroundProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const [shaderActive, setShaderActive] = useState(false);

  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    return createRenderGate(el, setShaderActive);
  }, []);

  return (
    <div
      ref={rootRef}
      className="relative isolate overflow-hidden border-b border-[var(--surface-border)]"
    >
      <div
        aria-hidden
        className="absolute inset-0 bg-cover bg-center opacity-[0.58] saturate-[1.12] contrast-[1.04]"
        style={{ backgroundImage: `url("${imageSrc}")` }}
      />
      <div aria-hidden className="absolute inset-0 bg-[rgb(243_247_240_/_0.12)]" />

      {/* Một lớp shader duy nhất, chỉ vẽ khi hero trong tầm nhìn + tab hiển thị */}
      {shaderActive ? (
        <div aria-hidden className="absolute inset-0 overflow-hidden opacity-40">
          <MeshGradient
            className="absolute inset-0 h-full w-full"
            colors={["#f8fbf2", "#d6b468", "#e0eddf", "#245f4b", "#ffffff"]}
            speed={0.24}
          />
        </div>
      ) : null}

      <div
        aria-hidden
        className="absolute inset-0 bg-[linear-gradient(90deg,rgb(243_247_240_/_0.93)_0%,rgb(243_247_240_/_0.72)_42%,rgb(243_247_240_/_0.14)_100%)]"
      />
      <div
        aria-hidden
        className="absolute inset-x-0 bottom-0 h-44 bg-[linear-gradient(180deg,transparent,rgb(243_247_240_/_0.98))]"
      />

      {children}
    </div>
  );
}

Ghi chú: đã bỏ HeroPanoramaBackground, bỏ <svg> filter feTurbulence, bỏ MeshGradient thứ 2 và mix-blend-mode: soft-light. Ảnh nền tĩnh (imageSrc, vốn là 01.jpg) vẫn cho cảm giác panorama.

  • Step 2: Xóa component không còn dùng
git rm src/app/components/ui/hero-panorama-background.tsx
  • Step 3: Kiểm chứng không còn tham chiếu

Run: grep -rn "hero-panorama-background\|HeroPanoramaBackground" src/ Expected: không còn kết quả.

  • Step 4: Build + xem bằng mắt

Run: npm run build Expected: build pass. Chạy npm run dev, mở / — hero vẫn đẹp; mở DevTools → chỉ còn 01 canvas WebGL ở hero (trước đây 3 lớp). Cuộn xuống dưới: shader ngừng vẽ (kiểm tra Performance không còn frame liên tục từ hero).

  • Step 5: Commit
git add -A
git commit -m "perf: hero bỏ canvas three.js + còn 1 shader, gate theo tầm nhìn (giảm 3 ngữ cảnh WebGL)"

Task 3: AntiquityViewer — render theo nhu cầu + gate + giảm pixelRatio

Files:

  • Modify: src/app/components/antiquity/AntiquityViewer.tsx

Hiện tick() render mọi frame vô điều kiện (kể cả khi đứng yên, autoRotate tắt) và pixelRatio tới 2. Chuyển sang render-on-demand như tour viewer + gate khi ngoài màn hình + pixelRatio tối đa 1.5.

  • Step 1: Thêm import

Sửa đầu file, thêm 2 import (sau dòng import { RoomEnvironment } ...):

import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { createRenderGate } from "@/app/lib/render-gate";
  • Step 2: Giảm pixelRatio

Sửa AntiquityViewer.tsx dòng pixelRatio:

// CŨ:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// MỚI:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
  • Step 3: Nạp meshopt decoder cho GLTFLoader

Sửa chỗ tạo loader:

// CŨ:
const loader = new GLTFLoader();
// MỚI:
const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
  • Step 4: Render theo nhu cầu + gate

Thay khối từ const resize = () => { (giữ nguyên resize) và đặc biệt thay vòng tick + thêm gate. Cụ thể, thay đoạn:

    const tick = () => {
      controls.update();
      renderer.render(scene, camera);
      raf = requestAnimationFrame(tick);
    };
    tick();

bằng:

    let needsRender = true;
    let active = true;
    const requestRender = () => {
      needsRender = true;
    };
    controls.addEventListener("change", requestRender);

    const stopGate = createRenderGate(mount, (isActive) => {
      active = isActive;
      if (isActive) requestRender();
    });

    const tick = () => {
      raf = requestAnimationFrame(tick);
      if (!active) return;
      // controls.update() trả về true khi camera còn chuyển động (damping/autoRotate)
      const moving = controls.update();
      if (needsRender || moving || controls.autoRotate) {
        renderer.render(scene, camera);
        needsRender = false;
      }
    };
    tick();

Thêm requestRender() sau khi model load xong (trong callback loader.load success, ngay sau setStatus("ready");):

        setStatus("ready");
        requestRender();

Trong resize, ép vẽ lại — sửa cuối hàm resize:

    const resize = () => {
      const w = mount.clientWidth;
      const h = mount.clientHeight;
      if (!w || !h) return;
      renderer.setSize(w, h, false);
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
      needsRender = true;
    };

needsRender được khai báo sau resize trong code hiện tại — chuyển khai báo let needsRender = true; lên trước hàm resize (ngay sau dòng tạo controls/controlsRef.current = controls;) để resize dùng được. Đảm bảo thứ tự: khai báo needsRender/active/requestRender trước cả resizetick.

  • Step 5: Dọn dẹp trong cleanup

Trong hàm cleanup (return của useEffect), thêm trước controls.dispose();:

      stopGate();
      controls.removeEventListener("change", requestRender);
  • Step 6: Build + đo

Run: npm run build Expected: pass. Chạy dev, mở /co-vat:

  • Khi tắt tự xoay và không tương tác → DevTools Performance cho thấy không còn render liên tục (đường GPU phẳng).

  • Cuộn để viewer ra khỏi màn hình → vòng lặp dừng.

  • Bật tự xoay vẫn mượt.

  • Step 7: Commit

git add src/app/components/antiquity/AntiquityViewer.tsx
git commit -m "perf: AntiquityViewer render theo nhu cầu + gate ngoài màn hình + pixelRatio 1.5 + meshopt"

GIAI ĐOẠN 2 — Pipeline tài nguyên

Task 4: Mở rộng script nén ảnh — 4096px + xuất WebP

Files:

  • Modify: scripts/optimize-images.mjs
  • Modify: package.json
  • Sinh ra: public/images/chua-thai-lac/NN.webp (×26) + cập nhật src/app/tours/chua-thai-lac.lqip.ts

Script hiện nén panorama in-place JPEG ở 6144px (vì vậy vẫn 24MB) và sinh LQIP theo path .jpg. Đổi sang: resize 4096px, xuất thêm WebP, và khóa LQIP theo path .webp (vì tour sẽ nạp .webp). Vẫn giữ nén .jpg (cho các <Image> next/image ở trang landing đang dùng img("NN.jpg")).

  • Step 1: Sửa hằng số
// CŨ:
const PANO_WIDTH = 6144;
// MỚI:
const PANO_WIDTH = 4096;
const PANO_WEBP_QUALITY = 78;
  • Step 2: Trong optimizePanoramas() — xuất thêm WebP và khóa LQIP theo .webp

Thay thân vòng for (const f of files) { ... } bằng:

  for (const f of files) {
    const dest = path.join(PANO_DIR, f);
    const src = await sourceFrom(dest, "chua-thai-lac");
    const before = (await fs.stat(dest)).size;

    // JPEG (giữ cho next/image ở trang landing) — cũng hạ về 4096
    const jpgBuf = await sharp(src)
      .rotate()
      .resize({ width: PANO_WIDTH, withoutEnlargement: true })
      .jpeg({ quality: PANO_QUALITY, mozjpeg: true, progressive: true })
      .toBuffer();
    await fs.writeFile(dest, jpgBuf);

    // WebP (tour viewer nạp bản này) — cùng tên, đuôi .webp
    const webpName = f.replace(/\.jpe?g$/i, ".webp");
    const webpDest = path.join(PANO_DIR, webpName);
    const webpBuf = await sharp(src)
      .rotate()
      .resize({ width: PANO_WIDTH, withoutEnlargement: true })
      .webp({ quality: PANO_WEBP_QUALITY })
      .toBuffer();
    await fs.writeFile(webpDest, webpBuf);

    // LQIP khóa theo path .webp (đúng path tour yêu cầu)
    const lqipBuf = await sharp(src).rotate().resize({ width: LQIP_WIDTH }).jpeg({ quality: 40 }).toBuffer();
    lqip["/images/chua-thai-lac/" + webpName] = "data:image/jpeg;base64," + lqipBuf.toString("base64");

    console.log(
      `${f}: ${fmt(before)} -> jpg ${fmt(jpgBuf.length)} | webp ${fmt(webpBuf.length)}`,
    );
  }
  • Step 3: Thêm npm script

Trong package.json, mục scripts, thêm:

    "optimize:images": "node scripts/optimize-images.mjs",
  • Step 4: Chạy script (an toàn — luôn nén từ backup gốc trong image-originals/)

Run: npm run optimize:images Expected: in ra mỗi ảnh … -> jpg ~0.61.0MB | webp ~0.250.5MB; cuối cùng Wrote src/app/tours/chua-thai-lac.lqip.ts (26 entries).

  • Step 5: Đo tổng dung lượng giảm

Run (PowerShell):

"{0:N1} MB" -f ((Get-ChildItem public/images/chua-thai-lac/*.webp | Measure-Object Length -Sum).Sum/1MB)

Expected: tổng WebP ~712MB (so với ~70MB JPEG 6144 trước đây).

  • Step 6: Commit
git add scripts/optimize-images.mjs package.json public/images/chua-thai-lac/ src/app/tours/chua-thai-lac.lqip.ts
git commit -m "perf: nén panorama 4096px + xuất WebP, LQIP theo path webp"

Task 5: Trỏ tour sang ảnh WebP

Files:

  • Modify: src/app/tours/chua-thai-lac.ts:12

Đổi một dòng imageForFile để mọi scene.image, welcomeBackgroundImage, welcomeCards[].image tự động dùng .webp (đều derive từ hàm này).

  • Step 1: Sửa imageForFile
// CŨ:
const imageForFile = (fileNumber: number) => `${base}/${String(fileNumber).padStart(2, "0")}.jpg`;
// MỚI:
const imageForFile = (fileNumber: number) => `${base}/${String(fileNumber).padStart(2, "0")}.webp`;
  • Step 2: Build + xem tour

Run: npm run build Expected: pass. Chạy dev, mở /tour/chua-thai-lac → ảnh 360 hiển thị bình thường (giờ tải bản webp nhẹ), blur-up LQIP vẫn hiện (vì LQIP đã khóa theo .webp). Mạng (Network tab) tải .webp ~0.3MB thay vì .jpg vài MB.

  • Step 3: Commit
git add src/app/tours/chua-thai-lac.ts
git commit -m "perf: tour nạp panorama WebP thay cho JPEG"

Task 6: Cache texture có giới hạn (LRU) trong VirtualTour

Files:

  • Modify: src/app/components/VirtualTour.tsx (vùng cache module-level ~dòng 214285 và hàm preloadTourScene ~dòng 1312)

Hiện panoramaTextureCache giữ toàn bộ texture vĩnh viễn → tràn VRAM. Thêm giới hạn LRU + "ghim" cảnh hiện tại/lân cận để không bao giờ dispose texture đang dùng.

  • Step 1: Thêm hằng số + tiện ích cache (ngay sau khai báo panoramaTextureCache, gần dòng 214)
const MAX_CACHED_PANORAMAS = 8; // đủ cho cảnh hiện tại + các cảnh lân cận
const pinnedPanoramaUrls = new Set<string>();

// Ghim các ảnh không được phép dispose (cảnh hiện tại + lân cận).
function setPinnedPanoramas(urls: string[]) {
  pinnedPanoramaUrls.clear();
  urls.forEach((url) => pinnedPanoramaUrls.add(url));
}

// Đánh dấu ảnh là mới-dùng-nhất (Map giữ thứ tự chèn) rồi dọn nếu vượt giới hạn.
function touchPanoramaCache(image: string, texture: THREE.Texture) {
  panoramaTextureCache.delete(image);
  panoramaTextureCache.set(image, texture);
  evictPanoramaTextures();
}

function evictPanoramaTextures() {
  if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
    return;
  }
  for (const [url, texture] of panoramaTextureCache) {
    if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
      break;
    }
    if (pinnedPanoramaUrls.has(url)) {
      continue; // không dispose ảnh đang ghim (đang hiển thị/lân cận)
    }
    panoramaTextureCache.delete(url);
    texture.dispose();
  }
}
  • Step 2: Gọi touchPanoramaCache trong loadPanoramaTexture

Trong loadPanoramaTexture, nhánh cache-hit (đầu hàm):

// CŨ:
  if (cachedTexture) {
    onProgress?.(100);
    return Promise.resolve(cachedTexture);
  }
// MỚI:
  if (cachedTexture) {
    touchPanoramaCache(image, cachedTexture);
    onProgress?.(100);
    return Promise.resolve(cachedTexture);
  }

Và trong callback load thành công:

// CŨ:
        panoramaTextureCache.set(image, texture);
        panoramaTexturePromises.delete(image);
// MỚI:
        panoramaTextureCache.set(image, texture);
        evictPanoramaTextures();
        panoramaTexturePromises.delete(image);
  • Step 3: Ghim cảnh hiện tại + lân cận trong preloadTourScene

Hàm preloadTourScene (≈ dòng 1312) đã liệt kê cảnh hiện tại + các cảnh đích của hotspot. Thêm ghim ở đầu hàm, trước khi preload:

  const preloadTourScene = useCallback(
    (scene: TourScene) => {
      // Ghim ảnh đang dùng + các cảnh lân cận để LRU không dispose nhầm.
      const neighborImages = scene.hotspots
        .map((hotspot) => tourConfig.sceneById.get(hotspot.targetId)?.image)
        .filter((image): image is string => Boolean(image));
      setPinnedPanoramas([scene.image, ...neighborImages]);

      const preloadImage = (image: string) => {
        loadPanoramaTexture(image)
          .then((texture) => warmPanoramaTexture(image, texture))
          .catch(() => undefined);
      };
      // ... phần còn lại giữ nguyên
  • Step 4: Build + đo VRAM/độ mượt khi chuyển nhiều cảnh

Run: npm run build Expected: pass. Chạy dev, đi qua nhiều cảnh (>10) trong tour. Trước đây mọi texture tích lại; giờ DevTools → Memory (hoặc about:gpu) cho thấy số texture giữ lại ổn định (~8). Không có nhấp nháy/blank khi quay lại cảnh trước.

  • Step 5: Commit
git add src/app/components/VirtualTour.tsx
git commit -m "perf: giới hạn cache panorama (LRU) + ghim cảnh hiện tại/lân cận, tránh tràn VRAM"

Task 7: Nén model .glb (meshopt + texture WebP)

Files:

  • Create: scripts/optimize-models.mjs
  • Modify: package.json
  • Sinh ra: public/antiquity/chuong-1.glb, chuong-2.glb (ghi đè bản nén; gốc lưu ở model-originals/)

chuong-2.glb 40MB, chuong-1.glb 27MB → đơ khi parse. Nén bằng @gltf-transform/cli (meshopt cho geometry + nén texture webp). Decoder meshopt đã nạp ở Task 3.

  • Step 1: Cài công cụ

Run: npm i -D @gltf-transform/cli Expected: cài xong, có node_modules/.bin/gltf-transform.

  • Step 2: Tạo script nén model
// scripts/optimize-models.mjs
// Nén .glb: backup bản gốc rồi tối ưu (meshopt + nén texture webp).
// Chạy: node scripts/optimize-models.mjs  (an toàn chạy lại — luôn nén từ backup gốc)
import { promises as fs } from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const run = promisify(execFile);
const ROOT = process.cwd();
const MODEL_DIR = path.join(ROOT, "public/antiquity");
const BACKUP_DIR = path.join(ROOT, "model-originals");
const fmt = (b) => (b / 1024 / 1024).toFixed(2) + "MB";
const exists = (p) => fs.access(p).then(() => true).catch(() => false);

async function optimizeModel(file) {
  const dest = path.join(MODEL_DIR, file);
  const backup = path.join(BACKUP_DIR, file);
  await fs.mkdir(BACKUP_DIR, { recursive: true });
  if (!(await exists(backup))) {
    await fs.copyFile(dest, backup);
  }
  const before = (await fs.stat(dest)).size;
  // Luôn tối ưu TỪ backup gốc; ghi đè ra dest.
  const bin = path.join(ROOT, "node_modules/.bin/gltf-transform");
  await run(bin, [
    "optimize",
    backup,
    dest,
    "--compress", "meshopt",
    "--texture-compress", "webp",
  ]);
  const after = (await fs.stat(dest)).size;
  console.log(`${file}: ${fmt(before)} -> ${fmt(after)} (-${Math.round((1 - after / before) * 100)}%)`);
}

try {
  const files = (await fs.readdir(MODEL_DIR)).filter((f) => /\.glb$/i.test(f)).sort();
  for (const f of files) {
    await optimizeModel(f);
  }
  console.log("Done.");
} catch (err) {
  console.error("optimize-models failed:", err);
  process.exit(1);
}
  • Step 3: Thêm npm script

Trong package.json mục scripts, thêm:

    "optimize:models": "node scripts/optimize-models.mjs",
  • Step 4: Chạy nén

Run: npm run optimize:models Expected: in ra chuong-1.glb: 27.26MB -> ~38MB (-…%) và tương tự chuong-2.glb. Nếu công cụ báo lỗi về meshopt, thử --compress draco (decoder DRACOLoader cũng có sẵn — khi đó Task 3 đổi sang DRACOLoader + loader.setDRACOLoader(...), đường dẫn node_modules/three/examples/jsm/libs/draco/).

  • Step 5: Build + xem model

Run: npm run build Expected: pass. Chạy dev, mở /co-vat → 2 mô hình tải nhanh hơn hẳn, không đơ tab khi parse; xoay/zoom đúng hình khối, hoa văn còn nét. Network tab cho thấy .glb nhỏ hơn nhiều.

  • Step 6: Commit
git add scripts/optimize-models.mjs package.json package-lock.json public/antiquity/
git commit -m "perf: nén model .glb (meshopt + texture webp), backup bản gốc model-originals/"

GIAI ĐOẠN 3 — Ngân sách hiệu ứng CSS

Task 8: Tắt các animation vô hạn ít giá trị

Files:

  • Modify: src/app/globals.css

Giữ hiệu ứng xuất hiện-khi-cuộn (chạy 1 lần) và marquee. Tắt các vòng lặp infinite chạy mãi: public-ambient-pan, public-gradient-flow, logo/CTA "breathe", header "glint".

  • Step 1: Bỏ animation nền public-page::before

Trong .public-page::before, xóa dòng:

  animation: public-ambient-pan 18s ease-in-out infinite alternate;
  • Step 2: Gỡ animation chữ gradient (giữ gradient tĩnh)

Trong .public-gradient-text, xóa dòng:

  animation: public-gradient-flow 8s ease-in-out infinite alternate;
  • Step 3: Tắt các vòng "breathe"/"glint" trong khối @media (prefers-reduced-motion: no-preference)

Xóa các quy tắc gán animation vô hạn sau (giữ lại public-header-drop, public-line-draw, public-section-reveal, public-stagger-reveal, public-rise vì chỉ chạy 1 lần):

  .public-header::after { animation: public-header-glint 7s ease-in-out 1.2s infinite; }
  .public-logo-mark { animation: public-logo-breathe 4.8s ease-in-out infinite; }
  .public-header-cta,
  .public-cta { animation: public-cta-breathe 4.2s ease-in-out infinite; }
  .public-subpage-hero-image { animation: public-photo-breathe 12s ease-in-out infinite alternate; }
  .public-hero-inner .public-image-stage { animation: public-stage-float 7.2s ease-in-out 900ms infinite alternate; }
  .public-back-to-top[data-visible="true"] { animation: public-back-to-top-float 4.8s ease-in-out infinite; }

Xóa các quy tắc gán animation ở trên (giữ lại các thuộc tính khác của chính selector đó nếu nằm ngoài khối media này — các selector này trong khối media chỉ có mỗi dòng animation, nên xóa cả khối). @keyframes tương ứng có thể để lại (không tốn gì nếu không dùng) hoặc xóa kèm.

  • Step 4: Build + đo lúc rảnh

Run: npm run build Expected: pass. Chạy dev, mở /, không tương tác. DevTools → Performance ghi 5s khi đứng yên: trước đây có nhiều "Recalculate Style/Composite" lặp lại do animation nền; sau khi sửa, hoạt động compositor lúc rảnh giảm rõ rệt. Hiệu ứng cuộn-hiện và marquee vẫn chạy.

  • Step 5: Commit
git add src/app/globals.css
git commit -m "perf: tắt animation CSS vô hạn ít giá trị (ambient-pan, gradient-flow, breathe, glint)"

Task 9: Giảm backdrop-filter blur & will-change

Files:

  • Modify: src/app/globals.css

backdrop-filter: blur(18px) trên mọi .public-panel + will-change: transform (luôn-bật) rất tốn repaint/bộ nhớ layer.

  • Step 1: Giảm blur + bỏ will-change ở .public-panel
/* CŨ (trong .public-panel): */
  backdrop-filter: blur(18px) saturate(132%);
  -webkit-backdrop-filter: blur(18px) saturate(132%);
  will-change: transform;
/* MỚI: */
  backdrop-filter: blur(10px) saturate(120%);
  -webkit-backdrop-filter: blur(10px) saturate(120%);
  • Step 2: Giảm blur + bỏ will-change ở .public-card
/* CŨ (trong .public-card): */
  backdrop-filter: blur(12px) saturate(125%);
  -webkit-backdrop-filter: blur(12px) saturate(125%);
  will-change: transform;
/* MỚI: */
  backdrop-filter: blur(8px) saturate(118%);
  -webkit-backdrop-filter: blur(8px) saturate(118%);
  • Step 3: Build + đo cuộn

Run: npm run build Expected: pass. Chạy dev, cuộn trang /di-tich (nhiều card). DevTools → Rendering → bật "Paint flashing": vùng repaint khi cuộn ít/nhỏ hơn; FPS cuộn ổn định hơn. Giao diện vẫn có cảm giác kính mờ.

  • Step 4: Commit
git add src/app/globals.css
git commit -m "perf: giảm backdrop-blur và bỏ will-change luôn-bật trên panel/card"

GIAI ĐOẠN 4 — Cấu hình build & chia nhỏ bundle

Task 10: Bật tối ưu ảnh trong next.config

Files:

  • Modify: next.config.ts

  • Step 1: Đọc tài liệu Next 16 (bản đã sửa)

Run: ls node_modules/next/dist/docs/ 2>/dev/null; grep -rl "images" node_modules/next/dist/docs/ 2>/dev/null | head Đọc trang nói về cấu hình images để xác nhận khóa formats/deviceSizes đúng với bản này trước khi sửa.

  • Step 2: Cập nhật config
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [360, 640, 768, 1024, 1280, 1536, 1920],
    minimumCacheTTL: 2678400, // 31 ngày
  },
};

export default nextConfig;

Nếu tài liệu bản này chỉ định khác (tên khóa/cấu trúc), theo tài liệu — mục tiêu: bật AVIF/WebP cho next/image và đặt deviceSizes hợp lý.

  • Step 3: Build + kiểm tra ảnh landing

Run: npm run build Expected: pass. Chạy dev/preview, mở / → Network tab cho thấy ảnh từ <Image> (vd img("01.jpg")) được phục vụ dạng AVIF/WebP, kích thước nhỏ theo viewport.

  • Step 4: Commit
git add next.config.ts
git commit -m "perf: bật tối ưu ảnh AVIF/WebP + deviceSizes cho next/image"

Task 11: Dynamic import các viewer nặng (three.js)

Files:

  • Modify: src/app/tour/chua-thai-lac/page.tsx
  • Modify: src/app/co-vat/page.tsx

Tách three.js khỏi bundle chính bằng next/dynamic (chỉ tải ở client, kèm fallback).

  • Step 1: Đọc tài liệu dynamic import của bản Next này

Run: grep -rl "next/dynamic\|dynamic" node_modules/next/dist/docs/ 2>/dev/null | head Xác nhận cú pháp dynamic(() => import(...), { ssr: false }) còn đúng ở bản này.

  • Step 2: Tour page — dynamic import

src/app/tour/chua-thai-lac/page.tsx thành:

import type { Metadata } from "next";
import dynamic from "next/dynamic";

const VirtualTour = dynamic(() => import("@/app/components/VirtualTour"), {
  ssr: false,
  loading: () => (
    <div className="grid min-h-[100dvh] place-items-center bg-[#f5f1e6] text-[#4a3f35]">
      <p className="text-sm font-bold">Đang tải không gian 360°…</p>
    </div>
  ),
});

export const metadata: Metadata = {
  title: "VR360 Chùa Thái Lạc (Pháp Vân tự)",
  description:
    "Trải nghiệm tham quan thực tế ảo 360° Chùa Thái Lạc (Pháp Vân tự), di tích quốc gia tại Xã Như Quỳnh, tỉnh Hưng Yên.",
};

export default function ChuaThaiLacTourPage() {
  return <VirtualTour tourId="chua-thai-lac" />;
}

Nếu bản Next này không cho ssr: false trong Server Component, tạo một wrapper client nhỏ ("use client") bọc dynamic(...) và import wrapper vào page. Theo tài liệu ở Step 1.

  • Step 3: Co-vat page — dynamic import showcase

Trong src/app/co-vat/page.tsx:

// CŨ:
import { AntiquityShowcase } from "@/app/components/antiquity/AntiquityShowcase";
// MỚI:
import dynamic from "next/dynamic";
const AntiquityShowcase = dynamic(
  () => import("@/app/components/antiquity/AntiquityShowcase").then((m) => m.AntiquityShowcase),
  {
    ssr: false,
    loading: () => (
      <div className="mx-auto max-w-7xl px-4 py-16 text-center text-sm text-[var(--muted-foreground)] sm:px-6">
        Đang tải phòng trưng bày 3D
      </div>
    ),
  },
);

(Phần JSX <AntiquityShowcase items={antiquities} /> giữ nguyên.)

  • Step 4: Build + kiểm tra bundle

Run: npm run build Expected: pass; báo cáo build cho thấy route / và các trang tĩnh không còn gánh three.js trong chunk chính (three nằm ở chunk động của /tour/.../co-vat). Mở các trang → vẫn hoạt động, có fallback khi tải.

  • Step 5: Commit
git add src/app/tour/chua-thai-lac/page.tsx src/app/co-vat/page.tsx
git commit -m "perf: dynamic import viewer three.js (tách khỏi bundle chính)"

Task 12: Nghiệm thu tổng thể

Files: không sửa code — chỉ đo và ghi nhận.

  • Step 1: Đếm ngữ cảnh WebGL ở trang chủ

Mở /, DevTools Console: document.querySelectorAll('canvas').length Expected: 01 (trước đây 3 lớp ở hero).

  • Step 2: Đo lúc rảnh

Trang / đứng yên, Performance ghi 5s. Expected: GPU/CPU gần như phẳng khi không tương tác; không còn rAF nền từ hero.

  • Step 3: Đo cuộn

Cuộn //di-tich. Expected: ~60fps, không tụt khung rõ rệt; quạt không rú.

  • Step 4: Đo tải tài nguyên

Network (disable cache) mở /tour/chua-thai-lac/co-vat. Expected: panorama .webp ~0.3MB/ảnh; .glb nhỏ hơn nhiều lần so với 2740MB.

  • Step 5: Lighthouse

Chạy Lighthouse (Performance) cho / ở chế độ build production (npm run build + npm run start). Expected: điểm Performance tăng so với trước; không lỗi giao diện.

  • Step 6: Ghi nhận kết quả vào cuối file spec (docs/superpowers/specs/2026-06-25-toi-uu-hieu-nang-design.md), commit:
git add docs/superpowers/specs/2026-06-25-toi-uu-hieu-nang-design.md
git commit -m "docs: ghi nhận kết quả nghiệm thu tối ưu hiệu năng"

Tự rà soát (đã thực hiện khi viết kế hoạch)

  • Phủ spec: GĐ1 (hero + gate + antiquity) ✓ Task 13; GĐ2 (nén ảnh ✓T45, LRU ✓T6, nén model ✓T7); GĐ3 (animation ✓T8, blur/will-change ✓T9); GĐ4 (next.config ✓T10, dynamic import ✓T11); nghiệm thu ✓T12.
  • Không placeholder: mọi step có code/lệnh cụ thể.
  • Nhất quán tên: createRenderGate (T1) dùng đúng ở T2/T3; setPinnedPanoramas/touchPanoramaCache/evictPanoramaTextures (T6) nhất quán; imageForFile (T5) khớp khóa LQIP .webp (T4).
  • Rủi ro đã chặn: LRU không dispose ảnh đang ghim; nén ảnh/model luôn từ backup gốc (chạy lại an toàn); đọc tài liệu Next 16 trước khi sửa config/dynamic.