feat(2d): support rotated sprites in atlas#2990
Conversation
WalkthroughThe PR expands sprite UV handling to support a full 16-vertex (4×4) grid with atlas 90° rotation awareness. Sprite's UV storage and computation are rewritten; size calculation accounts for rotated atlas dimensions; the loader explicitly initializes the rotation flag; and three assemblers adapt their UV reading to use column-major indexing into the new grid. ChangesUV Grid Expansion and Atlas Rotation Support
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/core/src/2d/sprite/Sprite.ts`:
- Around line 299-310: The sprite's cached size and UVs must be invalidated when
_atlasRotated changes: in the atlasRotated setter (the code that flips
this._atlasRotated), after toggling the boolean clear the cached computed size
and UVs by setting this._automaticWidth and this._automaticHeight to undefined
(or null) and force UV recompute/clear by invoking or resetting whatever
_getUVs() cache (e.g., call this._getUVs() or set the UV cache to null) and mark
the sprite dirty so width/height/_getUVs() will be recalculated; apply the same
invalidation logic wherever _atlasRotated can change.
- Around line 352-366: The rotated branch misapplies trim offsets—when
atlasRotated is true you must rotate the offsets and border axes to match the
packed axes mapping (original region/offset left/top/right/bottom →
bottom/left/top/right); update the computations that set left/top/right/bottom
and bLeft/bTop/bRight/bBottom to use the swapped offsets and border components:
for the X-side math (calculations that use atlasRegionW/realWidth and
regionBottom/regionTop) use offsetBottom/offsetTop and border.y/w where
appropriate, and for the Y-side math (calculations that use
atlasRegionH/realHeight and regionLeft/regionRight) use offsetLeft/offsetRight
and border.x/z accordingly so trimmed rotated sprites and 9-slice boundaries
align correctly (references: atlasRotated, realWidth, realHeight,
atlasRegionX/Y/W/H, regionLeft/Top/Right/Bottom, offsetLeft/Top/Right/Bottom,
border.x/y/w/z, regionW/regionH).
🪄 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: c7a666d6-7c92-41c5-b8a9-6232f629a763
📒 Files selected for processing (5)
packages/core/src/2d/assembler/SimpleSpriteAssembler.tspackages/core/src/2d/assembler/SlicedSpriteAssembler.tspackages/core/src/2d/assembler/TiledSpriteAssembler.tspackages/core/src/2d/sprite/Sprite.tspackages/loader/src/SpriteAtlasLoader.ts
| const { _texture, _atlasRegion, _atlasRegionOffset, _region, _atlasRotated } = this; | ||
| const ppuReciprocal = 1.0 / Engine._pixelsPerUnit; | ||
| // 先算 atlas 中绝对像素(texture 不一定是方形,必须各自乘对应维度) | ||
| const atlasPxW = _texture.width * _atlasRegion.width; | ||
| const atlasPxH = _texture.height * _atlasRegion.height; | ||
| // atlas 顺时针 pack 90°:原图 W×H 在 atlas 中占 H×W 区域,仅交换 atlasPx 的 W/H | ||
| const originWidth = _atlasRotated ? atlasPxH : atlasPxW; | ||
| const originHeight = _atlasRotated ? atlasPxW : atlasPxH; | ||
| this._automaticWidth = | ||
| ((_texture.width * _atlasRegion.width) / (1 - _atlasRegionOffset.x - _atlasRegionOffset.z)) * | ||
| _region.width * | ||
| pixelsPerUnitReciprocal; | ||
| (originWidth / (1 - _atlasRegionOffset.x - _atlasRegionOffset.z)) * _region.width * ppuReciprocal; | ||
| this._automaticHeight = | ||
| ((_texture.height * _atlasRegion.height) / (1 - _atlasRegionOffset.y - _atlasRegionOffset.w)) * | ||
| _region.height * | ||
| pixelsPerUnitReciprocal; | ||
| (originHeight / (1 - _atlasRegionOffset.y - _atlasRegionOffset.w)) * _region.height * ppuReciprocal; |
There was a problem hiding this comment.
Invalidate cached size and UVs when atlasRotated changes.
These paths now depend on _atlasRotated, but the setter at Lines 121-124 still only flips the boolean. If width, height, or _getUVs() has already been evaluated, changing sprite.atlasRotated leaves stale cached results until some other property dirties the sprite.
Suggested fix
set atlasRotated(value: boolean) {
- if (this._atlasRotated != value) {
+ if (this._atlasRotated !== value) {
this._atlasRotated = value;
+ this._dispatchSpriteChange(SpriteModifyFlags.atlasRegion);
+ if (this._customWidth === undefined || this._customHeight === undefined) {
+ this._dispatchSpriteChange(SpriteModifyFlags.size);
+ }
}
}Also applies to: 345-392
🤖 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/core/src/2d/sprite/Sprite.ts` around lines 299 - 310, The sprite's
cached size and UVs must be invalidated when _atlasRotated changes: in the
atlasRotated setter (the code that flips this._atlasRotated), after toggling the
boolean clear the cached computed size and UVs by setting this._automaticWidth
and this._automaticHeight to undefined (or null) and force UV recompute/clear by
invoking or resetting whatever _getUVs() cache (e.g., call this._getUVs() or set
the UV cache to null) and mark the sprite dirty so width/height/_getUVs() will
be recalculated; apply the same invalidation logic wherever _atlasRotated can
change.
| const realWidth = atlasRegionW / (1 - offsetLeft - offsetRight); | ||
| const realHeight = atlasRegionH / (1 - offsetTop - offsetBottom); | ||
| // Coordinates of the four boundaries. | ||
| const left = Math.max(regionX - offsetLeft, 0) * realWidth + atlasRegionX; | ||
| const top = Math.max(regionBottom - offsetTop, 0) * realHeight + atlasRegionY; | ||
| const right = atlasRegionW + atlasRegionX - Math.max(regionRight - offsetRight, 0) * realWidth; | ||
| const bottom = atlasRegionH + atlasRegionY - Math.max(regionY - offsetBottom, 0) * realHeight; | ||
| const { x: borderLeft, y: borderBottom, z: borderRight, w: borderTop } = this._border; | ||
| // Left-Bottom | ||
| uv[0].set(left, bottom); | ||
| // Border ( Left-Bottom ) | ||
| uv[1].set( | ||
| (regionX - offsetLeft + borderLeft * regionW) * realWidth + atlasRegionX, | ||
| atlasRegionH + atlasRegionY - (regionY - offsetBottom + borderBottom * regionH) * realHeight | ||
| ); | ||
| // Border ( Right-Top ) | ||
| uv[2].set( | ||
| atlasRegionW + atlasRegionX - (regionRight - offsetRight + borderRight * regionW) * realWidth, | ||
| (regionBottom - offsetTop + borderTop * regionH) * realHeight + atlasRegionY | ||
| ); | ||
| // Right-Top | ||
| uv[3].set(right, top); | ||
| // 4 个外边界 + 4 个 9-slice 内边界 | ||
| let left: number, top: number, right: number, bottom: number; | ||
| let bLeft: number, bTop: number, bRight: number, bBottom: number; | ||
| if (atlasRotated) { | ||
| // 原图 region/offset (left/top/right/bottom) 在 atlas 中映射为 (bottom/left/top/right) | ||
| left = Math.max(regionBottom - offsetLeft, 0) * realWidth + atlasRegionX; | ||
| top = Math.max(regionLeft - offsetTop, 0) * realHeight + atlasRegionY; | ||
| right = atlasRegionW + atlasRegionX - Math.max(regionTop - offsetRight, 0) * realWidth; | ||
| bottom = atlasRegionH + atlasRegionY - Math.max(regionRight - offsetBottom, 0) * realHeight; | ||
| bLeft = (regionBottom - offsetLeft + border.y * regionH) * realWidth + atlasRegionX; | ||
| bTop = (regionLeft - offsetTop + border.x * regionW) * realHeight + atlasRegionY; | ||
| bRight = atlasRegionW + atlasRegionX - (regionTop - offsetRight + border.w * regionH) * realWidth; | ||
| bBottom = atlasRegionH + atlasRegionY - (regionRight - offsetBottom + border.z * regionW) * realHeight; |
There was a problem hiding this comment.
Rotate the trim offsets with the packed axes.
In the rotated branch, atlas-X is derived from the sprite’s vertical span and atlas-Y from the horizontal span, but the code still feeds offsetLeft/offsetRight into the X-side math and offsetTop/offsetBottom into the Y-side math. That breaks trimmed rotated sprites when horizontal and vertical trims differ, and the 9-slice boundaries drift with them.
Suggested fix
- const realWidth = atlasRegionW / (1 - offsetLeft - offsetRight);
- const realHeight = atlasRegionH / (1 - offsetTop - offsetBottom);
+ const realWidth = atlasRotated
+ ? atlasRegionW / (1 - offsetTop - offsetBottom)
+ : atlasRegionW / (1 - offsetLeft - offsetRight);
+ const realHeight = atlasRotated
+ ? atlasRegionH / (1 - offsetLeft - offsetRight)
+ : atlasRegionH / (1 - offsetTop - offsetBottom);
@@
if (atlasRotated) {
- left = Math.max(regionBottom - offsetLeft, 0) * realWidth + atlasRegionX;
- top = Math.max(regionLeft - offsetTop, 0) * realHeight + atlasRegionY;
- right = atlasRegionW + atlasRegionX - Math.max(regionTop - offsetRight, 0) * realWidth;
- bottom = atlasRegionH + atlasRegionY - Math.max(regionRight - offsetBottom, 0) * realHeight;
- bLeft = (regionBottom - offsetLeft + border.y * regionH) * realWidth + atlasRegionX;
- bTop = (regionLeft - offsetTop + border.x * regionW) * realHeight + atlasRegionY;
- bRight = atlasRegionW + atlasRegionX - (regionTop - offsetRight + border.w * regionH) * realWidth;
- bBottom = atlasRegionH + atlasRegionY - (regionRight - offsetBottom + border.z * regionW) * realHeight;
+ left = Math.max(regionBottom - offsetBottom, 0) * realWidth + atlasRegionX;
+ top = Math.max(regionLeft - offsetLeft, 0) * realHeight + atlasRegionY;
+ right = atlasRegionW + atlasRegionX - Math.max(regionTop - offsetTop, 0) * realWidth;
+ bottom = atlasRegionH + atlasRegionY - Math.max(regionRight - offsetRight, 0) * realHeight;
+ bLeft = (regionBottom - offsetBottom + border.y * regionH) * realWidth + atlasRegionX;
+ bTop = (regionLeft - offsetLeft + border.x * regionW) * realHeight + atlasRegionY;
+ bRight = atlasRegionW + atlasRegionX - (regionTop - offsetTop + border.w * regionH) * realWidth;
+ bBottom = atlasRegionH + atlasRegionY - (regionRight - offsetRight + border.z * regionW) * realHeight;
} else {📝 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.
| const realWidth = atlasRegionW / (1 - offsetLeft - offsetRight); | |
| const realHeight = atlasRegionH / (1 - offsetTop - offsetBottom); | |
| // Coordinates of the four boundaries. | |
| const left = Math.max(regionX - offsetLeft, 0) * realWidth + atlasRegionX; | |
| const top = Math.max(regionBottom - offsetTop, 0) * realHeight + atlasRegionY; | |
| const right = atlasRegionW + atlasRegionX - Math.max(regionRight - offsetRight, 0) * realWidth; | |
| const bottom = atlasRegionH + atlasRegionY - Math.max(regionY - offsetBottom, 0) * realHeight; | |
| const { x: borderLeft, y: borderBottom, z: borderRight, w: borderTop } = this._border; | |
| // Left-Bottom | |
| uv[0].set(left, bottom); | |
| // Border ( Left-Bottom ) | |
| uv[1].set( | |
| (regionX - offsetLeft + borderLeft * regionW) * realWidth + atlasRegionX, | |
| atlasRegionH + atlasRegionY - (regionY - offsetBottom + borderBottom * regionH) * realHeight | |
| ); | |
| // Border ( Right-Top ) | |
| uv[2].set( | |
| atlasRegionW + atlasRegionX - (regionRight - offsetRight + borderRight * regionW) * realWidth, | |
| (regionBottom - offsetTop + borderTop * regionH) * realHeight + atlasRegionY | |
| ); | |
| // Right-Top | |
| uv[3].set(right, top); | |
| // 4 个外边界 + 4 个 9-slice 内边界 | |
| let left: number, top: number, right: number, bottom: number; | |
| let bLeft: number, bTop: number, bRight: number, bBottom: number; | |
| if (atlasRotated) { | |
| // 原图 region/offset (left/top/right/bottom) 在 atlas 中映射为 (bottom/left/top/right) | |
| left = Math.max(regionBottom - offsetLeft, 0) * realWidth + atlasRegionX; | |
| top = Math.max(regionLeft - offsetTop, 0) * realHeight + atlasRegionY; | |
| right = atlasRegionW + atlasRegionX - Math.max(regionTop - offsetRight, 0) * realWidth; | |
| bottom = atlasRegionH + atlasRegionY - Math.max(regionRight - offsetBottom, 0) * realHeight; | |
| bLeft = (regionBottom - offsetLeft + border.y * regionH) * realWidth + atlasRegionX; | |
| bTop = (regionLeft - offsetTop + border.x * regionW) * realHeight + atlasRegionY; | |
| bRight = atlasRegionW + atlasRegionX - (regionTop - offsetRight + border.w * regionH) * realWidth; | |
| bBottom = atlasRegionH + atlasRegionY - (regionRight - offsetBottom + border.z * regionW) * realHeight; | |
| const realWidth = atlasRotated | |
| ? atlasRegionW / (1 - offsetTop - offsetBottom) | |
| : atlasRegionW / (1 - offsetLeft - offsetRight); | |
| const realHeight = atlasRotated | |
| ? atlasRegionH / (1 - offsetLeft - offsetRight) | |
| : atlasRegionH / (1 - offsetTop - offsetBottom); | |
| // 4 个外边界 + 4 个 9-slice 内边界 | |
| let left: number, top: number, right: number, bottom: number; | |
| let bLeft: number, bTop: number, bRight: number, bBottom: number; | |
| if (atlasRotated) { | |
| // 原图 region/offset (left/top/right/bottom) 在 atlas 中映射为 (bottom/left/top/right) | |
| left = Math.max(regionBottom - offsetBottom, 0) * realWidth + atlasRegionX; | |
| top = Math.max(regionLeft - offsetLeft, 0) * realHeight + atlasRegionY; | |
| right = atlasRegionW + atlasRegionX - Math.max(regionTop - offsetTop, 0) * realWidth; | |
| bottom = atlasRegionH + atlasRegionY - Math.max(regionRight - offsetRight, 0) * realHeight; | |
| bLeft = (regionBottom - offsetBottom + border.y * regionH) * realWidth + atlasRegionX; | |
| bTop = (regionLeft - offsetLeft + border.x * regionW) * realHeight + atlasRegionY; | |
| bRight = atlasRegionW + atlasRegionX - (regionTop - offsetTop + border.w * regionH) * realWidth; | |
| bBottom = atlasRegionH + atlasRegionY - (regionRight - offsetRight + border.z * regionW) * realHeight; |
🤖 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/core/src/2d/sprite/Sprite.ts` around lines 352 - 366, The rotated
branch misapplies trim offsets—when atlasRotated is true you must rotate the
offsets and border axes to match the packed axes mapping (original region/offset
left/top/right/bottom → bottom/left/top/right); update the computations that set
left/top/right/bottom and bLeft/bTop/bRight/bBottom to use the swapped offsets
and border components: for the X-side math (calculations that use
atlasRegionW/realWidth and regionBottom/regionTop) use offsetBottom/offsetTop
and border.y/w where appropriate, and for the Y-side math (calculations that use
atlasRegionH/realHeight and regionLeft/regionRight) use offsetLeft/offsetRight
and border.x/z accordingly so trimmed rotated sprites and 9-slice boundaries
align correctly (references: atlasRotated, realWidth, realHeight,
atlasRegionX/Y/W/H, regionLeft/Top/Right/Bottom, offsetLeft/Top/Right/Bottom,
border.x/y/w/z, regionW/regionH).
GuoLei1990
left a comment
There was a problem hiding this comment.
增量审查(2026-06-14,commit 0a0c4a72)
代码自首次审查以来未变更,作者未回复也未推送修复。本轮在第一性原理下重新推导了旋转分支的 trim-offset 轴向映射,结论与之前几轮"公式正确"的关闭判断相反——下方 [P1] trim-offset 是实锤 bug,并给出证明。
已关闭问题清单
| 问题 | 状态 |
|---|---|
| rotated 分支变量命名 atlas 语义翻转 | ✅ 关闭(纯命名/可读性,逻辑无误) |
| UV 网格填充注释 / Y 轴方向说明 | ✅ 关闭(已处理) |
| ❌ 撤销关闭——早期是我自己的推导失误,下方重新证明为真 bug |
总结
把 Sprite._uvs 从 4 点扩到 16 点 column-major 网格,旋转完全烘焙进 _updateUVs 的 if (atlasRotated) 分支,三个 assembler 保持 rotation-agnostic(读固定索引 [0]/[3]/[12]/[15]、i*4+j、[0]/[5]/[10]/[15]),热路径无 per-vertex 分支,分层干净,方向正确。但旋转分支的 trim-offset 轴向配错,且 setter 漏脏标记两年级 P1 始终未修。
问题
[P1] Sprite.ts:_updateUVs 旋转分支 — trim offset 的轴向未随 90° 旋转交换(实锤,非"待测试坐实")
证明(用同 PR 内的 _calDefaultSize 做对照,两者必须用同一套约定):
TexturePacker 的 spriteSourceSize(即 atlasRegionOffset)始终是原图坐标系的 trim,与 rotated 无关;frame(即 atlasRegion)是 atlas 坐标系,旋转时 W/H 已交换。所以旋转后:atlasRegionW(atlas-X 跨度)= 原图高度方向的 packed 跨度,atlasRegionH(atlas-Y 跨度)= 原图宽度方向。
_calDefaultSize 正确地遵守了这个约定:
const originWidth = _atlasRotated ? atlasPxH : atlasPxW; // 旋转→atlasPxH=原图宽 ✓
const originHeight = _atlasRotated ? atlasPxW : atlasPxH; // 旋转→atlasPxW=原图高 ✓
this._automaticWidth = (originWidth / (1 - offset.x - offset.z)) * ... // 原图宽 ÷ (1-左trim-右trim) ✓
this._automaticHeight = (originHeight / (1 - offset.y - offset.w)) * ... // 原图高 ÷ (1-上trim-下trim) ✓即"原图宽方向的尺寸配原图左右 trim",轴向自洽。
但 _updateUVs 旋转分支违反了同一约定:
const realWidth = atlasRegionW / (1 - offsetLeft - offsetRight); // ✗ atlasRegionW=原图高方向,却除以左右(宽)trim
const realHeight = atlasRegionH / (1 - offsetTop - offsetBottom); // ✗ atlasRegionH=原图宽方向,却除以上下(高)trim
...
left = Math.max(regionBottom - offsetLeft, 0) * realWidth + atlasRegionX; // ✗ atlas-left(=原图bottom) 却配 offsetLeftatlasRegionW 是原图高方向,重建满跨度应除以 (1 - offsetTop - offsetBottom)(原图上下 trim),而不是 (1 - offsetLeft - offsetRight)。region/offset 配对同理:CW 90° 下 atlas-left 对应原图 bottom,应配 regionBottom - offsetBottom,而非 regionBottom - offsetLeft。
后果:带非对称 trim 的旋转 sprite(offsetLeft+offsetRight ≠ offsetTop+offsetBottom)UV 边界算错,渲染错位。而"裁剪 + 旋转打包"正是 TexturePacker rotated 的典型产物,不是罕见组合。border(9-slice 内边界)沿用同一错配,同样受影响。
修复即 CodeRabbit inline 建议的轴向交换(realWidth/realHeight 的分母互换 + 旋转分支内 offsetLeft↔offsetBottom、offsetTop↔offsetLeft 等按 CW 90° 重新配对)。注意修复后必须配一个反向证伪测试:构造 atlasRotated=true + 四边 trim 不等的 sprite,断言四角及内边界 UV 落在期望 atlas 坐标;revert 修复后该测试须 fail。当前 tests/src/core/Sprite.test.ts:58 的 atlasRotated 用例只测 getter/setter 往返,完全没覆盖 UV 数学。
[P1] Sprite.ts:111-115 — atlasRotated setter 不触发 UV/Size 脏标记
set atlasRotated(value: boolean) {
if (this._atlasRotated != value) {
this._atlasRotated = value; // 只翻 bool,不 dispatch 任何脏标记
}
}_updateUVs(受 SpriteUpdateFlags.uvs 门控)和 _calDefaultSize(受 automaticSize 门控)现在都读 _atlasRotated,但运行时 sprite.atlasRotated = x 后两个脏标记都不置位 → UV/size 保持陈旧。atlasRotated 是公开 setter(无 @internal),编辑器 Inspector 改值、序列化回写都会静默失效。修复模式就在同文件下方 atlasRegion setter 现成:
set atlasRotated(value: boolean) {
if (this._atlasRotated !== value) { // 顺手 != 改 !==
this._atlasRotated = value;
this._dispatchSpriteChange(SpriteModifyFlags.atlasRegion); // 触发 uvs + automaticSize
if (this._customWidth === undefined || this._customHeight === undefined) {
this._dispatchSpriteChange(SpriteModifyFlags.size);
}
}
}(SpriteAtlasLoader 在 Sprite 刚创建全脏时赋值,故 loader 路径不暴露此 bug,但这是 setter 语义契约的缺失,与 loader 是否安全无关。)
简化建议
无新增。两分支的 16 点填充已有注释辅助,可读性足够,不强求抽 helper。
自检
- trim-offset 一条:已用同 PR 的
_calDefaultSize(被各轮一致认可为正确)做对照证明_updateUVs旋转分支轴向不自洽,是纯静态可证的 bug,不再是之前几轮"需测试坐实"的 P2,升回 P1。早期"公式正确"的关闭是我的推导失误,已在清单中显式撤销。 - setter 漏脏标记:对照 HEAD
0a0c4a72源码 L111-115 与下方atlasRegionsetter 实际确认。 - assembler rotation-agnostic、热路径无回归:已确认(读固定 corner 索引,旋转烘焙在
_updateUVs)。
Summary
Adds support for rotated sprites in
SpriteAtlas— TexturePacker-style atlases that pack sprites with 90° rotation to optimize space.Changes
Sprite.ts— extends UV calculation to handle rotated source rect (~97 lines updated)SimpleSpriteAssembler/SlicedSpriteAssembler/TiledSpriteAssembler— vertex/UV generation respects rotation flagSpriteAtlasLoader.ts— propagates rotation flag from atlas metadata to SpriteTest Plan
Summary by CodeRabbit
Bug Fixes
Refactor