头图

原文参考我的公众号文章 threejs实现右上角小地图

经过三番五次的尝试,总算是实现了小地图功能。尝试了几种方式,但是综合效果和性能来看,独立相机(OrthographicCamera)+独立渲染器(WebGLRenderer)实现小地图是最佳选择。

这里直接上最佳实践,然后再依次记录下其它尝试的大致思路。

最佳实践

独立相机 + 独立渲染器

实现思路其实很简单,植入现有程序也很方便,来看下:

  • 第一步,创建地图像机

一般选择正交相机,它的最大特性就是所有物体在它的眼里都是一样的显示比例,没有近大远小,很适合 2D 小地图。像机的前四个参数决定了能看到主场景内多少范围,越大看到的东西越多。

// 初始化小地图相机
const mapSize = 20; //相机看到主场景的内容有多少
const mapCamera = new OrthographicCamera(
  -mapSize / 2,
  mapSize / 2,
  mapSize / 2,
  -mapSize / 2,
  1,
  1000
);

mapCamera.position.set(0, 100, 0);
mapCamera.lookAt(new THREE.Vector3(0, 0, 0));
  • 第二步,创建地图渲染器

创建小地图独立渲染器,这样可以和主程序的渲染器和流程解耦,也方便调整小地图在画面上的的显示位置。

// 初始化小地图渲染器
const mapRenderSize = 200; //决定了小地图2D平面的css样式大小
const mapRenderer = new WebGLRenderer({ alpha: true });
mapRenderer.setSize(mapRenderSize, mapRenderSize);
mapRenderer.setClearColor(0x7d684f);

//下面渲染器相关设置最好搞下,不然显示效果会有点怪!
mapRenderer.shadowMap.enabled = true;
mapRenderer.shadowMap.type = PCFSoftShadowMap;
mapRenderer.physicallyCorrectLights = true;
mapRenderer.outputEncoding = sRGBEncoding;

// 设置样式,并添加到HTML
mapRenderer.domElement.id = "mapcanvas";
mapRenderer.domElement.style.position = "absolute";
mapRenderer.domElement.style.right = "5px";
mapRenderer.domElement.style.top = "5px";
mapRenderer.domElement.style.zIndex = "1001";
mapRenderer.domElement.style.border = "1px dashed #000";
mapRenderer.domElement.style.transform = `rotateZ(${mapRotateZ}deg)`;
mapRenderer.domElement.style.borderRadius = "16px";

document.body.appendChild(mapRenderer.domElement);
  • 第三步,更新小地图相机位置与调用小地图渲染器渲染画面

为了让小地图上的画面与主程序画面同步,需要保持小地图相机的位置与我们的玩家(或主相机)位置同步。

// 更新地图相机位置和视点
function updateMapCameraAndRender(){
    let targetPos = player.position; //决定了小地图的观测中心点
    mapCamera.position.set(
      targetPos.x,
      targetPos.y + 100,
      targetPos.z
    );
    mapCamera.lookAt(targetPos.x, 2, targetPos.z);

    // 渲染小地图
    mapRenderer.render(this.scene, mapCamera);
}

animate(){
    //主程序render...

    //小地图render
    updateMapCameraAndRender();
}

完成以上三步,小地图就加入到了主程序中了,一句话来说就是:用一个单独的相机盯着目标场景,并用一个独立的渲染器实时渲染到一个新的 Dom 节点上。

下面是我封装好的小地图类,方便引入使用。

import {
  OrthographicCamera,
  WebGLRenderer,
  PCFSoftShadowMap,
  sRGBEncoding,
  MathUtils,
  ACESFilmicToneMapping,
  Vector3,
} from "three";

export class MiniMap {
  _miniMapCamera = null;
  _miniMapRenderer = null;
  _followTarget = null;

  /**
   * 初始化参数
   * @param {Object} options
   * @options.scene 主场景
   * @options.target 小地图以之为中心点的3D目标
   * @options.mapSize 决定了摄像机看到的内容大小,默认10
   * @options.mapRenderSize 决定了小地图2D平面的大小,默认120
   * @options.mapRotateZ number 小地图沿着Z轴(垂直屏幕)旋转角度,默认0
   * @options.mapSyncRotateZ boolean 小地图沿着Z轴(垂直屏幕)是否跟着一同target旋转,默认false
   */
  constructor(
    options = {
      scene,
      target,
      mapSize,
      mapRenderSize,
      mapRotateZ,
      mapSyncRotateZ,
    }
  ) {
    this.scene = options.scene;
    this.mapSize = options.mapSize || 10;
    this.mapRenderSize = options.mapRenderSize || 120;
    this.mapRotateZ = options.mapRotateZ || 0;
    this.mapSyncRotateZ = options.mapSyncRotateZ || false;
    this._followTarget = options.target;
    if (!this.scene) {
      throw new Error("scene不能为空");
    }
    if (!this._followTarget) {
      throw new Error("target不能为空,表示小地图画面主要跟随对象");
    }

    this.add();
  }

