Skip to content

feat(spine): build Spine in as a 2D skeletal animation solution (4.2 backend)#3050

Closed
cptbtptpbcptdtptp wants to merge 5 commits into
galacean:dev/2.0from
cptbtptpbcptdtptp:feat/builtin-spine
Closed

feat(spine): build Spine in as a 2D skeletal animation solution (4.2 backend)#3050
cptbtptpbcptdtptp wants to merge 5 commits into
galacean:dev/2.0from
cptbtptpbcptdtptp:feat/builtin-spine

Conversation

@cptbtptpbcptdtptp

@cptbtptpbcptdtptp cptbtptpbcptdtptp commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

RFC: Spine 内置化与多版本内核拆分

状态 Draft
日期 2026-06-22
基线 origin/dev/2.0 (97d8d610c, v2.0.0-alpha.34)
分支 feat/builtin-spine
实现 packages/spinepackages/spine-core-4.2(已落地);packages/spine-core-3.8(待做)

1. 摘要

把 Spine 从独立仓库收进 engine,并按引擎物理包的 provider 模式把版本内核拆成可替换后端:共享门面 @galacean/engine-spine(版本无关)+ engine-spine-core-4.2 / -3.8 两个内核(对标 physics-physx / physics-lite)。对外 API 与 engine-spine 4.2 分支一致。Phase 1/2 已落地、tsc 通过;3.8 后端与真机渲染冒烟待做。

2. 架构

三个包,职责对标物理:

物理类比 职责
@galacean/engine-spine Collider + IPhysics 用户面 API + 窄工厂接口,运行时不碰 spine-core
@galacean/engine-spine-core-4.2 physics-physx npm spine-core 4.2 + 生成器 + Runtime 实现
@galacean/engine-spine-core-3.8 physics-lite vendored spine-core 3.8 + 生成器 + Runtime 实现

接缝(IPhysics 的对应物):

  • ISpineRuntime — 解析、实例化、步进(updateState)、生成(buildPrimitive)。后端实现它。
  • ISpineRenderTarget — 门面 SpineAnimationRenderer 暴露给后端生成器写入的窄契约(缓冲/材质/包围盒),不含 spine-core 类型。

注入走副作用自注册:后端 importregisterSpineRuntime(new Spine42Runtime()),门面经 getSpineRuntime() 取。不进 engine config——Spine 是卫星包,不污染 engine core。

3. 关键决策

D1 — facade 切在"渲染数据 + 高频控制",不抽象整个 spine 对象模型。 版本相关的解析/步进/生成进后端,Galacean 侧缓冲/材质/绘制留门面;深层 spine-core 对象直接暴露原生类型,不重造中立模型。

D2 — spine-core 类的 import 路径落到内核包(physics-faithful)。门面 export * 不再含 spine-core,由内核包 re-export。代价:手动构造 spine-core 对象的高级用法,import 从 engine-spine 改到 engine-spine-core-4.2(常规用法不变)。

D3 — 着色器内置进 packages/shader engine 2.0 移除了运行时 GLSL 字符串 shader,故重写为内置 ShaderLab 2D/Spine(随引擎预编译、ShaderPool 注册,同 UI 的 2D/UIDefault);SpineMaterialShader.find,per-material blend 走 shaderData 绑定。全工程唯一非直译处,公开行为不变。

D4 — 命名 engine-spine-core-4.2 / -3.8 core 前缀避免裸版本号与包自身 semver 撞车。

D5 — 单 runtime 注册表。 SpineRuntimeRegistry 后注册覆盖,一个 app 一个内核。

D6 — 默认安装期单版本(A);运行时多版本并存(B)为可选。

  • 同时支持多版本的唯一先例是 pixi-spine(per-version 包 + 按骨骼头版本号分发的 uni-loader);官方 Spine / Unity / Cocos 都是一工程一版本。
  • 多版本的真实代价是包体积(多一份 spine-core),不是帧率——每个 skeleton 只跑自己版本的代码,update 随活跃骨骼数线性 O(n),与版本数无关。
  • B 改动小:registry 按版本建键 + loader 版本探测。仅"一个 app 混合不同年代资产"才需要。

D7 — es5 目标下的两处构建适配(Phase 4 冒烟才暴露,tsc 看不到)。

  • SpineTexture extends Texture(spine-core):monorepo 编 es5,外部 spine-core 是原生 es6 类,es5 子类 super() 调 es6 基类崩。解:后端 bundle spine-core(spine-core 降为 devDependency)+ rollup swc exclude 放行 @esotericsoftware 一并转 es5。
  • _render:原版 4.2 用 engine 1.x 的 _subRenderElementPool(2.0 已移除)。改为 2.0 模型——每个 sub-mesh 一个 engine._renderElementPool.get() + renderElement.set(...) + pushRenderElement(对标 MeshRenderer)。

4. 决议(原未决问题)

  1. AnimationStateData → 共享(N:1) ✅ 已实现。_cloneTo 复用源的 AnimationStateData,resource 上配的 mix 时长随之对所有实例生效;只有 per-instance 的 Skeleton / AnimationState 是新建的。
  2. 不做运行时多版本并存(B),锁定安装期单版本(A)。
  3. Phase 4 真机渲染冒烟 — 4.2 ✅ 通过(spineboy 正常渲染、色彩正确);冒烟发现并修复 2 处 tsc/预编译看不到的 1.x→2.0 内部漂移(见 D7)。
  4. 实现 3.8 后端 — 待做(见 Phase 3)。

5. 对象模型:资产 / 组件 / 数据

分类 对象 说明
资产 SpineResource("Spine") 持 SkeletonData + AnimationStateData + 模板 Entity
TextureAtlas("SpineAtlas")、Texture2D
SpineMaterial 自动创建+缓存,用内置 shader
组件 SpineAnimationRenderer 唯一,1 / Entity
共享数据 SkeletonDataAnimationStateData 资产内,所有实例公用
实例数据 SkeletonAnimationState 每个 renderer 私有,每帧 mutate

基数:

  • 1 SpineResource ──instantiate 1:N──> N SpineAnimationRenderer
  • 1 SpineAnimationRenderer1 Skeleton + 1 AnimationState
  • N Skeleton ──N:1 共享──> 1 SkeletonData
  • Runtime 全局单例,N skeleton : 1 runtime(共享的是代码不是工作)

6. 版本差异

4.2 3.8
spine-core npm 依赖 vendored 内置(无 Physics)
生成器 SpineGenerator MeshGenerator
世界变换 updateWorldTransform(Physics.update) updateWorldTransform()

差异被各自 Runtime.updateState() 吸收,门面与 ISpineRuntime 不感知——即抽象的收益。

7. 用法

import "@galacean/engine-spine-core-4.2";                       // 副作用:注册 4.2 内核
import { SpineAnimationRenderer } from "@galacean/engine-spine"; // 渲染器 / loader(版本无关)
import { Skeleton } from "@galacean/engine-spine-core-4.2";      // 仅手动构造 spine-core 对象时

