用 CanvasKit 实现超级丝滑的原神地图(已开源)!!!

首先给大家送上预览地址:

  • 官网地址:https://webstatic.mihoyo.com/ys/app/interactive-map/index.html

  • canvaskit地址:http://106.55.55.247/ky-genshin-map/

图片

为什么 canvaskit 有如此高的性能?

第一个问题,官方网页版地图引擎用的是 leaflet,这是一个以 dom 为主要实现方式的地图引擎,而频繁地大量操作 dom 会导致严重的性能问题。你可以想象一下,要保证视觉上流畅,手势及动画的采样频率至少是 60hz,意味着单个 dom 节点每秒就要变换 60 次,一旦数量超过 100 个,对浏览器来说就是无法承受的压力。

但是 leaflet 也有 canvas 实现的 layer,或者这么说,如果性能瓶颈在于 dom,那么用浏览器提供的 canvas api 就应该可以解决,为什么还需要 canvaskit 呢?

在回答这个问题之前,先介绍一下 canvaskit,这其实就是 skia 的 js + wasm 版,c++ 实现的渲染引擎被编译成了 wasm,通过 js 提供类似 canvas api 的绘制接口。也许你已经知道 chrome 的底层就是 skia 做渲染引擎,canvas api 也可以视为 skia 绘制接口的封装,那么 skia 编译成 wasm 再提供 js api 不是脱裤子放屁吗。

不是的,简单来说 canvas api 只提供一些简单的绘制接口,上限远低于提供了 skia 底层接口的 canvaskit,如果只是简单场景,canvas api 确实已经足够了,但在复杂场景,或者说渲染压力非常大的情况下,canvas api 很容易达到性能瓶颈,而 canvaskit 则可以更好地胜任。

举一个原神地图里的例子,在需要渲染大量重复图片(标记物)的场景下,canvas api 只能大量调用 drawImage 一个个地绘制,而 canvaskit 提供了 drawAtlas 可以对图片按 transforms 批量绘制。根据我的实践,一帧内 drawImage 调用达到几百次就足以导致帧超时,而 drawAtlas 一次处理上万个 transforms 都没有什么压力。

手势识别与动画

渲染性能只是丝滑体验的基础,要做到真正的丝滑,符合直觉的动画反馈才是关键。道理很简单,和手机上的滑动滚动一样,当我们拖拽地图结束的时候,我们会期望地图以拖拽、缩放结束时的速度继续运动一段距离,并且速度的衰减应该符合现实的阻尼运动,这意味着不能简单套个 timing-function。当然也会有用到 timing-function 的时候,比如双击放大就适合用 timing-function 做动画。

现实是,很多原生地图并没有很重视动画反馈,要么没做,要么做了但实现的动画不符合直觉。尽管有不少地图 SDK 已经是用 webgl 做渲染,性能没有什么问题,但用起来仍然谈不上丝滑。所以我决定从地图引擎开始实现,尝试实现理想中丝滑的原神地图体验。

好在手势识别及动画都已经有不错的库可以直接使用,手势识别用的是 @use-gesture/vanilla,而动画用的是 popmotion 其中主要用 inertia 做阻尼动画。

手势识别及动画的核心任务修改 offset,及 scaleoffset 是画布的偏移量,直观来说就是拖拽时的位移量,scale 是缩放系数,类似于 transform: scale()

拖拽手势处理

拖拽手势是最容易处理的,@use-gesture 已经把用到的数值都准备好了,比如 delta 是位移差值,velocity 是速度,direction 是运动方向,只要选好合适的参数就可以很容易实现符合直觉的阻尼动画。

onDrag({ delta }: FullGestureState<"drag">) {
  offset[0] -= delta[0];
  offset[1] -= delta[1];
}

onDragEnd({ velocity, direction }: FullGestureState<"drag">) {
  const lastOffset = [...offset];
  // 合加速度
  const v = Math.sqrt(velocity[0] ** 2 + velocity[1] ** 2);
  inertia({
    velocity: v,
    power: 200,
    timeConstant: 200,
    onUpdate: (value) => {
      offset[0] = lastOffset[0] - direction[0] * value * (velocity[0] / v)
      offset[1] = lastOffset[1] - direction[1] * value * (velocity[1] / v)
    },
  });
}

缩放手势处理

缩放手势的处理相对麻烦些,一方面我们需要引入一个新的概念 zoom(缩放级别),和 scale 是以 2 为底的对数关系,scale = 2 * zoom 或者 zoom = Math.log2(scale),你大概不会陌生,在地图领域都是用 zoom 来描述缩放,因为 zoom 的线性变化更符合操作逻辑,在做阻尼动画时,也必须根据 zoom 进行变化而不是 scale。

