实战教程:为 Sprite 实现描边后处理与交互)
Flame 基础着色器Shader实战教程为 Sprite 实现描边后处理与交互【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame导读本教程源自 Flame 官方文档doc/tutorials/basic_shader/系列将带你从零构建一个完整的描边着色器项目通过SpriteComponent、PostProcessComponent与 GLSL 片元着色器三层协作为带透明背景的.png精灵绘制轮廓描边并让描边颜色随鼠标悬停动态变化。读完本文你将掌握 Flame 中后处理管线的架构分工、PostProcess的逐帧 uniform 更新机制、GLSL 片段着色器的采样思路以及如何把游戏输入事件与着色器状态打通——这些能力可直接复用于描边、发光、模糊、色调映射等各类屏幕级或组件级特效。前置准备一个可运行的 Flame 项目本教程假定你已经有一个配置完成的 Flame 工程。如果还没有请先跟随 bare_flame_game.md 教程创建一个基础游戏项目确保flutter run能够正常启动。随后需要准备一张带透明背景的图片例如.png格式因为本教程实现的描边算法依赖透明度信息来区分精灵轮廓与空白区域在工程根目录创建assets/images/目录把图片放入其中在pubspec.yaml的flutter:段注册资源目录flutter: assets: - assets/images/注意Flutter 只会打包在pubspec.yaml中显式声明的资源。忘记注册是新手最常见的报错来源例如Asset not found。第一步SpriteComponent 与 PostProcessComponent 的分层设计架构与职责划分为了让着色器逻辑与精灵逻辑解耦教程把渲染对象拆成两个类SpriteComponent子类负责加载图片资源、设置尺寸并在后续步骤中挂载输入事件 mixinPostProcessComponent子类作为父组件包裹精灵负责应用后处理着色器。这种壳与核分离的设计带来的直接收益是改动着色器只需编辑包装类而给精灵增加子节点或输入事件时无需触碰着色器逻辑两边可以各自演进。创建精灵组件新建文件sword_component.dart文件名中的sword请替换为你自己的图片名import package:flame/components.dart; class SwordSprite extends SpriteComponent { override Futurevoid onLoad() async { sprite await Sprite.load(assets/images/sword.png); size sprite!.srcSize; } }关键点说明onLoad()是 Flame 组件生命周期中的异步初始化入口在这里完成Sprite.load的资源加载size sprite!.srcSize让组件尺寸与图片原始像素尺寸一致确保后处理采样时 UV 映射正确Sprite.load返回FutureSprite?因此取用时需要!本教程按非空处理。创建后处理包装组件继续在sword_component.dart中追加包装类import package:flame/components.dart; import package:flame/post_process.dart; import package:basic_shader_tutorial/outline_postprocess.dart; class OutlinedSwordSprite extends PostProcessComponent { OutlinedSwordSprite({super.position, super.anchor}) : super( children: [SwordSprite()], postProcess: OutlinePostProcess(anchor: anchor ?? Anchor.topLeft), ); }这里OutlinedSwordSprite通过构造参数直接把SwordSprite作为子组件传入并把OutlinePostProcess下一节实现作为后处理效果挂上。注意anchor默认值取Anchor.topLeft这个锚点随后会参与postProcess中的绘制平移计算保证描边与精灵的对齐。完整文件一览合并后的sword_component.dart为import package:flame/components.dart; import package:flame/post_process.dart; import package:basic_shader_tutorial/outline_postprocess.dart; class OutlinedSwordSprite extends PostProcessComponent { OutlinedSwordSprite({super.position, super.anchor}) : super( children: [SwordSprite()], postProcess: OutlinePostProcess(anchor: anchor ?? Anchor.topLeft), ); } class SwordSprite extends SpriteComponent { override Futurevoid onLoad() async { sprite await Sprite.load(assets/images/sword.png); size sprite!.srcSize; } }此刻代码还不能编译因为OutlinePostProcess尚不存在——这正是下一步要解决的事。第二步实现 OutlinePostProcess打通逐帧 uniform 更新PostProcess 的职责在 Flame 中PostProcess抽象类承担着着色器管家的角色从 post_process.dart 的源码注释可以看到它的完整能力对整棵组件子树施加特效特别适合基于FragmentShader的片元级效果在onLoad()中异步加载着色器程序等资源该钩子在后处理被挂载到CameraComponent或PostProcessComponent时调用每帧调用postProcess()更新 uniform 并绘制等价于组件的update环节提供rasterizeSubtree()把组件子树渲染成一张图片供着色器作为纹理采样preRenderedSubtree支持通过PostProcessGroup/PostProcessSequentialGroup组合成并行或串联的着色器链。关于PostProcessComponent它继承自PositionComponent源码 明确说明它只对自己的子节点施加效果区别于CameraComponent.postProcess的全屏效果并会根据子节点包围盒自动计算尺寸。教程在第四步正是利用了重算包围盒的思路来修正悬停命中区域。编写后处理类新建outline_postprocess.dartimport dart:ui; import package:flutter/material.dart; import package:flame/components.dart; import package:flame/post_process.dart; extension on Color { Vector4 toVector4() { return Vector4(r, g, b, a); } } class OutlinePostProcess extends PostProcess { final double outlineSize; Color outlineColor; final Anchor anchor; OutlinePostProcess({ this.outlineSize 7.0, this.outlineColor Colors.purpleAccent, this.anchor Anchor.topLeft, }); late final FragmentProgram _fragmentProgram; late final FragmentShader _fragmentShader _fragmentProgram.fragmentShader(); late final Paint _myPaint Paint()..shader _fragmentShader; override Futurevoid onLoad() async { await super.onLoad(); _fragmentProgram await FragmentProgram.fromAsset(assets/shaders/outline.frag); } override void postProcess(Vector2 size, Canvas canvas) { final preRenderedSubtree rasterizeSubtree(); _fragmentShader.setFloatUniforms((value) { value ..setVector(size) ..setFloat(outlineSize) ..setVector(outlineColor.toVector4()); }); _fragmentShader.setImageSampler(0, preRenderedSubtree); canvas ..save() ..translate(-size.x * anchor.x, -size.y * anchor.y) ..drawRect(Offset.zero size.toSize(), _myPaint) ..restore(); } }逐段拆解这段代码的工程意义运行时可调参数uniform 的上游outlineSize、outlineColor、anchor被设计为公开字段。outlineColor特意不是final这正是第四步悬停变色能够实现的前提——Flame 后处理允许通过运行时设置暴露开关/参数正如 post_process.dart 文档所说you can also expose runtime settings through uniforms, for example to enable or disable effects。资源加载onLoad()中通过FragmentProgram.fromAsset(assets/shaders/outline.frag)加载 GLSL 着色器程序再调用fragmentShader()创建实例最后包装进Paint的shader属性用于绘制。逐帧渲染管线postProcess()的固定套路是——rasterizeSubtree()把子组件即精灵离线渲染成纹理用setFloatUniforms把尺寸、描边宽度、描边颜色写入 GPU uniformsetImageSampler(0, preRenderedSubtree)把精灵纹理绑定到采样器槽位 0依据anchor平移画布translate(-size.x * anchor.x, -size.y * anchor.y)再以填充了FragmentShader的画笔绘制一个与size等大的矩形触发片元着色器对每个像素求值。setFloatUniforms的底层机制FragmentShaderExtension见 fragment_shader.dart封装了 Flutter 原生FragmentShader.setFloat(index, value)的索引管理——UniformsSetter内部维护递增索引按调用顺序把Size展开为两个 float、把Vector4展开为四个 float无需手动数索引。这在 uniform 数量增多时极大降低出错率。注意uniform 的写入顺序必须与 GLSL 中uniform声明顺序一一对应教程中为uSize、uOutlineWidth、uOutlineColor。为什么是先渲染子树再画矩形PostProcessComponent是精灵的父节点渲染顺序是先父后子父节点的后处理先把子树光栅化随后精灵才被画到上层。rasterizeSubtree()捕获到的这份快照正是片元着色器要采样的uTexture——着色器逐像素地分析这张快照的透明度才能判断哪里是轮廓边缘。这一步完成后上一步的编译错误消失但我们还缺真正的着色器文件且main.dart尚未把组件挂进游戏世界。组装到游戏中打开main.dart同时添加一个普通精灵与一个描边精灵作对比import package:flutter/material.dart; import package:flame/components.dart; import package:flame/game.dart; import package:basic_shader_tutorial/sword_component.dart; void main() { runApp( GameWidget(game: MyGame()), ); } class MyGame extends FlameGame { MyGame() : super(world: MyWorld()); override Color backgroundColor() Colors.green; } class MyWorld extends World { override Futurevoid onLoad() async { add( SwordSprite() ..position Vector2(-200, 0) ..anchor Anchor.center, ); add( OutlinedSwordSprite( position: Vector2(200, 0), anchor: Anchor.center, ), ); } }这里自定义了FlameGame子类并重写backgroundColor()为绿色——深色纯色背景能让描边效果更醒目final_result.png截图中即为此绿色背景。World的onLoad()中通过级联语法设置position与anchor两个精灵左右并排放置以便对照。此时运行应用屏幕上只会出现一个精灵控制台会抛出[...] Unhandled Exception: Exception: Asset assets/shaders/outline.frag not found [...]原因很直白着色器文件还没创建且未在pubspec.yaml注册。下一步解决它。第三步编写 GLSL 片元着色器编写前的思维转变着色器代码的运行方式与普通 Dart 代码截然不同片元着色器对每个像素、每一帧都要执行一次。这意味着循环与分支的代价会随像素数 × 循环次数线性放大编写时必须时刻警惕。另外本教程刻意略过优化细节例如用平方距离比较替代sqrt求根聚焦于可读性与正确性。outline.frag 完整代码创建assets/shaders/目录与outline.frag文件#version 460 core precision mediump float; #include flutter/runtime_effect.glsl uniform vec2 uSize; uniform float uOutlineWidth; uniform vec4 uOutlineColor; uniform sampler2D uTexture; const int MAX_SAMPLE_DISTANCE 8; out vec4 fragColor; void main() { vec2 uv FlutterFragCoord().xy / uSize; vec4 texColor texture(uTexture, uv); // If the current pixel is not transparent, render the original color if (texColor.a 0.0) { fragColor texColor; return; } // Check surrounding pixels for outline vec2 texelSize 1.0 / uSize; bool foundOpaqueNearby false; // Sample in the bounding square pattern around the current pixel // You must use static const loop counts in GLSL for (int x -MAX_SAMPLE_DISTANCE; x MAX_SAMPLE_DISTANCE; x) { for (int y -MAX_SAMPLE_DISTANCE; y MAX_SAMPLE_DISTANCE; y) { if (x 0 y 0) continue; // Check real distance instead of manhattan distance float distance sqrt(float( x*x y*y )); if (distance uOutlineWidth) continue; // Sample the shifted pixel from the current pixel (uv) vec2 offset vec2(float(x), float(y)) * texelSize; vec4 sampleColor texture(uTexture, uv offset); if (sampleColor.a 0.0) { // We found solid color in the iteration -- sprite is nearby foundOpaqueNearby true; break; } } // Break out from outer loop too if (foundOpaqueNearby) break; } if (foundOpaqueNearby) { fragColor uOutlineColor; } else { fragColor vec4(0.0, 0.0, 0.0, 0.0); } }算法逐行解读坐标与采样基础FlutterFragCoord().xy / uSize把当前像素的屏幕坐标归一化为 UV0~1 区间。注意FlutterFragCoord()是 Flutter 运行时效应Runtime Effect提供的标准入口由#include flutter/runtime_effect.glsl引入texture(uTexture, uv)采样精灵快照纹理得到当前像素颜色。核心分支透明 vs 不透明若当前像素alpha 0精灵实体区域直接输出原始纹理色不做任何处理——描边只发生在透明像素上若当前像素透明进入邻居扫描以当前像素为中心在MAX_SAMPLE_DISTANCE常量 8的方形范围内遍历偏移量(x, y)跳过中心点(0,0)自身用真实欧氏距离sqrt(x*x y*y)与uOutlineWidth比较只采样落在描边宽度圆内的邻居比曼哈顿距离更准确轮廓呈圆润而非菱形采样偏移后的像素若发现任一邻居alpha 0说明精灵就在附近置位foundOpaqueNearby并双层跳出循环。输出决策找到不透明邻居 → 输出uOutlineColor描边色否则输出完全透明的vec4(0,0,0,0)。这就是为什么教程要求透明背景图片——描边完全建立在透明/不透明的二分判定上。两个必须遵守的 GLSL 约束循环次数必须是编译期常量GLSL在 Flutter Runtime Effect 环境下不允许循环边界依赖 uniform因此MAX_SAMPLE_DISTANCE声明为const int而uOutlineWidth只能作为循环内的距离阈值。请务必保证MAX_SAMPLE_DISTANCE Dart 侧传入的 outlineSize否则超出扫描范围的描边宽度将失效uniform 声明顺序与 Dart 侧写入顺序一一对应uSize→uOutlineWidth→uOutlineColor与setFloatUniforms中的写入次序严格一致。注册着色器资源与图片资源同理需要在pubspec.yaml的flutter:段增加shaders:声明Flutter 才能在构建时打包.frag文件flutter: assets: - assets/images/ shaders: - assets/shaders/outline.frag重新运行应用两个精灵应同时出现左侧是原始精灵右侧带上了紫色描边。第四步接入鼠标悬停让描边活起来着色器已经工作现在通过HoverCallbacksmixin 让描边颜色随鼠标交互动态变化顺便解决一个容易被忽略的包围盒问题。挂载 HoverCallbacks打开sword_component.dart让OutlinedSwordSpritemixin 悬停回调import package:flame/events.dart; class OutlinedSwordSprite extends PostProcessComponent with HoverCallbacks { // ... }覆写悬停回调切换颜色添加一个字段保存原始描边色并在进入/离开时切换Color? _originalPostProcessColor; override void onHoverEnter() { super.onHoverEnter(); final outlinePostProcess postProcess as OutlinePostProcess; _originalPostProcessColor outlinePostProcess.outlineColor; outlinePostProcess.outlineColor Colors.blue; } override void onHoverExit() { final outlinePostProcess postProcess as OutlinePostProcess; outlinePostProcess.outlineColor _originalPostProcessColor ?? Colors.purpleAccent; super.onHoverExit(); }关键设计点outlineColor是非final公开字段而postProcess()每帧都会把它的当前值写入 GPU uniform因此悬停时改 Dart 字段下一帧描边颜色即更新无需重装着色器用_originalPostProcessColor缓存进入时的颜色离开时恢复?? Colors.purpleAccent兜底处理颜色尚未初始化的情况。包围盒修正让命中区域与精灵一致由于PostProcessComponent默认按子节点包围盒计算尺寸而子精灵存在锚点偏移教程补充了onChildrenChanged重算逻辑确保悬停命中区域与精灵实际大小匹配override void onChildrenChanged( Component component, ChildrenChangeType changeType, ) { _recalculateBoundingSize(); super.onChildrenChanged(component, changeType); } void _recalculateBoundingSize() { final boundingBox Vector2.zero(); final rectChildren children.queryPositionComponent(); if (rectChildren.isNotEmpty) { final boundingRect rectChildren .map((child) child.toRect()) .reduce((a, b) a.expandToInclude(b)); boundingBox.setValues(boundingRect.width, boundingRect.height); } size boundingBox; }这段代码在子组件集合变化时遍历所有PositionComponent子节点用toRect()得到各自包围矩形再通过expandToInclude合并出整体包围盒最后写回size。这也是 post_process_component.dart 源码中若未显式提供size则根据子节点包围盒计算行为的显式化。完整解决方案合并后的sword_component.dartimport package:flutter/material.dart; import package:flame/components.dart; import package:flame/events.dart; import package:flame/post_process.dart; import package:basic_shader_tutorial/outline_postprocess.dart; class OutlinedSwordSprite extends PostProcessComponent with HoverCallbacks { OutlinedSwordSprite({super.position, super.anchor}) : super( children: [SwordSprite()], postProcess: OutlinePostProcess(anchor: anchor ?? Anchor.topLeft), ); override void onChildrenChanged( Component component, ChildrenChangeType changeType, ) { _recalculateBoundingSize(); super.onChildrenChanged(component, changeType); } void _recalculateBoundingSize() { final boundingBox Vector2.zero(); final rectChildren children.queryPositionComponent(); if (rectChildren.isNotEmpty) { final boundingRect rectChildren .map((child) child.toRect()) .reduce((a, b) a.expandToInclude(b)); boundingBox.setValues(boundingRect.width, boundingRect.height); } size boundingBox; } Color? _originalPostProcessColor; override void onHoverEnter() { super.onHoverEnter(); final outlinePostProcess postProcess as OutlinePostProcess; _originalPostProcessColor outlinePostProcess.outlineColor; outlinePostProcess.outlineColor Colors.blue; } override void onHoverExit() { final outlinePostProcess postProcess as OutlinePostProcess; outlinePostProcess.outlineColor _originalPostProcessColor ?? Colors.purpleAccent; super.onHoverExit(); } } class SwordSprite extends SpriteComponent { override Futurevoid onLoad() async { sprite await Sprite.load(assets/images/sword.png); size sprite!.srcSize; } }运行后鼠标移入精灵 → 描边变为蓝色移出 → 恢复为紫色原色。回顾与延伸理解 Flame 着色器的三层架构本教程最终沉淀出一个清晰的三层模型这也是在 Flame 中使用着色器的一般架构层载体职责组件层SpriteComponent/PostProcessComponent连接着色器与 Flame 组件承载游戏逻辑与用户输入如HoverCallbacks后处理层PostProcess桥接组件与着色器管理运行时设置逐帧把 uniform 值同步到 GPUGLSL 着色器.frag文件在 GPU 上逐像素执行决定最终像素颜色基于这套分层你可以低成本地把本教程扩展到更多场景更换效果只改outline.frag的算法例如改成内发光、马赛克、色相偏移PostProcess与组件层无需大动运行时开关参考outlineColor的做法再暴露一个bool enabled字段在postProcess()中跳过drawRect即可一键关闭特效组合后处理查阅 post_process.dart 中提到的PostProcessGroup并行与PostProcessSequentialGroup串联把描边与其他特效链式叠加全屏后处理本教程的PostProcessComponent只作用于自身子树若要对整个屏幕生效改用CameraComponent.postProcess更多输入方式events.dart还提供拖拽DragCallbacks、缩放ScaleCallbacks、键盘等 mixin可把描边宽度outlineSize也做成动态 uniform。源码深入阅读入口后处理抽象类与组合机制packages/flame/lib/src/post_process/post_process.dart组件级后处理容器packages/flame/lib/src/post_process/post_process_component.dartuniform 便捷写入扩展packages/flame/lib/src/extensions/fragment_shader.dart祝你编码愉快——试着把描边宽度、颜色甚至着色器算法换成你自己的创意吧【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考