screenshot

近段时间flutter团队在开发impeller引擎用来替代skia引擎,同时也带来了副产物Flutter GPU

Flutter GPU是一个对底层图形接口的轻量级封装。从设计上来讲,更像vulkan,但是简化了vulkan繁琐的初始化过程。

图形学理论

你可以参考Jeanhua’s Blog了解大致渲染过程

https://www.blog.jeanhua.cn/2025/08/24/9c9ca101175f/

着色器简介

着色器是并行运行在GPU上的程序

顶点着色器

对于传入数据的每一个顶点,都会经过这个顶点着色器。也就是说,对于一个三角面,会并行计算3次。

得到顶点位置,和其他需要传入片段着色器的变量

片段着色器

处理的是面上的点,对于面上的任意一点,都会将上述顶点着色器传入的变量进行插值,然后计算颜色。

由于一个面上的点是无穷无尽的,不可能全部计算完。因此,GPU计算的其实是屏幕上对应的离散像素。

Flutter GPU 基本渲染流程

首先创建设备端的纹理(可写)用于储存渲染结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final renderTexture = gpu.gpuContext.createTexture(
gpu.StorageMode.devicePrivate,
width,
height,
enableRenderTargetUsage: true,
sampleCount: 1,
enableShaderReadUsage: true,
coordinateSystem: gpu.TextureCoordinateSystem.renderToTexture,
);
final depthTexture = gpu.gpuContext.createTexture(
gpu.StorageMode.deviceTransient,
width,
height,
format: gpu.gpuContext.defaultDepthStencilFormat,
enableRenderTargetUsage: true,
coordinateSystem: gpu.TextureCoordinateSystem.renderToTexture,
);

然后将上述纹理封装为渲染目标对象

1
2
3
4
5
6
7
final renderTarget = gpu.RenderTarget.singleColor(
gpu.ColorAttachment(texture: renderTexture),
depthStencilAttachment: gpu.DepthStencilAttachment(
texture: depthTexture,
depthClearValue: 1,
),
);

创建命令缓冲区并得到一个渲染通道

1
2
final commandBuffer = gpu.gpuContext.createCommandBuffer();
final pass = commandBuffer.createRenderPass(renderTarget);

编写顶点着色器

1
2
3
4
5
6
7
8
9
10
#version 460 core

uniform sampler2D tex;

in vec2 v_texture_coords;
in vec3 v_normal;
out vec4 frag_color;
void main() {
frag_color = texture(tex, v_texture_coords);
}

编写片段着色器 (颜色)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#version 460 core

uniform FrameInfo {
mat4 mvp;
}frame_info;
uniform Translation {
mat4 t;
}translation;

in vec3 position;
in vec3 aNormal;
in vec2 texture_coords;
out vec2 v_texture_coords;
out vec3 v_normal;


void main() {
v_texture_coords = texture_coords;
gl_Position = frame_info.mvp * translation.t * vec4(position, 1.0);
v_normal = aNormal;
}

加载着色器

1
2
3
4
5
6
7
8
//shader
final vertexShader = shaderLibrary["BaseVertex"]!;
final fragmentShader = shaderLibrary["BaseFragment"]!;
//pipeline
_pipeline = gpu.gpuContext.createRenderPipeline(
vertexShader,
fragmentShader,
);

绑定顶点

1
pass.bindVertexBuffer(vertices, 36);//array,顶点数

创建mvp矩阵并存入着色器

1
2
3
4
5
6
 _frameInfoSlot = _pipeline.vertexShader.getUniformSlot('FrameInfo');
_translationSlot = _pipeline.vertexShader.getUniformSlot('Translation');
//mvp是一系列矩阵的乘积,此处不再赘述
final mvp = ...;
pass.bindUniform(_frameInfoSlot, mvp);
pass.bindUniform(_translationSlot, t);//处理平移

加载纹理并存入着色器

