我们经常会在一些景区、展厅、酒店民宿的宣传页面上,或者卖房看房网站,都能频繁的看到各种3D全景预览的网页效果,这样就可以如临其境般的看到所在场景的360度范围内的各种事物;那么,这样的效果是如何来实现的呢,本文我们就来探讨一下其中所有的技术实现细节,本文干货满满,记得点赞+收藏。
环境搭建
阅读本文需要一定的Threejs基础。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
全景素材
  首先什么是全景图呢?全景图是一种实景360°全方位的平面图像,需要用特殊的工具来查看才能达到360°环绕的效果,比如用我们的Threejs。

  比如上面的这张照片,就是一张全景照片,将整个房间前后左右上下都拍摄进来,如果用肉眼看的话,整个房间弯弯曲曲的,没有什么特别的地方;那么,这样的照片是如何来拍摄的呢?
  打开你的手机相机,点击更多,里面一般会有一个全景相机的选项,然后站起身来,转一圈,你就能得到一张完整的全景图片了。

  当然开个玩笑,这样拍摄,除非你的手稳定得如同工厂里的机械臂一样,并且能够保持身体的平衡;一般拍摄出来的相片只能粗略的看看即可;专业的拍摄设备需要用到单反+三脚架+云台,拍摄后还需要专业的后期处理和拼接,比较麻烦;当然我们也可以从网上一些资源下载:
全景图下载
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
  近些年,随着个人vlog的兴起,各种消费级的拍摄设备和工具也迅速发展,全景相机、运动相机等产品不断更新迭代,已经能够带来很多非常好玩的拍摄体验了;比如这款Insta360 ONE X2在拍摄时就可以选择360°全景照片模式,轻轻一按,就可以得到一张满意的全景图了。
Threejs封装
  要实现全景图,首先我们来对Threejs的场景布局进行一个简单的封装,引入Threejs中所需要用到的组件:
|  | import {Scene,
 WebGLRenderer,
 PerspectiveCamera,
 Vector3,
 PCFSoftShadowMap,
 Color,
 Clock,
 AxesHelper,
 LinearToneMapping,
 } from "three";
 import Stats from "stats.js";
 
 import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
 
 | 
  将一些用到的默认配置定义到config中,这里将div的id设置为webgl-output,因此我们只需要在页面添加一个div并将id设置即可。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 
 | const DEFAULT_CONFIG = {
 domId: "webgl-output",
 initPosition: new Vector3(20, 10, 10),
 fov: 60,
 near: 1,
 far: 10000,
 rendererOptions: {},
 
 clearColor: new Color(0x94959a),
 
 showStats: false,
 
 helper: 0,
 
 exposure: 1,
 
 enableDamping: true,
 };
 
 | 
  我们定义一个Stage父类,将业务逻辑定义到子类,继承该类即可:
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 
 | export default class Stage {constructor(config) {
 const dConfig = DEFAULT_CONFIG;
 if (isObject(config)) {
 Object.assign(dConfig, config);
 }
 const {
 fov,
 near,
 far,
 initPosition,
 clearColor,
 domId,
 enableDamping,
 } = dConfig;
 this.scene = new Scene();
 
 this.camera = new PerspectiveCamera(fov, window.innerWidth / window.innerHeight, near, far);
 this.camera.position.copy(initPosition);
 this.camera.lookAt(this.scene.position);
 
 
 this.renderer = new WebGLRenderer({ antialias: true });
 
 document.getElementById(domId)?.append(this.renderer.domElement);
 
 this.orbitControls = new OrbitControls(this.camera, this.renderer.domElement);
 this.orbitControls.enableDamping = enableDamping;
 }
 render() {
 this.orbitControls.update();
 
 this.beforeRender && this.beforeRender();
 
 requestAnimationFrame(this.render.bind(this));
 this.renderer.render(this.scene, this.camera);
 }
 }
 
 | 
  这里render函数每次都会周期性的执行,一些周期性的渲染都会放到这里进行处理;如果子类也需要渲染,避免命名重复,我们将子类的渲染定义到beforeRender函数中。
  渲染器相当于是一个画布,我们对其进行更精细的设置,开启阴影、设置背景颜色等。
