Refactor Spine Material System to Support Custom Shaders for Advanced Effects
Description: We propose a refactor of the current Spine material system to enable custom shader capabilities, allowing developers to create more sophisticated visual effects through GPU shader programming.
Problem Statement: The current material system:
- Lacks flexibility for custom rendering requirements
- Limits artistic expression with pre-defined material options
- Cannot support modern VFX techniques like dynamic distortions, advanced blending, or GPU-powered animations
Proposed Solution:
1. Material System Refactor
- Decouple material management from core rendering
- Create extendable material base classes
- Implement shader template inheritance system
2. Custom Shader Support:
- Allow injection of custom vertex/fragment shaders
- Support shader uniforms/parameters binding
- Enable material property overrides via shader code
3. Effect Development Interface:
- Expose shader hook points through Material API
- Add texture slot management for effect resources
- Implement runtime material switching capability
Expected Outcomes:
- Artists can create custom effects through shader programming
- Support for advanced rendering techniques:
- Dynamic gradient transitions
- Texture distortion effects
- Multi-pass rendering
- Backward compatibility with existing material configurations
- Performance optimization guidelines for shader authors
Implementation Plan: Phase 1: Core system refactoring (2 weeks) Phase 2: Shader interface implementation (3 weeks) Phase 3: Testing & documentation (1 week)
Documentation: Will provide:
- Shader authoring guide
- Material API reference
- Example effect implementations
背景
Spine 作为业内领先的 2D 骨骼动画工具,其运行时系统通过高效的蒙皮网格计算和插值算法,为开发者提供了流畅的动画表现。然而,当前 Galacean 运行时仅支持基础的 Spine 渲染,无法实现动态溶解、法线贴章、UV扭曲等高级美术需求。
1. 需求驱动
随着游戏项目中美术表现复杂度的提升,亟需通过自定义材质实现以下场景:
- 动态视觉效果:角色受到攻击时希望表现出一些特殊的效果,但由于无法替换 Shader,所以无法实现。

Spineboy 被冰冻 🥶⬆️
- 风格化效果:比如描边效果,角色被遮挡时,需要展示轮廓描边,选中角色时展示描边。

2. 现有方案的不足
当前的材质方案存在显著缺陷:着色器硬编码在Material类,无法支持自定义Shader扩展
3. 功能价值
引入标准化自定义材质系统将带来以下收益:
- 表现力解放:赋能复杂效果的实现,减少美术的实现依赖,开发可以自主开发特效。
- 生态扩展性:为与编辑器的材质系统,ShaderLab 等功能融合铺平道路。
4. 关键挑战
- 运行时架构适配:需在不破坏 Spine 核心批处理机制的前提下注入材质逻辑。
- 性能基线保障:确保动态材质切换不影响动画实例的CPU开销与Draw Call合批。
本 RFC 旨在系统性解决上述问题,提出可落地的运行时扩展方案,推动 Spine 动画管线向材质驱动型工作流演进。
调研
根据市面上引擎的调研,支持自定义材质的引擎有:cocos,godot,unity
Godot
Godot 可以自定义 SpineSprite (可以理解为 Spine 渲染器)的四种材质。分别是:Normal , Additive, Multiply, Screen 模式的 Material。
如下图所示:
修改了纹理的 r 通道,进行了变色处理。

还可以写更复杂的 shader,比如加一点扰动:

不同的 SpineSprite 定义的材质互不影响。

