在高性能图形渲染中,无论是游戏开发、数据可视化还是复杂的UI,渲染效率始终是核心挑战。Flutter WebGL作为Flutter在Web平台提供高性能2D/3D渲染能力的重要途径,同样面临着传统图形API的性能瓶颈。其中,Draw Call的优化是提升渲染帧率、降低CPU消耗的关键一环。本讲座将深入探讨Flutter WebGL环境下,如何通过合并渲染批次(Batching)和动态打包几何体(Dynamic Geometry Packing)来有效减少Draw Calls,从而实现更流畅、更复杂的图形呈现。
认识 Draw Call:性能瓶颈的根源
什么是 Draw Call?
在计算机图形学中,一个“Draw Call”是CPU向GPU发出的一个指令,指示GPU绘制屏幕上的一个对象或一部分对象。这个指令通常包含绘制的几何体(顶点数据)、使用的材质(纹理、颜色、光照参数)以及渲染状态(混合模式、深度测试等)。例如,gl.drawElements() 和 gl.drawArrays() 就是典型的 Draw Call。
Draw Call 为什么昂贵?
Draw Call本身看起来只是一个简单的函数调用,但其背后涉及的开销远超想象:
- CPU 到 GPU 的状态切换开销: 每次 Draw Call 之前,CPU 需要设置大量的渲染状态,包括绑定顶点缓冲对象(VBO)、索引缓冲对象(IBO)、纹理、着色器程序,以及更新各种 uniform 变量(如模型-视图-投影矩阵、光照参数等)。这些操作都需要CPU与GPU进行通信,切换GPU的内部状态,这个过程是耗时的。
- 驱动层开销: 操作系统与图形驱动程序之间存在一个抽象层。每次 Draw Call 都需要通过驱动程序进行验证、调度和翻译,将其转换为GPU能够理解的底层指令。这个过程会引入显著的CPU开销。
- 管道刷新: 有些状态切换(如切换着色器程序)可能导致图形渲染管线的部分或全部刷新,这会打断GPU的并行执行,降低其吞吐量。
当场景中包含成百上千个小对象时,如果每个对象都发出一个独立的 Draw Call,CPU就会被大量的状态设置和驱动程序开销所压垮,形成“CPU瓶颈”,导致GPU无法全速运行,最终表现为帧率下降。
CPU-GPU 协同工作模式
理解 CPU 和 GPU 的工作模式对于优化至关重要。CPU 负责场景管理、物理模拟、动画更新、渲染准备(如剔除、排序、生成 Draw Call 列表),并将 Draw Call 发送给 GPU。GPU 则负责执行这些 Draw Call,进行顶点处理、光栅化、片元着色等工作。
理想情况下,CPU 应该能够快速地将 Draw Call 队列填充到 GPU,而 GPU 则能够持续高效地处理这些指令。如果 CPU 提交 Draw Call 的速度跟不上 GPU 处理的速度,那么 GPU 就会经常处于空闲等待状态,这就是 CPU 瓶颈。优化 Draw Call 的核心目标就是减少 CPU 在提交 Draw Call 上的时间消耗,让 CPU 能够更快地完成渲染准备工作,从而让 GPU 更充分地利用其并行处理能力。
Flutter WebGL 环境下的渲染管线
Flutter WebGL 是 Flutter 在 Web 平台上实现高性能图形渲染的一种机制。它通过 Dart 的 dart:js 库与浏览器提供的 WebGL API 进行交互,或者通过 dart:html 中的 HtmlElementView 嵌入一个 <canvas> 元素,并在其中使用 WebGL 进行渲染。
Flutter Widget Tree 与 Canvas 渲染
在传统的 Flutter 渲染中,Widget Tree 最终会被转换为一系列的 Canvas 绘制指令,这些指令由 Skia 引擎(在Web上可能是Skia WASM或CanvasKit)负责栅格化。然而,对于复杂的3D场景或高性能2D图形(如粒子系统、游戏),直接使用 Canvas API 可能无法满足性能需求,因为它通常是基于CPU的或者对GPU的利用不够直接和灵活。
WebGL 的介入
当我们需要直接利用 GPU 的强大并行处理能力时,WebGL 就登场了。通过 HtmlElementView 嵌入一个 <canvas> 元素,我们可以获得其渲染上下文(WebGLRenderingContext),然后使用 Dart 的 js 库(或更底层的 FFI,如果是在非Web平台)直接调用 WebGL API 进行绘制。
// 假设我们在一个StatefulWidget中
import 'dart:html' as html;
import 'dart:js' as js;
import 'package:flutter/material.dart';
class WebGLView extends StatefulWidget {
@override
_WebGLViewState createState() => _WebGLViewState();
}
class _WebGLViewState extends State<WebGLView> {
late html.CanvasElement _canvas;
late js.JsObject _gl; // WebGLRenderingContext
@override
void initState() {
super.initState();
_canvas = html.CanvasElement(width: 800, height: 600);
// 将Canvas元素添加到DOM中,通常通过HtmlElementView
// 但为了获取上下文,我们先创建它
// 获取WebGL上下文
var context = _canvas.getContext('webgl') ?? _canvas.getContext('webgl2');
if (context == null) {
print('WebGL not supported');
return;
}
_gl = js.JsObject.fromBrowserObject(context);
// 可以在这里进行WebGL初始化,如设置视口、清除颜色等
_gl.callMethod('clearColor', [0.0, 0.0, 0.0, 1.0]);
_gl.callMethod('clear', [_gl['COLOR_BUFFER_BIT']]);
// 启动渲染循环
html.window.requestAnimationFrame(_renderLoop);
}
void _renderLoop(num highResTime) {
// 实际的渲染逻辑
_gl.callMethod('clear', [_gl['COLOR_BUFFER_BIT'] | _gl['DEPTH_BUFFER_BIT']]);
// 绘制对象...
// 示例:一个简单的 Draw Call
// _gl.callMethod('drawArrays', [_gl['TRIANGLES'], 0, 3]);
html.window.requestAnimationFrame(_renderLoop);
}
@override
Widget build(BuildContext context) {
return HtmlElementView(viewType: 'webglCanvas', onPlatformViewCreated: (id) {
// 当PlatformView创建后,将我们的_canvas与它关联
// 这是一个简化的示例,实际中可能需要将_canvas通过js注册到PlatformView
// 详细的集成方式会更复杂,通常会有一个JavaScript辅助文件来处理DOM操作
// For example, in your web/index.html or a separate JS file
// document.getElementById('webglCanvasContainer').appendChild(_canvas);
});
}
}
注意: 上述 HtmlElementView 的集成代码是一个简化示例。在实际 Flutter Web 应用中,直接在 Dart 中操作 DOM 元素并将其插入到 HtmlElementView 内部是不可行的。通常的做法是:
- 在
web/index.html或一个独立的 JavaScript 文件中,创建一个<canvas>元素,并给它一个特定的id。 - 在 Dart 代码中,通过
platformViewRegistry.registerViewFactory注册一个工厂函数,该函数返回这个<canvas>元素。 - 在 Flutter 中使用
HtmlElementView并指定viewType来引用这个已注册的<canvas>元素。 - 然后,在 Dart 中通过
html.window.document.getElementById('yourCanvasId')获取到该CanvasElement,进而获取 WebGL 上下文。
渲染流程概览
- 资源加载: 加载模型数据(顶点、法线、UV、索引)、纹理、着色器代码。
- 初始化 WebGL: 创建着色器程序、编译着色器、链接程序。创建 VBO、IBO,上传几何体数据。创建纹理,上传图像数据。
- 渲染循环:
- 更新: 更新场景状态(动画、物理、用户输入)。
- 剔除: 移除视锥体外的对象(Frustum Culling)。
- 排序: 根据需要(如透明度)对对象进行排序。
- 批处理/打包: 合并渲染指令。
- Draw Call 提交: 设置渲染状态,发出 Draw Call。
- 缓冲区交换: 将渲染结果显示到屏幕。
我们的优化工作主要集中在“批处理/打包”阶段,以减少“Draw Call 提交”的数量。
问题:过多的 Draw Call 场景
在以下场景中,很容易产生过多的 Draw Call,导致性能问题:
- 大量独立的小对象: 例如,一个粒子系统中的数千个粒子,每个粒子都作为一个独立的 Draw Call 绘制。或者一个场景中包含大量具有相同几何体但不同变换的小石头、草地、树叶等。
- 复杂的用户界面(UI)元素: 如果Flutter WebGL被用于渲染自定义的2D UI,每个按钮、图标、文本框都可能是一个独立的渲染单元,如果设计不当,每个UI组件都可能触发一个Draw Call。
- 基于瓦片(Tile-based)的地图: 在2D游戏中,一个大地图由无数小瓦片组成。如果每个瓦片都独立绘制,Draw Call数量会爆炸式增长。
- 材质多样性: 即使是相同的几何体,如果使用了不同的纹理、着色器程序或混合模式,通常也需要独立的 Draw Call。
解决方案一:合并渲染批次 (Batching)
Batching 的核心思想是将多个小对象的渲染数据合并成一个大的缓冲区,然后通过一次 Draw Call 绘制这个大缓冲区,从而减少 Draw Call 的数量。
核心概念与工作原理
Batching 的有效性源于以下观察:
- Draw Call 的开销主要在于状态切换。 如果多个对象可以使用相同的着色器程序、纹理、混合模式和渲染状态,那么我们就可以尝试将它们的几何体数据合并起来。
- GPU 擅长处理大量顶点数据。 即使一个 Draw Call 包含数百万个顶点,GPU 也能高效处理,只要这些顶点数据能够一次性提交。
因此,Batching 的目标是:在不改变GPU渲染结果的前提下,最大化地将具有相同渲染状态的对象分组,并使用一个 Draw Call 绘制它们。
静态批处理 (Static Batching)
静态批处理适用于场景中位置、旋转、缩放等变换在运行时不会改变的对象。例如,一个大型的建筑模型,它可能由许多小部件(墙、窗户、门)组成。在加载时,我们可以将所有这些静态小部件的顶点数据合并成一个巨大的网格,然后只用一个 Draw Call 绘制这个合并后的网格。
优点: 运行时开销极低,因为合并只进行一次。
缺点: 不适用于会移动、旋转或缩放的对象。合并后的网格通常会失去原始的层次结构,对单独操作其中的小部件变得困难。
由于 Flutter WebGL 应用通常需要更动态的场景,静态批处理虽然重要,但我们更关注动态批处理。
动态批处理 (Dynamic Batching)
动态批处理适用于场景中可以移动、旋转或缩放,但共享相同材质和着色器程序的对象。它在每一帧或每当渲染状态改变时,都会收集符合条件的渲染对象,合并它们的顶点数据,然后进行绘制。
动态批处理的条件:
要将多个对象合并为一个批次进行渲染,它们通常需要满足以下所有条件:
- 相同的着色器程序 (Shader Program)。
- 相同的纹理 (Texture)。 如果使用多个纹理,需要使用纹理数组或图集。
- 相同的渲染模式 (Primitive Type)。 例如,都是
TRIANGLES。 - 相同的混合模式 (Blend Mode)。
- 其他相同的渲染状态: 如深度测试、剔除模式等。
- 几何体数据量不能过大: 合并后的顶点/索引数据不能超过WebGL的限制,也不能导致上传开销过大。
实现策略
动态批处理的实现通常涉及以下步骤:
- 定义渲染单元: 每个可渲染对象都应该有一个
Mesh(几何体数据)和一个Material(着色器、纹理、Uniforms)。 - 收集渲染请求: 在每一帧的渲染阶段,遍历所有可见对象,将它们的渲染请求添加到批处理器的队列中。
- 分组与排序: 批处理器根据渲染条件(材质ID、纹理ID、着色器程序ID等)对渲染请求进行分组。相同组内的对象可以合并。为了进一步优化,可能还需要根据深度进行排序,以减少过度绘制或处理透明物体。
- 构建合并缓冲区: 对于每个组,将组内所有对象的顶点数据(位置、法线、UV、颜色)和索引数据合并到一个大的顶点缓冲对象 (VBO) 和索引缓冲对象 (IBO) 中。由于每个对象都有自己的模型矩阵,合并时需要将每个对象的顶点数据从局部空间变换到世界空间,或者将模型矩阵作为实例数据传递。
- 单次 Draw Call: 使用一个 Draw Call(通常是
gl.drawElements)来绘制整个合并后的缓冲区。
核心数据结构与代码示例
我们来构建一个简化的 Flutter WebGL 渲染框架,并演示动态批处理的实现。
1. 基础 Mesh (网格) 类
// lib/webgl/mesh.dart
import 'dart:typed_data';
import 'dart:js';
import 'package:vector_math/vector_math.dart'; // 用于数学计算
class Mesh {
late JsObject _gl; // WebGLRenderingContext
late JsObject _vertexBuffer;
late JsObject _indexBuffer;
int _vertexCount = 0;
int _indexCount = 0;
int _vertexStride = 0; // 字节步长
// 顶点属性的偏移量和大小
Map<String, int> _attributeOffsets = {};
Mesh(JsObject gl, {
required Float32List vertices,
required Uint16List indices,
required Map<String, int> attributeLayout // e.g., {'position': 3, 'uv': 2, 'color': 4}
}) {
_gl = gl;
_vertexCount = vertices.length ~/ attributeLayout.values.reduce((a, b) => a + b);
_indexCount = indices.length;
_vertexStride = attributeLayout.values.reduce((sum, size) => sum + size) * Float32List.bytesPerElement;
int currentOffset = 0;
attributeLayout.forEach((name, size) {
_attributeOffsets[name] = currentOffset;
currentOffset += size * Float32List.bytesPerElement;
});
_vertexBuffer = _gl.callMethod('createBuffer');
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], _vertexBuffer]);
_gl.callMethod('bufferData', [_gl['ARRAY_BUFFER'], vertices, _gl['STATIC_DRAW']]);
_indexBuffer = _gl.callMethod('createBuffer');
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], _indexBuffer]);
_gl.callMethod('bufferData', [_gl['ELEMENT_ARRAY_BUFFER'], indices, _gl['STATIC_DRAW']]);
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], null]);
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], null]);
}
void bind() {
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], _vertexBuffer]);
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], _indexBuffer]);
}
void unbind() {
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], null]);
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], null]);
}
void dispose() {
_gl.callMethod('deleteBuffer', [_vertexBuffer]);
_gl.callMethod('deleteBuffer', [_indexBuffer]);
}
int get vertexCount => _vertexCount;
int get indexCount => _indexCount;
int get vertexStride => _vertexStride;
Map<String, int> get attributeOffsets => _attributeOffsets;
}
2. 材质 (Material) 类
// lib/webgl/material.dart
import 'dart:js';
import 'dart:typed_data';
class Material {
late JsObject _gl; // WebGLRenderingContext
late JsObject _program;
late JsObject? _texture; // 可以为空
late Map<String, dynamic> _uniforms;
Material(JsObject gl, String vertexShaderSource, String fragmentShaderSource, {
Map<String, dynamic>? initialUniforms,
JsObject? texture, // WebGLTexture
}) {
_gl = gl;
_texture = texture;
_uniforms = initialUniforms ?? {};
JsObject vertexShader = _compileShader(vertexShaderSource, _gl['VERTEX_SHADER']);
JsObject fragmentShader = _compileShader(fragmentShaderSource, _gl['FRAGMENT_SHADER']);
_program = _gl.callMethod('createProgram');
_gl.callMethod('attachShader', [_program, vertexShader]);
_gl.callMethod('attachShader', [_program, fragmentShader]);
_gl.callMethod('linkProgram', [_program]);
if (!_gl.callMethod('getProgramParameter', [_program, _gl['LINK_STATUS']])) {
String info = _gl.callMethod('getProgramInfoLog', [_program]);
_gl.callMethod('deleteProgram', [_program]);
throw Exception('Could not compile WebGL program: $info');
}
}
JsObject _compileShader(String source, int type) {
JsObject shader = _gl.callMethod('createShader', [type]);
_gl.callMethod('shaderSource', [shader, source]);
_gl.callMethod('compileShader', [shader]);
if (!_gl.callMethod('getShaderParameter', [shader, _gl['COMPILE_STATUS']])) {
String info = _gl.callMethod('getShaderInfoLog', [shader]);
_gl.callMethod('deleteShader', [shader]);
throw Exception('Could not compile shader: $info');
}
return shader;
}
void use() {
_gl.callMethod('useProgram', [_program]);
if (_texture != null) {
_gl.callMethod('activeTexture', [_gl['TEXTURE0']]);
_gl.callMethod('bindTexture', [_gl['TEXTURE_2D'], _texture]);
_gl.callMethod('uniform1i', [_gl.callMethod('getUniformLocation', [_program, 'u_sampler']), 0]);
}
_uniforms.forEach((name, value) {
JsObject uniformLocation = _gl.callMethod('getUniformLocation', [_program, name]);
if (uniformLocation != null) {
// 根据uniform类型调用不同的设置方法
if (value is Matrix4) {
_gl.callMethod('uniformMatrix4fv', [uniformLocation, false, value.storage]);
} else if (value is Vector3) {
_gl.callMethod('uniform3fv', [uniformLocation, value.storage]);
} else if (value is double || value is int) {
_gl.callMethod('uniform1f', [uniformLocation, value]);
}
// TODO: 添加更多 uniform 类型支持
}
});
}
void setUniform(String name, dynamic value) {
_uniforms[name] = value;
}
JsObject get program => _program;
void dispose() {
_gl.callMethod('deleteProgram', [_program]);
if (_texture != null) {
_gl.callMethod('deleteTexture', [_texture]);
}
}
}
3. 可渲染对象 (Renderable) 类
// lib/webgl/renderable.dart
import 'package:vector_math/vector_math.dart';
import 'mesh.dart';
import 'material.dart';
class Renderable {
Mesh mesh;
Material material;
Matrix4 modelMatrix; // 局部变换矩阵
Renderable({
required this.mesh,
required this.material,
Matrix4? modelMatrix,
}) : modelMatrix = modelMatrix ?? Matrix4.identity();
}
4. 批处理器 (Batcher) 类
这是实现动态批处理的核心。它负责收集 Renderable 对象,根据材质分组,合并它们的几何体数据,并最终进行一次 Draw Call。
// lib/webgl/batcher.dart
import 'dart:typed_data';
import 'dart:js';
import 'package:vector_math/vector_math.dart';
import 'mesh.dart';
import 'material.dart';
import 'renderable.dart';
class Batcher {
late JsObject _gl;
// 存储待渲染的Renderable对象
final List<Renderable> _renderables = [];
// 临时缓冲区用于合并顶点和索引数据
Float32List? _batchVertices;
Uint16List? _batchIndices;
// WebGL 缓冲区
late JsObject _batchVertexBuffer;
late JsObject _batchIndexBuffer;
// 当前批次的材质和着色器程序
Material? _currentMaterial;
JsObject? _currentProgram;
// 当前批次的顶点和索引计数
int _currentVertexOffset = 0; // 在_batchVertices中的浮点数偏移
int _currentIndexOffset = 0; // 在_batchIndices中的索引偏移
int _currentVertexCount = 0; // 实际使用的顶点数量
int _currentBatchDrawCount = 0; // 批次中实际要绘制的索引数量
// 批处理的最大顶点和索引数量(可配置)
static const int MAX_BATCH_VERTICES = 10000;
static const int MAX_BATCH_INDICES = 15000; // 假设每个面3个索引
Batcher(JsObject gl) {
_gl = gl;
_batchVertexBuffer = _gl.callMethod('createBuffer');
_batchIndexBuffer = _gl.callMethod('createBuffer');
// 预分配最大缓冲区,以减少运行时重新分配的开销
_batchVertices = Float32List(MAX_BATCH_VERTICES * (3 + 2 + 4)); // pos(3), uv(2), color(4)
_batchIndices = Uint16List(MAX_BATCH_INDICES);
}
void addRenderable(Renderable renderable) {
_renderables.add(renderable);
}
void _flushBatch(Matrix4 viewProjectionMatrix) {
if (_currentBatchDrawCount == 0 || _currentMaterial == null || _currentProgram == null) {
return; // 没有可绘制的内容
}
_currentMaterial!.use();
_currentMaterial!.setUniform('u_viewProjectionMatrix', viewProjectionMatrix);
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], _batchVertexBuffer]);
_gl.callMethod('bufferSubData', [_gl['ARRAY_BUFFER'], 0, _batchVertices!.sublist(0, _currentVertexOffset)]);
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], _batchIndexBuffer]);
_gl.callMethod('bufferSubData', [_gl['ELEMENT_ARRAY_BUFFER'], 0, _batchIndices!.sublist(0, _currentIndexOffset)]);
// 假设所有批处理的Mesh都遵循 {'position': 3, 'uv': 2, 'color': 4} 的布局
// 这里的属性位置需要根据你的着色器来定义
int positionLoc = _gl.callMethod('getAttribLocation', [_currentProgram, 'a_position']);
int uvLoc = _gl.callMethod('getAttribLocation', [_currentProgram, 'a_uv']);
int colorLoc = _gl.callMethod('getAttribLocation', [_currentProgram, 'a_color']);
int stride = (3 + 2 + 4) * Float32List.bytesPerElement; // pos(3) + uv(2) + color(4) = 9 floats
if (positionLoc != -1) {
_gl.callMethod('enableVertexAttribArray', [positionLoc]);
_gl.callMethod('vertexAttribPointer', [
positionLoc, 3, _gl['FLOAT'], false, stride, 0 // offset 0
]);
}
if (uvLoc != -1) {
_gl.callMethod('enableVertexAttribArray', [uvLoc]);
_gl.callMethod('vertexAttribPointer', [
uvLoc, 2, _gl['FLOAT'], false, stride, 3 * Float32List.bytesPerElement // offset 3 floats
]);
}
if (colorLoc != -1) {
_gl.callMethod('enableVertexAttribArray', [colorLoc]);
_gl.callMethod('vertexAttribPointer', [
colorLoc, 4, _gl['FLOAT'], false, stride, (3 + 2) * Float32List.bytesPerElement // offset 5 floats
]);
}
_gl.callMethod('drawElements', [_gl['TRIANGLES'], _currentBatchDrawCount, _gl['UNSIGNED_SHORT'], 0]);
if (positionLoc != -1) _gl.callMethod('disableVertexAttribArray', [positionLoc]);
if (uvLoc != -1) _gl.callMethod('disableVertexAttribArray', [uvLoc]);
if (colorLoc != -1) _gl.callMethod('disableVertexAttribArray', [colorLoc]);
// 重置批次状态
_currentVertexOffset = 0;
_currentIndexOffset = 0;
_currentVertexCount = 0;
_currentBatchDrawCount = 0;
_currentMaterial = null;
_currentProgram = null;
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], null]);
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], null]);
}
void render(Matrix4 viewProjectionMatrix) {
// 1. 排序:根据材质和着色器程序进行分组,实现Draw Call最小化
// 简单的排序策略:按材质的hashCode排序,确保相同材质的对象连续
_renderables.sort((a, b) {
int materialComparison = a.material.program.hashCode.compareTo(b.material.program.hashCode);
if (materialComparison != 0) return materialComparison;
// 如果材质相同,可以进一步按纹理ID排序
return a.material._texture.hashCode.compareTo(b.material._texture.hashCode);
});
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], _batchVertexBuffer]);
_gl.callMethod('bufferData', [_gl['ARRAY_BUFFER'], _batchVertices!.buffer, _gl['DYNAMIC_DRAW']]);
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], _batchIndexBuffer]);
_gl.callMethod('bufferData', [_gl['ELEMENT_ARRAY_BUFFER'], _batchIndices!.buffer, _gl['DYNAMIC_DRAW']]);
for (var renderable in _renderables) {
// 检查是否需要开始新批次
bool materialChanged = _currentMaterial == null ||
_currentMaterial!.program != renderable.material.program ||
_currentMaterial!._texture != renderable.material._texture;
// 检查当前批次是否已满
bool batchFull = (_currentVertexCount + renderable.mesh.vertexCount > MAX_BATCH_VERTICES) ||
(_currentBatchDrawCount + renderable.mesh.indexCount > MAX_BATCH_INDICES);
if (materialChanged || batchFull) {
_flushBatch(viewProjectionMatrix); // 绘制当前批次并开始新批次
_currentMaterial = renderable.material;
_currentProgram = renderable.material.program;
}
// 将当前Renderable的几何体数据添加到批次中
// 注意:这里的Mesh需要有统一的顶点属性布局
// 假定Mesh的顶点属性是 [pos.x, pos.y, pos.z, uv.s, uv.t, color.r, color.g, color.b, color.a]
// 获取Renderable的局部顶点数据
renderable.mesh.bind(); // 绑定以获取数据 (实际应该直接从Mesh对象获取)
// ⚠️ 真实的WebGl Mesh类通常不直接提供原始顶点数据,而是维护VBO。
// 为了批处理,Mesh需要提供一种方式来访问或重新生成其原始顶点数据。
// 在这个例子中,为了简化,假设我们可以直接访问其原始的Float32List。
// 实际实现中,通常会在Mesh创建时就存储一份原始数据副本,或者提供一个方法来导出。
// 假设Mesh构造函数中传入的vertices参数是可访问的
// 这里为了演示,我们直接使用一个虚拟的`getVerticesData`方法
// 实际代码中,你需要从Mesh的原始数据中读取。
Float32List meshVertices = _getMeshVerticesData(renderable.mesh); // 假设能获取到原始数据
Uint16List meshIndices = _getMeshIndicesData(renderable.mesh); // 假设能获取到原始数据
// 检查缓冲区是否足够大
if (_currentVertexOffset + meshVertices.length > _batchVertices!.length ||
_currentIndexOffset + meshIndices.length > _batchIndices!.length) {
// 如果单个对象都无法放入批次,或者批次容量不足以容纳下一个对象
// 这表示MAX_BATCH_VERTICES/MAX_BATCH_INDICES可能设置得太小
// 或者需要更大的动态缓冲区
_flushBatch(viewProjectionMatrix); // 尝试刷新并重新开始
if (_currentVertexOffset + meshVertices.length > _batchVertices!.length ||
_currentIndexOffset + meshIndices.length > _batchIndices!.length) {
print('Error: Mesh too large for batcher or buffer overflow, skipping renderable.');
continue;
}
}
// 将局部顶点变换到世界空间,并复制到批处理缓冲区
Matrix4 modelMatrix = renderable.modelMatrix;
for (int i = 0; i < meshVertices.length; i += renderable.mesh.vertexStride ~/ Float32List.bytesPerElement) {
Vector3 position = Vector3(meshVertices[i], meshVertices[i+1], meshVertices[i+2]);
position.applyMatrix4(modelMatrix); // 变换顶点位置
_batchVertices![_currentVertexOffset + 0] = position.x;
_batchVertices![_currentVertexOffset + 1] = position.y;
_batchVertices![_currentVertexOffset + 2] = position.z;
_batchVertices![_currentVertexOffset + 3] = meshVertices[i + 3]; // u
_batchVertices![_currentVertexOffset + 4] = meshVertices[i + 4]; // v
_batchVertices![_currentVertexOffset + 5] = meshVertices[i + 5]; // r
_batchVertices![_currentVertexOffset + 6] = meshVertices[i + 6]; // g
_batchVertices![_currentVertexOffset + 7] = meshVertices[i + 7]; // b
_batchVertices![_currentVertexOffset + 8] = meshVertices[i + 8]; // a
_currentVertexOffset += renderable.mesh.vertexStride ~/ Float32List.bytesPerElement;
}
// 复制索引数据,并根据当前批次的顶点偏移量进行调整
for (int i = 0; i < meshIndices.length; i++) {
_batchIndices![_currentIndexOffset + i] = meshIndices[i] + _currentVertexCount;
}
_currentIndexOffset += meshIndices.length;
_currentBatchDrawCount += meshIndices.length;
_currentVertexCount += renderable.mesh.vertexCount;
}
_flushBatch(viewProjectionMatrix); // 绘制剩余的批次
_renderables.clear(); // 清空渲染列表
}
// 辅助方法,假设Mesh内部存储了原始数据
// 实际情况你需要修改Mesh类以提供这些数据
Float32List _getMeshVerticesData(Mesh mesh) {
// ⚠️ 这是一个占位符,实际需要从mesh对象获取其原始顶点数据
// 在Mesh类中可以添加一个`_originalVertices`字段来存储
throw UnimplementedError('Mesh must provide a way to get its raw vertex data for batching.');
}
Uint16List _getMeshIndicesData(Mesh mesh) {
// ⚠️ 这是一个占位符,实际需要从mesh对象获取其原始索引数据
// 在Mesh类中可以添加一个`_originalIndices`字段来存储
throw UnimplementedError('Mesh must provide a way to get its raw index data for batching.');
}
void dispose() {
_gl.callMethod('deleteBuffer', [_batchVertexBuffer]);
_gl.callMethod('deleteBuffer', [_batchIndexBuffer]);
}
}
Vertex Shader (示例)
// lib/webgl/shaders/basic_vertex.glsl
attribute vec3 a_position;
attribute vec2 a_uv;
attribute vec4 a_color;
uniform mat4 u_viewProjectionMatrix;
varying vec2 v_uv;
varying vec4 v_color;
void main() {
gl_Position = u_viewProjectionMatrix * vec4(a_position, 1.0);
v_uv = a_uv;
v_color = a_color;
}
Fragment Shader (示例)
// lib/webgl/shaders/basic_fragment.glsl
precision mediump float;
uniform sampler2D u_sampler;
varying vec2 v_uv;
varying vec4 v_color;
void main() {
vec4 texColor = texture2D(u_sampler, v_uv);
gl_FragColor = texColor * v_color; // 将纹理颜色与顶点颜色混合
}
_getMeshVerticesData 和 _getMeshIndicesData 的实现补充:
为了让 Batcher 能够访问原始的顶点和索引数据,我们需要修改 Mesh 类,让它存储这些数据。
// lib/webgl/mesh.dart (修改后的 Mesh 类)
// ... (imports remain the same)
class Mesh {
late JsObject _gl; // WebGLRenderingContext
late JsObject _vertexBuffer;
late JsObject _indexBuffer;
int _vertexCount = 0;
int _indexCount = 0;
int _vertexStride = 0; // 字节步长
// 顶点属性的偏移量和大小
Map<String, int> _attributeOffsets = {};
// 新增:存储原始顶点和索引数据
final Float32List _originalVertices;
final Uint16List _originalIndices;
Mesh(JsObject gl, {
required Float32List vertices,
required Uint16List indices,
required Map<String, int> attributeLayout // e.g., {'position': 3, 'uv': 2, 'color': 4}
}) : _originalVertices = vertices, _originalIndices = indices { // 初始化原始数据
_gl = gl;
_vertexCount = vertices.length ~/ attributeLayout.values.reduce((a, b) => a + b);
_indexCount = indices.length;
_vertexStride = attributeLayout.values.reduce((sum, size) => sum + size) * Float32List.bytesPerElement;
int currentOffset = 0;
attributeLayout.forEach((name, size) {
_attributeOffsets[name] = currentOffset;
currentOffset += size * Float32List.bytesPerElement;
});
_vertexBuffer = _gl.callMethod('createBuffer');
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], _vertexBuffer]);
_gl.callMethod('bufferData', [_gl['ARRAY_BUFFER'], vertices, _gl['STATIC_DRAW']]);
_indexBuffer = _gl.callMethod('createBuffer');
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], _indexBuffer]);
_gl.callMethod('bufferData', [_gl['ELEMENT_ARRAY_BUFFER'], indices, _gl['STATIC_DRAW']]);
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], null]);
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], null]);
}
// ... (bind, unbind, dispose 方法不变)
// 新增:提供原始数据的访问器
Float32List get originalVertices => _originalVertices;
Uint16List get originalIndices => _originalIndices;
// ... (getters remain the same)
}
// 现在 Batcher 中的辅助方法可以这样实现:
// Float32List _getMeshVerticesData(Mesh mesh) => mesh.originalVertices;
// Uint16List _getMeshIndicesData(Mesh mesh) => mesh.originalIndices;
Instancing (实例渲染)
动态批处理的另一种高效形式是实例渲染 (Instancing)。当场景中存在大量具有相同几何体但不同位置、旋转、缩放或颜色的对象时,Instancing 是一个更优的选择。它不是将所有几何体数据合并到一个大缓冲区中,而是只上传一次几何体数据,然后将每个实例的变换信息(如模型矩阵、颜色)作为“实例属性”传递给着色器。
核心思想:
- 几何体只上传一次。
- 实例数据(Per-Instance Data) 通过特殊的顶点属性传递。
- 单个 Draw Call:
gl.drawArraysInstanced()或gl.drawElementsInstanced()。
实现 Instancing:
-
顶点着色器:
- 需要额外的
attribute来接收实例数据,例如a_instanceModelMatrix和a_instanceColor。 gl_InstanceID是一个内置变量,表示当前正在渲染的实例的索引。
- 需要额外的
-
设置实例属性:
- 创建新的 VBO 来存储每个实例的变换矩阵、颜色等数据。
- 使用
gl.vertexAttribDivisor()设置这些属性的“除数”。如果除数是1,表示每个实例使用一个新的值;如果除数是0(默认),表示每个顶点使用一个新的值。
Instancing Vertex Shader 示例:
// lib/webgl/shaders/instanced_vertex.glsl
attribute vec3 a_position;
attribute vec2 a_uv;
// attribute vec4 a_color; // 如果每个实例有不同颜色,可以从这里移除,放到实例属性中
attribute mat4 a_instanceModelMatrix; // 每个实例一个模型矩阵
attribute vec4 a_instanceColor; // 每个实例一个颜色
uniform mat4 u_viewProjectionMatrix;
varying vec2 v_uv;
varying vec4 v_color;
void main() {
// 顶点位置通过实例模型矩阵变换到世界空间,再通过视图投影矩阵变换到裁剪空间
gl_Position = u_viewProjectionMatrix * a_instanceModelMatrix * vec4(a_position, 1.0);
v_uv = a_uv;
v_color = a_instanceColor; // 使用实例颜色
}
Instancing Renderer 核心逻辑:
// 假设我们有一个 InstancedRenderable 类
class InstancedRenderable {
Mesh mesh;
Material material; // 材质可能包含纹理
List<Matrix4> modelMatrices; // 多个实例的变换矩阵
List<Vector4> colors; // 多个实例的颜色
InstancedRenderable({
required this.mesh,
required this.material,
required this.modelMatrices,
required this.colors,
});
}
// ... 在某个渲染循环中 ...
class Instancer {
late JsObject _gl;
late JsObject _instanceMatrixBuffer;
late JsObject _instanceColorBuffer;
Instancer(JsObject gl) {
_gl = gl;
_instanceMatrixBuffer = _gl.callMethod('createBuffer');
_instanceColorBuffer = _gl.callMethod('createBuffer');
}
void renderInstanced(InstancedRenderable instancedObject, Matrix4 viewProjectionMatrix) {
if (instancedObject.modelMatrices.isEmpty) return;
// 使用材质
instancedObject.material.use();
instancedObject.material.setUniform('u_viewProjectionMatrix', viewProjectionMatrix);
// 绑定基础几何体
instancedObject.mesh.bind();
// 上传实例模型矩阵
Float32List instanceMatricesData = Float32List(instancedObject.modelMatrices.length * 16);
for (int i = 0; i < instancedObject.modelMatrices.length; i++) {
instanceMatricesData.setAll(i * 16, instancedObject.modelMatrices[i].storage);
}
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], _instanceMatrixBuffer]);
_gl.callMethod('bufferData', [_gl['ARRAY_BUFFER'], instanceMatricesData, _gl['DYNAMIC_DRAW']]);
// 上传实例颜色
Float32List instanceColorsData = Float32List(instancedObject.colors.length * 4);
for (int i = 0; i < instancedObject.colors.length; i++) {
instanceColorsData.setAll(i * 4, instancedObject.colors[i].storage);
}
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], _instanceColorBuffer]);
_gl.callMethod('bufferData', [_gl['ARRAY_BUFFER'], instanceColorsData, _gl['DYNAMIC_DRAW']]);
// 设置顶点属性
int positionLoc = _gl.callMethod('getAttribLocation', [instancedObject.material.program, 'a_position']);
int uvLoc = _gl.callMethod('getAttribLocation', [instancedObject.material.program, 'a_uv']);
// 设置基础几何体的属性 (每个顶点一个值)
if (positionLoc != -1) {
_gl.callMethod('enableVertexAttribArray', [positionLoc]);
_gl.callMethod('vertexAttribPointer', [
positionLoc, 3, _gl['FLOAT'], false, instancedObject.mesh.vertexStride, instancedObject.mesh.attributeOffsets['position']!
]);
_gl.callMethod('vertexAttribDivisor', [positionLoc, 0]); // 0表示每个顶点更新
}
if (uvLoc != -1) {
_gl.callMethod('enableVertexAttribArray', [uvLoc]);
_gl.callMethod('vertexAttribPointer', [
uvLoc, 2, _gl['FLOAT'], false, instancedObject.mesh.vertexStride, instancedObject.mesh.attributeOffsets['uv']!
]);
_gl.callMethod('vertexAttribDivisor', [uvLoc, 0]); // 0表示每个顶点更新
}
// 设置实例属性 (每个实例一个值)
int instanceMatrixLoc = _gl.callMethod('getAttribLocation', [instancedObject.material.program, 'a_instanceModelMatrix']);
int instanceColorLoc = _gl.callMethod('getAttribLocation', [instancedObject.material.program, 'a_instanceColor']);
// 模型矩阵是mat4,需要4个vec4属性位置
if (instanceMatrixLoc != -1) {
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], _instanceMatrixBuffer]);
for (int i = 0; i < 4; i++) {
_gl.callMethod('enableVertexAttribArray', [instanceMatrixLoc + i]);
_gl.callMethod('vertexAttribPointer', [
instanceMatrixLoc + i, 4, _gl['FLOAT'], false, 16 * Float32List.bytesPerElement, i * 4 * Float32List.bytesPerElement
]);
_gl.callMethod('vertexAttribDivisor', [instanceMatrixLoc + i, 1]); // 1表示每个实例更新
}
}
if (instanceColorLoc != -1) {
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], _instanceColorBuffer]);
_gl.callMethod('enableVertexAttribArray', [instanceColorLoc]);
_gl.callMethod('vertexAttribPointer', [
instanceColorLoc, 4, _gl['FLOAT'], false, 4 * Float32List.bytesPerElement, 0
]);
_gl.callMethod('vertexAttribDivisor', [instanceColorLoc, 1]); // 1表示每个实例更新
}
// 绘制实例
_gl.callMethod('drawElementsInstanced', [
_gl['TRIANGLES'], instancedObject.mesh.indexCount, _gl['UNSIGNED_SHORT'], 0, instancedObject.modelMatrices.length
]);
// 清理
instancedObject.mesh.unbind();
// 禁用所有启用的属性
if (positionLoc != -1) _gl.callMethod('disableVertexAttribArray', [positionLoc]);
if (uvLoc != -1) _gl.callMethod('disableVertexAttribArray', [uvLoc]);
if (instanceMatrixLoc != -1) {
for (int i = 0; i < 4; i++) _gl.callMethod('disableVertexAttribArray', [instanceMatrixLoc + i]);
}
if (instanceColorLoc != -1) _gl.callMethod('disableVertexAttribArray', [instanceColorLoc]);
_gl.callMethod('bindBuffer', [_gl['ARRAY_BUFFER'], null]);
_gl.callMethod('bindBuffer', [_gl['ELEMENT_ARRAY_BUFFER'], null]);
}
void dispose() {
_gl.callMethod('deleteBuffer', [_instanceMatrixBuffer]);
_gl.callMethod('deleteBuffer', [_instanceColorBuffer]);
}
}
Instancing 需要 WebGL 扩展 ANGLE_instanced_arrays,在 WebGL2 中是内置的。在 WebGL1 中需要通过 _gl.getExtension('ANGLE_instanced_arrays') 获取扩展对象并调用其方法。
解决方案二:Geometry 的动态打包 (Dynamic Geometry Packing)
“动态打包几何体”通常指的是将多个几何体的数据(顶点、索引、UV等)在运行时合并成一个更大的几何体,并将其作为单个网格进行绘制。这与动态批处理有重叠,但更强调在数据层面进行合并,而不是仅仅通过实例渲染。它主要包括纹理图集(Texture Atlas)和运行时网格聚合(Runtime Mesh Aggregation)。
纹理图集 (Texture Atlas)
纹理图集是将多个小纹理(如精灵图、UI图标)合并到一个大纹理中。其目的是减少纹理绑定(Texture Binding)的 Draw Call 开销。每次切换纹理都会导致一个 Draw Call 的状态切换。如果所有对象都使用同一个大纹理图集,那么可以极大地减少纹理切换次数。
工作原理:
- 创建图集: 在加载时或预处理阶段,将多个小图片打包成一个大图片。
- 记录 UV 坐标: 对于图集中的每个小图片,记录它在大图集中的位置和大小,通常表示为归一化的 UV 坐标范围。
- 修改顶点 UV: 渲染时,不再直接使用小图片的原始 UV 坐标,而是使用它在图集中的 UV 范围来调整顶点的 UV 坐标。
Texture Atlas 类示例:
// lib/webgl/texture_atlas.dart
import 'dart:js';
import 'dart:html' as html;
class TextureAtlas {
late JsObject _gl;
late JsObject _texture; // WebGLTexture
int _width = 0;
int _height = 0;
// 存储每个子纹理的UV信息
final Map<String, List<double>> _uvRects = {}; // {name: [u0, v0, u1, v1]}
TextureAtlas(JsObject gl) {
_gl = gl;
_texture = _gl.callMethod('createTexture');
}
// 从ImageElement加载并构建图集
// 简化示例:假设images是一个包含多个html.ImageElement的列表
// 并且我们已经预先计算好它们在图集中的位置和大小
Future<void> loadFromImages(List<html.ImageElement> images, Map<String, List<int>> layout) async {
// 假设我们有一个 Canvas 来绘制所有图片到图集
html.CanvasElement tempCanvas = html.CanvasElement();
// 计算图集总大小
// 这里需要一个更复杂的算法来打包纹理,或者假设布局已给出
int atlasWidth = 0;
int atlasHeight = 0;
layout.forEach((name, rect) {
atlasWidth = html.max(atlasWidth, rect[0] + rect[2]);
atlasHeight = html.max(atlasHeight, rect[1] + rect[3]);
});
tempCanvas.width = atlasWidth;
tempCanvas.height = atlasHeight;
html.CanvasRenderingContext2D ctx = tempCanvas.getContext('2d') as html.CanvasRenderingContext2D;
for (var entry in layout.entries) {
String name = entry.key;
List<int> rect = entry.value; // [x, y, width, height]
html.ImageElement? img;
try {
img = images.firstWhere((element) => element.src!.contains(name)); // 简单匹配
} catch (e) {
print('Image for $name not found in provided list.');
continue;
}
ctx.drawImageScaledFromSource(img, 0, 0, img.width!, img.height!, rect[0], rect[1], rect[2], rect[3]);
// 计算并存储归一化UV坐标
double u0 = rect[0] / atlasWidth;
double v0 = rect[1] / atlasHeight;
double u1 = (rect[0] + rect[2]) / atlasWidth;
double v1 = (rect[1] + rect[3]) / atlasHeight;
_uvRects[name] = [u0, v0, u1, v1];
}
_width = atlasWidth;
_height = atlasHeight;
_gl.callMethod('bindTexture', [_gl['TEXTURE_2D'], _texture]);
_gl.callMethod('texImage2D', [
_gl['TEXTURE_2D'], 0, _gl['RGBA'], _gl['RGBA'], _gl['UNSIGNED_BYTE'], tempCanvas
]);
_gl.callMethod('texParameteri', [_gl['TEXTURE_2D'], _gl['TEXTURE_WRAP_S'], _gl['CLAMP_TO_EDGE']]);
_gl.callMethod('texParameteri', [_gl['TEXTURE_2D'], _gl['TEXTURE_WRAP_T'], _gl['CLAMP_TO_EDGE']]);
_gl.callMethod('texParameteri', [_gl['TEXTURE_2D'], _gl['TEXTURE_MIN_FILTER'], _gl['LINEAR_MIPMAP_LINEAR']]);
_gl.callMethod('texParameteri', [_gl['TEXTURE_2D'], _gl['TEXTURE_MAG_FILTER'], _gl['LINEAR']]);
_gl.callMethod('generateMipmap', [_gl['TEXTURE_2D']]);
_gl.callMethod('bindTexture', [_gl['TEXTURE_2D'], null]);
}
List<double>? getUVRect(String name) {
return _uvRects[name];
}
JsObject get texture => _texture;
void dispose() {
_gl.callMethod('deleteTexture', [_texture]);
}
}
// Sprite 类,使用纹理图集中的一部分
class Sprite {
TextureAtlas atlas;
String spriteName;
List<double> _uvCoords; // [u0, v0, u1, v1]
Sprite({required this.atlas, required this.spriteName})
: _uvCoords = atlas.getUVRect(spriteName)! {
if (_uvCoords.isEmpty) {
throw ArgumentError('Sprite $spriteName not found in atlas.');
}
}
// 获取归一化UV坐标
List<double> get uvCoords => _uvCoords;
// 根据原始 UV (0-1) 和 Sprite 的 UV 范围,计算出最终的图集 UV
List<double> calculateAtlasUVs(double originalU, double originalV) {
double u = _uvCoords[0] + originalU * (_uvCoords[2] - _uvCoords[0]);
double v = _uvCoords[1] + originalV * (_uvCoords[3] - _uvCoords[1]);
return [u, v];
}
}
在 Batcher 中,如果使用了纹理图集,那么所有使用该图集的对象都可以被批处理,因为它们共享同一个纹理。在构建 _batchVertices 时,需要根据 Sprite 的 calculateAtlasUVs 方法来调整顶点的 UV 坐标。
运行时网格聚合 (Runtime Mesh Aggregation)
运行时网格聚合是指在每一帧或每当一组对象需要渲染时,将其几何体数据合并到一个新的、临时的 Mesh 对象中。这通常适用于:
- Tilemap 渲染: 将屏幕上可见的所有瓦片合并成一个大网格。
- 复杂的UI组件: 将多个小UI元素的几何体合并成一个大UI网格。
- 某些粒子系统: 将固定形状的粒子几何体合并。
这与前面动态批处理中的“构建合并缓冲区”步骤非常相似,但可能更强调生成一个全新的 Mesh 对象,而不是仅仅更新一个预分配的缓冲区。
MeshCombiner 类示例:
// lib/webgl/mesh_combiner.dart
import 'dart:typed_data';
import 'package:vector_math/vector_math.dart';
import 'mesh.dart';
class CombinedMesh {
Float32List vertices;
Uint16List indices;
Map<String, int> attributeLayout; // 合并后的布局,应与所有源Mesh一致
CombinedMesh({
required this.vertices,
required this.indices,
required this.attributeLayout,
});
}
class MeshCombiner {
static CombinedMesh combine(List<Renderable> renderables) {
if (renderables.isEmpty) {
return CombinedMesh(vertices: Float32List(0), indices: Uint16List(0), attributeLayout: {});
}
// 假设所有renderables的mesh都具有相同的属性布局
Map<String, int> commonAttributeLayout = renderables.first.mesh.attributeOffsets.map(
(key, value) => MapEntry(key, value ~/ Float32List.bytesPerElement) // 转换为浮点数单位
);
int commonVertexFloatCount = commonAttributeLayout.values.reduce((a, b) => a + b); // 每个顶点的浮点数数量
int totalVertices = 0;
int totalIndices = 0;
for (var r in renderables) {
totalVertices += r.mesh.vertexCount;
totalIndices += r.mesh.indexCount;
}
Float32List combinedVertices = Float32List(totalVertices * commonVertexFloatCount);
Uint16List combinedIndices = Uint16List(totalIndices);
int currentVertexOffset = 0; // 浮点数偏移
int currentIndexOffset = 0; // 索引偏移
int vertexBaseIndex = 0; // 当前合并的起始顶点索引
for (var r in renderables) {
Float32List sourceVertices = r.mesh.originalVertices;
Uint16List sourceIndices = r.mesh.originalIndices;
Matrix4 modelMatrix = r.modelMatrix;
// 复制并转换顶点数据
int sourceVertexFloatStride = r.mesh.vertexStride ~/ Float32List.bytesPerElement;
for (int i = 0; i < sourceVertices.length; i += sourceVertexFloatStride) {
// 假设布局是 pos(3), uv(2), color(4)
Vector3 position = Vector3(sourceVertices[i], sourceVertices[i+1], sourceVertices[i+2]);
position.applyMatrix4(modelMatrix);
combinedVertices[currentVertexOffset + 0] = position.x;
combinedVertices[currentVertexOffset + 1] = position.y;
combinedVertices[currentVertexOffset + 2] = position.z;
combinedVertices[currentVertexOffset + 3] = sourceVertices[i + 3]; // u
combinedVertices[currentVertexOffset + 4] = sourceVertices[i + 4]; // v
combinedVertices[currentVertexOffset + 5] = sourceVertices[i + 5]; // r
combinedVertices[currentVertexOffset + 6] = sourceVertices[i + 6]; // g
combinedVertices[currentVertexOffset + 7] = sourceVertices[i + 7]; // b
combinedVertices[currentVertexOffset + 8] = sourceVertices[i + 8]; // a
// ⚠️ 如果有法线,也需要变换法线:normal.applyMatrix3(modelMatrix.getNormalMatrix());
// 并且需要考虑是否归一化。
currentVertexOffset += commonVertexFloatCount;
}
// 复制并调整索引数据
for (int i = 0; i < sourceIndices.length; i++) {
combinedIndices[currentIndexOffset + i] = sourceIndices[i] + vertexBaseIndex;
}
currentIndexOffset += sourceIndices.length;
vertexBaseIndex += r.mesh.vertexCount;
}
return CombinedMesh(
vertices: combinedVertices,
indices: combinedIndices,
attributeLayout: commonAttributeLayout.map(
(key, value) => MapEntry(key, value * Float32List.bytesPerElement) // 转换为字节单位
),
);
}
}
使用 MeshCombiner 的流程是:
- 收集需要合并的
Renderable对象。 - 调用
MeshCombiner.combine()生成一个CombinedMesh。 - 使用
CombinedMesh的数据创建一个新的Mesh对象(一次性 VBO/IBO 上传)。 - 用一个 Draw Call 绘制这个新的
Mesh。 - 在下一帧,如果这些对象位置发生变化,重复上述步骤。
这种方法在CPU侧有数据复制和变换的开销,但减少了GPU侧的 Draw Call 数量。适用于:
- 局部动态但整体静态的组合: 例如,一个由多个部分组成的敌人,它的所有部分作为一个整体移动,但内部部分相对位置不变。
- 可见性变化不频繁的大量小对象: 如一个屏幕上的瓦片地图,当视口移动时,重新生成可见瓦片的合并网格。
性能考量与权衡
Draw Call 优化并非免费午餐,它涉及CPU和GPU之间的权衡。
| 优化策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 动态批处理 | 减少 Draw Call;GPU效率高;易于实现。 | CPU开销(数据复制、变换、上传)可能变高;需要统一材质。 | 大量小对象,共享材质,变换不同。 |
| 实例渲染 | 极低 CPU 开销;极高 GPU 效率;几何体只上传一次。 | 仅适用于相同几何体,变换不同;需要着色器支持;WebGL1需扩展。 | 大量相同模型实例(如树、草、粒子)。 |
| 纹理图集 | 减少纹理绑定切换;提升批处理能力。 | 内存占用可能增加;UV计算复杂;图集管理。 | 2D精灵、UI图标、字体。 |
| 网格聚合 | 减少 Draw Call;GPU效率高。 | CPU开销(顶点变换、数据合并)显著;数据量大时,VRAM占用高。 | 静态/半静态物体组、Tilemap。 |
权衡点:
- CPU 开销 vs. GPU 开销: Batching 和 Packing 将 Draw Call 的 CPU 瓶颈转移到 CPU 上的数据准备瓶颈。如果数据合并和转换的 CPU 时间超过了 Draw Call 的开销,那么优化反而会带来负面效果。需要进行性能分析,找到瓶颈所在。
- 内存使用: 合并后的 VBO/IBO 可能会变得非常大,占用更多 VRAM 和系统内存。对于内存受限的设备(如移动端),需要仔细管理缓冲区大小。
- 复杂性: 实现一个健壮的批处理器和纹理图集管理器需要投入大量工程时间。
- 动态性: 如果对象频繁改变材质、纹理、着色器,或者几何体本身发生变化,那么批处理的效率会大大降低,甚至无法进行。
- 排序: 为了实现批处理,通常需要对渲染对象进行排序。这会引入额外的CPU开销。不透明物体通常按材质排序,透明物体通常按深度排序。
何时不进行批处理/打包:
- 对象数量很少: 如果场景中只有几十个对象,Draw Call 开销可能不是瓶颈,Batching 的 CPU 开销可能更高。
- 对象材质差异大: 如果每个对象都使用独特的材质、纹理或着色器,Batching 无法进行。
- 几何体频繁变化: 如果顶点数据每一帧都在变化,动态批处理需要频繁地更新 VBO,这可能比多个 Draw Call 更慢。
Flutter 集成考量
在 Flutter WebGL 环境中实现这些优化策略,除了上述 WebGL 编程细节,还需要考虑 Flutter 的生命周期和 Dart 与 JavaScript 的互操作性。
dart:js 互操作
前面所有的代码示例都使用了 dart:js 库与 JavaScript 的 WebGL API 进行交互。这要求开发者熟悉 WebGL API 的 JavaScript 接口,并将其映射到 Dart。
示例:
// Dart code
JsObject gl = js.JsObject.fromBrowserObject(canvas.getContext('webgl'));
gl.callMethod('clearColor', [0.0, 0.0, 0.0, 1.0]);
这里的 gl.callMethod('clearColor', ...) 就是通过 dart:js 调用 JavaScript 的 gl.clearColor() 函数。
HtmlElementView 和资源生命周期
当使用 HtmlElementView 嵌入 WebGL Canvas 时,你需要管理好 WebGL 资源的生命周期:
- 创建: 在
initState或onPlatformViewCreated中创建 WebGL 上下文、缓冲区、着色器等资源。 - 渲染循环: 使用
html.window.requestAnimationFrame驱动渲染循环。 - 销毁: 在
dispose方法中,务必释放所有 WebGL 资源(gl.deleteBuffer,gl.deleteTexture,gl.deleteProgram等),避免内存泄漏。当HtmlElementView被移除时,其底层的 Canvas 元素及其 WebGL 上下文可能不会自动清理。
@override
void dispose() {
_batcher.dispose(); // 销毁批处理器的VBO/IBO
// 销毁所有Mesh、Material、Texture Atlas等创建的WebGL资源
// ...
super.dispose();
}
状态管理
在一个复杂的 Flutter 应用中,你可能需要一个专门的渲染管理器来协调 Flutter Widget 状态与 WebGL 渲染状态。例如,当一个 Widget 的属性改变时(如位置、颜色),它应该通知渲染管理器,然后渲染管理器更新对应的 Renderable 对象,并在下一帧进行重绘。
性能分析
在 Flutter WebGL 中,可以通过浏览器的开发者工具(Performance, Memory 标签页)来分析 CPU 和 GPU 的性能。
- CPU 瓶颈: 如果 JS 主线程(Dart编译后)在频繁进行垃圾回收、对象创建、数据复制或频繁的
callMethod调用,可能是 CPU 瓶颈。 - GPU 瓶颈: 如果 GPU 使用率高,但帧率不理想,可能是 GPU 瓶颈(如过度绘制、复杂着色器)。
- Draw Call 统计: 有些浏览器扩展或 WebGL 调试工具可以显示当前的 Draw Call 数量。
进一步的优化途径
除了上述的批处理和几何体打包,还有一些高级优化技术可以进一步提升渲染性能:
- 视锥体剔除 (Frustum Culling): 在将对象加入批处理队列之前,先检查它们是否在摄像机的视锥体内。不在视锥体内的对象无需渲染,从而减少数据处理量。
- 遮挡剔除 (Occlusion Culling): 移除被其他不透明物体完全遮挡的对象。这比视锥体剔除更复杂,通常需要 GPU 查询或软件实现。
- LOD (Level of Detail): 对于距离摄像机较远的对象,使用简化版几何体,减少顶点数量。
- 统一缓冲区对象 (UBO) / 着色器存储缓冲区对象 (SSBO): 在 WebGL2 中可用,允许一次性将大量 Uniform 数据(如多个对象的小矩阵)上传到 GPU,减少 Uniform 绑定开销。这与 Instancing 类似,但更通用。
- 延迟渲染 (Deferred Rendering): 适用于大量光源的场景,将光照计算推迟到深度和法线信息可用之后,但实现复杂。
优化 Draw Call:让 Flutter WebGL 绽放光彩
Draw Call 优化是 Flutter WebGL 高性能渲染的基石。通过理解 Draw Call 的开销、CPU-GPU 的协同机制,并策略性地应用合并渲染批次(动态批处理、实例渲染)和动态打包几何体(纹理图集、运行时网格聚合),我们能够显著降低 CPU 负担,提升 GPU 效率。这使得 Flutter WebGL 不仅能够构建美观的界面,更能驾驭复杂、动态的3D场景和高性能2D应用,为用户带来流畅而沉浸式的体验。优化是一个持续的过程,需要结合具体的应用场景,进行深入的性能分析和迭代。