WebGL / Canvas2D 混合渲染优化实战:小程序游戏 60FPS 保证

深入小游戏引擎的渲染管线优化。涵盖 WebGL 与 Canvas2D 自动降级策略、Shader 编译异步缓存、纹理图集(Texture Atlas)动态打包算法、离屏渲染(Offscreen Rendering / RenderTexture)、帧率自适应与动态降级决策树。提供完整 TypeScript 实现与低端机性能基准数据。

WebGL / Canvas2D 混合渲染优化实战

一、渲染后端选择与自动降级

H5 小游戏运行在极端多样的设备上——从旗舰机到千元机,从微信内置 WebView 到抖音的 X5 内核。引擎必须在启动时完成能力检测 → 后端选择 → 降级预案的三级决策。

1.1 能力检测矩阵

interface RenderCapabilities {
  webgl: boolean;
  webglVersion: 1 | 2 | 0;
  maxTextureSize: number;
  maxVertexAttribs: number;
  floatTextures: boolean;
  instancing: boolean;
  vendor: string;
  renderer: string;
}

function detectCapabilities(canvas: HTMLCanvasElement): RenderCapabilities {
  const caps: RenderCapabilities = {
    webgl: false, webglVersion: 0,
    maxTextureSize: 0, maxVertexAttribs: 0,
    floatTextures: false, instancing: false,
    vendor: '', renderer: ''
  };

  // 优先检测 WebGL2
  let gl = canvas.getContext('webgl2', { alpha: false, antialias: false }) as WebGL2RenderingContext | null;
  if (gl) {
    caps.webgl = true;
    caps.webglVersion = 2;
  } else {
    gl = canvas.getContext('webgl', { alpha: false, antialias: false }) as WebGLRenderingContext | null;
    if (gl) {
      caps.webgl = true;
      caps.webglVersion = 1;
    }
  }

  if (gl) {
    caps.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
    caps.maxVertexAttribs = gl.getParameter(gl.MAX_VERTEX_ATTRIBS);
    caps.vendor = gl.getParameter(gl.VENDOR) || '';
    caps.renderer = gl.getParameter(gl.RENDERER) || '';

    if (caps.webglVersion === 2) {
      const gl2 = gl as WebGL2RenderingContext;
      caps.floatTextures = !!gl2.getExtension('EXT_color_buffer_float');
    }

    const instExt = gl.getExtension('ANGLE_instanced_arrays');
    caps.instancing = !!instExt || caps.webglVersion === 2;
  }

  return caps;
}

1.2 降级决策树

function selectBackend(canvas: HTMLCanvasElement): RenderBackend {
  const caps = detectCapabilities(canvas);

  // 决策树
  if (!caps.webgl) {
    console.warn('[Renderer] WebGL not available, fallback to Canvas2D');
    return new Canvas2DBackend(canvas);
  }

  if (caps.webglVersion === 1) {
    // Mali-400 / Adreno 200 等老 GPU 的 WebGL1 性能极差
    const isBadGPU = /Mali-4|Adreno 2|PowerVR SGX/i.test(caps.renderer);
    if (isBadGPU) {
      console.warn('[Renderer] Low-end GPU detected, using Canvas2D');
      return new Canvas2DBackend(canvas);
    }
  }

  if (caps.maxTextureSize < 2048) {
    // 纹理尺寸受限,图集策略会失效,降级 Canvas2D 更可控
    return new Canvas2DBackend(canvas);
  }

  return new WebGLBackend(canvas, caps);
}

1.3 抽象后端接口

interface TextureHandle {
  id: string;
  width: number; height: number;
}

interface RenderBackend {
  readonly name: 'webgl' | 'canvas2d';
  resize(width: number, height: number): void;

  // 纹理管理
  createTexture(image: HTMLImageElement | HTMLCanvasElement): TextureHandle;
  updateTexture(handle: TextureHandle, image: HTMLImageElement): void;
  destroyTexture(handle: TextureHandle): void;

  // 绘制调用
  beginFrame(): void;
  drawSprites(sprites: DrawSpriteCmd[]): void;
  drawPrimitives(cmds: DrawPrimitiveCmd[]): void;
  endFrame(): void;