另一方面,scale 必须有一个中心,缩放的过程中并不只有 scale 发生了变化,offset 也变化了,还必须重新计算 offset 的位置。

onPinch(state: FullGestureState<"pinch">) {
  const { origin, da, initial, touches } = state;
  if (touches != 2) return;

  const newScale = (da[0] / initial[0]) * this.lastScale;
  this.scaleTo(newScale, origin);
}

onPinchEnd({ origin, velocity, direction }: FullGestureState<"pinch">) {
  this.lastScale = scale;
  // 手势识别提供的速度是针对 scale 的,需要取对数转成针对 zoom 的速度
  const v = Math.log10(1 + Math.abs(velocity[0])) * 50;
  inertia({
    velocity: velocity,
    timeConstant: 50,
    restDelta: 0.001,
    onUpdate: (value) => {
      const zoom = Math.log2(this.lastScale) - direction[0] * value;
      this.scaleTo(2 ** zoom, origin);
    },
  });
}

实现地图绘制

首先我们需要有一个地图的入口,用一个 html element 作为容器,在里面创建一个 canvas,然后初始化 canvaskitrequestAnimationFrame(() => this.drawFrame()) 一直绘制就可以了,当然,静止的情况下是不需要绘制的,为此我们引入一个 dirty 变量,offset/scale 变化之后都设置 dirty = true,在 drawFrame 结束后设置 dirty = false

class Tilemap {
  _dirty = false;
  _offset = [0, 0];
  _scale = 0;

  constructor(options: TilemapOptions) {
    this._options = options;
    this._element = options.element;
    this._canvasElement = document.createElement("canvas");
    this._canvasElement.style.touchAction = "none";
    this._canvasElement.style.position = "absolute";
    this._context = canvaskit.MakeWebGLContext(
      canvaskit.GetWebGLContext(this._canvasElement)
    )!;
    this._element.appendChild(this._canvasElement);
    this._drawFrame();
  }

  _drawFrame() {
    if (this._dirty) {
      // draw
      this._dirty = false;
    }
    requestAnimationFrame(() => this._drawFrame());
  }

  draw() {
    this._dirty = true;
  }
}

然后我们对地图绘制任务进行抽象/封装成一个个图层(layer),比如有 TileLayer 用于实现瓦片地图的绘制,MarkerLayer 用于绘制图片标记,ImageLayer 用于绘制随地图缩放的 Image 等等,每个 Layer 都有一个 draw() 方法用于实现具体的 draw 任务。

那么 Tilemap 就只需要维护一个 layers 集合,layers.add() 和 layers.delete() 就可以实现 layer 的添加/删除,drawFrame() 里就每次遍历 for (const layer of layers),依次调用 layer.draw(),还可以给 Layer 新增一个 zIndex 属性,用于控制图层的堆叠顺序,其实就是对 layers 按 zIndex 排序即可。

interface LayerOptions {
  zIndex?: number;
  hidden?: boolean;
}

class Layer<O extends LayerOptions = LayerOptions> {
  /**
   * addLayer 时由 tilemap 赋值
   */
  tilemap: Tilemap;
  constructor(public options: O) {}
  abstract draw(canvas: Canvas): void;
  dispose() {}
}

class Tilemap {
  ...

  addLayer(layer: Layer) {
    layer.tilemap = this;
    this._layers.add(layer);
    this.draw();
  }

  removeLayer(layer: Layer) {
    layer.dispose();
    this._layers.delete(layer);
    this.draw();
  }

  _drawFrame() {
    if (this._dirty) {
      const canvas = this._surface.getCanvas();
      // 重置 matrix
      canvas.concat(canvaskit.Matrix.invert(canvas.getTotalMatrix())!);
      // 因为 scale 依赖原点,必须先 scale 后 translate
      canvas.scale(devicePixelRatio, devicePixelRatio);
      canvas.translate(-this._offset[0], -this._offset[1]);
      const layers = [...this._layers].filter((i) => !i.options.hidden);
      layers.sort((a, b) => a.options.zIndex - b.options.zIndex);
      for (const layer of layers) {
        layer.draw(canvas);
      }
      this._surface.flush();
      this._dirty = false;
    }
    requestAnimationFrame(() => this._drawFrame());
  }
}

如果我们要实现一种 Layer,只要继承 Layer,实现 draw() 方法即可。

interface TileLayerOptions extends LayerOptions {
  tileSize?: number;
  minZoom: number;
  maxZoom: number;
  getTileUrl: (x: number, y: number, z: number) => string;
}