|  | this.renderer.shadowMap.enabled = true;
 
 this.renderer.shadowMap.type = PCFSoftShadowMap;
 
 this.renderer.setClearColor(clearColor);
 
 this.renderer.setSize(window.innerWidth, window.innerHeight);
 
 this.renderer.toneMapping = LinearToneMapping;
 
 this.renderer.toneMappingExposure = exposure;
 
 this.renderer.physicallyCorrectLights = true;
 
 | 
  在使用时Stage时,由于render函数已经被占用了,我们重新设置一个beforeRender函数,在每次render调用时调用。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 
 | import Stage from "./stage"import { onMounted } from "vue"
 class Panorama extends Stage {
 constructor() {
 super({
 initPosition: new Vector3(0, 0, 1),
 clearColor: new Color(0x000000),
 });
 
 
 
 this.render();
 }
 beforeRender() {
 
 }
 }
 
 onMounted(() => {
 new Panorama();
 });
 
 | 
  子类必须放到dom元素渲染完成后实例化。
场景背景
  将环境贴图设置到场景scene的背景也能实现全景图的功能,这种方式实现起来也是最简单的;将相机放在整个场景的中心,当相机移动时,背景图片也随之移动。
  构建一个CubeTextureLoader加载器实例,将六个面的贴图通过加载器载入,顺序为[right,left,up,down,front,back],然后直接设置到背景即可。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 
 | const urls = ["/images/panorama/px.jpg",
 "/images/panorama/nx.jpg",
 "/images/panorama/py.jpg",
 "/images/panorama/ny.jpg",
 "/images/panorama/pz.jpg",
 "/images/panorama/nz.jpg",
 ];
 class Panorama extends Stage {
 constructor() {
 super({
 initPosition: new Vector3(0, 0, 1),
 clearColor: new Color(0x000000),
 });
 const cubeLoader = new CubeTextureLoader();
 const map = cubeLoader.load(urls);
 this.scene.background = map;
 this.scene.environment = map;
 this.render();
 }
 }
 
 | 
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

查看全景效果
  上面的顺序大家可能记不住,我们仔细查看px,nx的顺序会发现,是按照x、y、z轴的顺序,p表示positive正面,n表示negative反面,因此px也就是右侧了,nx就是左侧。
  这种方式相较于下面两种盒子的好处,就是实现起来简单,而且鼠标缩放不会暴露盒子的原型,不会出圈;但是缺点也明显,不能缩放查看背景的细节之处,也不能控制视角的距离,
全景球
  全景球则是首先创建一个球形,直接将一张全景图直接作为素材贴上。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 
 | class Panorama extends Stage {constructor() {
 super({
 fov: 75,
 near: 1,
 far: 1100,
 initPosition: new Vector3(0, 0, 1),
 clearColor: new Color(0x000000),
 });
 
 const sphereGeometry = new SphereGeometry(500, 50, 50);
 sphereGeometry.scale(-1, 1, 1);
 const sphereMaterial = new MeshBasicMaterial({
 map: new TextureLoader().load("/images/panorama/panorama.jpg"),
 });
 
 const sphere = new Mesh(sphereGeometry, sphereMaterial);
 this.scene.add(sphere);
 
 this.render();
 }
 }
 
 | 
  这里我们将球的半径设置为500,如果太小了,缩放时也会出圈;这里的sphereGeometry.scale(-1, 1, 1)其实相当于sphereGeometry.scale.x = -1,将贴图沿着x轴进行翻转。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
查看全景球效果
  全景球的方式只能用全景图,一般全景图的大小都达到几兆甚至几十兆,因此实际使用时需要对图片进行压缩;或者通过先加载一张模糊图再加载高清图的方式,加载两张图片来避免用户长时间等待,因此这种方式对网络有一定的要求。
