You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于Three.js的360全景图添加固定不缩放标记的技术求助

解决Three.js全景图添加固定位置标记的问题

核心思路

要实现缩放全景图时标记位置固定且大小不变,需利用CSS2DObject结合CSS2DRenderer:

  • CSS2DObject将DOM元素绑定到3D空间的固定点,确保标记始终对应全景图上的指定位置
  • CSS控制标记的大小,使其不受相机缩放(透视变化)影响
  • 正确初始化双渲染器(WebGLRenderer + CSS2DRenderer),并在动画循环中同步渲染

修改后的完整代码

import React, { useRef, useEffect, useState } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
import { EVENTS, TYPES, ThreeJsDrawing } from './threeJsDrawing';
import { setThreeJsData } from './mouseEvents';
import panImg from "../assets/shot.jpg";
import panImg2 from "../assets/shot2.jpg";
import panImg3 from "../assets/shot3.png";
import panImg4 from "../assets/shot4.png";

let scene, camera, controls, sphere, polygons = [], drawingTool, cssRenderer;
const sphereRadius = 5; // 全景球半径,保持全局一致

const images = [
  {
    url: panImg,
    location: {
      x: 369794,
      y: 2212918,
      lat: 20.008508,
      lng: 73.755165
    },
    markers: [[0.3, 0.4], [0.6, 0.7]] // 该全景图的标记UV坐标(u:0-1, v:0-1)
  },
  {
    url: panImg2,
    location: {
      x: 369779,
      y: 2212922,
      lat: 20.008382,
      lng: 73.755117
    },
    markers: [[0.2, 0.5]]
  },
  {
    url: panImg3,
    location: {
      x: 369769,
      y: 2212912,
      lat: 20.008251,
      lng: 73.755074
    },
    markers: []
  },
  {
    url: panImg4,
    location: {
      x: 369762,
      y: 2212895,
      lat: 20.008226,
      lng: 73.755165
    },
    markers: [[0.7, 0.3]]
  }
];

