用 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.rhkb.cn/news/247191.html

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

相关文章

JVM性能分析工具——Arthas及火焰图的使用

Arthas的使用 Arthas常用命令Arthas的安装Linux压测工具Apache Bench安装火焰图的使用火焰图如何分析火焰图的互动 Arthas常用命令 help &#xff1a;查看所有命令dashboard &#xff1a;仪表板&#xff0c;查看线程的CPU信息等heapdump &#xff1a;不同类对象占用内存比重&a…

python算法与数据结构---滑动窗口双指针

学习目标 了解滑动窗口的基本原理&#xff1b;学会用使用python语言解答滑动窗口经典题目&#xff1b;了解双指针的基本原理&#xff1b;学会使用python语言解答双指针经典题目&#xff1b; 滑动窗口 209. 长度最小的子数组 https://leetcode.cn/problems/minimum-size-sub…

初识人工智能,一文读懂机器学习之逻辑回归知识文集(6)

&#x1f3c6;作者简介&#xff0c;普修罗双战士&#xff0c;一直追求不断学习和成长&#xff0c;在技术的道路上持续探索和实践。 &#x1f3c6;多年互联网行业从业经验&#xff0c;历任核心研发工程师&#xff0c;项目技术负责人。 &#x1f389;欢迎 &#x1f44d;点赞✍评论…

深入理解TCP网络协议(1)

目录 1.TCP协议的段格式 2.TCP原理 2.1确认应答 2.2超时重传 3.三次握手(重点) 4.四次挥手 1.TCP协议的段格式 我们先来观察一下TCP协议的段格式图解: 源/目的端口号:标识数据从哪个进程来,到哪个进程去 32位序号/32位确认号:TCP会话的每一端都包含一个32位&#xff08…

linux 的nobody是什么用户? 对安全有没有影响?

目录 一、nobody是不是可疑用户&#xff1f; 二、Linux系统中的nobody用户&#xff1f; 三、有nobody用户存在&#xff0c;安全吗&#xff1f; 一、nobody是不是可疑用户&#xff1f; 在Linux系统中&#xff0c;nobody是一个特殊的用户账户&#xff0c;通常用于运行系统服务…

UE5在VisualStudio升级后产生C++无法编译的问题

往期的虚幻引擎项目在VS更新后&#xff0c;编译时会报错&#xff0c;这一般出现在VS升级之后&#xff0c;UE对于VC的编译器定位没有更新导致&#xff1b; 有出现如下问题&#xff1a; 问题1&#xff1a; Running I:/EPCI/Epic Games/UE_5.3/Engine/Build/BatchFiles/Build.ba…

C++设计模式介绍:优雅编程的艺术

物以类聚 人以群分 文章目录 简介为什么有设计模式&#xff1f; 设计模式七大原则单一职责原则&#xff08;Single Responsibility Principle - SRP&#xff09;开放封闭原则&#xff08;Open/Closed Principle - OCP&#xff09;里氏替换原则&#xff08;Liskov Substitution …

市场复盘总结 20240124

仅用于记录当天的市场情况&#xff0c;用于统计交易策略的适用情况&#xff0c;以便程序回测 短线核心&#xff1a;不参与任何级别的调整&#xff0c;采用龙空龙模式 昨日主题投资 连板进级率 19/43 28.2% 二进三&#xff1a; 进级率低 47% 最常用的二种方法&#xff1a; 方…

【数据结构四】栈与Stack详解

目录 栈与Stack 1.实现一个自己的栈 2.Stack的基本使用 3.栈的一些oj题训练 4.栈&#xff0c;虚拟机栈&#xff0c;栈帧的区别 栈与Stack 栈 &#xff1a;一种特殊的线性表&#xff0c;其 只允许在固定的一端进行插入和删除元素操作 。进行数据插入和删除操作的一端称为栈顶…

【音视频原理】音频编解码原理 ③ ( 音频 比特率 / 码率 | 音频 帧 / 帧长 | 音频 帧 采样排列方式 - 交错模式 和 非交错模式 )