8. 动机与非目标

动机:engine-spine 现为独立仓库、绑定单一 spine-core 版本,用户无法自由选版本,集成也游离于引擎外。

非目标:不复刻 3.8 的 SpineRenderer / SpineAnimation(它们 extends Script,与 4.2 extends Renderer 是两套 API);统一用 4.2 形状门面驱动 3.8 资产。默认不做运行时多版本并存。

9. 实施状态

  • Phase 1(基线)✅ packages/spine = 4.2 迁到 engine 2.0(12 处漂移修复);2D/Spine.shader 预编译+注册;闭包 tsc 零错误。
  • Phase 2(核心拆分)✅ 接口/注册表/枚举 + engine-spine-core-4.2 + Spine42Runtime;门面运行时零 spine-core(全 import type);闭包 tsc 零错误。
  • Phase 3(3.8 后端)⬜ vendored 3.8 core + Spine38Runtime + 适配生成器(读 3.8 attachment API,写共享顶点格式)。含 tint black:generator 读 slot.darkColorDARK_COLOR,让 3.8 资产也渲染两色染色(原版 3.8 galacean 没做;数据层与共享 shader 都现成)。
  • Phase 4(验证)— 4.2 ✅ rollup 全量构建通过;真机加载 spineboy(4.2.22)正常渲染、零报错、颜色正确。3.8 待 Phase 3 后同样验证。

10. 参考

Summary by CodeRabbit

  • New Features

    • Added built-in support for Spine 4.2 content, including loading, runtime playback, and rendering.
    • Added a new Spine animation renderer and material support for Spine scenes.
    • Added end-to-end coverage for a Spine Boy scene.
  • Bug Fixes

    • Improved handling of Spine-related dependencies so Spine assets and shaders load correctly at runtime.
    • Updated texture atlas and shader assets to support Spine rendering.

Move engine-spine into the engine monorepo following the physics provider pattern: a
version-agnostic facade plus a pluggable version backend. Public API matches the
engine-spine 4.2 branch.

- @galacean/engine-spine: user-facing SpineAnimationRenderer + loaders, owns the
  Galacean-side buffers/material, with zero spine-core runtime dependency. ISpineRuntime and
  ISpineRenderTarget are the seam; the backend self-registers on import, mirroring IPhysics
  with physics-physx / physics-lite.
- @galacean/engine-spine-core-4.2: bundles @esotericsoftware/spine-core 4.2, the mesh
  generator and Spine42Runtime.
- 2D/Spine ShaderLab shader, precompiled and registered in ShaderPool.
- e2e: spineboy case.

The 3.8 backend (@galacean/engine-spine-core-3.8) follows in a separate PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@augmentcode

augmentcode Bot commented Jun 24, 2026

Copy link
Copy Markdown

This pull request is abnormally large and would use a significant amount of tokens to review. If you still wish to review it, comment "augment review" and we will review it.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

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: 64f7a2b7-4042-47fd-961c-f09797401e31

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

Walkthrough

This PR introduces two new packages — @galacean/engine-spine and @galacean/engine-spine-core-4.2 — that add Spine skeletal animation to the Galacean engine. It defines a versioned runtime plugin architecture (ISpineRuntime/ISpineRenderTarget), implements a full Spine 4.2 backend (Spine42Runtime, SpineGenerator, SpineTexture), adds a custom 2D/Spine GPU shader, wires asset loaders and a SpineAnimationRenderer component, and includes a Spineboy E2E test.

Changes

Spine Animation Integration

Layer / File(s) Summary
Runtime contracts: interfaces, registry, and shared constants
packages/spine/src/runtime/ISpineRuntime.ts, packages/spine/src/runtime/ISpineRenderTarget.ts, packages/spine/src/runtime/SpineRuntimeRegistry.ts, packages/spine/src/enums/SpineBlendMode.ts, packages/spine/src/SpineConstant.ts
Defines ISpineRuntime and ISpineRenderTarget interfaces as the version-neutral plugin contracts, adds registerSpineRuntime/getSpineRuntime registry functions, and introduces SpineBlendMode enum and SpineVertexStride constants shared between the renderer and versioned backends.
Spine GPU shader: compiled asset, source index, and ShaderPool wiring
packages/shader/compiledShaders/2D/Spine.shaderc, packages/shader/compiledShaders/index.ts, packages/shader/src/Shaders/index.ts, packages/galacean/src/ShaderPool.ts
Adds the compiled 2D/Spine shader artifact encoding vertex/fragment programs for tint-black and premultiplied-alpha blending, registers it in both the compiled-shader and source-shader index files, and wires it into ShaderPool.registerShaders() for precompilation at engine startup.
Spine 4.2 backend: SpineTexture, utility pools, Spine42Runtime, and SpineGenerator
packages/spine-core-4.2/src/SpineTexture.ts, packages/spine-core-4.2/src/util/ClearablePool.ts, packages/spine-core-4.2/src/util/ReturnablePool.ts, packages/spine-core-4.2/src/Spine42Runtime.ts, packages/spine-core-4.2/src/SpineGenerator.ts
SpineTexture adapts Texture2D to Spine's texture interface (filter/wrap mapping, mipmap). ClearablePool/ReturnablePool provide object pooling for render items. Spine42Runtime implements ISpineRuntime for Spine 4.2 (atlas parsing, skeleton/state construction, per-frame physics update). SpineGenerator traverses the draw order to build vertices/indices with clipping, tint-black, blend-mode, and sub-primitive grouping.
Spine 4.2 package manifest, tsconfig, and entry point
packages/spine-core-4.2/package.json, packages/spine-core-4.2/tsconfig.json, packages/spine-core-4.2/src/index.ts
Defines the @galacean/engine-spine-core-4.2 npm package, TypeScript build config, and an index.ts that self-registers Spine42Runtime on import and re-exports all @esotericsoftware/spine-core APIs.
SpineMaterial and SpineAnimationRenderer
packages/spine/src/renderer/SpineMaterial.ts, packages/spine/src/renderer/SpineAnimationRenderer.ts, packages/spine/src/renderer/index.ts
SpineMaterial wraps the 2D/Spine shader with blend-mode, tint-black, and premultiplied-alpha configuration. SpineAnimationRenderer is a Renderer + ISpineRenderTarget that drives per-frame runtime updates, manages dynamic GPU vertex/index buffers, caches materials by texture/blend key, pushes sub-primitives into the camera pipeline, and handles bounds, clone, and destroy lifecycle.
Asset loading: LoaderUtils, SpineAtlasLoader, SpineLoader, SpineResource
packages/spine/src/loader/LoaderUtils.ts, packages/spine/src/loader/SpineAtlasLoader.ts, packages/spine/src/loader/SpineLoader.ts, packages/spine/src/loader/SpineResource.ts, packages/spine/src/loader/index.ts
LoaderUtils provides static helpers for atlas/texture/skeleton creation. SpineAtlasLoader loads .atlas files with associated images. SpineLoader loads skeleton JSON/binary plus the atlas and returns a SpineResource. SpineResource (a ReferResource) encapsulates skeleton/state data, manages texture reference association, and exposes instantiate() via entity-template cloning.
Package manifests, tsconfig, rollup, and engine entry-point wiring
packages/spine/package.json, packages/spine/tsconfig.json, packages/spine/src/index.ts, rollup.config.js
Adds the @galacean/engine-spine npm manifest and tsconfig, the package entry point that registers loader/renderer classes into the engine Loader and re-exports the public API, and updates rollup.config.js to transpile @esotericsoftware/spine-core through SWC to avoid ES5/ES6 subclass constructor issues.
E2E test: spineboy atlas asset and test case
e2e/.dev/public/spineboy.atlas, e2e/case/spine-spineboy.ts, e2e/package.json
Adds the Spineboy texture atlas with region definitions, an E2E test that loads Spine assets, instantiates the entity with a looping idle animation, and triggers screenshot capture; adds the two new spine workspace dependencies to the e2e package.

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant SpineLoader
  participant SpineAtlasLoader
  participant LoaderUtils
  participant ISpineRuntime as Spine42Runtime
  participant SpineResource
  participant SpineAnimationRenderer

  App->>SpineLoader: load({ url: "spineboy.json" })
  SpineLoader->>SpineAtlasLoader: load({ url: "spineboy.atlas" })
  SpineAtlasLoader->>LoaderUtils: loadTexturesByPaths(imagePaths)
  LoaderUtils-->>SpineAtlasLoader: Texture2D[]
  SpineAtlasLoader->>ISpineRuntime: createTextureAtlas(atlasText, textures)
  ISpineRuntime-->>SpineAtlasLoader: TextureAtlas
  SpineLoader->>ISpineRuntime: createSkeletonData(rawData, atlas, scale)
  ISpineRuntime-->>SpineLoader: SkeletonData
  SpineLoader->>SpineResource: new SpineResource(engine, skeletonData)
  SpineResource->>SpineAnimationRenderer: attach renderer with Skeleton + AnimationState

  Note over SpineAnimationRenderer,ISpineRuntime: Per-frame rendering loop
  SpineAnimationRenderer->>ISpineRuntime: updateState(skeleton, state, delta)
  SpineAnimationRenderer->>ISpineRuntime: buildPrimitive(skeleton, renderer)
  ISpineRuntime->>SpineAnimationRenderer: _addSubPrimitive / _getMaterial
  SpineAnimationRenderer-->>App: render elements pushed to camera pipeline
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Suggested labels

