Compare commits

..

40 Commits

Author SHA1 Message Date
tuyenio 4465d174fe feat: Implement error and not found pages with improved user feedback and navigation 2026-06-30 10:01:41 +07:00
tuyenio e081f54bd4 feat: Enhance cultural heritage site pages with new components and styling
- Added a new "HeritageSeal" SVG component for branding consistency across the site.
- Updated "CulturalSpaceExplorer" to include a descriptive header and introductory text.
- Modified "SiteHeader" to replace the logo with the new "HeritageSeal".
- Enhanced "HeritageDetailPage" with improved layout and styling for better readability.
- Updated "HeritageListPage" to include new visual elements and improved section layouts.
- Revamped "AboutPage" with new content structure and visual enhancements.
- Improved "ContactPage" with a visually appealing hero section and map integration.
- Updated global styles for better visual consistency and improved accessibility.
- Introduced "SectionDivider" component for decorative separation in content sections.
2026-06-26 16:17:24 +07:00
tuyenio 6bc57fe144 fix: update yaw angle for video scene in Chùa Thái Lạc tour 2026-06-26 15:40:44 +07:00
tuyenio 5945f30773 fix: adjust yaw angle for video scene in Chùa Thái Lạc tour 2026-06-26 15:39:28 +07:00
tuyenio 95a0836c11 feat: add FestivalGallery component for showcasing festival moments with responsive grid layout 2026-06-26 15:33:32 +07:00
tuyenio 9519e395e0 feat: add video support for festival markers and implement video modal 2026-06-26 14:49:33 +07:00
tuyenio ea402e4422 feat: implement parallax background effects and add CountUp and Typewriter components 2026-06-26 11:18:34 +07:00
tuyenio 5cf7726372 fix: increase mobile menu max height for better visibility 2026-06-26 10:52:12 +07:00
tuyenio 1fc3431ace feat: update audio files for Chùa Thái Lạc tour 2026-06-26 10:36:20 +07:00
tuyenio abc8924eee Refactor code structure for improved readability and maintainability 2026-06-26 10:22:29 +07:00
tuyenio 6fa8a6016a Add new audio file: nhac-phat-giao.mp3 to media directory 2026-06-25 14:28:46 +07:00
tuyenio 8ad9c7113b feat: enhance Chùa Thái Lạc tour with audio narration and scene updates
- Added audio narration support for various scenes, linking audio files to specific scene groups.
- Updated scene titles and descriptions for better clarity and accuracy.
- Introduced new scenes and info markers, enhancing the visitor experience with detailed information.
- Adjusted scene navigation and default views to reflect the new structure and content.
2026-06-25 13:31:17 +07:00
tuyenio be682ac95f refactor: update scene configuration and navigation after removing unused scenes 2026-06-25 10:50:35 +07:00
tuyenio 59563db3b4 merge: tối ưu hiệu năng web (hero/render-gate, nén ảnh+model, LRU, CSS, bundle) 2026-06-25 09:43:45 +07:00
tuyenio 9e3f98d3dd chore: dọn @keyframes không dùng + cập nhật comment .webp
- Xoá 8 @keyframes orphaned không được dùng:
  public-ambient-pan, public-back-to-top-float, public-cta-breathe,
  public-gradient-flow, public-header-glint, public-logo-breathe,
  public-photo-breathe, public-stage-float
- Giữ lại 7 @keyframes vẫn được dùng:
  public-section-reveal, public-header-drop, public-rise,
  public-stagger-reveal, public-line-draw, public-sheen, heritage-marquee