文章目录 一、音频 比特率 / 码率1、音频 比特率2、音频 比特率 案例3、音频 码率4、音频 码率相关因素5、常见的 音频 码率6、视频码率 - 仅做参考 二、音频 帧 / 帧长1、音频帧2、音频 帧长度 三、音频 帧 采样排列方式 - 交错模式 和 非交错模式1、交错模式2、非交错模式 一…

微信小程序-04

rpx&#xff08;responsive pixel&#xff09;是微信小程序独有的&#xff0c;用来解决屏适配的尺寸单位。 import 后跟需要导入的外联样式表的相对路径&#xff0c;用 ; 表示语句结束。 定义在 app.wxss 中的样式为全局样式&#xff0c;作用于每一个页面。 在页面的 .wxss 文…

薅运营商羊毛?封杀!

最近边小缘在蓝点网上看到一则消息 “浙江联通也开始严格排查PCDN和PT等大流量行为 被检测到可能会封停宽带”。 此前中国联通已经在四川和上海等多个省市严查家庭宽带 (部分企业宽带也被查) 使用 PCDN 或 PT&#xff0c;当用户的宽带账户存在大量上传数据的情况&#xff0c;中…

【USTC】verilog 习题练习 46-50

46 上升沿检测 题目描述 在实际应用中&#xff0c;我们经常需要对某个信号的边沿进行检测&#xff0c;并以此作为后续动作的触发信号&#xff08;例如电脑键盘的某个按键被按下或者被松开&#xff0c;在电路中则对应的是电平的变化&#xff09;。 设计一个电路&#xff0c;包…

幻兽帕鲁服务器价格,这个价格不够电费的

幻兽帕鲁服务器价格多少钱&#xff1f;4核16G服务器Palworld官方推荐配置&#xff0c;阿里云4核16G服务器32元1个月、96元3个月&#xff0c;腾讯云换手帕服务器服务器4核16G14M带宽66元一个月、277元3个月&#xff0c;8核32G22M配置115元1个月、345元3个月&#xff0c;16核64G3…

QT之 QDebug 调试(一)

在QT中&#xff0c;进行调试&#xff0c;则需要在头文件地方加上 #include <QDebug> 加上之后&#xff0c;在编译之后则其输出的信息则在应用程序输出那里显示信息。 其QDebug 信息调试则如&#xff1a; qDebug() << " 需要插入的信息 "…

2000-2022年上市公司全要素生产率测算固定效应FE法(含原始数据+测算代码do文档+计算结果)

2000-2022年上市公司全要素生产率测算固定效应FE法&#xff08;含原始数据测算代码do文档计算结果&#xff09; 1、时间&#xff1a;2000-2022年 2、范围&#xff1a;上市公司 3、指标&#xff1a;证券代码、证券简称、统计截止日期、固定资产净额、year、股票简称、报表类型…

C# RichTextBox常用属性、方法学习1

1 字体 Font font1 new Font("宋体", 18); richTextBox1.Font font1; Font font2 new Font("宋体", 10, FontStyle.Underline); richTextBox1.SelectionFont font2; 定义字体&#xff0c;可以带2个参数&#…

服务端开发小记03——vsftpd

这里写目录标题 vsftpd简介vsftpd在Linux下的安装vsftpd验证vsftpd常用命令 vsftpd简介 vsftpd是“very secure FTP daemon”的缩写&#xff0c;是一个用于Linux环境下的免费开源的ftp服务器软件。vsftpd在Linux发行版中最受推崇&#xff0c;小巧轻快&#xff0c;安全易用&…

(黑马出品_01)SpringCloud+RabbitMQ+Docker+Redis+搜索+分布式

&#xff08;黑马出品_01&#xff09;SpringCloudRabbitMQDockerRedis搜索分布式 微服务技术栈导学 1.认识微服务1.1.学习目标1.2.单体架构1.3.分布式架构1.4.微服务1.5.SpringCloud1.6.总结 2.服务拆分和远程调用2.1.服务拆分原则2.2.服务拆分示…

深度强化学习(王树森)笔记07

深度强化学习&#xff08;DRL&#xff09; 本文是学习笔记&#xff0c;如有侵权&#xff0c;请联系删除。本文在ChatGPT辅助下完成。 参考链接 Deep Reinforcement Learning官方链接&#xff1a;https://github.com/wangshusen/DRL 源代码链接&#xff1a;https://github.c…