3

一文掌握你所需Threejs3

 1 year ago
source link: https://xieyufei.com/2023/04/28/Threejs-Panorama.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

一文掌握你所需Threejs3D全景预览 - 谢小飞的博客

一文掌握你所需Threejs_

2023年4月28日 下午

4.6k 字

59 分钟

  我们经常会在一些景区、展厅、酒店民宿的宣传页面上,或者卖房看房网站,都能频繁的看到各种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设置即可。

// 默认的配置
const DEFAULT_CONFIG = {
domId: "webgl-output",
initPosition: new Vector3(20, 10, 10),
fov: 60,
near: 1,
far: 10000,
rendererOptions: {},
// 背景颜色
clearColor: new Color(0x94959a),
// 是否显示Stats
showStats: false,
// AxesHelper
helper: 0,
// 曝光值
exposure: 1,
// 启用阻尼(惯性)
enableDamping: true,
};

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们定义一个Stage父类,将业务逻辑定义到子类,继承该类即可:

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;
// 并将阴影贴图类型设置为THREE.PCFSoftShadowMap
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调用时调用。

import Stage from "./stage"
import { onMounted } from "vue"
class Panorama extends Stage {
constructor() {
super({
initPosition: new Vector3(0, 0, 1),
clearColor: new Color(0x000000),
});
// 在render之前可以向场景中添加一些其他的元素

// 子类中必须在constructor构建函数的最后调用
this.render();
}
beforeRender() {
// 一些周期性渲染的数据放到这里处理
}
}

onMounted(() => {
new Panorama();
});

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  子类必须放到dom元素渲染完成后实例化。

  将环境贴图设置到场景scene的背景也能实现全景图的功能,这种方式实现起来也是最简单的;将相机放在整个场景的中心,当相机移动时,背景图片也随之移动。

  构建一个CubeTextureLoader加载器实例,将六个面的贴图通过加载器载入,顺序为[right,left,up,down,front,back],然后直接设置到背景即可。

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就是左侧。

  这种方式相较于下面两种盒子的好处,就是实现起来简单,而且鼠标缩放不会暴露盒子的原型,不会出圈;但是缺点也明显,不能缩放查看背景的细节之处,也不能控制视角的距离,

  全景球则是首先创建一个球形,直接将一张全景图直接作为素材贴上。

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正方形;贴图也由一张全景图,变成了盒子六个面的贴图;六个面的贴图相较于一张全景图,可以利用浏览器的并行加载能力,提高加载速度。

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轴的位置:

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属性。

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,嵌套了多个子标签。

CSS2DRenderer渲染

CSS2DRenderer渲染

import { 
CSS2DRenderer,
CSS2DObject
} from "three/examples/jsm/renderers/CSS2DRenderer";

  CSS2DRenderer是Threejs提供的扩展库,我们需要额外从渲染器的包中引入CSS2DRenderer和它的模型对象CSS2DObject;由于要新建一个渲染器,我们对封装的Stage类进行改造,

export default class Stage {
constructor(config) {

// ...其他代码
//新建CSS2DRenderer
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);


// 将轨道控制器的渲染器从renderer改成labelRenderer
this.orbitControls = new OrbitControls(this.camera, this.labelRenderer.domElement);
}
render() {
this.renderer.render(this.scene, this.camera);
// 这里要添加CSS2DRenderer的渲染
this.labelRenderer.render(this.scene, this.camera);
}
}

  CSS2DRenderer渲染器和WebGLRenderer有些类似,也都有setSize和render方法,我们需要把实例化的domElement添加到body中来;CSS2DRenderer也需要实时更新,因此我们也需要在render函数中对其进行渲染

  我们还是和上面的Sprite锚点一样,循环创建div标签,添加到场景scene中;

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

function createLableObj(text, x, y, z) {
let laberDiv = document.createElement("div"); //创建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元素,我们直接给元素绑定原生事件即可:

// 创建div容器
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下的所有物体,但是有一些物体不是我们想要的,需要额外的判断,因此可以将需要照射物体存到数组中来。

class Panorama extends Stage {
constructor() {
this._pointerMove = this.pointerMove.bind(this);

// 鼠标移动绑定事件
this.renderer.domElement.addEventListener("mousemove", this._pointerMove);
}
pointerMove(event) {
// 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
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球形,然后将摄像机的位置放到球体的最顶部。

class Panorama extends Stage {
constructor() {
super({
// 视角
fov: 100,
near: 1,
far: 10000,
// 初始位置
initPosition: new Vector3(0, 500, 0),
clearColor: new Color(0x000000),
});

// 球体半径为500
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会帮我们自动计算出每个时间点,应该设置为什么样的值。

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更新。

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的值,这样效果过渡的更加自然。

查看场景动画效果


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK