Skip to content

feat(2d): add FilledSpriteAssembler and sprite filled mode support#2989

Draft
cptbtptpbcptdtptp wants to merge 8 commits into
galacean:dev/2.0from
cptbtptpbcptdtptp:feat/sprite-filled-mode
Draft

feat(2d): add FilledSpriteAssembler and sprite filled mode support#2989
cptbtptpbcptdtptp wants to merge 8 commits into
galacean:dev/2.0from
cptbtptpbcptdtptp:feat/sprite-filled-mode

Conversation

@cptbtptpbcptdtptp

@cptbtptpbcptdtptp cptbtptpbcptdtptp commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds support for Sprite Filled draw mode — render only a portion of a sprite based on a fill ratio along a configurable axis/origin. Useful for progress bars, cooldown indicators, etc.

Changes

  • New assembler FilledSpriteAssembler (~613 lines) handles vertex generation for filled mode
  • New enums:
    • SpriteFilledMode — direction (Horizontal / Vertical / Radial90 / Radial180 / Radial360)
    • SpriteFilledOrigin — anchor point for the fill direction
  • Extends SpriteDrawMode with Filled
  • Extends ISpriteRenderer interface
  • SpriteRenderer integrates filled assembler

Test Plan

  • Existing sprite e2e tests pass
  • Visual verification: progress-bar-style filled sprite renders correctly across all SpriteFilledMode × SpriteFilledOrigin combinations

Summary by CodeRabbit

Release Notes

  • New Features

    • Added a new Filled sprite drawing mode with linear (horizontal/vertical) and radial (90°, 180°, 360°) fills.
    • Exposed filled controls on SpriteRenderer: filledAmount (auto-clamped), filledMode, filledOrigin, and filledClockWise.
    • Added new public enums for filled mode and filled origin, and exported them from the 2D module.
  • Tests

    • Added E2E coverage and expanded SpriteRenderer unit tests for filled-mode behavior and geometry validation.

cptbtptpbcptdtptp and others added 2 commits May 11, 2026 19:19
@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds filled-mode sprite rendering to the Galacean 2D engine. It introduces new enums (SpriteFilledMode, SpriteFilledOrigin), extends the SpriteRenderer interface and class with filled configuration properties, implements a new FilledSpriteAssembler that generates optimized geometry for horizontal, vertical, and radial fills (90°/180°/360°), and updates the barrel exports to expose the new components. Comprehensive unit tests validate property semantics, geometry output, and transform correctness; E2E tests verify visual rendering across all fill modes and amounts.

Changes

Filled Sprite Rendering

Layer / File(s) Summary
Draw Mode & Fill Configuration Enums
packages/core/src/2d/enums/SpriteDrawMode.ts, packages/core/src/2d/enums/SpriteFilledMode.ts, packages/core/src/2d/enums/SpriteFilledOrigin.ts, packages/core/src/2d/assembler/ISpriteRenderer.ts
Added Filled enum member to SpriteDrawMode; introduced SpriteFilledMode with five variants (Horizontal, Vertical, Radial90/180/360); introduced SpriteFilledOrigin with eight directional origins (Right, TopRight, Top, TopLeft, Left, BottomLeft, Bottom, BottomRight); extended ISpriteRenderer with optional properties filledMode, filledAmount, filledOrigin, filledClockWise and their type imports.
Filled Geometry Assembler
packages/core/src/2d/assembler/FilledSpriteAssembler.ts
Implemented FilledSpriteAssembler class with static methods: resetData allocates fixed-capacity sub-chunk; updatePositions transforms vertices and dispatches to fill-mode handlers (linear, radial90/180/360); updateColor writes per-vertex RGBA using alpha multiplier; internal methods _filledLinear computes clipped rectangles; _filledRadial90/180/360 and _processRadialGrid handle radial patterns using angle ranges; _radialCut computes sector geometry via tangent-based interpolation and diagonal detection; _addTriangle/_addQuad append vertices and indices to sub-chunk buffer.
SpriteRenderer Integration
packages/core/src/2d/sprite/SpriteRenderer.ts
Updated imports; added internal state for _filledMode, _filledAmount, _filledOrigin, _filledClockWise; extended drawMode setter to assign FilledSpriteAssembler for SpriteDrawMode.Filled; exposed public getters/setters for filled properties with clamping (filledAmount to [0,1]), automatic filledOrigin snapping on filledMode change via _correctOrigin helper, and conditional dirty flag updates (WorldVolumeAndUV for mode/origin, WorldVolumeUVAndColor for amount); updated _onSpriteChange to handle filled mode separately for texture/size and atlas region updates.
Public API Exports
packages/core/src/2d/index.ts
Added exports for FilledSpriteAssembler, SpriteFilledMode, and SpriteFilledOrigin alongside existing sprite assembler and enum re-exports.
Unit & E2E Tests
tests/src/core/SpriteRenderer.test.ts, e2e/case/sprite-filled.ts, e2e/config.ts
Added 11 test suites validating filled property clamping/validation, geometry/UV output for linear and radial fills, origin fallback rules, UV refresh on atlas changes, transform/flip correctness, and cross-mode index consistency; added E2E test case generating filled sprite grid across all modes/amounts; updated E2E config with sprite-filled case threshold (0.1) and diff percentage (0.3).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A sprite now fills with grace,
From corner to radiant space,
Ninety, one-eighty, three-sixty glow—
Math-woven geometry show!
Triangles dance, quads align,
The filled new mode is 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 pull request title accurately and clearly describes the main change: adding FilledSpriteAssembler and sprite filled mode support, which directly aligns with the substantial additions across multiple files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 and usage tips.

