three.js实现VR看房自由

本文讨论了在房地产看房应用中使用VR和全景图技术提供沉浸式体验的实现原理,包括全景球的运动控制、初始化场景的问题、标签定位以及自定义VR控制器。同时提到了如何解决坐标精度、标签显示等问题和使用tween.js库实现平滑过渡效果。
摘要由CSDN通过智能技术生成

在现代看房软件中,例如贝壳看房、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'
    }
]

上面是数据格式

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

奥德坤

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值