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

925 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```ts
// 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**
```bash
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`**
```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**
```bash
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**
```bash
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 } ...`):
```ts
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:
```ts
// 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:
```ts
// 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:
```ts
const tick = () => {
controls.update();
renderer.render(scene, camera);
raf = requestAnimationFrame(tick);
};
tick();
```
bằng:
```ts
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");`):
```ts
setStatus("ready");
requestRender();
```
Trong `resize`, ép vẽ lại — sửa cuối hàm `resize`:
```ts
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ả `resize` và `tick`.
- [ ] **Step 5: Dọn dẹp trong cleanup**
Trong hàm cleanup (return của useEffect), thêm trước `controls.dispose();`:
```ts
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**
```bash
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ố**
```js
// 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:
```js
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:
```json
"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):
```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**
```bash
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`**
```ts
// 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**
```bash
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)**
```ts
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):
```ts
// 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:
```ts
// 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:
```ts
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**
```bash
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**
```js
// 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:
```json
"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**
```bash
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:
```css
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:
```css
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):
```css
.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**
```bash
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`**
```css
/* 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`**
```css
/* 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**
```bash
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**
```ts
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**
```bash
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:
```tsx
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`:
```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**
```bash
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:
```bash
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.