enhancement, Animation

Suggested reviewers

  • hhhhkrx
  • zhuxudong

Poem

🐰 A rabbit hops into the spine's embrace,
Where bones and slots dance with elegant grace.
The atlas is parsed, the shader compiled bright,
Each sub-primitive glows with tintBlack delight.
With pools and runtimes all wired in a line,
The spineboy now animates — idle and fine! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding Spine as a 2D skeletal animation solution with a 4.2 backend.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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.

@cptbtptpbcptdtptp cptbtptpbcptdtptp marked this pull request as draft June 24, 2026 06:43
@cptbtptpbcptdtptp cptbtptpbcptdtptp self-assigned this Jun 24, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (3)
packages/spine/src/renderer/SpineAnimationRenderer.ts (1)

178-179: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead guard. _subPrimitives is initialized to [] and only ever reset/refilled, so !_subPrimitives is never true. The loop already handles the empty case. Safe to drop, or guard on .length if early-out is intended.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine/src/renderer/SpineAnimationRenderer.ts` around lines 178 -
179, The guard in SpineAnimationRenderer’s update path is unreachable because
_subPrimitives is always initialized and only emptied/refilled, so
!this._subPrimitives will never fire. Remove the dead early return in the method
that destructures _primitive, _subPrimitives, and _materials, or change it to
check _subPrimitives.length if you want to skip work when there are no
sub-primitives.
packages/spine/src/loader/SpineLoader.ts (1)

116-139: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hardcoded skeleton scale (0.01).

createSkeletonData(skeletonRawData, textureAtlas, 0.01) bakes in a magic scale. If this is intentional for engine units, consider extracting a named constant; if it should be configurable, surface it via SpineLoaderParams. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine/src/loader/SpineLoader.ts` around lines 116 - 139, The Spine
resource creation path hardcodes the skeleton scale in
_loadAndCreateSpineResource via LoaderUtils.createSkeletonData, which makes the
unit conversion an opaque magic value. Update SpineLoader to replace the inline
0.01 with a named constant if it is a fixed engine-wide convention, or thread
the scale through SpineLoaderParams and use it in _loadAndCreateSpineResource so
the behavior is explicit and configurable.
packages/spine-core-4.2/src/index.ts (1)

15-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unconditional console.log on import.