1
2
3
4
5
6
7
8
9
//texture
final grassImg=imageAssets.grass;
_grassTexture = gpu.gpuContext.createTexture(
gpu.StorageMode.hostVisible, grassImg.width, grassImg.height,
enableShaderReadUsage: true
);
_grassTexture.overwrite(grassImg.data);
_texSlot = _pipeline.fragmentShader.getUniformSlot('tex');
pass.bindTexture(_texSlot, _grassTexture);

最后渲染就行了

1
2
3
4
pass.draw();
commandBuffer.submit();
final image = renderTexture.asImage();
canvas.drawImage(image, Offset.zero, Paint());

吐槽一点:flutter竟然将渲染结果储存为图片,然后再将图片绘制到canvas上,这显然是一种非常低效的做法。

地形生成

区块是一个非常重要的概念。

为了生成一个区块,我们可以在区块的四角各生成一个高度值。对于区块中的任意一点,我们都能计算出四角高度值在此处的插值(可以以距离反比作为权重)。

例如对于一个区块,其四角的高度值分别是11,58,23,211,58,23,2,那么正中间的点的高度就是(11+58+23+2)/4(11+58+23+2)/4

而对于相邻的区块,它们可以共享边界的高度值,这样就可以生成连续的地形。

由于直接使用其上方法生成的地形太过无聊,我们可以更改插值函数,用“平方插值”替换“线性插值”。更进一步,我们可以用这种方法生成一个大区块(奠定基调),然后再用同样的方法叠加小区块(提升细节),就可以生成一个还看得过去的地形了。

为了能让地形更有趣,我还为高度值添加了“从众”属性,在50%的概率下,新生成的高度值会受到旁边的高度值的影响(如果存在):如果旁边的高度值之和为正,那么新高度值也会是正数;如果为负,那么新高度值就是一个负数。(以一个水平高度为基准)

渲染优化

背面剔除

这个是图形接口自带的功能。由于我们以三角面为基础进行绘制,那么我们可以定义顶点序列的方向(例如我们认为顺时针为正面)。

当我们在图形接口中启用这个功能后,那些计算出来是反面的三角形就不会被渲染了。(严格来说是只会经过顶点着色器,不会经过片段着色器)

面剔除

由于方块数量非常多,如果每个方块都被渲染,那么那些看不见的方块就会浪费非常多的性能。

因此,我们只渲染那些被空气或者半透明材质接触的面。

更进一步,为了优化半透明材质的性能,我们假设半透明材质可以被半透明材质遮挡。例如,水方块可以遮挡水方块,但是不能遮挡空气方块。这样又能进一步提升性能。

注: 由于区块边缘的面也需要计算是否被遮挡,我们需要保证当前计算可见面的这个区块四周的区块已经被生成。

缓存机制

我们可以认为这个世界的地形是很少变化的,那么对于每一个区块,我们都可以将上述剔除面后的顶点缓存起来,下一次直接渲染这些缓存的内容即可。

光照模型

blinn-phong光照模型

这个光照模型的基本思想是:

  • 颜色 = 环境光 + 漫反射光 + 镜面反射光

环境光

环境光是指在场景中均匀分布的光,它不会被任何物体所遮挡。

1
ambient = light.ambient * material.ambient * texColor;

漫反射光

漫反射光只与面和光源有关,因为我们假设漫反射的任意反射方向的光都是相同的。

1
2
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * (diff * material.diffuse) * texColor;

镜面反射光

在blinn-phong光照模型中,镜面反射光使用的是经验公式:

1
2
3
4
5
vec3 viewDir = normalize(camera.viewPos - v_fragPos);
vec3 halfwayDir = normalize(lightDir + viewDir);
float shine = max(material.shininess, 1.0); // 确保shininess至少为1
float spec = pow(max(dot(norm, halfwayDir), 0.0), shine);
vec3 specular = light.specular * spec * material.specular;

但这里还有一个小缺陷,按理来说高光是非常亮的,会掩盖半透明材质背后的颜色。但我们直接使用了材质本身的alpha通道,因此我们需要将高光纳入alpha通道的计算