- Cập nhật comment trong chua-thai-lac.ts để phản ánh .webp thay cho .jpg

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:40:55 +07:00
tuyenio a0c202546e perf: dynamic import viewer three.js (tách khỏi bundle chính)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:31:52 +07:00
tuyenio 2bd2a42dcf perf: bật tối ưu ảnh AVIF/WebP + deviceSizes cho next/image 2026-06-25 09:28:21 +07:00
tuyenio 9b7ce49f54 perf: giảm backdrop-blur và bỏ will-change luôn-bật trên panel/card 2026-06-25 09:25:26 +07:00
tuyenio 8a0635944a perf: tắt animation CSS vô hạn ít giá trị (ambient-pan, gradient-flow, breathe, glint)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:22:47 +07:00
tuyenio d58760a63b perf: nén model .glb (meshopt + texture webp), backup bản gốc model-originals/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:18:22 +07:00
tuyenio da8bbb7641 perf: giới hạn cache panorama (LRU) + ghim cảnh hiện tại/lân cận, tránh tràn VRAM 2026-06-25 09:12:06 +07:00
tuyenio dc14bc5197 perf: tour nạp panorama WebP thay cho JPEG 2026-06-25 09:09:05 +07:00
tuyenio 7ac7d86570 perf: webp panorama q72 + effort 6 (giảm dung lượng tải)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:05:59 +07:00
tuyenio 5e9e632345 perf: nén panorama 4096px + xuất WebP, LQIP theo path webp
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:01:13 +07:00
tuyenio c96b6f195c perf: AntiquityViewer render theo nhu cau + gate ngoai man hinh + pixelRatio 1.5 + meshopt 2026-06-25 08:51:40 +07:00
tuyenio e0117ab4bd perf: hero bỏ canvas three.js + còn 1 shader, gate theo tầm nhìn (giảm 3 ngữ cảnh WebGL) 2026-06-25 08:47:36 +07:00
tuyenio cea9efab99 perf: tiện ích render-gate (dừng vòng lặp vẽ khi ngoài màn hình/đổi tab) 2026-06-25 08:44:24 +07:00
tuyenio ddef1f56bb 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>
2026-06-25 08:40:42 +07:00
tuyenio 707eb0f5c1 docs: spec tối ưu hiệu năng web (phân tích nguyên nhân + phương án theo giai đoạn)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:32:35 +07:00
tuyenio cf9f09eba0 refactor: bỏ LQIP nhánh chuyển cảnh (không hiển thị), giữ blur-up cảnh đầu
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:18:43 +07:00
tuyenio e4eb4a81b2 perf: ảnh mờ blur-up (LQIP) khi tải cảnh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:14:49 +07:00
tuyenio 5cc3c7b636 perf: preload cảnh kề lúc trình duyệt rảnh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:12:13 +07:00
tuyenio cf360694d6 fix: ép render lại sau khi resize cửa sổ (on-demand)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:10:31 +07:00
tuyenio 336e3ddb63 perf: render theo nhu cầu + throttle cameraYaw (giảm tải khi đứng yên)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:07:17 +07:00
tuyenio 3ffa0d9455 perf: bỏ preserveDrawingBuffer, hạ pixelRatio cap xuống 1.5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:04:50 +07:00
tuyenio db1f76bd0c fix: bản đồ Vị trí auto-fit, không cắt điểm, căn giữa cân đối
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:02:09 +07:00
tuyenio 71179e2c78 perf: nén panorama 394MB->~30MB + sinh LQIP, nén modal PNG
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:57:56 +07:00
tuyenio 68e7b4230f fix: thoát mã lỗi khi script nén ảnh gặp sự cố
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:54:19 +07:00
tuyenio d4892544e0 feat: script nén ảnh panorama + LQIP
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:51:04 +07:00
tuyenio 97644677ad docs: plan triển khai tối ưu phy-vr360
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:45:59 +07:00
132 changed files with 6183 additions and 696 deletions
+6
View File
@@ -41,3 +41,9 @@ yarn-error.log*
next-env.d.ts
.vercel
# ảnh gốc trước khi nén (backup cục bộ)
/image-originals
# model .glb gốc trước khi nén (backup cục bộ)
/model-originals
@@ -0,0 +1,761 @@
# Tối ưu hiệu năng & sửa bản đồ phy-vr360 — Implementation Plan
> **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:** Loại bỏ đơ/giật/lag và làm bản đồ panel "Vị trí" hiển thị cân đối, đầy đủ, không bị khuất.
**Architecture:** Nén ảnh panorama 394MB → ~30MB bằng script `sharp` (backup gốc + sinh ảnh mờ blur-up), sửa `MiniMap` overview sang auto-fit, và tối ưu engine `VirtualTour.tsx` (render theo nhu cầu, bỏ `preserveDrawingBuffer`, hạ pixelRatio, preload lúc rảnh, blur-up khi tải).
**Tech Stack:** Next.js 16 / React 19, three.js 0.184, sharp (đã cài), TypeScript.
**Spec:** `docs/superpowers/specs/2026-06-24-toi-uu-phy-vr360-design.md`
**Lưu ý môi trường:** Shell là Git Bash trên Windows; chạy lệnh trong thư mục `phy-vr360`. Dự án **không có test runner** — kiểm chứng bằng `npx tsc --noEmit`, output của script, `npm run build`, và checklist thủ công. Mỗi commit kết thúc bằng trailer:
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
---
## Task 1: Script nén ảnh + .gitignore
**Files:**
- Create: `scripts/optimize-images.mjs`
- Modify: `.gitignore`
- [ ] **Step 1: Tạo script nén ảnh**
Create `scripts/optimize-images.mjs`:
```js
// Nén ảnh panorama + modal, backup bản gốc, sinh ảnh mờ (LQIP).
// Chạy: node scripts/optimize-images.mjs (chạy lại nhiều lần an toàn — luôn nén từ backup gốc)
import sharp from "sharp";
import { promises as fs } from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const PANO_DIR = path.join(ROOT, "public/images/chua-thai-lac");
const MODAL_DIR = path.join(ROOT, "public/modal");
const BACKUP_DIR = path.join(ROOT, "image-originals");
const LQIP_OUT = path.join(ROOT, "src/app/tours/chua-thai-lac.lqip.ts");
const PANO_WIDTH = 6144;
const PANO_QUALITY = 80;
const LQIP_WIDTH = 32;
const MODAL_FILES = ["modal-1.png", "modal-2.png"];
const fmt = (b) => (b / 1024 / 1024).toFixed(2) + "MB";
const ensureDir = (d) => fs.mkdir(d, { recursive: true });
const exists = (p) => fs.access(p).then(() => true).catch(() => false);
// Đảm bảo ảnh gốc đã được backup; luôn ĐỌC từ backup để không nén chồng.
async function sourceFrom(destPath, backupSubdir) {
const name = path.basename(destPath);
const backupPath = path.join(BACKUP_DIR, backupSubdir, name);
await ensureDir(path.dirname(backupPath));
if (!(await exists(backupPath))) {
await fs.copyFile(destPath, backupPath);
}
return backupPath;
}
async function optimizePanoramas() {
const files = (await fs.readdir(PANO_DIR)).filter((f) => /\.jpe?g$/i.test(f)).sort();
const lqip = {};
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;
const buf = await sharp(src)
.rotate()
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
.jpeg({ quality: PANO_QUALITY, mozjpeg: true, progressive: true })
.toBuffer();
await fs.writeFile(dest, buf);
const lqipBuf = await sharp(src).rotate().resize({ width: LQIP_WIDTH }).jpeg({ quality: 40 }).toBuffer();
lqip["/images/chua-thai-lac/" + f] = "data:image/jpeg;base64," + lqipBuf.toString("base64");
console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)} (-${Math.round((1 - buf.length / before) * 100)}%)`);
}
return lqip;
}
async function optimizeModals() {
for (const f of MODAL_FILES) {
const dest = path.join(MODAL_DIR, f);
if (!(await exists(dest))) continue;
const src = await sourceFrom(dest, "modal");
const before = (await fs.stat(dest)).size;
const buf = await sharp(src).png({ compressionLevel: 9, palette: true, quality: 90, dither: 1 }).toBuffer();
await fs.writeFile(dest, buf);
console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)}`);
}
}
async function writeLqip(lqip) {
const entries = Object.entries(lqip)
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`)
.join("\n");
const content =
"// TỰ ĐỘNG SINH bởi scripts/optimize-images.mjs — KHÔNG sửa tay.\n" +
"// Ảnh mờ tạm (blur-up/LQIP) base64 cho mỗi panorama, khoá theo đường dẫn ảnh.\n" +
"export const lqip: Record<string, string> = {\n" +
entries +
"\n};\n";
await fs.writeFile(LQIP_OUT, content);
console.log("Wrote " + path.relative(ROOT, LQIP_OUT) + " (" + Object.keys(lqip).length + " entries)");
}
const lqip = await optimizePanoramas();
await optimizeModals();
await writeLqip(lqip);
console.log("Done.");
```
- [ ] **Step 2: Thêm `/image-originals` vào .gitignore**
Append to `.gitignore` (sau dòng `.vercel` cuối file):
```
# ảnh gốc trước khi nén (backup cục bộ)
/image-originals
```
- [ ] **Step 3: Kiểm tra script parse được (không chạy nén)**
Run: `node --check scripts/optimize-images.mjs`
Expected: không in lỗi (exit 0).
- [ ] **Step 4: Commit**
```bash
git add scripts/optimize-images.mjs .gitignore
git commit -m "feat: script nén ảnh panorama + LQIP
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Chạy nén ảnh + commit tài sản đã nén
**Files:**
- Modify (ghi đè): `public/images/chua-thai-lac/*.jpg`, `public/modal/modal-1.png`, `public/modal/modal-2.png`
- Create: `src/app/tours/chua-thai-lac.lqip.ts`
- Create (gitignored): `image-originals/...`
- [ ] **Step 1: Chạy script nén**
Run: `node scripts/optimize-images.mjs`
Expected: in từng dòng `01.jpg: 22.64MB -> ~1.xMB (-9x%)` … và `Wrote src/app/tours/chua-thai-lac.lqip.ts (23 entries)` rồi `Done.`
- [ ] **Step 2: Xác nhận dung lượng đã giảm**
Run: `du -sh public/images/chua-thai-lac && du -sh image-originals/chua-thai-lac`
Expected: `public/images/chua-thai-lac` ~2535MB; `image-originals/chua-thai-lac` ~394MB (bản gốc đã backup).
- [ ] **Step 3: Xác nhận LQIP đã sinh đúng số lượng**
Run: `grep -c "data:image/jpeg;base64" src/app/tours/chua-thai-lac.lqip.ts`
Expected: `23`
- [ ] **Step 4: Xác nhận `image-originals` không bị git theo dõi**
Run: `git status --porcelain image-originals | head`
Expected: không có dòng nào (thư mục bị ignore).
- [ ] **Step 5: Kiểm tra mắt vài ảnh** (thủ công)
Mở `public/images/chua-thai-lac/01.jpg`, `12.jpg`, `21.jpg` bằng trình xem ảnh. Expected: rõ nét, không vỡ hạt rõ rệt, đúng nội dung. Mở `public/modal/modal-1.png` — Expected: vẫn giữ trong suốt, không banding nặng. (Nếu modal banding rõ: chấp nhận hoặc khôi phục từ `image-originals/modal/` và bỏ phần modal trong script.)
- [ ] **Step 6: Commit ảnh đã nén + LQIP**
```bash
git add public/images/chua-thai-lac src/app/tours/chua-thai-lac.lqip.ts public/modal
git commit -m "perf: nén panorama 394MB->~30MB + sinh LQIP, nén modal PNG
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Bản đồ "Vị trí" auto-fit
**Files:**
- Modify: `src/app/components/VirtualTour.tsx` (hàm `MiniMap`, phần `getMapPosition`)
- [ ] **Step 1: Thay phép co cứng bằng auto-fit theo bao toàn bộ điểm**
Find (trong hàm `MiniMap`):
```js
const { mapRoutes, sceneById, scenes } = tourConfig;
const isOverview = mode === "overview";
const nodeSize = compact ? 5.2 : 5.2;
const edgeInset = nodeSize / 2;
const clampMapPoint = (value: number) => THREE.MathUtils.clamp(value, edgeInset, 100 - edgeInset);
const compressOverviewY = (value: number) => 50 + (value - 50) * 0.86;
const getMapPosition = (scene: TourScene) => {
if (!scene.mapPosition || (!isOverview && !activeScene.mapPosition)) return null;
if (isOverview) {
return {
x: clampMapPoint(scene.mapPosition.x),
y: clampMapPoint(compressOverviewY(scene.mapPosition.y)),
};
}
```
Replace with:
```js
const { mapRoutes, sceneById, scenes } = tourConfig;
const isOverview = mode === "overview";
const nodeSize = compact ? 5.2 : 5.2;
const edgeInset = nodeSize / 2;
const clampMapPoint = (value: number) => THREE.MathUtils.clamp(value, edgeInset, 100 - edgeInset);
// Auto-fit overview: co toàn bộ điểm vào khung với lề an toàn để không bị cắt.
const OVERVIEW_MARGIN = 8;
const overviewPts = scenes
.map((s) => s.mapPosition)
.filter((p): p is { x: number; y: number } => Boolean(p));
const overviewBounds = {
minX: Math.min(...overviewPts.map((p) => p.x)),
maxX: Math.max(...overviewPts.map((p) => p.x)),
minY: Math.min(...overviewPts.map((p) => p.y)),
maxY: Math.max(...overviewPts.map((p) => p.y)),
};
const fitOverview = (value: number, min: number, max: number) =>
max - min < 0.0001
? 50
: OVERVIEW_MARGIN + ((value - min) / (max - min)) * (100 - 2 * OVERVIEW_MARGIN);
const getMapPosition = (scene: TourScene) => {
if (!scene.mapPosition || (!isOverview && !activeScene.mapPosition)) return null;
if (isOverview) {
return {
x: clampMapPoint(fitOverview(scene.mapPosition.x, overviewBounds.minX, overviewBounds.maxX)),
y: clampMapPoint(fitOverview(scene.mapPosition.y, overviewBounds.minY, overviewBounds.maxY)),
};
}
```
- [ ] **Step 2: Type-check**
Run: `npx tsc --noEmit`
Expected: exit 0, không lỗi (đặc biệt không còn cảnh báo `compressOverviewY` unused vì đã xoá).
- [ ] **Step 3: Commit**
```bash
git add src/app/components/VirtualTour.tsx
git commit -m "fix: bản đồ Vị trí auto-fit, không cắt điểm, căn giữa cân đối
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Bỏ preserveDrawingBuffer + hạ pixelRatio
**Files:**
- Modify: `src/app/components/VirtualTour.tsx` (cấu hình `WebGLRenderer` của tour, trong useEffect khởi tạo three)
- [ ] **Step 1: Sửa cấu hình renderer**
Find:
```js
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.8));
```
Replace with:
```js
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
```
- [ ] **Step 2: Type-check**
Run: `npx tsc --noEmit`
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add src/app/components/VirtualTour.tsx
git commit -m "perf: bỏ preserveDrawingBuffer, hạ pixelRatio cap xuống 1.5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: Render theo nhu cầu (on-demand) + throttle cameraYaw
**Files:**
- Modify: `src/app/components/VirtualTour.tsx` (khai báo ref ở đầu component; hàm `animate`; đăng ký listener `controls`)
Mục tiêu: khi người dùng đứng yên, không vẽ thừa và không gọi `setCameraYaw` mỗi frame (đang gây React re-render 60 lần/giây).
- [ ] **Step 1: Thêm 2 ref điều phối render**
Find (cụm ref gần đầu component `TourExperience`, ngay sau dòng `const currentFovRef = useRef(DEFAULT_FOV);`):
```js
const currentFovRef = useRef(DEFAULT_FOV);
const targetFovRef = useRef(DEFAULT_FOV);
```
Replace with:
```js
const currentFovRef = useRef(DEFAULT_FOV);
const targetFovRef = useRef(DEFAULT_FOV);
const needsRenderRef = useRef(true);
const lastYawRef = useRef(0);
```
- [ ] **Step 2: Đăng ký listener `controls 'change'` để bật cờ render**
Find:
```js
renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });
```
Replace with:
```js
const handleControlsChange = () => {
needsRenderRef.current = true;
};
controls.addEventListener("change", handleControlsChange);
renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });
```
- [ ] **Step 3: Gỡ listener khi cleanup**
Find:
```js
renderer.setAnimationLoop(null);
controls.dispose();
```
Replace with:
```js
controls.removeEventListener("change", handleControlsChange);
renderer.setAnimationLoop(null);
controls.dispose();
```
- [ ] **Step 4: Viết lại `animate` để vẽ theo nhu cầu**
Find:
```js
const animate = () => {
const targetFov = targetFovRef.current;
if (Math.abs(currentFovRef.current - targetFov) > 0.01) {
currentFovRef.current += (targetFov - currentFovRef.current) * 0.16;
camera.fov = currentFovRef.current;
camera.updateProjectionMatrix();
} else if (camera.fov !== targetFov) {
currentFovRef.current = targetFov;
camera.fov = targetFov;
camera.updateProjectionMatrix();
}
if (!vrModeRef.current) {
controls.update();
}
// Update camera yaw for mini-map rotation
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
const yaw = yawFromDirection(direction);
setCameraYaw(yaw);
updateHotspots();
renderer.render(threeScene, camera);
};
```
Replace with:
```js
const animate = () => {
const targetFov = targetFovRef.current;
const fovAnimating = Math.abs(currentFovRef.current - targetFov) > 0.01;
if (fovAnimating) {
currentFovRef.current += (targetFov - currentFovRef.current) * 0.16;
camera.fov = currentFovRef.current;
camera.updateProjectionMatrix();
} else if (camera.fov !== targetFov) {
currentFovRef.current = targetFov;
camera.fov = targetFov;
camera.updateProjectionMatrix();
needsRenderRef.current = true;
}
// controls.update() trả về true khi camera vừa thay đổi (kể cả damping đang lắng).
const controlsChanged = vrModeRef.current ? false : controls.update();
const transitionActive = transitionFrameRef.current !== null;
const active =
needsRenderRef.current ||
fovAnimating ||
controlsChanged ||
controls.autoRotate ||
vrModeRef.current ||
transitionActive;
if (!active) {
return;
}
needsRenderRef.current = false;
// Cập nhật yaw cho mini-map — chỉ setState khi đổi đủ lớn để tránh re-render thừa.
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
const yaw = yawFromDirection(direction);
if (Math.abs(normalizeYaw(yaw - lastYawRef.current)) > 0.15) {
lastYawRef.current = yaw;
setCameraYaw(yaw);
}
updateHotspots();
renderer.render(threeScene, camera);
};
```
- [ ] **Step 5: Type-check**
Run: `npx tsc --noEmit`
Expected: exit 0.
- [ ] **Step 6: Kiểm tra hành vi thủ công** (`npm run dev`)
Mở tour. Expected: kéo xoay mượt; thả tay cảnh đứng yên (không nhấp nháy); zoom (cuộn chuột) mượt; auto-rotate quay đều; chuyển cảnh qua mũi tên hoạt động; mini-map radar vẫn xoay theo góc nhìn; bật VR (trên máy có cảm biến) vẫn cập nhật. Không có cảnh "đứng hình" sau tương tác.
- [ ] **Step 7: Commit**
```bash
git add src/app/components/VirtualTour.tsx
git commit -m "perf: render theo nhu cầu + throttle cameraYaw (giảm tải khi đứng yên)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: Preload thông minh (lùi xuống lúc rảnh)
**Files:**
- Modify: `src/app/components/VirtualTour.tsx` (helper module-level `scheduleIdle`; hàm `preloadTourScene`)
- [ ] **Step 1: Thêm helper `scheduleIdle` ở cấp module**
Find (helper module-level, ngay sau hàm `directionFromYawPitch`):
```js
function directionFromYawPitch(yaw: number, pitch: number) {
const yawRad = THREE.MathUtils.degToRad(yaw);
const pitchRad = THREE.MathUtils.degToRad(pitch);
const cosPitch = Math.cos(pitchRad);
return new THREE.Vector3(
Math.sin(yawRad) * cosPitch,
Math.sin(pitchRad),
-Math.cos(yawRad) * cosPitch,
);
}
```
Add immediately after it:
```js
type IdleWindow = typeof window & {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
};
// Lên lịch chạy lúc trình duyệt rảnh (fallback setTimeout) — dùng cho preload nền.
function scheduleIdle(cb: () => void) {
if (typeof window === "undefined") {
return;
}
const w = window as IdleWindow;
if (w.requestIdleCallback) {
w.requestIdleCallback(cb, { timeout: 2000 });
} else {
window.setTimeout(cb, 400);
}
}
```
- [ ] **Step 2: Cảnh hiện tại tải ngay, cảnh kề preload lúc rảnh**
Find:
```js
const preloadTourScene = useCallback(
(scene: TourScene) => {
const preloadImage = (image: string) => {
loadPanoramaTexture(image)
.then((texture) => warmPanoramaTexture(image, texture))
.catch(() => undefined);
};
preloadImage(scene.image);
scene.hotspots.forEach((hotspot) => {
const targetScene = tourConfig.sceneById.get(hotspot.targetId);
if (targetScene) {
preloadImage(targetScene.image);
}
});
},
[tourConfig, warmPanoramaTexture],
);
```
Replace with:
```js
const preloadTourScene = useCallback(
(scene: TourScene) => {
const preloadImage = (image: string) => {
loadPanoramaTexture(image)
.then((texture) => warmPanoramaTexture(image, texture))
.catch(() => undefined);
};
// Cảnh hiện tại: tải ngay. Cảnh kề: lùi xuống lúc rảnh để không nghẽn khi vừa vào.
preloadImage(scene.image);
scheduleIdle(() => {
scene.hotspots.forEach((hotspot) => {
const targetScene = tourConfig.sceneById.get(hotspot.targetId);
if (targetScene) {
preloadImage(targetScene.image);
}
});
});
},
[tourConfig, warmPanoramaTexture],
);
```
- [ ] **Step 3: Type-check**
Run: `npx tsc --noEmit`
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/app/components/VirtualTour.tsx
git commit -m "perf: preload cảnh kề lúc trình duyệt rảnh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: Blur-up (LQIP) khi tải cảnh
**Files:**
- Modify: `src/app/components/VirtualTour.tsx` (import LQIP; helper `loadLqipTexture`; chèn vào `runSceneTransition` cho cả nhánh chuyển cảnh và nhánh tải cảnh đầu)
Phụ thuộc: `src/app/tours/chua-thai-lac.lqip.ts` (đã sinh ở Task 2) và `needsRenderRef` (đã thêm ở Task 5).
- [ ] **Step 1: Import LQIP**
Find:
```js
import { chuaThaiLacTour } from "@/app/tours/chua-thai-lac";
```
Replace with:
```js
import { chuaThaiLacTour } from "@/app/tours/chua-thai-lac";
import { lqip } from "@/app/tours/chua-thai-lac.lqip";
```
- [ ] **Step 2: Thêm cache + loader cho texture mờ (cấp module)**
Find:
```js
const panoramaTextureCache = new Map<string, THREE.Texture>();
```
Add immediately before it:
```js
// Texture ảnh mờ (blur-up) tạo từ data-URI base64 — hiện gần như tức thì khi cảnh đang tải.
const lqipTextureCache = new Map<string, THREE.Texture>();
function loadLqipTexture(image: string) {
const data = lqip[image];
if (!data) {
return null;
}
const cached = lqipTextureCache.get(image);
if (cached) {
return cached;
}
const texture = new THREE.TextureLoader().load(data);
texture.colorSpace = THREE.SRGBColorSpace;
lqipTextureCache.set(image, texture);
return texture;
}
```
- [ ] **Step 3: Blur-up cho nhánh chuyển cảnh (texture chưa cache)**
Find:
```js
} else {
loadPanoramaTexture(scene.image, (progress) => {
if (transitionTokenRef.current === token) {
setLoadProgress(progress);
}
})
```
Replace with:
```js
} else {
const lqipTexture = loadLqipTexture(scene.image);
if (lqipTexture) {
transitionMaterial.map = lqipTexture;
transitionMaterial.needsUpdate = true;
}
loadPanoramaTexture(scene.image, (progress) => {
if (transitionTokenRef.current === token) {
setLoadProgress(progress);
}
})
```
(Khi ảnh nét tải xong, `prepareTransitionTexture` thay `transitionMaterial.map` bằng ảnh nét — chuyển mờ→nét trên cùng cảnh.)
- [ ] **Step 4: Blur-up cho nhánh tải cảnh đầu (initial load)**
Find:
```js
setIsLoading(!initialCachedTexture);
setLoadProgress(initialCachedTexture ? 100 : 0);
loadTexture
.then((texture) => {
```
Replace with:
```js
setIsLoading(!initialCachedTexture);
setLoadProgress(initialCachedTexture ? 100 : 0);
if (!initialCachedTexture) {
const lqipTexture = loadLqipTexture(scene.image);
if (lqipTexture) {
material.map = lqipTexture;
material.opacity = 1;
material.needsUpdate = true;
needsRenderRef.current = true;
}
}
loadTexture
.then((texture) => {
```
- [ ] **Step 5: Type-check**
Run: `npx tsc --noEmit`
Expected: exit 0.
- [ ] **Step 6: Kiểm tra thủ công** (`npm run dev`, mở DevTools → Network → Slow 3G để thấy rõ)
Expected: khi vào cảnh chưa tải, hiện ảnh **mờ** ngay tức thì rồi sắc nét dần (không còn màn trắng/đen chờ). Chuyển cảnh tới cảnh chưa tải cũng thấy mờ→nét.
- [ ] **Step 7: Commit**
```bash
git add src/app/components/VirtualTour.tsx
git commit -m "perf: ảnh mờ blur-up (LQIP) khi tải cảnh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 8: Kiểm tra tổng thể (build + checklist)
**Files:** không sửa — chỉ kiểm chứng.
- [ ] **Step 1: Build production**
Run: `npm run build`
Expected: build thành công, không lỗi TypeScript/ESLint chặn build.
- [ ] **Step 2: Lint**
Run: `npm run lint`
Expected: không có lỗi (cảnh báo nhẹ chấp nhận được).
- [ ] **Step 3: Checklist thủ công** (`npm run dev`)
Xác nhận từng mục — Expected: tất cả PASS:
- Trang chủ/welcome vào tour nhanh, ảnh đầu tiên hiện mờ rồi nét.
- Kéo xoay, zoom mượt; đứng yên không giật/nhấp nháy.
- Auto-rotate quay đều; tắt thì dừng vẽ thừa.
- Mũi tên hướng đi bấm chuyển cảnh đúng; mini-map radar xoay theo.
- Panel "Vị trí": đủ 23 điểm, căn giữa, **không điểm nào bị cắt** ở mép.
- Mở info marker, modal hiển thị đúng (ảnh modal không vỡ).
- (Nếu có thiết bị cảm biến) VR mode hoạt động.
- [ ] **Step 4: Commit (nếu có chỉnh sửa nhỏ phát sinh từ kiểm tra)**
```bash
git add -A
git commit -m "chore: hoàn tất tối ưu hiệu năng & bản đồ phy-vr360
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Self-review notes (đã đối chiếu với spec)
- **Spec Phần A** (nén 6144/q80, backup gốc, modal PNG, LQIP) → Task 12. ✓
- **Spec Phần B** (auto-fit, lề 8%) → Task 3. ✓
- **Spec Phần C-1** render on-demand → Task 5; **C-2** bỏ preserveDrawingBuffer → Task 4; **C-3** pixelRatio 1.5 → Task 4; **C-4** preload idle → Task 6; **C-5** blur-up → Task 7. ✓
- **Phase 2** (GLB) cố ý ngoài phạm vi. ✓
- Type nhất quán: `needsRenderRef` định nghĩa ở Task 5 và dùng lại ở Task 7; `loadLqipTexture`/`lqip` khoá theo `scene.image` (đường dẫn `/images/chua-thai-lac/NN.jpg`) khớp với khoá script sinh ra ở Task 1. ✓
```
@@ -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 **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.
@@ -0,0 +1,95 @@
# Lồng tiếng thuyết minh + nhạc nền — Tour Chùa Thái Lạc
Ngày: 2026-06-25
## Mục tiêu
Gắn âm thanh thuyết minh (lồng tiếng) cho từng cảnh trong tour VR360 Chùa Thái Lạc,
và thêm nhạc nền chạy liên tục trên toàn bộ tour với âm lượng nhỏ nhẹ để không lấn lời thuyết minh.
## Bối cảnh kỹ thuật
Engine `src/app/components/VirtualTour.tsx` đã hỗ trợ đầy đủ, **không cần sửa logic**:
- `backgroundAudio: string` — một track nhạc nền phát lặp (`loop`). Âm lượng nền
`AUDIO_TARGET_VOLUME = 0.16`, tự động hạ xuống `BACKGROUND_DUCK_VOLUME = 0.045`
khi đang phát thuyết minh (ducking). Chuỗi rỗng `""` → engine bỏ qua, không render audio.
- `sceneNarration: Map<SceneId, string[]>` — mỗi cảnh có một MẢNG nguồn âm thanh,
phát tuần tự (hàng đợi). Âm lượng thuyết minh `NARRATION_TARGET_VOLUME = 0.7`.
- `continuousNarrationGroups: SceneId[][]` — các cảnh trong cùng nhóm dùng chung file
sẽ phát LIỀN MẠCH (không tua lại) khi khách di chuyển giữa chúng.
Đây là việc cấu hình DỮ LIỆU trong `src/app/tours/chua-thai-lac.ts` + đổi tên file âm thanh.
## Yêu cầu
### 1. Đổi tên 13 file âm thanh sang ASCII
Theo convention dự án (tránh lỗi URL-encode + phân biệt hoa/thường khi deploy lên Linux/Vercel).
Hai file `.MP3` (chữ hoa) được chuẩn hóa thành `.mp3`.
| File hiện tại | Tên mới |
|---|---|
| `public/media/chua-thai-lac/Lời chào.mp3` | `loi-chao.mp3` |
| `public/media/chua-thai-lac/Tam quan.mp3` | `tam-quan.mp3` |
| `public/media/chua-thai-lac/Sân Tịnh An.mp3` | `san-tinh-an.mp3` |
| `public/media/chua-thai-lac/Hồ Di Lặc.MP3` | `ho-di-lac.mp3` |
| `public/media/chua-thai-lac/Tiền sảnh.mp3` | `tien-sanh.mp3` |
| `public/media/chua-thai-lac/Tôn Đài Đức Phật Thích Ca.mp3` | `ton-dai-duc-phat.mp3` |
| `public/media/chua-thai-lac/Vườn tháp cổ.mp3` | `vuon-thap-co.mp3` |
| `public/media/chua-thai-lac/Khách xá.mp3` | `khach-xa.mp3` |
| `public/media/chua-thai-lac/Khách đường.mp3` | `khach-duong.mp3` |
| `public/media/chua-thai-lac/Hữu Vu La Hán- Tả Vu La Hán.MP3` | `huu-vu-ta-vu-la-han.mp3` |
| `public/media/chua-thai-lac/Nhà Tổ.mp3` | `nha-to.mp3` |
| `public/media/chua-thai-lac/Tiền Đường.mp3` | `tien-duong.mp3` |
| `public/media/nhac-nen/Nhạc Phật Giáo sâu lắng.mp3` | `nhac-phat-giao.mp3` |
### 2. Cấu hình `src/app/tours/chua-thai-lac.ts`
**a) Nhạc nền:** `backgroundAudio: ""``"/media/nhac-nen/nhac-phat-giao.mp3"`.
Giữ nguyên âm lượng mặc định của engine (nền 0.16, ducking 0.045 khi thuyết minh).
**b) Bảng thuyết minh `narrationGroups`** — mở rộng kiểu trường `audio` thành `string | string[]`
để cảnh 1-3 phát nối tiếp hai file (lời chào rồi Tam quan):
```
[loi-chao, tam-quan] → cảnh 1, 2, 3
san-tinh-an → cảnh 4, 5
ho-di-lac → cảnh 6
tien-sanh → cảnh 7, 8 (MỚI)
ton-dai-duc-phat → cảnh 9 (MỚI)
vuon-thap-co → cảnh 10 (MỚI)
(không thuyết minh) → cảnh 11
khach-xa → cảnh 12, 13, 14
khach-duong → cảnh 15
huu-vu-ta-vu-la-han → cảnh 16, 18
nha-to → cảnh 17
tien-duong → cảnh 19, 20, 21
```
Vòng lặp build `sceneNarration` phải chuẩn hóa `audio` (string hoặc string[]) thành mảng nguồn
qua `audioForName` cho mỗi cảnh.
**c) `continuousNarrationGroups`** — thêm `["scene-7", "scene-8"]` (hai cảnh kề nhau dùng chung
`tien-sanh`). Các nhóm hiện có giữ nguyên. Cảnh 16 & 18 vẫn KHÔNG gom (cách nhau bởi cảnh 17)
nên sẽ phát lại — đúng hành vi mong muốn.
## Hành vi đã thống nhất
- Cảnh 1: phát `loi-chao` rồi tự động phát `tam-quan`; di chuyển 1→2→3 không tua lại.
- Rời cảnh 1-3 rồi quay lại → "lời chào" phát lại từ đầu (hành vi mặc định, chấp nhận được).
- Nhạc nền nhỏ nhẹ và tự hạ thấp hơn nữa khi có thuyết minh.
## Kiểm chứng
1. `npm run lint` / `npm run build` không lỗi type.
2. `npm run dev`, vào tour và xác nhận:
- Nhạc nền chạy nhẹ liên tục mọi cảnh.
- Mỗi cảnh phát đúng file thuyết minh theo bảng trên.
- Cảnh 1 nghe "lời chào → tam quan" nối tiếp.
- Nhạc nền tự nhỏ lại khi đang có lời thuyết minh.
## Ngoài phạm vi
- Không sửa logic engine `VirtualTour.tsx`.
- Không thay đổi nội dung popup info markers, hướng mũi tên, hay sơ đồ mini-map.
@@ -0,0 +1,71 @@
# Thiết kế: Tối ưu hiệu năng web phy-vr360
- **Ngày:** 2026-06-25
- **Trạng thái:** Đã duyệt thiết kế, chờ lập kế hoạch triển khai
- **Chiến lược:** Hướng A — tối ưu theo giai đoạn, ưu tiên việc rẻ–lợi-cao
- **Ràng buộc người dùng:** Giữ thẩm mỹ tổng thể nhưng bỏ lớp thừa; được phép nén cả ảnh và model 3D.
---
## 1. Vấn đề
Web khi chạy làm laptop nóng/quạt rú, cuộn giật, trải nghiệm không mượt. Cảm nhận là **toàn bộ site đều nặng**, không khu trú một chỗ.
## 2. Nguyên nhân gốc (đã phân tích)
### Nghiêm trọng
1. **Trang chủ chồng 3 ngữ cảnh WebGL chạy liên tục**`hero-shader-background.tsx`:
- 2× `MeshGradient` (shader GPU paper-design) animation vô hạn 60fps.
- 1× `HeroPanoramaBackground` (`hero-panorama-background.tsx`): WebGLRenderer three.js, quả cầu xoay, `requestAnimationFrame` render **vô điều kiện**, tải ảnh panorama vài MB chỉ để làm nền mờ 58%.
- `mix-blend-mode: soft-light` + filter SVG `feTurbulence`.
- Không có `IntersectionObserver`/`visibilitychange` → chạy cả khi cuộn khỏi vùng nhìn hoặc đổi tab.
2. **Ảnh panorama nặng, không tối ưu** — 26 ảnh JPG **6144×3072**, 24MB/ảnh (tổng ~70MB), nạp thẳng làm texture three.js ở độ phân giải gốc. Cache `panoramaTextureCache` (`VirtualTour.tsx:214`) giữ **toàn bộ** texture vĩnh viễn → tràn VRAM trên máy card tích hợp → thrashing/khựng. `next.config.ts` rỗng.
3. **Model 3D `.glb` khổng lồ**`chuong-2.glb` 40MB, `chuong-1.glb` 27MB → đơ tab khi parse. `AntiquityViewer.tsx` render mọi frame vô điều kiện, pixelRatio tới 2.
### Phụ (tích lũy)
4. **CSS animation luôn-bật + hiệu ứng nặng** (`globals.css`): nền `body` chồng 4 lớp gradient + `body::after` `mix-blend-mode: multiply` full màn hình; `public-ambient-pan` 18s vô hạn; gradient-text flow; logo/CTA "breathe"; header "glint"; lớp `.site-grain` nhiễu SVG full màn hình; **`backdrop-filter: blur(18px)`** trên mọi `.public-panel`/`.public-card` + `will-change: transform` (ép tạo layer, tốn bộ nhớ).
5. **Điểm tốt sẵn có:** Tour viewer chính (`VirtualTour.tsx:1502`) đã render **theo nhu cầu** (`needsRenderRef`) — giữ và nhân rộng mô hình này.
## 3. Phạm vi
**Trong phạm vi:** trang chủ + các trang công khai, tour viewer 360, viewer cổ vật 3D, pipeline tài nguyên (ảnh + model), cấu hình build, ngân sách CSS.
**Ngoài phạm vi:** thay đổi nội dung/bố cục lớn, thêm tính năng mới, thiết kế lại giao diện.
## 4. Giải pháp theo giai đoạn
### Giai đoạn 1 — Chặn việc chạy nền vô ích *(đòn bẩy lớn nhất, rủi ro thấp)*
- **Hero:** thay `HeroPanoramaBackground` (canvas three.js xoay) bằng **ảnh tĩnh đã tối ưu** (next/image hoặc CSS background) → loại bỏ 1 ngữ cảnh WebGL + texture vài MB. Giảm **2 → 1** `MeshGradient`; bỏ `mix-blend-mode: soft-light`; gỡ filter SVG `feTurbulence` nếu không cần.
- **Gate mọi WebGL/animation nền** bằng `IntersectionObserver` + `document.visibilitychange`: ngoài màn hình hoặc đổi tab → **dừng `requestAnimationFrame`/`setAnimationLoop`**. Áp dụng cho hero (nếu còn shader) và `AntiquityViewer`.
- **AntiquityViewer:** chuyển sang **render theo nhu cầu** (theo mô hình tour viewer) — chỉ vẽ khi `controls` đổi hoặc đang tự xoay; pixelRatio tối đa **1.5**; dừng khi ngoài màn hình; đảm bảo chỉ **một** viewer hoạt động tại một thời điểm (lazy-mount, không khởi tạo tất cả `.glb` cùng lúc).
### Giai đoạn 2 — Pipeline tài nguyên
- **Ảnh panorama:** thêm script Node (`sharp`) tạo bản tối ưu:
- Resize tối đa **4096px** chiều rộng (giữ tỉ lệ 2:1), xuất **WebP** chất lượng ~80 (cân nhắc AVIF). Mục tiêu giảm 7085% dung lượng.
- Tour viewer nạp bản `.webp`; giữ LQIP blur-up sẵn có (`chua-thai-lac.lqip.ts`).
- **Model 3D:** dùng `gltf-transform` (Draco/meshopt + nén texture/resize) → 40MB ⟶ ước tính 38MB. Thêm `DRACOLoader`/meshopt decoder vào `AntiquityViewer`; lazy-load model.
- **Cache texture có giới hạn (LRU):** chỉ giữ cảnh hiện tại + lân cận, dispose phần xa → tránh tràn VRAM. Cân nhắc tắt mipmap cho ảnh cầu (giảm chi phí upload), kiểm chứng chất lượng.
### Giai đoạn 3 — Ngân sách hiệu ứng CSS
- Giảm **`backdrop-filter: blur`**: hạ bán kính hoặc thay bằng nền bán trong suốt cho phần tử phổ biến; **bỏ `will-change: transform`** ở phần tử luôn hiển thị (chỉ đặt khi cần, ví dụ lúc hover).
- Tắt bớt animation **vô hạn** ít giá trị: `public-ambient-pan`, gradient-text flow, logo/CTA "breathe", header glint, `.site-grain`. **Giữ** hiệu ứng xuất hiện-khi-cuộn (chạy 1 lần) và marquee.
- Gọn lại lớp gradient nền `body`/`public-page`.
### Giai đoạn 4 — Chia nhỏ bundle & tải
- `dynamic import` các thành phần nặng three.js (tour viewer, `AntiquityViewer`) để bundle chính nhẹ; đảm bảo `framer-motion`/`lottie-react` chỉ tải nơi cần.
- Cấu hình `next.config.ts`: bật `images` (AVIF/WebP, `deviceSizes`) và tối ưu build hợp lệ cho Next 16.
## 5. Cách đo thành công (nghiệm thu)
- **Trang chủ lúc rảnh ≈ 0% GPU** (không còn rAF chạy ngầm); cuộn ~60fps; không còn quạt rú.
- Số ngữ cảnh WebGL trên trang chủ: **3 → 01**.
- Dung lượng tải trang giảm rõ (ảnh panorama & model 3D).
- Lighthouse Performance tăng; không hồi quy giao diện nhìn thấy được.
## 6. Rủi ro & lưu ý
- **Next.js 16 bản đã chỉnh sửa** (theo `AGENTS.md`): trước khi sửa `next.config`/dynamic-import phải đọc tài liệu trong `node_modules/next/dist/docs/`.
- Nén ảnh/model có thể đổi nhẹ chất lượng → kiểm chứng bằng mắt sau mỗi bước; giữ file gốc (không ghi đè, xuất ra thư mục/định dạng mới).
- Việc gate animation phải dọn dẹp listener đúng cách để tránh rò rỉ bộ nhớ.
## 7. Nguyên tắc thực thi
- Làm tuần tự theo giai đoạn; **đo lại sau mỗi giai đoạn** trước khi sang bước kế tiếp.
- Mỗi thay đổi giữ đúng pattern/định dạng code hiện có của dự án.
+5 -1
View File
@@ -1,7 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [360, 640, 768, 1024, 1280, 1536, 1920],
minimumCacheTTL: 2678400,
},
};
export default nextConfig;
+1902 -3
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"optimize:images": "node scripts/optimize-images.mjs",
"optimize:models": "node scripts/optimize-models.mjs"
},
"dependencies": {
"@paper-design/shaders-react": "^0.0.76",
@@ -19,6 +21,7 @@
"three": "^0.184.0"
},
"devDependencies": {
"@gltf-transform/cli": "^4.4.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 MiB

After

Width:  |  Height:  |  Size: 810 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 MiB

After

Width:  |  Height:  |  Size: 949 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

After

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 MiB

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More