engine-spine icon indicating copy to clipboard operation
engine-spine copied to clipboard

Refactor Spine Material System to Support Custom Shaders for Advanced Effects

Open johanzhu opened this issue 1 year ago • 1 comments

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

johanzhu avatar Feb 17 '25 09:02 johanzhu

背景

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 级别的材质自定义。

一、默认材质系统

  1. 预定义四种混合模式材质
  • 使用 CanvasItemMaterial 预置 Normal/Additive/Multiply/Screen 四种默认材质
  • 存储在 SpineSpriteStatics 单例中
  • 根据 Slot 的 blendMode 自动选择对应默认材质

二、全局材质覆盖

  1. SpineSprite 提供四组材质属性:
    • normal_material
    • additive_material
    • multiply_material
    • screen_material
  2. 优先级高于默认材质
  3. 通过 setter 方法设置后,所有对应混合模式的 slot 都会使用该材质

三、Slot 级材质覆盖

  1. 通过 SpineSlotNode 节点实现
  2. 每个 SpineSlotNode 可设置四种混合模式材质
  3. 附加到具体 slot 时可覆盖全局材质设置
  4. 实现原理:
    • 在 generate_meshes_for_slots 时建立 slot 索引映射
    • 渲染时检查 slot 对应的 SpineSlotNode 是否存在
    • 优先使用 SpineSlotNode 设置的材质

四、材质选择优先级

  1. SpineSlotNode 材质(最高优先级)
  2. SpineSprite 自定义材质
  3. 默认材质(最低优先级)

核心代码

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 实现的自定义材质能力。

这里的自定义材质功能有两个:

  1. Custom Slot Materials

自定义插槽的材质。可以设置多个:

  1. 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 这样简洁的设计。理由如下

  1. ❌ Unity 材质系统太重,复杂程度远超实际需求

Unity 为 Spine 提供了 十几种内置着色器,覆盖 URP、LWRP、Standard 管线、Outline、多种 Tint、多种混合模式、光照/非光照、Sprite 专用、Canvas 专用等场景。这种设计初衷是兼容各种渲染管线和平台差异,但带来的问题是:

  • 材质概念繁多,用户学习成本高
  • 对于轻量项目开发者,美术和前端都需理解大量 Unity 专属的 shader pipeline、ZWrite、PMA 等概念,理解成本高
  • 实现复杂,维护困难,容易形成耦合。

✅ 而 Galacean 引擎只使用了一到两个基础 shader(默认 / Tint),当前阶段根本不需要如此繁复的系统设计

  1. 目前追求:易用性 > 复杂性,直觉式工作流更符合目标用户

Galacean 引擎当前主打轻量化和移动端场景,希望能让用户快速上手。

根据上述调研可以发现:

  • Cocos 和 Godot 的方案更加易于理解,用户只需要拖一个材质即可,立即在编辑器中看到效果;
  • 无需理解材质变体、管线兼容性等高级概念;
  • 编辑器 UI 也更简洁清晰,支持预览、快捷设置
  1. 当前没有多 shader 需求,设计复杂系统属于“超前优化”

目前我们的 Spine 运行时只维护了两套材质:

  • Spine/Skeleton: 默认无光照 shader;
  • Spine/Skeleton Tint: 支持双色 Tint Black 的 shader。

Unity 的十几种 shader 设计是因为它要同时兼容:

  • 多种渲染管线(Built-in / URP / LWRP)
  • 多种使用场景(3D Sprite / Canvas / Outline / Shadow)

但在我们的引擎中:

  • 没有渲染管线切换需求;
  • 没有那么多光照、边缘光、法线、RT 等需求;
  • 不需要提前做那么多材质资产;

所以:独立出 Material、做一个高度通用的系统,不仅意义不大,还会造成复杂系统的早产。

  1. 性能优先 + 可扩展性留口,兼顾现在与未来

当下的设计目标是:

  • 保持运行时性能:默认路径无插槽 override,即可最大合批;
  • 让用户在需要时开启自定义材质:插槽级材质仅按需使用;

如果有定制化的需求,使用自定义材质即可。

具体方案

编辑器功能设计

核心功能

Galacean 提供对全局(渲染器)以及特定插槽的材质自定义。

交互形式

  1. 提供额外的自定义材质配置项,能够传入一个自定义的材质球。

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

运行时设计

材质实现方案调整

主要修改点:

  • 新增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")

johanzhu avatar Apr 02 '25 09:04 johanzhu