Skip to content

feat(spine): support rendering spine inside UICanvas#3068

Open
cptbtptpbcptdtptp wants to merge 1 commit into
galacean:fix/shaderlabfrom
cptbtptpbcptdtptp:feat/ui-spine
Open

feat(spine): support rendering spine inside UICanvas#3068
cptbtptpbcptdtptp wants to merge 1 commit into
galacean:fix/shaderlabfrom
cptbtptpbcptdtptp:feat/ui-spine

Conversation

@cptbtptpbcptdtptp

Copy link
Copy Markdown
Collaborator

Summary

Spine can now be hosted by the UI system as well as world space. New package @galacean/engine-spine-ui exposes UISpineAnimationRenderer extends UIRenderer:

  • collected / ordered / culled by its root canvas like Image/Text (WorldSpace, ScreenSpaceCamera, and the overlay pass — overlay assigns subShader itself, matching Image)
  • UIGroup alpha folds into skeleton vertex colors (rebuilt every frame; PMA-aware)
  • RectMask2D clips it; rect hit-testing via UITransform (size defines the raycast area only — the skeleton renders at the entity transform scale, and the inherited color property intentionally does not tint the skeleton: skeleton.color is the spine-native way)
  • keeps its own primitive and per-slot materials; declared unbatchable (_canBatch → false), so the constructor-gated batcher passes its elements through untouched

Shared render core

The world renderer's primitive/buffer/material-cache/skeleton bookkeeping moved into shared SpineRendererUtils + SpineMaterialCache (SpineAnimationRenderer keeps its public API and behavior; _materialCacheMap remains as an alias). Both hosts and both version backends share one implementation:

  • ISpineRenderTarget.globalAlpha (default 1): the 3.8/4.2 generators multiply it into the final vertex alpha before premultiplication, so PMA light/dark colors follow automatically — a 2-line change per generator, no other backend changes
  • Spine shader: new RENDERER_UI_RECT_CLIP macro variant derives the rect-clip world position from renderer_ModelMat (spine vertices are entity-local, unlike pre-transformed UI chunk vertices; the UI host always writes the full transform set so the model matrix stays fresh in the overlay pass) and fades PMA rgb explicitly — hard clip discards on the rect factor, not final alpha, since additive slots legitimately emit zero-alpha fragments; plus ENGINE_SHOULD_SRGB_CORRECT output correction for overlay parity with UIDefault

Why a separate package

@galacean/engine-spine must stay free of a @galacean/engine-ui dependency (world-only users, UMD script-tag globals). Same layering as the spine version-backend packages.

e2e harness fix (pre-existing breakage)

Spine e2e cases could not run at all on this branch: package dist is downleveled to ES5, and SpineTexture (ES5 class in spine-core-4.2 dist) extends the external @esotericsoftware/spine-core native class → Class constructor Texture cannot be invoked without 'new'. The case dev server now aliases the spine packages to source (scoped to spine only; every other case still runs against dist). The existing spineboy/tintBlack baselines pass unchanged under this fix — which also serves as pixel-level regression evidence for the world-path refactor.

Note: the ES5-dist-extends-native-class problem equally affects the published @galacean/engine-spine-core-* artifacts; bundling spine-core vs. raising the build target deserves a separate decision.

Tests

  • Unit (17 new, tests/src/spine-ui/): canvas collection (world-space + overlay incl. subShader), group alpha vertex assertions (straight + PMA), alpha=0 element skip, material-cache sharing across hosts, full-transform injection, skeleton-derived bounds, rect hit-test, RectMask wiring, clone (fresh skeleton/state, shared data), destroy (cache eviction + primitive release), batching opt-out
  • e2e (2 new + baselines): spine-ui-canvas — world renderer vs UI renderer render identically side by side, third one faded by UIGroup; spine-ui-rect-mask — masked vs unmasked. All 4 Spine cases green locally
  • Existing spine/ui/loader/math/rhi-webgl unit suites pass

Pre-existing failures on a clean fix/shaderlab checkout, unrelated to this PR (verified via stash baseline runs): Text > get bounds (tests/src/ui/Text.test.ts); local-only vitest browser-mode infra issues in the core (websocket WS_ERR_UNSUPPORTED_MESSAGE_LENGTH) and shader-lab (collection error) suites.

Out of scope

  • Stencil Mask interaction with spine is untested (RectMask2D is the supported clipping path)
  • SpineResource.instantiate() still instantiates the world-space template; UI usage is addComponent(UISpineAnimationRenderer) + the resource setter

🤖 Generated with Claude Code