运行时实现
https://github.com/EsotericSoftware/spine-runtimes/blob/4.2/spine-godot/spine_godot/SpineSprite.cpp
Godot 通过分层材质系统实现材质的控制,支持全局和 slot 级别的材质自定义。
一、默认材质系统
- 预定义四种混合模式材质
- 使用 CanvasItemMaterial 预置 Normal/Additive/Multiply/Screen 四种默认材质
- 存储在 SpineSpriteStatics 单例中
- 根据 Slot 的 blendMode 自动选择对应默认材质
二、全局材质覆盖
-
SpineSprite 提供四组材质属性:
- normal_material
- additive_material
- multiply_material
- screen_material
- 优先级高于默认材质
- 通过 setter 方法设置后,所有对应混合模式的 slot 都会使用该材质
三、Slot 级材质覆盖
- 通过 SpineSlotNode 节点实现
- 每个 SpineSlotNode 可设置四种混合模式材质
- 附加到具体 slot 时可覆盖全局材质设置
-
实现原理:
- 在 generate_meshes_for_slots 时建立 slot 索引映射
- 渲染时检查 slot 对应的 SpineSlotNode 是否存在
- 优先使用 SpineSlotNode 设置的材质
四、材质选择优先级
- SpineSlotNode 材质(最高优先级)
- SpineSprite 自定义材质
- 默认材质(最低优先级)
核心代码
if (!custom_material.is_valid()) {
switch (blend_mode) {
case spine::BlendMode_Normal:
custom_material = normal_material;
break;
case spine::BlendMode_Additive:
custom_material = additive_material;
break;
case spine::BlendMode_Multiply:
custom_material = multiply_material;
break;
case spine::BlendMode_Screen:
custom_material = screen_material;
break;
}
}
// Set the custom material, or the default material
if (custom_material.is_valid()) mesh_instance->set_material(custom_material);
else
mesh_instance->set_material(statics.default_materials[slot->getData().getBlendMode()]);
Cocos
Cocos spine 的自定义材质与引擎 2D 自定义渲染材质功能是相同的:
https://docs.cocos.com/creator/3.8/manual/zh/ui-system/components/engine/ui-material.html
2D 渲染组件大部分都支持使用自定义材质,其使用界面如下图(以 Sprite 组件为例):其使用方法与其他内置材质并无不同,将要使用的材质拖拽到 CustomMaterial 属性框中即可。

