Compare commits
50 Commits
17a7866cd2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4465d174fe | |||
| e081f54bd4 | |||
| 6bc57fe144 | |||
| 5945f30773 | |||
| 95a0836c11 | |||
| 9519e395e0 | |||
| ea402e4422 | |||
| 5cf7726372 | |||
| 1fc3431ace | |||
| abc8924eee | |||
| 6fa8a6016a | |||
| 8ad9c7113b | |||
| be682ac95f | |||
| 59563db3b4 | |||
| 9e3f98d3dd | |||
| a0c202546e | |||
| 2bd2a42dcf | |||
| 9b7ce49f54 | |||
| 8a0635944a | |||
| d58760a63b | |||
| da8bbb7641 | |||
| dc14bc5197 | |||
| 7ac7d86570 | |||
| 5e9e632345 | |||
| c96b6f195c | |||
| e0117ab4bd | |||
| cea9efab99 | |||
| ddef1f56bb | |||
| 707eb0f5c1 | |||
| cf9f09eba0 | |||
| e4eb4a81b2 | |||
| 5cc3c7b636 | |||
| cf360694d6 | |||
| 336e3ddb63 | |||
| 3ffa0d9455 | |||
| db1f76bd0c | |||
| 71179e2c78 | |||
| 68e7b4230f | |||
| d4892544e0 | |||
| 97644677ad | |||
| 0e8af80f07 | |||
| 464ce1e402 | |||
| 035411493d | |||
| 2820724ace | |||
| 2ad4bed763 | |||
| 11b78433a9 | |||
| 224166f1d4 | |||
| dd6dadaa55 | |||
| 25772c9c25 | |||
| f518c07763 |
@@ -39,3 +39,11 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
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` ~25–35MB; `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 1–2. ✓
|
||||
- **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 **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.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Thiết kế: Tối ưu hiệu năng & sửa bản đồ — phy-vr360
|
||||
|
||||
- **Ngày:** 2026-06-24
|
||||
- **Phạm vi:** Dự án tour VR360 Chùa Thái Lạc (`phy-vr360`)
|
||||
- **Mục tiêu:** (1) Bản đồ trong panel "Vị trí" hiển thị cân đối, đầy đủ, không bị khuất. (2) Loại bỏ hiện tượng đơ/giật/lag, load mượt và nhanh hơn.
|
||||
|
||||
## Bối cảnh & chẩn đoán
|
||||
|
||||
Đo thực tế trong repo:
|
||||
|
||||
- **Panorama gốc quá nặng:** mỗi ảnh `public/images/chua-thai-lac/*.jpg` rộng ~10.000–11.800px (ví dụ 10364×5182, 11774×5887), dung lượng 12–24MB, **tổng 394MB** (26 ảnh, đang được git theo dõi). Đây là ảnh xuất thẳng từ iPhone, quá khổ cho web.
|
||||
- Một ảnh 11774×5887 khi giải nén thành texture chiếm **~280MB RAM/GPU** (RGBA). Đây là nguyên nhân chính gây lag, tốn bộ nhớ, load chậm.
|
||||
- **Preload ồ ạt:** `preloadSceneAndHotspots` / `preloadTourScene` tải trước texture của *tất cả* cảnh kề → chuyển cảnh có thể nạp hàng chục MB cùng lúc → giật.
|
||||
- **Render liên tục:** `requestAnimationFrame` vẽ mỗi frame kể cả khi đứng yên; renderer bật `preserveDrawingBuffer: true` (đã xác nhận **không** có tính năng chụp ảnh/`toDataURL` nào dùng tới → bỏ được); `pixelRatio` cap 1.8.
|
||||
- **Bản đồ overview** (panel "Vị trí"): các điểm dưới cùng (01/02/03) bị cắt ở mép khung; bố cục không căn giữa/cân đối. Hiện dùng `compressOverviewY` + clamp cứng, không tự vừa khung.
|
||||
- **Tài sản phụ:** `modal-1.png` (2.6MB), `modal-2.png` (2.5MB) nặng không cần thiết. Hai model `antiquity/*.glb` (28MB + 42MB) nặng nhưng thuộc tính năng cổ vật riêng → **ngoài phạm vi** (Phase 2).
|
||||
|
||||
`sharp` đã có sẵn trong `node_modules`.
|
||||
|
||||
## Quyết định đã chốt
|
||||
|
||||
| Hạng mục | Lựa chọn |
|
||||
|---|---|
|
||||
| Mức nén panorama | Rộng **6144px**, **JPEG q80** (mozjpeg, progressive) — cân bằng nét/nhẹ (~30MB tổng) |
|
||||
| Sửa bản đồ | **Auto-fit** (co cả bộ điểm vào khung, lề an toàn) |
|
||||
| Tối ưu engine | **Gói đầy đủ** (render on-demand, bỏ preserveDrawingBuffer, hạ pixelRatio, preload thông minh, blur-up LQIP) |
|
||||
| Bản gốc ảnh | Backup sang `image-originals/` (gitignored) trước khi ghi đè |
|
||||
| GLB cổ vật | Ngoài phạm vi (Phase 2) |
|
||||
|
||||
## Phần A — Pipeline nén ảnh
|
||||
|
||||
**File mới:** `scripts/optimize-images.mjs` (chạy bằng `node scripts/optimize-images.mjs`).
|
||||
|
||||
**Panorama** (`public/images/chua-thai-lac/*.jpg`):
|
||||
1. Với mỗi ảnh: nếu `image-originals/chua-thai-lac/<tên>` **chưa** tồn tại → di chuyển ảnh hiện tại vào đó (backup). Luôn đọc **nguồn từ backup** để nén lại an toàn, không nén chồng (idempotent).
|
||||
2. `sharp(src).rotate()` (áp dụng EXIF orientation rồi xoá metadata) → `.resize({ width: 6144, withoutEnlargement: true })` → `.jpeg({ quality: 80, mozjpeg: true, progressive: true })` → ghi đè file đích trong `public/images/chua-thai-lac/`.
|
||||
3. In log: tên, kích thước trước/sau, % giảm.
|
||||
|
||||
**Modal PNG** (`public/modal/modal-1.png`, `modal-2.png`):
|
||||
- Backup sang `image-originals/modal/` rồi nén lại bằng sharp giữ alpha: `.png({ compressionLevel: 9, palette: true, quality: 80 })`. Mục tiêu < 500KB/ảnh. `background-1.png` (252KB) giữ nguyên.
|
||||
|
||||
**Blur-up / LQIP** (phục vụ Phần C-5):
|
||||
- Với mỗi panorama, tạo bản thu nhỏ rộng ~32px, JPEG q40, xuất base64 data-URI.
|
||||
- Ghi tất cả vào `src/app/tours/chua-thai-lac.lqip.ts` dưới dạng `export const lqip: Record<string, string>` khoá theo đường dẫn ảnh (vd `"/images/chua-thai-lac/01.jpg"`). Tổng ~40KB inline.
|
||||
|
||||
**`.gitignore`:** thêm dòng `/image-originals`.
|
||||
|
||||
**Sau khi chạy:** kiểm tra mắt vài cảnh, rồi commit ảnh đã nén + `lqip.ts`.
|
||||
|
||||
## Phần B — Bản đồ auto-fit (`MiniMap` trong `VirtualTour.tsx`)
|
||||
|
||||
Chỉ áp dụng cho **overview mode** (panel "Vị trí"); radar mode trong tour giữ nguyên.
|
||||
|
||||
- Tính bao (bounds) của toàn bộ điểm có `mapPosition`: `minX, maxX, minY, maxY` (tính một lần, từ `scenes`).
|
||||
- Đặt **lề** `MARGIN = 8` (đơn vị toạ độ 0–100).
|
||||
- Hàm map cho overview:
|
||||
```
|
||||
fittedX = MARGIN + (x - minX) / (maxX - minX) * (100 - 2*MARGIN)
|
||||
fittedY = MARGIN + (y - minY) / (maxY - minY) * (100 - 2*MARGIN)
|
||||
```
|
||||
- Thay thế `compressOverviewY` + clamp cứng hiện tại trong nhánh `isOverview` của `getMapPosition`. Giữ `clampMapPoint` làm lớp an toàn cuối.
|
||||
- Áp dụng đồng nhất cho cả node (div) và đường nối (SVG line) vì cả hai đều qua `getMapPosition`.
|
||||
|
||||
Kết quả: 23 điểm + nhãn luôn nằm trọn trong khung, căn giữa, cân đối, không bị cắt.
|
||||
|
||||
## Phần C — Gói tối ưu engine (`VirtualTour.tsx`)
|
||||
|
||||
1. **Render theo nhu cầu (on-demand):**
|
||||
- Thêm ref `needsRenderRef` + hàm `requestRender()` đặt cờ.
|
||||
- Vòng `animate()` vẫn chạy qua RAF nhưng **chỉ gọi `renderer.render()` + `updateHotspots()` khi** cờ bật hoặc đang ở trạng thái động (transition, autoRotate, VR, đang kéo controls).
|
||||
- Kích hoạt `requestRender()` ở: sự kiện `controls 'change'`, resize, đổi FOV, chuyển cảnh, bật/tắt autoRotate/VR, thay đổi orientation thiết bị.
|
||||
- Mục tiêu: khi đứng yên, GPU gần như không tải.
|
||||
|
||||
2. **Bỏ `preserveDrawingBuffer: true`** trong cấu hình `WebGLRenderer` (đã xác nhận không có tính năng nào phụ thuộc).
|
||||
|
||||
3. **Hạ pixelRatio:** cap `Math.min(devicePixelRatio, 1.8)` → `1.5` trong renderer của tour.
|
||||
|
||||
4. **Preload thông minh:**
|
||||
- Chỉ preload texture của các cảnh **kề trực tiếp** với cảnh hiện tại (đã đúng), nhưng **lùi xuống lúc rảnh** bằng `requestIdleCallback` (fallback `setTimeout`) thay vì gọi ngay khi vào cảnh.
|
||||
- Cảnh hiện tại vẫn tải ưu tiên ngay.
|
||||
|
||||
5. **Blur-up (LQIP):**
|
||||
- Khi bắt đầu tải một cảnh, nạp ngay texture từ data-URI trong `lqip` (Phần A) làm ảnh nền mờ tức thì trên sphere.
|
||||
- Khi texture full tải xong → thay vào, bỏ blur (có thể fade nhẹ qua cơ chế transition material sẵn có).
|
||||
- Nếu cảnh không có LQIP → giữ hành vi loading hiện tại.
|
||||
|
||||
## Phần D — `next.config` / khác
|
||||
|
||||
- Không bắt buộc đổi cho texture THREE (tải trực tiếp). Welcome cards dùng `next/image` trên ảnh đã nén → tự tối ưu thêm, không cần sửa.
|
||||
- Không thêm cấu hình thừa.
|
||||
|
||||
## Rủi ro & giảm thiểu
|
||||
|
||||
- **Render on-demand** là thay đổi rủi ro nhất (dễ "đứng hình" nếu quên `requestRender` ở một tương tác). Giảm thiểu: liệt kê đủ điểm kích hoạt ở trên; test kỹ kéo/zoom/chuyển cảnh/VR/auto-rotate.
|
||||
- **Nén ảnh không hoàn nguyên** nếu chạy chồng. Giảm thiểu: luôn nén từ `image-originals/` backup.
|
||||
- **Chất lượng khi zoom sâu:** 6144px/q80 đủ cho FOV tối thiểu 36; nếu thấy thiếu nét có thể nâng q hoặc width sau (chạy lại từ backup).
|
||||
|
||||
## Tiêu chí hoàn thành
|
||||
|
||||
- Tổng ảnh panorama giảm từ 394MB xuống ~30MB; mỗi ảnh ~1–1.5MB.
|
||||
- Panel "Vị trí": đủ 23 điểm hiển thị, căn giữa, không điểm/nhãn nào bị cắt.
|
||||
- Tour: chuyển cảnh mượt, có ảnh mờ tạm khi tải; khi đứng yên không vẽ thừa; không còn giật rõ rệt.
|
||||
- `tsc --noEmit` sạch; tour chạy đúng (kéo/zoom/chuyển cảnh/mini-map/VR/auto-rotate).
|
||||
|
||||
## Ngoài phạm vi (Phase 2 — nếu muốn)
|
||||
|
||||
- Nén `antiquity/chuong-1.glb` (28MB) và `chuong-2.glb` (42MB) bằng Draco/meshopt (cần `gltf-transform`).
|
||||
@@ -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**, 2–4MB/ả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 70–85% 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 3–8MB. 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 → 0–1**.
|
||||
- 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 22 MiB After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 13 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 23 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 14 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 799 KiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 994 KiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 863 KiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 19 MiB After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 964 KiB |
|
Before Width: | Height: | Size: 14 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 817 KiB |
|
Before Width: | Height: | Size: 10 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 992 KiB |
|
Before Width: | Height: | Size: 10 MiB After Width: | Height: | Size: 810 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 21 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 839 KiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 14 MiB After Width: | Height: | Size: 949 KiB |
|
After Width: | Height: | Size: 565 KiB |
|
Before Width: | Height: | Size: 13 MiB After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 892 KiB |
|
Before Width: | Height: | Size: 19 MiB After Width: | Height: | Size: 912 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 999 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 14 MiB After Width: | Height: | Size: 906 KiB |
|
After Width: | Height: | Size: 531 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 565 KiB |
|
After Width: | Height: | Size: 650 KiB |
|
After Width: | Height: | Size: 561 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 527 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 666 KiB |