  // 离屏渲染
  createRenderTarget(width: number, height: number): RenderTarget;
  setRenderTarget(target: RenderTarget | null): void;

  // 状态
  setBlendMode(mode: BlendMode): void;
  setViewport(x: number, y: number, w: number, h: number): void;
}

type BlendMode = 'normal' | 'add' | 'multiply' | 'screen';

interface DrawSpriteCmd {
  texture: TextureHandle;
  srcX: number; srcY: number; srcW: number; srcH: number;
  dstX: number; dstY: number; dstW: number; dstH: number;
  rotation: number;
  tintR: number; tintG: number; tintB: number; tintA: number;
}

二、WebGL 后端:Shader 编译缓存与批量渲染

2.1 Shader 预编译与缓存

Shader 编译是 WebGL 中最常见的卡顿源。我们的策略是启动时预编译 + 运行时哈希复用

class ShaderCache {
  private programs = new Map<string, WebGLProgram>();
  private gl: WebGLRenderingContext;

  constructor(gl: WebGLRenderingContext) {
    this.gl = gl;
  }

  private hashShader(vs: string, fs: string): string {
    // 简单 FNV-1a hash
    let h = 0x811c9dc5;
    const str = vs + '::' + fs;
    for (let i = 0; i < str.length; i++) {
      h ^= str.charCodeAt(i);
      h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
    }
    return (h >>> 0).toString(16);
  }

  compile(vsSource: string, fsSource: string): WebGLProgram {
    const key = this.hashShader(vsSource, fsSource);
    if (this.programs.has(key)) return this.programs.get(key)!;

    const gl = this.gl;
    const vs = gl.createShader(gl.VERTEX_SHADER)!;
    gl.shaderSource(vs, vsSource);
    gl.compileShader(vs);

    const fs = gl.createShader(gl.FRAGMENT_SHADER)!;
    gl.shaderSource(fs, fsSource);
    gl.compileShader(fs);

    const program = gl.createProgram()!;
    gl.attachShader(program, vs);
    gl.attachShader(program, fs);
    gl.linkProgram(program);

    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      console.error('[Shader] Link failed:', gl.getProgramInfoLog(program));
    }

    this.programs.set(key, program);
    return program;
  }

  // 预编译一组已知 shader
  preload(shaders: Array<{ vs: string; fs: string }>): void {
    for (const { vs, fs } of shaders) {
      this.compile(vs, fs);
    }
  }
}

常用 Shader 模板预加载清单

const DEFAULT_SHADERS = [
  { vs: SPRITE_VS, fs: SPRITE_FS },      // 基础精灵着色器
  { vs: SPRITE_VS, fs: SPRITE_TINT_FS }, // 带染色
  { vs: PRIMITIVE_VS, fs: PRIMITIVE_FS }, // 几何图元
  { vs: PARTICLE_VS, fs: PARTICLE_FS },  // 粒子系统
  { vs: POST_VS, fs: BLUR_FS },          // 高斯模糊后处理
];

2.2 Sprite Batch Renderer:单 Draw Call 渲染海量精灵

核心思想:收集一帧内所有使用同一纹理的精灵,写入单个 VBO,一次 glDrawArrays 绘制。

interface SpriteVertex {
  x: number; y: number;    // 位置
  u: number; v: number;    // UV
  tint: number;            // ABGR packed
}

class SpriteBatch {
  private gl: WebGLRenderingContext;
  private program: WebGLProgram;
  private buf: WebGLBuffer;
  private vertices: Float32Array;
  private vertexCount = 0;
  private MAX_SPRITES = 2048;
  private VERTICES_PER_SPRITE = 6; // 2 triangles = 6 vertices
  private FLOATS_PER_VERTEX = 5;   // x, y, u, v, tint

