百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 编程字典 > 正文

让 web 再次伟大——用 canvaskit 实现超级丝滑的原神地图(已开源)

toyiye 2024-05-19 19:35 19 浏览 0 评论

canvaskit 版:

因为 gif 最高只有 50hz,实际上 cavnaskit 版要远比这看起来的更丝滑,你可以通过以下链接自行体验。

官方网页版:webstatic.mihoyo.com/ys/app/inte…

canvaskit 版:空荧酒馆原神地图 (gitee.io) (备用地址:ky-genshin-map.7c00.cc/ )

或者这个 demo:

为什么官方网页版的性能如此糟糕以及为什么 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 则可以更好地胜任。推荐阅读这篇文章 canvaskit-wasm —— 在浏览器中直接使用 skia 的能力渲染 sketch 文件 - 知乎 (zhihu.com) 里面有解释 canvaskit 对比 canvas api 的优势。

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

丝滑的地图体验还不止于渲染性能——手势识别与动画

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

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

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

手势识别及动画的核心任务修改 offset,及 scale,offset 是画布的偏移量,直观来说就是拖拽时的位移量,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,然后初始化 canvaskit,requestAnimationFrame(() => 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;
  }
}

作者:7c00
链接:https://juejin.cn/post/7287781316590583863
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

然后我们对地图绘制任务进行抽象/封装成一个个图层(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
  }
}

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

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 组件封装,最后构建出可交互的地图应用。每个部分都还有不少细节值得介绍,碍于篇幅原因先做一个大致的介绍,以后再写文章继续介绍。

相关推荐

为何越来越多的编程语言使用JSON(为什么编程)

JSON是JavascriptObjectNotation的缩写,意思是Javascript对象表示法,是一种易于人类阅读和对编程友好的文本数据传递方法,是JavaScript语言规范定义的一个子...

何时在数据库中使用 JSON(数据库用json格式存储)

在本文中,您将了解何时应考虑将JSON数据类型添加到表中以及何时应避免使用它们。每天?分享?最新?软件?开发?,Devops,敏捷?,测试?以及?项目?管理?最新?,最热门?的?文章?,每天?花?...

MySQL 从零开始:05 数据类型(mysql数据类型有哪些,并举例)

前面的讲解中已经接触到了表的创建,表的创建是对字段的声明,比如:上述语句声明了字段的名称、类型、所占空间、默认值和是否可以为空等信息。其中的int、varchar、char和decimal都...

JSON对象花样进阶(json格式对象)

一、引言在现代Web开发中,JSON(JavaScriptObjectNotation)已经成为数据交换的标准格式。无论是从前端向后端发送数据,还是从后端接收数据,JSON都是不可或缺的一部分。...

深入理解 JSON 和 Form-data(json和formdata提交区别)

在讨论现代网络开发与API设计的语境下,理解客户端和服务器间如何有效且可靠地交换数据变得尤为关键。这里,特别值得关注的是两种主流数据格式:...

JSON 语法(json 语法 priority)

JSON语法是JavaScript语法的子集。JSON语法规则JSON语法是JavaScript对象表示法语法的子集。数据在名称/值对中数据由逗号分隔花括号保存对象方括号保存数组JS...

JSON语法详解(json的语法规则)

JSON语法规则JSON语法是JavaScript对象表示法语法的子集。数据在名称/值对中数据由逗号分隔大括号保存对象中括号保存数组注意:json的key是字符串,且必须是双引号,不能是单引号...

MySQL JSON数据类型操作(mysql的json)

概述mysql自5.7.8版本开始,就支持了json结构的数据存储和查询,这表明了mysql也在不断的学习和增加nosql数据库的有点。但mysql毕竟是关系型数据库,在处理json这种非结构化的数据...

JSON的数据模式(json数据格式示例)

像XML模式一样,JSON数据格式也有Schema,这是一个基于JSON格式的规范。JSON模式也以JSON格式编写。它用于验证JSON数据。JSON模式示例以下代码显示了基本的JSON模式。{"...

前端学习——JSON格式详解(后端json格式)

JSON(JavaScriptObjectNotation)是一种轻量级的数据交换格式。易于人阅读和编写。同时也易于机器解析和生成。它基于JavaScriptProgrammingLa...

什么是 JSON:详解 JSON 及其优势(什么叫json)

现在程序员还有谁不知道JSON吗?无论对于前端还是后端,JSON都是一种常见的数据格式。那么JSON到底是什么呢?JSON的定义...

PostgreSQL JSON 类型:处理结构化数据

PostgreSQL提供JSON类型,以存储结构化数据。JSON是一种开放的数据格式,可用于存储各种类型的值。什么是JSON类型?JSON类型表示JSON(JavaScriptO...

JavaScript:JSON、三种包装类(javascript 包)

JOSN:我们希望可以将一个对象在不同的语言中进行传递,以达到通信的目的,最佳方式就是将一个对象转换为字符串的形式JSON(JavaScriptObjectNotation)-JS的对象表示法...

Python数据分析 只要1分钟 教你玩转JSON 全程干货

Json简介:Json,全名JavaScriptObjectNotation,JSON(JavaScriptObjectNotation(记号、标记))是一种轻量级的数据交换格式。它基于J...

比较一下JSON与XML两种数据格式?(json和xml哪个好)

JSON(JavaScriptObjectNotation)和XML(eXtensibleMarkupLanguage)是在日常开发中比较常用的两种数据格式,它们主要的作用就是用来进行数据的传...

取消回复欢迎 发表评论:

请填写验证码