天空盒
  天空盒的思路和上面全景球相同,只不过将SphereGeometry球形换成了BoxGeometry正方形;贴图也由一张全景图,变成了盒子六个面的贴图;六个面的贴图相较于一张全景图,可以利用浏览器的并行加载能力,提高加载速度。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 
 | const urls = ["/images/panorama/cubemap/px.png",
 "/images/panorama/cubemap/nx.png",
 "/images/panorama/cubemap/py.png",
 "/images/panorama/cubemap/ny.png",
 "/images/panorama/cubemap/pz.png",
 "/images/panorama/cubemap/nz.png",
 ];
 
 const materialArr = [];
 const textureLoader = new TextureLoader();
 
 urls.map((item) => {
 const material = new MeshBasicMaterial({
 map: textureLoader.load(item),
 });
 materialArr.push(material);
 });
 var box = new BoxGeometry(500, 500, 500);
 var mesh = new Mesh(box, materialArr);
 mesh.geometry.scale(-1, 1, 1);
 
 this.scene.add(mesh);
 
 | 
  整体的思路和上面两种方式有些类似,只不过Mesh传参的材质由原来的一个材质变成了材质数组;然后还是将mesh对象沿X轴进行翻转。
查看天空盒效果
导航标签
  环境搭建好之后,我们会看到有一些网站上,物体周围有一些可点击的悬浮标签,或者鼠标悬浮后有一些提示的文案,这种导航标签是如何来实现的呢?这里就需要介绍一下Threejs的精灵对象Sprite,它是一个永远面向相机的平面,我们通常用它来显示一些标签;我们看下他的构造函数:
|  | Sprite(material: Material)
 | 
  这里的material是SpriteMaterial的一个实例,也就是将材质传进来,我们来看下具体的用法
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
添加锚点
  我们首先定义好锚点的数据,在哪些位置需要标注的,这一步也可以利用dat.gui不断调整XYZ轴的位置:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 
 | const posList = [{
 x: -5,
 y: -4,
 z: -14,
 content: "床",
 },
 {
 x: -20,
 y: -5,
 z: -9,
 content: "沙发",
 },
 {
 x: 10,
 y: -6,
 z: 12,
 content: "冰箱",
 },
 ];
 
 | 
  从上面的构造函数看出Sprite的构建不需要几何图形,如果是没有任何文案的简单锚点,我们可以将一张png图片设置为精灵材质SpriteMaterial的贴图,然后循环构建多个Sprite添加到场景中去。
|  | const spriteMaterial = new SpriteMaterial({ map: textureLoader.load("/images/icon/position.png")
 });
 posList.map((item) => {
 const { x, y, z } = item;
 const sprite = new Sprite(spriteMaterial);
 sprite.position.set(x, y, z);
 this.scene.add(sprite);
 });
 
 | 
  这样就能看到多个锚点固定漂浮在某个位置了。

锚点文字
  我们还会看到一些锚点旁边有文案标识,事情就开始变得复杂起来了;由于Threejs中不能添加div,因此我们有两种方式可以来实现这样的效果;首先是使用canvas绘制文字,并作为纹理设置为SpriteMaterial的map属性。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 
 | const createSpriteLabel = (txt, x, y, z) => {const canvas = document.createElement("canvas");
 canvas.setAttribute("width", "286px");
 canvas.setAttribute("height", "112px");
 const ctx = canvas.getContext("2d");
 ctx.fillStyle = "#FF0000";
 ctx.lineWidth = 4;
 
 const textMetrics = ctx.measureText(txt);
 
 ctx.fillText(txt, (canvas.width - textMetrics.width) / 2, 55);
 
 const texture = new Texture(canvas);
 texture.needsUpdate = true;
 
 const spriteMaterial = new SpriteMaterial({
 map: texture,
 });
 const sp = new Sprite(spriteMaterial);
 sp.scale.set(4, 2, 1);
 sp.position.set(x, y, z);
 return sp;
 };
 
 | 
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
  我们创建一个canvas画布,设置画笔的颜色粗细,然后绘制文案,将canvas传入Texture,然后作为map属性构建了SpriteMaterial。
canvas的方式一般用来画图形简单、内容格式较为固定的Sprite标签。
  另一种方式就是使用CSS2DRenderer(CSS 2D渲染器),这个组件听着非常高深,其实来说很简单,就是在页面最外层插入一个div,然后将我们所需要的渲染dom节点插入到这个div中,渲染我们熟悉的HTML元素;当相机旋转时,实时更新每个dom节点的位置即可;同时场景放大缩小时,不会缩放标签的大小,可以触发DOM点击事件;我们如果打开开发者工具查看,可以看到body最下面有一个div,嵌套了多个子标签。