This logs to every consumer's console as an import side effect, including production builds. Consider gating it behind a dev/debug check or removing it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine-core-4.2/src/index.ts` at line 15, The top-level console.log
in the index module is running on every import as a side effect. Remove that
unconditional logging or guard it behind a dev/debug condition so the module
initialization in spine-core-4.2 does not emit to consumer consoles in
production; update the index.ts entrypoint where version is logged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/spine-core-4.2/package.json`:
- Around line 31-40: Promote `@esotericsoftware/spine-core` out of devDependencies
in package.json because src/index.ts re-exports it and the generated
types/index.d.ts exposes that module specifier. Move it to dependencies or
peerDependencies alongside the existing `@galacean/engine` entries so consumers
installing the package get the runtime module they need.

In `@packages/spine-core-4.2/src/SpineGenerator.ts`:
- Around line 108-144: Wrap each non-fallthrough switch branch in
SpineGenerator’s attachment type switch with its own block so the case-local
bindings are scoped correctly. In the RegionAttachment, MeshAttachment, and
ClippingAttachment cases, enclose the declarations for regionAttachment,
meshAttachment, and clip in { } and keep the existing logic inside those blocks,
while leaving the default branch unchanged.

In `@packages/spine-core-4.2/src/SpineTexture.ts`:
- Around line 18-26: The setFilters method in SpineTexture is checking the wrong
parameter for the mipmap/trilinear path, which makes that branch unreachable.
Update the condition that sets TextureFilterMode.Trilinear to use minFilter
instead of magFilter, keeping the existing TextureFilter.Nearest handling and
Bilinear fallback intact. This should align with the mipmap logic already driven
by generateMipmaps() in the SpineTexture constructor.

In `@packages/spine/src/loader/LoaderUtils.ts`:
- Around line 46-49: The rejection handling in LoaderUtils.loadTexturesByPaths
is swallowing the original failure by calling reject(error) and then resolving
with an empty array, which lets callers continue with bad state. Update the
catch path in loadTexturesByPaths to rethrow or return a rejected promise
instead of returning [], so the Promise.all chain in SpineAtlasLoader.load
short-circuits immediately and preserves the original error from texture
loading.

In `@packages/spine/src/loader/SpineAtlasLoader.ts`:
- Around line 14-39: The Spine atlas asset path collection is missing image
extension tracking, so KTX/KTX2 files are never recognized correctly. Update
SpineAtlasLoader._groupAssetsByExtension and
SpineAtlasLoader._assignAssetPathsFromUrl to populate
SpineAtlasAsset.imageExtensions alongside imagePaths, preserving the extension
for each collected image URL/virtual path. Make sure the extension is pushed
whenever atlas dependencies are expanded so LoaderUtils.loadTexturesByPaths can
read the correct per-image format; also remove or otherwise address the unused
resourceManager parameter in _groupAssetsByExtension if it is no longer needed.
- Around line 68-78: The atlas loading path in SpineAtlasLoader should use the
validated resolved atlas path instead of shadowing it with item.url, which can
be undefined when item.urls is used. Update the loadTextureAtlas call in the
imagePaths.length === 0 branch to use spineAtlasAsset.atlasPath (the same
resolved value already computed earlier in the loader) so the texture atlas is
loaded from the correct path.

In `@packages/spine/src/renderer/SpineAnimationRenderer.ts`:
- Around line 317-323: In SpineAnimationRenderer’s _onDestroy cache cleanup,
guard the material.shaderData.getTexture("material_SpineTexture") result before
using texture.instanceId, since it can be null and currently can throw during
teardown. Also make the cache key computation match _getMaterial by using the
same premultiplied-alpha source as the material key logic instead of
this.premultipliedAlpha, so materialCache.delete targets the exact entry and
does not leave stale cache items behind.
- Around line 299-311: `SpineAnimationRenderer._getMaterial` is reading
`_materialCacheMap` with bracket/property access instead of `Map.get`, so the
cache lookup always misses and new `SpineMaterial` instances are created
repeatedly. Update the lookup to use the map API consistently with the existing
`.set()` and `.delete()` calls, and keep the rest of the material reuse logic
unchanged so cached materials are actually returned.

---

Nitpick comments:
In `@packages/spine-core-4.2/src/index.ts`:
- Line 15: The top-level console.log in the index module is running on every
import as a side effect. Remove that unconditional logging or guard it behind a
dev/debug condition so the module initialization in spine-core-4.2 does not emit
to consumer consoles in production; update the index.ts entrypoint where version
is logged.

In `@packages/spine/src/loader/SpineLoader.ts`:
- Around line 116-139: The Spine resource creation path hardcodes the skeleton
scale in _loadAndCreateSpineResource via LoaderUtils.createSkeletonData, which
makes the unit conversion an opaque magic value. Update SpineLoader to replace
the inline 0.01 with a named constant if it is a fixed engine-wide convention,
or thread the scale through SpineLoaderParams and use it in
_loadAndCreateSpineResource so the behavior is explicit and configurable.

In `@packages/spine/src/renderer/SpineAnimationRenderer.ts`:
- Around line 178-179: The guard in SpineAnimationRenderer’s update path is
unreachable because _subPrimitives is always initialized and only
emptied/refilled, so !this._subPrimitives will never fire. Remove the dead early
return in the method that destructures _primitive, _subPrimitives, and
_materials, or change it to check _subPrimitives.length if you want to skip work
when there are no sub-primitives.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a123da16-c16c-49dc-b710-3bb01ae304da

📥 Commits

Reviewing files that changed from the base of the PR and between 39b4b08 and dd71455.

⛔ Files ignored due to path filters (3)
  • e2e/.dev/public/spineboy.png is excluded by !**/*.png
  • packages/shader/src/Shaders/2D/Spine.shader is excluded by !**/*.shader
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (33)
  • e2e/.dev/public/spineboy.atlas
  • e2e/.dev/public/spineboy.json
  • e2e/case/spine-spineboy.ts
  • e2e/package.json
  • packages/galacean/src/ShaderPool.ts
  • packages/shader/compiledShaders/2D/Spine.shaderc
  • packages/shader/compiledShaders/index.ts
  • packages/shader/src/Shaders/index.ts
  • packages/spine-core-4.2/package.json
  • packages/spine-core-4.2/src/Spine42Runtime.ts
  • packages/spine-core-4.2/src/SpineGenerator.ts
  • packages/spine-core-4.2/src/SpineTexture.ts
  • packages/spine-core-4.2/src/index.ts
  • packages/spine-core-4.2/src/util/ClearablePool.ts
  • packages/spine-core-4.2/src/util/ReturnablePool.ts
  • packages/spine-core-4.2/tsconfig.json
  • packages/spine/package.json
  • packages/spine/src/SpineConstant.ts
  • packages/spine/src/enums/SpineBlendMode.ts
  • packages/spine/src/index.ts
  • packages/spine/src/loader/LoaderUtils.ts
  • packages/spine/src/loader/SpineAtlasLoader.ts
  • packages/spine/src/loader/SpineLoader.ts
  • packages/spine/src/loader/SpineResource.ts
  • packages/spine/src/loader/index.ts
  • packages/spine/src/renderer/SpineAnimationRenderer.ts
  • packages/spine/src/renderer/SpineMaterial.ts
  • packages/spine/src/renderer/index.ts
  • packages/spine/src/runtime/ISpineRenderTarget.ts
  • packages/spine/src/runtime/ISpineRuntime.ts
  • packages/spine/src/runtime/SpineRuntimeRegistry.ts
  • packages/spine/tsconfig.json
  • rollup.config.js

Comment on lines +31 to +40
"dependencies": {
"@galacean/engine-spine": "workspace:*"
},
"devDependencies": {
"@esotericsoftware/spine-core": "~4.2.66",
"@galacean/engine": "workspace:*"
},
"peerDependencies": {
"@galacean/engine": "workspace:*"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect spine-core-4.2 entry to confirm public re-export surface and check sibling package dep classification.
fd -t f 'index.ts' packages/spine-core-4.2/src --exec cat -n {}
echo "---- package.json deps ----"
fd -t f 'package.json' packages/spine-core-4.2 packages/spine --exec sh -c 'echo "== $1 =="; jq "{dependencies,devDependencies,peerDependencies}" "$1"' _ {}

Repository: galacean/engine

Length of output: 1484


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== packages/spine-core-4.2/package.json =="
cat -n packages/spine-core-4.2/package.json

echo "== packages/spine-core-4.2/tsconfig.json =="
cat -n packages/spine-core-4.2/tsconfig.json

echo "== packages/spine-core-4.2 source tree =="
fd -t f . packages/spine-core-4.2 | sed 's#^\./##'

echo "== package metadata refs =="
rg -n '"(name|main|module|types|exports|files|publishConfig|bundleDependencies|dependencies|devDependencies|peerDependencies)"' packages/spine-core-4.2 -g 'package.json' -g 'tsconfig*.json'

Repository: galacean/engine

Length of output: 3297


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== packages/spine-core-4.2/package.json =="
cat -n packages/spine-core-4.2/package.json

echo "== packages/spine-core-4.2/tsconfig.json =="
cat -n packages/spine-core-4.2/tsconfig.json

echo "== packages/spine-core-4.2 source tree =="
fd -t f . packages/spine-core-4.2 | sed 's#^\./##'

echo "== package metadata refs =="
rg -n '"(name|main|module|types|exports|files|publishConfig|bundleDependencies|dependencies|devDependencies|peerDependencies)"' packages/spine-core-4.2 -g 'package.json' -g 'tsconfig*.json'

Repository: galacean/engine

Length of output: 3297


Promote @esotericsoftware/spine-core out of devDependencies src/index.ts re-exports it, so the published types/index.d.ts exposes that module specifier. Consumers installing this package won’t get @esotericsoftware/spine-core from devDependencies, so it should be a runtime dependency or peer dependency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine-core-4.2/package.json` around lines 31 - 40, Promote
`@esotericsoftware/spine-core` out of devDependencies in package.json because
src/index.ts re-exports it and the generated types/index.d.ts exposes that
module specifier. Move it to dependencies or peerDependencies alongside the
existing `@galacean/engine` entries so consumers installing the package get the
runtime module they need.