1
2
3
4
//vec3(0.299, 0.587, 0.114)是一个心理学公式,用于将RGB颜色转换为灰度颜色
float specularIntensity = dot(specular, vec3(0.299, 0.587, 0.114)); // 将高光颜色转换为灰度强度
float alphaWithSpecular = textColor4.a + pow(specularIntensity,index); // 将高光强度添加到alpha值
alphaWithSpecular = clamp(alphaWithSpecular, 0.0, 1.0); // 确保alpha值在有效范围内

其中index>1,是一个指数,用于控制高光影响alpha通道的衰减。

这样,高光特别亮的地方就看不见半透明材质背后的东西了,更符合直觉。

到这里,还有一个小缺陷,那就是一个复杂物体的表面,每一处的高光反射强度都不尽相同

因此,我们需要采用高光贴图。但受限素材缺(tou)失(lan),我们直接将纹理贴图视为高光贴图。然后更改上述提到的计算高光的代码:

1
2
3
4
float grey=clamp(dot(textColor4.rgb,vec3(0.299, 0.587, 0.114))*scale_value,0.1,1.0);
//scale_value是一个系数,用于控制高光相对于纹理颜色的强度。然后将高光系数其值限制在0-1
...existing code...
vec3 specular = light.specular * spec * material.specular * grey;

改善高光的质感

注1: shader中的uniform block是有对齐的,在传输数据时注意padding.
注2: 一定要在uniform前写layout(binding = xxx),否则会发生很奇怪的错误。

在MC中,view distance处的物体会受到雾气的影响,这不仅增加了氛围,还可以隐藏区块出现与消失的突兀感。

雾的实现比较简单,我们需要实现3个属性:雾的起始距离、雾的结束距离、雾的颜色。

就是在片段着色器中计算一下玩家到该点处的距离,然后算出线性组合系数,再与方块原来的颜色线性组合即可。

1
2
3
float fogDistance = distance(camera.viewPos, v_fragPos);
float fogFactor = smoothstep(fogStart, fogEnd, fogDistance);
vec3 fogColor = mix(texColor, fogColor, fogFactor);

太阳

顶点

为了找到太阳平面,我们要求出太阳方向向量 ddR3R^3 中的正交补空间,获得正交基向量b1,b2b_1,b_2

然后计算太阳的中心点c=[player pos]d[sun distance]c=\text{[player pos]}-d*\text{[sun distance]}(注意是减号,因为太阳光方向是向下的)

然后将中心点分别加减正交基向量乘以太阳半径就能计算出太阳的四个角(把太阳看成一个正方形)

1
2
3
4
5
6
7
final center = playerPosition - sunDirection.normalized() * sunDistance;
final du = u * sunRadius;
final dw = w * sunRadius;
final pp=(center + du + dw).storage;
final pn=(center + du - dw).storage;
final nn=(center - du - dw).storage;
final np=(center - du + dw).storage;

然后就很容易找到太阳的三角面了。

片段着色

但现在问题是太阳是圆形的,而我们渲染的是正方形。这时候我们 可以想到上文的雾。我们可以考虑使用雾的方法让太阳变成一个圆。

我们定义如下属性:

  • 太阳中心点
  • 太阳颜色
  • 衰减的起始距离
  • 衰减的结束距离
1
2
3
4
5
vec4 transparent=vec4(0,0,0,0);
vec3 toCenter=sun.center-v_fragPos;
float distance=length(toCenter);
float intensity = smoothstep(sun.edge.x, sun.edge.y,distance);
vec4 finalColor = mix( sun.color,transparent, intensity);

这样就能让太阳变成一个圆了,且与天空之间有一个渐变.

screenshot

缺陷

毕竟目前Flutter GPU还在early access阶段,所以有很多缺陷,比如:

现在只能用来开发点小玩具,没法真正用在生产环境。

参考

光照模型: https://learnopengl-cn.github.io/02 Lighting/02 Basic Lighting/

高光贴图: https://learnopengl-cn.github.io/02 Lighting/04 Lighting maps/