|  | import { CSS2DRenderer,
 CSS2DObject
 } from "three/examples/jsm/renderers/CSS2DRenderer";
 
 | 
  CSS2DRenderer是Threejs提供的扩展库,我们需要额外从渲染器的包中引入CSS2DRenderer和它的模型对象CSS2DObject;由于要新建一个渲染器,我们对封装的Stage类进行改造,
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 
 | export default class Stage {constructor(config) {
 
 
 
 const labelRenderer = new CSS2DRenderer();
 labelRenderer.setSize(window.innerWidth, window.innerHeight);
 labelRenderer.domElement.style.position = "absolute";
 labelRenderer.domElement.style.top = 0;
 labelRenderer.domElement.style.left = 0;
 this.labelRenderer = labelRenderer;
 
 document.body.appendChild(labelRenderer.domElement);
 
 
 
 this.orbitControls = new OrbitControls(this.camera, this.labelRenderer.domElement);
 }
 render() {
 this.renderer.render(this.scene, this.camera);
 
 this.labelRenderer.render(this.scene, this.camera);
 }
 }
 
 | 
  CSS2DRenderer渲染器和WebGLRenderer有些类似,也都有setSize和render方法,我们需要把实例化的domElement添加到body中来;CSS2DRenderer也需要实时更新,因此我们也需要在render函数中对其进行渲染
  我们还是和上面的Sprite锚点一样,循环创建div标签,添加到场景scene中;
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 
 | function createLableObj(text, x, y, z) {let laberDiv = document.createElement("div");
 laberDiv.className = "laber_name";
 laberDiv.innerHTML = text;
 laberDiv.style.color = "#F4EA2A";
 laberDiv.style.fontSize = "30px";
 laberDiv.style.background = "url(/images/icon/position.png) no-repeat";
 laberDiv.style.cursor = "pointer";
 
 let pointLabel = new CSS2DObject(laberDiv);
 pointLabel.position.set(x, y, z);
 return pointLabel;
 }
 
 posList.map((item) => {
 const { x, y, z, content } = item;
 const label = createLableObj(content, x, y, z);
 
 this.scene.add(label);
 });
 
 | 
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
查看标签效果
  我们发现,上面创建完div后,通过div标签创建了CSS2DObject的实例对象,然后设置XYZ轴坐标;这个对象的作用就是将Threejs中的坐标与屏幕的坐标进行转换,进行实时的渲染。

锚点触发事件
  锚点加上后,我们需要对其进行触发事件的绑定,如果使用CSS2DRenderer渲染器的方式,由于是dom元素,我们直接给元素绑定原生事件即可:
|  | const laberDiv = document.createElement("div");
 
 laberDiv.addEventListener("click", (ev) => {
 console.log("ev", ev);
 });
 
 laberDiv.addEventListener("mouseover", (ev) => {
 console.log("ev", ev);
 });
 const pointLabel = new CSS2DObject(laberDiv);
 
 | 
  我们可以进行后续的业务逻辑,比如场景切换了,或者弹框展示详细信息等;而使用Sprite,由于所有的物体都在Threejs的场景中,我们不能简单的利用绑定点击事件来触发,Sprite也没有addEventListener事件。
  因此用到一个光线投射类:Raycaster,这个类用于进行鼠标拾取,也就是在三维空间中计算出鼠标移动或点击时,划过了什么物体。
  它的原理是从鼠标处发射一条射线,穿过场景中的物体,通过计算,找出与射线相交的物体,因此这种方法也叫射线追踪法;我们来看下它的一个用法,
