全景看图是一个比较有趣的功能,全景看图让我们有一种身临场景的感觉,给用户一种更直观的体验。实现全景看图通过CSS3D即可实现,但是这里我分享关于Three.js的全景看图,因为Three.js可以实现更丰富的交互。
前言
虽然例子很简单,但也需要您掌握基本的three.js
知识点,例如场景、模型等基础。
感兴趣的可以先访问在线实例看看。
开始
废话不多说,现在就来开始进行代码的实践。
准备工作
首先实现全景看图功能,就得先有全景图,我这里的全景图通过在polyhaven这个网站里面获取的,你可以选择你喜欢的图片进行下载。
设计思路
我们设计全景看图的思路就两个步骤:
- 创建一个球体,将全景图作为纹理添加到球体上;
- 把摄像机放置入球体内部。 通过这两个步骤,就能很简单的实现一个全景预览的功能。
代码实现
球体类实现
ts
import { EventEmitter } from 'events';
import { DataTexture, DoubleSide, Mesh, MeshBasicMaterial, Scene, SphereGeometry } from 'three';
import { Game } from '../index';
export class SphereInstance extends EventEmitter {
game: Game;
scene: Scene;
mesh!: Mesh<SphereGeometry, MeshBasicMaterial>;
geometry: SphereGeometry = new SphereGeometry(2, 32, 32);
constructor() {
super();
this.game = Game.getInstance();
this.scene = this.game.gameScene.scene;
}
init(texture: DataTexture) {
const material = new MeshBasicMaterial({
map: texture,
side: DoubleSide
});
this.mesh = new Mesh(this.geometry, material);
this.scene.add(this.mesh);
return this.mesh;
}
update() { }
}
上面,我创建一个球体类,通过init
方法来添加不同纹理的球体网格。
接着,我们再对球体网格进行初始化:
ts
initSphere() {
this.sphere = new SphereInstance();
this.addScene('scene1', 'acoustical_shell_1k');
this.addScene('scene2', 'rural_asphalt_road_1k');
this.addScene('scene3', 'kloofendal_43d_clear_1k');
this.addScene('scene4', 'resting_place_1k');
this.scene2.position.set(2, 0, -4);
this.scene3.position.set(4, 0, -8);
this.scene4.position.set(-4, 0, -8);
this.activeScene = this.scene1;
}
addScene(sceneName: SceneName, textureName: string) {
const acoustical_shell_1k = this.game.resource.getHDR(textureName) as DataTexture;
this[sceneName] = this.sphere.init(acoustical_shell_1k);
}
这里,我通过定义的addScene
方法创建纹理所对应的所有球体网格,并且初始化不同的位置信息。
当然,你可以可以选择先加载一个纹理,等场景切换时或者其他时候进行动态加载。
完成这些步骤后,你在浏览器中应该会看到类似下面的结果:
也就是我们的纹理贴图,但是目前仅仅只有一张图的功能,不能切换视角,更不能切换场景。
视角切换
视角切换的方案挺多,可以用OrbitControls
来实现,但是我这里希望的效果是相机原地不动看四周,但OrbitControls
这种不能实现这种效果(或者我没有发现用法)。
然后又找了FirstPersonalControls
来测试,发现效果也不是我希望的。
控件实现
没有找到对应的插件,那就没办法只有自己来手动实现自己想要的效果了,现在对控件功能进行分析:
- 相机原地不动,通过滑动鼠标位置进行视角切换;
- 横向滑动时,摄像机Y轴进行旋转;
- 纵向滑动时,摄像机X轴进行旋转;
- 旋转的顺序是先Y轴再进行X轴旋转。
明白以上几点后,我们就可以开始进行控件的代码设计:
ts
import EventEmitter from "events";
import { Euler, PerspectiveCamera, Quaternion, Vector2, Vector3 } from "three";
import { Game } from "..";
export class FirstPersonalControls extends EventEmitter {
game: Game;
camera: PerspectiveCamera;
domElement: HTMLCanvasElement;
mouse: Vector2 = new Vector2(); // 记录点击鼠标的当前位置和上一次的偏移值
offset: Vector2 = new Vector2(); // 记录按住鼠标后的的下一次偏移值
isActive: boolean = false;
currentPosition: Vector3 = new Vector3();
xAxis: Vector3 = new Vector3(1, 0, 0);
yAxis: Vector3 = new Vector3(0, 1, 0);
quaternionX: Quaternion = new Quaternion();
quaternionY: Quaternion = new Quaternion();
constructor(camera: PerspectiveCamera, domElement: HTMLCanvasElement) {
super();
this.camera = camera;
this.camera.rotation.order = 'YXZ';
this.domElement = domElement;
this.game = Game.instance;
this.initPCEvents();
}
initPCEvents() {
this.domElement.addEventListener('mousedown', evt => {
const { offsetX, offsetY } = evt;
this.onStart(offsetX, offsetY);
});
this.domElement.addEventListener('mousemove', evt => {
const { offsetX, offsetY } = evt;
this.offset.x = offsetX;
this.offset.y = offsetY;
});
document.addEventListener('mouseup', () => {
this.isActive = false;
});
}
onStart(offsetX: number, offsetY: number) {
this.mouse.x = offsetX;
this.mouse.y = offsetY;
this.offset.copy(this.mouse);
this.isActive = true;
}
onMove() {
const disX = this.offset.x - this.mouse.x;
const disY = this.offset.y - this.mouse.y;
this.mouse.x = this.offset.x;
this.mouse.y = this.offset.y;
// 两次移动的像素间距
let x = -disX / this.game.width;
let y = -disY / this.game.height;
const speed = 5;
// X轴的旋转速度
let xSpeed = speed * y;
// 现在X轴最大幅度
const maxAngle = Math.PI * 0.5;
// 获取当前的旋转幅度欧拉值
const euler = new Euler().setFromQuaternion(this.camera.quaternion, 'YXZ');
if (
(euler.x + xSpeed > maxAngle) ||
(euler.x + xSpeed < -maxAngle)
) {
xSpeed = 0; // 当超过90度后不再进行X轴旋转
}
this.quaternionX.setFromAxisAngle(this.xAxis, xSpeed); // 进行X轴的四元素赋值
this.quaternionY.setFromAxisAngle(this.yAxis, speed * x); // 进行Y轴的四元素赋值
this.camera.quaternion.premultiply(this.quaternionY).multiply(this.quaternionX);
this.emit('changeRotation', this.camera.rotation);
}
update() {
this.isActive && this.onMove();
}
}
现在来对以上代码进行一个简单的分析:
- 通过构造器传入相机和容器,因为我们需要操作这两个东西;
- 监听鼠标的按下/滑动/松开事件,来对视角切换进行操作;
- 当鼠标移动时,计算上一次到这一次的移动距离并保存;
- 在
update
方法渲染每一次的相机状态:- 计算上一次和这次的滑动距离,以此作为切换的速度;
- 将计算出来的速度,作为旋转弧度对相机的四元数进行重新赋值;
- 通过premultiply方法来实现先进行Y轴旋转,再通过multiply进行X轴的旋转。
现在,再对该控件进行一个引用:
ts
this.controls = new FirstPersonalControls(this.game.gameCamera.camera, this.game.gameRenderer.renderer.domElement);
update() {
is.controls.update();
}
完成之后,我们再来看看效果,如下图所示:
标签实现
现在全景预览我们已经基本实现了,接下来就是给场景中添加一些标签来作交互。
标签的添加,方式也不是唯一的,这里通过Sprite
配合CanvasTexture
来实现:
- 首先简单的定义一个标签的信息文件:
ts
export type TagInfo = {
name: string;
title: string;
position: [number, number, number];
target: string;
}
export const scene1Tags: TagInfo[] = [
{ name: 'out', title: '出去', position: [1, 0, -1], target: 'scene2' },
];
export const scene2Tags: TagInfo[] = [
{ name: 'road1', title: '大路', position: [1, -0.1, 0], target: 'scene3' },
{ name: 'road2', title: '小路', position: [-1, 0, -0.5], target: 'scene4' },
{ name: 'road3', title: '起始点', position: [0, 0, 1], target: 'scene1' },
];
export const scene3Tags: TagInfo[] = [
{ name: 'back', title: '去马路', position: [0, -0.1, 1], target: 'scene2' },
];
export const scene4Tags: TagInfo[] = [
{ name: 'back', title: '去马路', position: [0, 0, 1], target: 'scene2' },
];
- 定义一个标签
Tag
类,用来给场景添加标签:
ts
import { EventEmitter } from 'events';
import { CanvasTexture, Mesh, Scene, Sprite, SpriteMaterial, Vector3 } from 'three';
import { Game } from '../index';
import { TagInfo } from './ts/tags';
export class Tag extends EventEmitter {
game: Game;
scene: Scene;
sprite!: Sprite;
constructor() {
super();
this.game = Game.getInstance();
this.scene = this.game.gameScene.scene;
}
add(mesh: Mesh, tagInfo: TagInfo) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx.font = `16px 微软雅黑`;
const textInfo = ctx.measureText(tagInfo.title);
// 设置画布尺寸,确保宽度和高度足够容纳文字
const width = textInfo.width;
const height = textInfo.actualBoundingBoxAscent + textInfo.actualBoundingBoxDescent;
canvas.width = width + 10;
canvas.height = height + 10;
ctx.beginPath();
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.font = `16px 微软雅黑`;
ctx.fillStyle = '#fff';
ctx.textBaseline = 'top';
ctx.fillText(tagInfo.title, 5, 5);
const texture = new CanvasTexture(canvas);
const material = new SpriteMaterial({
map: texture
});
this.sprite = new Sprite(material);
this.sprite.scale.set(0.1, 0.08, 1);
this.sprite.position.copy(new Vector3(...tagInfo.position));
this.sprite.name = tagInfo.name;
this.sprite.userData.target = tagInfo.target;
mesh.add(this.sprite);
}
update() { }
}
- 修改之前初始化球体网格方法,如下:
ts
initSphere() {
this.sphere = new SphereInstance();
this.addScene('scene1', scene1Tags, 'acoustical_shell_1k');
this.addScene('scene2', scene2Tags, 'rural_asphalt_road_1k');
this.addScene('scene3', scene3Tags, 'kloofendal_43d_clear_1k');
this.addScene('scene4', scene4Tags, 'resting_place_1k');
this.scene2.position.set(2, 0, -4);
this.scene3.position.set(4, 0, -8);
this.scene4.position.set(-4, 0, -8);
this.activeScene = this.scene1;
}
addScene(sceneName: SceneName, tags: TagInfo[], textureName: string) {
const acoustical_shell_1k = this.game.resource.getHDR(textureName) as DataTexture;
this[sceneName] = this.sphere.init(acoustical_shell_1k);
tags.forEach(tagInfo => {
this.tag.add(this[sceneName], tagInfo);
});
}
现在再进入页面,发现标签已经成功添加到球体网格中了,效果如下图所示:
场景切换
有了标签之后,我们就可以通过点击标签时进行场景的切换,设计思路如下:
- 通过
Raycaster
光线投射检测是否点击了标签; - 通过标签对应的信息,把摄像机移动到下一个场景。
思路明确后,进行如下的代码编写:
ts
initEvent() {
document.addEventListener('mousedown', evt => {
const { offsetX, offsetY } = evt;
this.mouse.x = offsetX / this.game.width * 2 - 1;
this.mouse.y = -offsetY / this.game.height * 2 + 1;
this.handleRaycaster();
});
}
handleRaycaster() {
const camera = this.game.gameCamera.camera;
this.raycater.setFromCamera(this.mouse, camera);
const tags = this.activeScene.children.filter(item => item.type === 'Sprite');
const result = this.raycater.intersectObjects(tags);
if (result.length) {
const object = result[0].object as Sprite;
const { x, y, z } = camera.position.clone().add(object.position);
const radian = Math.atan2(
camera.position.x - x,
camera.position.z - z
);
const targetScene = this[object.userData.target as SceneName];
camera.lookAt(targetScene.position);
gsap.to(object.material, {
opacity: 0, duration: 1, onComplete: () => {
object.material.opacity = 1;
}
});
gsap.to(camera.rotation, { y: radian, duration: 1 });
gsap.to(camera.position, {
x, y, z, duration: 1,
onComplete: () => {
const scene = targetScene;
camera.position.copy(scene.position);
this.activeScene = scene;
}
});
}
}
现在再来看看实现的效果,如下图所示:
结束
只是全景看图的预览功能实现还是比较简单的,只是其他的一些交互可能会麻烦一点,方案比较多,看各人喜欢怎么来实现。