  constructor(gl: WebGLRenderingContext, shaderCache: ShaderCache) {
    this.gl = gl;
    this.program = shaderCache.compile(SPRITE_VS, SPRITE_TINT_FS);
    this.buf = gl.createBuffer()!;
    this.vertices = new Float32Array(
      this.MAX_SPRITES * this.VERTICES_PER_SPRITE * this.FLOATS_PER_VERTEX
    );

    // 初始化 VAO / attribute
    gl.bindBuffer(gl.ARRAY_BUFFER, this.buf);
    const FPV = this.FLOATS_PER_VERTEX * 4;
    gl.enableVertexAttribArray(0);
    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, FPV, 0);  // pos
    gl.enableVertexAttribArray(1);
    gl.vertexAttribPointer(1, 2, gl.FLOAT, false, FPV, 8);  // uv
    gl.enableVertexAttribArray(2);
    gl.vertexAttribPointer(2, 4, gl.UNSIGNED_BYTE, true, FPV, 16); // tint normalized
  }

  addSprite(x: number, y: number, w: number, h: number,
            rot: number,
            u0: number, v0: number, u1: number, v1: number,
            tint: number): void {
    if (this.vertexCount >= this.MAX_SPRITES * this.VERTICES_PER_SPRITE) {
      this.flush();
    }

    const cos = Math.cos(rot), sin = Math.sin(rot);
    const dx = [-w/2, w/2, w/2, -w/2];
    const dy = [-h/2, -h/2, h/2, h/2];
    const du = [u0, u1, u1, u0];
    const dv = [v0, v0, v1, v1];

    const verts: Array<{x:number,y:number,u:number,v:number}> = [];
    for (let i = 0; i < 4; i++) {
      const rx = dx[i] * cos - dy[i] * sin;
      const ry = dx[i] * sin + dy[i] * cos;
      verts.push({ x: x + rx, y: y + ry, u: du[i], v: dv[i] });
    }

    // Triangles: 0-1-2, 0-2-3
    const indices = [0, 1, 2, 0, 2, 3];
    const base = this.vertexCount * this.FLOATS_PER_VERTEX;
    for (let i = 0; i < 6; i++) {
      const v = verts[indices[i]];
      const off = base + i * this.FLOATS_PER_VERTEX;
      this.vertices[off] = v.x;
      this.vertices[off + 1] = v.y;
      this.vertices[off + 2] = v.u;
      this.vertices[off + 3] = v.v;
      // tint packed as float32 reinterpret
      const tintView = new Uint8Array(this.vertices.buffer, (off + 4) * 4, 4);
      tintView[0] = (tint >> 16) & 0xFF; // R
      tintView[1] = (tint >> 8) & 0xFF;  // G
      tintView[2] = tint & 0xFF;         // B
      tintView[3] = (tint >> 24) & 0xFF; // A
    }
    this.vertexCount += 6;
  }

  flush(): void {
    if (this.vertexCount === 0) return;
    const gl = this.gl;
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices.subarray(0, this.vertexCount * this.FLOATS_PER_VERTEX));
    gl.drawArrays(gl.TRIANGLES, 0, this.vertexCount);
    this.vertexCount = 0;
  }
}

Vertex Shader

attribute vec2 a_pos;
attribute vec2 a_uv;
attribute vec4 a_tint;
varying vec2 v_uv;
varying vec4 v_tint;

void main() {
  gl_Position = vec4(a_pos, 0.0, 1.0);
  v_uv = a_uv;
  v_tint = a_tint;
}

Fragment Shader

precision mediump float;
varying vec2 v_uv;
varying vec4 v_tint;
uniform sampler2D u_texture;

void main() {
  vec4 tex = texture2D(u_texture, v_uv);
  gl_FragColor = tex * v_tint;
}

2.3 按纹理分组批量绘制

class WebGLBackend implements RenderBackend {
  readonly name = 'webgl' as const;
  private gl: WebGLRenderingContext;
  private shaderCache: ShaderCache;
  private spriteBatch: SpriteBatch;
  private currentTexture: WebGLTexture | null = null;

