Files
phy-vr360/docs/superpowers/plans/2026-06-24-toi-uu-phy-vr360.md
T
tuyenio 97644677ad docs: plan triển khai tối ưu phy-vr360
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:45:59 +07:00

24 KiB
Raw Blame History

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:

// 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
git add scripts/optimize-images.mjs .gitignore
git commit -m "feat: script nén ảnh panorama + LQIP

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 2: Chạy nén ảnh + commit tài sản đã nén

Files:

  • Modify (ghi đè): public/images/chua-thai-lac/*.jpg, public/modal/modal-1.png, public/modal/modal-2.png

  • Create: src/app/tours/chua-thai-lac.lqip.ts

  • Create (gitignored): image-originals/...

  • Step 1: Chạy script nén

Run: node scripts/optimize-images.mjs Expected: in từng dòng 01.jpg: 22.64MB -> ~1.xMB (-9x%) … và Wrote src/app/tours/chua-thai-lac.lqip.ts (23 entries) rồi Done.

  • Step 2: Xác nhận dung lượng đã giảm

Run: du -sh public/images/chua-thai-lac && du -sh image-originals/chua-thai-lac Expected: public/images/chua-thai-lac ~2535MB; image-originals/chua-thai-lac ~394MB (bản gốc đã backup).

  • Step 3: Xác nhận LQIP đã sinh đúng số lượng

Run: grep -c "data:image/jpeg;base64" src/app/tours/chua-thai-lac.lqip.ts Expected: 23

  • Step 4: Xác nhận image-originals không bị git theo dõi

Run: git status --porcelain image-originals | head Expected: không có dòng nào (thư mục bị ignore).

  • Step 5: Kiểm tra mắt vài ảnh (thủ công)

Mở public/images/chua-thai-lac/01.jpg, 12.jpg, 21.jpg bằng trình xem ảnh. Expected: rõ nét, không vỡ hạt rõ rệt, đúng nội dung. Mở public/modal/modal-1.png — Expected: vẫn giữ trong suốt, không banding nặng. (Nếu modal banding rõ: chấp nhận hoặc khôi phục từ image-originals/modal/ và bỏ phần modal trong script.)

  • Step 6: Commit ảnh đã nén + LQIP
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):

  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:

  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
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:

    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:

    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
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);):

  const currentFovRef = useRef(DEFAULT_FOV);
  const targetFovRef = useRef(DEFAULT_FOV);

Replace with:

  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:

    renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });

Replace with:

    const handleControlsChange = () => {
      needsRenderRef.current = true;
    };
    controls.addEventListener("change", handleControlsChange);

    renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });
  • Step 3: Gỡ listener khi cleanup

Find:

      renderer.setAnimationLoop(null);
      controls.dispose();

Replace with:

      controls.removeEventListener("change", handleControlsChange);
      renderer.setAnimationLoop(null);
      controls.dispose();
  • Step 4: Viết lại animate để vẽ theo nhu cầu

Find:

    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:

    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
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):

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:

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:

  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:

  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
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:

import { chuaThaiLacTour } from "@/app/tours/chua-thai-lac";

Replace with:

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:

const panoramaTextureCache = new Map<string, THREE.Texture>();

Add immediately before it:

// 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:

        } else {
          loadPanoramaTexture(scene.image, (progress) => {
            if (transitionTokenRef.current === token) {
              setLoadProgress(progress);
            }
          })

Replace with:

        } 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:

      setIsLoading(!initialCachedTexture);
      setLoadProgress(initialCachedTexture ? 100 : 0);
      loadTexture
        .then((texture) => {

Replace with:

      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
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)

git add -A
git commit -m "chore: hoàn tất tối ưu hiệu năng & bản đồ phy-vr360

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Self-review notes (đã đối chiếu với spec)

  • Spec Phần A (nén 6144/q80, backup gốc, modal PNG, LQIP) → Task 12. ✓
  • Spec Phần B (auto-fit, lề 8%) → Task 3. ✓
  • Spec Phần C-1 render on-demand → Task 5; C-2 bỏ preserveDrawingBuffer → Task 4; C-3 pixelRatio 1.5 → Task 4; C-4 preload idle → Task 6; C-5 blur-up → Task 7. ✓
  • Phase 2 (GLB) cố ý ngoài phạm vi. ✓
  • Type nhất quán: needsRenderRef định nghĩa ở Task 5 và dùng lại ở Task 7; loadLqipTexture/lqip khoá theo scene.image (đường dẫn /images/chua-thai-lac/NN.jpg) khớp với khoá script sinh ra ở Task 1. ✓