Add @galacean/engine-spine-ui with UISpineAnimationRenderer, the UI-space
host for spine: collected/ordered/culled by its root canvas (world-space,
screen-space camera and overlay), faded by UIGroup alpha, clipped by
RectMask2D and hit-tested against the UITransform rect.

- Extract the world renderer's primitive/buffer/material-cache/skeleton
  bookkeeping into shared SpineRendererUtils; SpineAnimationRenderer keeps
  its public API and behavior, both hosts share one SpineMaterialCache
- Add ISpineRenderTarget.globalAlpha; both version generators fold it into
  the final vertex alpha before premultiplication so PMA light/dark colors
  follow automatically
- Spine shader: RENDERER_UI_RECT_CLIP macro variant derives the rect-clip
  world position from renderer_ModelMat (spine vertices are entity-local,
  unlike pre-transformed UI chunk vertices) and fades PMA rgb explicitly;
  overlay pass sRGB output correction for parity with the UI shader
- e2e: serve spine packages from source in the case dev server — their ES5
  dist subclasses of native @esotericsoftware/spine-core classes throw
  "Class constructor cannot be invoked without 'new'", so spine cases could
  not run at all; existing spineboy/tintBlack baselines pass unchanged
- tests: 17-case UISpineAnimationRenderer suite; e2e: spine-ui-canvas and
  spine-ui-rect-mask with baselines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • ^(dev/)?\d+.\d+$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8fbcc641-6933-4983-819a-145cfae9bee8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审查 @ 75c9348db(首轮)— feat(spine): support rendering spine inside UICanvas

总结