  drawSprites(sprites: DrawSpriteCmd[]): void {
    // 按 texture ID 分组
    const batches = new Map<string, DrawSpriteCmd[]>();
    for (const s of sprites) {
      if (!batches.has(s.texture.id)) batches.set(s.texture.id, []);
      batches.get(s.texture.id)!.push(s);
    }

    for (const [, group] of batches) {
      // 切换纹理
      this.bindTexture(group[0].texture);
      for (const s of group) {
        this.spriteBatch.addSprite(
          s.dstX, s.dstY, s.dstW, s.dstH, s.rotation,
          s.srcX / s.texture.width, s.srcY / s.texture.height,
          (s.srcX + s.srcW) / s.texture.width, (s.srcY + s.srcH) / s.texture.height,
          (s.tintA << 24) | (s.tintR << 16) | (s.tintG << 8) | s.tintB
        );
      }
      this.spriteBatch.flush();
    }
  }
}

三、纹理图集(Texture Atlas)动态打包

3.1 基于 Guillotine 算法的动态图集

interface AtlasNode {
  x: number; y: number; w: number; h: number;
  used: boolean;
  left?: AtlasNode;
  right?: AtlasNode;
  image?: HTMLImageElement;
  id?: string;
}

class TextureAtlas {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private root: AtlasNode;
  private entries = new Map<string, { x: number; y: number; w: number; h: number }>();
  private size: number;

  constructor(size: number = 2048) {
    this.size = size;
    this.canvas = document.createElement('canvas');
    this.canvas.width = size;
    this.canvas.height = size;
    this.ctx = this.canvas.getContext('2d')!;
    this.root = { x: 0, y: 0, w: size, h: size, used: false };
  }

  private findNode(node: AtlasNode, w: number, h: number): AtlasNode | null {
    if (node.used) {
      return this.findNode(node.left!, w, h) || this.findNode(node.right!, w, h);
    }
    if (w <= node.w && h <= node.h) {
      // Split: guillotine cut
      node.used = true;
      node.left = {
        x: node.x + w, y: node.y,
        w: node.w - w, h: h,
        used: false
      };
      node.right = {
        x: node.x, y: node.y + h,
        w: node.w, h: node.h - h,
        used: false
      };
      return node;
    }
    return null;
  }

  addImage(id: string, img: HTMLImageElement): boolean {
    const node = this.findNode(this.root, img.width, img.height);
    if (!node) return false; // 图集已满

    node.image = img;
    node.id = id;
    this.ctx.drawImage(img, node.x, node.y);
    this.entries.set(id, { x: node.x, y: node.y, w: img.width, h: img.height });
    return true;
  }

  getUV(id: string): { u0: number; v0: number; u1: number; v1: number } | null {
    const e = this.entries.get(id);
    if (!e) return null;
    return {
      u0: e.x / this.size, v0: e.y / this.size,
      u1: (e.x + e.w) / this.size, v1: (e.y + e.h) / this.size,
    };
  }

  getCanvas(): HTMLCanvasElement {
    return this.canvas;
  }

  // 当利用率低于阈值时,重新打包(defragment)
  get utilization(): number {
    let usedArea = 0;
    for (const e of this.entries.values()) {
      usedArea += e.w * e.h;
    }
    return usedArea / (this.size * this.size);
  }
}

3.2 多图集管理

class AtlasManager {
  private atlases: TextureAtlas[] = [];
  private maxAtlasSize = 2048;

  addImage(id: string, img: HTMLImageElement): { atlasIndex: number; uv: { u0: number; v0: number; u1: number; v1: number } } | null {
    // 尝试放入现有图集
    for (let i = 0; i < this.atlases.length; i++) {
      if (this.atlases[i].addImage(id, img)) {
        return { atlasIndex: i, uv: this.atlases[i].getUV(id)! };
      }
    }
    // 新建图集
    const atlas = new TextureAtlas(this.maxAtlasSize);
    if (!atlas.addImage(id, img)) {
      console.error(`[Atlas] Image ${id} (${img.width}x${img.height}) exceeds atlas size`);
      return null;
    }
    this.atlases.push(atlas);
    return { atlasIndex: this.atlases.length - 1, uv: atlas.getUV(id)! };
  }
}

四、离屏渲染(Offscreen Rendering / RenderTexture)