class TileLayer extends Layer<TileLayerOptions> {
  draw(canvas: Canvas) {
    // draw tiles
  }
}

interface MarkerItem {
  x: number;
  y: number;
}

interface MarkerLayerOptions<T extends MarkerItem = MarkerItem> extends LayerOptions {
  items: T[];
  image?: CanvasImageSource;
}

class MarkerLayer<T extends MarkerItem = MarkerItem> extends Layer<MarkerLayerOptions<T>> {
  draw() {
    // draw markers
  }
}
interface TileLayerOptions extends LayerOptions {
  tileSize?: number;
  minZoom: number;
  maxZoom: number;
  getTileUrl: (x: number, y: number, z: number) => string;
}

class TileLayer extends Layer<TileLayerOptions> {
  draw(canvas: Canvas) {
    // draw tiles
  }
}

interface MarkerItem {
  x: number;
  y: number;
}

interface MarkerLayerOptions<T extends MarkerItem = MarkerItem> extends LayerOptions {
  items: T[];
  image?: CanvasImageSource;
}

class MarkerLayer<T extends MarkerItem = MarkerItem> extends Layer<MarkerLayerOptions<T>> {
  draw() {
    // draw markers
  }
}

如此一来,就可以通过以下代码创建一个地图:

const tilemap = new Tilemap({
  element: "#tilemap",
  mapSize: [17408, 17408],
  origin: [3568 + 5888, 6286 + 2048],
  maxZoom: 1,
});

tilemap.addLayer(
  new TileLayer({
    minZoom: 10,
    maxZoom: 13,
    offset: [-5888, -2048],
    getTileUrl(x, y, z) {
      return `https://assets.yuanshen.site/tiles_twt40/${z}/${x}_${y}.png`;
    },
  })
);

图片

封装成 react/vue 组件以方便界面开发

地图渲染能力有了,但要构建复杂的地图功能,还得封装成 react/vue 组件来方便界面开发。以 vue 为例,我们会期望通过这样代码来构建地图应用:

<Tilemap
  class="absolute w-full h-full left-0 top-0"
  v-if="!loading"
  :map-size="[17408, 17408]"
  :origin="[3568 - tileOffset[0], 6286 - tileOffset[1]]"
  :max-zoom="1"
>
  <TileLayer
    :min-zoom="10"
    :max-zoom="13"
    :offset="tileOffset"
    :get-tile-url="getTileUrl"
  />
  <MarkerLayer class="p-1" :items="i.items" v-for="i in markers">
    <div
      class="w-6 h-6 shadow shadow-black flex justify-center items-center rounded-full border border-solid border-white bg-gray-700"
    >
      <img
        class="w-11/12 h-11/12 object-cover"
        cross-origin=""
        :src="i.icon"
      />
    </div>
  </MarkerLayer>
</Tilemap>

到了这里要做的事就没有那么复杂了,只是要用好 react/vue 的 hooks 处理好生命周期、传参。

Tilemap 的封装

用于构造 Tilemap 的参数 options 可以作为 props 直接传入,在组件内部,用 ref 存储构造出来的 tilemap,用 provide 提子组件访问。如果是 react 则是用 Context

import * as core from "@core";
import { defineComponent, provide, ref, watchEffect } from "vue";

interface TilemapProps extends Omit<core.TilemapOptions, "element"> {}

export const Tilemap = defineComponent((props: TilemapProps, { slots }) => {
  const element = ref<HTMLDivElement>();
  const tilemap = ref<core.Tilemap>();
  watchEffect(() => {
    if (element.value && !tilemap.value) {
      tilemap.value = new core.Tilemap({ ...props, element: element.value });
    }
  });
  provide("tilemap", tilemap);
  return () => <div ref={element}>{slots.default?.()}</div>;
});

Layer 的封装

Layer 的封装思路是,先用 inject 取到父级 provide 的 tilemap,用 watchEffect 构造 Layer 实例并调用 tilemap.addLayer() 在 onUnmounted 的时候 tilemap.removeLayer() 即可。

import * as core from "@core";
import {
  defineComponent,
  inject,
  onUnmounted,
  ref,
  Ref,
  watchEffect,
} from "vue";

interface TileLayerProps extends core.TileLayerOptions {}

