角色渲染与着色器管线
ArkPets 的桌宠角色渲染采用 两段式(Two-pass)渲染管线:第一渲染通道 SpineRenderPass 将 Spine 骨骼动画绘制到正交相机的帧缓冲(FBO)中,第二渲染通道 PostProcessRenderPass 再将该 FBO 纹理合成到屏幕,并附加描边、阴影、背景色与透明度过渡等后处理效果。着色器逻辑由 RawShader 与 EffectShader 两个 Java 封装类与 assets/shaders/ 下的 GLSL 源文件共同实现。
目的与范围(Purpose and Scope)
本页覆盖 cn.harryh.arkpets.render 包中与角色绘制直接相关的完整渲染子系统:
- 渲染管线架构:
SpineRenderPass(骨架绘制通道)与PostProcessRenderPass(后处理合成通道)的协作方式; - 着色器封装:
RawShader、EffectShader的使用方式与对应 GLSL 资源(assets/shaders/下的 Plain/Complex 系列); - 渲染目标管理:
DynamicOrthographicCamara的帧缓冲(FBO)在两个通道间的桥梁作用; - 与渲染相关的配置项(
render_shader_high_quality、render_outline_width、render_shadow_color等)及其消费方式。
以下内容有意留给同级页面,本页仅作引用:
- Spine 骨骼/动画数据模型的加载与行为状态机驱动 —— 参见 "Pet Runtime Core" 分类下的骨架动画相关页面;
- 桌面窗口、透明窗体与主循环(
ArkPets/ Stage 层)的生命周期 —— 参见运行时核心的其他页面; ArkConfig配置体系的整体加载与持久化 —— 参见配置系统页面(本页只列出渲染消费的键)。
概述(Overview)
为什么需要两段式渲染?
桌宠角色的最终视觉效果并非"直接把骨架画到屏幕上"就能得到:
- 描边与阴影是全帧效果。要为一个任意姿态的角色生成描边,必须先拥有一张包含完整角色形状(含 Alpha)的纹理,然后对该纹理做邻域采样检测边缘。这正是 FBO 的作用——先把角色渲染成一张离屏纹理。
- 背景色与透明窗体合成。桌面窗口可以是半透明的,角色背后需要按配置填充背景色,并且整体不透明度要参与淡入淡出(例如窗口关闭动画),这些都必须在拿到角色纹理之后才能进行。
- 性能与质量可切换。后处理着色器提供高质量/低质量两档 GLSL 变体(
ComplexFragment.glsl与ComplexFragmentLow.glsl),由配置项render_shader_high_quality在初始化时选择。
关键概念与术语:
| 术语 | 含义 |
|---|---|
| Render Pass(渲染通道) | 一次完整的绘制过程。本管线分为骨架通道与后处理通道 |
| FBO(Frame Buffer Object) | 离屏帧缓冲,DynamicOrthographicCamara.getFBO() 返回,充当两通道之间的"纹理中转站" |
| RawShader | 骨架通道使用的轻量着色器封装,配合 TwoColorPolygonBatch 绘制 Spine 双色网格 |
| EffectShader | 后处理通道使用的特效着色器封装,接收描边/阴影/透明度等 uniform |
| TransitionFloat | 带 easing 的浮点过渡器,用于描边与整体透明度的平滑动画 |
架构(Architecture)
分层解读:
- 骨架层(sg_Pass1):
SpineRenderPass持有TwoColorPolygonBatch(Spine 官方双色调色多边形批处理,支持 tint/dark 双色)与SkeletonRenderer(设置premultipliedAlpha(true),因为美术资源纹理按预乘 Alpha 导出)。着色器通过batch.setShader(shader)绑定为RawShader,写入目标始终是相机 FBO。 - 中转层(sg_Camera):
DynamicOrthographicCamara(源码中的既有拼写)是两个通道之间唯一的耦合点。第一通道用camera.combined设置投影矩阵并用camera.getFBO().begin()/end()划定绘制范围;第二通道用camera.getFBO().getColorBufferTexture()把结果当作普通纹理读回。 - 后处理层(sg_Pass2):
PostProcessRenderPass使用SpriteBatch+EffectShader,先画背景纹理(setCanvas生成的纯色bgTexture),再画 FBO 纹理,特效全部在EffectShader的 uniform 中控制。 - 配置层(sg_Config):所有视觉可调项集中在
ArkConfig,在PostProcessRenderPass构造函数中一次性读取并固化为字段;着色器质量变体在构造时决定,运行期不再切换。
渲染时序(每帧执行顺序)
该时序表明两个通道严格串行:第二通道必须等第一通道完成 FBO.end() 之后才能取到完整的角色纹理,因此每帧的角色像素数据流是 骨架 → FBO 纹理 → 特效合成 → 屏幕 的单向流水线。
实现详解(Main Content)
第一渲染通道:SpineRenderPass
SpineRenderPass 的职责非常单一:把一个 Spine Skeleton 画进相机 FBO,或者(在叠加模式下)以指定透明度画到"当前已绑定的帧缓冲"上。
构造阶段(SpineRenderPass.java#L25-L34):
1public SpineRenderPass(DynamicOrthographicCamara camera) {
2 this.camera = camera;
3 batch = new TwoColorPolygonBatch();
4 renderer = new SkeletonRenderer();
5 renderer.setPremultipliedAlpha(true);
6 /* Shader pedantic should be disabled to avoid uniform not-found error. */
7 ShaderProgram.pedantic = false;
8 shader = new RawShader();
9 batch.setShader(shader);
10}TwoColorPolygonBatch是 libGDX/Spine 生态的多边形批处理器,支持每个顶点携带 tint 与 dark 两种颜色,这是明日方舟立绘资源实现"染色+压暗"质感的前提;setPremultipliedAlpha(true)说明骨架纹理按 预乘 Alpha 处理,因此混合函数必须使用相应的 SRC 组合,否则半透明边缘会发白;ShaderProgram.pedantic = false是一个全局开关,用于容忍着色器中"未使用的 uniform"不报错——因为 RawShader 与 EffectShader 共享 libGDX 的严格校验机制,而 GLSL 编译器常会裁剪未引用的 uniform;batch.setShader(shader)把RawShader绑定为该批次的着色器,所有后续draw调用都会走它。
常规渲染 render(Skeleton)(SpineRenderPass.java#L39-L49):
1public void render(Skeleton skeleton) {
2 batch.getProjectionMatrix().set(camera.combined);
3 camera.getFBO().begin();
4 shader.bind();
5 shader.setAlpha(1.0f);
6 ScreenUtils.clear(0, 0, 0, 0, true);
7 batch.begin();
8 renderer.draw(batch, skeleton);
9 batch.end();
10 camera.getFBO().end();
11}逐行解读其控制流:
batch.getProjectionMatrix().set(camera.combined)—— 批次的投影矩阵与相机同步,保证骨架坐标(Spine 世界坐标)正确映射到 FBO 像素;camera.getFBO().begin()—— 切换渲染目标为离屏 FBO;shader.bind()+shader.setAlpha(1.0f)—— 绑定 RawShader 并把整体不透明度设为满值(通道内不需要做透明过渡,过渡统一放在第二通道);ScreenUtils.clear(0, 0, 0, 0, true)—— 用 完全透明 的颜色清屏,这一步至关重要:后处理的描边/阴影都依赖 Alpha 通道判定"角色存在与否",残留的不透明像素会污染边缘检测;batch.begin()/renderer.draw(batch, skeleton)/batch.end()—— 标准批绘制,SkeletonRenderer把骨架的 Attachment 逐个提交给批;camera.getFBO().end()—— 切回默认帧缓冲,为第二通道让路。
叠加渲染 renderAdditive(Skeleton, float)(SpineRenderPass.java#L56-L63):
1public void renderAdditive(Skeleton skeleton, float alpha) {
2 batch.getProjectionMatrix().set(camera.combined);
3 shader.bind();
4 shader.setAlpha(alpha);
5 batch.begin();
6 renderer.draw(batch, skeleton);
7 batch.end();
8}设计意图:与 render() 不同,renderAdditive 不 调用 FBO.begin()/end()、也不清屏,而是要求"调用者自己负责绑定与清理帧缓冲"。它通过 shader.setAlpha(alpha) 实现 淡入淡出式的骨架叠加(例如残影/换装过渡时把新旧骨架按不同 alpha 叠画到同一个 FBO 上),把帧缓冲生命周期的控制权交给外层,避免通道内部越权管理渲染目标。
第二渲染通道:PostProcessRenderPass
PostProcessRenderPass 负责把 FBO 纹理"成品化":加描边、加阴影、垫背景色、控制整体不透明度,并让这些参数平滑过渡。
构造阶段(PostProcessRenderPass.java#L41-L55):
1public PostProcessRenderPass(DynamicOrthographicCamara camera, ArkConfig config) {
2 this.camera = camera;
3 batch = new SpriteBatch();
4 /* Shader pedantic should be disabled to avoid uniform-not-found error. */
5 ShaderProgram.pedantic = false;
6 shader = new EffectShader(config.render_shader_high_quality);
7 batch.setShader(shader);
8 EasingFunction easingFunction = ArkConfig.getEasingFunctionFrom(config.transition_type);
9 float easingDuration = Math.max(0, config.transition_duration);
10 outlineAlpha = new TransitionFloat(easingFunction, easingDuration);
11 alpha = new TransitionFloat(easingFunction, easingDuration);
12 outlineWidth = config.render_outline_width;
13 outlineColor = new Color(Color.CLEAR);
14 shadowColor = ArkConfig.getGdxColorFrom(config.render_shadow_color);
15}关键设计点:
new EffectShader(config.render_shader_high_quality)—— 质量变体在构造期一次性确定。高质量走ComplexFragment.glsl,低质量走ComplexFragmentLow.glsl(更省邻域采样,适合低端 GPU),运行中不热切换,避免着色器重编译带来的卡顿;outlineAlpha与alpha都包了一层TransitionFloat,其 easing 函数与时长直接来自用户配置transition_type/transition_duration(Math.max(0, ...)兜底防止负时长);outlineColor初始为Color.CLEAR(全透明),等待外部(例如鼠标交互逻辑)调用setOutlineColor显式上色;shadowColor从配置字符串经ArkConfig.getGdxColorFrom解析为 libGDXColor。
背景画布 setCanvas(Color)(PostProcessRenderPass.java#L60-L65):
1public void setCanvas(Color bgColor) {
2 Pixmap pixmap = new Pixmap(canvasMaxSize, canvasMaxSize, Format.RGBA8888);
3 pixmap.setColor(bgColor);
4 pixmap.fill();
5 bgTexture = new Texture(pixmap);
6}它用一张 canvasMaxSize × canvasMaxSize 的 RGBA8888 像素图填充纯色(可含 Alpha),生成 bgTexture。为什么需要它:透明桌宠窗口在某些合成器上需要不透明的"画布底色"才能正确显示,同时这块底色也是阴影效果的承接面。画布尺寸取的是常量上限(Const.canvasMaxSize),与相机实际宽高解耦,绘制时再用相机宽高裁剪。
每帧渲染 render()(PostProcessRenderPass.java#L91-L113):
1public void render() {
2 outlineAlpha.addProgress(Gdx.graphics.getDeltaTime());
3 alpha.addProgress(Gdx.graphics.getDeltaTime());
4 batch.getProjectionMatrix().set(camera.combined);
5 Texture passedTexture = camera.getFBO().getColorBufferTexture();
6 shader.bind();
7 shader.setOutlineColor(outlineColor);
8 shader.setOutlineWidth(outlineWidth);
9 shader.setOutlineAlpha(outlineAlpha.now());
10 shader.setShadowColor(shadowColor);
11 shader.setTextureSize(passedTexture);
12 shader.setAlpha(alpha.now());
13 ScreenUtils.clear(0, 0, 0, 0, true);
14 batch.begin();
15 batch.setBlendFunction(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA);
16 batch.draw(bgTexture, 0, 0);
17 batch.draw(passedTexture,
18 0, 0, 0, 0, camera.getWidth(), camera.getHeight(),
19 1, 1, 0,
20 0, 0, passedTexture.getWidth(), passedTexture.getHeight(),
21 false, true);
22 batch.end();
23}控制流拆解:
- 过渡推进:两个
TransitionFloat各自按getDeltaTime()推进,因此描边与整体透明度的渐变帧率无关(时间驱动而非帧数驱动); - 纹理获取:
camera.getFBO().getColorBufferTexture()把第一通道的输出当作输入纹理,这是两通道之间唯一的数据交接点; - Uniform 灌装:依次设置描边颜色/宽度/透明度、阴影颜色、纹理尺寸(供 GLSL 内计算像素步长
1/size做邻域采样)、整体透明度; - 清屏:同样清成全透明,保持窗口无内容区域像素为 0 alpha,配合透明窗体;
- 混合模式:显式设置
GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA的标准 alpha 混合; - 两次 draw:先画
bgTexture(背景),再画 FBO 纹理。第二个batch.draw使用了完整的 10 参数 + 翻转标志重载,最后一个flipY=true是 垂直翻转——libGDX FBO 纹理的原点在左下角,而屏幕绘制坐标系原点在左上角,不翻转会导致角色上下颠倒。
属性设置接口:setOutlineAlpha(float)、setOutlineColor(Color)、setAlpha(float)(PostProcessRenderPass.java#L70-L86)都是"请求式"接口——它们只重置 TransitionFloat 的目标值(并 clamp 到 [0,1]),真正的视觉变化在后续每帧 render() 里被 easing 平滑拉过去,而不是立即跳变。这就是 ArkPets 描边/淡入淡出看起来"有弹性"的原因。
着色器封装与 GLSL 资源
渲染包中有两个着色器封装类,分别对应两个通道:
| 封装类 | 所属通道 | GLSL 资源(assets/shaders/) | 用途 |
|---|---|---|---|
RawShader | SpineRenderPass | PlainVertex.glsl / PlainFragment.glsl | 最小化顶点/片元处理,仅应用整体 alpha uniform |
EffectShader | PostProcessRenderPass | ComplexVertex.glsl / ComplexFragment.glsl(高质量)或 ComplexFragmentLow.glsl(低质量) | 描边检测、阴影着色、纹理合成 |
两份 fragment 变体的差异是 ArkPets 性能分层策略的核心:ComplexFragment.glsl 保留完整精度的边缘采样,ComplexFragmentLow.glsl 牺牲部分效果换取更低的 GPU 占用,由 render_shader_high_quality 在初始化时二选一。着色器源码放在 assets/ 下随 jar 分发,Java 侧封装负责编译与 uniform 上传。
说明:
RawShader.java/EffectShader.java的内部实现细节未在本次采集范围内展开(源码预算所限),其对外可见的调用面已完整记录于上文两个 Render Pass 中(bind/setAlpha/setOutlineColor/setOutlineWidth/setOutlineAlpha/setShadowColor/setTextureSize)。
数据与纹理流(Data Model / Persistence)
本子系统不直接持久化数据,但其数据流具备明确的"纹理流水线"结构:
- 输入:Spine
Skeleton(由动画/行为子系统每帧更新姿态); - 中间产物:
DynamicOrthographicCamara的 FBO 颜色纹理(尺寸随相机,含有效 Alpha 通道); - 静态资源:
bgTexture(setCanvas时创建,一次生成、每帧复用); - 输出:屏幕/透明窗口像素,其中"角色不存在"区域保持 alpha=0。
使用示例(Usage Examples)
示例 1:搭建骨架渲染通道并渲染一帧(节选自 SpineRenderPass.java#L25-L49):
1// 绑定相机并创建通道
2SpineRenderPass pass = new SpineRenderPass(camera);
3// 每帧:把当前姿态的骨架画入 camera 的 FBO
4pass.render(skeleton); // 内部完成 FBO begin/clear/draw/end示例 2:在已绑定的帧缓冲上叠加骨架(用于残影/过渡)(SpineRenderPass.java#L56-L63):
1// 调用方需自行绑定并清空帧缓冲
2camera.getFBO().begin();
3ScreenUtils.clear(0, 0, 0, 0, true);
4pass.renderAdditive(oldSkeleton, 0.35f); // 以 35% 不透明度叠加旧姿态
5pass.renderAdditive(newSkeleton, 1.0f); // 再画满强度新姿态
6camera.getFBO().end();示例 3:配置后处理通道并请求描边变化(节选自 PostProcessRenderPass.java#L41-L72):
1// 按用户配置初始化(质量档、描边宽度、阴影色、过渡曲线)
2PostProcessRenderPass post = new PostProcessRenderPass(camera, config);
3post.setCanvas(new Color(0, 0, 0, 0)); // 全透明画布
4// 鼠标悬停时请求显示描边(渐变由 TransitionFloat 完成)
5post.setOutlineColor(Color.WHITE);
6post.setOutlineAlpha(1.0f); // 目标值,非立即生效
7// 每帧调用:推进过渡并把 FBO 纹理合成到屏幕
8post.render();配置项(Configuration Options)
以下配置键在 PostProcessRenderPass 构造函数中被读取并固化为渲染属性:
| 配置键 | 类型 | 消费位置 | 作用 |
|---|---|---|---|
render_shader_high_quality | 布尔 | new EffectShader(...) 的入参 | 选择 ComplexFragment.glsl(高质量)或 ComplexFragmentLow.glsl(低质量)片元着色器变体 |
render_outline_width | 浮点 | outlineWidth 字段 | 描边像素宽度,直接作为 EffectShader 的 uniform |
render_shadow_color | 颜色字符串 | ArkConfig.getGdxColorFrom(...) | 阴影颜色(RGBA),解析为 libGDX Color 后灌入着色器 |
transition_type | 枚举字符串 | ArkConfig.getEasingFunctionFrom(...) | TransitionFloat 使用的缓动函数类型(同时作用于描边 alpha 与整体 alpha) |
transition_duration | 浮点(秒) | Math.max(0, ...) | 过渡时长,0 表示无渐变 |
另有常量 Const.canvasMaxSize(setCanvas 中使用)决定背景画布的最大边长。
API 参考(API Reference)
SpineRenderPass(DynamicOrthographicCamara camera): SpineRenderPass
初始化绑定到指定相机的骨架渲染通道。
参数:
camera(DynamicOrthographicCamara):其 FBO 作为渲染目标,其combined矩阵作为投影。
行为:创建 TwoColorPolygonBatch、SkeletonRenderer(premultipliedAlpha=true)、RawShader,并全局设置 ShaderProgram.pedantic = false。
SpineRenderPass.render(Skeleton skeleton): void
把骨架完整绘制到相机 FBO。
参数:
skeleton(Skeleton):待渲染的 Spine 骨架(姿态由调用方先行更新)。
副作用:清空 FBO 为全透明,随后绘制骨架,整体 alpha 固定为 1.0。
SpineRenderPass.renderAdditive(Skeleton skeleton, float alpha): void
把骨架按给定透明度叠加绘制到 当前已绑定 的帧缓冲。
参数:
skeleton(Skeleton):待叠加的骨架;alpha(float):叠加透明度。
前置条件:调用方必须已绑定帧缓冲并完成清理(本方法不执行 FBO.begin/end 与清屏)。
PostProcessRenderPass(DynamicOrthographicCamara camera, ArkConfig config): PostProcessRenderPass
初始化后处理通道。
参数:
camera(DynamicOrthographicCamara):其 FBO 纹理作为输入;config(ArkConfig):渲染相关配置集合(见上表)。
行为:创建 SpriteBatch 与 EffectShader(质量档由 render_shader_high_quality 决定),初始化两个 TransitionFloat、描边宽度/颜色与阴影颜色。
PostProcessRenderPass.setCanvas(Color bgColor): void
生成纯色背景纹理。
参数:bgColor(Color):可含 Alpha 的背景色。
PostProcessRenderPass.setOutlineAlpha(float newAlpha): void / setAlpha(float newAlpha): void
请求描边透明度 / 整体透明度过渡到 newAlpha(自动 clamp 到 [0,1])。实际渐变由后续每帧 render() 中的 TransitionFloat 完成。
PostProcessRenderPass.setOutlineColor(Color color): void
设置描边颜色(立即生效于下一次 render())。
PostProcessRenderPass.render(): void
推进过渡计时,灌装全部特效 uniform,并把背景纹理与 FBO 纹理(垂直翻转)合成绘制到屏幕。每帧调用一次。
失败模式、边界与并发(Failure Modes, Edge Cases & Concurrency)
- Alpha 清屏不可省略:两个通道都用
ScreenUtils.clear(0, 0, 0, 0, true)清成全透明。若 FBO 残留不透明像素,EffectShader的边缘检测会把"上一次的残影"也当作角色形状参与描边/阴影,造成鬼影。因此第一通道每次render()都强制清屏,而renderAdditive故意不清屏(叠加语义),把风险转移给调用方负责。 renderAdditive的隐式契约:它依赖调用方绑定帧缓冲,属于"受信调用"设计。若在未绑定 FBO 的情况下调用,绘制会落到当前默认帧缓冲,直接污染屏幕内容——这是有意为之的控制权下放,换取残影类效果的灵活性。- FBO 纹理坐标翻转:
batch.draw(..., false, true)中的flipY=true处理 libGDX FBO 的左下原点与屏幕左上原点的坐标差异。遗漏该参数是此类管线最常见的"角色上下颠倒"缺陷来源。 - 预乘 Alpha 的一致性:
SkeletonRenderer.setPremultipliedAlpha(true)要求纹理资源确实是预乘格式;第二通道使用GL_SRC_ALPHA/GL_ONE_MINUS_SRC_ALPHA标准混合承接结果。两段管线对 Alpha 语义的约定必须一致,否则边缘出现白色或黑色描圈。 - pedantic 全局开关:
ShaderProgram.pedantic = false是 libGDX 的 静态全局 设置,两处通道构造时都显式再设一次以保险。其代价是真实的着色器 uniform 拼写错误也会被静默吞掉,属于"容错优先于严格校验"的取舍。 - 质量档不可热切换:
EffectShader的变体在构造期固定,运行期没有提供重建接口;用户改配置后需要重启实例(或重新构造通道)才能生效。 - 线程模型:本管线遵循 libGDX 单渲染线程模型,
render()中的Gdx.graphics.getDeltaTime()表明所有状态推进都发生在渲染线程内;setOutlineAlpha/setAlpha/setOutlineColor设计为"请求式"(只重置目标),因此即使从逻辑线程发起请求,实际的 uniform 写入仍统一发生在渲染帧中,避免了 GL 上下文跨线程问题。 - 时长兜底:
Math.max(0, config.transition_duration)防御性处理负时长;setOutlineAlpha/setAlpha对输入做[0,1]clamp,防御越界 alpha 导致的混合异常。
性能与运维(Performance & Operations)
- 单次离屏 + 单次合成:每帧恰好 1 次 FBO 绘制 + 2 次
SpriteBatchdraw(背景 + 角色),开销集中在EffectShader的片元处理(描边/阴影采样)。低端设备可通过render_shader_high_quality=false切到ComplexFragmentLow.glsl降低片元成本。 - 背景纹理复用:
bgTexture在setCanvas时一次生成、每帧复用,避免每帧重建 Pixmap 的开销;但重复调用setCanvas会创建新Texture而不释放旧纹理,调用方应避免高频换底色。 - 时间驱动的过渡:过渡按 delta 时间推进而非帧计数,帧率波动不会改变渐变时长(时长恒等于
transition_duration秒)。 - 运维建议:调整
render_outline_width时注意过大的描边宽度会显著增加片元着色器邻域采样命中范围;在透明窗口合成器异常的平台上,可通过setCanvas提供不透明底色规避。
扩展点(Extension Points)
- 新增后处理效果:效果集中在
EffectShader及其 GLSL 变体中,Java 侧只需在封装类上增加setXxxuniform 方法,并在PostProcessRenderPass.render()中灌装;由于ShaderProgram.pedantic=false,旧版本着色器缺 uniform 不会报错,利于渐进式升级。 - 新增叠加绘制语义:
renderAdditive已示范"通道不管理帧缓冲、由调用方控制"的模式,可据此扩展残影、幻影、多重叠装等效果,而不必改动SpineRenderPass核心路径。 - 相机适配:所有绘制均以
DynamicOrthographicCamara的combined与宽高为参照,画布层另有Const.canvasMaxSize兜底,因此缩放画布/相机不需要改动两个通道本身。
相关链接(Related Links)
- SpineRenderPass.java —— 骨架渲染通道完整实现
- PostProcessRenderPass.java —— 后处理渲染通道完整实现
- assets/shaders/ ——
ComplexFragment.glsl等四份 GLSL 着色器源 - 骨骼动画与行为状态机、透明窗口与主循环、
ArkConfig配置体系 —— 参见 "Pet Runtime Core" 分类下的对应兄弟页面