# 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) ` --- ## 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 = {\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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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(); ``` 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(); 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) " ``` --- ## 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) " ``` --- ## 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. ✓ ```