Frontend Development 14 min read

Building a High‑Performance Genshin Impact Map with CanvasKit, Gesture Recognition, and React/Vue Integration

This article explains why the official web map built with Leaflet suffers from poor performance, how using CanvasKit (Skia compiled to WebAssembly) together with @use-gesture and popmotion dramatically improves rendering speed and interaction smoothness, and provides a complete implementation and component wrappers for Vue and React.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Building a High‑Performance Genshin Impact Map with CanvasKit, Gesture Recognition, and React/Vue Integration

The official web map of Genshin Impact is built with Leaflet, a DOM‑centric engine; frequent DOM updates for hundreds of markers cause severe performance bottlenecks, especially when aiming for 60 Hz refresh rates.

Why CanvasKit Improves Performance

CanvasKit is the JavaScript + WebAssembly port of Skia. While the regular Canvas API offers only basic drawing primitives, CanvasKit exposes the full power of Skia, allowing batch drawing via drawAtlas instead of thousands of individual drawImage calls. This reduces draw calls dramatically and prevents frame‑time overruns.

Smooth Interaction: Gesture Recognition and Animation

Beyond raw rendering, a fluid user experience requires intuitive gesture handling and physics‑based animations. The article uses @use-gesture/vanilla for gesture detection and popmotion (specifically its inertia helper) to model damped motion.

Drag Gesture Handling

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);
    },
  });
}

Pinch (Zoom) Gesture Handling

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;
  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);
    },
  });
}

Map Rendering Engine

A Tilemap class creates a canvas, initializes CanvasKit, and drives a render loop with requestAnimationFrame . A _dirty flag ensures drawing only occurs when offset or scale changes.

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

  constructor(options) {
    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;
  }
}

Layers (e.g., TileLayer , MarkerLayer ) are abstracted via a base Layer class with a draw(canvas) method. The Tilemap maintains a sorted collection of layers and invokes each layer’s draw each frame.

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

class Layer
{
  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();
      canvas.concat(canvaskit.Matrix.invert(canvas.getTotalMatrix())!);
      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());
  }
}

Creating a map instance is straightforward:

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`;
    },
  })
);

Wrapping as React/Vue Components

To make the engine reusable in UI frameworks, the article shows Vue component wrappers that accept the same options as props, create the Tilemap via a ref , and provide it to child layer components via Vue’s provide/inject API. A similar approach works in React using Context.

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

interface TilemapProps extends Omit
{}

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

Layer components inject the parent tilemap , instantiate the appropriate core layer, add it on mount, and remove it on unmount.

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
;
  const layer = ref
();
  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;
});

The article concludes that the combination of CanvasKit for high‑performance rendering, sophisticated gesture handling, and framework‑level component wrappers yields a responsive, interactive map application, and invites readers to explore the open‑source repository qiuxiang/ky-genshin-map for deeper details.

frontendreactVueWebGLGesture RecognitionCanvasKitMap Rendering
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.