( 这里体验非常好,鼠标悬停在不同的材质时,viewport 能够直接预览其渲染效果

可以看到默认材质中有一个 default-spine-material:

编辑器中的体验和 godot 类似。把自己定义的材质拖到插槽即可。
运行时实现
https://github.com/cocos/cocos-engine/blob/v3.8.7/cocos/spine/skeleton.ts
Cocos 的实现方式与 Galacean 类似:
- customMaterial: 用户自定义材质属性。当设置时,触发updateMaterial()更新材质。
- 默认材质: 若无自定义材质,使用内置的default-spine-material。
- 材质实例缓存 (_materialCache): 以混合参数和类型为键缓存材质实例,避免重复创建。
// 自定义材质设置
set customMaterial (val) {
this._customMaterial = val;
this.updateMaterial(); // 触发材质更新
this._markForUpdateRenderData();
}
// 更新材质逻辑
updateMaterial() {
let mat: Material;
if (this._customMaterial) mat = this._customMaterial;
else mat = this._updateBuiltinMaterial(); // 获取默认材质
this.setSharedMaterial(mat, 0); // 设置为共享材质
this._cleanMaterialCache(); // 清理旧缓存
}
- 动态生成不同混合模式的材质
- 混合状态配置: 根据混合因子(如BlendFactor.SRC_ALPHA)动态设置材质的混合模式。
- 双色染色支持: 通过SpineMaterialType.TWO_COLORED类型启用,生成对应着色器变体。
getMaterialForBlendAndTint(src: BlendFactor, dst: BlendFactor, type: SpineMaterialType) {
const key = `${type}/${src}/${dst}`;
if (inst) return inst; // 如果存在直接返回
// 创建新实例(自定义或默认材质)
const inst = new MaterialInstance(...);
// 配置混合状态
inst.overridePipelineStates({
blendState: {
targets: [{ blendSrc: src, blendDst: dst }]
}
});
// 双色染色处理
let useTwoColor = (type === TWO_COLORED);
inst.recompileShaders({ TWO_COLORED: useTwoColor });
return inst; // 返回缓存实例
}
PS: 这里和 Galacean 实现方法一样,但是我的做法明显有缺陷, 我根据 textureId 额外存储了一份 Material,但是 Materia 无需根据 纹理 ID 来做区分,texture 只是 material 的一个属性。
Unity
https://zh.esotericsoftware.com/spine-unity-utility-components#SkeletonRendererCustomMaterials
Unity 通过一个脚本组件:SkeletonRenderer Custom Materials 实现的自定义材质能力。

这里的自定义材质功能有两个:
- Custom Slot Materials
自定义插槽的材质。可以设置多个:

- Custom Material Overrides
覆盖原本的 spine 材质。也可以设置多个。

为啥会有多个呢?因为在上传素材时,不同混合模式的材质会独立出来,产生多份。

运行时实现
默认材质
Unity 在加载阶段就会生成默认的 Material 资产, 这一点和其他的引擎都不一样: https://github.com/EsotericSoftware/spine-runtimes/blob/4.2/spine-unity/Assets/Spine/Editor/spine-unity/Editor/Utility/AssetUtility.cs
核心代码如下:
public static AtlasAssetBase IngestSpriteAtlas (UnityEngine.U2D.SpriteAtlas spriteAtlas, List<string> texturesWithoutMetaFile) {
atlasAsset.spriteAtlasFile = spriteAtlas;
int pagesCount = 1;
List<Material> populatingMaterials = new List<Material>(pagesCount);
{
string pageName = "SpriteAtlas";
string materialPath = assetPath + "/" + primaryName + "_" + pageName + ".mat";
Material material = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
if (material == null) {
Shader defaultShader = GetDefaultShader();
material = defaultShader != null ? new Material(defaultShader) : null;
ApplyPMAOrStraightAlphaSettings(material, SpineEditorUtilities.Preferences.textureSettingsReference);
AssetDatabase.CreateAsset(material, materialPath);
} else {
vestigialMaterials.Remove(material);
}
if (texture != null)
material.mainTexture = texture; // 设置纹理
EditorUtility.SetDirty(material);
// 把创建的材质添加到 populatingMaterials
populatingMaterials.Add(material);
}
// 设置 material 到 atlas资产
atlasAsset.materials = populatingMaterials.ToArray();
}
其他引擎的 Material 都是内置的。资产一般都是:骨骼,atlas,纹理三个资产。而 Unity 除了 Texture 外,还会生对应的 Material 资产。
自定义材质
- 插槽材质:
这里会使用内部存储的 customSlotMaterials 进行设置。(同样可以通过 SkeletonRendererCustomMaterials 脚本进行设置。)
Material material;
if (isCustomSlotMaterialsPopulated) {
if (!customSlotMaterials.TryGetValue(slot, out material))
material = (Material)((AtlasRegion)region).page.rendererObject;
} else {
material = (Material)((AtlasRegion)region).page.rendererObject;
}
- 全局覆盖材质
通过内部存储的 customMaterialOverride 进行设置。(同样可以通过 SkeletonRendererCustomMaterials 脚本进行设置。)
if (customMaterialOverride.Count > 0) // isCustomMaterialOverridePopulated
MeshGenerator.TryReplaceMaterials(workingSubmeshInstructions, customMaterialOverride);
总结
调研方案对比
1. Godot 方案
Godot 提供 SpineSprite 全局材质覆盖 和 Slot 节点级材质覆盖 两种粒度,采用简单的分层材质控制体系:
-
优势
- 简洁直观,用户易于理解
- 插槽级材质覆盖提供较高灵活度
-
劣势
- 每个插槽材质需额外节点(SpineSlotNode),增加节点管理开销
2. Cocos 方案
Cocos Creator 的 Spine 自定义材质与引擎其他 2D 组件共享统一的材质管理界面,支持拖拽即可快速生效:
-
优势
- 统一的材质管理,且编辑器操作简单,容易上手
- 材质缓存机制简单高效
- 编辑器:鼠标悬停在材质上即可预览效果
-
劣势
- 仅支持 Spine 渲染器级材质,未原生提供插槽级别的材质控制
- 自定义程度相对较低,混合模式等配置依赖代码实现,非完全可视化
3. Unity 方案
Unity 将 Spine 的材质完全资产化,支持全局覆盖和插槽级别多个材质资产的自定义:
-
优势
- 极致的灵活度,材质资产独立,便于高度复用与复杂项目的统一管理
- 官方内置丰富的 Spine Shader 支持(如Lit、Fill、Tint Additive Special),易于实现高级效果
-
劣势
- 过于复杂,对普通用户学习成本高,需理解 Shader 及多个资产关系
- 引擎侧材质资产的维护成本大,实现复杂,用户也有额外理解成本
调研结论
使用 Cocos / Godot 类似的简介设计
综合以上的调研,决定不采用 Unity 这样比较重的方案,而是选择类似 Cocos / Godot 这样简洁的设计。理由如下
- ❌ Unity 材质系统太重,复杂程度远超实际需求
Unity 为 Spine 提供了 十几种内置着色器,覆盖 URP、LWRP、Standard 管线、Outline、多种 Tint、多种混合模式、光照/非光照、Sprite 专用、Canvas 专用等场景。这种设计初衷是兼容各种渲染管线和平台差异,但带来的问题是:
- 材质概念繁多,用户学习成本高;
- 对于轻量项目开发者,美术和前端都需理解大量 Unity 专属的 shader pipeline、ZWrite、PMA 等概念,理解成本高;
- 实现复杂,维护困难,容易形成耦合。
✅ 而 Galacean 引擎只使用了一到两个基础 shader(默认 / Tint),当前阶段根本不需要如此繁复的系统设计。
- 目前追求:易用性 > 复杂性,直觉式工作流更符合目标用户
Galacean 引擎当前主打轻量化和移动端场景,希望能让用户快速上手。
根据上述调研可以发现:
- Cocos 和 Godot 的方案更加易于理解,用户只需要拖一个材质即可,立即在编辑器中看到效果;
- 无需理解材质变体、管线兼容性等高级概念;
- 编辑器 UI 也更简洁清晰,支持预览、快捷设置
- 当前没有多 shader 需求,设计复杂系统属于“超前优化”
目前我们的 Spine 运行时只维护了两套材质:
-
Spine/Skeleton: 默认无光照 shader; -
Spine/Skeleton Tint: 支持双色 Tint Black 的 shader。
Unity 的十几种 shader 设计是因为它要同时兼容:
- 多种渲染管线(Built-in / URP / LWRP)
- 多种使用场景(3D Sprite / Canvas / Outline / Shadow)
但在我们的引擎中:
- 没有渲染管线切换需求;
- 没有那么多光照、边缘光、法线、RT 等需求;
- 不需要提前做那么多材质资产;
所以:独立出 Material、做一个高度通用的系统,不仅意义不大,还会造成复杂系统的早产。
- 性能优先 + 可扩展性留口,兼顾现在与未来
当下的设计目标是:
- 保持运行时性能:默认路径无插槽 override,即可最大合批;
- 让用户在需要时开启自定义材质:插槽级材质仅按需使用;
如果有定制化的需求,使用自定义材质即可。
具体方案
编辑器功能设计
核心功能
Galacean 提供对全局(渲染器)以及特定插槽的材质自定义。
交互形式
- 提供额外的自定义材质配置项,能够传入一个自定义的材质球。

- 提供一个自定义插槽材质的配置项,能够点击加号,添加一个配置项。可以配置插槽名字以及自定义的材质。



运行时设计
材质实现方案调整
主要修改点:
- 新增
SpineMaterialManager,材质的创建/获取/缓存由SpineMaterialManager统一集中管理,SpineGenerator只负责生成primitive不再负责持有材质相关逻辑 - 增加自定义材质能力,材质优先级:
[Default Material]
↑
[Renderer Custom Material]
↑
[Slot Custom Material]
新增材质管理器
class SpineMaterialManager {
readonly defaultMaterial: SpineMaterial; // 默认内置材质
customMaterial?: Material; // 自定义材质(渲染器纬度)
// 自定义插槽材质
private _slotMaterials: Record<string, Material> = {};
// 全局材质缓存,避免重复创建材质实例
private static _materialCache: Record<string, Material> = {};
constructor(private _engine: Engine, private _renderer: SpineAnimationRenderer) {
this.defaultMaterial = this._getOrCreateTemplate("normal", false);
}
// 重制方法
reset(): void {
this.customMaterial = undefined;
this._slotMaterials = {};
this.clearCache(); // 清除缓存
}
// 自定义插槽材质
setSlotMaterial(slotName: string, material: Material) {
this._slotMaterials[slotName] = material;
}
// 获取插槽材质
getSlotMaterial(slotName: string) {
return this._slotMaterials[slotName];
}
// 获取材质
get(slotName: string, texture: Texture2D, blendMode: number): Material {
const premul = this._renderer.premultipliedAlpha;
// 1. 插槽材质优先
const slotMat = this._slotMaterials[slotName];
if (slotMat) {
slotMat.shaderData.setTexture("material_SpineTexture", texture);
setBlendMode(slotMat, blendMode, premul);
return slotMat;
}
// 2. 自定义材质优先
if (this.customMaterial) {
this.customMaterial.shaderData.setTexture("material_SpineTexture", texture);
setBlendMode(this.customMaterial, blendMode, premul);
return this.customMaterial;
}
// 3. 默认材质 + 缓存管理
const key = `${texture.instanceId}_${blendMode}_${premul ? 1 : 0}`;
let cached = SpineMaterialManager._materialCache[key];
if (!cached) {
const template = this._getOrCreateTemplate("normal", premul); // 默认以 normal 为基础
cached = template.clone();
setBlendMode(cached, blendMode, premul);
SpineMaterialManager._materialCache[key] = cached;
}
cached.shaderData.setTexture("material_SpineTexture", texture);
return cached;
}
private _getOrCreateTemplate(mode: "normal" | "screen" | "add" | "multiply", premul: boolean): Material {
const key = `${mode}_${premul ? 1 : 0}`;
if (SpineMaterialManager._templateMaterials[key]) return SpineMaterialManager._templateMaterials[key];
const mat = new SpineMaterial(this._engine);
setBlendMode(mat, mode, premul);
SpineMaterialManager._templateMaterials[key] = mat;
return mat;
}
}
class SpineAnimationRenderer extends Renderer {
readonly materialManager = new SpineMaterialManager(this.engine, this);
set customMaterial(mtl) {
this.materialManager.customMaterial = mtl;
}
get customMaterial() {
return this.materialManager.customMaterial;
}
setSlotMaterial(slotName: string, mtl: Material) {
this.materialManager.setSlotMaterial(slotName, mtl);
}
getSlotMaterial(slotName: string) {
return this.materialManager.getSlotMaterial(slotName);
}
}
const { slotName, blendMode, texture, subPrimitive } = renderItem;
renderer._addSubPrimitive(subPrimitive);
const subTexture = _separateSlotTextureMap.get(slotName) || texture.getImage();
// 这里get一下就好
const material = renderer.materialManager.get(slotName, subTexture, blendMode);
renderer.setMaterial(i, material);
核心 API
| 功能 | API | 示例 |
|---|---|---|
| 设置全局材质 | renderer.customMaterial = mat |
renderer.customMaterial = dissolveMat |
| 设置插槽材质 | setSlotMaterial(slot, mat) |
renderer.setSlotMaterial("head", edgeMat) |
| 获取插槽材质 | getSlotMaterial(slot) |
const mat = renderer.getSlotMaterial("body") |