export const TileLayer = defineComponent(
  (props: TileLayerProps) => {
    const tilemap = inject("tilemap") as Ref<core.Tilemap>;
    const layer = ref<core.Layer>();
    watchEffect(() => {
      if (tilemap?.value && !layer.value) {
        layer.value = new core.TileLayer(props);
        tilemap.value.addLayer(layer.value);
      }
    });
    onUnmounted(() => {
      if (layer.value) {
        tilemap.value.removeLayer(layer.value);
      }
    });
    return () => null;
  },
);

如果是 MarkerLayer,要处理的情况要多一些,比如为了实现用 vue 组件作为 Marker image,需要先渲染出一个真实的 dom,然后把这个 dom 转成 image,再传入 MarkerLayer 去渲染。

从手势识别开始,到 canvaskit 实现地图引擎,再到 react/vue 组件封装,最后构建出可交互的地图应用。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/360463.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【数据结构 07】AVL树

目录 一、二叉搜索树 二、AVL树 2.1 左单旋 2.2 右单旋 2.3 左右双旋 2.4 右左双旋 三、AVL.h 四、test.cpp 一、二叉搜索树 二叉搜索树&#xff0c;又称二叉排序树&#xff08;Binary Search Tree&#xff09;&#xff0c;相比于普通二叉树&#xff0c;BST的特性有&a…

Python学习--一个逻辑推理的猜数字的游戏

修订Pico Fermi Bagels猜数字游戏代码&#xff0c;仅用于学习Python。 运行界面如下&#xff1a; 修订的代码如下&#xff1a; # // # 提升逻辑思维猜数字小游戏 # BY&#xff1a;Al Sweigart alinventwithpython.com # 翻译&#xff1a;诚外无物 # 说明&#xff1a;一个逻辑…

rust学习基于tokio_actor聊天服务器实战(一 )

前言 tokio是Rust中使用最广泛的异步Runtime&#xff0c;它性能高、功能丰富、便于使用&#xff0c;是使用Rust实现高并发不可不学的一个框架 Actor 背后的基本思想是产生一个独立的任务&#xff0c;该任务独立于程序的其他部分执行某些工作。 通常&#xff0c;这些参与者通过使…

如何使用postman进行接口自动化测试?

1、什么是自动化测试&#xff1f; 把人对软件的测试行为转化为由机器执行测试行为的一种实践。 例如GUI自动化测试&#xff0c;模拟人去操作软件界面&#xff0c;把人从简单重复的劳动中解放出来&#xff0c;本质是用代码去测试另一段代码&#xff0c;属于一种软件开发工作&a…

TRIZ:打破便携与功能矛盾,卫星通信领域的新曙光!

在当今的卫星通信领域&#xff0c;便携性与功能性的矛盾一直是困扰着研发人员的一大难题。如何在保持设备便携的同时&#xff0c;确保其功能的完善和稳定&#xff0c;成为了业界关注的焦点。而解决这一问题的关键&#xff0c;或许正隐藏在TRIZ这一强大的创新方法论之中。 TRIZ&…

【PyCharm教程】PyCharm 安装、卸载和升级包

PyCharm 为特定的 Python 解释器提供了安装、卸载和升级 Python 包的方法。默认情况下&#xff0c;PyCharm 使用 pip 来管理项目包。对于 Conda 环境&#xff0c;您可以使用conda 包管理器。 在 PyCharm 中&#xff0c;您可以在Python 包工具窗口和 Python 解释器Settings/Pre…

暴雨受邀出席太原市人工智能行业协会年度大会

2024年1月26日&#xff0c;太原市人工智能行业协会第二届二次会员大会暨2024年年会成功召开。太原市委、市工商联、市大数据应用中心、市政协经济委员会以及太原市科技局的专家领导&#xff0c;与三百多名来自各行业的人工智能企业家和协会会员一同参加了本次盛会&#xff0c;共…

(十一)springboot实战——springboot3下关于WebFlux项目的一些常用功能整合

前言 本节内容主要是对webflux项目一些常用功能的介绍&#xff0c;例如系统集成swagger接口文档&#xff0c;方便接口测试以及前后端项目联调测试&#xff1b;使用actuator完成系统各种指标的监控功能&#xff1b;系统使用logback日志框架完成项目日志的收集&#xff1b;使用过…

项目:博客

1. 运行环境&#xff1a; 主机 主机名 系统 服务 192.168.223.129 Server_Web Linux Web 192.168.48.131 Server-NFS-DNS Linux NFS/DNS 2. 基础配置 配置主机名&#xff0c;静态IP地址 开启防火墙并配置 部分开启SElinux并配置 服务器之间使用同ntp.aliyun.com进行…

eBay测评自养号下单需要满足哪些技术要求?养号优势有哪些?