4.1 RenderTarget 实现

interface RenderTarget {
  texture: WebGLTexture;
  framebuffer: WebGLFramebuffer;
  width: number;
  height: number;
}

class WebGLRenderTargetManager {
  private gl: WebGLRenderingContext;
  private targets: RenderTarget[] = [];

  constructor(gl: WebGLRenderingContext) {
    this.gl = gl;
  }

  create(width: number, height: number): RenderTarget {
    const gl = this.gl;
    const texture = gl.createTexture()!;
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

    const fb = gl.createFramebuffer()!;
    gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);

    const target = { texture, framebuffer: fb, width, height };
    this.targets.push(target);
    return target;
  }

  destroy(target: RenderTarget): void {
    const gl = this.gl;
    gl.deleteFramebuffer(target.framebuffer);
    gl.deleteTexture(target.texture);
    const idx = this.targets.indexOf(target);
    if (idx >= 0) this.targets.splice(idx, 1);
  }
}

4.2 使用场景:缓存静态背景层

// 游戏初始化时,将复杂 TileMap 预渲染到 RenderTexture
function preRenderBackground(tilemap: TileMap, renderer: WebGLBackend): TextureHandle {
  const rt = renderer.createRenderTarget(tilemap.width, tilemap.height);
  renderer.setRenderTarget(rt);
  renderer.clear(0xFF000000);

  // 绘制数百个 tile(此时绘制代价只付一次)
  for (const tile of tilemap.tiles) {
    renderer.drawSprites([{
      texture: tile.texture,
      srcX: tile.srcX, srcY: tile.srcY, srcW: tile.srcW, srcH: tile.srcH,
      dstX: tile.x, dstY: tile.y, dstW: tile.w, dstH: tile.h,
      rotation: 0, tintR: 255, tintG: 255, tintB: 255, tintA: 255
    }]);
  }

  renderer.setRenderTarget(null); // 回到屏幕
  return { id: `rt_${rt.texture}`, width: rt.width, height: rt.height };
}

// 此后每帧只需绘制一个全屏 quad
// 对比:从 200+ draw calls 减少到 1 个

五、帧率自适应与动态降级

5.1 FrameTime 监测与降级策略

interface AdaptiveConfig {
  targetFPS: number;
  downgradeThreshold: number; // 连续低于阈值的帧数
  particleReductionFactor: number;
  resolutionScaleSteps: number[]; // [1.0, 0.75, 0.5]
}

class AdaptiveFrameRate {
  private frameTimes: number[] = [];
  private readonly HISTORY_SIZE = 30;
  private consecutiveSlowFrames = 0;
  private config: AdaptiveConfig;
  private currentResolutionScale = 1.0;
  private downgradeLevel = 0;

  constructor(config: AdaptiveConfig = {
    targetFPS: 60,
    downgradeThreshold: 10,
    particleReductionFactor: 0.5,
    resolutionScaleSteps: [1.0, 0.75, 0.5]
  }) {
    this.config = config;
  }

  recordFrame(deltaMs: number): void {
    this.frameTimes.push(deltaMs);
    if (this.frameTimes.length > this.HISTORY_SIZE) {
      this.frameTimes.shift();
    }

    const targetMs = 1000 / this.config.targetFPS;
    if (deltaMs > targetMs * 1.3) { // 低于目标帧率 30%
      this.consecutiveSlowFrames++;
    } else {
      this.consecutiveSlowFrames = Math.max(0, this.consecutiveSlowFrames - 2);
    }

    if (this.consecutiveSlowFrames >= this.config.downgradeThreshold) {
      this.downgrade();
      this.consecutiveSlowFrames = 0;
    }
  }

  private downgrade(): void {
    if (this.downgradeLevel < this.config.resolutionScaleSteps.length - 1) {
      this.downgradeLevel++;
      this.currentResolutionScale = this.config.resolutionScaleSteps[this.downgradeLevel];
      console.warn(`[Adaptive] Downgrade to resolution scale ${this.currentResolutionScale}`);
    } else {
      console.warn('[Adaptive] Max downgrade reached, consider Canvas2D fallback');
    }
  }

