【threejs】VR看房项目经验总结

发布于:2025-06-24 ⋅ 阅读:(12) ⋅ 点赞:(0)

先进行了总结哈,具体的请往后看

核心步骤实现思路总结

第一步:搭建基本场景

实现思路: 创建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=540

pointer.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;
    
    // 显示tooltip
    tooltipElement.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.2
    object: infoSprite,      // 被命中的物体
    point: Vector3(1,2,3),   // 命中点坐标
    // ...其他信息
  },
  {
    distance: 5.8,           // 第2个物体,距离5.8
    object: 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轴向下

网站公告

今日签到

点亮在社区的每一天
去签到