@codecov

codecov Bot commented May 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.78443% with 102 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.38%. Comparing base (950c7f6) to head (812638e).

Files with missing lines Patch % Lines
...ges/core/src/2d/assembler/FilledSpriteAssembler.ts 91.92% 50 Missing ⚠️
e2e/case/sprite-filled.ts 0.00% 43 Missing and 1 partial ⚠️
e2e/config.ts 0.00% 8 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #2989      +/-   ##
===========================================
+ Coverage    79.31%   79.38%   +0.07%     
===========================================
  Files          919      923       +4     
  Lines       102450   103283     +833     
  Branches     11365    11479     +114     
===========================================
+ Hits         81255    81988     +733     
- Misses       21007    21106      +99     
- Partials       188      189       +1     
Flag Coverage Δ
unittests 79.38% <87.78%> (+0.07%) ⬆️

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.

@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: 3

🧹 Nitpick comments (5)
packages/core/src/2d/assembler/FilledSpriteAssembler.ts (3)

113-115: 💤 Low value

@ts-ignore hides an interface gap on renderer._bounds.

The _bounds field isn't declared on ISpriteRenderer, so this access requires @ts-ignore. Other assemblers presumably need the same hatch. Consider widening ISpriteRenderer to include _bounds: BoundingBox (or expose a small _setBounds(box) method) so the suppression can be removed and the interface accurately reflects what the assemblers depend on.

🤖 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/assembler/FilledSpriteAssembler.ts` around lines 113 -
115, The `@ts-ignore` exists because assemblers access renderer._bounds which
isn't declared on ISpriteRenderer; update the renderer interface
(ISpriteRenderer) to include a private/internal _bounds: BoundingBox (or add a
small method like _setBounds(box: BoundingBox) and use that) so
FilledSpriteAssembler (where BoundingBox.transform(renderer._getBounds(),
modelMatrix, renderer._bounds) is called) no longer needs `@ts-ignore`; change the
interface accordingly and remove the suppression from FilledSpriteAssembler (and
any other assemblers using _bounds).

137-140: 💤 Low value

Extract repeated amount <= 0.001 short-circuit and the magic threshold.

The same if (amount <= 0.001) { renderer._subChunk.indices.length = 0; return; } guard is repeated in 4 _filled* methods, and the threshold itself is a magic number. Consider hoisting both into a single named constant + helper (or moving the early-out up into updatePositions so the per-mode functions don't have to duplicate it).

Also applies to: 222-225, 288-291, 383-386

🤖 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/assembler/FilledSpriteAssembler.ts` around lines 137 -
140, Extract the repeated small-amount guard into a named constant and single
helper (or move the early-out into updatePositions) to remove duplication:
define a descriptive constant (e.g. MIN_FILL_AMOUNT = 0.001) in
FilledSpriteAssembler and replace the four occurrences of the literal check (the
lines in the _filled* methods that do `if (amount <= 0.001) {
renderer._subChunk.indices.length = 0; return; }`) with a call to a new helper
like shouldSkipFill(amount, renderer) or performSkipFillIfNeeded(amount,
renderer) so the threshold is centralized and the index-clear/return logic is
not duplicated across _filled* methods. Ensure you update all four locations
mentioned (the guards at the shown snippet and the ones around lines 222-225,
288-291, 383-386) and keep behavior identical.

244-271: ⚡ Quick win

Radial handlers silently no-op on unexpected origins.

_filledRadial90 only handles the 4 corner origins, _filledRadial180 and _filledRadial360 only handle the 4 edge origins. The default: break; branches leave _inPositions / _inUVs populated from a previous invocation (radial 90/180) or startAngle = 0 (radial 360), producing visually wrong geometry without any indication of misuse.