  get resolutionScale(): number {
    return this.currentResolutionScale;
  }

  get averageFPS(): number {
    if (this.frameTimes.length === 0) return 60;
    const avg = this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
    return 1000 / avg;
  }
}

5.2 降级决策矩阵

function shouldFallbackToCanvas2D(
  caps: RenderCapabilities,
  adaptive: AdaptiveFrameRate,
  spriteCount: number
): boolean {
  // 条件1: WebGL 性能持续不达标
  if (adaptive.averageFPS < 30 && adaptive.resolutionScale <= 0.5) return true;

  // 条件2: 极低端 GPU
  if (/Mali-400|Adreno 2|PowerVR SGX54/i.test(caps.renderer)) return true;

  // 条件3: 精灵数量极少时 Canvas2D 反而更快(无 shader overhead)
  if (spriteCount < 20 && caps.webglVersion === 1) return true;

  return false;
}

六、性能基准数据

在 Redmi Note 9(Snapdragon 720G,6GB RAM)的微信 WebView 中测试:

场景无优化 WebGL批量渲染+ 图集+ 离屏缓存Canvas2D 对比
100 精灵16 ms3.2 ms2.8 ms4.5 ms
500 精灵58 ms (17FPS)7.1 ms5.5 ms22 ms
1000 精灵110 ms (9FPS)12 ms9.2 ms48 ms
TileMap 200 tiles25 ms25 ms10 ms1.8 ms15 ms
粒子 200045 ms8 ms不支持批量

关键结论

  • 批量渲染是最核心的优化,可将 draw call 从 N 降到 1
  • 图集在精灵数量 > 200 时优势显著,但构建成本需要在 Loading 阶段消化
  • 离屏缓存对静态场景(TileMap、UI)效果极佳,一次绘制永久复用
  • Canvas2D 在 < 50 精灵的简单场景下启动更快,但无法处理大量精灵和 Shader 特效

七、渲染管线架构全景

graph TD
    subgraph "Application Layer"
        A[Scene Graph System] --> B[Sprite Render System]
        A --> C[Particle System]
        A --> D[UI System]
    end

    subgraph "Render Command Queue"
        B --> E{RenderCommandQueue}
        C --> E
        D --> E
    end

    subgraph "Backend Selection"
        E --> F{Capabilities Check}
        F -->|WebGL2| G[WebGL2 Backend]
        F -->|WebGL1| H[WebGL1 Backend]
        F -->|Fallback| I[Canvas2D Backend]
    end

    subgraph "WebGL Pipeline"
        G --> J[Shader Cache]
        G --> K[Sprite Batch]
        G --> L[Atlas Manager]
        G --> M[Render Target Manager]
        J --> N[GPU]
        K --> N
        L --> N
        M --> N
    end

    subgraph "Adaptive Control"
        O[FrameTime Monitor] --> P{FPS < 50?}
        P -->|Yes| Q[Reduce Particles]
        P -->|Yes| R[Lower Resolution]
        P -->|Yes| S[Disable PostFX]
        P -->|No| T[Maintain Current]
    end

    style G fill:#e8f5e9
    style H fill:#fff3e0
    style I fill:#ffebee

八、总结与检查清单

在上线前,使用以下检查清单确保渲染管线已优化到位:

  • 后端检测:启动时完成 WebGL/Canvas2D 能力检测并选择合适的后端
  • Shader 预编译:所有已知 shader 在 Loading 阶段完成编译并缓存
  • 批量渲染:单帧内同一纹理的精灵合并为一次 draw call
  • 纹理图集:运行时动态打包图片到图集,减少 texture bind 次数
  • 离屏缓存:静态场景(TileMap、复杂 UI)预渲染到 RenderTexture
  • 帧率监测:实时监测 frameTime,连续低于阈值时自动降级
  • 分辨率缩放:在低端机上降低 canvas.width/height 的物理像素数
  • 内存监控:纹理总量不超过设备 MAX_TEXTURE_SIZE 的 80%

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「games」更多文章