const PanoramaViewer = () => {
  const [activeImage, setActiveImage] = useState(0);
  const [renderer, setRenderer] = useState(null);
  const [loading, setLoading] = useState(true);
  const containerRef = useRef(null);
  const [markers, setMarkers] = useState([]); // 存储当前全景图的标记对象

  // 切换全景图时加载新图并更新标记
  useEffect(() => {
    if (activeImage !== null) loadImage(activeImage);
  }, [activeImage]);

  // 添加单个标记到场景
  const addMarker = (uv) => {
    const [u, v] = uv;
    // 将UV坐标转换为全景球上的3D坐标
    const theta = u * Math.PI * 2;
    const phi = v * Math.PI;
    const x = sphereRadius * Math.sin(phi) * Math.cos(theta);
    const y = sphereRadius * Math.cos(phi);
    const z = sphereRadius * Math.sin(phi) * Math.sin(theta);

    // 创建DOM标记元素
    const markerElement = document.createElement('div');
    markerElement.style.width = '20px';
    markerElement.style.height = '20px';
    markerElement.style.borderRadius = '50%';
    markerElement.style.backgroundColor = '#ff4444';
    markerElement.style.boxShadow = '0 0 10px rgba(255,68,68,0.8)';
    markerElement.style.position = 'absolute';
    markerElement.style.transform = 'translate(-50%, -50%)'; // 确保标记中心对准3D点
    markerElement.style.cursor = 'pointer';
    markerElement.title = '全景标记';

    const marker = new CSS2DObject(markerElement);
    marker.position.set(x, y, z);
    scene.add(marker);
    return marker;
  };

  // 清除当前所有标记
  const clearMarkers = () => {
    markers.forEach(marker => {
      scene.remove(marker);
      marker.element.remove(); // 清理DOM元素
    });
    setMarkers([]);
  };

  const loadImage = (index, initCall) => {
    const img = images[index];
    const [x, y] = [0, 0];
    const loader = new THREE.TextureLoader();
    
    // 先清除旧标记
    if (!initCall) clearMarkers();

    const texture = loader.load(img.url, (texture) => {
      texture.wrapS = THREE.RepeatWrapping;
      // 更新全景球材质
      if (initCall) {
        sphere.material.map = texture;
      } else {
        // 替换旧纹理并释放资源
        const oldTexture = sphere.material.map;
        sphere.material.map = texture;
        oldTexture.dispose();
      }
      sphere.material.needsUpdate = true;
      setLoading(false);

      // 添加当前全景图的标记
      const newMarkers = img.markers.map(uv => addMarker(uv));
      setMarkers(newMarkers);
    });

    if (initCall) {
      // 初始化全景球
      const geometry = new THREE.SphereGeometry(sphereRadius, 32, 32);
      geometry.scale(-1, 1, 1); // 内部可见
      const material = new THREE.MeshBasicMaterial({ map: texture });
      sphere = new THREE.Mesh(geometry, material);
      sphere.position.set(x, y, 0);
      scene.add(sphere);

      camera.position.set(x, y, 0.1);
      controls.target.set(x, y, 0);
      controls.update();
    }
  };

  const nextImage = () => {
    if (activeImage + 1 < images.length) {
      setLoading(true);
      setActiveImage(activeImage + 1);
    }
  };

  const prevImage = () => {
    if (activeImage - 1 >= 0) {
      setLoading(true);
      setActiveImage(activeImage - 1);
    }
  };

  const initThreeJs = () => {
    scene = new THREE.Scene();

    // 初始化相机
    camera = new THREE.PerspectiveCamera(
      75,
      containerRef.current.clientWidth / containerRef.current.clientHeight,
      1,
      1000
    );

    // 初始化WebGL渲染器
    const webglRenderer = new THREE.WebGLRenderer();
    setRenderer(webglRenderer);
    webglRenderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight);
    containerRef.current.appendChild(webglRenderer.domElement);

    // 初始化CSS2D渲染器(用于标记)
    cssRenderer = new CSS2DRenderer();
    cssRenderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight);
    cssRenderer.domElement.style.position = 'absolute';
    cssRenderer.domElement.style.top = '0';
    cssRenderer.domElement.style.pointerEvents = 'none'; // 不干扰全景图交互
    containerRef.current.appendChild(cssRenderer.domElement);

    // 初始化轨道控制器
    controls = new OrbitControls(camera, webglRenderer.domElement);
    controls.enableZoom = true;
    controls.rotateSpeed = -0.25;
    controls.enableDamping = true; // 平滑旋转

    loadImage(0, true);
    setThreeJsData(webglRenderer, scene, camera, controls);

    // 动画循环
    const animate = () => {
      requestAnimationFrame(animate);
      controls.update(); // 更新控制器状态
      webglRenderer.render(scene, camera);
      cssRenderer.render(scene, camera); // 同步渲染CSS标记
    };
    animate();

    // 窗口大小调整处理
    const handleResize = () => {
      const width = containerRef.current.clientWidth;
      const height = containerRef.current.clientHeight;
      camera.aspect = width / height;
      camera.updateProjectionMatrix();
      webglRenderer.setSize(width, height);
      cssRenderer.setSize(width, height);
    };
    window.addEventListener('resize', handleResize);

    // 清理函数
    return () => {
      window.removeEventListener('resize', handleResize);
      containerRef.current.removeChild(webglRenderer.domElement);
      containerRef.current.removeChild(cssRenderer.domElement);
      controls.dispose();
      clearMarkers();
      sphere.geometry.dispose();
      sphere.material.dispose();
    };
  };

  useEffect(initThreeJs, []);

  const Loader = () => {
    return loading ? (
      <div style={{ position: 'absolute', width: "100%", height: "100%", display: "flex", justifyContent: "center", alignItems: "center", color: 'white', fontSize: '20px' }}>
        Loading...
      </div>
    ) : null;
  };

  const markIssue = (type) => {
    if (drawingTool) {
      drawingTool.enableDrawing(type);
      drawingTool.on(EVENTS.GEOMETRY_ADDED, (geometry) => {
        polygons.push(geometry);
      });
    } else {
      drawingTool = new ThreeJsDrawing(scene, camera, [sphere], false);
      drawingTool.enableDrawing(type);
      drawingTool.on(EVENTS.GEOMETRY_ADDED, (geometry) => {
        polygons.push(geometry);
      });
    }
  };

  return (
    <>
      <Loader />
      <div style={{ position: "absolute", display: "flex", width: "100%", height: "40px", alignItems: 'center', justifyContent: 'space-around', background: "#00000082", color: 'white' }}>
        <div onClick={prevImage} style={{ cursor: "pointer" }}>上一张</div>
        <div onClick={nextImage} style={{ cursor: "pointer" }}>下一张</div>
      </div>
      <div style={{ position: "absolute", display: "flex", width: "100%", height: "40px", bottom: 0, alignItems: 'center', justifyContent: 'space-around', background: "#00000082", color: 'white', cursor: "pointer" }}>
        <div onClick={() => markIssue("Polygon")}>多边形</div>
        <div onClick={() => markIssue("Rectangle")}>矩形</div>
        <div onClick={() => markIssue("Hotspot")}>热点</div>
      </div>
      <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />
    </>
  );
};

export default PanoramaViewer;

关键修改点说明

  1. 双渲染器初始化:

    • 新增CSS2DRenderer,将其DOM元素叠加在WebGL渲染器之上,设置pointerEvents: none避免干扰全景图交互
    • 动画循环中同时渲染WebGL场景和CSS2D场景
  2. 标记创建与坐标转换:

    • 通过addMarker函数将UV坐标(对应全景图的像素位置比例)转换为全景球的3D坐标
    • 使用DOM元素作为标记,通过CSS固定大小,确保缩放时视觉大小不变
  3. 标记生命周期管理:

    • 切换全景图时自动清除旧标记,添加新标记
    • 组件卸载时清理标记的DOM元素和Three.js对象,避免内存泄漏
  4. 控制器优化:

    • 启用enableDamping实现平滑旋转
    • 添加窗口大小调整监听,同步更新渲染器和相机参数

内容的提问来源于stack exchange,提问作者Matsagar Abhijit

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.07 12:12:06