znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第10章:框架集成、实战项目与部署优化

前面的九章,我们从 Viewer 核心 API、标记系统、插件生态、地图集成一路讲到视频全景和移动端交互——你已经有能力基于 PSV 构建功能齐全的全景应用。本章将这些能力整合到真实的生产环境中:在现代前端框架(React / Vue)中封装和复用全景组件,通过三个完整的实战项目巩固所学知识,最后讨论性能优化、构建部署以及从 v4 向 v5 的迁移路径。

无论你是在做一个房地产 VR 看房项目,还是一个景区全景导览平台,抑或是 360° 视频展厅,本章的内容都将提供可以直接落地的参考方案。

10.1 React 集成

将 PSV 集成到 React 中有两条路径:使用社区维护的封装库 react-photo-sphere-viewer,或者手动编写 useEffect 生命周期管理。前者适合快速开发,后者适合需要完全控制 Viewer 行为的场景。

10.1.1 社区封装:react-photo-sphere-viewer

react-photo-sphere-viewer 由 Elia Lazzari(GitHub: Elius94)维护,它将 Viewer 实例化、生命周期管理和 DOM 容器绑定封装为 React 组件,支持 props 传递配置、事件回调以及通过 ref 暴露 Viewer 方法。

安装:

npm install @photo-sphere-viewer/core react-photo-sphere-viewer