自养号测评环境搭建系统涉及几个主要环节&#xff1a; 1.系统环境&#xff1a;用于登录和管理多个账号&#xff0c;需具备防关联功能&#xff0c;每个账号使用独立IP&#xff0c;确保完全隔离无关联问题。 2.高质量网络环境&#xff1a;需要国外家庭住宅IP&#xff0c;纯净度…

开发者带你了解山海鲸智慧社区解决方案

作为山海鲸可视化软件的开发者&#xff0c;我们深知在智慧社区建设中&#xff0c;数据可视化对于提升社区管理效率和居民生活品质的重要性。因此在提供人人都能免费使用的产品基础上&#xff0c;特结合山海鲸可视化软件优势打造智慧社区解决方案&#xff0c;本文将为您详细介绍…

sketchup草图大师模型网怎么样?

SketchUp草图大师模型网是一个提供SketchUp模型下载和分享的平台&#xff0c;如建e网等&#xff0c;用户可以在上面找到大量的SU模型&#xff0c;并学习一些SketchUp的使用技巧。该网站模型种类丰富&#xff0c;涵盖了建筑、景观、室内等不同领域&#xff0c;同时也有一些教程&…

6天获邀请函|食品科学老师赴英国Quadram研究所访学交流

C老师拟申报CSC国家公派访问学者项目&#xff0c;因DIY申请无果且申报期临近而求助于我们。依本人要求&#xff0c;我们迅速开展了全方位申请&#xff0c;几天内获得近十个反馈&#xff0c;最终其选择了不需要面试且不收板凳费的英国Quadram研究所&#xff0c;此时距离开展申请…

2024新鲜出炉 Java集合常见面试题总结(上)

2024新鲜出炉 Java集合常见面试题总结(上) 文章目录 2024新鲜出炉 Java集合常见面试题总结(上)集合概述Java 集合概览说说 List, Set, Queue, Map 四者的区别&#xff1f;集合框架底层数据结构总结ListSetQueueMap 如何选用集合?为什么要使用集合&#xff1f; ListArrayList 和…

SOME/IP SD 协议介绍(五)使用SOME/IP-SD宣布非SOME/IP协议的协议。

使用SOME/IP-SD宣布非SOME/IP协议的协议。 除了SOME/IP之外&#xff0c;车辆内部还使用其他通信协议&#xff0c;例如用于网络管理、诊断或闪存更新。这些通信协议可能需要传递服务实例或具有事件组。 对于非SOME/IP协议&#xff0c;应使用特殊的服务ID&#xff0c;并使用配置…

最新盘点!2024年最好用的十大进销存管理系统

深度盘点国内外十大进销存管理系统&#xff1a;简道云进销存、Oracle NetSuite、金蝶精斗云、用友、浪潮云进销存、傻瓜进销存、旺店通、生意专家、QT9、Linnworks。 进销存指的是企业管理过程中采购&#xff08;进&#xff09;——入库&#xff08;存&#xff09;——销售&am…

20240127在ubuntu20.04.6下配置whisper

20240131在ubuntu20.04.6下配置whisper 2024/1/31 15:48 首先你要有一张NVIDIA的显卡&#xff0c;比如我用的PDD拼多多的二手GTX1080显卡。【并且极其可能是矿卡&#xff01;】800&#xffe5; 2、请正确安装好NVIDIA最新的驱动程序和CUDA。可选安装&#xff01; 3、配置whispe…

备战蓝桥杯---数据结构与STL应用(入门3)

我们先来一道题作为过渡&#xff1a; 我们只需枚举n,选出左右第一个小于它高度的坐标即可&#xff0c;于是我们可以用两个方向的优先队列来维护&#xff0c;下面是AC代码&#xff1a; #include<bits/stdc.h> using namespace std; #define int long long int n; struct …

基于ssm和微信小程序的健身房私教预约管理系统

文章目录 项目介绍主要功能截图&#xff1a;部分代码展示设计总结项目获取方式 &#x1f345; 作者主页&#xff1a;超级无敌暴龙战士塔塔开 &#x1f345; 简介&#xff1a;Java领域优质创作者&#x1f3c6;、 简历模板、学习资料、面试题库【关注我&#xff0c;都给你】 &…

Word莫名其妙开启兼容模式将其永久取消的方法

这是因为Word模板文件被意外更改了 找到Word模板文件&#xff0c;目录在C:\Users\15976\AppData\Roaming\Microsoft\Templates 15976替换成你自己的用户名&#xff0c;不确定的就先点进C/Users看一看&#xff0c; AppData是隐藏文件夹&#xff0c;显示隐藏文件夹才能看见&am…