|  | class Panorama extends Stage {constructor() {
 this.raycaster = new Raycaster();
 this.mouse = new Vector2();
 this.list = [];
 posList.map((item) => {
 this.list.push(sprite);
 })
 }
 }
 
 | 
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
  我们实例化Raycaster,新建一个二维的点mouse,这个点下面会用来存储鼠标移动的二维坐标;然后将Sprite放到数组list中,用于Raycaster检测照射到了场景中的哪些物体;当然我们下面也能照射整个场景scene.children下的所有物体,但是有一些物体不是我们想要的,需要额外的判断,因此可以将需要照射物体存到数组中来。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 
 | class Panorama extends Stage {constructor() {
 this._pointerMove = this.pointerMove.bind(this);
 
 
 this.renderer.domElement.addEventListener("mousemove", this._pointerMove);
 }
 pointerMove(event) {
 
 this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
 this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
 
 
 
 this.raycaster.setFromCamera(this.mouse, this.camera);
 
 const intersects = this.raycaster.intersectObjects(this.list);
 if (intersects.length) {
 
 console.log("intersects", intersects);
 }
 }
 beforeDestroy() {
 this.renderer.domElement.removeEventListener("mousemove", this._pointerMove);
 }
 }
 
 | 
  我们详细看下这里的逻辑,首先通过event来计算mouse的XY轴坐标,由于setFromCamera需要归一化的坐标值,因此我们计算时将其处理为[-1, 1]范围内的值。setFromCamera方法通过摄像机和鼠标位置更新射线,接收mouse和camera对象;更新射线后就可以使用intersectObject函数来拾取对象了,接收的intersects数组就是拾取到的Mesh合集。
  我们发现Sprite和CSS2DRenderer两种方式各有利弊,Sprite虽然添加元素方便,但是canvas绘制图形比较麻烦,同时触发事件也繁琐;而CSS2DRenderer添加元素较为复杂,需要用到一系列原生属性,但是触发事件方便,具体使用哪种方式,还需要结合业务场景,选择合适的方式。
场景动画
  我们有时候会看到这样的俯视效果的场景动画,那么这种效果是如何来实现的呢?

  首先这种效果就需要用到全景球的SphereGeometry球形,然后将摄像机的位置放到球体的最顶部。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 
 | class Panorama extends Stage {constructor() {
 super({
 
 fov: 100,
 near: 1,
 far: 10000,
 
 initPosition: new Vector3(0, 500, 0),
 clearColor: new Color(0x000000),
 });
 
 
 const sphereGeometry = new SphereGeometry(500, 50, 50);
 sphereGeometry.scale.x = -1;
 
 const sphereMaterial = new MeshBasicMaterial({
 map: new TextureLoader().load("/images/panorama/simons_town_harbour.jpg"),
 });
 
 const sphere = new Mesh(sphereGeometry, sphereMaterial);
 this.sphere = sphere;
 
 this.scene.add(sphere);
 }
 }
 
 | 
  我们这里将initPosition,也就是摄像机的初始位置,放到了Y轴500的位置,由于球体的半径也是500,因此就位于球体内部的最顶上,接下来我们只需要将摄像机的位置缓慢下移就可以。
  这里引入一个补间动画库tween.js,让我们可以用平滑的方式更改对象的属性,只需要告诉它初始值、最终值以及所需要花费的时间,在这段时间里,tween.js会帮我们自动计算出每个时间点,应该设置为什么样的值。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 
 | import Tween from "@tweenjs/tween.js";class Panorama extends Stage {
 constructor() {
 setTimeout(() => {
 this.animateCamera();
 }, 1.5 * 1000);
 }
 animateCamera() {
 
 new Tween.Tween({
 y: 500,
 })
 
 .to({
 y: 0,
 }, 4000)
 .onUpdate((pos) => {
 
 this.camera.position.y = pos.y;
 this.camera.updateProjectionMatrix();
 })
 .start();
 }
 beforeRender() {
 Tween.update();
 }
 }
 
 | 
  它的用法也很简单,这里通过链式调用,创建一个Tween对象,设置y的结束位置和动画时间4000毫秒;在onUpdate函数中,设置每次更新后的y实时数值,最后调用start函数激活tween。需要注意的是,还要在render函数中调用Tween.update更新。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 
 | new Tween.Tween({y: 500,
 fov: 100,
 z: 0,
 })
 .to(
 {
 y: 0,
 fov: 70,
 z: -200,
 },
 4000,
 )
 .onUpdate((pos) => {
 this.camera.position.y = pos.y;
 this.camera.updateProjectionMatrix();
 this.camera.fov = pos.fov;
 this.camera.lookAt(new Vector3(0, 0, pos.z));
 
 this.sphere.rotation.y += 0.006;
 })
 .start();
 
 | 
  当然这样的镜头最后会比较生硬,我们还可以调用摄像头的lookAt,当镜头下降时,逐渐看向远方;初始化也设置一个大的广角fov为100,当镜头下降时,缩小fov的值,这样效果过渡的更加自然。
查看场景动画效果
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里