Comment on lines +108 to +144
switch (attachment.constructor) {
case RegionAttachment:
const regionAttachment = <RegionAttachment>attachment;
attachmentColor = regionAttachment.color;
numFloats = clippedVertexSize << 2;
regionAttachment.computeWorldVertices(slot, tempVerts, 0, clippedVertexSize);
triangles = SpineGenerator.QUAD_TRIANGLES;
uvs = regionAttachment.uvs;
texture = regionAttachment.region.texture;
break;
case MeshAttachment:
const meshAttachment = <MeshAttachment>attachment;
attachmentColor = meshAttachment.color;
numFloats = (meshAttachment.worldVerticesLength >> 1) * clippedVertexSize;
if (numFloats > _vertices.length) {
SpineGenerator.tempVerts = new Array(numFloats);
}
meshAttachment.computeWorldVertices(
slot,
0,
meshAttachment.worldVerticesLength,
tempVerts,
0,
clippedVertexSize
);
triangles = meshAttachment.triangles;
uvs = meshAttachment.uvs;
texture = meshAttachment.region.texture;
break;
case ClippingAttachment:
let clip = <ClippingAttachment>attachment;
_clipper.clipStart(slot, clip);
continue;
default:
_clipper.clipEndWithSlot(slot);
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wrap switch case bodies in blocks to scope the declarations.

regionAttachment (Line 110), meshAttachment (Line 119), and clip (Line 138) are declared directly inside case clauses, so their bindings are visible to sibling cases and can hit a temporal dead zone. Enclose each case in { }.

🔧 Proposed fix
       switch (attachment.constructor) {
-        case RegionAttachment:
+        case RegionAttachment: {
           const regionAttachment = <RegionAttachment>attachment;
           attachmentColor = regionAttachment.color;
           numFloats = clippedVertexSize << 2;
           regionAttachment.computeWorldVertices(slot, tempVerts, 0, clippedVertexSize);
           triangles = SpineGenerator.QUAD_TRIANGLES;
           uvs = regionAttachment.uvs;
           texture = regionAttachment.region.texture;
           break;
-        case MeshAttachment:
+        }
+        case MeshAttachment: {
           const meshAttachment = <MeshAttachment>attachment;
           attachmentColor = meshAttachment.color;
           numFloats = (meshAttachment.worldVerticesLength >> 1) * clippedVertexSize;
           if (numFloats > _vertices.length) {
             SpineGenerator.tempVerts = new Array(numFloats);
           }
           meshAttachment.computeWorldVertices(
             slot,
             0,
             meshAttachment.worldVerticesLength,
             tempVerts,
             0,
             clippedVertexSize
           );
           triangles = meshAttachment.triangles;
           uvs = meshAttachment.uvs;
           texture = meshAttachment.region.texture;
           break;
-        case ClippingAttachment:
+        }
+        case ClippingAttachment: {
           let clip = <ClippingAttachment>attachment;
           _clipper.clipStart(slot, clip);
           continue;
+        }
         default:
           _clipper.clipEndWithSlot(slot);
           continue;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
switch (attachment.constructor) {
case RegionAttachment:
const regionAttachment = <RegionAttachment>attachment;
attachmentColor = regionAttachment.color;
numFloats = clippedVertexSize << 2;
regionAttachment.computeWorldVertices(slot, tempVerts, 0, clippedVertexSize);
triangles = SpineGenerator.QUAD_TRIANGLES;
uvs = regionAttachment.uvs;
texture = regionAttachment.region.texture;
break;
case MeshAttachment:
const meshAttachment = <MeshAttachment>attachment;
attachmentColor = meshAttachment.color;
numFloats = (meshAttachment.worldVerticesLength >> 1) * clippedVertexSize;
if (numFloats > _vertices.length) {
SpineGenerator.tempVerts = new Array(numFloats);
}
meshAttachment.computeWorldVertices(
slot,
0,
meshAttachment.worldVerticesLength,
tempVerts,
0,
clippedVertexSize
);
triangles = meshAttachment.triangles;
uvs = meshAttachment.uvs;
texture = meshAttachment.region.texture;
break;
case ClippingAttachment:
let clip = <ClippingAttachment>attachment;
_clipper.clipStart(slot, clip);
continue;
default:
_clipper.clipEndWithSlot(slot);
continue;
}
switch (attachment.constructor) {
case RegionAttachment: {
const regionAttachment = <RegionAttachment>attachment;
attachmentColor = regionAttachment.color;
numFloats = clippedVertexSize << 2;
regionAttachment.computeWorldVertices(slot, tempVerts, 0, clippedVertexSize);
triangles = SpineGenerator.QUAD_TRIANGLES;
uvs = regionAttachment.uvs;
texture = regionAttachment.region.texture;
break;
}
case MeshAttachment: {
const meshAttachment = <MeshAttachment>attachment;
attachmentColor = meshAttachment.color;
numFloats = (meshAttachment.worldVerticesLength >> 1) * clippedVertexSize;
if (numFloats > _vertices.length) {
SpineGenerator.tempVerts = new Array(numFloats);
}
meshAttachment.computeWorldVertices(
slot,
0,
meshAttachment.worldVerticesLength,
tempVerts,
0,
clippedVertexSize
);
triangles = meshAttachment.triangles;
uvs = meshAttachment.uvs;
texture = meshAttachment.region.texture;
break;
}
case ClippingAttachment: {
let clip = <ClippingAttachment>attachment;
_clipper.clipStart(slot, clip);
continue;
}
default:
_clipper.clipEndWithSlot(slot);
continue;
}
🧰 Tools
🪛 Biome (2.5.0)

[error] 110-110: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

(lint/correctness/noSwitchDeclarations)


[error] 119-119: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

(lint/correctness/noSwitchDeclarations)


[error] 138-138: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

(lint/correctness/noSwitchDeclarations)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine-core-4.2/src/SpineGenerator.ts` around lines 108 - 144, Wrap
each non-fallthrough switch branch in SpineGenerator’s attachment type switch
with its own block so the case-local bindings are scoped correctly. In the
RegionAttachment, MeshAttachment, and ClippingAttachment cases, enclose the
declarations for regionAttachment, meshAttachment, and clip in { } and keep the
existing logic inside those blocks, while leaving the default branch unchanged.

Source: Linters/SAST tools

Comment on lines +18 to +26
setFilters(minFilter: TextureFilter, magFilter: TextureFilter) {
if (minFilter === TextureFilter.Nearest) {
this._image.filterMode = TextureFilterMode.Point;
} else if (magFilter === TextureFilter.MipMapLinearLinear) {
this._image.filterMode = TextureFilterMode.Trilinear;
} else {
this._image.filterMode = TextureFilterMode.Bilinear;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file and related texture/filter code
git ls-files | rg '^packages/spine-core-4\.2/src/.*(SpineTexture|Texture|TextureFilter).*(ts|js)$|^packages/spine-core-4\.2/src/SpineTexture\.ts$|^packages/spine-core-4\.2/src/Texture.*\.ts$' || true

echo '--- SpineTexture.ts outline ---'
ast-grep outline packages/spine-core-4.2/src/SpineTexture.ts --view expanded || true

echo '--- Search for setFilters and mipmap filter usage ---'
rg -n "setFilters|MipMapLinearLinear|Trilinear|generateMipmaps|TextureFilterMode" packages/spine-core-4.2/src -S || true

Repository: galacean/engine

Length of output: 1414


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant file with line numbers in a small slice
sed -n '1,120p' packages/spine-core-4.2/src/SpineTexture.ts | cat -n

echo '--- Search for original engine-spine implementation in repo ---'
rg -n "class SpineTexture|setFilters\\(|MipMapLinearLinear|TextureFilterMode" . -S || true

Repository: galacean/engine

Length of output: 10397


🌐 Web query:

spine runtime TextureFilter setFilters magFilter MipMapLinearLinear minFilter trilinear source

💡 Result:

In the Spine runtime, the TextureFilter enumeration defines options for texture sampling, including MipMapLinearLinear [1][2]. When calling setFilters(minFilter, magFilter), these values are used to configure the GPU's texture sampling behavior [1][3]. Important considerations regarding these filters include: 1. Magnification Filter Constraints: Standard graphics APIs (like WebGL) generally only support Nearest or Linear for the magnification filter (magFilter) [4]. Attempting to use a MipMap-based filter (such as MipMapLinearLinear) as a magnification filter will typically cause errors or be invalid [4]. To handle this, implementations (such as the WebGL backend) often include validation logic to automatically fall back to Linear if an invalid MipMap filter is provided for magnification [3]. 2. Minification and MipMaps: MipMapLinearLinear (and other MipMap filters) are valid for the minification filter (minFilter) [4]. When a MipMap-based minification filter is selected, the runtime typically flags the texture to use MipMaps and may trigger a call to generate them (e.g., gl.generateMipmap in WebGL) [3]. 3. Atlas Configuration: The filter setting in a.atlas file defines these values for texture pages [5]. When loading, the runtime interprets the filter string to set the minFilter and magFilter for the corresponding texture page [6][5]. If you are manually configuring textures in code, ensure that only Linear or Nearest are passed as the magFilter to avoid runtime API errors [4][3]. In summary, while MipMapLinearLinear exists as a valid enumeration member for Spine textures [1][2], it should only be applied to the minification filter [4][3]. The runtime's setFilters method is responsible for applying these states to the underlying graphics API, and developers should ensure valid combinations are provided to satisfy GPU constraints [7][3].

Citations:


Use minFilter for the mipmap branch. TextureFilter.MipMapLinearLinear is a minification value, so checking magFilter makes the trilinear path unreachable. Switch the condition to minFilter; generateMipmaps() in the constructor already matches that case.

🔧 Proposed fix
   setFilters(minFilter: TextureFilter, magFilter: TextureFilter) {
     if (minFilter === TextureFilter.Nearest) {
       this._image.filterMode = TextureFilterMode.Point;
-    } else if (magFilter === TextureFilter.MipMapLinearLinear) {
+    } else if (minFilter === TextureFilter.MipMapLinearLinear) {
       this._image.filterMode = TextureFilterMode.Trilinear;
     } else {
       this._image.filterMode = TextureFilterMode.Bilinear;
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setFilters(minFilter: TextureFilter, magFilter: TextureFilter) {
if (minFilter === TextureFilter.Nearest) {
this._image.filterMode = TextureFilterMode.Point;
} else if (magFilter === TextureFilter.MipMapLinearLinear) {
this._image.filterMode = TextureFilterMode.Trilinear;
} else {
this._image.filterMode = TextureFilterMode.Bilinear;
}
}
setFilters(minFilter: TextureFilter, magFilter: TextureFilter) {
if (minFilter === TextureFilter.Nearest) {
this._image.filterMode = TextureFilterMode.Point;
} else if (minFilter === TextureFilter.MipMapLinearLinear) {
this._image.filterMode = TextureFilterMode.Trilinear;
} else {
this._image.filterMode = TextureFilterMode.Bilinear;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine-core-4.2/src/SpineTexture.ts` around lines 18 - 26, The
setFilters method in SpineTexture is checking the wrong parameter for the
mipmap/trilinear path, which makes that branch unreachable. Update the condition
that sets TextureFilterMode.Trilinear to use minFilter instead of magFilter,
keeping the existing TextureFilter.Nearest handling and Bilinear fallback
intact. This should align with the mipmap logic already driven by
generateMipmaps() in the SpineTexture constructor.

Source: Linters/SAST tools

Comment on lines +46 to +49
return Promise.all(texturePromises).catch((error) => {
reject(error);
return [];
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Swallowed rejection lets callers continue with empty textures.

On failure this calls reject(error) but then resolves the returned promise with []. Callers like SpineAtlasLoader.load (Promise.all([...text, LoaderUtils.loadTexturesByPaths(...)])) will still proceed into createTextureAtlas(atlasText, []), which can throw a second, less meaningful error before the original reject settles the outer AssetPromise. Prefer rethrowing so the promise chain short-circuits cleanly.

🛡️ Proposed fix
-    return Promise.all(texturePromises).catch((error) => {
-      reject(error);
-      return [];
-    });
+    return Promise.all(texturePromises).catch((error) => {
+      reject(error);
+      throw error;
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return Promise.all(texturePromises).catch((error) => {
reject(error);
return [];
});
return Promise.all(texturePromises).catch((error) => {
reject(error);
throw error;
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine/src/loader/LoaderUtils.ts` around lines 46 - 49, The rejection
handling in LoaderUtils.loadTexturesByPaths is swallowing the original failure
by calling reject(error) and then resolving with an empty array, which lets
callers continue with bad state. Update the catch path in loadTexturesByPaths to
rethrow or return a rejected promise instead of returning [], so the Promise.all
chain in SpineAtlasLoader.load short-circuits immediately and preserves the
original error from texture loading.

Comment on lines +14 to +39
private static _groupAssetsByExtension(url: string, assetPath: SpineAtlasAsset, resourceManager: ResourceManager) {
let ext = SpineLoader._getUrlExtension(url);
if (!ext) return;

if (ext === "atlas") {
assetPath.atlasPath = url;
}
if (["png", "jpg", "webp", "jpeg", "ktx", "ktx2"].includes(ext)) {
assetPath.imagePaths.push(url);
}
}

private static _assignAssetPathsFromUrl(url: string, assetPath: SpineAtlasAsset, resourceManager: ResourceManager) {
const ext = SpineLoader._getUrlExtension(url);
if (ext === "atlas") {
assetPath.atlasPath = url;
// @ts-ignore
const atlasDependency = resourceManager?._virtualPathResourceMap?.[url]?.dependentAssetMap;
if (atlasDependency) {
for (let key in atlasDependency) {
const imageVirtualPath = atlasDependency[key];
assetPath.imagePaths.push(imageVirtualPath);
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

imageExtensions is never populated, breaking KTX/KTX2 detection.

Both _groupAssetsByExtension and _assignAssetPathsFromUrl compute ext/image paths but never push into spineAtlasAsset.imageExtensions. Downstream, LoaderUtils.loadTexturesByPaths(imagePaths, imageExtensions, ...) (Line 86) reads imageExtensions[index], which is always undefined, so the ktx/ktx2 branches never fire and those textures are loaded as AssetType.Texture. The resourceManager parameter in _groupAssetsByExtension is also unused.

🐛 Proposed fix
-  private static _groupAssetsByExtension(url: string, assetPath: SpineAtlasAsset, resourceManager: ResourceManager) {
+  private static _groupAssetsByExtension(url: string, assetPath: SpineAtlasAsset) {
     let ext = SpineLoader._getUrlExtension(url);
     if (!ext) return;

     if (ext === "atlas") {
       assetPath.atlasPath = url;
     }
     if (["png", "jpg", "webp", "jpeg", "ktx", "ktx2"].includes(ext)) {
       assetPath.imagePaths.push(url);
+      assetPath.imageExtensions.push(ext);
     }
   }

Apply the same imageExtensions.push(...) in _assignAssetPathsFromUrl where image virtual paths are collected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine/src/loader/SpineAtlasLoader.ts` around lines 14 - 39, The
Spine atlas asset path collection is missing image extension tracking, so
KTX/KTX2 files are never recognized correctly. Update
SpineAtlasLoader._groupAssetsByExtension and
SpineAtlasLoader._assignAssetPathsFromUrl to populate
SpineAtlasAsset.imageExtensions alongside imagePaths, preserving the extension
for each collected image URL/virtual path. Make sure the extension is pushed
whenever atlas dependencies are expanded so LoaderUtils.loadTexturesByPaths can
read the correct per-image format; also remove or otherwise address the unused
resourceManager parameter in _groupAssetsByExtension if it is no longer needed.

Comment on lines +68 to +78
const imagePaths = spineAtlasAsset.imagePaths;
if (imagePaths.length === 0) {
const atlasPath = item.url;
LoaderUtils.loadTextureAtlas(atlasPath, engine, reject)
.then((textureAtlas) => {
resolve(textureAtlas);
})
.catch((err) => {
reject(err);
});
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the resolved atlasPath, not item.url.

When item.urls is provided (no separate images), item.url may be undefined, yet Line 70 shadows the outer atlasPath with item.url and passes it to loadTextureAtlas. Use the already-validated spineAtlasAsset.atlasPath instead.

🐛 Proposed fix
       const imagePaths = spineAtlasAsset.imagePaths;
       if (imagePaths.length === 0) {
-        const atlasPath = item.url;
         LoaderUtils.loadTextureAtlas(atlasPath, engine, reject)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const imagePaths = spineAtlasAsset.imagePaths;
if (imagePaths.length === 0) {
const atlasPath = item.url;
LoaderUtils.loadTextureAtlas(atlasPath, engine, reject)
.then((textureAtlas) => {
resolve(textureAtlas);
})
.catch((err) => {
reject(err);
});
} else {
const imagePaths = spineAtlasAsset.imagePaths;
if (imagePaths.length === 0) {
LoaderUtils.loadTextureAtlas(atlasPath, engine, reject)
.then((textureAtlas) => {
resolve(textureAtlas);
})
.catch((err) => {
reject(err);
});
} else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine/src/loader/SpineAtlasLoader.ts` around lines 68 - 78, The
atlas loading path in SpineAtlasLoader should use the validated resolved atlas
path instead of shadowing it with item.url, which can be undefined when
item.urls is used. Update the loadTextureAtlas call in the imagePaths.length ===
0 branch to use spineAtlasAsset.atlasPath (the same resolved value already
computed earlier in the loader) so the texture atlas is loaded from the correct
path.

Comment on lines +299 to +311
const key = `${texture.instanceId}_${blendMode}_${premultipliedAlpha ? 1 : 0}`;
let cached = SpineAnimationRenderer._materialCacheMap[key] as SpineMaterial;
if (!cached) {
cached = new SpineMaterial(engine);
cached.isGCIgnored = true;
SpineAnimationRenderer._materialCacheMap.set(key, cached);
}
cached._setBlendMode(blendMode, premultipliedAlpha);
cached._setTexture(texture);
cached._setTintBlack(tintBlack);
cached._setPremultipliedAlpha(premultipliedAlpha);
return cached;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Cache read uses Map property access — always misses, leaking materials.

_materialCacheMap is a Map, but the lookup on Line 300 uses property/bracket access (_materialCacheMap[key]) while writes use .set() (Line 304) and deletes use .delete() (Line 322). Property access on a Map never returns values stored via .set(), so cached is always undefined. A new SpineMaterial is created on every _getMaterial call, and since each is marked isGCIgnored = true, they are never reclaimed — a per-frame memory/GPU leak and wasted allocation.

🐛 Proposed fix
-    let cached = SpineAnimationRenderer._materialCacheMap[key] as SpineMaterial;
+    let cached = SpineAnimationRenderer._materialCacheMap.get(key);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const key = `${texture.instanceId}_${blendMode}_${premultipliedAlpha ? 1 : 0}`;
let cached = SpineAnimationRenderer._materialCacheMap[key] as SpineMaterial;
if (!cached) {
cached = new SpineMaterial(engine);
cached.isGCIgnored = true;
SpineAnimationRenderer._materialCacheMap.set(key, cached);
}
cached._setBlendMode(blendMode, premultipliedAlpha);
cached._setTexture(texture);
cached._setTintBlack(tintBlack);
cached._setPremultipliedAlpha(premultipliedAlpha);
return cached;
}
const key = `${texture.instanceId}_${blendMode}_${premultipliedAlpha ? 1 : 0}`;
let cached = SpineAnimationRenderer._materialCacheMap.get(key);
if (!cached) {
cached = new SpineMaterial(engine);
cached.isGCIgnored = true;
SpineAnimationRenderer._materialCacheMap.set(key, cached);
}
cached._setBlendMode(blendMode, premultipliedAlpha);
cached._setTexture(texture);
cached._setTintBlack(tintBlack);
cached._setPremultipliedAlpha(premultipliedAlpha);
return cached;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine/src/renderer/SpineAnimationRenderer.ts` around lines 299 -
311, `SpineAnimationRenderer._getMaterial` is reading `_materialCacheMap` with
bracket/property access instead of `Map.get`, so the cache lookup always misses
and new `SpineMaterial` instances are created repeatedly. Update the lookup to
use the map API consistently with the existing `.set()` and `.delete()` calls,
and keep the rest of the material reuse logic unchanged so cached materials are
actually returned.

Comment on lines +317 to +323
for (let i = 0, len = materials.length; i < len; i += 1) {
const material = materials[i] as SpineMaterial;
const texture = material.shaderData.getTexture("material_SpineTexture");
const blendMode = material._getBlendMode();
const key = `${texture.instanceId}_${blendMode}_${premultipliedAlpha ? 1 : 0}`;
materialCache.delete(key);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against a null texture during cache cleanup.

shaderData.getTexture(...) can return null; the subsequent texture.instanceId would then throw during _onDestroy. Since the key here is also computed using this.premultipliedAlpha rather than each material's own value, a mismatch with the key built in _getMaterial can additionally leave stale entries.

🛡️ Proposed guard
       const material = materials[i] as SpineMaterial;
       const texture = material.shaderData.getTexture("material_SpineTexture");
+      if (!texture) continue;
       const blendMode = material._getBlendMode();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (let i = 0, len = materials.length; i < len; i += 1) {
const material = materials[i] as SpineMaterial;
const texture = material.shaderData.getTexture("material_SpineTexture");
const blendMode = material._getBlendMode();
const key = `${texture.instanceId}_${blendMode}_${premultipliedAlpha ? 1 : 0}`;
materialCache.delete(key);
}
for (let i = 0, len = materials.length; i < len; i += 1) {
const material = materials[i] as SpineMaterial;
const texture = material.shaderData.getTexture("material_SpineTexture");
if (!texture) continue;
const blendMode = material._getBlendMode();
const key = `${texture.instanceId}_${blendMode}_${premultipliedAlpha ? 1 : 0}`;
materialCache.delete(key);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/spine/src/renderer/SpineAnimationRenderer.ts` around lines 317 -
323, In SpineAnimationRenderer’s _onDestroy cache cleanup, guard the
material.shaderData.getTexture("material_SpineTexture") result before using
texture.instanceId, since it can be null and currently can throw during
teardown. Also make the cache key computation match _getMaterial by using the
same premultiplied-alpha source as the material key logic instead of
this.premultipliedAlpha, so materialCache.delete targets the exact entry and
does not leave stale cache items behind.

cptbtptpbcptdtptp and others added 4 commits June 24, 2026 18:56
e2e: spineboy basic render, plus a tint-black two-color case using the real tank-pro
asset sampled at the muzzle-smoke frame where tintBlack on/off differs ~7% (registered
in config.ts with 0.05 tolerance).

unit (tests/src/spine): pool reuse, runtime registry, vertex stride and blend ordinal
contracts, loader url parsing, SpineMaterial blend factors, SpineAnimationRenderer
tintBlack setter and material cache.

fix: SpineAnimationRenderer._getMaterial read its cache via Map[key] (always undefined)
instead of .get(key), so every sub-mesh allocated a fresh SpineMaterial every frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	packages/shader/compiledShaders/index.ts
The packages/spine-core-3.8 directory was never committed, but a leftover
lockfile importer entry for it remained, which pnpm install prunes.
The merge's pre-commit hook auto-fixed Object -> object in this file per
the newly-merged ESLint config, which made the implementation signature's
AssetPromise.all(promises) return type no longer assignable (T is
unconstrained here since it must satisfy both the single-item and
collection overloads).
@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.89831% with 819 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.42%. Comparing base (721b42a) to head (369102e).

Files with missing lines Patch % Lines
packages/spine-core-4.2/src/SpineGenerator.ts 0.00% 302 Missing and 1 partial ⚠️
...kages/spine/src/renderer/SpineAnimationRenderer.ts 51.42% 119 Missing ⚠️
packages/spine/src/loader/SpineLoader.ts 14.81% 92 Missing ⚠️
packages/spine/src/loader/LoaderUtils.ts 26.19% 62 Missing ⚠️
packages/spine/src/loader/SpineResource.ts 20.54% 58 Missing ⚠️
packages/spine-core-4.2/src/Spine42Runtime.ts 0.00% 49 Missing and 1 partial ⚠️
packages/spine-core-4.2/src/SpineTexture.ts 0.00% 35 Missing and 1 partial ⚠️
e2e/case/spine-tint-black.ts 0.00% 29 Missing and 1 partial ⚠️
e2e/case/spine-spineboy.ts 0.00% 27 Missing and 1 partial ⚠️
e2e/config.ts 0.00% 14 Missing ⚠️
... and 6 more
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3050      +/-   ##
===========================================
+ Coverage    79.37%   79.42%   +0.05%     
===========================================
  Files          903      925      +22     
  Lines       100632   101995    +1363     
  Branches     11260    11323      +63     
===========================================
+ Hits         79879    81014    +1135     
- Misses       20569    20794     +225     
- Partials       184      187       +3     
Flag Coverage Δ
unittests 79.42% <33.89%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cptbtptpbcptdtptp

Copy link
Copy Markdown
Collaborator Author

Superseded by #3057 — same branch, now pushed directly to galacean/engine instead of a fork.

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.

1 participant