  add() {
    const { mapSize, mapRenderSize, mapRotateZ } = this;

    // 初始化小地图渲染器
    const mapRenderer = new WebGLRenderer({ alpha: true });
    mapRenderer.setSize(mapRenderSize, mapRenderSize);
    // mapRenderer.setClearColor(0x7d684f);
    mapRenderer.shadowMap.enabled = true;
    mapRenderer.shadowMap.type = PCFSoftShadowMap;
    mapRenderer.physicallyCorrectLights = true;
    mapRenderer.outputEncoding = sRGBEncoding;
    // mapRenderer.toneMapping = ACESFilmicToneMapping; //电影渲染效果
    // mapRenderer.toneMappingExposure = 0.6;
    this._miniMapRenderer = mapRenderer;

    // 设置样式,并添加到HTML
    mapRenderer.domElement.id = "mapcanvas";
    mapRenderer.domElement.style.position = "absolute";
    mapRenderer.domElement.style.right = "5px";
    mapRenderer.domElement.style.top = "5px";
    mapRenderer.domElement.style.zIndex = "1001";
    mapRenderer.domElement.style.border = "1px dashed #000";
    mapRenderer.domElement.style.transform = `rotateZ(${mapRotateZ}deg)`;
    mapRenderer.domElement.style.borderRadius = "16px";

    this._miniMapDomEl = mapRenderer.domElement;
    document.body.appendChild(mapRenderer.domElement);

    // 初始化小地图相机
    const mapCamera = new OrthographicCamera(
      -mapSize / 2,
      mapSize / 2,
      mapSize / 2,
      -mapSize / 2,
      1,
      1000
    ); //在这种投影模式下,无论物体距离相机距离远或者近,在最终渲染的图片中物体的大小都保持不变。这对于渲染2D场景或者UI元素是非常有用的。
    this._miniMapCamera = mapCamera;

    // 更新地图相机位置和朝向
    this.updateCamera();
  }

  updateCamera() {
    // 更新小地图css旋转角度,与玩家同步
    if (this.mapSyncRotateZ) {
      let targetRotateY = MathUtils.radToDeg(this._followTarget.rotation.y);
      this._miniMapDomEl.style.transform = `rotateZ(${
        this.mapRotateZ + targetRotateY
      }deg)`;
    }

    // 更新地图相机位置和朝向
    let targetPos = this._followTarget.position;
    this._miniMapCamera.position.set(
      targetPos.x,
      targetPos.y + 10,
      targetPos.z
    );
    this._miniMapCamera.lookAt(targetPos.x, 3, targetPos.z);
  }

  update() {
    // 更新地图相机位置和朝向
    this.updateCamera();

    // 渲染小地图
    this._miniMapRenderer.render(this.scene, this._miniMapCamera);
  }
}

使用示例:

import { MiniMap } from "./scripts/MiniMap";

this.miniMap = new MiniMap({
  target: this.player,
  scene: this.scene,
  mapSize: 12,
  mapRenderSize: 160,
});

animate() {
    // 主程序代码....

    // 更新小地图
    this.miniMap.update();
}

其它尝试

  • v1 实现:OrthographicCamera + Canvas 将渲染器的内容以图片的形式绘制到 Canvas 元素上。体验地址 1

缺点:每次绘制都是一次图片加载,页面请求量无限大!而且会有延迟,因为图片加载过程是需要网络传输时间的,这样就导致小地图卡顿!

  • v2 实现:在界面上新建一块区域作为小地图,贴上场景地图的贴纸,再绘制一个标记代表玩家。主场景中的玩家移动时,将位置同步给小地图上的玩家标记。体验地址 2

优点:可以自定义小地图的样式和玩家标记的样式,因为一般小地图只是现实笼统的地形、物体、玩家位置标记!

缺点:因为小地图的渲染和主业务的渲染是在同一个相机下显示的,当镜头视角切换、缩放等操作会影响小地图的位置和大小,因此需要编写复杂的逻辑去实时更新小地图的位置和渲染大小,有一定难度!

  • v3 实现:独立相机(OrthographicCamera)+独立渲染器(SVGRenderer)

效果也很特别,且因为是以 svg 的形式展示小地图,所以可以通过 js 和 css 控制 svg 节点。只不过对比下来大场景下性能没那么好 🤷体验地址 3


Believer
47 声望5 粉丝

无法忍受尘世间的丑 便看不到尘世间的美