# 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`).