diff --git a/docs/superpowers/specs/2026-06-24-toi-uu-phy-vr360-design.md b/docs/superpowers/specs/2026-06-24-toi-uu-phy-vr360-design.md new file mode 100644 index 0000000..1d78482 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-toi-uu-phy-vr360-design.md @@ -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/` **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` 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`).