基于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;
关键修改点说明
双渲染器初始化:
- 新增
CSS2DRenderer,将其DOM元素叠加在WebGL渲染器之上,设置pointerEvents: none避免干扰全景图交互 - 动画循环中同时渲染WebGL场景和CSS2D场景
- 新增
标记创建与坐标转换:
- 通过
addMarker函数将UV坐标(对应全景图的像素位置比例)转换为全景球的3D坐标 - 使用DOM元素作为标记,通过CSS固定大小,确保缩放时视觉大小不变
- 通过
标记生命周期管理:
- 切换全景图时自动清除旧标记,添加新标记
- 组件卸载时清理标记的DOM元素和Three.js对象,避免内存泄漏
控制器优化:
- 启用
enableDamping实现平滑旋转 - 添加窗口大小调整监听,同步更新渲染器和相机参数
- 启用
内容的提问来源于stack exchange,提问作者Matsagar Abhijit
相关产品推荐
相关产品推荐

