docs: kế hoạch triển khai tối ưu hiệu năng (12 task, 4 giai đoạn)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,924 @@
|
|||||||
|
# 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 **0–1** 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 2–4MB) 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.6–1.0MB | webp ~0.25–0.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 ~7–12MB (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 214–285 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 -> ~3–8MB (-…%)` 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/...` và `/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: **0–1** (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 `/` và `/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` và `/co-vat`.
|
||||||
|
Expected: panorama `.webp` ~0.3MB/ảnh; `.glb` nhỏ hơn nhiều lần so với 27–40MB.
|
||||||
|
|
||||||
|
- [ ] **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 1–3; GĐ2 (nén ảnh ✓T4–5, 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.
|
||||||
Reference in New Issue
Block a user