新增 @galacean/engine-spine-ui 包,UISpineAnimationRenderer extends UIRenderer,让 spine 能像 Image/Text 一样被 UICanvas 采集/排序/裁剪。同时把世界渲染器的 primitive/buffer/material-cache/skeleton 簿记抽进共享的 SpineRendererUtils + SpineMaterialCacheSpineAnimationRenderer+21/-107,是真去重不是加壳)。方向完全正确、抽象健康:共享核心恰好 2 个真实消费者(world + UI),新包只承载 UI 专属 host;世界路径逐字对齐、shader 与 uiDefault.fs.glsl 的 rect-clip 逻辑同源。逐链核对无 P0/P1,仅两条环境类 [P2](均不阻塞代码正确性)+ 两条 [P3]。是 stacked 在 fix/shaderlab 上的干净 PR(同 #3063 base)。

逐链核对(非信 PR 描述 / commit message,均对 fix/shaderlab tree 实读)

  • UIRenderer/UICanvas/Engine 契约全部坐实_getGlobalAlpha()(UIRenderer.ts:230 =this._getGroup()?._getGlobalAlpha()??1)、_rectMasks(:90)、@dependentComponents(UITransform,AutoAdd)(:32)、_getRootCanvas/engine._renderElementPool/canvas._renderElements/_realRenderMode/_sortDistance/sortOrder 均存在且与 Image._render(advanced/Image.ts:266-328) 同构使用——overlay 分支 renderElement.subShader = material.shader.subShaders[0] 逐字一致(overlay pass 不走 pipeline 的 pushRenderElement 故 host 自赋 subShader)。_renderImage._render 不能共用 helper(Image 走 subChunk/assembler,spine 走 subPrimitives 多 draw),是忠实适配非可去重的重复。
  • _updateTransformShaderData 覆写有据且必需UIRenderer 基类覆写走 _updateWorldSpaceTransformShaderData(视顶点已在世界空间、model=identity),但 spine 顶点是 entity-local,故本类跳过父覆写直调 Renderer.prototype._updateTransformShaderData.call(this,context,false) 写真 renderer_ModelMat,并强制 onlyMVP=false 让 overlay pass 也刷新 renderer_ModelMat(rect-clip varying v_worldPosition = renderer_ModelMat*POSITION 依赖它)。多写 3 个矩阵/帧但 spine 不合批、量小可忽略。测试 :1542 反向钉住(overlay onlyMVP=true 时 model 仍须写)。
  • shader rect-clip 是 uiDefault.fs.glsl 的忠实移植 + PMA 增补:四个 renderer_UIRectClip* uniform 均为 UI 系统真实注册的 ShaderProperty(UIRenderer.ts:47-53),getUIRectClipAlpha()uiDefault.fs.glsl:12 逐字相同;差异仅:① spine 用 RENDERER_UI_RECT_CLIP 宏门控(world spine 永不 enable=零开销,UI spine 构造期 enableMacro),UIDefault 无条件计算——这是对的(world spine 无 UI 概念不该带这段码,宏变种恰当);② 新增 gl_FragColor.rgb *= mix(1.0, rectClipAlpha, renderer_PremultipliedAlpha) 因 spine 用 PMA 而 UIDefault 不用。renderer_PremultipliedAlphaSpineMaterial._setPremultipliedAlpha(:45) 恒设、outputSRGBCorrectioncommon.glsl:55(spine #include <common> 在 scope)、ENGINE_SHOULD_SRGB_CORRECT#ifdef 门控(未定义即跳过,无编译风险)。
  • globalAlpha 只影响顶点色 alpha、不动 bounds:两 generator(3.8/4.2 同一两行改动)finalAlpha = ...*globalAlpha 只喂 finalColor.a + PMA 的 rgb 缩放;_localBounds_expandBounds(x,y,z) 只吃位置。故改 group alpha 不动几何包围盒(测试 :1559 "computes bounds from the skeleton, not the UITransform rect" 钉住)。globalAlpha=0finalAlpha=0 → rgb=0/alpha=0,无除法无 NaN,安全。_renderglobalAlpha<=0 早返(测试 :1505 钉 group alpha=0 时 push 0 个 renderElement)。世界渲染器 readonly globalAlpha=1 定值,与接口 ISpineRenderTarget.globalAlpha:readonly 一致。
  • 共享 material cache 对齐SpineAnimationRenderer._materialCacheMap = SpineMaterialCache._cacheMap(alias 保 public API),world/UI 都经 SpineMaterialCache.getMaterial 同 key(含 tintBlack)→ 跨 host 共享(测试 :1525 钉 world 与 UI 拿同一 material)。destroySpinePrimitive refCount 逐链归零(primitive._addReferCount(-1) 级联到 binding buffer)与 #3063 二轮核过的一致。
  • clone 正确_cloneTo 空 skeleton 守卫 + 共享不可变 SkeletonData/AnimationStateData 只新建 per-instance skeleton/state,defaultConfig @deepClone(同世界渲染器),SpineAnimationDefaultConfig 现从 spine 包 index 导出复用。测试 :1600/:1621 双向覆盖(含未绑 resource 时 clone 不抛)。

问题

[P2] CI 白名单:本 PR target=fix/shaderlab 不在 ci.ymlpull_request: branchesmain/dev/*)内 → 仅 labeler 跑,build×3 / e2e×4 / lint / codecov 全静默不跑。 gh pr checks 3068 现只有 labeler 通过。PR 里真正被重写/新增的部分(共享核心抽取、RENDERER_UI_RECT_CLIP shader 变种、两个新 e2e case)恰是渲染/结构改动,却零 CI 覆盖,"4 个 spine case 本地全绿"无 CI 实证。同 #3063 的判例——建议合并前在 base 本地跑 e2e 四 case 对图,或在 fix/shaderlabdev/2.0#2983)合并时由完整 CI 补验。不阻塞(受 shaderlab 线合并粒度约束)。

[P2] e2e ES5-dist 别名 hack 揭示的更深问题(已在 PR 描述标注,记录不阻塞): vite.config.js 把 4 个 spine 包别名到 src(scoped 到 spine,其它 case 仍走 dist),修的是"ES5 dist 子类 extends 外部 @esotericsoftware/spine-core native class 抛 Class constructor cannot be invoked without 'new'"这个 pre-existing 破坏。PR 已诚实指出发布产物 @galacean/engine-spine-core-* 同样受影响、"bundle spine-core vs 抬 build target"值得单独决策。方向正确、scope 收敛,仅提示这个 debt 别忘。

[P3] UISpineAnimationRenderer.ts:485 _canBatch(): boolean 覆写丢了基类的两个入参(基类 Renderer._canBatch(preElement, curElement) / UIRenderer._canBatch(preElement, curElement))。TS 合法(收窄参数)、运行时正确(恒 false),测试 _canBatch(null, null) 也能跑;纯签名一致性,与 _batch(): void {} 同。可保留基类签名以对齐,锦上添花。

[P3] UISpineAnimationRenderer.ts:467 @remarks 续行缺 * 前缀\n If this option is enabled, the Spine editor must export... 没有 * 前导),是从世界渲染器 SpineAnimationRenderer.ts:52 逐字带进的既有格式瑕疵——但本新文件是新出现,顺手补上 * 对齐 JSDoc 块即可。

简化建议

代码已相当干净,没有可去的冗余。共享核心抽取(SpineRendererUtils + SpineMaterialCache)是本 PR 最有价值的部分:把 world 渲染器里的 primitive 构造 / buffer 绑定 / material cache / skeleton-state 构造收口成纯函数 + 单一 cache 类,两个 host 共享一份实现,SpineAnimationRenderer 反而瘦了 86 行。这正是"大道至简"——新增能力却减少了重复真相源。

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants