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 2e3de18eab..08e09f566a 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -571,6 +571,15 @@ export const E2E_CONFIG = { diffPercentage: 0.044 } }, + Sprite: { + filled: { + category: "Sprite", + caseFileName: "sprite-filled", + threshold: 0.1, + diffPercentage: 0.3 + } + }, + UI: { batchOrder: { category: "UI", 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 diff --git a/packages/core/src/2d/assembler/FilledSpriteAssembler.ts b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts new file mode 100644 index 0000000000..4ecf56f104 --- /dev/null +++ b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts @@ -0,0 +1,619 @@ +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; + // 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; + } + + 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 <= this._fillAmountEpsilon) { + 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 <= this._fillAmountEpsilon) { + 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.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; + // 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; + } + + 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 <= this._fillAmountEpsilon) { + 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.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; + // 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; + } + + subChunk.indices.length = this._indicesOffset; + } + + private static _filledRadial360( + renderer: ISpriteRenderer, + matrix: Matrix, + origin: SpriteFilledOrigin, + amount: number, + cw: boolean + ): void { + if (amount <= this._fillAmountEpsilon) { + renderer._subChunk.indices.length = 0; + return; + } + + let startAngle = 0; + switch (origin) { + 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; + // 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; + 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]); + } + } + + // 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]); + 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 2a6986af6a..3e2bff4409 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,76 @@ 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; + // 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; + } + } + } + + /** + * The fill origin of the sprite renderer. (Only works in filled mode.) + */ + get filledOrigin(): SpriteFilledOrigin { + return this._filledOrigin; + } + + 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) { + 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. */ @@ -437,6 +521,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: @@ -456,7 +543,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; @@ -471,6 +562,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 db08e68b08..370582ba77 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,436 @@ 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); + + // 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); + }); + + 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); + expect([...subChunk.indices]).to.eql([0, 1, 2, 2, 1, 3]); + + // 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); + 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)", () => { + 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); + }); + + 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]); + } + } + }); }); /**