注意:自 v5.0.0-psv5.7.1 起,react-photo-sphere-viewer 不再内置 @photo-sphere-viewer/core,需要单独安装。所有插件和适配器也需要直接从 @photo-sphere-viewer/* 包导入。

基础用法:

import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer';
import '@photo-sphere-viewer/core/index.css';

function App() {
  return (
    <div className="App">
      <ReactPhotoSphereViewer
        src="panorama.jpg"
        height="100vh"
        width="100%"
      />
    </div>
  );
}

带插件的完整示例:

import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { CompassPlugin } from '@photo-sphere-viewer/compass-plugin';
import { GalleryPlugin } from '@photo-sphere-viewer/gallery-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/markers-plugin/index.css';
import '@photo-sphere-viewer/gallery-plugin/index.css';

function App() {
  const handleReady = (viewer) => {
    console.log('Viewer ready, plugins:', viewer.getPlugin('markers'));
  };

  const handleClick = (data, viewer) => {
    console.log('Clicked at yaw:', data.data.yaw, 'pitch:', data.data.pitch);
  };

  return (
    <ReactPhotoSphereViewer
      src="living-room.jpg"
      height="100vh"
      width="100%"
      navbar={['zoom', 'fullscreen', 'gallery', 'caption']}
      caption="客厅全景"
      defaultYaw="45deg"
      defaultPitch="5deg"
      plugins={[
        [MarkersPlugin, {
          markers: [
            {
              id: 'door',
              position: { yaw: '90deg', pitch: '0deg' },
              html: '主卧入口',
              tooltip: '点击进入主卧',
            },
          ],
        }],
        [CompassPlugin, {
          hotspots: [
            { yaw: '0deg' },
            { yaw: '90deg' },
            { yaw: '180deg' },
            { yaw: '270deg' },
          ],
        }],
        [GalleryPlugin, {
          items: [
            { id: '1', panorama: 'living-room.jpg', thumbnail: 'thumb-living.jpg', name: '客厅' },
            { id: '2', panorama: 'bedroom.jpg', thumbnail: 'thumb-bedroom.jpg', name: '主卧' },
          ],
        }],
      ]}
      onReady={handleReady}
      onClick={handleClick}
    />
  );
}

Props 分类说明:

类别 Props 说明
标准 Props src, height, width, containerClass 基础容器和图片配置
特效 Props littlePlanet, fishEye 小行星效果和鱼眼效果
控制 Props navbar, hideNavbarButton, lang 导航栏和行为控制
Viewer 原 Props panorama, plugins, adapter, defaultYaw, defaultPitch, minFov, maxFov, moveSpeed, zoomSpeed, panoData, rendererParameters 几乎所有 ViewerConfig 选项都能作为 Props 传入
事件 Props onReady, onClick, onDblclick, onPositionChange, onZoomChange 回调接收 (eventData, viewerInstance)

通过 ref 调用 Viewer 方法:

import { useRef } from 'react';
import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer';

function App() {
  const viewerRef = useRef();

  const switchPanorama = () => {
    viewerRef.current.setPanorama('new-pano.jpg', {
      transition: { speed: 800, effect: 'fade' },
    });
  };

  const getPluginData = () => {
    const markersPlugin = viewerRef.current.getPlugin('markers');
    console.log('Current markers:', markersPlugin);
  };

  return (
    <>
      <ReactPhotoSphereViewer
        ref={viewerRef}
        src="panorama.jpg"
        height="80vh"
      />
      <button onClick={switchPanorama}>切换全景</button>
      <button onClick={getPluginData}>获取标记数据</button>
    </>
  );
}

ref 暴露的主要方法:

方法 说明
setPanorama(path, options?) 切换全景图
getPlugin(id) 获取已注册的插件实例
getPosition() 获取当前视角位置
getZoomLevel() 获取当前缩放级别
rotate(position) 旋转到指定位置
zoom(value) / zoomIn(step) / zoomOut(step) 缩放控制
animate(options) 启动动画
destroy() 销毁 Viewer
setOption(key, value) / setOptions(partial) 动态修改配置
enterFullscreen() / exitFullscreen() 全屏控制
showError(msg) / hideError() 错误提示

10.1.2 手动封装 React 组件

如果项目需要对 Viewer 生命周期做更精细的控制——如条件性创建、与 Redux/Zustand 状态同步、或在 StrictMode 下避免双重实例化——手动封装是更好的选择。

基础封装:PanoramaViewer 组件:

import { useEffect, useRef, useCallback } from 'react';
import { Viewer } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';

export function PanoramaViewer({
  panorama,
  plugins,
  defaultYaw = 0,
  defaultPitch = 0,
  onReady,
  className = '',
  style = {},
}) {
  const containerRef = useRef(null);
  const viewerRef = useRef(null);

  // 初始化
  useEffect(() => {
    if (!containerRef.current) return;

    const viewer = new Viewer({
      container: containerRef.current,
      panorama,
      plugins: plugins || [],
      defaultYaw,
      defaultPitch,
    });

    viewerRef.current = viewer;

    viewer.addEventListener('ready', () => {
      onReady?.(viewer);
    }, { once: true });

    return () => {
      viewer.destroy();
      viewerRef.current = null;
    };
  }, []); // 仅在挂载时创建

  // 响应式全景切换(不重建 Viewer)
  useEffect(() => {
    if (!viewerRef.current) return;
    viewerRef.current.setPanorama(panorama, {
      transition: { speed: 1000, effect: 'fade' },
    });
  }, [panorama]);

  return (
    <div
      ref={containerRef}
      className={`panorama-container ${className}`}
      style=
    />
  );
}

严格模式下的安全封装:

React 18+ 的 StrictMode 会在开发环境下双重调用 useEffect,导致 Viewer 被创建两次。以下模式处理这个问题:

useEffect(() => {
  let viewer = null;
  let destroyed = false;

  const container = containerRef.current;
  if (!container) return;

  viewer = new Viewer({
    container,
    panorama,
    plugins,
  });

  if (destroyed) {
    viewer.destroy();
    return;
  }

  viewerRef.current = viewer;

  return () => {
    destroyed = true;
    viewer?.destroy();
    viewerRef.current = null;
  };
}, []);

动态全景切换的 React 模式:

当全景 URL 频繁变化时,每次销毁重建 Viewer 会带来闪烁和性能开销。推荐使用 setPanorama 进行热切换:

function RoomViewer({ currentRoom }) {
  const containerRef = useRef(null);
  const viewerRef = useRef(null);
  const initializedRef = useRef(false);

  // 仅首次挂载时创建 Viewer
  useEffect(() => {
    if (initializedRef.current || !containerRef.current) return;
    initializedRef.current = true;

    const viewer = new Viewer({
      container: containerRef.current,
      panorama: currentRoom.panorama,
      plugins: [
        [MarkersPlugin, { markers: currentRoom.markers }],
      ],
    });

    viewerRef.current = viewer;

    return () => {
      viewer.destroy();
      viewerRef.current = null;
      initializedRef.current = false;
    };
  }, []);

  // 房间切换时更新全景和标记
  useEffect(() => {
    const viewer = viewerRef.current;
    if (!viewer) return;

    viewer.setPanorama(currentRoom.panorama, {
      transition: { speed: 800, effect: 'fade' },
    });

    const markersPlugin = viewer.getPlugin('markers');
    if (markersPlugin) {
      markersPlugin.setMarkers(currentRoom.markers);
    }
  }, [currentRoom]);

  return <div ref={containerRef} style= />;
}

子组件通信模式:

当需要从父组件控制 Viewer 行为(如通过外部 UI 旋转视角、切换标记可见性),可以通过 useImperativeHandle 暴露操作接口:

import { forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
import { Viewer } from '@photo-sphere-viewer/core';

export const PanoramaViewer = forwardRef(function PanoramaViewer(
  { panorama, plugins }, ref
) {
  const containerRef = useRef(null);
  const viewerRef = useRef(null);

  useEffect(() => {
    const viewer = new Viewer({
      container: containerRef.current,
      panorama,
      plugins,
    });
    viewerRef.current = viewer;
    return () => viewer.destroy();
  }, []);

  useImperativeHandle(ref, () => ({
    rotateTo(yaw, pitch) {
      viewerRef.current?.rotate({ yaw, pitch });
    },
    getViewer() {
      return viewerRef.current;
    },
    getPlugin(id) {
      return viewerRef.current?.getPlugin(id);
    },
  }));

  return <div ref={containerRef} style= />;
});

// 父组件使用
function App() {
  const viewerRef = useRef();

  return (
    <>
      <button onClick={() => viewerRef.current?.rotateTo('45deg', '10deg')}>
        看向厨房
      </button>
      <PanoramaViewer
        ref={viewerRef}
        panorama="house.jpg"
        plugins={[[MarkersPlugin, { markers: [...] }]]}
      />
    </>
  );
}

10.2 Vue 集成

PSV 官方没有提供现成的 Vue 封装库,但 Vue 3 的 Composition API 让手动封装变得极为顺畅。以下展示三种封装方案,从简单到复杂逐级推进。

10.2.1 Vue 3 组件封装

基础单文件组件(SFC):

<template>
  <div ref="containerRef" class="panorama-viewer"></div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { Viewer } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';

const props = defineProps({
  panorama: { type: String, required: true },
  plugins: { type: Array, default: () => [] },
  defaultYaw: { type: [Number, String], default: 0 },
  defaultPitch: { type: [Number, String], default: 0 },
});

const emit = defineEmits(['ready', 'positionChange']);

const containerRef = ref(null);
let viewer = null;

onMounted(() => {
  viewer = new Viewer({
    container: containerRef.value,
    panorama: props.panorama,
    plugins: props.plugins,
    defaultYaw: props.defaultYaw,
    defaultPitch: props.defaultPitch,
  });

  viewer.addEventListener('ready', () => {
    emit('ready', viewer);
  }, { once: true });

  viewer.addEventListener('position-updated', (e) => {
    emit('positionChange', e.position);
  });
});

onBeforeUnmount(() => {
  viewer?.destroy();
  viewer = null;
});

// 响应式全景切换
watch(() => props.panorama, (newVal) => {
  viewer?.setPanorama(newVal, {
    transition: { speed: 800, effect: 'fade' },
  });
});
</script>

<style scoped>
.panorama-viewer {
  width: 100%;
  height: 100vh;
}
</style>

父组件使用:

<template>
  <div>
    <PanoramaViewer
      ref="viewerComp"
      :panorama="currentPano"
      :plugins="viewerPlugins"
      @ready="onViewerReady"
      @position-change="onPositionChange"
    />
    <button @click="gotoKitchen">看向厨房</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import PanoramaViewer from './PanoramaViewer.vue';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import '@photo-sphere-viewer/markers-plugin/index.css';

const viewerComp = ref(null);
const currentPano = ref('living-room.jpg');

const viewerPlugins = [
  [MarkersPlugin, {
    markers: [
      { id: 'kitchen', position: { yaw: '90deg', pitch: '0deg' }, html: '厨房' },
    ],
  }],
];

function onViewerReady(viewer) {
  console.log('Viewer ready');
}

function onPositionChange(position) {
  console.log('Position:', position.yaw, position.pitch);
}

function gotoKitchen() {
  viewerComp.value?.viewer?.rotate({ yaw: '90deg', pitch: '0deg' });
}
</script>

10.2.2 可复用 Composable:usePhotoSphereViewer

将 Viewer 生命周期管理抽取为 Composable,让多个组件共享同一套逻辑:

// composables/usePhotoSphereViewer.js
import { onBeforeUnmount, ref, shallowRef } from 'vue';
import { Viewer } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';

export function usePhotoSphereViewer(config) {
  const containerRef = ref(null);
  const viewerRef = shallowRef(null);
  const isReady = ref(false);

  function init() {
    if (!containerRef.value || viewerRef.value) return;

    const viewer = new Viewer({
      container: containerRef.value,
      ...config,
    });

    viewerRef.value = viewer;

    viewer.addEventListener('ready', () => {
      isReady.value = true;
      config.onReady?.(viewer);
    }, { once: true });

    return viewer;
  }

  function destroy() {
    viewerRef.value?.destroy();
    viewerRef.value = null;
    isReady.value = false;
  }

  function getPlugin(id) {
    return viewerRef.value?.getPlugin(id);
  }

  function setPanorama(path, options) {
    return viewerRef.value?.setPanorama(path, options);
  }

  function rotate(position) {
    viewerRef.value?.rotate(position);
  }

  onBeforeUnmount(destroy);

  return {
    containerRef,
    viewerRef,
    isReady,
    init,
    destroy,
    getPlugin,
    setPanorama,
    rotate,
  };
}

在组件中使用 Composable:

<template>
  <div ref="containerRef" class="viewer"></div>
</template>

<script setup>
import { watch } from 'vue';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { usePhotoSphereViewer } from '../composables/usePhotoSphereViewer';

const props = defineProps({
  panorama: { type: String, required: true },
});

const {
  containerRef,
  viewerRef,
  isReady,
  init,
  getPlugin,
  setPanorama,
  rotate,
} = usePhotoSphereViewer({
  panorama: props.panorama,
  plugins: [
    [MarkersPlugin, {
      markers: [
        { id: 'm1', position: { yaw: '45deg', pitch: '10deg' }, html: '标记1' },
      ],
    }],
  ],
  navbar: ['zoom', 'fullscreen', 'markers'],
});

// 在此触发初始化(需要在 onMounted 之后执行)
import { onMounted } from 'vue';

onMounted(() => {
  init();
});

watch(() => props.panorama, (url) => {
  setPanorama(url, { transition: { speed: 600 } });
});

defineExpose({ viewerRef, rotate, getPlugin });
</script>

10.3 TypeScript 实践

PSV v5 全量采用 TypeScript 编写,类型体系完备。在本节中,我们讨论在框架集成中如何充分利用类型系统。

10.3.1 核心类型导入

所有类型均从 @photo-sphere-viewer/core 导出:

import type {
  Viewer,
  ViewerConfig,
  ViewerState,
  Position,
  ExtendedPosition,
  AnimateOptions,
  PanoData,
  PluginConfig,
  CssSize,
} from '@photo-sphere-viewer/core';
类型 用途
Viewer Viewer 实例类型
ViewerConfig 创建 Viewer 时的配置对象类型(v4 中叫 ViewerOptions
ViewerState Viewer 运行时状态(v4 中叫 ViewerProps
Position 球面坐标 { yaw, pitch }
ExtendedPosition 扩展坐标(支持角度字符串如 '45deg'
AnimateOptions 动画配置
PanoData 全景图像元数据
PluginConfig 插件配置条目类型
CssSize CSS 尺寸类型({ width, height }

10.3.2 事件类型

PSV v5 使用原生 EventTarget API,事件类型通过 events 命名空间导出:

import { events } from '@photo-sphere-viewer/core';
import type {
  ClickEvent,
  PositionUpdateEvent,
  ZoomUpdateEvent,
  ReadyEvent,
  RenderEvent,
} from '@photo-sphere-viewer/core';

const viewer = new Viewer({ container, panorama: 'pano.jpg' });

viewer.addEventListener(events.PositionUpdateEvent.type, (e: PositionUpdateEvent) => {
  console.log(e.position.yaw, e.position.pitch);
});

viewer.addEventListener(events.ClickEvent.type, (e: ClickEvent) => {
  console.log('Clicked at', e.data.yaw, e.data.pitch, 'on marker:', e.data.marker);
});

插件也有独立的事件类型:

import type { SelectMarkerEvent } from '@photo-sphere-viewer/markers-plugin';

viewer.addEventListener('select-marker', (e: SelectMarkerEvent) => {
  console.log('Selected marker:', e.marker.id);
});

10.3.3 插件配置类型

每个插件的配置类型命名规则为 XxxPluginConfig(v4 中叫 XxxPluginOptions):

import type {
  MarkersPluginConfig,
  MarkerConfig,
} from '@photo-sphere-viewer/markers-plugin';
import type { GalleryPluginConfig } from '@photo-sphere-viewer/gallery-plugin';
import type { VirtualTourPluginConfig } from '@photo-sphere-viewer/virtual-tour-plugin';

const markersConfig: MarkersPluginConfig = {
  markers: [
    {
      id: 'p1',
      position: { yaw: 0.5, pitch: 0.1 },
      html: 'Point 1',
    },
  ],
};

10.3.4 React 组件 TypeScript 封装

import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { Viewer, type ViewerConfig } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';

interface PanoramaViewerProps extends Omit<ViewerConfig, 'container'> {
  className?: string;
  style?: React.CSSProperties;
}

export interface PanoramaViewerHandle {
  viewer: Viewer | null;
  rotateTo: (yaw: number | string, pitch: number | string) => void;
}

export const PanoramaViewer = forwardRef<PanoramaViewerHandle, PanoramaViewerProps>(
  function PanoramaViewer({ className = '', style = {}, ...viewerConfig }, ref) {
    const containerRef = useRef<HTMLDivElement>(null);
    const viewerRef = useRef<Viewer | null>(null);

    useEffect(() => {
      if (!containerRef.current) return;

      const viewer = new Viewer({
        container: containerRef.current,
        ...viewerConfig,
      });

      viewerRef.current = viewer;

      return () => {
        viewer.destroy();
        viewerRef.current = null;
      };
    }, []);

    useImperativeHandle(ref, () => ({
      get viewer() {
        return viewerRef.current;
      },
      rotateTo(yaw, pitch) {
        viewerRef.current?.rotate({ yaw, pitch });
      },
    }));

    return (
      <div
        ref={containerRef}
        className={`panorama-viewer ${className}`}
        style=
      />
    );
  }
);

10.3.5 tsconfig.json 配置

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

关键配置项说明:

配置 原因
target ES2020 PSV v5 使用 ES2020+ 语法,index.module.js 包含可选链等特性
moduleResolution bundler 现代打包器(Vite/Webpack 5)模式,兼容 PSV 的 ESM 导出
strict true PSV 类型定义完整,使用严格模式能及早发现类型错误
skipLibCheck true Three.js 的类型定义较宽泛,跳过避免编译耗时

10.3.6 泛型约束自定义插件

当扩展自定义插件时,利用泛型约束确保类型安全:

import { AbstractPlugin, type Viewer, type PluginConfig } from '@photo-sphere-viewer/core';

interface HotspotPluginConfig extends PluginConfig {
  hotspots: Array<{
    id: string;
    position: { yaw: number | string; pitch: number | string };
    color?: string;
    radius?: number;
  }>;
}

class HotspotPlugin extends AbstractPlugin {
  static override id = 'hotspot-plugin';

  constructor(viewer: Viewer, config: HotspotPluginConfig) {
    super(viewer);
    this.config = { ...HotspotPlugin.defaultConfig, ...config };
  }

  static defaultConfig: HotspotPluginConfig = {
    hotspots: [],
  };

  declare config: HotspotPluginConfig;
}

10.4 实战项目一:房产虚拟看房

10.4.1 需求分析

一个典型的房地产 VR 看房应用需要以下能力:

需求 描述
多房间全景漫游 客厅、卧室、厨房、卫生间之间自由切换
POI 标记 标注户型亮点(采光面、精装细节、家电品牌)
平面图导航 楼层平面图上显示当前位置和可跳转点位
移动端适配 支持陀螺仪旋转、触摸手势缩放
过渡动画 场景切换时平滑过渡
信息面板 点击标记展示详细信息

10.4.2 技术选型

插件 用途
VirtualTourPlugin 管理房间节点和链接,控制漫游逻辑
MarkersPlugin 在房间内标注 POI 信息点
PlanPlugin 基于 Leaflet 的真实 GIS 地图(可选)或 MapPlugin 展示楼层平面图
GyroscopePlugin 移动端陀螺仪交互
GalleryPlugin 房间缩略图快速切换

对于平面图导航,如果项目需要真实的地理坐标定位,使用 PlanPlugin(Leaflet);如果只是楼层示意图,使用 MapPlugin 更轻量。

10.4.3 项目结构

vr-house/
├── index.html
├── src/
│   ├── main.js
│   ├── config/
│   │   ├── rooms.js          # 房间数据(全景图URL、标记、链接)
│   │   └── floorPlan.js      # 平面图数据
│   ├── viewer/
│   │   └── HouseViewer.js    # 核心 Viewer 管理器
│   ├── ui/
│   │   ├── RoomInfoPanel.js  # 房间信息面板
│   │   └── FloorPlanMap.js   # 平面图导航
│   └── utils/
│       └── preloader.js      # 预加载工具
└── panoramas/                # 全景图资源
    ├── living-room.jpg
    ├── bedroom.jpg
    ├── kitchen.jpg
    └── thumbs/               # 缩略图

10.4.4 核心代码实现

房间数据配置(config/rooms.js):

export const rooms = {
  'living-room': {
    id: 'living-room',
    name: '客厅',
    panorama: 'panoramas/living-room.jpg',
    thumbnail: 'panoramas/thumbs/living-room.jpg',
    defaultYaw: '30deg',
    description: '宽敞明亮,南北通透',
    // VirtualTour 节点链接
    links: [
      { nodeId: 'bedroom', position: { yaw: '90deg', pitch: '0deg' }, name: '主卧' },
      { nodeId: 'kitchen', position: { yaw: '-90deg', pitch: '0deg' }, name: '厨房' },
    ],
    // MarkersPlugin 标记
    markers: [
      {
        id: 'window-view',
        position: { yaw: '0deg', pitch: '-5deg' },
        html: '<div class="marker marker-view">好视野</div>',
        tooltip: '南向落地窗,采光极佳',
        data: { type: 'feature', detail: '3.2m面宽落地窗' },
      },
      {
        id: 'floor-material',
        position: { yaw: '-45deg', pitch: '10deg' },
        html: '<div class="marker marker-material">实木地板</div>',
        tooltip: '橡木实木复合地板',
        data: { type: 'material', detail: '橡木实木复合,地暖适用' },
      },
    ],
    // 平面图坐标
    floorPlan: { x: 200, y: 100 },
  },
  'bedroom': {
    id: 'bedroom',
    name: '主卧',
    panorama: 'panoramas/bedroom.jpg',
    thumbnail: 'panoramas/thumbs/bedroom.jpg',
    links: [
      { nodeId: 'living-room', position: { yaw: '-90deg', pitch: '0deg' }, name: '客厅' },
    ],
    markers: [
      {
        id: 'closet',
        position: { yaw: '45deg', pitch: '0deg' },
        html: '<div class="marker marker-closet">步入式衣帽间</div>',
        data: { type: 'feature', detail: '8m²步入式衣帽间' },
      },
    ],
    floorPlan: { x: 350, y: 100 },
  },
  'kitchen': {
    id: 'kitchen',
    name: '厨房',
    panorama: 'panoramas/kitchen.jpg',
    thumbnail: 'panoramas/thumbs/kitchen.jpg',
    links: [
      { nodeId: 'living-room', position: { yaw: '90deg', pitch: '0deg' }, name: '客厅' },
    ],
    markers: [
      {
        id: 'oven',
        position: { yaw: '-30deg', pitch: '5deg' },
        html: '<div class="marker marker-appliance">集成灶</div>',
        data: { type: 'appliance', detail: '方太集成烹饪中心' },
      },
    ],
    floorPlan: { x: 100, y: 250 },
  },
};

核心 Viewer 管理器(viewer/HouseViewer.js):

import { Viewer } from '@photo-sphere-viewer/core';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { VirtualTourPlugin } from '@photo-sphere-viewer/virtual-tour-plugin';
import { GyroscopePlugin } from '@photo-sphere-viewer/gyroscope-plugin';
import { MapPlugin } from '@photo-sphere-viewer/map-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/markers-plugin/index.css';
import '@photo-sphere-viewer/virtual-tour-plugin/index.css';
import '@photo-sphere-viewer/map-plugin/index.css';
import { rooms } from '../config/rooms';

export class HouseViewer {
  constructor(container, options = {}) {
    this.container = container;
    this.currentRoomId = null;
    this.onRoomChange = options.onRoomChange || (() => {});
    this.onMarkerClick = options.onMarkerClick || (() => {});

    this._initViewer();
  }

  _initViewer() {
    const firstRoom = Object.values(rooms)[0];

    this.viewer = new Viewer({
      container: this.container,
      panorama: firstRoom.panorama,
      defaultYaw: firstRoom.defaultYaw,
      navbar: [
        'zoom',
        'fullscreen',
        'autorotate',
        'gyroscope',
        {
          id: 'floor-plan-toggle',
          title: '平面图',
          content: '🏠',
          className: 'floor-plan-btn',
          onClick: () => this.toggleFloorPlan(),
        },
      ],
      plugins: [
        [MarkersPlugin, {
          markers: firstRoom.markers,
        }],
        [VirtualTourPlugin, {
          positionMode: 'manual',
          renderMode: 'markers',
          nodes: this._buildNodes(),
          startNodeId: firstRoom.id,
        }],
        [GyroscopePlugin, {
          touchmove: true,
        }],
        [MapPlugin, {
          imageUrl: 'floor-plan.png',
          center: firstRoom.floorPlan,
          size: '250px',
          position: 'bottom right',
          visibleOnLoad: false,
          hotspots: this._buildHotspots(firstRoom.id),
        }],
      ],
    });

    this._bindEvents();
  }

  _buildNodes() {
    return Object.values(rooms).map(room => ({
      id: room.id,
      panorama: room.panorama,
      name: room.name,
      links: room.links,
      markers: room.markers,
      defaultYaw: room.defaultYaw,
    }));
  }

  _buildHotspots(currentRoomId) {
    return Object.values(rooms).map(room => ({
      id: room.id,
      x: room.floorPlan.x,
      y: room.floorPlan.y,
      tooltip: room.name,
    }));
  }

  _bindEvents() {
    // 房间切换事件
    this.viewer.addEventListener('node-changed', (e) => {
      const room = rooms[e.nodeId];
      if (!room) return;

      this.currentRoomId = e.nodeId;

      // 更新标记
      const markersPlugin = this.viewer.getPlugin('markers');
      if (markersPlugin) {
        markersPlugin.setMarkers(room.markers);
      }

      // 更新平面图热点样式
      const mapPlugin = this.viewer.getPlugin('map');
      if (mapPlugin) {
        mapPlugin.setCenter(room.floorPlan, false);
      }

      this.onRoomChange(room);
    });

    // 标记点击事件
    this.viewer.addEventListener('select-marker', (e) => {
      this.onMarkerClick(e.marker);
    });
  }

  toggleFloorPlan() {
    const mapPlugin = this.viewer.getPlugin('map');
    if (!mapPlugin) return;
    if (mapPlugin.isVisible()) {
      mapPlugin.close();
    } else {
      mapPlugin.open();
    }
  }

  navigateTo(roomId) {
    const tourPlugin = this.viewer.getPlugin('virtualTour');
    if (tourPlugin) {
      tourPlugin.setCurrentNode(roomId);
    }
  }

  destroy() {
    this.viewer?.destroy();
  }
}

入口文件(main.js):

import { HouseViewer } from './viewer/HouseViewer.js';

const container = document.getElementById('viewer');
const infoPanel = document.getElementById('room-info');

const houseViewer = new HouseViewer(container, {
  onRoomChange: (room) => {
    infoPanel.innerHTML = `
      <h2>${room.name}</h2>
      <p>${room.description}</p>
    `;
  },
  onMarkerClick: (marker) => {
    if (marker.data?.detail) {
      showDetailPopup(marker.data.detail);
    }
  },
});

function showDetailPopup(text) {
  const popup = document.getElementById('detail-popup');
  popup.textContent = text;
  popup.style.display = 'block';
  setTimeout(() => { popup.style.display = 'none'; }, 3000);
}

10.4.5 关键设计决策

决策点 选择 理由
标记渲染模式 renderMode: 'markers' 使用 3D 标记(而非 HTML 覆盖层),交互更自然,随球面旋转消失
位置模式 positionMode: 'manual' 在 VirtualTour 节点中手动指定每个房间链接的位置,比 GPS 模式更灵活
平面图插件 MapPlugin(非 PlanPlugin) 室内看房用楼层平面图即可,无需 Leaflet 的真实地理定位
导航栏定制 自定义按钮混合默认按钮 保留核心交互(缩放、全屏),添加自定义”平面图”切换按钮
陀螺仪 始终加载 GyroscopePlugin 移动端看房是核心场景,加载即可用

10.5 实战项目二:景区全景导览

10.5.1 需求分析

景区全景导览与室内看房的核心差异在于:场景更开放、视角更广阔、用户对地理位置和方向的感知需求更强。

需求 描述
多景点切换 观景台、瀑布、寺庙等多个景点之间切换
自动导览模式 按推荐路线自动切换全景和视角
缩略图画廊 所有景点的缩略图导航
信息面板 当前景点的名称、海拔、介绍文字
地图联动 地图上同步显示当前位置和朝向(户外的真实 GPS 坐标更有意义)
指南针 角落指南针帮助辨别方向

10.5.2 技术选型

插件 用途
GalleryPlugin 景点缩略图导航
AutorotatePlugin 自动旋转或在导览模式下按关键点序列展示
MarkersPlugin 景点内的标记(观鸟点、休息区、历史遗迹说明牌)
CompassPlugin 指南针指示当前朝向
MapPlugin 景区手绘地图叠加

10.5.3 核心代码实现

import { Viewer } from '@photo-sphere-viewer/core';
import { GalleryPlugin } from '@photo-sphere-viewer/gallery-plugin';
import { AutorotatePlugin } from '@photo-sphere-viewer/autorotate-plugin';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { CompassPlugin } from '@photo-sphere-viewer/compass-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/gallery-plugin/index.css';
import '@photo-sphere-viewer/markers-plugin/index.css';
import '@photo-sphere-viewer/compass-plugin/index.css';

const scenicSpots = [
  {
    id: 'viewpoint',
    name: '观景台',
    panorama: 'panoramas/viewpoint.jpg',
    thumbnail: 'panoramas/thumbs/viewpoint.jpg',
    description: '海拔 1800m,可俯瞰整片山谷',
    markers: [
      {
        id: 'east-peak',
        position: { yaw: '0deg', pitch: '5deg' },
        html: '<div class="marker-scenic">东峰</div>',
        data: { spot: 'east-peak' },
      },
      {
        id: 'waterfall',
        position: { yaw: '120deg', pitch: '-10deg' },
        html: '<div class="marker-scenic">银链瀑布</div>',
        data: { spot: 'waterfall' },
      },
    ],
  },
  {
    id: 'waterfall',
    name: '银链瀑布',
    panorama: 'panoramas/waterfall.jpg',
    thumbnail: 'panoramas/thumbs/waterfall.jpg',
    description: '落差 68m 的三叠瀑布',
    markers: [
      {
        id: 'viewpoint',
        position: { yaw: '180deg', pitch: '10deg' },
        html: '<div class="marker-scenic">返回观景台</div>',
        data: { spot: 'viewpoint' },
      },
    ],
  },
  {
    id: 'temple',
    name: '古寺',
    panorama: 'panoramas/temple.jpg',
    thumbnail: 'panoramas/thumbs/temple.jpg',
    description: '始建于明代的古刹',
    markers: [
      {
        id: 'ancient-tree',
        position: { yaw: '-45deg', pitch: '5deg' },
        html: '<div class="marker-scenic">千年银杏</div>',
        data: { tree: 'ginkgo' },
      },
    ],
  },
];

const viewer = new Viewer({
  container: document.getElementById('viewer'),
  panorama: scenicSpots[0].panorama,
  navbar: [
    'zoom',
    'fullscreen',
    'gallery',
    'autorotate',
    {
      id: 'tour-mode',
      title: '导览模式',
      content: '🎧',
      onClick: () => startAutoTour(),
    },
  ],
  plugins: [
    [GalleryPlugin, {
      thumbnailSize: { width: 120, height: 60 },
      items: scenicSpots.map(spot => ({
        id: spot.id,
        panorama: spot.panorama,
        thumbnail: spot.thumbnail,
        name: spot.name,
      })),
    }],
    [AutorotatePlugin, {
      autostartDelay: null,
      speed: '1rpm',
    }],
    [MarkersPlugin, {
      markers: scenicSpots[0].markers,
    }],
    [CompassPlugin, {
      hotspots: [
        { yaw: '0deg' },    // 北
        { yaw: '90deg' },   // 东
        { yaw: '180deg' },  // 南
        { yaw: '270deg' },  // 西
      ],
    }],
  ],
});

// 信息面板更新
function updateInfoPanel(spot) {
  document.getElementById('spot-name').textContent = spot.name;
  document.getElementById('spot-desc').textContent = spot.description;
}

// 全景切换时同步更新标记和信息
viewer.addEventListener('panorama-loaded', (e) => {
  const spot = scenicSpots.find(s => s.panorama === e.data.panorama);
  if (!spot) return;

  const markersPlugin = viewer.getPlugin('markers');
  markersPlugin?.setMarkers(spot.markers);
  updateInfoPanel(spot);
});

// 标记点击跳转
viewer.addEventListener('select-marker', (e) => {
  const targetSpotId = e.marker.data?.spot;
  if (targetSpotId) {
    const target = scenicSpots.find(s => s.id === targetSpotId);
    if (target) {
      viewer.setPanorama(target.panorama, {
        transition: { speed: 1200, effect: 'fade' },
      });
    }
  }
});

// 自动导览模式
let tourTimer = null;

function startAutoTour() {
  const autorotate = viewer.getPlugin('autorotate');
  let index = 0;

  function nextSpot() {
    const spot = scenicSpots[index % scenicSpots.length];
    viewer.setPanorama(spot.panorama, {
      transition: { speed: 1000, effect: 'fade' },
    });

    autorotate?.start();

    index++;
    tourTimer = setTimeout(() => {
      autorotate?.stop();
      nextSpot();
    }, 8000); // 每个景点展示8秒
  }

  if (tourTimer) {
    clearTimeout(tourTimer);
    tourTimer = null;
    autorotate?.stop();
    return;
  }

  nextSpot();
}

10.5.4 地图联动与指南针

对于户外景区全景,建议使用 PlanPlugin 关联真实 GPS 坐标实现地图联动:

import { PlanPlugin } from '@photo-sphere-viewer/plan-plugin';
import '@photo-sphere-viewer/plan-plugin/index.css';

// PlanPlugin 配置
[PlanPlugin, {
  defaultZoom: 14,
  coordinates: [120.15, 30.28], // 景区中心 GPS
  hotspots: scenicSpots.map(spot => ({
    id: spot.id,
    coordinates: spot.coordinates, // [lng, lat]
    tooltip: spot.name,
    color: '#e74c3c',
  })),
  // 地图上标记的名称
  poiTooltip: (poi) => poi.tooltip,
}]

MapPlugin 与 PlanPlugin 的对比选择:

维度 MapPlugin PlanPlugin
底图 自定义静态图片(PNG/JPG/SVG) Leaflet 瓦片地图(OSM 等)
定位方式 像素坐标或角度+距离 GPS 经纬度
适合场景 景区手绘地图、室内平面图 需要真实地理位置的户外全景
依赖 Leaflet(约 40KB gzip)
交互能力 缩放平移,热点可点击 标准地图交互(缩放、平移、图层切换)

10.6 实战项目三:360° 视频展厅

10.6.1 需求分析

360° 视频展厅与静态全景项目的最大区别在于”时间维度”——视频在播放过程中画面不断变化,需要在时间轴上做标记和交互。

需求 描述
360° 视频播放 支持多分辨率、流畅播放
移动端 VR 模式 双屏立体显示 + 陀螺仪
时间轴标记 视频播放到特定时间点时触发事件
场景切换 多段视频之间的切换
播放控制 暂停/播放、音量、进度条
加载状态 视频加载进度指示

10.6.2 技术选型

插件/适配器 用途
EquirectangularVideoAdapter 等距柱状投影视频适配器(360° MP4)
VideoPlugin 视频播放控制(播放/暂停、进度条、音量)
ResolutionPlugin 多分辨率切换(必须配合 SettingsPlugin)
SettingsPlugin 设置面板框架
StereoPlugin 立体 VR 视图(Cardboard 等)
GyroscopePlugin 移动端陀螺仪旋转

10.6.3 核心代码实现

import { Viewer } from '@photo-sphere-viewer/core';
import { EquirectangularVideoAdapter } from '@photo-sphere-viewer/equirectangular-video-adapter';
import { VideoPlugin } from '@photo-sphere-viewer/video-plugin';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
import { StereoPlugin } from '@photo-sphere-viewer/stereo-plugin';
import { GyroscopePlugin } from '@photo-sphere-viewer/gyroscope-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/video-plugin/index.css';
import '@photo-sphere-viewer/resolution-plugin/index.css';
import '@photo-sphere-viewer/settings-plugin/index.css';
import '@photo-sphere-viewer/stereo-plugin/index.css';

const viewer = new Viewer({
  container: document.getElementById('viewer'),
  adapter: [EquirectangularVideoAdapter, {
    autoplay: false,
    muted: false,
  }],
  panorama: {
    source: 'exhibition-4k.mp4',
  },
  navbar: [
    'zoom',
    'fullscreen',
    'settings',
    {
      id: 'stereo-toggle',
      title: 'VR模式',
      content: '🥽',
      onClick: () => {
        viewer.getPlugin('stereo')?.toggle();
      },
    },
  ],
  plugins: [
    [VideoPlugin, {
      // 视频进度上的关键帧标记
      keypoints: [
        { time: 5, label: '展厅A入口' },
        { time: 18, label: '主展区中心' },
        { time: 35, label: '互动装置区' },
        { time: 52, label: '出口/礼品店' },
      ],
      // 自定义进度条样式
      progressbar: {
        className: 'custom-progress-bar',
      },
    }],
    [SettingsPlugin, {}],
    [ResolutionPlugin, {
      resolutions: [
        {
          id: '4K',
          label: '超清 4K',
          panorama: { source: 'exhibition-4k.mp4' },
        },
        {
          id: '1080p',
          label: '高清 1080p',
          panorama: { source: 'exhibition-1080p.mp4' },
        },
        {
          id: '720p',
          label: '流畅 720p',
          panorama: { source: 'exhibition-720p.mp4' },
        },
      ],
    }],
    [StereoPlugin, {
      // VR 立体视图配置
      overlay: null, // 禁用默认叠加层
    }],
    [GyroscopePlugin, {
      touchmove: true,
      absolute: false,
    }],
  ],
});

// 视频进度事件——在特定时间点触发交互
viewer.addEventListener('keypoint-reached', (e) => {
  const { time, label } = e;
  showSectionInfo(label, time);
});

function showSectionInfo(label, time) {
  const infoBar = document.getElementById('section-info');
  infoBar.textContent = `当前位置:${label}`;
  infoBar.style.display = 'block';
  setTimeout(() => { infoBar.style.display = 'none'; }, 4000);
}

// 多段视频切换
function switchToVideo(videoUrl, quality = '1080p') {
  viewer.setPanorama(
    { source: videoUrl },
    { transition: { speed: 500, effect: 'fade' } }
  );
}

// 视频播放状态监听
viewer.addEventListener('play', () => {
  console.log('视频开始播放');
});

viewer.addEventListener('pause', () => {
  console.log('视频暂停');
});

// 获取视频插件实例以进行程序化控制
const videoPlugin = viewer.getPlugin('video');

document.getElementById('play-btn').addEventListener('click', () => {
  videoPlugin?.play();
});

document.getElementById('pause-btn').addEventListener('click', () => {
  videoPlugin?.pause();
});

document.getElementById('mute-btn').addEventListener('click', () => {
  videoPlugin?.toggleMute();
});

document.getElementById('seek-to-30').addEventListener('click', () => {
  videoPlugin?.setTime(30);
});

10.6.4 视频关键帧与场景切换

VideoPluginkeypoints 配置允许在进度条上标记关键时间点。结合事件监听可以实现精准的场景联动:

// 场景数据配置
const exhibitionSections = [
  { startTime: 0, endTime: 10, name: '前厅', panorama: 'exhibition-part1.mp4' },
  { startTime: 10, endTime: 25, name: '主展厅', panorama: 'exhibition-part2.mp4' },
  { startTime: 25, endTime: 40, name: '互动区', panorama: 'exhibition-part3.mp4' },
];

// 监听 keypoint 事件,触发 UI 更新
viewer.addEventListener('keypoint-reached', (e) => {
  const section = exhibitionSections.find(
    s => e.time >= s.startTime && e.time <= s.endTime
  );
  if (section) {
    document.getElementById('section-indicator').textContent = section.name;
  }
});

四个与视频相关的 keypoint 事件:

事件 参数 触发时机
keypoint-reached { time, label } 视频进度到达标记点
play 视频开始播放
pause 视频暂停
volumechange { volume, muted } 音量变化

10.6.5 视频多分辨率策略

ResolutionPlugin 的核心价值在于让用户根据网络条件选择画质。推荐提供三档分辨率:

分辨率 典型参数 码率建议 适用网络
4K 3840×1920 15-25 Mbps Wi-Fi / 5G
1080p 1920×960 5-10 Mbps 4G
720p 1280×640 2-4 Mbps 弱网 / 3G

通过 SettingsPlugin 作为容器,ResolutionPlugin 会自动注册画质切换按钮。

10.7 性能优化

全景应用的性能瓶颈通常不在 JS 逻辑,而在 GPU 纹理带宽和渲染管线。本节讨论从图像分辨率到渲染参数的完整优化链条。

10.7.1 全景图分辨率选择策略

全景图的理想分辨率取决于两个因素:设备视口宽度最大 FOV。全景球面上的纹理利用率不是 100%——用户同一时刻只能看到球面的一部分(约 90°-100° FOV)。

设备类型 屏幕宽度 推荐全景图分辨率 每像素对
桌面显示器 1920px 4096×2048 (4K) 约 2.1
笔记本 1440px 3072×1536 约 2.1
平板横屏 1024px 2048×1024 (2K) 约 2.0
手机竖屏 375px 1024×512 约 1.8

“每像素对” = 全景图水平分辨率 / 屏幕宽度。该值在 1.5-2.0 之间时,人眼感知的清晰度损失很小,同时比加载原图节省 50%-75% 的带宽。

策略:根据屏幕尺寸动态加载不同分辨率

function getOptimalResolution() {
  const width = window.innerWidth;
  if (width >= 1600) return '4k';
  if (width >= 900) return '2k';
  return '1k';
}

const resolutionMap = {
  '4k': 'panoramas/living-room-4k.jpg',
  '2k': 'panoramas/living-room-2k.jpg',
  '1k': 'panoramas/living-room-1k.jpg',
};

const optimalUrl = resolutionMap[getOptimalResolution()];

const viewer = new Viewer({
  container: document.getElementById('viewer'),
  panorama: optimalUrl,
});

也可以在 <picture> 风格中使用 srcset 类似的方式(需自行实现),或结合 ResolutionPlugin 动态切换。

10.7.2 分块加载(TilesAdapter)

当单张全景图超过 8K 时,使用分块加载能显著降低首屏等待时间。EquirectangularTilesAdapter 将全景图切分为多级分辨率的瓦片:

import { EquirectangularTilesAdapter } from '@photo-sphere-viewer/equirectangular-tiles-adapter';

const viewer = new Viewer({
  container: document.getElementById('viewer'),
  adapter: [EquirectangularTilesAdapter, {
    // 使用 GDAL2Tiles 或类似工具生成的瓦片
    baseUrl: 'tiles/',
    tileSize: 512,
    levels: [
      { width: 512, cols: 1, rows: 1 },        // 缩放级别 0
      { width: 1024, cols: 2, rows: 1 },       // 缩放级别 1
      { width: 2048, cols: 4, rows: 2 },       // 缩放级别 2
      { width: 4096, cols: 8, rows: 4 },       // 缩放级别 3
      { width: 8192, cols: 16, rows: 8 },      // 缩放级别 4
    ],
  }],
});
场景 推荐方案
普通全景图(≤ 4K) 单张 Equirectangular,不做分块
高清全景图(4K - 8K) 单张 + 资源预加载(相邻节点),根据需要决定是否分块
超高清全景图(> 8K) 必须分块 TilesAdapter,否则加载时间过长
动态按需缩放 TilesAdapter 自动根据 FOV 选择瓦片级别,无需手动干预

10.7.3 缓存策略

PSV 依赖浏览器的标准 HTTP 缓存机制。全景图作为最大资源,应配置较长的缓存时间:

Nginx 配置示例:

location /panoramas/ {
  expires 30d;
  add_header Cache-Control "public, immutable";
}

使用 Service Worker 做离线缓存(进阶):

// sw.js
const CACHE_NAME = 'pano-cache-v1';
const PANO_URLS = [
  '/panoramas/living-room.jpg',
  '/panoramas/bedroom.jpg',
  '/panoramas/kitchen.jpg',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(PANO_URLS))
  );
});

对于需要离线展示的看房或导览应用,Service Worker 缓存可以确保用户在无网络环境下仍能浏览已缓存的房间。

10.7.4 预加载策略

预加载可以消除场景切换时的黑屏等待时间。以下是三种策略:

策略一:相邻节点预加载

function preloadAdjacentNodes(currentRoomId, rooms) {
  const room = rooms[currentRoomId];
  const adjacentIds = room.links.map(link => link.nodeId);

  adjacentIds.forEach(id => {
    const img = new Image();
    img.src = rooms[id].panorama;
  });
}

viewer.addEventListener('node-changed', (e) => {
  preloadAdjacentNodes(e.nodeId, rooms);
});

策略二:画廊缩略图预加载(GalleryPlugin 自动处理)

GalleryPlugin 默认会预加载所有 thumbnail 图片,全景图本身在用户点击时才加载——这已经是合理的默认行为。

策略三:空闲时间预加载

function idlePreload(urls) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      urls.forEach(url => {
        const link = document.createElement('link');
        link.rel = 'prefetch';
        link.href = url;
        document.head.appendChild(link);
      });
    });
  }
}

10.7.5 Three.js 渲染优化

rendererParameters 配置:

const viewer = new Viewer({
  container: document.getElementById('viewer'),
  panorama: 'panorama.jpg',
  rendererParameters: {
    antialias: false,           // 全景球面不需要抗锯齿
    powerPreference: 'high-performance',
  },
});
参数 推荐值 说明
antialias false 全景球面边缘在相机坐标系中很稳定,无需 MSAA
powerPreference 'high-performance' 优先使用独立 GPU
alpha false(默认) 除非需要透明背景,否则关闭以节省资源

pixelRatio 控制:

// 限制设备像素比,避免高 DPI 屏过度渲染
const viewer = new Viewer({
  container: document.getElementById('viewer'),
  panorama: 'panorama.jpg',
  rendererParameters: {
    // 通过外部控制 pixelRatio
  },
});

// 在创建后手动设置
viewer.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

pixelRatio 上限设为 2,在 Retina 屏上节省约 50% 的像素填充开销,而视觉效果几乎无差异。

FPS 控制:

PSV 默认会根据用户交互状态自动调整渲染频率——交互时高帧率,静止时停止渲染。当使用了 AutorotatePlugin 等持续运动的插件后,渲染会保持激活。通过 needsContinuousUpdate() 可以手动控制:

// 启用持续渲染
viewer.needsContinuousUpdate(true);

// 禁用持续渲染(恢复按需渲染)
viewer.needsContinuousUpdate(false);

10.7.6 代码分割

PSV 的 monorepo 包结构天然支持按需加载。不要一次性导入所有插件:

// 不推荐:一次性导入所有可能的插件
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { GalleryPlugin } from '@photo-sphere-viewer/gallery-plugin';
import { VirtualTourPlugin } from '@photo-sphere-viewer/virtual-tour-plugin';
import { GyroscopePlugin } from '@photo-sphere-viewer/gyroscope-plugin';
// ... 10+ imports

// 推荐:按路由/页面按需加载
const loadViewerPlugins = async (pageType) => {
  switch (pageType) {
    case 'house':
      return {
        tour: await import('@photo-sphere-viewer/virtual-tour-plugin'),
        markers: await import('@photo-sphere-viewer/markers-plugin'),
      };
    case 'scenic':
      return {
        gallery: await import('@photo-sphere-viewer/gallery-plugin'),
        compass: await import('@photo-sphere-viewer/compass-plugin'),
      };
  }
};

CSS 同样需要按需引入,不要在入口文件引入所有插件的样式。

10.7.7 Lighthouse 指标

一个优化良好的全景页面,在 Lighthouse 上的表现可以达到以下水平:

指标 优化后数值 优化手段
FCP(首次内容绘制) < 1.5s 合理分辨率、渐进式加载
LCP(最大内容绘制) < 2.5s 全景图 HTTP 缓存、CDN、TilesAdapter
TBT(总阻塞时间) < 100ms 代码分割、延迟加载非核心插件
CLS(累计布局偏移) < 0.1 固定 Viewer 容器尺寸、loadingImg 占位
Performance Score 90+ 综合上述优化

10.8 构建与部署

10.8.1 Webpack 配置

// webpack.config.js
const path = require('path');

module.exports = {
  entry: './src/main.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.[contenthash].js',
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
      {
        // 处理 Three.js 和 PSV 包中的静态资源
        test: /\.(glb|gltf|hdr|bin)$/,
        type: 'asset/resource',
      },
    ],
  },
  resolve: {
    extensions: ['.js'],
    // 确保 Three.js 只用一份副本
    alias: {
      three: path.resolve('./node_modules/three'),
    },
  },
};

10.8.2 Vite 配置

Vite 对 ESM 原生支持的特性与 PSV 的 index.module.js 天然契合:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    target: 'es2020',
    rollupOptions: {
      output: {
        manualChunks: {
          'three': ['three'],
          'psv-core': ['@photo-sphere-viewer/core'],
          'psv-plugins': [
            '@photo-sphere-viewer/markers-plugin',
            '@photo-sphere-viewer/virtual-tour-plugin',
            '@photo-sphere-viewer/gallery-plugin',
          ],
        },
      },
    },
  },
});

通过 manualChunks 将 Three.js 和 PSV 拆分为独立 chunk,利用浏览器缓存减少二次访问的加载量。

10.8.3 静态资源路径处理

全景图通常托管在 CDN 或对象存储上。部署时确保路径正确:

// 环境感知的路径配置
const BASE_URL = import.meta.env.VITE_CDN_URL || '';
const PANO_PATH = `${BASE_URL}/panoramas`;

const viewer = new Viewer({
  container: document.getElementById('viewer'),
  panorama: `${PANO_PATH}/living-room.jpg`,
});

Vite 中处理 public 目录的全景图:

将全景图放在 public/panoramas/ 下,构建后会被原样复制到 dist/panoramas/。代码中引用时使用绝对路径:

panorama: '/panoramas/living-room.jpg'

10.8.4 GitHub Pages / Vercel / Netlify 部署

GitHub Pages:

GitHub Pages 对静态站点部署提供了最简单的路径。将构建输出推到 gh-pages 分支或配置 GitHub Actions 自动部署:

# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: $
          publish_dir: ./dist

Vercel:

Vercel 自动检测 Vite 项目,零配置部署。注意全景图资源可能超出 Vercel 的单文件 100MB 限制——这种情况下应将全景图托管在外部 CDN。

Netlify:

类似 Vercel,连接仓库后自动构建。需要在 netlify.toml 中指定构建命令和输出目录:

[build]
  command = "npm run build"
  publish = "dist"

[[headers]]
  for = "/panoramas/*"
  [headers.values]
    Cache-Control = "public, max-age=2592000, immutable"

10.8.5 CORS 和跨域资源加载

当全景图托管在不同于网页的域名时,浏览器会执行 CORS 检查。解决方式:

  1. CDN 配置 CORS 头
Access-Control-Allow-Origin: *
  1. Viewer 中启用跨域
const viewer = new Viewer({
  container: document.getElementById('viewer'),
  panorama: 'https://cdn.example.com/panoramas/living-room.jpg',
  withCredentials: false, // 默认值,匿名请求不发送 Cookie
});
  1. 使用 <img>crossorigin 属性(Three.js 内部通过 Image 加载纹理时设置)。

10.8.6 HTTPS 要求

以下功能强制要求 HTTPS(或在 localhost 下允许 HTTP):

功能 原因
GyroscopePlugin W3C DeviceOrientation API 仅在安全上下文可用
全屏 API requestFullscreen() 在某些浏览器中仅 HTTPS 可用
Service Worker 仅在 HTTPS 或 localhost 下注册
跨域纹理 crossOrigin: 'anonymous' 的 Three.js 纹理加载在某些浏览器中要求 HTTPS

部署到生产环境时,GitHub Pages / Vercel / Netlify 默认提供 HTTPS,无需额外配置。

10.8.7 Tree Shaking 与压缩

PSV 的 ESM 构建版本(index.module.js)支持 Tree Shaking。确保打包器配置为 ESM 模式即可。

针对 Three.js 的优化:

Three.js 的某些模块(如 WebGL 相关的内部实现)可能被完整引入。如果项目只用到了 PSV 提供的 Three.js 场景(不自行创建 Mesh 等),可以考虑使用 three-stdlib 或 import map 精确控制。

Brocoli / Gzip 压缩建议:

资源类型 压缩后典型大小 压缩策略
PSV Core ~45 KB (gzip) Brotli 可再减少 15%
PSV + 3 个插件 ~80 KB (gzip) Tree Shaking + 代码分割
Three.js ~120 KB (gzip) 仅导入用到的模块
全景图(2K) ~500 KB (JPG) WebP 可再减少 25-35%
全景图(4K) ~1.5 MB (JPG) 按设备分辨率加载 + WebP

10.9 从 v4 迁移到 v5

如果你的项目还在使用 Photo-Sphere-Viewer v4,本节提供完整的迁移清单。

10.9.1 包名变更对照

v4 包 v5 包 说明
photo-sphere-viewer @photo-sphere-viewer/core 核心 Viewer
photo-sphere-viewer/dist/plugins/markers @photo-sphere-viewer/markers-plugin 标记插件
photo-sphere-viewer/dist/plugins/virtual-tour @photo-sphere-viewer/virtual-tour-plugin 虚拟导览
photo-sphere-viewer/dist/plugins/gallery @photo-sphere-viewer/gallery-plugin 画廊
photo-sphere-viewer/dist/plugins/gyroscope @photo-sphere-viewer/gyroscope-plugin 陀螺仪
photo-sphere-viewer/dist/plugins/compass @photo-sphere-viewer/compass-plugin 指南针
photo-sphere-viewer/dist/plugins/autorotate-keypoints @photo-sphere-viewer/autorotate-plugin 自动旋转
photo-sphere-viewer/dist/plugins/visible-range @photo-sphere-viewer/visible-range-plugin 可见范围
photo-sphere-viewer/dist/plugins/video @photo-sphere-viewer/video-plugin 视频控制
photo-sphere-viewer/dist/plugins/resolution @photo-sphere-viewer/resolution-plugin 分辨率
photo-sphere-viewer/dist/plugins/settings @photo-sphere-viewer/settings-plugin 设置面板
photo-sphere-viewer/dist/plugins/stereo @photo-sphere-viewer/stereo-plugin VR 立体
photo-sphere-viewer/dist/adapters/equirectangular @photo-sphere-viewer/core(内置) 等距柱状投影
photo-sphere-viewer/dist/adapters/cubemap @photo-sphere-viewer/cubemap-adapter 立方体贴图
photo-sphere-viewer/dist/adapters/equirectangular-tiles @photo-sphere-viewer/equirectangular-tiles-adapter 分块全景
photo-sphere-viewer/dist/adapters/equirectangular-video @photo-sphere-viewer/equirectangular-video-adapter 视频全景

10.9.2 事件系统迁移

v4 使用 uEvent 库,v5 改用原生 EventTarget API:

// v4
viewer.on('position-updated', (e, position) => {
  console.log(position.longitude, position.latitude);
});
viewer.once('ready', () => {});
viewer.off('position-updated', handler);

// v5
viewer.addEventListener('position-updated', (e) => {
  console.log(e.position.yaw, e.position.pitch);
});
viewer.addEventListener('ready', () => {}, { once: true });
viewer.removeEventListener('position-updated', handler);

10.9.3 坐标重命名

球面坐标和像素坐标全部重命名,以避免与 GPS 系统混淆:

v4 v5 含义
longitude yaw 水平偏航角
latitude pitch 垂直俯仰角
x(纹理坐标) textureX 纹理像素 X 坐标
y(纹理坐标) textureY 纹理像素 Y 坐标

配置项同步重命名:

v4 v5
defaultLong defaultYaw
defaultLat defaultPitch

10.9.4 标记 API 变更

// v4
markers: [{
  id: 'zone',
  polygonRad: [{ longitude: 0.1, latitude: 0.2 }, ...],
  polygonPx: [100, 200, 300, ...],
  polylineRad: [...],
  polylinePx: [...],
}]

// v5
markers: [{
  id: 'zone',
  polygon: [{ yaw: 0.1, pitch: 0.2 }, ...],
  polygonPixels: [100, 200, 300, ...],
  polyline: [...],
  polylinePixels: [...],
}]

10.9.5 TypeScript 类型重命名

v4 v5
ViewerOptions ViewerConfig
ViewerProps ViewerState
XxxAdapterOptions XxxAdapterConfig
XxxPluginOptions XxxPluginConfig
MarkerProperties MarkerConfig

10.9.6 自动旋转迁移

v4 的自动旋转是 Viewer 的内置功能(autorotateSpeedautorotateDelay 等选项)。v5 将其彻底迁移到独立插件:

// v4
const viewer = new Viewer({
  panorama: 'pano.jpg',
  autorotateSpeed: '1rpm',
  autorotateDelay: 2000,
  autorotateZoom: false,
});

// v5
import { AutorotatePlugin } from '@photo-sphere-viewer/autorotate-plugin';

const viewer = new Viewer({
  panorama: 'pano.jpg',
  plugins: [
    [AutorotatePlugin, {
      autostartDelay: 2000,
      speed: '1rpm',
      autostartOnIdle: true,
    }],
  ],
});

所有 autorotateXxx 配置项在 v5 核心中已删除。

10.9.7 迁移检查清单

  • 将所有 photo-sphere-viewer 的导入改为 @photo-sphere-viewer/core
  • 每个插件从独立包导入(@photo-sphere-viewer/xxx-plugin
  • 每个插件引入独立的 CSS(@photo-sphere-viewer/xxx-plugin/index.css
  • viewer.on() / viewer.off() / viewer.once()addEventListener / removeEventListener
  • longitudeyawlatitudepitch
  • x / y(纹理坐标) → textureX / textureY
  • polygonRadpolygonpolygonPxpolygonPixels
  • ViewerOptionsViewerConfigXxxOptionsXxxConfig
  • 自动旋转功能从 Viewer 配置移除,改用 AutorotatePlugin
  • 检查所有事件的回调参数格式(v4 的 (e, arg) → v5 的 (e),数据在 e 属性中)
  • 运行测试覆盖全景加载、标记交互、插件功能等核心路径

10.10 常见问题与故障排除

10.10.1 全景图不显示

症状 原因 解决
黑屏 容器尺寸为 0 给容器设置明确的高度(如 100vh500px),或在 Viewer 构造后调用 autoSize()
白屏 图片格式不支持 确认使用 JPEG/PNG/WebP 格式;检查图片 URL 是否可访问
黑屏(有时) CORS 被拦截 检查浏览器控制台的跨域错误;在 CDN 配置 Access-Control-Allow-Origin: *
画面变形 全景图不是等距柱状投影 确认图片为 2:1 比例;若不是标准全景图,使用 panoData 配置裁剪参数
加载转圈不消失 图片过大 降低分辨率、启用 TilesAdapter、或使用 WebP 格式

10.10.2 插件不工作

症状 常见原因
插件无效果 未引入 CSS(@photo-sphere-viewer/xxx-plugin/index.css
插件报错 xyz is undefined 插件导入顺序错误,依赖的插件未注册(如 ResolutionPlugin 依赖 SettingsPlugin)
插件 API 调用无响应 ready 事件之前调用了插件方法;应等待 viewer.addEventListener('ready', ...)
TypeScript 报类型错误 未安装插件的类型包或使用了 v4 的类型名(XxxOptionsXxxConfig

10.10.3 标记位置偏移

标记在全景球面上偏移通常由以下原因造成:

  1. 坐标系混淆——最常发生在从 v4 迁移到 v5 后仍使用 longitude/latitude 名称。v5 已重命名为 yaw/pitch
  2. panoData 配置不匹配——如果全景图被裁剪(cropped panorama),但没有提供 panoDatafullWidthfullHeightcroppedXcroppedY 等),标记将基于”完整”球面坐标计算,导致偏移。
  3. 纹理坐标 vs 球面坐标混用——polygonPixels 使用像素坐标,polygon 使用弧度坐标。在使用标记生成工具提取坐标时,确认使用的是哪种坐标系统。

检查方法:

viewer.addEventListener('click', (e) => {
  console.log('Click position:', e.data);
  // 对比标记的 position 与点击位置是否在合理范围内
});

10.10.4 移动端性能差

症状 可能原因 解决
拖动卡顿 全景图分辨率过高 移动端使用 1024×512 或 1920×960 分辨率
发热严重 持续高帧率渲染 不使用 AutorotatePlugin 时自动停止渲染;限制 pixelRatio ≤ 2
陀螺仪不响应 非 HTTPS 环境或无传感器权限 部署到 HTTPS;iOS 13+ 需用户主动授权 DeviceOrientationEvent.requestPermission()
页面崩溃 内存不足 切换全景图前 destroy() 旧 Viewer;同一页面不要同时存在多个 Viewer 实例

iOS 陀螺仪权限请求:

const gyroBtn = document.getElementById('enable-gyro');

gyroBtn.addEventListener('click', async () => {
  if (typeof DeviceOrientationEvent?.requestPermission === 'function') {
    const permission = await DeviceOrientationEvent.requestPermission();
    if (permission === 'granted') {
      // 权限已获取,陀螺仪插件自动激活
    }
  }
});

10.10.5 同页面多个 Viewer

在同一个页面上创建多个 Viewer 实例是可行的,但需要注意:

// 为每个 Viewer 使用独立的容器
const viewer1 = new Viewer({
  container: document.getElementById('viewer-1'),
  panorama: 'pano1.jpg',
});

const viewer2 = new Viewer({
  container: document.getElementById('viewer-2'),
  panorama: 'pano2.jpg',
});

注意事项:

注意点 说明
容器隔离 每个 Viewer 必须有独立的 HTML 容器元素
全屏冲突 同时只能有一个 Viewer 进入全屏模式
内存 每个 Viewer 都会创建独立的 WebGL 上下文;移动端建议最多 2 个同时活跃
事件隔离 每个 Viewer 的事件系统完全独立,互不干扰
导航栏 ID 冲突 v5 的导航栏按钮有默认 ID,多实例时注意避免 CSS ID 冲突

10.10.6 内存泄漏

最常见的两种内存泄漏模式:

模式一:事件监听器未清理

// 错误
function setupViewer() {
  const viewer = new Viewer({ container, panorama });
  document.addEventListener('resize', () => viewer.autoSize());
  // 如果 viewer 被 destroy 了,resize 监听器仍持有引用
}

// 正确
function setupViewer() {
  const viewer = new Viewer({ container, panorama });
  const handleResize = () => viewer.autoSize();
  document.addEventListener('resize', handleResize);

  // 在销毁时清理外部事件
  const originalDestroy = viewer.destroy.bind(viewer);
  viewer.destroy = () => {
    document.removeEventListener('resize', handleResize);
    originalDestroy();
  };
}

模式二:SPA 路由切换时未销毁

// React / Vue 路由切换
useEffect(() => {
  const viewer = new Viewer({ container, panorama });
  return () => {
    viewer.destroy(); // 必须在路由离开时销毁
  };
}, []);

验证内存泄漏:Chrome DevTools → Performance → 录制一段全景图切换操作 → 检查 JS Heap 是否持续增长。

10.10.7 构建工具兼容性

问题 原因 解决
Cannot find module CJS/ESM 混用 确保 moduleResolution: 'bundler''node16';使用现代打包器(Vite/Webpack 5)
Three.js 重复打包 多个三方库各自引入 Three.js 配置 resolve.alias 统一指向同一份 Three.js
CSS 加载失败 打包器未配置 CSS loader Vite 天然支持 import '*.css';Webpack 需 css-loader + style-loader
window is not defined SSR/SSG 环境(Next.js、Gatsby、Nuxt) PSV 依赖 WebGL 和 DOM,无法在服务端运行;使用 'use client'client:only 标记为客户端组件

Next.js 中的正确处理:

// app/page.js
'use client';

import dynamic from 'next/dynamic';

// 动态导入,禁止 SSR
const PanoramaViewer = dynamic(
  () => import('../components/PanoramaViewer'),
  { ssr: false }
);

export default function Page() {
  return <PanoramaViewer panorama="pano.jpg" />;
}

10.11 本章小结

本章是全系列教程的终章,聚焦于将 Photo-Sphere-Viewer 的知识转化为可部署的生产应用。以下是对本章核心要点的回顾。

10.11.1 核心要点回顾

模块 核心要点
React 集成 react-photo-sphere-viewer 快速上手,手动 useEffect 封装精细控制;通过 ref (useImperativeHandle) 暴露 Viewer 方法
Vue 集成 Vue 3 Composition API + onMounted/onBeforeUnmount 生命周期;usePhotoSphereViewer composable 实现逻辑复用
TypeScript 所有类型从 @photo-sphere-viewer/core 导入;PSV v5 事件系统基于 EventTarget,类型通过 events 命名空间获取
房产看房 VirtualTourPlugin 管理房间节点;MarkersPlugin 标注 POI;MapPlugin 平面图导航;GyroscopePlugin 移动端交互
景区导览 GalleryPlugin 缩略图导航;AutorotatePlugin 自动导览;CompassPlugin 方向指示;地图联动(PlanPlugin 真实地理 / MapPlugin 手绘地图)
视频展厅 EquirectangularVideoAdapter 视频适配;VideoPlugin 时间轴标记;ResolutionPlugin 多分辨率;StereoPlugin VR 立体
性能优化 按屏幕宽度选择分辨率;TilesAdapter 分块加载(> 8K 必备);pixelRatio ≤ 2;antialias=false;代码分割按需加载
构建部署 Webpack/Vite 配置 manualChunks 拆分 Three.js 和 PSV;静态资源 CDN 托管;HTTPS 是陀螺仪和全屏的前置条件
v4→v5 迁移 包名全部变更到 @photo-sphere-viewer/*;事件 API 由 uEvent → EventTarget;坐标 longitude/latitudeyaw/pitch;类型 XxxOptionsXxxConfig

10.11.2 学习路径总结

从第 1 章到第 10 章,本教程覆盖了以下完整的学习路径:

概述与学习路线
    │
    ▼
环境搭建 → Viewer 核心配置 ──→ 全景图类型与适配器
                                        │
                                        ▼
                              标记系统深度解析
                                        │
                                        ▼
                              插件体系与自定义开发
                                        │
                                        ▼
                    ┌───────────────────┼───────────────────┐
                    ▼                   ▼                   ▼
              虚拟导览+画廊        视频全景+移动端       地图集成+辅助
                    │                   │                   │
                    └───────────────────┼───────────────────┘
                                        ▼
                              框架集成+实战项目+部署

10.11.3 后续学习方向

掌握了本教程的全部内容后,你可以向以下方向进一步深入:

方向 学习内容
Three.js 深度 理解 PSV 底层的 Scene/Camera/Renderer 架构,开发自定义 shader 效果
WebXR 使用 StereoPlugin 的底层 WebXR API,实现原生 VR 头显支持而不依赖 Cardboard polyfill
全景图制作 学习 equirectangular 全景图拍摄流程(节点云台 + 拼接 + 后期),从源头控制内容质量
GIS 与全景融合 将 PlanPlugin 的能力与 PostGIS/GeoJSON 结合,构建大规模地理全景数据库
WebCodecs + WebGL 对 360° 视频实现帧级处理,如实时叠加字幕、虚拟植入 3D 对象
服务端渲染优化 为全景图 CDN 部署边缘函数(Edge Functions),实现动态分辨率选择和 WebP 格式转换

← 上一章 返回目录