在现代看房软件中,例如贝壳看房、VR 展馆等,VR 技术通过沉浸式的全景图展示,增强了用户体验,让视觉效果更加震撼。而这些效果的实现,其实并不复杂,甚至性能要求也相对较低。本文将通过 Three.js 实现一个简单的 VR 看房场景,包含全景图切换、相机移动、渐隐效果等功能。
实现原理
核心思路是通过创建一个球体的内部贴上全景图(简称全景球),将相机置于球体中心,通过鼠标的旋转移动来改变相机的视角。当用户点击轨迹点时,相机会平滑地切换至下一个全景球的位置,同时隐藏当前球体并显示新的球体。
需要解决的问题
1. 相机的初始位置和姿态如何确定?
相机应位于初始全景球的中心,且高度可根据需要调整,避免相机视角穿透全景球,从而看到不必要的图像缺陷。
2. 如何根据点击轨迹找到下一个全景球?
轨迹点与全景球命名规则应一致。例如,轨迹点命名为 VR_A
,其对应的全景球命名为 VR_AA
。通过点击轨迹点后提取名称,再在全景球数组中找到匹配的全景球进行显示即可。
3. 如何实现相机的平滑移动?
使用 tween.js
库生成从相机当前位置到目标位置的平滑过渡,并应用到相机上。
4. 如何实现全景球切换时的渐隐效果?
相机在移动过程中可以计算当前与目标全景球之间的距离比率 ratio
。例如,ratio=1
表示未开始移动,ratio=0
表示移动完成。根据 ratio
调整当前全景球和目标全景球的透明度,达到渐隐渐显的视觉效果。
标签位置的问题及解决方案
在 VR 看房或三维场景中,常常需要标注标签与物体进行交互,但由于全景球是一个 2D 图像贴图,点击的准确性较差。主要有两种解决方案:
- 方案一:在全景球外部加载一个隐藏的 3D 模型,点击时拾取模型上的点作为标签位置。这样虽然会有一些误差,但只需为整个 VR 场景设计一套标签。
- 方案二:为每个全景球设计独立的标签,这样标签位置绝对准确,但代价是每个全景球都需要设置一套标签,显得笨重。
自定义 VR 控制器
Three.js 没有类似 Babylon.js 中的 FreeCamera,下面的代码展示了一个简单的 VR 控制器,可以通过鼠标拖动来旋转相机视角,滚轮缩放相机的视场角 (FOV)。
import * as THREE from "three";
export default class VRcontrol extends THREE.EventDispatcher {
autoRotate = false;
onPointerDownMouseX = 0;
onPointerDownMouseY = 0;
lon = 0;
onPointerDownLon = 0;
lat = 0;
onPointerDownLat = 0;
phi = 0;
theta = 0;
keyDown = false;
camera: THREE.PerspectiveCamera;
domElement: HTMLElement;
constructor(camera: THREE.PerspectiveCamera, domElement: HTMLElement) {
super();
this.camera = camera;
this.domElement = domElement;
this.domElement.addEventListener(
"pointerdown",
this.onPointerDown.bind(this)
);
this.domElement.addEventListener(
"pointermove",
this.onPointerMove.bind(this)
);
window.addEventListener("pointerup", this.onPointerUp.bind(this));
this.domElement.addEventListener(
"wheel",
this.onDocumentMouseWheel.bind(this)
);
}
onPointerDown(event: PointerEvent) {
if (event.isPrimary === false) return;
this.keyDown = true;
this.onPointerDownMouseX = event.clientX;
this.onPointerDownMouseY = event.clientY;
this.onPointerDownLon = this.lon;
this.onPointerDownLat = this.lat;
}
onPointerMove(event: PointerEvent) {
if (event.isPrimary === false || !this.keyDown) return;
this.lon =
(this.onPointerDownMouseX - event.clientX) * 0.1 + this.onPointerDownLon;
this.lat =
(event.clientY - this.onPointerDownMouseY) * 0.1 + this.onPointerDownLat;
// 分发事件
this.dispatchEvent({
type: "camera",
camera: this.camera,
});
}
onPointerUp(event: PointerEvent) {
if (event.isPrimary === false) return;
this.keyDown = false;
this.domElement.removeEventListener("pointermove", this.onPointerMove);
}
onDocumentMouseWheel(event: WheelEvent) {
const fov = this.camera.fov + event.deltaY * 0.05;
this.camera.fov = THREE.MathUtils.clamp(fov, 10, 75);
this.camera.updateProjectionMatrix();
}
update() {
if (this.autoRotate) {
this.lon += 0.1;
}
this.lat = Math.max(-85, Math.min(85, this.lat));
this.phi = THREE.MathUtils.degToRad(90 - this.lat);
this.theta = THREE.MathUtils.degToRad(this.lon);
const x = 500 * Math.sin(this.phi) * Math.cos(this.theta);
const y = 500 * Math.cos(this.phi);
const z = 500 * Math.sin(this.phi) * Math.sin(this.theta);
this.camera.lookAt(x, y, z);
}
remove() {
this.domElement.removeEventListener(
"pointerdown",
this.onPointerDown.bind(this)
);
this.domElement.removeEventListener(
"pointermove",
this.onPointerMove.bind(this)
);
window.removeEventListener("pointerup", this.onPointerUp.bind(this));
this.domElement.removeEventListener(
"wheel",
this.onDocumentMouseWheel.bind(this)
);
}
}
接下来给出场景的核心代码,里面写的是一些demo,没有经过封装,里面包含了两个渲染器,一个是webGL,另外一个是CSS2DRenderer,主要用来渲染标签,里面也包含了一些创建标签的方法
import * as THREE from "three";
import FirstPersonCameraControl from "../controls/Mycontrol";
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer";
import {
createrPoint,
createVRItem,
createVideoLabel,
createIconLabel,
} from "../tools";
import TWEEN from "@tweenjs/tween.js";
export default class VR extends THREE.EventDispatcher {
radii: number;
parent: THREE.Group;
pointObj: THREE.Group;
currentVRItem: THREE.Mesh | any;
preVRItem: THREE.Mesh | any;
container: string | HTMLElement | any;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
labelRenderer: CSS2DRenderer;
initLocaltion: any;
raycaster: THREE.Raycaster;
mouse: THREE.Vector2;
control: any;
getMousePosition: (event: any) => void;
onMouseDown: (event: any) => void;
onResize: () => void;
marking: boolean;
labelContent: string;
videoUrl: string;
imgUrl: string;
labelType: number;
effective: number;
textures: THREE.Texture[] = [];
clock = new THREE.Clock();
Pupop: THREE.Object3D<THREE.Event> | undefined;
constructor(option: any) {
super();
this.marking = false;
this.videoUrl = "";
this.labelContent = "";
this.imgUrl = "";
this.labelType = 0;
this.effective = 1;
this.radii = 4; // 全景球半径
this.pointObj = new THREE.Group();
this.parent = new THREE.Group();
this.container =
option.container instanceof HTMLElement
? option.container
: document.getElementById(option.container); // 渲染的DOM节点
this.scene = new THREE.Scene(); // 三维场景
this.scene.background = new THREE.Color(0xaaccff);
this.scene.add(this.parent); // 全景球集合
this.scene.add(this.pointObj); // 点集合
this.camera = new THREE.PerspectiveCamera(
70,
this.container.clientWidth / this.container.clientHeight,
0.05,
500
); // 透视相机初始化
// 初始化渲染器
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
logarithmicDepthBuffer: true,
});
// 初始化标签渲染器
this.labelRenderer = new CSS2DRenderer();
this.labelRenderer.setSize(
this.container.clientWidth,
this.container.clientHeight
);
this.labelRenderer.domElement.style.position = "absolute";
this.labelRenderer.domElement.style.top = "0px";
this.renderer.setSize(
this.container.clientWidth,
this.container.clientHeight
);
// 设置渲染的尺寸
// this.renderer.setPixelRatio = window.devicePixelRatio
this.renderer.setClearColor(new THREE.Color("#1e1e1e"));
this.initLocaltion = option.initLocaltion; // 全景图初始位置
this.container.appendChild(this.labelRenderer.domElement);
this.container.appendChild(this.renderer.domElement);
this.raycaster = new THREE.Raycaster(); // 初始化射线
this.mouse = new THREE.Vector2(); //初始化鼠标位置
// 控制器
this.control = new FirstPersonCameraControl(
this.camera,
this.labelRenderer.domElement
);
// 获取鼠标坐标
this.getMousePosition = function (event) {
this.mouse.x = (event.offsetX / this.container.clientWidth) * 2 - 1;
this.mouse.y = -(event.offsetY / this.container.clientHeight) * 2 + 1; //这里为什么是-号,没有就无法点中
};
this.onMouseDown = async function (event) {
this.getMousePosition(event);
//将平面坐标系转为世界坐标系
this.raycaster.setFromCamera(this.mouse, this.camera);
//得到点击的几何体
const raycasters = this.raycaster.intersectObjects(
this.pointObj.children
);
const name = raycasters.length > 0 && raycasters[0].object.name;
this.currentVRItem = this.scene.getObjectByName(name + "VR");
if (this.currentVRItem) {
const position = this.currentVRItem.position.clone();
this.ChangeScene(position, () => {
this.parent.children.forEach((mesh) => {
if (mesh.name != name + "VR") {
mesh.visible = false;
}
});
});
}
const currentVRLocal = raycasters.find((item) => {
if (item.distance > this.radii - 0.2 && item.distance < this.radii) {
return item;
}
});
let currentLabel;
if (currentVRLocal && this.marking) {
switch (this.labelType) {
case 0:
const position = currentVRLocal.point.clone();
case 1:
currentLabel = this.imgUrl && createIconLabel(this.imgUrl);
break;
case 2:
currentLabel = this.videoUrl && createVideoLabel(this.videoUrl);
default:
break;
}
const position = currentVRLocal.point.clone();
currentLabel && currentLabel.position.copy(position);
currentLabel && this.scene.add(currentLabel);
}
};
this.onResize = function () {
this.renderer.setSize(
this.container.clientWidth,
this.container.clientHeight
);
this.camera.aspect =
this.container.clientWidth / this.container.clientHeight;
this.camera.updateProjectionMatrix();
};
this.container.addEventListener(
"mousedown",
this.onMouseDown.bind(this),
false
); // 鼠标点击事件
window.addEventListener("resize", this.onResize.bind(this), false); // 窗口缩重新渲染
}
// 切换动画
ChangeScene(newTarget: THREE.Vector3, callback: () => void) {
const leng = this.camera.position.clone().distanceTo(newTarget);
const time = THREE.MathUtils.clamp(leng * 200, 800, 1200);
const that = this;
that.currentVRItem.visible = true;
new TWEEN.Tween(that.camera.position)
.to(newTarget, time)
// easing缓动函数,Out表示最开始加速,最后放缓
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(function () {
const ratio = that.camera.position.distanceTo(newTarget);
const Ratio = ratio / leng;
if (that.currentVRItem) {
that.currentVRItem.material.uniforms.ratio.value = 1 - Ratio;
}
that.dispatchEvent({
type: "camera",
camera: that.camera,
});
})
.start()
.onComplete(function () {
that.preVRItem = that.currentVRItem;
callback();
});
}
// 渲染函数
renderFn() {
TWEEN.update();
this.control.update();
this.renderer.render(this.scene, this.camera);
this.labelRenderer.render(this.scene, this.camera);
requestAnimationFrame(this.renderFn.bind(this));
}
initVR(infoList: any[]) {
infoList.forEach((item) => {
const point = createrPoint(item.id, item.tz, -0.5 * this.radii, item.tx);
this.pointObj.add(point);
const VRitem = createVRItem(
item.img,
item.id + "VR",
item.tz,
item.ty,
item.tx
);
this.parent.add(VRitem.skyBox);
this.textures.push(VRitem.textureA);
});
if (this.initLocaltion) {
this.preVRItem = this.scene.getObjectByName(this.initLocaltion);
} else {
this.preVRItem = this.parent.children[0];
}
this.preVRItem.visible = true;
this.camera.position.set(
this.preVRItem.position.x,
this.preVRItem.position.y,
this.preVRItem.position.z
);
}
dispose() {
this.scene.children.forEach((item) => {
if (item instanceof THREE.Mesh) {
item.material.dispose();
item.geometry.dispose();
}
});
this.textures.forEach((texture) => {
texture.dispose();
});
this.renderer.clear()
this.renderer.forceContextLoss();
this.renderer.dispose();
this.scene.clear();
}
}
还有一下几个工具方法,用来创建轨迹点的createrPoint,创建全景球的createVRItem,在着色器材质中,接收一个从当前点到下一个点的距离除以总距离,得到一个比率ratio,将这个比率作为一个透明度,传给着色器
import * as THREE from "three";
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer";
import { Font, FontLoader } from "three/examples/jsm/loaders/FontLoader";
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry";
/**
*
* @param {string} id
* @param {string} tx
* @param {string} ty
* @param {string} tz
* @returns {THREE.Mesh}
*/
export const createrPoint = (
id: string,
tx: number,
ty: number,
tz = 0
): THREE.Mesh => {
const geometry = new THREE.CircleGeometry(0.15, 30);
const material = new THREE.MeshBasicMaterial({ color: "#eeeeee" });
const circle = new THREE.Mesh(geometry, material);
circle.name = id;
circle.translateX(tx);
circle.translateY(ty);
circle.translateZ(tz);
circle.rotateX(-Math.PI / 2);
return circle;
};
export const createVRItem = (
url: string,
id: string,
tx: number,
ty: number,
tz = 0,
radii = 4
) => {
const vertexShader = `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = projectionMatrix*viewMatrix*modelMatrix*vec4( position, 1.0 );
}
`;
const fragmentShader = `
uniform sampler2D texture1;
uniform float ratio;
varying vec2 vUv;
void main() {
vec4 vcolor = texture2D( texture1, vUv );
vec4 tcolor = vec4(0.,0.,0.,0.);
gl_FragColor = mix(tcolor,vcolor,ratio);
}
`;
const textureA = new THREE.TextureLoader().load(url);
const materialObj = new THREE.ShaderMaterial({
uniforms: {
texture1: {
value: textureA,
},
ratio: {
value: 1.0,
},
},
vertexShader,
fragmentShader,
});
materialObj.transparent = true;
materialObj.depthWrite = false;
const skyBox = new THREE.Mesh(
new THREE.SphereGeometry(radii, 40, 40),
materialObj
);
skyBox.name = id;
skyBox.translateX(tx);
skyBox.translateY(0);
skyBox.translateZ(tz);
skyBox.geometry.scale(1, 1, -1);
skyBox.visible = false;
return {
skyBox,
textureA,
};
};
export const createVideoLabel = (url: string): CSS2DObject => {
const x = document.createElement("video");
x.setAttribute("width", "320");
x.setAttribute("height", "240");
x.setAttribute("controls", "controls");
x.setAttribute("src", url);
const videoObj = new CSS2DObject(x);
videoObj.name = "video";
return videoObj;
};
export const createIconLabel = (url: string, height = 50): CSS2DObject => {
const img = document.createElement("img");
img.src = url;
img.height = height;
const imgObj = new CSS2DObject(img);
imgObj.name = "icon";
return imgObj;
};
export const text3D = async (text: string): Promise<THREE.Mesh> => {
const loader = new FontLoader();
return new Promise((resolve) => {
loader.load("font/helvetiker_regular.typeface.json", function (response) {
const textGeo = new TextGeometry(text, {
font: response,
size: 1,
height: 200,
curveSegments: 1,
bevelThickness: 1,
bevelSize: 1,
bevelEnabled: false,
});
const materials = [
new THREE.MeshPhongMaterial({ color: 0xffffff, flatShading: true }), // front
new THREE.MeshPhongMaterial({ color: 0xffffff }), // side
];
const mesh = new THREE.Mesh(textGeo, materials);
resolve(mesh);
});
});
};
使用的时候直接实例化VR这个类就可以了
vrScene = new VR({
container: 'threeContainer'
})
vrScene.initVR(dataList)
vrScene.renderFn()
vrScene.addEventListener('camera', cameraFn)
}
})
export const dataList = [
{
id: '01',
tx: 0,
ty: 0,
tz: 0,
img: 'models/gardent/别墅_地下室.jpg'
},
{
id: '02',
tx: 2,
ty: 0,
tz: 2,
img: 'models/gardent/别墅_主卫.jpg'
},
{
id: '03',
tx: 2,
ty: 0,
tz: 5,
img: 'models/gardent/别墅_主卧.jpg'
},
{
id: '04',
tx: 5,
ty: 0,
tz: 9,
img: 'models/gardent/卧室1.jpg'
},
{
id: '05',
tx: 6,
ty: 0,
tz: 1,
img: 'models/gardent/卧室2.jpg'
}
]
上面是数据格式