资讯动态

Flutter Impeller“Baby‘s First Triangle”实战:从 GLSL 着色器到底层渲染管线的完整走读

发布时间:2026/9/7 17:12:22 来源:尧图企业网站定制
Flutter Impeller“Babys First Triangle”实战从 GLSL 着色器到底层渲染管线的完整走读【免费下载链接】flutterFlutter makes it easy and fast to build beautiful apps for mobile and beyond项目地址: https://gitcode.com/GitHub_Trending/flutter41/flutter本篇指南基于 Impeller 官方教程Babys First Triangle带你用 Impeller 渲染栈的最底层 API 亲手画出一个红色三角形并逐步掌握 Impeller 的 HALHardware Abstraction Layer与着色器编译机制编写 GLSL 顶点/片元着色器、利用编译器生成的 C 头文件构建管线描述符、用VertexBufferBuilder填充顶点数据、以 HostBufferjumbo buffer提交 uniform 数据以及通过 varying 实现逐像素插值着色。读完本文你将能够看懂 Impeller 渲染器 API 的完整调用链并理解“着色器先行、编译器生成接口、构建期静态校验”这一 Impeller 特有的开发范式。教程定位与完整示例代码在哪原教程声明完整的代码示例位于renderer_unittests.cc的RendererTest.BabysFirstTriangle测试中它使用的是渲染栈最底层的接口Context、RenderPass、Pipeline等不经过 Impeller 上层封装。在当前仓库中需要注意一个演进该场景已被移植为 golden 测试实际实现在 renderer_golden_unittests.cc 的TEST_P(RendererGoldenTest, BabysFirstTriangle)中——它通过 golden 测试框架把渲染结果上传到 Skia Gold 做像素比对并且“shader 的 time uniform 被钉在 0 以保证 golden 确定性”见该文件 L53-L55 的注释。着色器的源文件则存放在 fixtures/baby.vert 与 fixtures/baby.frag。下文所有代码均与这份仓库实现一一对应。一、渲染管线The Pipeline在真正渲染之前必须先创建管线Pipeline。管线描述了一次光栅化作业的固定功能阶段fixed function stages与可编程阶段programmable stages。管线创建开销很大因此通常都在初始化阶段一次性创建并长期复用不要放在每帧的工作负载里否则会引入卡顿jank。1.1 两个着色器提示原文档 TIP当你决定要渲染什么时通常最好先写着色器。它会先勾勒出你将要提供给着色器的输入全貌着色器编译器随后会生成必要的接口你只需在 C 代码里把它接起来还能享受代码补全等便利。Impeller 就是希望你能以这种方式工作编译器会帮你。着色器定义了管线的可编程阶段。我们的三角形需要一个顶点着色器和一个片元着色器顶点着色器的职责把三角形的顶点变换到标准化设备坐标系NDC, Normalized Device Coordinates。光栅化器随后会把这些坐标转换到 framebuffer 中的 2D 坐标。片元着色器的职责为三角形覆盖到的每一个像素着色。顶点着色器baby.vertin vec2 position; void main() { gl_Position vec4(position, 0.0, 1.0); }这个着色器每顶点执行一次接收一个vec2并把它写成 NDC。注意这里其实没有做任何“变换”——因为我们的顶点数据本身就已经是 NDC后文会给出坐标值。由于只画一个简单的三角形需要给它 3 个顶点这将在后面的顶点数据环节处理。仓库中的 baby.vert 是教程“Extra Credit”走完之后演进到的最终形态同时携带两组颜色输入并输出为 varyingin vec2 position; in vec4 color; in vec4 color2; out vec4 v_color; out vec4 v_color2; void main() { gl_Position vec4(position, 0.0, 1.0); v_color color; v_color2 color2; }片元着色器baby.frag教程起点的最简版本每片元被三角形覆盖的每个纹素执行一次直接输出纯红色。out vec4 frag_color; void main() { frag_color vec4(1.0, 0.0, 0.0, 1.0); }仓库中 baby.frag 的演进版则接收两个 varying 颜色并用 uniformtime在两者之间往复混合完整代码见“Uniforms”一节。1.2 着色器编译器生成了什么调用着色器编译器会为每个后端生成对应的着色器产物OpenGL ES 对应 GLSL ES、Metal 对应 MSL、Vulkan 对应 SPIR-V。伴随这些产物编译器还会生成几个 C 头文件包含你运行期用这些着色器创建管线所需的接口与元数据——对我们这两个着色器而言就是baby.vert.h和baby.frag.h构建期生成需要 include 到你的编译单元中管线描述符的构建依赖它们。打开baby.vert.h里面有很多你不用关心的元数据但有一个叫PerVertexData的 struct 很值得关注struct PerVertexData { Point position; // (offset 0, size 8) }; // struct PerVertexData (size 8)编译器检测到着色器期望每顶点一个点位置而填充它正是我们运行期的职责。这个 struct 的价值在于当你修改着色器时编译器会增删、重排字段如果 GPU 侧有对齐要求编译器清楚这些细节会在字段之间加入必要的 padding——你只需要负责填 position不必关心布局。所有这些接口与元数据都封装在一个叫BabyVertexShader的结构体里baby.frag.h里则能找到类似的BabyFragmentShader。这正是 golden 测试 中#include impeller/fixtures/baby.frag.h/#include impeller/fixtures/baby.vert.h的来源随后通过using VS BabyVertexShader; using FS BabyFragmentShader;使用它们。1.3 管线描述符Pipeline Descriptor先声明着色器别名再创建一个把两个着色器组合起来、固定功能状态默认初始化的管线描述符// Declare a shorthand for the shaders we are going to use. using VS BabyVertexShader; using FS BabyFragmentShader; // Create a pipeline descriptor that uses the shaders together and default // initializes the fixed function state. // // If the vertex shader outputs disagree with the fragment shader inputs, this // will be a compile time error. auto desc PipelineBuilderVS, FS::MakeDefaultPipelineDescriptor(*context);MakeDefaultPipelineDescriptor实现见 pipeline_builder.h会为管线的固定功能阶段填入默认值视具体环境不同可能还需要对这个描述符做少量微调。注意从当前仓库的测试代码看该函数返回的是可选值ASSERT_TRUE(desc.has_value())调用方需先检查成功与否。敏锐的读者会注意到这是我们第一次把顶点阶段与片元阶段“融合”到一起。那如果两者不兼容呢比如片元阶段期望一个顶点阶段并未提供的输入编译器已经替你考虑到了通过PipelineBuilderVS, FS组装管线构建器这一动作会在**编译期构建时**静态检查两个阶段的兼容性——如果阶段不兼容C 代码直接拒绝编译。这是 Impeller 把着色器接口问题从“运行期黑盒”提升为“编译期白盒”的核心设计。万事俱备创建管线auto pipeline context-GetPipelineLibrary()-GetPipeline(desc).Get();这次调用会在后台线程上创建管线并返回一个 future但我们需要立即拿到管线.Get()就会阻塞等待管线创建完成。再次提醒管线创建昂贵所以只在初始化时做一次、并尽量长期持有绝不要在每帧工作中创建否则只会得到卡顿。1.4 顶点数据Vertex Data还记得编译器生成的PerVertexDatastruct 吗填充顶点信息、并以 GPU buffer 的形式提供给 draw call 是我们的工作。 convenientlyImpeller 提供了 C 工具VertexBufferBuilderT见 vertex_buffer_builder.h来为我们创建这个 buffer模板参数 T 就是PerVertexDatastruct。创建将要交给 draw call 的数据。别忘了我们的顶点着色器是个“摸鱼选手”它对顶点信息不做任何 NDC 变换因此必须保证数据本身已是标准化设备坐标VertexBufferBuilderVS::PerVertexData vertex_buffer_builder; vertex_buffer_builder.AddVertices({ {{-0.5, -0.5}}, {{0.0, 0.5}}, {{0.5, -0.5}}, });请顶点缓冲构建器为我们创建设备 buffer。因为我们不会逐帧修改顶点坐标所以只需一次性创建之后在每次 draw call 中反复引用同一个 buffer 即可auto vertex_buffer vertex_buffer_builder.CreateVertexBuffer( *context-GetResourceAllocator());如果以后你修改了着色器的顶点输入PerVertexDatastruct 会随之变化而你在设置顶点缓冲的地方将出现编译期错误。这样你可以确信对着色器的任何重构都会立刻暴露出所有向着色器提供顶点数据的代码位置。二、每帧绘制Draw沉重的部分你已经全部完成了。每一帧你只需要在 render pass 上设置好你已缓存的管线与顶点缓冲然后发起一次 draw call。pass.SetPipeline(pipeline); pass.SetVertexBuffer(vertex_buffer); pass.Draw();大约 10 行 C 代码加上几行简单的 GLSL你就应该能看到一个漂亮的红色三角形。编译器承担了将 GLSL 转换为 Metal Shading Language或其他后端语言的繁重工作并计算了组装配管线所需的全部元数据。上述三步与 RenderPass 的实际 API 一一对应golden 测试中的调用序列renderer_golden_unittests.cc#L86-L93正是pass.SetPipeline(pipeline); pass.SetVertexBuffer(vertex_buffer); ... return pass.Draw().ok();。三、Extra CreditVarying——让两个阶段“对话”三角形画出来了但着色器太简单顶点阶段和片元阶段之间没有任何交流。我们来演示一下除了 position还给每个顶点一个颜色然后在片元着色器中根据三角形内的位置决定如何混合各顶点的颜色贡献。第一步更新baby.vert声明额外的输入与输出in vec4 color; out vec4 v_color;在顶点着色器主体里把输入直接传给输出v_color color;在片元着色器baby.frag中声明来自上一阶段的输入in vec4 v_color;并在主体中把片元颜色设为该输入frag_color v_color;我们没有做任何颜色混合的显式计算——因为光栅化器会自动在阶段之间插值这些值。这些值随像素位置变化因此被称为 “varying”并约定用v_前缀命名。着色器到此完成。但编译器现在会警告你顶点缓冲构建器再也无法构建原来的顶点缓冲它说得对因为现在每个顶点还需要附带一个颜色。补上vertex_buffer_builder.AddVertices({ {{-0.5, -0.5}, Color::Red()}, {{0.0, 0.5}, Color::Green()}, {{0.5, -0.5}, Color::Blue()}, });现在我们应当能看到一个每个像素着色都不同的三角形。仓库中的 golden 测试renderer_golden_unittests.cc#L70-L74正是这一模式再进一步——每个顶点携带两组颜色为下一节的动画混合做准备vertex_buffer_builder.AddVertices({ {{-0.5, -0.5}, Color::Red(), Color::Green()}, {{0.0, 0.5}, Color::Green(), Color::Blue()}, {{0.5, -0.5}, Color::Blue(), Color::Red()}, });四、Extra CreditUniform——向两个阶段同时提供常量数据你现在已经是“画三角形高手”了。还有一个在 Impeller 中被大量使用的机制需要讨论uniform 数据。到目前为止你提供给着色器的数据都是提交给顶点着色器、再经过 varying 固定功能单元变换的。但也许你需要向任意一个甚至两个着色器提交“在所有调用中保持恒定”的数据——这类常量信息就叫 uniform data。先定下 demo 的形式给每个顶点提供两种颜色并根据**当前时间秒**在两者之间做动画。时间在所有片元着色器调用中是相同的因此作为 uniform 提交。给顶点着色器补上第二组颜色、并以 varying 传给片元着色器——上一节刚讲过留作读者练习。在需要当前时间秒的片元着色器中添加 uniform 块uniform FragInfo { float time; } frag_info;编译器会“贴心地”吐出一个形似FragInfo的 C struct。和PerVertexData一样编译器清楚各字段的对齐要求会按需放置正确的 padding——你只需在 C 代码中填充字段并把数据作为设备 buffer 交给 GPU。编译器生成的结果struct FragInfo { Scalar time; // (offset 0, size 4) }; // struct FragInfo (size 4)看起来很直接。为证明 padding 机制再塞入几个已知需要对齐的字段uniform FragInfo { float time; vec2 bar; vec4 baz; } frag_info;生成的 C struct 如下——注意那些你完全不用操心的 paddingstruct FragInfo { Scalar time; // (offset 0, size 4) Padding4 _PADDING_bar_; // (offset 4, size 4) Point bar; // (offset 8, size 8) Vector4 baz; // (offset 16, size 16) }; // struct FragInfo (size 32)在我们的“游乐场”里创建这个FragInfostruct 并把time设为当前秒数FS::FragInfo frag_info; frag_info.time fml::TimePoint::Now().ToEpochDelta().ToSecondsF();Host Buffer 与 Device Buffer我们需要把这段数据放进 GPU 可访问的内存。这里引入另一个工具host buffer。与可以用realloc扩容的mallocbuffer 不同Impeller 中的设备 buffer 不能调整大小同时创建大量零碎的小设备 buffer 也是坏味道。因此 Impeller 的策略是把所有数据uniform、顶点等先暂存进一个大块分配然后在 draw 时每次 draw call 引用该大块分配中特定 offset 与 length 的视图。实现这一模式的便捷方式就是HostBuffer一个在堆上分配malloc或类似的 buffer核心用法是“尽可能快地增长”。Impeller 会把整帧的所有数据暂存到这样的 buffer 中在 buffer 构建过程中指向其中各个视图offset length的信息会被记录到命令流里在 draw call 提交前不久这些信息才被上传到 GPU届时 buffer 可被复用。在 Impeller 代码库中这类 buffer 被称为jumbo buffer巨型缓冲。我们用同样的机制把 uniform 数据上传到 GPU——只是我们的 buffer 算不上“巨无霸”但足以说明 Impeller 整体是怎么运转的。把FragInfostruct 放进一个每帧新建的host buffer 并绑定到 render passauto host_buffer HostBuffer::Create(context-GetResourceAllocator()); FS::BindFragInfo(pass, host_buffer-EmplaceUniform(frag_info));实现演进提示从当前仓库的 golden 测试调用renderer_golden_unittests.cc#L78-L80看HostBuffer::Create现在需要额外传入空闲等待器与最小 uniform 对齐值HostBuffer::Create(context-GetResourceAllocator(), context-GetIdleWaiter(), context-GetCapabilities()-GetMinimumUniformAlignment())且每帧/每 pass 开始前先host_buffer-Reset()从干净状态开始对应 host_buffer.h。等等BindFragInfo是从哪冒出来的它同样由着色器编译器生成——因为编译器基于自己生成的元数据知道如何把 buffer 绑定到该阶段。如果你修改了着色器在修复所有调用点之前会得到编译错误这让着色器重构变得更安全。最后修改片元着色器把当前时间纳入考虑后在两组颜色值之间混合与仓库中 baby.frag#L15-L22 完全一致void main() { float floor floor(frag_info.time); float fract frag_info.time - floor; if (mod(int(floor), 2) 0) { fract 1.0 - fract; } frag_color mix(v_color, v_color2, fract); }到这里你应该能看到一个动画着色的三角形——整帧时间取整后的奇偶决定了混合方向从而在两组顶点颜色之间往复渐变。五、用仓库测试验证这套流程整套流程在仓库中有可运行的验证renderer_golden_unittests.cc 中的RendererGoldenTest.BabysFirstTriangle完整复刻了教程调用链并且做了两处工程化改造值得留意确定性frag_info.time 0.0f被钉死在 0而非取当前时间使 golden 像素比对结果可复现harness 语义渲染回调在OpenPlaygroundHere中每 pass 执行一次因此每次先用host_buffer-Reset()回到干净的 host buffer 再填充 uniform。文件头注释还说明了它的定位“Unlike renderer_unittests.cc, which opens an interactive playground, the tests here render through the golden harness and have their output uploaded to Skia Gold”——即这些测试通过 golden 框架渲染并上传比对结果。六、小结你已学会如何用 Impeller 底层 API 画出一个三角形、修改其顶点、在片元着色器中通过 varying 访问插值信息、以及向着色器提交 uniform 数据。你会发现自己有点恼火——不能在三角形轮廓之外涂色。那就继续加三角形吧剩下的部分Flutter 在概念上并不做任何比这更复杂的事。想继续深入的话仓库内与本文强相关的延伸阅读包括coordinate_system.mdNDC 与坐标变换体系、blending.md、shader_optimization.md 以及 glossary.md着色器 fixture 的完整目录见 fixtures/ 目录。【免费下载链接】flutterFlutter makes it easy and fast to build beautiful apps for mobile and beyond项目地址: https://gitcode.com/GitHub_Trending/flutter41/flutter创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价