Since SpriteRenderer.filledOrigin setter does not validate against filledMode, a user can put the renderer in a bad state. Consider either:

  • Validating in SpriteRenderer.filledOrigin setter (reject/coerce origins that don't match the current mode), or
  • Adding an explicit warning/Logger.warn in the default branch here.

Also applies to: 320-371, 389-404

🤖 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/assembler/FilledSpriteAssembler.ts` around lines 244 -
271, The switch default branches in _filledRadial90, _filledRadial180 and
_filledRadial360 silently leave inPositions/inUVs from prior calls causing wrong
geometry; update each default case to log a warning (use Logger.warn or the
project logger) mentioning the function name and the invalid origin, and then
clear/reset the arrays (inPositions and inUVs) or set them to safe defaults so
stale data isn't reused; alternatively, add validation/coercion in the
SpriteRenderer.filledOrigin setter to reject or coerce origins incompatible with
the current filledMode, but if you choose the logger approach make sure to
reference the origin value and the method name in the message.
packages/core/src/2d/sprite/SpriteRenderer.ts (1)

179-186: ⚡ Quick win

Consider validating filledOrigin against the active filledMode.

The setter accepts any SpriteFilledOrigin, but each filledMode only renders correctly for a subset of origins (Horizontal: Left/Right; Vertical: Top/Bottom; Radial90: corners; Radial180/360: edge midpoints). Combined with the silent default: break; branches in FilledSpriteAssembler, an invalid combination produces broken geometry with no diagnostic.

Validating here (e.g., Logger.warn and coercing to a valid origin, or clamping based on _filledMode) would help users catch misuse early. Same applies to the filledMode setter when the previously-set origin is no longer valid for the new mode.

🤖 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/SpriteRenderer.ts` around lines 179 - 186, The
filledOrigin setter currently accepts any SpriteFilledOrigin even when
incompatible with the active _filledMode, which can produce broken geometry;
update the set filledOrigin(value: SpriteFilledOrigin) to validate value against
the current _filledMode (e.g., allowed origins for Horizontal = Left/Right,
Vertical = Top/Bottom, Radial90 = corners, Radial180/360 = edge midpoints), and
if invalid call Logger.warn with a clear message and coerce/clamp to a valid
origin before assigning _filledOrigin and setting the dirty flag
(_dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV) for
SpriteDrawMode.Filled; also mirror this validation in the filledMode setter so
when changing _filledMode you adjust or coerce the existing _filledOrigin (and
warn) to a compatible value to avoid relying on the silent default branches in
FilledSpriteAssembler.
packages/core/src/2d/assembler/ISpriteRenderer.ts (1)

20-20: ⚡ Quick win

Consider renaming filledClockWisefilledClockwise.

"Clockwise" is a single English word; splitting the W creates an unusual public API name. Since this property is also exposed via SpriteRenderer and is part of a 2.0 release, normalizing it now avoids a breaking rename later. Same rename should be applied in SpriteRenderer.ts (private field, getter/setter) and FilledSpriteAssembler.ts (read site).

♻️ Suggested rename
-  filledClockWise?: boolean;
+  filledClockwise?: boolean;
🤖 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/assembler/ISpriteRenderer.ts` at line 20, Rename the
public property and all usages from filledClockWise to filledClockwise: update
the ISpriteRenderer interface property (filledClockWise → filledClockwise),
rename the private field and its getter/setter in SpriteRenderer (ensure method
names and internal references use filledClockwise), and update the read site in
FilledSpriteAssembler to read the new property name; keep behavior unchanged and
run tests to catch any missed references.
🤖 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/assembler/FilledSpriteAssembler.ts`:
- Around line 117-119: The FilledSpriteAssembler.updateUVs is a no-op which
leaves UVs stale when atlasRegion changes because SpriteRenderer._onSpriteChange
sets only SpriteRendererUpdateFlags.UV for SpriteModifyFlags.atlasRegion and
_render will call updateUVs (skipped updatePositions); fix this by changing
SpriteRenderer._onSpriteChange to treat atlasRegion the same as other
UV-affecting changes for filled mode: when atlasRegion is detected, set the
update flags to WorldVolumeAndUV (or include WorldVolume) instead of only UV so
FilledSpriteAssembler.updatePositions runs and recomputes baked UVs; update
references to SpriteModifyFlags.atlasRegion and SpriteRendererUpdateFlags.*
accordingly.

In `@packages/core/src/2d/sprite/SpriteRenderer.ts`:
- Around line 160-170: The filledMode setter currently sets _filledOrigin to
Bottom for all non-Radial90 modes which makes Horizontal default to an invalid
Bottom origin; update the logic in the set filledMode(value: SpriteFilledMode)
to choose a mode-appropriate default: when value === SpriteFilledMode.Horizontal
set _filledOrigin to SpriteFilledOrigin.Left (so
FilledSpriteAssembler._filledLinear sees originIsStart true), when value ===
SpriteFilledMode.Radial90 set it to SpriteFilledOrigin.BottomLeft, otherwise
keep SpriteFilledOrigin.Bottom; keep the existing branch that sets
_dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV when
this._drawMode === SpriteDrawMode.Filled.

---

Nitpick comments:
In `@packages/core/src/2d/assembler/FilledSpriteAssembler.ts`:
- Around line 113-115: The `@ts-ignore` exists because assemblers access
renderer._bounds which isn't declared on ISpriteRenderer; update the renderer
interface (ISpriteRenderer) to include a private/internal _bounds: BoundingBox
(or add a small method like _setBounds(box: BoundingBox) and use that) so
FilledSpriteAssembler (where BoundingBox.transform(renderer._getBounds(),
modelMatrix, renderer._bounds) is called) no longer needs `@ts-ignore`; change the
interface accordingly and remove the suppression from FilledSpriteAssembler (and
any other assemblers using _bounds).
- Around line 137-140: Extract the repeated small-amount guard into a named
constant and single helper (or move the early-out into updatePositions) to
remove duplication: define a descriptive constant (e.g. MIN_FILL_AMOUNT = 0.001)
in FilledSpriteAssembler and replace the four occurrences of the literal check
(the lines in the _filled* methods that do `if (amount <= 0.001) {
renderer._subChunk.indices.length = 0; return; }`) with a call to a new helper
like shouldSkipFill(amount, renderer) or performSkipFillIfNeeded(amount,
renderer) so the threshold is centralized and the index-clear/return logic is
not duplicated across _filled* methods. Ensure you update all four locations
mentioned (the guards at the shown snippet and the ones around lines 222-225,
288-291, 383-386) and keep behavior identical.
- Around line 244-271: The switch default branches in _filledRadial90,
_filledRadial180 and _filledRadial360 silently leave inPositions/inUVs from
prior calls causing wrong geometry; update each default case to log a warning
(use Logger.warn or the project logger) mentioning the function name and the
invalid origin, and then clear/reset the arrays (inPositions and inUVs) or set
them to safe defaults so stale data isn't reused; alternatively, add
validation/coercion in the SpriteRenderer.filledOrigin setter to reject or
coerce origins incompatible with the current filledMode, but if you choose the
logger approach make sure to reference the origin value and the method name in
the message.

In `@packages/core/src/2d/assembler/ISpriteRenderer.ts`:
- Line 20: Rename the public property and all usages from filledClockWise to
filledClockwise: update the ISpriteRenderer interface property (filledClockWise
→ filledClockwise), rename the private field and its getter/setter in
SpriteRenderer (ensure method names and internal references use
filledClockwise), and update the read site in FilledSpriteAssembler to read the
new property name; keep behavior unchanged and run tests to catch any missed
references.

In `@packages/core/src/2d/sprite/SpriteRenderer.ts`:
- Around line 179-186: The filledOrigin setter currently accepts any
SpriteFilledOrigin even when incompatible with the active _filledMode, which can
produce broken geometry; update the set filledOrigin(value: SpriteFilledOrigin)
to validate value against the current _filledMode (e.g., allowed origins for
Horizontal = Left/Right, Vertical = Top/Bottom, Radial90 = corners,
Radial180/360 = edge midpoints), and if invalid call Logger.warn with a clear
message and coerce/clamp to a valid origin before assigning _filledOrigin and
setting the dirty flag (_dirtyUpdateFlag |=
SpriteRendererUpdateFlags.WorldVolumeAndUV) for SpriteDrawMode.Filled; also
mirror this validation in the filledMode setter so when changing _filledMode you
adjust or coerce the existing _filledOrigin (and warn) to a compatible value to
avoid relying on the silent default branches in FilledSpriteAssembler.
🪄 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: 24f0fb05-7587-4d34-9703-6bb15d3ffab5

📥 Commits

Reviewing files that changed from the base of the PR and between 1bc2b10 and 44702de.

📒 Files selected for processing (7)
  • packages/core/src/2d/assembler/FilledSpriteAssembler.ts
  • packages/core/src/2d/assembler/ISpriteRenderer.ts
  • packages/core/src/2d/enums/SpriteDrawMode.ts
  • packages/core/src/2d/enums/SpriteFilledMode.ts
  • packages/core/src/2d/enums/SpriteFilledOrigin.ts
  • packages/core/src/2d/index.ts
  • packages/core/src/2d/sprite/SpriteRenderer.ts

Comment on lines +117 to +119
static updateUVs(renderer: ISpriteRenderer): void {
// UVs are computed in updatePositions.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Empty updateUVs leaves UVs stale on atlas-region changes.

updateUVs is a no-op because UVs are baked inside updatePositions. However, SpriteRenderer._onSpriteChange handles SpriteModifyFlags.atlasRegion by setting only SpriteRendererUpdateFlags.UV (no WorldVolume). In _render, this triggers updateUVs while updatePositions is skipped — so for filled mode the UVs are never refreshed when the sprite's atlas region changes (e.g., dynamic atlas repacking). Other assemblers don't have this regression because they have a real updateUVs.

Two reasonable fixes:

  1. In SpriteRenderer._onSpriteChange, treat atlasRegion like other UV-affecting events for filled mode (set WorldVolumeAndUV), or
  2. Implement a real UV-only path here (extract the UV computation from each _filled* function so it can be invoked independently).

Option 1 is the minimal fix.

🛠 Minimal fix in SpriteRenderer._onSpriteChange
       case SpriteModifyFlags.atlasRegion:
-        this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.UV;
+        this._dirtyUpdateFlag |=
+          this._drawMode === SpriteDrawMode.Filled
+            ? SpriteRendererUpdateFlags.WorldVolumeAndUV
+            : SpriteRendererUpdateFlags.UV;
         break;
🤖 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/assembler/FilledSpriteAssembler.ts` around lines 117 -
119, The FilledSpriteAssembler.updateUVs is a no-op which leaves UVs stale when
atlasRegion changes because SpriteRenderer._onSpriteChange sets only
SpriteRendererUpdateFlags.UV for SpriteModifyFlags.atlasRegion and _render will
call updateUVs (skipped updatePositions); fix this by changing
SpriteRenderer._onSpriteChange to treat atlasRegion the same as other
UV-affecting changes for filled mode: when atlasRegion is detected, set the
update flags to WorldVolumeAndUV (or include WorldVolume) instead of only UV so
FilledSpriteAssembler.updatePositions runs and recomputes baked UVs; update
references to SpriteModifyFlags.atlasRegion and SpriteRendererUpdateFlags.*
accordingly.

Comment thread packages/core/src/2d/assembler/FilledSpriteAssembler.ts
Comment thread packages/core/src/2d/sprite/SpriteRenderer.ts
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 mentioned this pull request May 11, 2026
3 tasks
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 marked this pull request as draft June 15, 2026 02:47
cptbtptpbcptdtptp and others added 3 commits June 20, 2026 11:16
- spriteUVs[15] -> spriteUVs[3]: Sprite._uvs only has 4 entries, so the
  out-of-bounds right-top UV crashed filled rendering on real sprites
- filledMode setter now picks a valid default origin per mode (Horizontal -> Left)
- atlasRegion change refreshes UV in filled mode (UV is baked in updatePositions)
- radial fills fall back to the default origin for unsupported origins
- add tests covering filled linear/radial rendering and the above fixes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the render-level coverage that was missing: Radial180 (incl.
unsupported-origin fallback), counter-clockwise radial, flipX mirroring,
linear non-default origins (Right/Top), linear index values, Vertical UVs,
and an indices-count topology table across all 5 modes and amounts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a single e2e case rendering all five fill modes across fill amounts
0/0.25/0.5/0.75/1 in one baseline image, covering the topology boundaries
(45-degree quad/triangle, 90-degree quadrant edges).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cptbtptpbcptdtptp cptbtptpbcptdtptp force-pushed the feat/sprite-filled-mode branch from 1fa9f7d to 17ac677 Compare June 20, 2026 06:44
filledOrigin is a shared 8-member enum but each mode only accepts a subset
(linear: Left/Right or Top/Bottom; Radial90: corners; Radial180/360: edges).
A mismatched origin previously stuck and the assembler silently fell back to
the default, so the getter disagreed with what was rendered. Snap the origin
to a mode-valid value in both setters via a shared helper, keeping the current
origin on mode change when it stays valid.

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

Copy link
Copy Markdown
Collaborator Author

@GuoLei1990 感谢逐条复核,已推送修复(67d06add6 / a70ffa40e / 17ac677d9 / 477a770f4),回应如下:

P1(已修)

  • atlasRegion 在 Filled 模式不刷新 UV:已按建议的最小修法,Filled 下置 WorldVolumeAndUV
  • filledMode 默认 origin:已按 mode 设默认(H→Left / V→Bottom / R90→BottomLeft / R180·360→Bottom);并在 filledOrigin setter 增加校验,把与当前 mode 不匹配的 origin 收敛到合法子集,避免“设了不匹配 origin 却静默走默认、getter 与实际渲染不一致”。

额外修复(顺带发现)

  • spriteUVs[15] 在当前 head 下越界:Sprite._uvs 仍是 4 元素([0]=LB / [3]=RT),[15] 取到 undefined,filled 在任意真实 sprite 上一渲染即抛错。已全部改为 spriteUVs[3]。(对应“已关闭清单”那条 —— 它假设 feat(2d): support rotated sprites in atlas #2990 的 16-point UV 已合入使 [15] 有效,但当前 head 未合入,所以这是现存崩溃,已独立修复。)

P3(已修)

  • 已补单测(五种 mode × origin × amount 边界 + flip / CCW 的顶点·索引断言)+ 一个 5×5 的 filled e2e 基准图,patch 覆盖率 ~20% → ~93%。

P2

  • resetData 每帧 realloc”:应为误读 —— resetData 只在 drawMode 切换时调用一次,updatePositions 复用同一个 16 顶点 chunk,无每帧重分配。
  • rotated atlas:filled 与 Simple/Tiled 读取相同的 _getUVs(),而 Sprite._updateUVs 本身不 bake 旋转,故 rotation 行为与现有 mode 一致、非本 PR 引入;可另补一个 atlasRotated 的 e2e 作防回归。
  • _bounds@ts-ignore:这是 4 个 assembler(Simple/Sliced/Tiled/Filled)一致的既有模式,根因是 Renderer._boundsprotected、public 的 ISpriteRenderer 无法直接声明它(protected 不满足 public 成员),需动基类访问级别或给接口加 _setBounds() 才能消除,会牵动全部 4 处;宜作独立小重构统一处理,不在本 feature PR 夹带。

Extract the repeated 0.001 fill-amount threshold into a named constant, and add
comments for the fixed 16-vertex allocation and the 45-degree quadrant diagonal
in the radial cut. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cptbtptpbcptdtptp cptbtptpbcptdtptp force-pushed the feat/sprite-filled-mode branch from 71222d1 to 768c767 Compare June 22, 2026 11:42
@cptbtptpbcptdtptp cptbtptpbcptdtptp marked this pull request as ready for review June 22, 2026 12:06

@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: 1

🤖 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 `@e2e/case/sprite-filled.ts`:
- Around line 30-63: Replace the external CDN URL in the resourceManager.load()
call with a path to a repo-hosted texture fixture to ensure test stability.
Additionally, add a .catch() rejection handler to the promise chain (after the
existing .then() block) to explicitly handle and log load failures, so that
asset loading errors are properly diagnosed in CI.
🪄 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: fdb391c4-0ddb-461b-89f6-e5deef3d9ec6

📥 Commits

Reviewing files that changed from the base of the PR and between 44702de and 812638e.

⛔ Files ignored due to path filters (1)
  • e2e/fixtures/originImage/Sprite_sprite-filled.jpg is excluded by !**/*.jpg
📒 Files selected for processing (5)
  • e2e/case/sprite-filled.ts
  • e2e/config.ts
  • packages/core/src/2d/assembler/FilledSpriteAssembler.ts
  • packages/core/src/2d/sprite/SpriteRenderer.ts
  • tests/src/core/SpriteRenderer.test.ts
✅ Files skipped from review due to trivial changes (1)
  • e2e/config.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/core/src/2d/sprite/SpriteRenderer.ts
  • packages/core/src/2d/assembler/FilledSpriteAssembler.ts

Comment thread e2e/case/sprite-filled.ts
Comment on lines +30 to +63
engine.resourceManager
.load<Texture2D>({
url: "https://gw.alipayobjects.com/mdn/rms_7c464e/afts/img/A*rgNGR4Vb7lQAAAAAAAAAAAAAARQnAQ",
type: AssetType.Texture
})
.then((texture) => {
const sprite = new Sprite(engine, texture);
const modes = [
SpriteFilledMode.Horizontal,
SpriteFilledMode.Vertical,
SpriteFilledMode.Radial90,
SpriteFilledMode.Radial180,
SpriteFilledMode.Radial360
];
const amounts = [0, 0.25, 0.5, 0.75, 1];

modes.forEach((mode, row) => {
amounts.forEach((amount, col) => {
const entity = rootEntity.createChild(`filled-${row}-${col}`);
entity.transform.setPosition((col - 2) * 3, (2 - row) * 3, 0);
const renderer = entity.addComponent(SpriteRenderer);
renderer.sprite = sprite;
renderer.width = 2.4;
renderer.height = 2.4;
renderer.drawMode = SpriteDrawMode.Filled;
renderer.filledMode = mode;
renderer.filledAmount = amount;
});
});

updateForE2E(engine);
initScreenshot(engine, camera);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a repo-hosted texture fixture and handle load failures explicitly.

Line 32 fetches from an external CDN, which makes this baseline test vulnerable to network/service/asset drift. Also, the load chain has no rejection handler, so failures are harder to diagnose in CI.

Suggested hardening
-  engine.resourceManager
-    .load<Texture2D>({
-      url: "https://gw.alipayobjects.com/mdn/rms_7c464e/afts/img/A*rgNGR4Vb7lQAAAAAAAAAAAAAARQnAQ",
-      type: AssetType.Texture
-    })
-    .then((texture) => {
+  const textureUrl = "e2e/assets/sprite-filled.png"; // checked-in deterministic fixture
+  engine.resourceManager
+    .load<Texture2D>({
+      url: textureUrl,
+      type: AssetType.Texture
+    })
+    .then((texture) => {
       const sprite = new Sprite(engine, texture);
       // ...
       updateForE2E(engine);
       initScreenshot(engine, camera);
-    });
+    })
+    .catch((err) => {
+      throw new Error(`sprite-filled e2e setup failed: ${String(err)}`);
+    });
📝 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
engine.resourceManager
.load<Texture2D>({
url: "https://gw.alipayobjects.com/mdn/rms_7c464e/afts/img/A*rgNGR4Vb7lQAAAAAAAAAAAAAARQnAQ",
type: AssetType.Texture
})
.then((texture) => {
const sprite = new Sprite(engine, texture);
const modes = [
SpriteFilledMode.Horizontal,
SpriteFilledMode.Vertical,
SpriteFilledMode.Radial90,
SpriteFilledMode.Radial180,
SpriteFilledMode.Radial360
];
const amounts = [0, 0.25, 0.5, 0.75, 1];
modes.forEach((mode, row) => {
amounts.forEach((amount, col) => {
const entity = rootEntity.createChild(`filled-${row}-${col}`);
entity.transform.setPosition((col - 2) * 3, (2 - row) * 3, 0);
const renderer = entity.addComponent(SpriteRenderer);
renderer.sprite = sprite;
renderer.width = 2.4;
renderer.height = 2.4;
renderer.drawMode = SpriteDrawMode.Filled;
renderer.filledMode = mode;
renderer.filledAmount = amount;
});
});
updateForE2E(engine);
initScreenshot(engine, camera);
});
});
const textureUrl = "e2e/assets/sprite-filled.png"; // checked-in deterministic fixture
engine.resourceManager
.load<Texture2D>({
url: textureUrl,
type: AssetType.Texture
})
.then((texture) => {
const sprite = new Sprite(engine, texture);
const modes = [
SpriteFilledMode.Horizontal,
SpriteFilledMode.Vertical,
SpriteFilledMode.Radial90,
SpriteFilledMode.Radial180,
SpriteFilledMode.Radial360
];
const amounts = [0, 0.25, 0.5, 0.75, 1];
modes.forEach((mode, row) => {
amounts.forEach((amount, col) => {
const entity = rootEntity.createChild(`filled-${row}-${col}`);
entity.transform.setPosition((col - 2) * 3, (2 - row) * 3, 0);
const renderer = entity.addComponent(SpriteRenderer);
renderer.sprite = sprite;
renderer.width = 2.4;
renderer.height = 2.4;
renderer.drawMode = SpriteDrawMode.Filled;
renderer.filledMode = mode;
renderer.filledAmount = amount;
});
});
updateForE2E(engine);
initScreenshot(engine, camera);
})
.catch((err) => {
throw new Error(`sprite-filled e2e setup failed: ${String(err)}`);
});
});
🤖 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 `@e2e/case/sprite-filled.ts` around lines 30 - 63, Replace the external CDN URL
in the resourceManager.load() call with a path to a repo-hosted texture fixture
to ensure test stability. Additionally, add a .catch() rejection handler to the
promise chain (after the existing .then() block) to explicitly handle and log
load failures, so that asset loading errors are properly diagnosed in CI.

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

增量审查(HEAD 812638eb9

自上轮 CHANGES_REQUESTED44702de)以来,作者推了 4 个修复 commit(67d06add6/a70ffa40e/17ac677d9/477a770f4,后续 merge dev/2.0812638eb9)。两条阻塞 P1 + 一个潜伏崩溃已全部修复,并补齐了单测与 e2e。我已本地 build + 反向证伪逐条核验,解除上轮门控。

已关闭问题清单(逐条已对照实际代码 + 反向测试验证,不再重提)

问题 关闭依据
[P1] atlasRegion 在 Filled 模式不刷新 UV ✅ 已修。_onSpriteChangeatlasRegion 分支按建议在 Filled 下置 WorldVolumeAndUV反向验证:把它改回 UV-only,且令 sprite 设了 custom size(使 atlasRegion setter 跳过 size 派发)后,rightU 保持 1(UV stale);修复在场时 1→0.5。fix 真实有效。
[P1] filledMode setter 默认 origin 错(Horizontal→Bottom 致反向填充) ✅ 已修,且优于我的建议:抽出 _correctOrigin(mode, origin) 收口,filledMode/filledOrigin 两个 setter 共用——既给 mode 合理默认(H→Left/V→Bottom/R90→BottomLeft/R180·360→Bottom,对齐 Unity),又把不匹配 origin 收敛到合法子集,消除了"getter 与实际渲染不一致"。反向验证:把 Horizontal 默认改回 Bottom,3 个测试 fail。
spriteUVs[0]/[15] 依赖 #2990 16-point UV ✅ 被作者的额外修复推翻并修正。当前 base Sprite._uvs 仍是 4 元素[0]=LB/[3]=RT),[15] 取到 undefined,filled 在任意真实 sprite 上一渲染即 Cannot destructure undefined 崩溃。已全部改 [3]反向验证:改回 [15],9 个 filled 测试崩溃。我上轮"已关闭(依赖 #2990)"的结论对当前 base 是错的,作者抓得对。
atlasRotated UV 翻转 ✅ moot。Sprite._updateUVs 本身不 bake 旋转,filled 与 Simple/Tiled 走同一 _getUVs(),rotation 行为一致、非本 PR 引入。
静态 _vertexOffset/_indicesOffset 跨 renderer 污染、fillAmount=0 残留脏顶点 ✅ 前几轮已自查撤回(用前 reset / <=epsilonindices.length=0 = no-draw)。
updateColor 遍历全 16 slot ✅ P3 闭环(未用顶点无 indices)。
resetData 每帧 realloc ✅ 闭环。已确认仅在 drawMode 切换时调用一次,且补了说明注释。
@ts-ignore on _bounds ✅ 解释成立。Renderer._boundsprotected,public 的 ISpriteRenderer 无法声明;4 个 assembler 一致模式,宜独立小重构统一处理,不在本 feature PR 夹带。
测试覆盖缺失(patch ~20%) ✅ 已修。五种 mode × origin × amount 边界 + flip/CCW 顶点·索引断言,patch ~93%;exact-value 断言(revert 即红),粒度正确(subChunk 输出即 assembler 契约)。

总结

FilledSpriteAssembler 实现 Horizontal/Vertical/Radial90/180/360 五种填充,对标 Unity Image.Type.Filled_correctOrigin 收口设计干净,radial _radialCut 的 tan 插值 + 象限分解正确。方向正确,质量达标,无 P0/P1,可合入。 本地 vitest 全绿(28/28),三条 fix 均通过反向证伪。

问题(均不阻塞)

[P2] tests/src/core/SpriteRenderer.test.ts:1763 — "filled mode refreshes UV when the atlas region changes" 是 false guard,并不守住它声称的 fix

该测试只设了 spriteRenderer.width/height(renderer 的 custom size),没设 sprite.width/height。于是 Sprite.atlasRegion setter(Sprite.ts:130)因 sprite 自身 _customWidth/_customHeight === undefined,在派发 atlasRegion 之外又派发了 sizesize 在 Filled 下置 WorldVolumeUVAndColorupdatePositions 照样重跑 → UV 刷新。

实测:把 atlasRegion 分支改回 UV-only(重引入 bug),此测试仍 pass(28/28 全绿)。即它无法守住 atlasRegion fix——断言走的是 size 派发这条旁路,绕过了被测修复路径。

修法:让 sprite 也设 custom size,使 atlasRegion 单独派发:

const sprite = new Sprite(engine, texture2D);
sprite.width = 1;
sprite.height = 1;   // 否则 atlasRegion 会连带派发 size,掩盖被测 bug
// ...
sprite.atlasRegion = new Rect(0, 0, 0.5, 0.5);

加上后,revert fix 即可让该测试转红,才真正守住回归。

[P3] e2e/case/sprite-filled.ts:31 — e2e 用外部 CDN 纹理 URL,CI 稳定性依赖外网

CodeRabbit 也提了。其余 sprite e2e 用 https://gw.alipayobjects.com/... 是既有惯例,非本 PR 引入,但新 case 可考虑改用 repo 内 fixture 纹理(如已有的内置贴图),并对 .load().catch 记录失败,便于 CI 诊断。不阻塞。

[P3] 注释末尾句号

FilledSpriteAssembler.ts 多条单行 // 注释带句号(:43 "...degenerate geometry."、:51 "...at runtime."、:267/:361 "...stale data."、:561 "...a triangle.");SpriteRenderer.ts 新增 _correctOrigin 区域同样("...subset of origins."、"...a no-op."、"...corner origins."、"...edge origins."、"...getter === rendered).")。项目约定单行 // 不加句号(多行 JSDoc 才加)。不过 SlicedSpriteAssembler 既有注释也带句号,惯例本身不统一,纯锦上添花。

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

设计探讨(forward-looking,非阻塞,不改变此前 APPROVE)

本 PR 功能正确、测试守得住,几何 filled 与 Unity Image.Type.Filled 一致、是合格实现,APPROVE 不变。以下是一条 roadmap 级的设计建议,核心只有一个值得在合入前权衡的真问题(radial 进 public API),其余是未来演进方向,不要求本 PR 改动

经多引擎对标 + 第一性原理分析,把 filled 拆成三个正交轴看,结论是 linear 与 radial 应区别对待

轴1 — 实现:GPU 不是“完全占优”,要分 linear/radial

把 fill 做成 shader(恒定 4 顶点 + analytic mask)听起来更现代,但在 Galacean 的批处理架构上只对 radial 成立

  • PrimitiveChunknon-instanced 顶点拼接器,共享布局固定 9 floats / stride 36POSITION vec3 + TEXCOORD_0 vec2 + COLOR_0 vec4,见 packages/core/src/RenderPipeline/PrimitiveChunk.ts:42-48)。要让 GPU filled 保合批,fill 参数只能:① 加进共享顶点布局 → 全场 99% 的 Simple sprite 每帧每顶点白扛字节(落在最热的逐帧上传路径,全员税);或 ② 走 per-instance → 但 2D 主路径没有 instancing 管线,等于新开一条。
  • 几何在 batching 这轴是真·零成本(同布局同材质,自然 coalesce),且输出普通顶点 → SpriteMask / 自定义材质 / atlas 全部 just work;fill 一旦封进 shader 分支,用户每个自定义材质都要重写 fill 才支持 filled。

逐轴记分:batching、composition → 几何赢;fill-rate → 打平;radial 边缘 AA → GPU 决定性赢(几何硬边只有 framebuffer MSAA,救不了慢旋转的对角切边;analytic atan2+smoothstep 分辨率无关)

linear:几何成本本就极低(quad UV 上 step),GPU 化要付税/开管线/丢 composition,得不偿失
radial:硬边是它唯一真打不过的地方,GPU 的 analytic AA 值这个钱。

轴2 — 需求:linear 都要(几何够),radial 都有真需求但不宜做成几何 drawMode

  • linear(进度/血条):UI 控件刚需 + 世界头顶血条,几何已完美(Unity/UE 的线性进度也都是几何)。
  • radial(冷却环 + 世界范围/扇形指示):是 ARPG/MOBA/塔防刚需,且 world-space 大尺寸下硬边最刺眼(UI 通常小且正交对齐反而不明显)。用几何交付 radial = 交付一个永远锯齿的控件。

轴3 — 封装:行为进 shader + 参数走 renderer ShaderData(正好绕开轴1 全员税)

若做 GPU radial,最小封装面 = 一个官方 GPU Filled 材质(mask/AA 封 fragment shader),fillAmount/origin/clockwiserenderer 级 ShaderData(per-renderer uniform,不进共享顶点流 → 不向 Simple sprite 征税)。代价:同材质 filled sprite 彼此不合批——但 radial 指示器数量少,可接受,且圈在真正用它的少量 renderer 上。不要给主 sprite shader 加 fill dynamic branch(污染 Simple 路径 + 逼自定义材质 opt-in)。

唯一需要本 PR 现在权衡的点(你拍板)

drawModepublic APIradial 用几何进 public SpriteDrawMode.Filled,等于把“radial = 锯齿”锁进 1.0 契约,将来 GPU 化要么 breaking、要么背一个永久劣质 drawMode。两个选择:

  1. 现在就把 radial 从 drawMode 拆出来,走可选 GPU 材质(linear 仍作几何 drawMode,是终态)——避免 public API 锁死硬边契约。
  2. 先合几何 radial 当 1.0 baseline,roadmap 标记“radial AA 走 GPU 材质重构”,接受后续可能的 breaking。

我的倾向是 1(radial 进 public API 前三思),但这是产品节奏取舍,最终由你定。linear 无此问题,保持现状即可。


总纲:你的方案没有“不够现代”的问题——集中式 filled 与 Unity 一致、是现行标准;做在 world-space SpriteRenderer 上反而比 Unity 的 Canvas-only 更适配 2D。唯一值得动的是 radial 的 AA + 是否让硬边 radial 进 public drawMode,而非推翻几何方案。

@GuoLei1990 GuoLei1990 marked this pull request as draft June 30, 2026 11:46
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