From 35bd34b571cd992968bc97936a1e48f7737cb916 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 2 Apr 2026 16:37:45 +0800 Subject: [PATCH 1/7] feat(2d): add FilledSpriteAssembler and sprite filled mode support Co-Authored-By: Claude Opus 4.6 --- .../src/2d/assembler/FilledSpriteAssembler.ts | 595 ++++++++++++++++++ .../core/src/2d/assembler/ISpriteRenderer.ts | 6 + packages/core/src/2d/enums/SpriteDrawMode.ts | 4 +- .../core/src/2d/enums/SpriteFilledMode.ts | 15 + .../core/src/2d/enums/SpriteFilledOrigin.ts | 21 + packages/core/src/2d/index.ts | 3 + packages/core/src/2d/sprite/SpriteRenderer.ts | 85 +++ 7 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/2d/assembler/FilledSpriteAssembler.ts create mode 100644 packages/core/src/2d/enums/SpriteFilledMode.ts create mode 100644 packages/core/src/2d/enums/SpriteFilledOrigin.ts diff --git a/packages/core/src/2d/assembler/FilledSpriteAssembler.ts b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts new file mode 100644 index 0000000000..894f1b07c6 --- /dev/null +++ b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts @@ -0,0 +1,595 @@ +import { BoundingBox, Matrix, Vector2, Vector3 } from "@galacean/engine-math"; +import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; +import { StaticInterfaceImplement } from "../../base/StaticInterfaceImplement"; +import { SpriteFilledMode } from "../enums/SpriteFilledMode"; +import { SpriteFilledOrigin } from "../enums/SpriteFilledOrigin"; +import { ISpriteAssembler } from "./ISpriteAssembler"; +import { ISpriteRenderer } from "./ISpriteRenderer"; + +/** + * Assemble vertex data for the sprite renderer in filled mode. + */ +@StaticInterfaceImplement() +export class FilledSpriteAssembler { + private static _matrix = new Matrix(); + private static _worldPositions = [ + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3() + ]; + private static _uvs = [ + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2() + ]; + private static _inPositions: Vector3[] = []; + private static _inUVs: Vector2[] = []; + private static _outPositions: Vector3[] = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; + private static _outUVs: Vector2[] = [new Vector2(), new Vector2(), new Vector2(), new Vector2()]; + private static _vertexOffset = 0; + private static _indicesOffset = 0; + + static resetData(renderer: ISpriteRenderer): void { + const manager = renderer._getChunkManager(); + const lastSubChunk = renderer._subChunk; + lastSubChunk && manager.freeSubChunk(lastSubChunk); + const subChunk = manager.allocateSubChunk(16); + subChunk.indices = []; + renderer._subChunk = subChunk; + } + + static updatePositions( + renderer: ISpriteRenderer, + worldMatrix: Matrix, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean + ): void { + const { x: pivotX, y: pivotY } = pivot; + const modelMatrix = FilledSpriteAssembler._matrix; + const { elements: wE } = modelMatrix; + const { elements: pWE } = worldMatrix; + const sx = flipX ? -width : width; + const sy = flipY ? -height : height; + (wE[0] = pWE[0] * sx), (wE[1] = pWE[1] * sx), (wE[2] = pWE[2] * sx); + (wE[4] = pWE[4] * sy), (wE[5] = pWE[5] * sy), (wE[6] = pWE[6] * sy); + (wE[8] = pWE[8]), (wE[9] = pWE[9]), (wE[10] = pWE[10]); + wE[12] = pWE[12] - pivotX * wE[0] - pivotY * wE[4]; + wE[13] = pWE[13] - pivotX * wE[1] - pivotY * wE[5]; + wE[14] = pWE[14] - pivotX * wE[2] - pivotY * wE[6]; + + switch (renderer.filledMode) { + case SpriteFilledMode.Horizontal: + this._filledLinear(renderer, modelMatrix, true); + break; + case SpriteFilledMode.Vertical: + this._filledLinear(renderer, modelMatrix, false); + break; + case SpriteFilledMode.Radial90: + this._filledRadial90(renderer, modelMatrix, renderer.filledOrigin, renderer.filledAmount, renderer.filledClockWise); + break; + case SpriteFilledMode.Radial180: + this._filledRadial180(renderer, modelMatrix, renderer.filledOrigin, renderer.filledAmount, renderer.filledClockWise); + break; + case SpriteFilledMode.Radial360: + this._filledRadial360(renderer, modelMatrix, renderer.filledOrigin, renderer.filledAmount, renderer.filledClockWise); + break; + default: + break; + } + + // @ts-ignore + BoundingBox.transform(renderer.sprite._getBounds(), modelMatrix, renderer._bounds); + } + + static updateUVs(renderer: ISpriteRenderer): void { + // UVs are computed in updatePositions. + } + + static updateColor(renderer: ISpriteRenderer, alpha: number): void { + const subChunk = renderer._subChunk; + const { r, g, b, a } = renderer.color; + const finalAlpha = a * alpha; + const vertices = subChunk.chunk.vertices; + const vertexArea = subChunk.vertexArea; + for (let i = 0, o = vertexArea.start + 5, n = vertexArea.size / 9; i < n; ++i, o += 9) { + vertices[o] = r; + vertices[o + 1] = g; + vertices[o + 2] = b; + vertices[o + 3] = finalAlpha; + } + } + + private static _filledLinear(renderer: ISpriteRenderer, matrix: Matrix, isHorizontal: boolean): void { + const amount = renderer.filledAmount; + if (amount <= 0.001) { + renderer._subChunk.indices.length = 0; + return; + } + + const sprite = renderer.sprite; + const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); + const spriteUVs = sprite._getUVs(); + const { x: left, y: bottom } = spriteUVs[0]; + const { x: right, y: top } = spriteUVs[3]; + + const subChunk = renderer._subChunk; + const vertices = subChunk.chunk.vertices; + + let x0: number, y0: number, u0: number, v0: number; + let x1: number, y1: number, u1: number, v1: number; + let x2: number, y2: number, u2: number, v2: number; + let x3: number, y3: number, u3: number, v3: number; + + if (isHorizontal) { + const originIsStart = renderer.filledOrigin === SpriteFilledOrigin.Left; + const startX = originIsStart ? lPosLB.x : lPosRB.x - (lPosRB.x - lPosLB.x) * amount; + const endX = originIsStart ? lPosLB.x + (lPosRB.x - lPosLB.x) * amount : lPosRB.x; + const startU = originIsStart ? left : right - (right - left) * amount; + const endU = originIsStart ? left + (right - left) * amount : right; + (x0 = startX), (y0 = lPosLB.y), (u0 = startU), (v0 = bottom); + (x1 = endX), (y1 = lPosRB.y), (u1 = endU), (v1 = bottom); + (x2 = startX), (y2 = lPosLT.y), (u2 = startU), (v2 = top); + (x3 = endX), (y3 = lPosRT.y), (u3 = endU), (v3 = top); + } else { + const originIsStart = renderer.filledOrigin === SpriteFilledOrigin.Bottom; + const startY = originIsStart ? lPosLB.y : lPosLT.y - (lPosLT.y - lPosLB.y) * amount; + const endY = originIsStart ? lPosLB.y + (lPosLT.y - lPosLB.y) * amount : lPosLT.y; + const startV = originIsStart ? bottom : top - (top - bottom) * amount; + const endV = originIsStart ? bottom + (top - bottom) * amount : top; + (x0 = lPosLB.x), (y0 = startY), (u0 = left), (v0 = startV); + (x1 = lPosRB.x), (y1 = startY), (u1 = right), (v1 = startV); + (x2 = lPosLT.x), (y2 = endY), (u2 = left), (v2 = endV); + (x3 = lPosRT.x), (y3 = endY), (u3 = right), (v3 = endV); + } + + const { elements: wE } = matrix; + const start = subChunk.vertexArea.start; + // LB + vertices[start] = wE[0] * x0 + wE[4] * y0 + wE[12]; + vertices[start + 1] = wE[1] * x0 + wE[5] * y0 + wE[13]; + vertices[start + 2] = wE[2] * x0 + wE[6] * y0 + wE[14]; + vertices[start + 3] = u0; + vertices[start + 4] = v0; + // RB + vertices[start + 9] = wE[0] * x1 + wE[4] * y1 + wE[12]; + vertices[start + 10] = wE[1] * x1 + wE[5] * y1 + wE[13]; + vertices[start + 11] = wE[2] * x1 + wE[6] * y1 + wE[14]; + vertices[start + 12] = u1; + vertices[start + 13] = v1; + // LT + vertices[start + 18] = wE[0] * x2 + wE[4] * y2 + wE[12]; + vertices[start + 19] = wE[1] * x2 + wE[5] * y2 + wE[13]; + vertices[start + 20] = wE[2] * x2 + wE[6] * y2 + wE[14]; + vertices[start + 21] = u2; + vertices[start + 22] = v2; + // RT + vertices[start + 27] = wE[0] * x3 + wE[4] * y3 + wE[12]; + vertices[start + 28] = wE[1] * x3 + wE[5] * y3 + wE[13]; + vertices[start + 29] = wE[2] * x3 + wE[6] * y3 + wE[14]; + vertices[start + 30] = u3; + vertices[start + 31] = v3; + + const indices = subChunk.indices; + indices[0] = 0; + indices[1] = 1; + indices[2] = 2; + indices[3] = 2; + indices[4] = 1; + indices[5] = 3; + indices.length = 6; + } + + private static _filledRadial90( + renderer: ISpriteRenderer, + matrix: Matrix, + origin: SpriteFilledOrigin, + amount: number, + cw: boolean + ): void { + if (amount <= 0.001) { + renderer._subChunk.indices.length = 0; + return; + } + + const sprite = renderer.sprite; + const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); + const spriteUVs = sprite._getUVs(); + const { x: left, y: bottom } = spriteUVs[0]; + const { x: right, y: top } = spriteUVs[3]; + + // Transform 4 corners to world space + const [wLB, wRB, wLT, wRT] = this._worldPositions; + const [uvLB, uvRB, uvLT, uvRT] = this._uvs; + wLB.set(lPosLB.x, lPosLB.y, 0).transformToVec3(matrix), uvLB.set(left, bottom); + wRB.set(lPosRB.x, lPosRB.y, 0).transformToVec3(matrix), uvRB.set(right, bottom); + wLT.set(lPosLT.x, lPosLT.y, 0).transformToVec3(matrix), uvLT.set(left, top); + wRT.set(lPosRT.x, lPosRT.y, 0).transformToVec3(matrix), uvRT.set(right, top); + + // Map vertices based on origin corner: + // [center, CW-adjacent, CCW-adjacent, opposite] + const { _inPositions: inPositions, _inUVs: inUVs, _outPositions: outPositions, _outUVs: outUVs } = this; + switch (origin) { + case SpriteFilledOrigin.BottomLeft: + (inPositions[0] = wLB), (inUVs[0] = uvLB); + (inPositions[1] = wRB), (inUVs[1] = uvRB); + (inPositions[2] = wLT), (inUVs[2] = uvLT); + (inPositions[3] = wRT), (inUVs[3] = uvRT); + break; + case SpriteFilledOrigin.BottomRight: + (inPositions[0] = wRB), (inUVs[0] = uvRB); + (inPositions[1] = wRT), (inUVs[1] = uvRT); + (inPositions[2] = wLB), (inUVs[2] = uvLB); + (inPositions[3] = wLT), (inUVs[3] = uvLT); + break; + case SpriteFilledOrigin.TopRight: + (inPositions[0] = wRT), (inUVs[0] = uvRT); + (inPositions[1] = wLT), (inUVs[1] = uvLT); + (inPositions[2] = wRB), (inUVs[2] = uvRB); + (inPositions[3] = wLB), (inUVs[3] = uvLB); + break; + case SpriteFilledOrigin.TopLeft: + (inPositions[0] = wLT), (inUVs[0] = uvLT); + (inPositions[1] = wLB), (inUVs[1] = uvLB); + (inPositions[2] = wRT), (inUVs[2] = uvRT); + (inPositions[3] = wRB), (inUVs[3] = uvRB); + break; + default: + break; + } + + const startAngle = cw ? 90 - amount * 90 : 0; + const endAngle = cw ? 90 : amount * 90; + + this._vertexOffset = this._indicesOffset = 0; + this._radialCut(renderer._subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + renderer._subChunk.indices.length = this._indicesOffset; + } + + private static _filledRadial180( + renderer: ISpriteRenderer, + matrix: Matrix, + origin: SpriteFilledOrigin, + amount: number, + cw: boolean + ): void { + if (amount <= 0.001) { + renderer._subChunk.indices.length = 0; + return; + } + + const sprite = renderer.sprite; + const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); + const spriteUVs = sprite._getUVs(); + const { x: left, y: bottom } = spriteUVs[0]; + const { x: right, y: top } = spriteUVs[3]; + + // Transform corners and compute edge midpoints + const [wLB, wMB, wRB, wLM, , wRM, wLT, wMT, wRT] = this._worldPositions; + const [uvLB, uvMB, uvRB, uvLM, , uvRM, uvLT, uvMT, uvRT] = this._uvs; + wLB.set(lPosLB.x, lPosLB.y, 0).transformToVec3(matrix), uvLB.set(left, bottom); + wRB.set(lPosRB.x, lPosRB.y, 0).transformToVec3(matrix), uvRB.set(right, bottom); + wLT.set(lPosLT.x, lPosLT.y, 0).transformToVec3(matrix), uvLT.set(left, top); + wRT.set(lPosRT.x, lPosRT.y, 0).transformToVec3(matrix), uvRT.set(right, top); + Vector3.lerp(wLB, wRB, 0.5, wMB), Vector2.lerp(uvLB, uvRB, 0.5, uvMB); + Vector3.lerp(wLB, wLT, 0.5, wLM), Vector2.lerp(uvLB, uvLT, 0.5, uvLM); + Vector3.lerp(wLT, wRT, 0.5, wMT), Vector2.lerp(uvLT, uvRT, 0.5, uvMT); + Vector3.lerp(wRB, wRT, 0.5, wRM), Vector2.lerp(uvRB, uvRT, 0.5, uvRM); + + const startAngle = cw ? 180 - amount * 180 : 0; + const endAngle = cw ? 180 : amount * 180; + + this._vertexOffset = this._indicesOffset = 0; + const { _inPositions: inPositions, _inUVs: inUVs, _outPositions: outPositions, _outUVs: outUVs } = this; + const { _subChunk: subChunk } = renderer; + + // Center is at the origin edge midpoint; two quadrants cover the full sprite. + // Quadrant A (0°-90°), Quadrant B (90°-180°) + switch (origin) { + case SpriteFilledOrigin.Bottom: + // Center=MB, A: [MB,RB,MT,RT], B: [MB,MT,LB,LT] + (inPositions[0] = wMB), (inUVs[0] = uvMB); + (inPositions[1] = wRB), (inUVs[1] = uvRB); + (inPositions[2] = wMT), (inUVs[2] = uvMT); + (inPositions[3] = wRT), (inUVs[3] = uvRT); + this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + (inPositions[1] = wMT), (inUVs[1] = uvMT); + (inPositions[2] = wLB), (inUVs[2] = uvLB); + (inPositions[3] = wLT), (inUVs[3] = uvLT); + this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); + break; + case SpriteFilledOrigin.Top: + // Center=MT, A: [MT,LT,MB,LB], B: [MT,MB,RT,RB] + (inPositions[0] = wMT), (inUVs[0] = uvMT); + (inPositions[1] = wLT), (inUVs[1] = uvLT); + (inPositions[2] = wMB), (inUVs[2] = uvMB); + (inPositions[3] = wLB), (inUVs[3] = uvLB); + this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + (inPositions[1] = wMB), (inUVs[1] = uvMB); + (inPositions[2] = wRT), (inUVs[2] = uvRT); + (inPositions[3] = wRB), (inUVs[3] = uvRB); + this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); + break; + case SpriteFilledOrigin.Left: + // Center=LM, A: [LM,LB,RM,RB], B: [LM,RM,LT,RT] + (inPositions[0] = wLM), (inUVs[0] = uvLM); + (inPositions[1] = wLB), (inUVs[1] = uvLB); + (inPositions[2] = wRM), (inUVs[2] = uvRM); + (inPositions[3] = wRB), (inUVs[3] = uvRB); + this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + (inPositions[1] = wRM), (inUVs[1] = uvRM); + (inPositions[2] = wLT), (inUVs[2] = uvLT); + (inPositions[3] = wRT), (inUVs[3] = uvRT); + this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); + break; + case SpriteFilledOrigin.Right: + // Center=RM, A: [RM,RT,LM,LT], B: [RM,LM,RB,LB] + (inPositions[0] = wRM), (inUVs[0] = uvRM); + (inPositions[1] = wRT), (inUVs[1] = uvRT); + (inPositions[2] = wLM), (inUVs[2] = uvLM); + (inPositions[3] = wLT), (inUVs[3] = uvLT); + this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + (inPositions[1] = wLM), (inUVs[1] = uvLM); + (inPositions[2] = wRB), (inUVs[2] = uvRB); + (inPositions[3] = wLB), (inUVs[3] = uvLB); + this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); + break; + default: + break; + } + + subChunk.indices.length = this._indicesOffset; + } + + private static _filledRadial360( + renderer: ISpriteRenderer, + matrix: Matrix, + origin: SpriteFilledOrigin, + amount: number, + cw: boolean + ): void { + if (amount <= 0.001) { + renderer._subChunk.indices.length = 0; + return; + } + + let startAngle = 0; + switch (origin) { + case SpriteFilledOrigin.Right: + startAngle = cw ? 360 - amount * 360 : 0; + break; + case SpriteFilledOrigin.Top: + startAngle = cw ? 450 - amount * 360 : 90; + break; + case SpriteFilledOrigin.Left: + startAngle = cw ? 540 - amount * 360 : 180; + break; + case SpriteFilledOrigin.Bottom: + startAngle = cw ? 630 - amount * 360 : 270; + break; + default: + break; + } + const endAngle = startAngle + amount * 360; + + this._processRadialGrid(renderer, matrix, startAngle, endAngle); + } + + /** + * Prepare the 3x3 grid and process 4 quadrants for radial fill. + */ + private static _processRadialGrid( + renderer: ISpriteRenderer, + matrix: Matrix, + startAngle: number, + endAngle: number + ): void { + const sprite = renderer.sprite; + const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); + const spriteUVs = sprite._getUVs(); + const { x: left, y: bottom } = spriteUVs[0]; + const { x: right, y: top } = spriteUVs[3]; + + // --------------- + // LT - MT - RT + // | | | + // LM - C - RM + // | | | + // LB - MB - RB + // --------------- + const [wPosLB, wPosMB, wPosRB, wPosLM, wPosC, wPosRM, wPosLT, wPosMT, wPosRT] = this._worldPositions; + const [uvLB, uvMB, uvRB, uvLM, uvC, uvRM, uvLT, uvMT, uvRT] = this._uvs; + + wPosLB.set(lPosLB.x, lPosLB.y, 0).transformToVec3(matrix), uvLB.set(left, bottom); + wPosRB.set(lPosRB.x, lPosRB.y, 0).transformToVec3(matrix), uvRB.set(right, bottom); + wPosLT.set(lPosLT.x, lPosLT.y, 0).transformToVec3(matrix), uvLT.set(left, top); + wPosRT.set(lPosRT.x, lPosRT.y, 0).transformToVec3(matrix), uvRT.set(right, top); + Vector3.lerp(wPosLB, wPosRB, 0.5, wPosMB), Vector2.lerp(uvLB, uvRB, 0.5, uvMB); + Vector3.lerp(wPosLB, wPosLT, 0.5, wPosLM), Vector2.lerp(uvLB, uvLT, 0.5, uvLM); + Vector3.lerp(wPosLT, wPosRT, 0.5, wPosMT), Vector2.lerp(uvLT, uvRT, 0.5, uvMT); + Vector3.lerp(wPosRB, wPosRT, 0.5, wPosRM), Vector2.lerp(uvRB, uvRT, 0.5, uvRM); + Vector3.lerp(wPosLB, wPosRT, 0.5, wPosC), Vector2.lerp(uvLB, uvRT, 0.5, uvC); + + this._vertexOffset = this._indicesOffset = 0; + const { _inPositions: inPositions, _inUVs: inUVs, _outPositions: outPositions, _outUVs: outUVs } = this; + const { _subChunk: subChunk } = renderer; + let quadrantStart = 0; + let quadrantEnd = 0; + (inPositions[0] = wPosC), (inUVs[0] = uvC); + + { + // First quadrant (0°-90°) + if (startAngle >= 90) { + quadrantStart = startAngle - 360; + quadrantEnd = endAngle - 360; + } else { + quadrantStart = startAngle; + quadrantEnd = endAngle; + } + (inPositions[1] = wPosRM), (inUVs[1] = uvRM); + (inPositions[2] = wPosMT), (inUVs[2] = uvMT); + (inPositions[3] = wPosRT), (inUVs[3] = uvRT); + this._radialCut(subChunk, inPositions, inUVs, quadrantStart, quadrantEnd, outPositions, outUVs); + } + + { + // Second quadrant (90°-180°) + if (startAngle >= 180) { + quadrantStart = startAngle - 360 - 90; + quadrantEnd = endAngle - 360 - 90; + } else { + quadrantStart = startAngle - 90; + quadrantEnd = endAngle - 90; + } + (inPositions[1] = wPosMT), (inUVs[1] = uvMT); + (inPositions[2] = wPosLM), (inUVs[2] = uvLM); + (inPositions[3] = wPosLT), (inUVs[3] = uvLT); + this._radialCut(subChunk, inPositions, inUVs, quadrantStart, quadrantEnd, outPositions, outUVs); + } + + { + // Third quadrant (180°-270°) + if (startAngle >= 270) { + quadrantStart = startAngle - 360 - 180; + quadrantEnd = endAngle - 360 - 180; + } else { + quadrantStart = startAngle - 180; + quadrantEnd = endAngle - 180; + } + (inPositions[1] = wPosLM), (inUVs[1] = uvLM); + (inPositions[2] = wPosMB), (inUVs[2] = uvMB); + (inPositions[3] = wPosLB), (inUVs[3] = uvLB); + this._radialCut(subChunk, inPositions, inUVs, quadrantStart, quadrantEnd, outPositions, outUVs); + } + + { + // Fourth quadrant (270°-360°) + if (startAngle >= 360) { + quadrantStart = startAngle - 360 - 270; + quadrantEnd = endAngle - 360 - 270; + } else { + quadrantStart = startAngle - 270; + quadrantEnd = endAngle - 270; + } + (inPositions[1] = wPosMB), (inUVs[1] = uvMB); + (inPositions[2] = wPosRM), (inUVs[2] = uvRM); + (inPositions[3] = wPosRB), (inUVs[3] = uvRB); + this._radialCut(subChunk, inPositions, inUVs, quadrantStart, quadrantEnd, outPositions, outUVs); + } + + subChunk.indices.length = this._indicesOffset; + } + + private static _radialCut( + subChunk: SubPrimitiveChunk, + positions: Vector3[], + uvs: Vector2[], + start: number, + end: number, + outPositions: Vector3[], + outUVs: Vector2[] + ): void { + if (start >= 90 || end <= 0) return; + outPositions[0].copyFrom(positions[0]); + outUVs[0].copyFrom(uvs[0]); + + if (start <= 0) { + outPositions[1].copyFrom(positions[1]); + outUVs[1].copyFrom(uvs[1]); + } else { + const startTan = Math.tan((start * Math.PI) / 180); + if (startTan < 1) { + Vector3.lerp(positions[1], positions[3], startTan, outPositions[1]); + Vector2.lerp(uvs[1], uvs[3], startTan, outUVs[1]); + } else { + Vector3.lerp(positions[2], positions[3], 1 / startTan, outPositions[1]); + Vector2.lerp(uvs[2], uvs[3], 1 / startTan, outUVs[1]); + } + } + + if (end >= 90) { + outPositions[2].copyFrom(positions[2]); + outUVs[2].copyFrom(uvs[2]); + } else { + const endTan = Math.tan((end * Math.PI) / 180); + if (endTan < 1) { + Vector3.lerp(positions[1], positions[3], endTan, outPositions[2]); + Vector2.lerp(uvs[1], uvs[3], endTan, outUVs[2]); + } else { + Vector3.lerp(positions[2], positions[3], 1 / endTan, outPositions[2]); + Vector2.lerp(uvs[2], uvs[3], 1 / endTan, outUVs[2]); + } + } + + if (start < 45 && end > 45) { + outPositions[3].copyFrom(positions[3]); + outUVs[3].copyFrom(uvs[3]); + this._addQuad(subChunk, outPositions, outUVs); + } else { + this._addTriangle(subChunk, outPositions, outUVs); + } + } + + private static _addTriangle(subChunk: SubPrimitiveChunk, positions: Vector3[], uvs: Vector2[]): void { + const vertices = subChunk.chunk.vertices; + const indices = subChunk.indices; + const vertexOffset = this._vertexOffset; + const vertexCount = vertexOffset / 9; + const start = subChunk.vertexArea.start + vertexOffset; + for (let i = 0, o = start; i < 3; ++i, o += 9) { + const position = positions[i]; + const uv = uvs[i]; + vertices[o] = position.x; + vertices[o + 1] = position.y; + vertices[o + 2] = position.z; + vertices[o + 3] = uv.x; + vertices[o + 4] = uv.y; + } + const indicesOffset = this._indicesOffset; + indices[indicesOffset] = vertexCount; + indices[indicesOffset + 1] = vertexCount + 1; + indices[indicesOffset + 2] = vertexCount + 2; + this._vertexOffset += 3 * 9; + this._indicesOffset += 3; + } + + private static _addQuad(subChunk: SubPrimitiveChunk, positions: Vector3[], uvs: Vector2[]): void { + const vertices = subChunk.chunk.vertices; + const indices = subChunk.indices; + const vertexOffset = this._vertexOffset; + const vertexCount = vertexOffset / 9; + const start = subChunk.vertexArea.start + vertexOffset; + for (let i = 0, o = start; i < 4; ++i, o += 9) { + const position = positions[i]; + const uv = uvs[i]; + vertices[o] = position.x; + vertices[o + 1] = position.y; + vertices[o + 2] = position.z; + vertices[o + 3] = uv.x; + vertices[o + 4] = uv.y; + } + const indicesOffset = this._indicesOffset; + indices[indicesOffset] = vertexCount; + indices[indicesOffset + 1] = vertexCount + 1; + indices[indicesOffset + 2] = vertexCount + 2; + indices[indicesOffset + 3] = vertexCount + 2; + indices[indicesOffset + 4] = vertexCount + 1; + indices[indicesOffset + 5] = vertexCount + 3; + this._vertexOffset += 4 * 9; + this._indicesOffset += 6; + } +} diff --git a/packages/core/src/2d/assembler/ISpriteRenderer.ts b/packages/core/src/2d/assembler/ISpriteRenderer.ts index a72f4e9436..04b50b3fc9 100644 --- a/packages/core/src/2d/assembler/ISpriteRenderer.ts +++ b/packages/core/src/2d/assembler/ISpriteRenderer.ts @@ -1,6 +1,8 @@ import { Color } from "@galacean/engine-math"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; +import { SpriteFilledMode } from "../enums/SpriteFilledMode"; +import { SpriteFilledOrigin } from "../enums/SpriteFilledOrigin"; import { SpriteTileMode } from "../enums/SpriteTileMode"; import { Sprite } from "../sprite"; @@ -12,6 +14,10 @@ export interface ISpriteRenderer { color?: Color; tileMode?: SpriteTileMode; tiledAdaptiveThreshold?: number; + filledMode?: SpriteFilledMode; + filledAmount?: number; + filledOrigin?: SpriteFilledOrigin; + filledClockWise?: boolean; _subChunk: SubPrimitiveChunk; _getChunkManager(): PrimitiveChunkManager; } diff --git a/packages/core/src/2d/enums/SpriteDrawMode.ts b/packages/core/src/2d/enums/SpriteDrawMode.ts index 46bcfd3783..6f35d8c1ef 100644 --- a/packages/core/src/2d/enums/SpriteDrawMode.ts +++ b/packages/core/src/2d/enums/SpriteDrawMode.ts @@ -7,5 +7,7 @@ export enum SpriteDrawMode { /** When modifying the size of the renderer, it scales to fill the range according to the sprite border settings. */ Sliced, /** When modifying the size of the renderer, it will tile to fill the range according to the sprite border settings. */ - Tiled + Tiled, + /** Fill the sprite partially, controlled by fill amount, mode and origin. */ + Filled } diff --git a/packages/core/src/2d/enums/SpriteFilledMode.ts b/packages/core/src/2d/enums/SpriteFilledMode.ts new file mode 100644 index 0000000000..a01cda9e53 --- /dev/null +++ b/packages/core/src/2d/enums/SpriteFilledMode.ts @@ -0,0 +1,15 @@ +/** + * Sprite's filled mode enumeration. + */ +export enum SpriteFilledMode { + /** Fill horizontally. */ + Horizontal, + /** Fill vertically. */ + Vertical, + /** Fill radially over 90 degrees. */ + Radial90, + /** Fill radially over 180 degrees. */ + Radial180, + /** Fill radially over 360 degrees. */ + Radial360 +} diff --git a/packages/core/src/2d/enums/SpriteFilledOrigin.ts b/packages/core/src/2d/enums/SpriteFilledOrigin.ts new file mode 100644 index 0000000000..e9b3193970 --- /dev/null +++ b/packages/core/src/2d/enums/SpriteFilledOrigin.ts @@ -0,0 +1,21 @@ +/** + * Sprite's filled origin enumeration. + */ +export enum SpriteFilledOrigin { + /** Origin at the right. */ + Right, + /** Origin at the top-right. */ + TopRight, + /** Origin at the top. */ + Top, + /** Origin at the top-left. */ + TopLeft, + /** Origin at the left. */ + Left, + /** Origin at the bottom-left. */ + BottomLeft, + /** Origin at the bottom. */ + Bottom, + /** Origin at the bottom-right. */ + BottomRight +} diff --git a/packages/core/src/2d/index.ts b/packages/core/src/2d/index.ts index 47be64ccfc..5481f42b28 100644 --- a/packages/core/src/2d/index.ts +++ b/packages/core/src/2d/index.ts @@ -1,11 +1,14 @@ export type { ISpriteAssembler } from "./assembler/ISpriteAssembler"; export type { ISpriteRenderer } from "./assembler/ISpriteRenderer"; +export { FilledSpriteAssembler } from "./assembler/FilledSpriteAssembler"; export { SimpleSpriteAssembler } from "./assembler/SimpleSpriteAssembler"; export { SlicedSpriteAssembler } from "./assembler/SlicedSpriteAssembler"; export { TiledSpriteAssembler } from "./assembler/TiledSpriteAssembler"; export { SpriteAtlas } from "./atlas/SpriteAtlas"; export { FontStyle } from "./enums/FontStyle"; export { SpriteDrawMode } from "./enums/SpriteDrawMode"; +export { SpriteFilledMode } from "./enums/SpriteFilledMode"; +export { SpriteFilledOrigin } from "./enums/SpriteFilledOrigin"; export { SpriteMaskInteraction } from "./enums/SpriteMaskInteraction"; export { SpriteModifyFlags } from "./enums/SpriteModifyFlags"; export { SpriteTileMode } from "./enums/SpriteTileMode"; diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index c1b183ee7a..a75dac1510 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -10,10 +10,13 @@ import { assignmentClone, deepClone, ignoreClone } from "../../clone/CloneManage import { ShaderProperty } from "../../shader/ShaderProperty"; import { ISpriteAssembler } from "../assembler/ISpriteAssembler"; import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; +import { FilledSpriteAssembler } from "../assembler/FilledSpriteAssembler"; import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; import { SlicedSpriteAssembler } from "../assembler/SlicedSpriteAssembler"; import { TiledSpriteAssembler } from "../assembler/TiledSpriteAssembler"; import { SpriteDrawMode } from "../enums/SpriteDrawMode"; +import { SpriteFilledMode } from "../enums/SpriteFilledMode"; +import { SpriteFilledOrigin } from "../enums/SpriteFilledOrigin"; import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; import { SpriteTileMode } from "../enums/SpriteTileMode"; @@ -39,6 +42,14 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { private _tileMode: SpriteTileMode = SpriteTileMode.Continuous; @assignmentClone private _tiledAdaptiveThreshold: number = 0.5; + @assignmentClone + private _filledMode: SpriteFilledMode = SpriteFilledMode.Radial360; + @assignmentClone + private _filledAmount: number = 1; + @assignmentClone + private _filledOrigin: SpriteFilledOrigin = SpriteFilledOrigin.Bottom; + @assignmentClone + private _filledClockWise: boolean = true; @deepClone private _color: Color = new Color(1, 1, 1, 1); @@ -78,6 +89,9 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { case SpriteDrawMode.Tiled: this._assembler = TiledSpriteAssembler; break; + case SpriteDrawMode.Filled: + this._assembler = FilledSpriteAssembler; + break; default: break; } @@ -119,6 +133,74 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { } } + /** + * The fill amount of the sprite renderer, range from 0 to 1. (Only works in filled mode.) + */ + get filledAmount(): number { + return this._filledAmount; + } + + set filledAmount(value: number) { + value = MathUtil.clamp(value, 0, 1); + if (this._filledAmount !== value) { + this._filledAmount = value; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; + } + } + } + + /** + * The fill mode of the sprite renderer. (Only works in filled mode.) + */ + get filledMode(): SpriteFilledMode { + return this._filledMode; + } + + set filledMode(value: SpriteFilledMode) { + if (this._filledMode !== value) { + this._filledMode = value; + // Reset origin to a valid default for the new mode + this._filledOrigin = + value === SpriteFilledMode.Radial90 ? SpriteFilledOrigin.BottomLeft : SpriteFilledOrigin.Bottom; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; + } + } + } + + /** + * The fill origin of the sprite renderer. (Only works in filled mode.) + */ + get filledOrigin(): SpriteFilledOrigin { + return this._filledOrigin; + } + + set filledOrigin(value: SpriteFilledOrigin) { + if (this._filledOrigin !== value) { + this._filledOrigin = value; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; + } + } + } + + /** + * Whether the fill is clockwise. (Only works in filled radial mode.) + */ + get filledClockWise(): boolean { + return this._filledClockWise; + } + + set filledClockWise(value: boolean) { + if (this._filledClockWise !== value) { + this._filledClockWise = value; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; + } + } + } + /** * The Sprite to render. */ @@ -438,6 +520,9 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { case SpriteDrawMode.Tiled: this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeUVAndColor; break; + case SpriteDrawMode.Filled: + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeUVAndColor; + break; } break; case SpriteModifyFlags.border: From 44702de111b80bd7465e18d3af52a6ab62892ae7 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 27 Apr 2026 06:53:22 +0800 Subject: [PATCH 2/7] fix: filled uv --- .../src/2d/assembler/FilledSpriteAssembler.ts | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/core/src/2d/assembler/FilledSpriteAssembler.ts b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts index 894f1b07c6..b5e9797119 100644 --- a/packages/core/src/2d/assembler/FilledSpriteAssembler.ts +++ b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts @@ -80,13 +80,31 @@ export class FilledSpriteAssembler { this._filledLinear(renderer, modelMatrix, false); break; case SpriteFilledMode.Radial90: - this._filledRadial90(renderer, modelMatrix, renderer.filledOrigin, renderer.filledAmount, renderer.filledClockWise); + this._filledRadial90( + renderer, + modelMatrix, + renderer.filledOrigin, + renderer.filledAmount, + renderer.filledClockWise + ); break; case SpriteFilledMode.Radial180: - this._filledRadial180(renderer, modelMatrix, renderer.filledOrigin, renderer.filledAmount, renderer.filledClockWise); + this._filledRadial180( + renderer, + modelMatrix, + renderer.filledOrigin, + renderer.filledAmount, + renderer.filledClockWise + ); break; case SpriteFilledMode.Radial360: - this._filledRadial360(renderer, modelMatrix, renderer.filledOrigin, renderer.filledAmount, renderer.filledClockWise); + this._filledRadial360( + renderer, + modelMatrix, + renderer.filledOrigin, + renderer.filledAmount, + renderer.filledClockWise + ); break; default: break; @@ -125,7 +143,7 @@ export class FilledSpriteAssembler { const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); const spriteUVs = sprite._getUVs(); const { x: left, y: bottom } = spriteUVs[0]; - const { x: right, y: top } = spriteUVs[3]; + const { x: right, y: top } = spriteUVs[15]; const subChunk = renderer._subChunk; const vertices = subChunk.chunk.vertices; @@ -210,7 +228,7 @@ export class FilledSpriteAssembler { const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); const spriteUVs = sprite._getUVs(); const { x: left, y: bottom } = spriteUVs[0]; - const { x: right, y: top } = spriteUVs[3]; + const { x: right, y: top } = spriteUVs[15]; // Transform 4 corners to world space const [wLB, wRB, wLT, wRT] = this._worldPositions; @@ -276,7 +294,7 @@ export class FilledSpriteAssembler { const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); const spriteUVs = sprite._getUVs(); const { x: left, y: bottom } = spriteUVs[0]; - const { x: right, y: top } = spriteUVs[3]; + const { x: right, y: top } = spriteUVs[15]; // Transform corners and compute edge midpoints const [wLB, wMB, wRB, wLM, , wRM, wLT, wMT, wRT] = this._worldPositions; @@ -402,7 +420,7 @@ export class FilledSpriteAssembler { const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); const spriteUVs = sprite._getUVs(); const { x: left, y: bottom } = spriteUVs[0]; - const { x: right, y: top } = spriteUVs[3]; + const { x: right, y: top } = spriteUVs[15]; // --------------- // LT - MT - RT From 67d06add6bd57de445549b7390910c3843a69a6c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 20 Jun 2026 11:16:30 +0800 Subject: [PATCH 3/7] fix(2d): correct filled sprite UV index, default origin and dirty flags - 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 --- .../src/2d/assembler/FilledSpriteAssembler.ts | 50 ++--- packages/core/src/2d/sprite/SpriteRenderer.ts | 21 +- tests/src/core/SpriteRenderer.test.ts | 197 ++++++++++++++++++ 3 files changed, 239 insertions(+), 29 deletions(-) diff --git a/packages/core/src/2d/assembler/FilledSpriteAssembler.ts b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts index b5e9797119..34a3a40ffd 100644 --- a/packages/core/src/2d/assembler/FilledSpriteAssembler.ts +++ b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts @@ -143,7 +143,7 @@ export class FilledSpriteAssembler { const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); const spriteUVs = sprite._getUVs(); const { x: left, y: bottom } = spriteUVs[0]; - const { x: right, y: top } = spriteUVs[15]; + const { x: right, y: top } = spriteUVs[3]; const subChunk = renderer._subChunk; const vertices = subChunk.chunk.vertices; @@ -228,7 +228,7 @@ export class FilledSpriteAssembler { const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); const spriteUVs = sprite._getUVs(); const { x: left, y: bottom } = spriteUVs[0]; - const { x: right, y: top } = spriteUVs[15]; + const { x: right, y: top } = spriteUVs[3]; // Transform 4 corners to world space const [wLB, wRB, wLT, wRT] = this._worldPositions; @@ -242,12 +242,6 @@ export class FilledSpriteAssembler { // [center, CW-adjacent, CCW-adjacent, opposite] const { _inPositions: inPositions, _inUVs: inUVs, _outPositions: outPositions, _outUVs: outUVs } = this; switch (origin) { - case SpriteFilledOrigin.BottomLeft: - (inPositions[0] = wLB), (inUVs[0] = uvLB); - (inPositions[1] = wRB), (inUVs[1] = uvRB); - (inPositions[2] = wLT), (inUVs[2] = uvLT); - (inPositions[3] = wRT), (inUVs[3] = uvRT); - break; case SpriteFilledOrigin.BottomRight: (inPositions[0] = wRB), (inUVs[0] = uvRB); (inPositions[1] = wRT), (inUVs[1] = uvRT); @@ -266,7 +260,13 @@ export class FilledSpriteAssembler { (inPositions[2] = wRT), (inUVs[2] = uvRT); (inPositions[3] = wRB), (inUVs[3] = uvRB); break; + // BottomLeft is the default; any unsupported origin falls back to it instead of rendering stale data. + case SpriteFilledOrigin.BottomLeft: default: + (inPositions[0] = wLB), (inUVs[0] = uvLB); + (inPositions[1] = wRB), (inUVs[1] = uvRB); + (inPositions[2] = wLT), (inUVs[2] = uvLT); + (inPositions[3] = wRT), (inUVs[3] = uvRT); break; } @@ -294,7 +294,7 @@ export class FilledSpriteAssembler { const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); const spriteUVs = sprite._getUVs(); const { x: left, y: bottom } = spriteUVs[0]; - const { x: right, y: top } = spriteUVs[15]; + const { x: right, y: top } = spriteUVs[3]; // Transform corners and compute edge midpoints const [wLB, wMB, wRB, wLM, , wRM, wLT, wMT, wRT] = this._worldPositions; @@ -318,18 +318,6 @@ export class FilledSpriteAssembler { // Center is at the origin edge midpoint; two quadrants cover the full sprite. // Quadrant A (0°-90°), Quadrant B (90°-180°) switch (origin) { - case SpriteFilledOrigin.Bottom: - // Center=MB, A: [MB,RB,MT,RT], B: [MB,MT,LB,LT] - (inPositions[0] = wMB), (inUVs[0] = uvMB); - (inPositions[1] = wRB), (inUVs[1] = uvRB); - (inPositions[2] = wMT), (inUVs[2] = uvMT); - (inPositions[3] = wRT), (inUVs[3] = uvRT); - this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); - (inPositions[1] = wMT), (inUVs[1] = uvMT); - (inPositions[2] = wLB), (inUVs[2] = uvLB); - (inPositions[3] = wLT), (inUVs[3] = uvLT); - this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); - break; case SpriteFilledOrigin.Top: // Center=MT, A: [MT,LT,MB,LB], B: [MT,MB,RT,RB] (inPositions[0] = wMT), (inUVs[0] = uvMT); @@ -366,7 +354,19 @@ export class FilledSpriteAssembler { (inPositions[3] = wLB), (inUVs[3] = uvLB); this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); break; + // Bottom is the default; any unsupported origin falls back to it instead of rendering stale data. + case SpriteFilledOrigin.Bottom: default: + // Center=MB, A: [MB,RB,MT,RT], B: [MB,MT,LB,LT] + (inPositions[0] = wMB), (inUVs[0] = uvMB); + (inPositions[1] = wRB), (inUVs[1] = uvRB); + (inPositions[2] = wMT), (inUVs[2] = uvMT); + (inPositions[3] = wRT), (inUVs[3] = uvRT); + this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + (inPositions[1] = wMT), (inUVs[1] = uvMT); + (inPositions[2] = wLB), (inUVs[2] = uvLB); + (inPositions[3] = wLT), (inUVs[3] = uvLT); + this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); break; } @@ -387,9 +387,6 @@ export class FilledSpriteAssembler { let startAngle = 0; switch (origin) { - case SpriteFilledOrigin.Right: - startAngle = cw ? 360 - amount * 360 : 0; - break; case SpriteFilledOrigin.Top: startAngle = cw ? 450 - amount * 360 : 90; break; @@ -399,7 +396,10 @@ export class FilledSpriteAssembler { case SpriteFilledOrigin.Bottom: startAngle = cw ? 630 - amount * 360 : 270; break; + // Right is the default; any unsupported origin falls back to it. + case SpriteFilledOrigin.Right: default: + startAngle = cw ? 360 - amount * 360 : 0; break; } const endAngle = startAngle + amount * 360; @@ -420,7 +420,7 @@ export class FilledSpriteAssembler { const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); const spriteUVs = sprite._getUVs(); const { x: left, y: bottom } = spriteUVs[0]; - const { x: right, y: top } = spriteUVs[15]; + const { x: right, y: top } = spriteUVs[3]; // --------------- // LT - MT - RT diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index a75dac1510..2faf38a538 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -160,9 +160,18 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { set filledMode(value: SpriteFilledMode) { if (this._filledMode !== value) { this._filledMode = value; - // Reset origin to a valid default for the new mode - this._filledOrigin = - value === SpriteFilledMode.Radial90 ? SpriteFilledOrigin.BottomLeft : SpriteFilledOrigin.Bottom; + // Reset origin to a valid default for the new mode, since each mode only accepts a subset of origins. + switch (value) { + case SpriteFilledMode.Horizontal: + this._filledOrigin = SpriteFilledOrigin.Left; + break; + case SpriteFilledMode.Radial90: + this._filledOrigin = SpriteFilledOrigin.BottomLeft; + break; + default: + this._filledOrigin = SpriteFilledOrigin.Bottom; + break; + } if (this._drawMode === SpriteDrawMode.Filled) { this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; } @@ -542,7 +551,11 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; break; case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.UV; + // Filled mode bakes UVs inside `updatePositions`, so a UV-only refresh would be a no-op. + this._dirtyUpdateFlag |= + this._drawMode === SpriteDrawMode.Filled + ? SpriteRendererUpdateFlags.WorldVolumeAndUV + : SpriteRendererUpdateFlags.UV; break; case SpriteModifyFlags.pivot: this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; diff --git a/tests/src/core/SpriteRenderer.test.ts b/tests/src/core/SpriteRenderer.test.ts index db08e68b08..3275adac82 100644 --- a/tests/src/core/SpriteRenderer.test.ts +++ b/tests/src/core/SpriteRenderer.test.ts @@ -2,6 +2,8 @@ import { RendererUpdateFlags, Sprite, SpriteDrawMode, + SpriteFilledMode, + SpriteFilledOrigin, SpriteMaskInteraction, SpriteMaskLayer, SpriteRenderer, @@ -1578,6 +1580,201 @@ describe("SpriteRenderer", async () => { expect(spriteRenderer.bounds.min).to.deep.eq(new Vector3(-0.5, -1, 0)); expect(spriteRenderer.bounds.max).to.deep.eq(new Vector3(0.5, 1, 0)); }); + + it("get set filled properties", () => { + const rootEntity = scene.getRootEntity(); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.drawMode = SpriteDrawMode.Filled; + + // filledAmount is clamped to [0, 1] + spriteRenderer.filledAmount = 2; + expect(spriteRenderer.filledAmount).to.eq(1); + spriteRenderer.filledAmount = -1; + expect(spriteRenderer.filledAmount).to.eq(0); + spriteRenderer.filledAmount = 0.5; + expect(spriteRenderer.filledAmount).to.eq(0.5); + + spriteRenderer.filledClockWise = false; + expect(spriteRenderer.filledClockWise).to.eq(false); + + // Changing filledMode resets filledOrigin to a valid default for the new mode + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + expect(spriteRenderer.filledMode).to.eq(SpriteFilledMode.Horizontal); + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Left); + spriteRenderer.filledMode = SpriteFilledMode.Vertical; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Bottom); + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.BottomLeft); + spriteRenderer.filledMode = SpriteFilledMode.Radial180; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Bottom); + spriteRenderer.filledMode = SpriteFilledMode.Radial360; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Bottom); + + // filledOrigin can still be set explicitly + spriteRenderer.filledOrigin = SpriteFilledOrigin.TopRight; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.TopRight); + }); + + it("draw Filled Sprite (linear)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const readVertex = (i: number) => { + const o = subChunk.vertexArea.start + i * 9; + return { + pos: new Vector3(vertices[o], vertices[o + 1], vertices[o + 2]), + uv: new Vector2(vertices[o + 3], vertices[o + 4]) + }; + }; + + // Horizontal, default origin Left → fill the left half + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(6); + expect(Vector3.equals(readVertex(0).pos, new Vector3(0, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(2, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(0, 5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(2, 5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0, 1))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(0.5, 1))).to.eq(true); + expect(Vector2.equals(readVertex(2).uv, new Vector2(0, 0))).to.eq(true); + expect(Vector2.equals(readVertex(3).uv, new Vector2(0.5, 0))).to.eq(true); + + // Vertical, default origin Bottom → fill the bottom half + spriteRenderer.filledMode = SpriteFilledMode.Vertical; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(6); + expect(Vector3.equals(readVertex(0).pos, new Vector3(0, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(4, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(0, 2.5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(4, 2.5, 0))).to.eq(true); + }); + + it("draw Filled Sprite (radial amount bounds)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + + // Radial360 full fill → 4 quadrants (4 quads × 6 indices) + spriteRenderer.filledMode = SpriteFilledMode.Radial360; + spriteRenderer.filledAmount = 1; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(24); + + // amount 0 → nothing is drawn + spriteRenderer.filledAmount = 0; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(0); + + // Radial90 full fill → single quad + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + spriteRenderer.filledAmount = 1; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(6); + }); + + it("draw Filled Sprite falls back to the default origin for an unsupported origin", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + // The radial cut depends only on amount/clockwise, so all origins emit one triangle here. + const snapshotGeom = () => { + const start = subChunk.vertexArea.start; + const out: number[] = []; + for (let i = 0; i < 3; ++i) { + const o = start + i * 9; + out.push(vertices[o], vertices[o + 1], vertices[o + 2], vertices[o + 3], vertices[o + 4]); + } + return out; + }; + + // Radial90 with its default corner origin (BottomLeft) + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + const bottomLeftGeom = snapshotGeom(); + + // A different valid corner origin produces different geometry + spriteRenderer.filledOrigin = SpriteFilledOrigin.TopRight; + // @ts-ignore + spriteRenderer._render(context); + expect(snapshotGeom()).to.not.eql(bottomLeftGeom); + + // An unsupported edge origin must fall back to the default (BottomLeft), + // not silently reuse the previous (TopRight) geometry. + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; + // @ts-ignore + spriteRenderer._render(context); + expect(snapshotGeom()).to.eql(bottomLeftGeom); + }); + + it("filled mode refreshes UV when the atlas region changes", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + spriteRenderer.filledAmount = 1; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + // U of the right (RB) vertex, which equals the sprite's right UV for a full horizontal fill. + const rightU = () => vertices[subChunk.vertexArea.start + 9 + 3]; + + // @ts-ignore + spriteRenderer._render(context); + const uvBefore = rightU(); + + // Changing the atlas region dispatches SpriteModifyFlags.atlasRegion. Filled mode bakes + // UVs inside updatePositions, so the renderer must re-run it instead of a no-op updateUVs. + sprite.atlasRegion = new Rect(0, 0, 0.5, 0.5); + // @ts-ignore + spriteRenderer._render(context); + expect(rightU()).to.not.eq(uvBefore); + }); }); /** From a70ffa40ef2f9f72dec6bef578e9f32069608499 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 20 Jun 2026 12:38:56 +0800 Subject: [PATCH 4/7] test(2d): expand filled sprite unit coverage 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 --- tests/src/core/SpriteRenderer.test.ts | 225 ++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/tests/src/core/SpriteRenderer.test.ts b/tests/src/core/SpriteRenderer.test.ts index 3275adac82..be94202915 100644 --- a/tests/src/core/SpriteRenderer.test.ts +++ b/tests/src/core/SpriteRenderer.test.ts @@ -1651,6 +1651,7 @@ describe("SpriteRenderer", async () => { expect(Vector2.equals(readVertex(1).uv, new Vector2(0.5, 1))).to.eq(true); expect(Vector2.equals(readVertex(2).uv, new Vector2(0, 0))).to.eq(true); expect(Vector2.equals(readVertex(3).uv, new Vector2(0.5, 0))).to.eq(true); + expect([...subChunk.indices]).to.eql([0, 1, 2, 2, 1, 3]); // Vertical, default origin Bottom → fill the bottom half spriteRenderer.filledMode = SpriteFilledMode.Vertical; @@ -1662,6 +1663,10 @@ describe("SpriteRenderer", async () => { expect(Vector3.equals(readVertex(1).pos, new Vector3(4, 0, 0))).to.eq(true); expect(Vector3.equals(readVertex(2).pos, new Vector3(0, 2.5, 0))).to.eq(true); expect(Vector3.equals(readVertex(3).pos, new Vector3(4, 2.5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0, 1))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(1, 1))).to.eq(true); + expect(Vector2.equals(readVertex(2).uv, new Vector2(0, 0.5))).to.eq(true); + expect(Vector2.equals(readVertex(3).uv, new Vector2(1, 0.5))).to.eq(true); }); it("draw Filled Sprite (radial amount bounds)", () => { @@ -1775,6 +1780,226 @@ describe("SpriteRenderer", async () => { spriteRenderer._render(context); expect(rightU()).to.not.eq(uvBefore); }); + + it("draw Filled Sprite (linear, non-default origin)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const readVertex = (i: number) => { + const o = subChunk.vertexArea.start + i * 9; + return { + pos: new Vector3(vertices[o], vertices[o + 1], vertices[o + 2]), + uv: new Vector2(vertices[o + 3], vertices[o + 4]) + }; + }; + + // Horizontal with origin Right → fill the right half (originIsStart === false branch) + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + spriteRenderer.filledOrigin = SpriteFilledOrigin.Right; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + expect(Vector3.equals(readVertex(0).pos, new Vector3(2, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(4, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(2, 5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(4, 5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0.5, 1))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(1, 1))).to.eq(true); + expect(Vector2.equals(readVertex(2).uv, new Vector2(0.5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(3).uv, new Vector2(1, 0))).to.eq(true); + + // Vertical with origin Top → fill the top half + spriteRenderer.filledMode = SpriteFilledMode.Vertical; + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + expect(Vector3.equals(readVertex(0).pos, new Vector3(0, 2.5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(4, 2.5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(0, 5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(4, 5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0, 0.5))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(1, 0.5))).to.eq(true); + expect(Vector2.equals(readVertex(2).uv, new Vector2(0, 0))).to.eq(true); + expect(Vector2.equals(readVertex(3).uv, new Vector2(1, 0))).to.eq(true); + }); + + it("draw Filled Sprite (Radial180 + unsupported origin fallback)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const readPos = (i: number) => { + const o = subChunk.vertexArea.start + i * 9; + return new Vector3(vertices[o], vertices[o + 1], vertices[o + 2]); + }; + const snapshot = (n: number) => { + const start = subChunk.vertexArea.start; + const out: number[] = []; + for (let i = 0; i < n; ++i) { + const o = start + i * 9; + out.push(vertices[o], vertices[o + 1], vertices[o + 2], vertices[o + 3], vertices[o + 4]); + } + return out; + }; + + // Radial180, default origin Bottom, full fill → two quads (12 indices) + spriteRenderer.filledMode = SpriteFilledMode.Radial180; + spriteRenderer.filledAmount = 1; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(12); + // The first emitted vertex is the fill center = bottom-edge midpoint. + expect(Vector3.equals(readPos(0), new Vector3(2, 0, 0))).to.eq(true); + const bottomGeom = snapshot(8); + + // A valid non-default origin (Top, center = top-edge midpoint) produces different geometry. + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; + // @ts-ignore + spriteRenderer._render(context); + expect(Vector3.equals(readPos(0), new Vector3(2, 5, 0))).to.eq(true); + expect(snapshot(8)).to.not.eql(bottomGeom); + + // An unsupported corner origin (BottomLeft) must fall back to the default Bottom, + // not silently reuse the previous Top geometry. + spriteRenderer.filledOrigin = SpriteFilledOrigin.BottomLeft; + // @ts-ignore + spriteRenderer._render(context); + expect(snapshot(8)).to.eql(bottomGeom); + }); + + it("draw Filled Sprite (counter-clockwise radial differs from clockwise)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const snapshot3 = () => { + const start = subChunk.vertexArea.start; + const out: number[] = []; + for (let i = 0; i < 3; ++i) { + const o = start + i * 9; + out.push(vertices[o], vertices[o + 1], vertices[o + 2], vertices[o + 3], vertices[o + 4]); + } + return out; + }; + + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + spriteRenderer.filledAmount = 0.5; + + // Clockwise (default) cuts the [45, 90] sector → one triangle. + spriteRenderer.filledClockWise = true; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(3); + const cwGeom = snapshot3(); + + // Counter-clockwise cuts the [0, 45] sector → a different triangle. + spriteRenderer.filledClockWise = false; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(3); + expect(snapshot3()).to.not.eql(cwGeom); + }); + + it("draw Filled Sprite (flipX mirrors positions, keeps UVs)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const readVertex = (i: number) => { + const o = subChunk.vertexArea.start + i * 9; + return { + pos: new Vector3(vertices[o], vertices[o + 1], vertices[o + 2]), + uv: new Vector2(vertices[o + 3], vertices[o + 4]) + }; + }; + + // Horizontal (origin Left) half fill with flipX → positions mirror to negative x, UVs unchanged. + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + spriteRenderer.filledAmount = 0.5; + spriteRenderer.flipX = true; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(6); + expect(Vector3.equals(readVertex(0).pos, new Vector3(0, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(-2, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(0, 5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(-2, 5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0, 1))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(0.5, 1))).to.eq(true); + }); + + it("filled indices count across modes and amounts", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + + // Each mode runs with its default origin and clockwise=true; indices count locks the + // triangle/quad topology produced at amount 0 / 0.25 / 0.5 / 0.75 / 1. + const amounts = [0, 0.25, 0.5, 0.75, 1]; + const cases: Array<[SpriteFilledMode, number[]]> = [ + [SpriteFilledMode.Horizontal, [0, 6, 6, 6, 6]], + [SpriteFilledMode.Vertical, [0, 6, 6, 6, 6]], + [SpriteFilledMode.Radial90, [0, 3, 3, 6, 6]], + [SpriteFilledMode.Radial180, [0, 3, 6, 9, 12]], + [SpriteFilledMode.Radial360, [0, 6, 12, 18, 24]] + ]; + for (const [mode, expected] of cases) { + spriteRenderer.filledMode = mode; + for (let i = 0; i < amounts.length; ++i) { + spriteRenderer.filledAmount = amounts[i]; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length, `mode=${mode} amount=${amounts[i]}`).to.eq(expected[i]); + } + } + }); }); /** From 17ac677d9dd581220fdd2e0fd3d9424e2bf1439e Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 20 Jun 2026 12:48:17 +0800 Subject: [PATCH 5/7] test(2d): add filled sprite e2e visual coverage 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 --- e2e/case/sprite-filled.ts | 63 +++++++++++++++++++ e2e/config.ts | 9 +++ .../originImage/Sprite_sprite-filled.jpg | 3 + 3 files changed, 75 insertions(+) create mode 100644 e2e/case/sprite-filled.ts create mode 100644 e2e/fixtures/originImage/Sprite_sprite-filled.jpg diff --git a/e2e/case/sprite-filled.ts b/e2e/case/sprite-filled.ts new file mode 100644 index 0000000000..dbe3415571 --- /dev/null +++ b/e2e/case/sprite-filled.ts @@ -0,0 +1,63 @@ +/** + * @title SpriteFilled + * @category Sprite + */ +import { + AssetType, + Camera, + Sprite, + SpriteDrawMode, + SpriteFilledMode, + SpriteRenderer, + Texture2D, + WebGLEngine +} from "@galacean/engine"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +// One baseline covering every fill mode (rows) across fill amounts 0/0.25/0.5/0.75/1 (columns). +// The amount steps hit the topology boundaries (45° quad↔triangle, 90° quadrant edges). +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + + const cameraEntity = rootEntity.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 50); + const camera = cameraEntity.addComponent(Camera); + camera.isOrthographic = true; + camera.orthographicSize = 9; + + engine.resourceManager + .load({ + 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); + }); +}); diff --git a/e2e/config.ts b/e2e/config.ts index 0140328de4..83b1ffd3ca 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -532,5 +532,14 @@ export const E2E_CONFIG = { threshold: 0, diffPercentage: 0.044 } + }, + + Sprite: { + filled: { + category: "Sprite", + caseFileName: "sprite-filled", + threshold: 0.1, + diffPercentage: 0.3 + } } }; diff --git a/e2e/fixtures/originImage/Sprite_sprite-filled.jpg b/e2e/fixtures/originImage/Sprite_sprite-filled.jpg new file mode 100644 index 0000000000..79272e5169 --- /dev/null +++ b/e2e/fixtures/originImage/Sprite_sprite-filled.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ef3c3796189e73349329df7cc1d0b35b5a5c47ef8d9e5757b5c68dd918b327a +size 215254 From 477a770f4dbad346f137f1530d3801eb44eb9e0c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 22 Jun 2026 17:54:22 +0800 Subject: [PATCH 6/7] fix(2d): validate filledOrigin against the active fill mode 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 --- packages/core/src/2d/sprite/SpriteRenderer.ts | 50 ++++++++++++++----- tests/src/core/SpriteRenderer.test.ts | 12 ++++- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index 2faf38a538..6bf0b5a8f5 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -160,18 +160,9 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { set filledMode(value: SpriteFilledMode) { if (this._filledMode !== value) { this._filledMode = value; - // Reset origin to a valid default for the new mode, since each mode only accepts a subset of origins. - switch (value) { - case SpriteFilledMode.Horizontal: - this._filledOrigin = SpriteFilledOrigin.Left; - break; - case SpriteFilledMode.Radial90: - this._filledOrigin = SpriteFilledOrigin.BottomLeft; - break; - default: - this._filledOrigin = SpriteFilledOrigin.Bottom; - break; - } + // Keep the current origin if it is still valid for the new mode, otherwise snap to the mode's + // default, since each mode only accepts a subset of origins. + this._filledOrigin = SpriteRenderer._correctOrigin(value, this._filledOrigin); if (this._drawMode === SpriteDrawMode.Filled) { this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; } @@ -186,6 +177,8 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { } set filledOrigin(value: SpriteFilledOrigin) { + // Snap to a value valid for the current mode so state stays self-consistent (getter === rendered). + value = SpriteRenderer._correctOrigin(this._filledMode, value); if (this._filledOrigin !== value) { this._filledOrigin = value; if (this._drawMode === SpriteDrawMode.Filled) { @@ -570,6 +563,39 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { private _onColorChanged(): void { this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.Color; } + + /** + * Returns an origin valid for the given fill mode: the passed origin when it belongs to the mode's + * accepted subset, otherwise the mode's default. Keeps `filledMode`/`filledOrigin` always consistent. + */ + private static _correctOrigin(mode: SpriteFilledMode, origin: SpriteFilledOrigin): SpriteFilledOrigin { + switch (mode) { + case SpriteFilledMode.Horizontal: + return origin === SpriteFilledOrigin.Left || origin === SpriteFilledOrigin.Right + ? origin + : SpriteFilledOrigin.Left; + case SpriteFilledMode.Vertical: + return origin === SpriteFilledOrigin.Top || origin === SpriteFilledOrigin.Bottom + ? origin + : SpriteFilledOrigin.Bottom; + case SpriteFilledMode.Radial90: + // Radial90 accepts the four corner origins. + return origin === SpriteFilledOrigin.TopLeft || + origin === SpriteFilledOrigin.TopRight || + origin === SpriteFilledOrigin.BottomLeft || + origin === SpriteFilledOrigin.BottomRight + ? origin + : SpriteFilledOrigin.BottomLeft; + default: + // Radial180 / Radial360 accept the four edge origins. + return origin === SpriteFilledOrigin.Top || + origin === SpriteFilledOrigin.Bottom || + origin === SpriteFilledOrigin.Left || + origin === SpriteFilledOrigin.Right + ? origin + : SpriteFilledOrigin.Bottom; + } + } } /** diff --git a/tests/src/core/SpriteRenderer.test.ts b/tests/src/core/SpriteRenderer.test.ts index be94202915..370582ba77 100644 --- a/tests/src/core/SpriteRenderer.test.ts +++ b/tests/src/core/SpriteRenderer.test.ts @@ -1610,8 +1610,18 @@ describe("SpriteRenderer", async () => { spriteRenderer.filledMode = SpriteFilledMode.Radial360; expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Bottom); - // filledOrigin can still be set explicitly + // A valid origin for the current mode (Radial360 accepts edges) is kept + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Top); + // An origin invalid for the current mode snaps to the mode's default spriteRenderer.filledOrigin = SpriteFilledOrigin.TopRight; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Bottom); + + // Corner-based modes validate origin the same way + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; // edge origin invalid for Radial90 → snap default + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.BottomLeft); + spriteRenderer.filledOrigin = SpriteFilledOrigin.TopRight; // valid corner → kept expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.TopRight); }); From 768c767e1471d2635af5a54f98711ad9c67eaea1 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 22 Jun 2026 19:37:44 +0800 Subject: [PATCH 7/7] refactor(2d): name the fill-amount epsilon and document filled geometry 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 --- .../src/2d/assembler/FilledSpriteAssembler.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/2d/assembler/FilledSpriteAssembler.ts b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts index 34a3a40ffd..4ecf56f104 100644 --- a/packages/core/src/2d/assembler/FilledSpriteAssembler.ts +++ b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts @@ -40,11 +40,15 @@ export class FilledSpriteAssembler { private static _outUVs: Vector2[] = [new Vector2(), new Vector2(), new Vector2(), new Vector2()]; private static _vertexOffset = 0; private static _indicesOffset = 0; + // Fill amounts at or below this are treated as empty (render nothing), avoiding degenerate geometry. + private static readonly _fillAmountEpsilon = 0.001; static resetData(renderer: ISpriteRenderer): void { const manager = renderer._getChunkManager(); const lastSubChunk = renderer._subChunk; lastSubChunk && manager.freeSubChunk(lastSubChunk); + // Allocate the maximum any fill mode can produce (Radial360 = 4 quadrants x 4 verts = 16). + // A fixed size avoids reallocating the sub-chunk when filledMode changes at runtime. const subChunk = manager.allocateSubChunk(16); subChunk.indices = []; renderer._subChunk = subChunk; @@ -134,7 +138,7 @@ export class FilledSpriteAssembler { private static _filledLinear(renderer: ISpriteRenderer, matrix: Matrix, isHorizontal: boolean): void { const amount = renderer.filledAmount; - if (amount <= 0.001) { + if (amount <= this._fillAmountEpsilon) { renderer._subChunk.indices.length = 0; return; } @@ -219,7 +223,7 @@ export class FilledSpriteAssembler { amount: number, cw: boolean ): void { - if (amount <= 0.001) { + if (amount <= this._fillAmountEpsilon) { renderer._subChunk.indices.length = 0; return; } @@ -285,7 +289,7 @@ export class FilledSpriteAssembler { amount: number, cw: boolean ): void { - if (amount <= 0.001) { + if (amount <= this._fillAmountEpsilon) { renderer._subChunk.indices.length = 0; return; } @@ -380,7 +384,7 @@ export class FilledSpriteAssembler { amount: number, cw: boolean ): void { - if (amount <= 0.001) { + if (amount <= this._fillAmountEpsilon) { renderer._subChunk.indices.length = 0; return; } @@ -396,7 +400,7 @@ export class FilledSpriteAssembler { case SpriteFilledOrigin.Bottom: startAngle = cw ? 630 - amount * 360 : 270; break; - // Right is the default; any unsupported origin falls back to it. + // Right is handled here, and this branch also catches any unsupported origin as a fallback. case SpriteFilledOrigin.Right: default: startAngle = cw ? 360 - amount * 360 : 0; @@ -553,6 +557,8 @@ export class FilledSpriteAssembler { } } + // 45° is the quadrant diagonal: a sector spanning it reaches the far corner and needs a quad; + // otherwise the cut produces a triangle. if (start < 45 && end > 45) { outPositions[3].copyFrom(positions[3]); outUVs[3].copyFrom(uvs[3]);