用 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) {// drawthis._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();// 重置 matrixcanvas.concat(canvaskit.Matrix.invert(canvas.getTotalMatrix())!);// 因为 scale 依赖原点,必须先 scale 后 translatecanvas.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 为例,我们会期望通过这样代码来构建地图应用:

<Tilemapclass="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"><divclass="w-6 h-6 shadow shadow-black flex justify-center items-center rounded-full border border-solid border-white bg-gray-700"><imgclass="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.mzph.cn/news/659572.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

算法训练营day19,二叉树8-2

type TreeNode struct { Val int Left *TreeNode Right *TreeNode } 450. 删除二叉搜索树中的节点 /*本题比较难&#xff0c;删除节点要分五种情况考虑 1.没有找到要删除的节点 2.找到要删除的节点是叶子节点 3.找到要删除的节点&#xff0c;左指针不为空&#xff0c;…

[嵌入式系统-7]:龙芯1B 开发学习套件 -4- LoongIDE 集成开发工具的使用-创建应用程序工程、编译、下载、调试

目录 前言&#xff1a; 步骤1&#xff1a;设置工作工作空间 步骤2&#xff1a;设置工具链 步骤3&#xff1a;创建裸机应用程序 步骤4&#xff1a;创建带实时操作系统的应用程序 步骤5&#xff1a;编译 步骤6&#xff1a;下载调试 前言&#xff1a; LoongIDE集成开发环境…

使用 axios 请求库,设置请求拦截

什么是 axios&#xff1f; 基于promise网络请求库&#xff0c;可以同构&#xff08;同一套代码可以运行在浏览器&#xff09;&#xff0c;在服务端&#xff0c;使用原生node.js的http模块&#xff0c;在客户端&#xff08;浏览器&#xff09;中&#xff0c;使用XMLHttpRequests…

什么是适配器模式?它的实现方式有哪些?

什么是适配器模式&#xff1f;它的实现方式有哪些&#xff1f; 适配器模式是一种结构型设计模式&#xff0c;用于将一个类的接口转换成客户端所期望的另一个接口&#xff0c;以解决由于接口不兼容而不能协同工作的问题。适配器模式可以使原本由于接口不兼容而不能一起工作的类…

知识价值1-github站点域名

github如果访问不上&#xff0c;有一个办法是hosts映射&#xff1a; github.com x.x.x.x github.global.ssl.fastly.net y.y.y.y assets-cdn.github.com z.z.z.z1 assets-cdn.github.com z.z.z.z2 assets-cdn.github.com z.z.z.z3 assets-cdn.github.com z.z.z.z3 那这几个域名…

vue3开发,axios发送请求是携带params参数的避坑

vue3开发,axios发送请求是携带params参数的避坑&#xff01;今天一直报错&#xff0c;点击新增购物车&#xff0c;报错&#xff0c; 【Uncaught (in promise) TypeError: target must be an object】。查询了网上的资料说的都不对。都没有解决。最终还是被我整明白了。 网上网…

指针的深入理解(三)

这一节主要使用复习回调函数&#xff0c; 利用冒泡模拟实现qsort函数。 qsort 排序使用冒泡排序&#xff0c;主要难点在于运用元素个数和字节数以及基地址控制元素的比较&#xff1a; if里面使用了一个判断函数&#xff0c;qsort可以排序任意的数据&#xff0c;原因就是因为可…

[工具探索]Safari 和 Google Chrome 浏览器内核差异

最近有些Vue3的项目&#xff0c;使用了safari进行测试环境搞开发&#xff0c;发现页面存在不同程序的页面乱码情况&#xff0c;反而google浏览器没问题&#xff0c;下面我们就对比下他们之间的差异点&#xff1a; 日常开发google chrome占多数&#xff1b;现在主流浏览器 Goog…

oracle 监听的主机名出现异常时候,排查放向

oracle创建监听有多种方式&#xff1a; 1、手动编写$ORACLE_HOME/network/admin/listener.ora配置文件 2、通过netmgr或者netca创建 3、通过netca静默创建 当前环境是&#xff1a; 1、/etc/hosts文件中没有对主机名进行解析 2、在oracle的.bash_profile中增加了环境变量export…

机器学习-3降低损失(Reducing Loss)

机器学习-3降低损失(Reducing Loss) 学习内容来自&#xff1a;谷歌ai学习 https://developers.google.cn/machine-learning/crash-course/framing/check-your-understanding?hlzh-cn 本文作为学习记录1.降低损失&#xff1a;迭代方法 迭代学习 下图展示了机器学习算法用于训…

Flink实战五_状态机制

接上文&#xff1a;Flink实战四_TableAPI&SQL 在学习Flink的状态机制之前&#xff0c;我们需要理解什么是状态。回顾我们之前介绍的很多流计算的计算过程&#xff0c;有些计算方法&#xff0c;比如说我们之前多次使用的将stock.txt中的一行文本数据转换成Stock股票对象的ma…

python笔记11

1、模块简介 在Python中&#xff0c;模块是一种组织代码的方式&#xff0c;允许你将相关的代码放在一个文件中&#xff0c;以便更好地组织和重用。模块可以包含变量、函数和类等。下面是关于Python模块的一些基本概念&#xff1a; 1. 创建模块 要创建一个模块&#xff0c;只…

【DB2 流浪之旅】 第一讲 Linux 环境安装 db2 数据库

DB2数据库是IBM开发的一种大型关系型数据库平台。它支持多用户或应用程序在同一条SQL 语句中查询不同database甚至不同DBMS中的数据。一般DB2是搭配IBM Power系列小机使用的&#xff0c;兼容性好、性能高。当然DB2也有Linux版本的&#xff0c;相对性能会差一些&#xff0c;主要…

【FAS Survey】《Deep learning for face anti-spoofing: A Survey》

PAMI-2022 最新成果&#xff1a;https://github.com/ZitongYu/DeepFAS 文章目录 1 Introduction & Background1.1 Face Spoofing Attacks1.2 Datasets for Face Anti-Spoofing1.3 Evaluation Metrics1.4 Evaluation Protocols 2 Deep FAS with Commercial RGB Camera2.1 H…

CF1918 D. Blocking Elements [二分+数据结构优化dp]

传送门:CF [前题提要]:二分数据结构优化dp,赛时想到了二分,想到了dp,想到了应该是某种双log的做法,但是硬是想不出正确的dp的定义,看了讲解感觉dp方程的定义还是很典的,dp题写的少是这样的… 题目要求我们输出满足所有去掉的数字和以及区间段和的最大值的最小值.不难想到使用二…

meson、ninja编译dpdk

解压目录meson编译dpdk meson buildmeson编译dpdk debug版 meson setup --buildtypedebug debugbuildmeson编译使用静态库&#xff0c;编译example meson .. --prefix/usr/local --buildtypedebugoptimized --default-librarystatic -Dexamplesallninja编译 ninjaninja安装…

springboot-前后端分离——第二篇

本篇主要介绍一个发送请求的工具—postman&#xff0c;然后对请求中的参数进行介绍&#xff0c;例如简单参数、实体参数、数组参数、集合参数、日期类型参数以及json类型参数&#xff0c;对这些参数接收进行总结。最后对响应数据进行介绍&#xff0c;使用统一响应结果返回浏览器…

在JAVA中如何使用ASCLL码

在Java中&#xff0c;你可以直接使用ASCII码来表示和处理字符。ASCII&#xff08;American Standard Code for Information Interchange&#xff0c;美国信息交换标准代码&#xff09;是一种计算机编码系统&#xff0c;用于表示英文字符为数字。ASCII码使用7位或8位二进制数表示…

轮转数组[中等]

优质博文&#xff1a;IT-BLOG-CN 一、题目 给定一个整数数组nums&#xff0c;将数组中的元素向右轮转k个位置&#xff0c;其中k是非负数。 示例 1: 输入: nums [1,2,3,4,5,6,7], k 3 输出: [5,6,7,1,2,3,4] 解释: 向右轮转 1 步: [7,1,2,3,4,5,6] 向右轮转 2 步: [6,7,1,2,…

Golang k8s相关yaml包的区别

问题背景 大概是因为 k8s 定义了一些特殊的数据类型&#xff0c;所以 k8s 对象 yaml 序列化时与其它 yaml 包结果不同。 源代码 package mainimport ("log""os""github.com/ghodss/yaml"yamlv2 "gopkg.in/yaml.v2"yamlv3 "k8s…