【threejs】VR看房项目经验总结
先进行了总结哈,具体的请往后看
核心步骤实现思路总结
第一步:搭建基本场景
实现思路: 创建Three.js三要素,通过new THREE.Scene()
、new THREE.PerspectiveCamera()
、new THREE.WebGLRenderer()
来初始化,再用requestAnimationFrame()
创建渲染循环,让画面持续刷新。
第二步:立方体内部贴图
实现思路: 加载6张贴图作为材质数组,创建立方体后使用box.geometry.scale(1, 1, -1)
翻转Z轴,让贴图面朝内部,实现"站在房间里看墙壁"的效果。
第三步:相机控制
实现思路: 设置相机位置通过camera.position.set(0, 0, 0.01)
实现,具体值需结合立方体大小位置来设置。通过监听鼠标事件,用camera.rotation.x/y += event.movementY/X * 0.01
实现拖拽转动视角。
第四步:多房间拼接
实现思路: 创建多个立方体房间,通过Vector3
精确控制位置让房间无缝拼接,如第一个房间在(0,0,0)
,第二个在(0,0,-10)
,两房间共享边界实现连通。
第五步:导航精灵
实现思路: 用Canvas绘制文字标签,转换为CanvasTexture
贴到Sprite
上。通过Raycaster
射线检测判断鼠标点击,将屏幕坐标转换为NDC坐标(-1到+1)
,检测到点击后执行相机移动。
第六步:信息点处理
实现思路: 创建小图标Sprite
放置在场景中,通过userData
存储详细信息。鼠标悬停时用射线检测判断命中,再用worldVector.project(camera)
将3D坐标转换为2D屏幕坐标,在对应位置显示提示框。
第七步:模块化封装
实现思路: 将重复的房间创建逻辑封装成Class
,通过构造函数传入房间名称、贴图路径、位置等参数,内部自动完成材质加载、几何体创建、位置设置的标准流程,提高代码复用性。
核心技术要点:
- 坐标转换:屏幕坐标 → NDC坐标 → 3D世界坐标
- 射线检测:
Raycaster
实现点击和悬停交互 - 几何体翻转:
scale(1,1,-1)
让贴图朝向内部 - 位置计算:精确的
Vector3
坐标让房间无缝拼接
那么接下来详细的介绍一下:
第一步:搭建基本场景
// 创建场景、相机、渲染器
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);// 渲染循环
const render = () => {renderer.render(scene, camera);requestAnimationFrame(render);
};// 窗口自适应
window.addEventListener("resize", () => {camera.aspect = window.innerWidth / window.innerHeight;camera.updateProjectionMatrix();renderer.setSize(window.innerWidth, window.innerHeight);
});
第二步:立方体内部贴图
这一步就是经常使用的天空盒效果,非常常见,核心就是将正方体的六个面贴图处理,这里我们将图片贴在内部,下面的代码就是比较固定的方式
// 加载6面贴图材质
const materials = [];
const faces = ['room_r', 'room_l', 'room_u', 'room_d', 'room_f', 'room_b'];
faces.forEach(face => {const texture = new THREE.TextureLoader().load(`./textures/main/${face}.jpg`);materials.push(new THREE.MeshBasicMaterial({ map: texture }));
});// 创建立方体房间
const geometry = new THREE.BoxGeometry(10, 10, 10);
const box = new THREE.Mesh(geometry, materials);
box.geometry.scale(1, 1, -1); // 翻转Z轴,让贴图朝向内部
scene.add(box);
关键点:scale(1, 1, -1)
- 翻转Z轴使贴图面向房间内部
第三步:相机控制
将相机移动到正方体的内部,并且使鼠标可以转动
// 相机位置:房间中心点稍微偏移,避免计算错误
camera.position.set(0, 0, 0.01); // (X轴, Y轴, Z轴)// 鼠标拖拽控制
let isMouseDown = false;
container.addEventListener("mousedown", () => { isMouseDown = true; });
container.addEventListener("mouseup", () => { isMouseDown = false; });container.addEventListener("mousemove", (event) => {if (isMouseDown) {camera.rotation.x += event.movementY * 0.01; // 上下转动camera.rotation.y += event.movementX * 0.01; // 左右转动camera.rotation.order = "YXZ"; // 旋转顺序,避免万向锁}
});
关键点:
Vector3(0, 0, 0.01)
- 房间中心微偏移rotation.order = "YXZ"
- 更加符合人类的习惯
第四步:多房间拼接
其实就是再创建一个正方体,这里需要注意设置好位置,避免出现缝隙。其实你也可以不使用Vector3设置位置,通过position.set()也行
// 第二个房间材质
const bedroomMaterials = [];
const bedroomFaces = ['bedroom_r', 'bedroom_l', 'bedroom_u', 'bedroom_d', 'bedroom_f', 'bedroom_b'];
bedroomFaces.forEach(face => {const texture = new THREE.TextureLoader().load(`./textures/bedroom/${face}.jpg`);bedroomMaterials.push(new THREE.MeshBasicMaterial({ map: texture }));
});// 创建第二个房间
const bedroomGeometry = new THREE.BoxGeometry(10, 10, 10);
const bedroomBox = new THREE.Mesh(bedroomGeometry, bedroomMaterials);
bedroomBox.geometry.scale(1, 1, -1);// 房间位置:Z轴-10,与第一个房间(Z轴-5到+5)相连
const bedroomPosition = new THREE.Vector3(0, 0, -10);
bedroomBox.position.copy(bedroomPosition);
scene.add(bedroomBox);// 相机移动到第二个房间
gsap.to(camera.position, { duration: 1, x: 0, y: 0, z: -10 });
关键点:
Vector3(0, 0, -10)
- 房间1占据Z(-5到+5),房间2占据Z(-15到-5),无缝拼接Euler
- 控制物体旋转角度(绕X轴, 绕Y轴, 绕Z轴)
第五步:导航精灵
极其常见的功能,就是一个卡片效果,用来展示信息或者点击之类的操作,这部分代码可以拿去复用,经常能用到;这里有一个重点,那就是如何确定拿到我想和点击的元素,那就是使用Raycaster ,注意Raycaster 的值是负一到一需要转换一下 :
// 假设屏幕尺寸 1920x1080
// 鼠标在屏幕中心点击:clientX=960, clientY=540pointer.x = (960 / 1920) * 2 - 1 = 0.5 * 2 - 1 = 0 // 中心X
pointer.y = -(540 / 1080) * 2 + 1 = -0.5 * 2 + 1 = 0 // 中心Y
// 结果:(0, 0) 正好是NDC坐标的中心点
// Canvas创建文字标签
const canvas = document.createElement("canvas");
canvas.width = 1024;
canvas.height = 1024;
const context = canvas.getContext("2d");
context.fillStyle = "rgba(50,50,50,.7)";
context.fillRect(0, 256, canvas.width, canvas.height / 2);
context.font = "bold 200px Arial";
context.fillStyle = "white";
context.fillText("Room B", canvas.width / 2, canvas.height / 2);// 创建精灵
const spriteTexture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: spriteTexture, transparent: true
}));
sprite.position.set(0, 0, -4); // 两房间之间位置
scene.add(sprite);// 点击检测
const raycaster = new THREE.Raycaster(); // 射线检测器
const pointer = new THREE.Vector2(); // 鼠标坐标window.addEventListener("click", (event) => {// 鼠标坐标转换为NDC坐标(-1到+1)pointer.x = (event.clientX / window.innerWidth) * 2 - 1;pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;raycaster.setFromCamera(pointer, camera);const intersects = raycaster.intersectObjects([sprite]);if (intersects.length > 0) {gsap.to(camera.position, { duration: 1, x: 0, y: 0, z: -10 });}
});
关键点:
Sprite
- 始终面向相机的2D元素Raycaster
- 从相机发射射线检测点击物体- NDC坐标 - 标准化设备坐标,范围(-1, +1)
第六步:信息点处理
1. 鼠标悬停 → 射线检测命中sprite
2. 获取userData → intersects[0].object.userData,如果射线命中了物体,返回数组(按距离排序,近到远)
3. 更新Vue数据 → tooltipContent.value = userData
4. 计算2D位置 → 3D坐标转屏幕坐标
5. 显示HTML元素 → Vue响应式更新DOM
// 创建信息点
const infoTexture = new THREE.TextureLoader().load("./images/marker.png");
const infoSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: infoTexture, transparent: true
}));
infoSprite.scale.set(0.2, 0.2, 0.2); // 缩放到20%
infoSprite.position.set(1.5, -0.1, -3);
infoSprite.userData = { // 存储自定义数据type: "information",name: "展示品A",description: "精美装饰品"
};// 鼠标悬停检测
function showTooltip(event) {pointer.x = (event.clientX / window.innerWidth) * 2 - 1;pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;raycaster.setFromCamera(pointer, camera);const intersects = raycaster.intersectObjects([infoSprite]);if (intersects.length > 0) {// 3D坐标转屏幕坐标const worldVector = intersects[0].object.position.clone();const screenPos = worldVector.project(camera);const x = (screenPos.x + 1) * window.innerWidth / 2;const y = (-screenPos.y + 1) * window.innerHeight / 2;// 显示tooltiptooltipElement.style.left = `${x}px`;tooltipElement.style.top = `${y}px`;}
}
关键点:
userData
- 存储自定义数据的属性project()
- 将3D坐标投影到2D屏幕坐标
第七步:模块化封装
class Room {constructor(name, prefix, imagePath, scene, position = new THREE.Vector3(0,0,0)) {const materials = [];const faces = ['r', 'l', 'u', 'd', 'f', 'b'];faces.forEach(face => {const texture = new THREE.TextureLoader().load(`${imagePath}${prefix}_${face}.jpg`);materials.push(new THREE.MeshBasicMaterial({ map: texture }));});const geometry = new THREE.BoxGeometry(10, 10, 10);const room = new THREE.Mesh(geometry, materials);room.geometry.scale(1, 1, -1);room.position.copy(position);scene.add(room);}
}// 使用
new Room("主厅", "hall", "./textures/main/", scene);
new Room("卧室", "bedroom", "./textures/bedroom/", scene, new THREE.Vector3(0, 0, -10));
核心API速查
Vector3(x, y, z)
- 3D空间位置坐标Euler(x, y, z)
- 3D物体旋转角度scale(x, y, z)
- 缩放几何体Raycaster
- 射线检测,用于点击/悬停判断Sprite
- 始终面向相机的2D元素project()
- 3D坐标转2D屏幕坐标userData
- 存储自定义数据
补充
intersects[0]的来源
1. intersects数组的产生
const raycaster = new THREE.Raycaster();
const intersects = raycaster.intersectObjects([infoSprite]);
// ↑
// 这里返回一个数组
intersects数组结构:
// 如果射线命中了物体,返回数组(按距离排序,近到远)
intersects = [{distance: 3.2, // 第1个物体,距离3.2object: infoSprite, // 被命中的物体point: Vector3(1,2,3), // 命中点坐标// ...其他信息},{distance: 5.8, // 第2个物体,距离5.8object: anotherSprite, // ...}
]// intersects[0] = 距离最近的被命中物体信息
// intersects.length > 0 表示至少命中了一个物体
2. 为什么用[0]?
if (intersects.length > 0) {// intersects[0] = 距离相机最近的物体// 通常我们只关心最近的那个const hitObject = intersects[0].object;const userData = intersects[0].object.userData;
}
3D坐标转2D屏幕坐标详解
1. 获取3D坐标
// 从被命中的物体获取3D世界坐标
const worldVector = new THREE.Vector3(intersects[0].object.position.x, // 物体的X坐标intersects[0].object.position.y, // 物体的Y坐标intersects[0].object.position.z // 物体的Z坐标
);
2. 3D转NDC坐标
// project()方法:3D世界坐标 → NDC坐标(-1到+1)
const screenPosition = worldVector.project(camera);
// 结果:screenPosition.x 和 screenPosition.y 都在 -1 到 +1 范围内
3. NDC转屏幕像素坐标
// NDC坐标(-1到+1) → 屏幕像素坐标(0到屏幕宽高)
const elementWidth = window.innerWidth / 2; // 屏幕宽度的一半
const elementHeight = window.innerHeight / 2; // 屏幕高度的一半const left = screenPosition.x * elementWidth + elementWidth;
const top = -screenPosition.y * elementHeight + elementHeight;
// ↑ 注意这里有负号,因为屏幕Y轴向下,3D Y轴向上
4. 转换公式详解
// X轴转换:NDC(-1到+1) → 屏幕像素(0到屏幕宽)
// screenPosition.x = -1 时:-1 * (宽/2) + (宽/2) = 0 (屏幕左边)
// screenPosition.x = 0 时:0 * (宽/2) + (宽/2) = 宽/2 (屏幕中心)
// screenPosition.x = +1 时:+1 * (宽/2) + (宽/2) = 宽 (屏幕右边)// Y轴转换:NDC(-1到+1) → 屏幕像素(0到屏幕高)
// screenPosition.y = +1 时:-1 * (高/2) + (高/2) = 0 (屏幕顶部)
// screenPosition.y = 0 时:0 * (高/2) + (高/2) = 高/2 (屏幕中心)
// screenPosition.y = -1 时:1 * (高/2) + (高/2) = 高 (屏幕底部)
5. 完整流程示例
// 假设物体在3D空间的位置是 (1.5, -0.1, -3)
const worldVector = new THREE.Vector3(1.5, -0.1, -3);// 转换为NDC坐标,假设结果是 (0.2, 0.1, ...)
const screenPosition = worldVector.project(camera);// 假设屏幕是1920x1080
const left = 0.2 * 960 + 960 = 1152; // X像素位置
const top = -0.1 * 540 + 540 = 486; // Y像素位置// 最终HTML元素显示在屏幕的(1152, 486)位置
tooltipElement.style.left = '1152px';
tooltipElement.style.top = '486px';
关键概念:
intersects[0]
- 射线检测返回的最近物体project()
- Three.js内置方法,3D转NDC坐标- NDC坐标 - 标准化设备坐标(-1到+1)
- 坐标系差异 - 3D Y轴向上,屏幕Y轴向下