用 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
,及 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) {// 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 组件封装,最后构建出可交互的地图应用。
相关文章:

用 CanvasKit 实现超级丝滑的原神地图(已开源)!!!
首先给大家送上预览地址: 官网地址:https://webstatic.mihoyo.com/ys/app/interactive-map/index.html canvaskit地址:http://106.55.55.247/ky-genshin-map/ 为什么 canvaskit 有如此高的性能? 第一个问题,官方网页…...

MySQL原理(三)锁定机制(2)表锁行锁与页锁
前面提到,mysql锁按照操作颗粒分类,一般认为有表级锁、行级锁、页面锁三种。其实还有一种特殊的全局锁。 锁场景问题全局锁全库逻辑备份加了全局锁之后,整个数据库都是【只读状态】,如果数据库里有很多数据,备份就会花…...

设计模式⑩ :用类来实现
文章目录 一、前言二、Command 模式1. 介绍2.应用3. 总结 三、Interpreter 模式1. 介绍2. 应用3. 总结 参考文章 一、前言 有时候不想动脑子,就懒得看源码又不像浪费时间所以会看看书,但是又记不住,所以决定开始写"抄书"系列。本系…...
Flutter 解决ExpansionTile上下分割线问题,以及title撑满问题
文章目录 前言一、解决上下分割线问题二、使ExpansionTile的title撑满总结 前言 最近在做flutter项目,其中的一个功能用到了ExpansionTile的效果,奈何我们的设计师要求很高,展开的时候不能有上下一根线,而且我们是不需要展开的按…...

数据可视化 pycharts实现时间数据可视化
自用版 数据格式为: 运行效果为: from pyecharts import options as opts from pyecharts.charts import Polar, Page import csv filename "./hot-dog-places.csv" data_x [] data_y [] with open(filename) as f:reader csv.reade…...

深度强化学习(王树森)笔记11
深度强化学习(DRL) 本文是学习笔记,如有侵权,请联系删除。本文在ChatGPT辅助下完成。 参考链接 Deep Reinforcement Learning官方链接:https://github.com/wangshusen/DRL 源代码链接:https://github.c…...

python 实现 macOS状态栏 网速实时显示
安装依赖包: pip install pillow psutil rumpsnetSpeedApp.py from PIL import Image, ImageDraw, ImageFont import psutil import rumpsclass NetSpeedApp(rumps.App):def __init__(self):super(NetSpeedApp, self).__init__("NetSpeed")self.titlese…...

【C++】开源:Windows图形库EasyX配置与使用
😏★,:.☆( ̄▽ ̄)/$:.★ 😏 这篇文章主要介绍Windows图形库EasyX配置与使用。 无专精则不能成,无涉猎则不能通。——梁启超 欢迎来到我的博客,一起学习,共同进步。 喜欢的朋友可以关注一下&#…...
微信小程序 全局变量键值对map对象
在微信小程序中,键值对的map对象通常用于存储和操作键值对的集合。以下是一些常见的操作: 创建map对象 在JavaScript中,可以通过对象字面量语法或者使用new Map()来创建map对象 // 使用对象字面量 var map {key1: value1,key2: value2 };…...

20240131在WIN10下配置whisper
20240131在WIN10下配置whisper 2024/1/31 18:25 首先你要有一张NVIDIA的显卡,比如我用的PDD拼多多的二手GTX1080显卡。【并且极其可能是矿卡!】800¥ 2、请正确安装好NVIDIA最新的545版本的驱动程序和CUDA。 2、安装Torch 3、配置whisper http…...

3338 蓝桥杯 wyz的数组IV 简单
3338 蓝桥杯 wyz的数组IV 简单 //C风格解法1,通过率50% #include<bits/stdc.h>int main(){std::ios::sync_with_stdio(false);std::cin.tie(nullptr);std::cout.tie(nullptr);int n; std::cin >> n;int ans 0;std::vector<int>a(n);for(auto &am…...
git Filename too long
git Filename too long 原因: 文件名限制260长度 解决:全局配置git git config --system core.longpaths true查看: git config --get core.longpaths...

MySQL数据库-理论基础
1.1 什么是数据库 数据: 描述事物的符号记录, 可以是数字、 文字、图形、图像、声音、语言等,数据有多种形式,它们都可以经过数字化后存入计算机。 数据库: 存储数据的仓库,是长期存放在计算机内、有组织…...

立体边界,让arcgis出图更酷炫一些
就是这样子的那个图—— 本期我们还是用长沙市为例, 来手把手的演示制作立体边界, 就是这个样子的边界—— 第一步—准备底图 其实你准备什么底图都可以哈,例如调用天地图、下载个影像图,或者用其他什么的底图,都是…...

【C++】 C++入门—内联函数
C入门 1 内联函数1.1 定义1.2 查看方式1.3 注意 Thanks♪(・ω・)ノ谢谢阅读下一篇文章见!!! 1 内联函数 1.1 定义 程序在执行一个函数前需要做准备工作:要将实参、局部变量、返回地址以及若干寄存…...
软件工程知识梳理2-需求分析
需求分析时软件定义的最后一个阶段,它的基本任务时准确回答系统必须做什么的问题。 输出:本阶段必须的输出时软件需求规格说明书。 角色:需求分析员 参与者:用户、需求分析员 需求分析遵循的准则: 必须理解并描述问…...

mac裁剪图片
今天第一次用mac裁剪图片,记录一下过程,差点我还以为我要下载photoshop了, 首先准备好图片 裁剪的目的是把图片的标题给去掉,但是不能降低分辨率,否则直接截图就可以了 解决办法 打开原始图片(不要使用预览…...
Compose | UI组件(十) | Box,Surface - 帧布局
文章目录 前言Box 组件的参数说明Box 组件的使用Surface 的参数说明Surface 的使用 总结 前言 Box组件是 按子组件依次叠加 的布局组件,相当传统View中的 FrameLayout Box 组件的参数说明 Composable inline fun Box(modifier: Modifier Modifier, …...

种草日记|林曦老师的冬日好物分享
冬天将尽春天就要来了,换季的时候最容易引起皮肤干燥、头发毛躁不舒服的问题,今天就来说说林曦老师推荐的冬日护理爱用好物。大家都要“如婴儿乎”,照顾好自己哦~ 1、Aco甘油保湿霜 Aco甘油保湿霜好大一罐&#x…...

【算法与数据结构】139、LeetCode单词拆分
文章目录 一、题目二、解法三、完整代码 所有的LeetCode题解索引,可以看这篇文章——【算法和数据结构】LeetCode题解。 一、题目 二、解法 思路分析:本题可以看做一个动态规划问题。其中,字符串s是背包,而字典中的单词就是物品。…...

Lombok 的 @Data 注解失效,未生成 getter/setter 方法引发的HTTP 406 错误
HTTP 状态码 406 (Not Acceptable) 和 500 (Internal Server Error) 是两类完全不同的错误,它们的含义、原因和解决方法都有显著区别。以下是详细对比: 1. HTTP 406 (Not Acceptable) 含义: 客户端请求的内容类型与服务器支持的内容类型不匹…...
CVPR 2025 MIMO: 支持视觉指代和像素grounding 的医学视觉语言模型
CVPR 2025 | MIMO:支持视觉指代和像素对齐的医学视觉语言模型 论文信息 标题:MIMO: A medical vision language model with visual referring multimodal input and pixel grounding multimodal output作者:Yanyuan Chen, Dexuan Xu, Yu Hu…...
树莓派超全系列教程文档--(62)使用rpicam-app通过网络流式传输视频
使用rpicam-app通过网络流式传输视频 使用 rpicam-app 通过网络流式传输视频UDPTCPRTSPlibavGStreamerRTPlibcamerasrc GStreamer 元素 文章来源: http://raspberry.dns8844.cn/documentation 原文网址 使用 rpicam-app 通过网络流式传输视频 本节介绍来自 rpica…...
DockerHub与私有镜像仓库在容器化中的应用与管理
哈喽,大家好,我是左手python! Docker Hub的应用与管理 Docker Hub的基本概念与使用方法 Docker Hub是Docker官方提供的一个公共镜像仓库,用户可以在其中找到各种操作系统、软件和应用的镜像。开发者可以通过Docker Hub轻松获取所…...

安宝特方案丨XRSOP人员作业标准化管理平台:AR智慧点检验收套件
在选煤厂、化工厂、钢铁厂等过程生产型企业,其生产设备的运行效率和非计划停机对工业制造效益有较大影响。 随着企业自动化和智能化建设的推进,需提前预防假检、错检、漏检,推动智慧生产运维系统数据的流动和现场赋能应用。同时,…...

STM32标准库-DMA直接存储器存取
文章目录 一、DMA1.1简介1.2存储器映像1.3DMA框图1.4DMA基本结构1.5DMA请求1.6数据宽度与对齐1.7数据转运DMA1.8ADC扫描模式DMA 二、数据转运DMA2.1接线图2.2代码2.3相关API 一、DMA 1.1简介 DMA(Direct Memory Access)直接存储器存取 DMA可以提供外设…...

DIY|Mac 搭建 ESP-IDF 开发环境及编译小智 AI
前一阵子在百度 AI 开发者大会上,看到基于小智 AI DIY 玩具的演示,感觉有点意思,想着自己也来试试。 如果只是想烧录现成的固件,乐鑫官方除了提供了 Windows 版本的 Flash 下载工具 之外,还提供了基于网页版的 ESP LA…...
【RockeMQ】第2节|RocketMQ快速实战以及核⼼概念详解(二)
升级Dledger高可用集群 一、主从架构的不足与Dledger的定位 主从架构缺陷 数据备份依赖Slave节点,但无自动故障转移能力,Master宕机后需人工切换,期间消息可能无法读取。Slave仅存储数据,无法主动升级为Master响应请求ÿ…...
ip子接口配置及删除
配置永久生效的子接口,2个IP 都可以登录你这一台服务器。重启不失效。 永久的 [应用] vi /etc/sysconfig/network-scripts/ifcfg-eth0修改文件内内容 TYPE"Ethernet" BOOTPROTO"none" NAME"eth0" DEVICE"eth0" ONBOOT&q…...

使用Spring AI和MCP协议构建图片搜索服务
目录 使用Spring AI和MCP协议构建图片搜索服务 引言 技术栈概览 项目架构设计 架构图 服务端开发 1. 创建Spring Boot项目 2. 实现图片搜索工具 3. 配置传输模式 Stdio模式(本地调用) SSE模式(远程调用) 4. 注册工具提…...